| 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 |
|