PluginProbe
Easy Invoice – Invoice Generator, PDF Quotes & Payments / 2.4.1
Easy Invoice – Invoice Generator, PDF Quotes & Payments v2.4.1
2.4.0 2.4.1 2.3.8 2.3.7 2.3.6 2.3.5 2.3.4 2.3.3 2.3.2 2.3.1 2.2.0 2.1.21 2.1.20 2.1.19 2.1.18 2.1.0 2.1.1 2.1.10 2.1.11 2.1.12 2.1.13 2.1.14 2.1.15 2.1.16 2.1.2 All 57 releases
easy-invoice / templates / admin / addons-page.php

addons-page.php in Easy Invoice – Invoice Generator, PDF Quotes & Payments 2.4.1, at templates/admin/addons-page.php

633 lines 34.2 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Addons admin page — matches the standard Easy Invoice page chrome
4 * (p-8 outer + ei-app-page-header header bar), lives inside main-template.php.
5 *
6 * Expected locals (set by AddonsController::display):
7 * array $addons — full registry
8 * string $current_plan — 'none' | 'professional' | 'agency'
9 * array $upgrade_urls — plan → checkout URL
10 *
11 * @package EasyInvoice
12 */
13
14 use EasyInvoice\Addons\AddonManager;
15 use EasyInvoice\Addons\LicensePlanResolver;
16
17 if (!defined('ABSPATH')) {
18 exit;
19 }
20
21 $addons = $addons ?? [];
22 $current_plan = $current_plan ?? LicensePlanResolver::PLAN_NONE;
23 $upgrade_urls = $upgrade_urls ?? [];
24 $current_label = LicensePlanResolver::getPlanLabel($current_plan);
25
26 // Plan pill colour classes (Tailwind utilities — same vocabulary as the rest of the app).
27 // Personal is the entry-level paid tier (migrated Pro features); rendered
28 // in teal so it's visually distinct from Professional (blue) and Agency
29 // (purple) — and from Free (grey).
30 $plan_classes = [
31 LicensePlanResolver::PLAN_NONE => ['chip' => 'bg-gray-100 text-gray-700 border-gray-200', 'bar' => 'bg-gray-300', 'icon' => 'bg-gray-100 text-gray-600 border-gray-200'],
32 LicensePlanResolver::PLAN_PERSONAL => ['chip' => 'bg-teal-50 text-teal-700 border-teal-200', 'bar' => 'bg-teal-500', 'icon' => 'bg-teal-50 text-teal-700 border-teal-200'],
33 LicensePlanResolver::PLAN_PROFESSIONAL => ['chip' => 'bg-blue-50 text-blue-700 border-blue-200', 'bar' => 'bg-blue-500', 'icon' => 'bg-blue-50 text-blue-700 border-blue-200'],
34 LicensePlanResolver::PLAN_AGENCY => ['chip' => 'bg-purple-50 text-purple-700 border-purple-200', 'bar' => 'bg-purple-600', 'icon' => 'bg-purple-50 text-purple-700 border-purple-200'],
35 ];
36 $hero_plan_class = $plan_classes[$current_plan] ?? $plan_classes[LicensePlanResolver::PLAN_NONE];
37
38 // Distinct categories present for the filter chips.
39 $categories = [];
40 foreach ($addons as $a) {
41 $categories[$a['category']] = true;
42 }
43 $categories = array_keys($categories);
44 ?>
45
46 <div class="p-8">
47
48
49 <div class="ei-app-page-header bg-white border-b border-gray-200 px-6 easy-invoice-admin-page-header">
50 <div class="flex items-center justify-between flex-wrap gap-4">
51 <div>
52 <h1 class="text-2xl font-bold text-gray-900"><?php esc_html_e('Addons', 'easy-invoice'); ?></h1>
53 <p class="mt-1 text-sm text-gray-500">
54 <?php esc_html_e('Extend Easy Invoice with optional features. Enable only what you need — disabled addons add zero overhead.', 'easy-invoice'); ?>
55 </p>
56 </div>
57
58 <div class="flex items-center gap-3">
59 <div class="inline-flex items-center gap-2 text-sm text-gray-600">
60 <span class="font-medium"><?php esc_html_e('Your plan:', 'easy-invoice'); ?></span>
61 <span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-semibold border <?php echo esc_attr($hero_plan_class['chip']); ?>">
62 <?php echo esc_html($current_label); ?>
63 </span>
64 </div>
65 <a href="<?php echo esc_url(admin_url('admin.php?page=easy-invoice-license')); ?>"
66 class="inline-flex items-center px-3 py-1.5 border border-gray-300 text-sm font-medium rounded-md shadow-sm text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">
67 <?php esc_html_e('Manage license', 'easy-invoice'); ?>
68 </a>
69 </div>
70 </div>
71 </div>
72
73 <?php if (!empty($license_banner)):
74 $b = $license_banner;
75 $skin = $b['type'] === 'warning'
76 ? ['wrap' => 'bg-amber-50 border-amber-200', 'icon' => 'text-amber-500', 'title' => 'text-amber-900', 'body' => 'text-amber-800', 'btn' => 'bg-amber-600 hover:bg-amber-700 text-white', 'dash' => 'warning']
77 : ['wrap' => 'bg-indigo-50 border-indigo-200', 'icon' => 'text-indigo-500','title' => 'text-indigo-900','body' => 'text-indigo-800','btn' => 'bg-indigo-600 hover:bg-indigo-700 text-white','dash' => 'info'];
78 ?>
79 <div class="mt-8 mb-2 border <?php echo esc_attr($skin['wrap']); ?> rounded-lg p-4 flex flex-col sm:flex-row sm:items-center justify-between gap-4">
80 <div class="flex items-start gap-3">
81 <div class="flex-shrink-0 <?php echo esc_attr($skin['icon']); ?>">
82 <span class="dashicons dashicons-<?php echo esc_attr($skin['dash']); ?>" style="font-size:22px;width:22px;height:22px;line-height:22px;"></span>
83 </div>
84 <div>
85 <p class="text-sm font-semibold <?php echo esc_attr($skin['title']); ?>"><?php echo esc_html($b['title']); ?></p>
86 <p class="mt-0.5 text-sm <?php echo esc_attr($skin['body']); ?>"><?php echo wp_kses_post($b['message']); ?></p>
87 </div>
88 </div>
89 <a href="<?php echo esc_url($b['cta_url']); ?>"
90 <?php echo strpos($b['cta_url'], 'http') === 0 ? 'target="_blank" rel="noopener"' : ''; ?>
91 class="inline-flex items-center px-4 py-2 rounded-md text-sm font-semibold shadow-sm whitespace-nowrap <?php echo esc_attr($skin['btn']); ?>">
92 <?php echo esc_html($b['cta_label']); ?>
93 </a>
94 </div>
95 <?php endif; ?>
96
97
98 <div class="flex flex-wrap items-center gap-2 mt-6 mb-3" id="ei-addons-filters">
99 <button type="button" data-filter="all" class="ei-addons-chip is-active inline-flex items-center px-4 py-1.5 rounded-full text-sm font-medium bg-gray-900 text-white">
100 <?php esc_html_e('All', 'easy-invoice'); ?>
101 </button>
102 <?php foreach ($categories as $cat): ?>
103 <button type="button" data-filter="<?php echo esc_attr($cat); ?>" class="ei-addons-chip inline-flex items-center px-4 py-1.5 rounded-full text-sm font-medium bg-white text-gray-700 border border-gray-300 hover:bg-gray-50">
104 <?php echo esc_html(ucfirst($cat)); ?>
105 </button>
106 <?php endforeach; ?>
107 </div>
108
109
110
111 <div id="ei-addons-toolbar" class="bg-white border border-gray-200 rounded-md px-4 py-2 mb-5 flex flex-wrap items-center gap-3">
112
113
114 <label class="inline-flex items-center gap-2 text-sm text-gray-700 cursor-pointer select-none">
115 <input type="checkbox" id="ei-addons-select-all"
116 class="h-4 w-4 text-indigo-600 focus:ring-indigo-500 border-gray-300 rounded"/>
117 <span><?php esc_html_e('Select all', 'easy-invoice'); ?></span>
118 <span id="ei-addons-selected-count" class="text-xs text-gray-500"></span>
119 </label>
120
121 <button type="button" id="ei-addons-bulk-activate"
122 class="inline-flex items-center gap-1 px-3 py-1.5 rounded-md text-sm font-semibold bg-indigo-600 text-white hover:bg-indigo-700 disabled:opacity-50 disabled:pointer-events-none"
123 disabled>
124 <span class="dashicons dashicons-yes-alt" style="font-size:14px;width:14px;height:14px;"></span>
125 <?php esc_html_e('Activate selected', 'easy-invoice'); ?>
126 </button>
127 <button type="button" id="ei-addons-bulk-deactivate"
128 class="inline-flex items-center gap-1 px-3 py-1.5 rounded-md text-sm font-semibold bg-white border border-red-200 text-red-600 hover:bg-red-50 disabled:opacity-50 disabled:pointer-events-none"
129 disabled>
130 <span class="dashicons dashicons-dismiss" style="font-size:14px;width:14px;height:14px;"></span>
131 <?php esc_html_e('Deactivate selected', 'easy-invoice'); ?>
132 </button>
133
134
135
136 <label class="inline-flex items-center gap-2 text-sm text-gray-700 ml-auto">
137 <span class="dashicons dashicons-filter" style="font-size:14px;width:14px;height:14px;line-height:14px;color:#6b7280;" aria-hidden="true"></span>
138 <select id="ei-addons-plan-filter"
139 class="block border border-gray-300 rounded-md py-1.5 px-3 text-sm focus:ring-indigo-500 focus:border-indigo-500">
140 <option value=""><?php esc_html_e('All plans', 'easy-invoice'); ?></option>
141 <option value="<?php echo esc_attr(LicensePlanResolver::PLAN_PERSONAL); ?>"><?php echo esc_html(LicensePlanResolver::getPlanLabel(LicensePlanResolver::PLAN_PERSONAL)); ?></option>
142 <option value="<?php echo esc_attr(LicensePlanResolver::PLAN_PROFESSIONAL); ?>"><?php echo esc_html(LicensePlanResolver::getPlanLabel(LicensePlanResolver::PLAN_PROFESSIONAL)); ?></option>
143 <option value="<?php echo esc_attr(LicensePlanResolver::PLAN_AGENCY); ?>"><?php echo esc_html(LicensePlanResolver::getPlanLabel(LicensePlanResolver::PLAN_AGENCY)); ?></option>
144 </select>
145 </label>
146
147
148 <div class="relative w-56">
149 <span class="dashicons dashicons-search absolute left-3 inset-y-0 my-auto h-4 text-gray-400 pointer-events-none" style="font-size:14px;width:14px;line-height:14px;" aria-hidden="true"></span>
150 <input type="search" id="ei-addons-search"
151 placeholder="<?php esc_attr_e('Search addons…', 'easy-invoice'); ?>"
152 class="block w-full border border-gray-300 rounded-md py-1.5 pl-9 pr-3 text-sm focus:ring-indigo-500 focus:border-indigo-500"/>
153 </div>
154
155 <button type="button" id="ei-addons-filter-reset"
156 class="text-xs font-medium text-gray-500 hover:text-indigo-600 underline-offset-2 hover:underline hidden">
157 <?php esc_html_e('Clear', 'easy-invoice'); ?>
158 </button>
159 <span id="ei-addons-filter-count" class="text-xs text-gray-500"></span>
160 </div>
161
162
163 <div id="ei-addons-empty" class="hidden bg-white border border-dashed border-gray-300 rounded-md px-6 py-10 text-center">
164 <span class="dashicons dashicons-search" style="font-size:32px;width:32px;height:32px;color:#9ca3af;"></span>
165 <p class="mt-2 text-sm text-gray-600"><?php esc_html_e('No addons match your filters.', 'easy-invoice'); ?></p>
166 </div>
167
168
169 <div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4" id="ei-addons-grid">
170 <?php foreach ($addons as $addon):
171 $status = AddonManager::statusFor($addon['id']);
172 $is_active = $status === 'active';
173 $is_locked = $status === 'locked';
174 $is_available = $status === 'available';
175 $plan_klass = $plan_classes[$addon['plan']] ?? $plan_classes[LicensePlanResolver::PLAN_PROFESSIONAL];
176 $plan_label = LicensePlanResolver::getPlanLabel($addon['plan']);
177 $plan_label_short = LicensePlanResolver::getPlanLabelShort($addon['plan']);
178 $upgrade_url = $upgrade_urls[$addon['plan']] ?? '#';
179 $category = $addon['category'];
180 $badge = $addon['badge'] ?? null;
181 ?>
182 <article
183 class="ei-addons-card relative bg-white rounded-lg shadow border border-gray-200 hover:shadow-md transition-shadow flex flex-col"
184 data-addon-id="<?php echo esc_attr($addon['id']); ?>"
185 data-addon-plan="<?php echo esc_attr($addon['plan']); ?>"
186 data-category="<?php echo esc_attr($category); ?>"
187 data-search-text="<?php echo esc_attr(strtolower(trim(($addon['name'] ?? '') . ' ' . ($addon['short_name'] ?? '') . ' ' . ($addon['tagline'] ?? '') . ' ' . ($addon['description'] ?? '')))); ?>"
188 >
189
190
191 <div class="p-4 flex-1 flex flex-col">
192
193 <div class="flex items-start justify-between mb-3">
194 <div class="flex items-center gap-2">
195 <?php if (!$is_locked): ?>
196 <input type="checkbox"
197 class="ei-addons-row-check h-4 w-4 text-indigo-600 focus:ring-indigo-500 border-gray-300 rounded"
198 data-addon-id="<?php echo esc_attr($addon['id']); ?>"
199 data-addon-status="<?php echo esc_attr($status); ?>"
200 aria-label="<?php /* translators: %s: addon name. */ echo esc_attr(sprintf(__('Select %s for bulk action', 'easy-invoice'), $addon['name'])); ?>"/>
201 <?php endif; ?>
202 <div class="rounded-full border <?php echo esc_attr($plan_klass['icon']); ?> inline-flex items-center justify-center"
203 style="width:24px;height:24px;">
204 <span class="dashicons <?php echo esc_attr($addon['icon']); ?>"
205 style="font-size:12px;width:12px;height:12px;line-height:12px;"></span>
206 </div>
207 </div>
208 <div class="flex items-center flex-wrap gap-1 justify-end">
209 <span class="inline-flex items-center leading-none rounded-full font-semibold uppercase border <?php echo esc_attr($plan_klass['chip']); ?>"
210 style="font-size:9px; letter-spacing:0.03em; padding:3px 7px;">
211 <?php echo esc_html($plan_label); ?>
212 </span>
213 <?php
214 // "New" badge removed per design — the plan
215 // pill alone is the addon's identity tag. The
216 // $addon['badge'] field is still respected for
217 // future variants (e.g. 'popular'), just not
218 // for the 'new' state.
219 ?>
220 <?php if ($is_locked): ?>
221 <span class="text-gray-400" title="<?php esc_attr_e('Locked — upgrade required', 'easy-invoice'); ?>">
222 <span class="dashicons dashicons-lock" style="font-size:11px;width:11px;height:11px;line-height:11px;"></span>
223 </span>
224 <?php endif; ?>
225 </div>
226 </div>
227
228
229 <h3 class="text-sm font-semibold text-gray-900 leading-snug mb-2"><?php echo esc_html($addon['name']); ?></h3>
230 <?php if (!empty($addon['description'])):
231 // Show the full text in a hover popover only when the
232 // visible clamp (3 lines × ~60 chars) would actually
233 // hide content; otherwise the truncated paragraph IS
234 // the full text and a popover would just be noise.
235 $needs_popover = mb_strlen($addon['description']) > 140;
236 ?>
237 <div class="ei-addon-desc-wrap<?php echo $needs_popover ? ' has-popover' : ''; ?>">
238 <p class="ei-addon-desc text-xs text-gray-500 leading-relaxed flex-1 mb-3">
239 <?php echo esc_html($addon['description']); ?>
240 </p>
241 <?php if ($needs_popover): ?>
242 <div class="ei-addon-desc-popover" role="tooltip" aria-hidden="true">
243 <?php echo esc_html($addon['description']); ?>
244 </div>
245 <?php endif; ?>
246 </div>
247 <?php endif; ?>
248
249
250 <div class="pt-3 border-t border-gray-100 flex items-center justify-between gap-2">
251 <?php if ($is_active): ?>
252
253 <div class="flex items-center gap-2 min-w-0 flex-wrap">
254 <span class="inline-flex items-center gap-1 px-2 py-1 rounded-md text-xs font-semibold bg-green-100 text-green-800">
255 <span class="dashicons dashicons-yes" style="font-size:12px;width:12px;height:12px;line-height:12px;"></span>
256 <?php esc_html_e('Active', 'easy-invoice'); ?>
257 </span>
258 <button
259 type="button"
260 class="ei-addons-btn ei-addons-btn--toggle text-xs font-medium text-red-600 hover:text-red-800 underline-offset-2 hover:underline"
261 data-action="disable"
262 >
263 <?php esc_html_e('Deactivate', 'easy-invoice'); ?>
264 </button>
265 </div>
266 <div class="flex items-center gap-3 whitespace-nowrap">
267 <?php if (!empty($addon['docs_url'])): ?>
268 <a href="<?php echo esc_url($addon['docs_url']); ?>"
269 target="_blank"
270 rel="noopener"
271 class="text-xs font-medium text-gray-500 hover:text-indigo-600"
272 title="<?php /* translators: %s: addon name. */ echo esc_attr(sprintf(__('Open %s documentation', 'easy-invoice'), $addon['name'])); ?>">
273 <?php esc_html_e('Docs', 'easy-invoice'); ?>
274 </a>
275 <?php endif; ?>
276 <?php
277 // Only link to a settings page the installed Pro actually
278 // registers: an older Pro alongside a newer free would
279 // otherwise send the user to a 403.
280 $ei_settings_url = $addon['settings_url'] ?? '';
281 if ($ei_settings_url && preg_match('/[?&]page=([a-z0-9_-]+)/', $ei_settings_url, $ei_m)) {
282 global $admin_page_hooks, $_registered_pages;
283 $ei_slug = $ei_m[1];
284 $ei_registered = isset($admin_page_hooks[$ei_slug])
285 || (is_array($_registered_pages) && !empty(array_filter(array_keys($_registered_pages), static function ($hook) use ($ei_slug) { return false !== strpos($hook, '_page_' . $ei_slug); })));
286 if (!$ei_registered) {
287 $ei_settings_url = '';
288 }
289 }
290 if (!empty($ei_settings_url)): ?>
291 <a href="<?php echo esc_url($ei_settings_url); ?>"
292 class="text-xs font-medium text-indigo-600 hover:text-indigo-800 underline-offset-2 hover:underline"
293 title="<?php /* translators: %s: addon name. */ echo esc_attr(sprintf(__('Open %s settings', 'easy-invoice'), $addon['name'])); ?>">
294 <?php echo esc_html($addon['settings_label'] ?? __('Settings', 'easy-invoice')); ?>
295 </a>
296 <?php endif; ?>
297 </div>
298 <?php elseif ($is_locked): ?>
299 <button
300 type="button"
301 class="ei-addons-btn ei-addons-btn--upgrade inline-flex items-center px-2 py-1 rounded-md text-xs font-semibold text-white shadow-sm <?php echo $addon['plan'] === LicensePlanResolver::PLAN_AGENCY ? 'bg-purple-600 hover:bg-purple-700' : 'bg-blue-600 hover:bg-blue-700'; ?> transition-colors"
302 data-action="upgrade"
303 data-upgrade-url="<?php echo esc_url($upgrade_url); ?>"
304 data-required-plan="<?php echo esc_attr($addon['plan']); ?>"
305 data-required-plan-label="<?php echo esc_attr($plan_label); ?>"
306 data-feature-name="<?php echo esc_attr($addon['name']); ?>"
307 data-feature-description="<?php echo esc_attr($addon['description']); ?>"
308 >
309 <?php /* translators: %s: plan name. */ echo esc_html(sprintf(__('Upgrade to %s', 'easy-invoice'), $plan_label)); ?>
310 </button>
311 <a href="<?php echo esc_url($addon['docs_url']); ?>" target="_blank" rel="noopener" class="text-xs font-medium text-gray-500 hover:text-indigo-600 whitespace-nowrap">
312 <?php esc_html_e('Learn', 'easy-invoice'); ?>
313 </a>
314 <?php else: // available ?>
315 <button
316 type="button"
317 class="ei-addons-btn ei-addons-btn--toggle inline-flex items-center px-3 py-1 rounded-md text-xs font-semibold bg-indigo-600 text-white hover:bg-indigo-700 shadow-sm transition-colors"
318 data-action="enable"
319 >
320 <?php esc_html_e('Activate', 'easy-invoice'); ?>
321 </button>
322 <a href="<?php echo esc_url($addon['docs_url']); ?>" target="_blank" rel="noopener" class="text-xs font-medium text-gray-500 hover:text-indigo-600 whitespace-nowrap">
323 <?php esc_html_e('Learn more', 'easy-invoice'); ?>
324 </a>
325 <?php endif; ?>
326 </div>
327 </div>
328 </article>
329 <?php endforeach; ?>
330 </div>
331
332 <?php if (empty($addons)): ?>
333 <div class="bg-white border border-dashed border-gray-300 rounded-lg p-12 text-center text-gray-500 mt-6">
334 <p><?php esc_html_e('No addons are registered yet.', 'easy-invoice'); ?></p>
335 </div>
336 <?php endif; ?>
337
338 </div>
339
340 <style>
341 .ei-addons-card.is-hidden { display: none !important; }
342 .ei-addons-btn.is-loading { opacity: 0.6; pointer-events: none; }
343
344 /* Lift the hovered card above its siblings so the description popover
345 (which uses position: absolute) isn't visually clipped by neighbouring
346 cards' shadows or stacking context. */
347 .ei-addons-card { z-index: 1; }
348 .ei-addons-card:hover,
349 .ei-addons-card:focus-within { z-index: 20; }
350
351 /* Addon description: 3-line clamp with a hover popover for the full text
352 when the description is long enough to be truncated. Replaces the native
353 browser `title` tooltip with a styled in-card popover. */
354 .ei-addon-desc-wrap { position: relative; flex: 1; margin-bottom: 0.75rem; }
355 .ei-addon-desc {
356 margin: 0;
357 display: -webkit-box;
358 -webkit-line-clamp: 3;
359 -webkit-box-orient: vertical;
360 overflow: hidden;
361 }
362 .ei-addon-desc-wrap.has-popover .ei-addon-desc { cursor: help; }
363 .ei-addon-desc-popover {
364 position: absolute;
365 top: calc(100% + 6px);
366 left: 0;
367 right: 0;
368 z-index: 30;
369 background: #1f2937;
370 color: #f9fafb;
371 padding: 10px 12px;
372 border-radius: 6px;
373 font-size: 11px;
374 line-height: 1.55;
375 box-shadow: 0 12px 28px rgba(0, 0, 0, 0.18), 0 4px 10px rgba(0, 0, 0, 0.08);
376 opacity: 0;
377 visibility: hidden;
378 transform: translateY(-4px);
379 transition: opacity 140ms ease, transform 140ms ease, visibility 140ms;
380 pointer-events: none;
381 }
382 .ei-addon-desc-popover::before {
383 content: '';
384 position: absolute;
385 top: -5px;
386 left: 16px;
387 width: 10px;
388 height: 10px;
389 background: inherit;
390 transform: rotate(45deg);
391 box-shadow: -2px -2px 4px rgba(0, 0, 0, 0.04);
392 }
393 .ei-addon-desc-wrap.has-popover:hover .ei-addon-desc-popover,
394 .ei-addon-desc-wrap.has-popover:focus-within .ei-addon-desc-popover {
395 opacity: 1;
396 visibility: visible;
397 transform: translateY(0);
398 }
399 /* When a card is in the last column / right edge, flip the popover to
400 the right so it doesn't overflow off-screen on narrow viewports. */
401 @media (max-width: 640px) {
402 .ei-addon-desc-popover { font-size: 12px; padding: 12px 14px; }
403 }
404 </style>
405
406 <script>
407 // Bulk activate / deactivate — fans out the existing single-toggle AJAX
408 // endpoint in parallel. Each row reports its own success/failure toast via
409 // the per-card click path the single-toggle code already wires up, so the
410 // only thing this block manages is selection + dispatch.
411 (function () {
412 'use strict';
413
414 var bulkActivate = document.getElementById('ei-addons-bulk-activate');
415 var bulkDeactivate = document.getElementById('ei-addons-bulk-deactivate');
416 var selectAll = document.getElementById('ei-addons-select-all');
417 var counter = document.getElementById('ei-addons-selected-count');
418 if (!bulkActivate || !bulkDeactivate || !selectAll) return;
419
420 function rowChecks() {
421 return Array.prototype.slice.call(document.querySelectorAll('.ei-addons-row-check'));
422 }
423 function selectedIds(filterStatus) {
424 return rowChecks()
425 .filter(function (cb) { return cb.checked; })
426 .filter(function (cb) { return !filterStatus || cb.dataset.addonStatus === filterStatus; })
427 .map(function (cb) { return cb.dataset.addonId; });
428 }
429 function refreshButtons() {
430 var checked = rowChecks().filter(function (cb) { return cb.checked; });
431 var n = checked.length;
432 counter.textContent = n > 0 ? '(' + n + ')' : '';
433 // Activate is enabled only when at least one selected row is `available`.
434 // Deactivate is enabled only when at least one selected row is `active`.
435 bulkActivate.disabled = !checked.some(function (cb) { return cb.dataset.addonStatus === 'available'; });
436 bulkDeactivate.disabled = !checked.some(function (cb) { return cb.dataset.addonStatus === 'active'; });
437 }
438
439 selectAll.addEventListener('change', function () {
440 rowChecks().forEach(function (cb) {
441 // Don't override a row that's locked (not selectable) — locked rows
442 // don't render a checkbox, so this is just per-visible-card.
443 cb.checked = selectAll.checked;
444 });
445 refreshButtons();
446 });
447 document.addEventListener('change', function (e) {
448 if (e.target && e.target.classList && e.target.classList.contains('ei-addons-row-check')) {
449 refreshButtons();
450 }
451 });
452
453 /**
454 * Direct AJAX bulk dispatcher.
455 *
456 * The previous implementation tried to "click each card's single-toggle
457 * button" and watch jQuery's $.active counter. That had two real bugs:
458 *
459 * 1. confirm-helper.js runs in CAPTURE phase and *removes*
460 * dataset.eiConfirmed before any bubble-phase handler can read it,
461 * so the Deactivate-selected click path always evaluated to "not
462 * confirmed" and silently no-op'd.
463 *
464 * 2. Even when activate worked, the FIRST AJAX to respond would call
465 * window.location.reload() (the single-toggle success path) and
466 * cancel every sibling in-flight request. Net result: only one
467 * addon would flip at a time.
468 *
469 * The fix: bypass the single-toggle path entirely. Fire $.post directly
470 * here, $.when() all promises, reload exactly once when all settle.
471 */
472 function dispatchBulk(action) {
473 var status = action === 'enable' ? 'available' : 'active';
474 var ids = selectedIds(status);
475 if (!ids.length) return;
476 if (!window.jQuery) {
477 alert('Cannot bulk-toggle: jQuery missing on this page.');
478 return;
479 }
480 var $ = window.jQuery;
481 var cfg = window.EasyInvoiceAddons || {};
482 if (!cfg.ajaxUrl || !cfg.nonce) {
483 alert('Cannot bulk-toggle: addons-page config missing.');
484 return;
485 }
486
487 // Visual feedback — both buttons go disabled with "Working…" label
488 // so the user knows something is happening.
489 var origActivateText = bulkActivate.innerHTML;
490 var origDeactivateText = bulkDeactivate.innerHTML;
491 bulkActivate.disabled = true;
492 bulkDeactivate.disabled = true;
493 var activeBtn = action === 'enable' ? bulkActivate : bulkDeactivate;
494 activeBtn.innerHTML = action === 'enable'
495 ? '<?php echo esc_js(__('Activating…', 'easy-invoice')); ?>'
496 : '<?php echo esc_js(__('Deactivating…', 'easy-invoice')); ?>';
497
498 // Mark in flight so any other lingering single-toggle handler skips
499 // its own reload (belt-and-braces with the dedicated $.when below).
500 window.EasyInvoiceAddons = window.EasyInvoiceAddons || {};
501 window.EasyInvoiceAddons.bulkInFlight = true;
502
503 var requests = ids.map(function (id) {
504 return $.post(cfg.ajaxUrl, {
505 action: 'easy_invoice_toggle_addon',
506 _nonce: cfg.nonce,
507 addon_id: id,
508 enabled: action === 'enable' ? 1 : 0
509 });
510 });
511
512 // $.when treats EACH deferred individually — we don't care about
513 // per-row results because the page reload will repaint everything
514 // from server state. We only need to know when ALL settle (success
515 // or failure) so we can reload exactly once.
516 var settled = 0;
517 var failed = 0;
518 function onSettled(ok) {
519 settled++;
520 if (!ok) failed++;
521 if (settled === requests.length) {
522 window.EasyInvoiceAddons.bulkInFlight = false;
523 if (window.EasyInvoiceToast && window.EasyInvoiceToast[failed ? 'warning' : 'success']) {
524 window.EasyInvoiceToast[failed ? 'warning' : 'success'](
525 failed
526 ? (failed + ' of ' + requests.length + ' addon(s) failed. The page will refresh.')
527 : (requests.length + ' addon(s) updated.')
528 );
529 }
530 // Small delay so the toast is visible before the reload kills it.
531 setTimeout(function () { window.location.reload(); }, 600);
532 }
533 }
534 requests.forEach(function (req) {
535 req.done(function (resp) { onSettled(resp && resp.success); })
536 .fail(function () { onSettled(false); });
537 });
538
539 // Clear selection UI immediately so the user sees the click landed.
540 selectAll.checked = false;
541 rowChecks().forEach(function (cb) { cb.checked = false; });
542 // Keep the bulk buttons disabled while in flight — refreshButtons
543 // would re-enable them but we want them locked until reload.
544 }
545
546 bulkActivate.addEventListener('click', function () {
547 dispatchBulk('enable');
548 });
549
550 // Bulk deactivate: drive the EasyInvoiceConfirmation modal directly
551 // instead of going through data-ei-confirm + confirm-helper (which
552 // strips its dataset marker too early — see dispatchBulk comments).
553 bulkDeactivate.addEventListener('click', function () {
554 var doIt = function () { dispatchBulk('disable'); };
555 if (window.EasyInvoiceConfirmation && typeof window.EasyInvoiceConfirmation.show === 'function') {
556 window.EasyInvoiceConfirmation.show({
557 title: '<?php echo esc_js(__('Deactivate selected addons', 'easy-invoice')); ?>',
558 message: '<?php echo esc_js(__('Deactivate every selected addon? Their settings stay saved.', 'easy-invoice')); ?>',
559 confirmText: '<?php echo esc_js(__('Deactivate', 'easy-invoice')); ?>',
560 confirmClass: 'bg-red-600 hover:bg-red-700',
561 onConfirm: doIt
562 });
563 } else if (window.confirm('<?php echo esc_js(__('Deactivate every selected addon?', 'easy-invoice')); ?>')) {
564 doIt();
565 }
566 });
567
568 refreshButtons();
569 })();
570
571 // ── Plan-filter + search ─────────────────────────────────────────
572 (function () {
573 var planSelect = document.getElementById('ei-addons-plan-filter');
574 var searchInp = document.getElementById('ei-addons-search');
575 var resetBtn = document.getElementById('ei-addons-filter-reset');
576 var countSpan = document.getElementById('ei-addons-filter-count');
577 var emptyState = document.getElementById('ei-addons-empty');
578 var grid = document.getElementById('ei-addons-grid');
579 if (!planSelect || !searchInp || !grid) return;
580
581 var cards = Array.prototype.slice.call(grid.querySelectorAll('.ei-addons-card'));
582
583 function applyFilters() {
584 var plan = planSelect.value || '';
585 var q = (searchInp.value || '').trim().toLowerCase();
586 var shown = 0;
587 cards.forEach(function (card) {
588 var cardPlan = card.getAttribute('data-addon-plan') || '';
589 var cardText = card.getAttribute('data-search-text') || '';
590 var planOk = !plan || cardPlan === plan;
591 var qOk = !q || cardText.indexOf(q) !== -1;
592 var visible = planOk && qOk;
593 card.classList.toggle('hidden', !visible);
594 if (visible) shown++;
595 });
596
597 // Empty-state + count.
598 if (emptyState) emptyState.classList.toggle('hidden', shown > 0);
599 if (countSpan) {
600 countSpan.textContent = (plan || q)
601 ? (shown + ' / ' + cards.length + ' shown')
602 : '';
603 }
604
605 // Reveal "Clear filters" only when at least one filter is active.
606 if (resetBtn) {
607 resetBtn.classList.toggle('hidden', !plan && !q);
608 }
609 }
610
611 planSelect.addEventListener('change', applyFilters);
612 searchInp.addEventListener('input', applyFilters);
613 if (resetBtn) {
614 resetBtn.addEventListener('click', function () {
615 planSelect.value = '';
616 searchInp.value = '';
617 applyFilters();
618 searchInp.focus();
619 });
620 }
621 })();
622 </script>
623 <?php
624 // Wire up the data-ei-confirm modal so the bulk-deactivate button gets the
625 // project's custom confirmation dialog instead of a native window.confirm.
626 $ei_confirm_helper = defined('EASY_INVOICE_PLUGIN_PATH')
627 ? EASY_INVOICE_PLUGIN_PATH . 'templates/admin/partials/confirm-helper.php'
628 : EASY_INVOICE_PLUGIN_DIR . 'templates/admin/partials/confirm-helper.php';
629 if ($ei_confirm_helper && file_exists($ei_confirm_helper)) {
630 include $ei_confirm_helper;
631 }
632 ?>
633