PluginProbe
Easy Invoice – Invoice Generator, PDF Quotes & Payments / 2.3.1
Easy Invoice – Invoice Generator, PDF Quotes & Payments v2.3.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.3.1, at templates/admin/addons-page.php

619 lines 32.9 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 esc_html($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 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 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 if (!empty($addon['settings_url'])): ?>
277 <a href="<?php echo esc_url($addon['settings_url']); ?>"
278 class="text-xs font-medium text-indigo-600 hover:text-indigo-800 underline-offset-2 hover:underline"
279 title="<?php echo esc_attr(sprintf(__('Open %s settings', 'easy-invoice'), $addon['name'])); ?>">
280 <?php esc_html_e('Settings', 'easy-invoice'); ?>
281 </a>
282 <?php endif; ?>
283 </div>
284 <?php elseif ($is_locked): ?>
285 <button
286 type="button"
287 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"
288 data-action="upgrade"
289 data-upgrade-url="<?php echo esc_url($upgrade_url); ?>"
290 data-required-plan="<?php echo esc_attr($addon['plan']); ?>"
291 data-required-plan-label="<?php echo esc_attr($plan_label); ?>"
292 data-feature-name="<?php echo esc_attr($addon['name']); ?>"
293 data-feature-description="<?php echo esc_attr($addon['description']); ?>"
294 >
295 <?php echo esc_html(sprintf(__('Upgrade to %s', 'easy-invoice'), $plan_label)); ?>
296 </button>
297 <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">
298 <?php esc_html_e('Learn', 'easy-invoice'); ?>
299 </a>
300 <?php else: // available ?>
301 <button
302 type="button"
303 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"
304 data-action="enable"
305 >
306 <?php esc_html_e('Activate', 'easy-invoice'); ?>
307 </button>
308 <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">
309 <?php esc_html_e('Learn more', 'easy-invoice'); ?>
310 </a>
311 <?php endif; ?>
312 </div>
313 </div>
314 </article>
315 <?php endforeach; ?>
316 </div>
317
318 <?php if (empty($addons)): ?>
319 <div class="bg-white border border-dashed border-gray-300 rounded-lg p-12 text-center text-gray-500 mt-6">
320 <p><?php esc_html_e('No addons are registered yet.', 'easy-invoice'); ?></p>
321 </div>
322 <?php endif; ?>
323
324 </div>
325
326 <style>
327 .ei-addons-card.is-hidden { display: none !important; }
328 .ei-addons-btn.is-loading { opacity: 0.6; pointer-events: none; }
329
330 /* Lift the hovered card above its siblings so the description popover
331 (which uses position: absolute) isn't visually clipped by neighbouring
332 cards' shadows or stacking context. */
333 .ei-addons-card { z-index: 1; }
334 .ei-addons-card:hover,
335 .ei-addons-card:focus-within { z-index: 20; }
336
337 /* Addon description: 3-line clamp with a hover popover for the full text
338 when the description is long enough to be truncated. Replaces the native
339 browser `title` tooltip with a styled in-card popover. */
340 .ei-addon-desc-wrap { position: relative; flex: 1; margin-bottom: 0.75rem; }
341 .ei-addon-desc {
342 margin: 0;
343 display: -webkit-box;
344 -webkit-line-clamp: 3;
345 -webkit-box-orient: vertical;
346 overflow: hidden;
347 }
348 .ei-addon-desc-wrap.has-popover .ei-addon-desc { cursor: help; }
349 .ei-addon-desc-popover {
350 position: absolute;
351 top: calc(100% + 6px);
352 left: 0;
353 right: 0;
354 z-index: 30;
355 background: #1f2937;
356 color: #f9fafb;
357 padding: 10px 12px;
358 border-radius: 6px;
359 font-size: 11px;
360 line-height: 1.55;
361 box-shadow: 0 12px 28px rgba(0, 0, 0, 0.18), 0 4px 10px rgba(0, 0, 0, 0.08);
362 opacity: 0;
363 visibility: hidden;
364 transform: translateY(-4px);
365 transition: opacity 140ms ease, transform 140ms ease, visibility 140ms;
366 pointer-events: none;
367 }
368 .ei-addon-desc-popover::before {
369 content: '';
370 position: absolute;
371 top: -5px;
372 left: 16px;
373 width: 10px;
374 height: 10px;
375 background: inherit;
376 transform: rotate(45deg);
377 box-shadow: -2px -2px 4px rgba(0, 0, 0, 0.04);
378 }
379 .ei-addon-desc-wrap.has-popover:hover .ei-addon-desc-popover,
380 .ei-addon-desc-wrap.has-popover:focus-within .ei-addon-desc-popover {
381 opacity: 1;
382 visibility: visible;
383 transform: translateY(0);
384 }
385 /* When a card is in the last column / right edge, flip the popover to
386 the right so it doesn't overflow off-screen on narrow viewports. */
387 @media (max-width: 640px) {
388 .ei-addon-desc-popover { font-size: 12px; padding: 12px 14px; }
389 }
390 </style>
391
392 <script>
393 // Bulk activate / deactivate — fans out the existing single-toggle AJAX
394 // endpoint in parallel. Each row reports its own success/failure toast via
395 // the per-card click path the single-toggle code already wires up, so the
396 // only thing this block manages is selection + dispatch.
397 (function () {
398 'use strict';
399
400 var bulkActivate = document.getElementById('ei-addons-bulk-activate');
401 var bulkDeactivate = document.getElementById('ei-addons-bulk-deactivate');
402 var selectAll = document.getElementById('ei-addons-select-all');
403 var counter = document.getElementById('ei-addons-selected-count');
404 if (!bulkActivate || !bulkDeactivate || !selectAll) return;
405
406 function rowChecks() {
407 return Array.prototype.slice.call(document.querySelectorAll('.ei-addons-row-check'));
408 }
409 function selectedIds(filterStatus) {
410 return rowChecks()
411 .filter(function (cb) { return cb.checked; })
412 .filter(function (cb) { return !filterStatus || cb.dataset.addonStatus === filterStatus; })
413 .map(function (cb) { return cb.dataset.addonId; });
414 }
415 function refreshButtons() {
416 var checked = rowChecks().filter(function (cb) { return cb.checked; });
417 var n = checked.length;
418 counter.textContent = n > 0 ? '(' + n + ')' : '';
419 // Activate is enabled only when at least one selected row is `available`.
420 // Deactivate is enabled only when at least one selected row is `active`.
421 bulkActivate.disabled = !checked.some(function (cb) { return cb.dataset.addonStatus === 'available'; });
422 bulkDeactivate.disabled = !checked.some(function (cb) { return cb.dataset.addonStatus === 'active'; });
423 }
424
425 selectAll.addEventListener('change', function () {
426 rowChecks().forEach(function (cb) {
427 // Don't override a row that's locked (not selectable) — locked rows
428 // don't render a checkbox, so this is just per-visible-card.
429 cb.checked = selectAll.checked;
430 });
431 refreshButtons();
432 });
433 document.addEventListener('change', function (e) {
434 if (e.target && e.target.classList && e.target.classList.contains('ei-addons-row-check')) {
435 refreshButtons();
436 }
437 });
438
439 /**
440 * Direct AJAX bulk dispatcher.
441 *
442 * The previous implementation tried to "click each card's single-toggle
443 * button" and watch jQuery's $.active counter. That had two real bugs:
444 *
445 * 1. confirm-helper.js runs in CAPTURE phase and *removes*
446 * dataset.eiConfirmed before any bubble-phase handler can read it,
447 * so the Deactivate-selected click path always evaluated to "not
448 * confirmed" and silently no-op'd.
449 *
450 * 2. Even when activate worked, the FIRST AJAX to respond would call
451 * window.location.reload() (the single-toggle success path) and
452 * cancel every sibling in-flight request. Net result: only one
453 * addon would flip at a time.
454 *
455 * The fix: bypass the single-toggle path entirely. Fire $.post directly
456 * here, $.when() all promises, reload exactly once when all settle.
457 */
458 function dispatchBulk(action) {
459 var status = action === 'enable' ? 'available' : 'active';
460 var ids = selectedIds(status);
461 if (!ids.length) return;
462 if (!window.jQuery) {
463 alert('Cannot bulk-toggle: jQuery missing on this page.');
464 return;
465 }
466 var $ = window.jQuery;
467 var cfg = window.EasyInvoiceAddons || {};
468 if (!cfg.ajaxUrl || !cfg.nonce) {
469 alert('Cannot bulk-toggle: addons-page config missing.');
470 return;
471 }
472
473 // Visual feedback — both buttons go disabled with "Working…" label
474 // so the user knows something is happening.
475 var origActivateText = bulkActivate.innerHTML;
476 var origDeactivateText = bulkDeactivate.innerHTML;
477 bulkActivate.disabled = true;
478 bulkDeactivate.disabled = true;
479 var activeBtn = action === 'enable' ? bulkActivate : bulkDeactivate;
480 activeBtn.innerHTML = action === 'enable'
481 ? '<?php echo esc_js(__('Activating…', 'easy-invoice')); ?>'
482 : '<?php echo esc_js(__('Deactivating…', 'easy-invoice')); ?>';
483
484 // Mark in flight so any other lingering single-toggle handler skips
485 // its own reload (belt-and-braces with the dedicated $.when below).
486 window.EasyInvoiceAddons = window.EasyInvoiceAddons || {};
487 window.EasyInvoiceAddons.bulkInFlight = true;
488
489 var requests = ids.map(function (id) {
490 return $.post(cfg.ajaxUrl, {
491 action: 'easy_invoice_toggle_addon',
492 _nonce: cfg.nonce,
493 addon_id: id,
494 enabled: action === 'enable' ? 1 : 0
495 });
496 });
497
498 // $.when treats EACH deferred individually — we don't care about
499 // per-row results because the page reload will repaint everything
500 // from server state. We only need to know when ALL settle (success
501 // or failure) so we can reload exactly once.
502 var settled = 0;
503 var failed = 0;
504 function onSettled(ok) {
505 settled++;
506 if (!ok) failed++;
507 if (settled === requests.length) {
508 window.EasyInvoiceAddons.bulkInFlight = false;
509 if (window.EasyInvoiceToast && window.EasyInvoiceToast[failed ? 'warning' : 'success']) {
510 window.EasyInvoiceToast[failed ? 'warning' : 'success'](
511 failed
512 ? (failed + ' of ' + requests.length + ' addon(s) failed. The page will refresh.')
513 : (requests.length + ' addon(s) updated.')
514 );
515 }
516 // Small delay so the toast is visible before the reload kills it.
517 setTimeout(function () { window.location.reload(); }, 600);
518 }
519 }
520 requests.forEach(function (req) {
521 req.done(function (resp) { onSettled(resp && resp.success); })
522 .fail(function () { onSettled(false); });
523 });
524
525 // Clear selection UI immediately so the user sees the click landed.
526 selectAll.checked = false;
527 rowChecks().forEach(function (cb) { cb.checked = false; });
528 // Keep the bulk buttons disabled while in flight — refreshButtons
529 // would re-enable them but we want them locked until reload.
530 }
531
532 bulkActivate.addEventListener('click', function () {
533 dispatchBulk('enable');
534 });
535
536 // Bulk deactivate: drive the EasyInvoiceConfirmation modal directly
537 // instead of going through data-ei-confirm + confirm-helper (which
538 // strips its dataset marker too early — see dispatchBulk comments).
539 bulkDeactivate.addEventListener('click', function () {
540 var doIt = function () { dispatchBulk('disable'); };
541 if (window.EasyInvoiceConfirmation && typeof window.EasyInvoiceConfirmation.show === 'function') {
542 window.EasyInvoiceConfirmation.show({
543 title: '<?php echo esc_js(__('Deactivate selected addons', 'easy-invoice')); ?>',
544 message: '<?php echo esc_js(__('Deactivate every selected addon? Their settings stay saved.', 'easy-invoice')); ?>',
545 confirmText: '<?php echo esc_js(__('Deactivate', 'easy-invoice')); ?>',
546 confirmClass: 'bg-red-600 hover:bg-red-700',
547 onConfirm: doIt
548 });
549 } else if (window.confirm('<?php echo esc_js(__('Deactivate every selected addon?', 'easy-invoice')); ?>')) {
550 doIt();
551 }
552 });
553
554 refreshButtons();
555 })();
556
557 // ── Plan-filter + search ─────────────────────────────────────────
558 (function () {
559 var planSelect = document.getElementById('ei-addons-plan-filter');
560 var searchInp = document.getElementById('ei-addons-search');
561 var resetBtn = document.getElementById('ei-addons-filter-reset');
562 var countSpan = document.getElementById('ei-addons-filter-count');
563 var emptyState = document.getElementById('ei-addons-empty');
564 var grid = document.getElementById('ei-addons-grid');
565 if (!planSelect || !searchInp || !grid) return;
566
567 var cards = Array.prototype.slice.call(grid.querySelectorAll('.ei-addons-card'));
568
569 function applyFilters() {
570 var plan = planSelect.value || '';
571 var q = (searchInp.value || '').trim().toLowerCase();
572 var shown = 0;
573 cards.forEach(function (card) {
574 var cardPlan = card.getAttribute('data-addon-plan') || '';
575 var cardText = card.getAttribute('data-search-text') || '';
576 var planOk = !plan || cardPlan === plan;
577 var qOk = !q || cardText.indexOf(q) !== -1;
578 var visible = planOk && qOk;
579 card.classList.toggle('hidden', !visible);
580 if (visible) shown++;
581 });
582
583 // Empty-state + count.
584 if (emptyState) emptyState.classList.toggle('hidden', shown > 0);
585 if (countSpan) {
586 countSpan.textContent = (plan || q)
587 ? (shown + ' / ' + cards.length + ' shown')
588 : '';
589 }
590
591 // Reveal "Clear filters" only when at least one filter is active.
592 if (resetBtn) {
593 resetBtn.classList.toggle('hidden', !plan && !q);
594 }
595 }
596
597 planSelect.addEventListener('change', applyFilters);
598 searchInp.addEventListener('input', applyFilters);
599 if (resetBtn) {
600 resetBtn.addEventListener('click', function () {
601 planSelect.value = '';
602 searchInp.value = '';
603 applyFilters();
604 searchInp.focus();
605 });
606 }
607 })();
608 </script>
609 <?php
610 // Wire up the data-ei-confirm modal so the bulk-deactivate button gets the
611 // project's custom confirmation dialog instead of a native window.confirm.
612 $ei_confirm_helper = defined('EASY_INVOICE_PLUGIN_PATH')
613 ? EASY_INVOICE_PLUGIN_PATH . 'templates/admin/partials/confirm-helper.php'
614 : EASY_INVOICE_PLUGIN_DIR . 'templates/admin/partials/confirm-helper.php';
615 if ($ei_confirm_helper && file_exists($ei_confirm_helper)) {
616 include $ei_confirm_helper;
617 }
618 ?>
619