From 42fa8304ddb811b0f725f245130f70c0f5e86a6c Mon Sep 17 00:00:00 2001
From: Jake Vanderwerf <get@jakevanderwerf.ca>
Date: Tue, 04 Nov 2025 06:12:02 +0000
Subject: [PATCH] =Refactored LoginManager to be more extensible and configurable, as well as an AjaxRateLimiter
---
src/faq/style.scss | 70
build/faq/index.css | 1
src/summary/render.php | 11
assets/js/dash/TaxonomyCreator.js | 244
build/gmbreviews/index.css | 1
inc/blocks/SummaryBlock.php | 4
inc/managers/FormManager.php | 3
assets/css/dash.min.css | 2
inc/helpers/legacy.php | 112
jvb.php | 61
inc/helpers/formatting.php | 44
build/faq/index.js | 1
assets/js/min/square.min.js | 2
inc/rest/routes/TermRoutes.php | 48
inc/managers/OperationQueue.php | 15
assets/js/min/form.min.js | 2
inc/managers/DashboardManager.php | 1120 ++-
inc/rest/routes/ContentRoutes.php | 197
src/video/index.js | 13
inc/rest/routes/FeedRoutes.php | 20
inc/rest/routes/BioRoutes.php | 6
inc/managers/_setup.php | 3
build/gmbreviews/render.php | 203
build/summary/index.js | 2
build/faq/style-index-rtl.css | 1
src/faq/render.php | 0
JVBase.php | 4
inc/managers/LoginManager.php | 2220 +++--
inc/meta/MetaManager.php | 5
build/glossary/render.php | 8
assets/js/concise/Queue.js | 179
src/faq/index.js | 11
inc/integrations/Integrations.php | 66
src/faq/index.php | 0
build/gmbreviews/index.js | 1
inc/helpers/breadcrumbs.php | 4
src/glossary/style.scss | 104
assets/js/concise/UploadManager.js | 19
build/gmbreviews/view.js | 0
build/glossary/index-rtl.css | 1
build/glossary/style-index-rtl.css | 1
src/glossary/edit.js | 38
src/gmbreviews/block.json | 68
package-lock.json | 2905 +++++--
src/gmbreviews/edit.js | 69
build/gmbreviews/index.asset.php | 1
assets/css/forms.min.css | 2
assets/js/concise/View.js | 69
inc/blocks/_setup.php | 41
src/gmbreviews/style.scss | 58
src/glossary/index.php | 0
build/glossary/index.js | 1
src/glossary/editor.scss | 0
assets/js/min/integrations.min.js | 2
inc/blocks/FAQBlock.php | 297
assets/js/min/settings.min.js | 1
inc/managers/MagicLinkManager.php | 437
src/glossary/index.js | 33
src/glossary/view.js | 196
inc/meta/MetaForm.php | 882 +
inc/rest/routes/UploadRoutes.php | 113
assets/js/min/populate.min.js | 2
src/glossary/block.json | 24
build/video/style-index.css | 2
src/gmbreviews/index.php | 0
build/video/style-index-rtl.css | 2
inc/managers/UploadManager.php | 2
inc/integrations/GoogleMyBusiness.php | 135
inc/blocks/GlossaryBlock.php | 153
inc/managers/CacheManagerOld.php | 376 +
build/gmbreviews/style-index.css | 1
src/gmbreviews/index.js | 11
build/glossary/index.css | 1
assets/js/concise/TaxonomySelector.js | 474
assets/js/min/view.min.js | 2
inc/rest/routes/Invitations.php | 17
build/gmbreviews/index-rtl.css | 1
inc/rest/routes/SettingsRoutes.php | 17
src/gmbreviews/view.js | 0
globals.php | 5
inc/blocks/VideoCoverBlock.php | 25
inc/importers/JaneAppClientImporter.php | 386 +
assets/js/concise/DataStore.js | 375
build/faq/index-rtl.css | 1
webpack.jvb.js | 75
assets/js/min/dataStore.min.js | 2
inc/managers/UmamiMetrics.php | 4
build/glossary/view.js | 1
inc/forms/TaxonomySelectorOld.php | 2
build/faq/view.js | 1
inc/managers/CacheManager.php | 747 +
assets/js/concise/navigation.js | 12
assets/js/min/navigation.min.js | 2
build/faq/style-index.css | 1
assets/js/dash/UtilityFunctions.js | 4
build/glossary/block.json | 27
inc/rest/routes/NotificationsRoutes.php | 28
src/faq/block.json | 34
inc/helpers/ui.php | 5
inc/forms/TaxonomySelector.php | 195
inc/helpers/saveFields.php | 3
build/video/index.asset.php | 2
inc/registry/TaxonomyRegistrar.php | 8
inc/managers/CRUDManager.php | 157
inc/managers/RoleManager.php | 54
assets/js/min/queue.min.js | 2
inc/blocks/CustomBlocks.php | 817 ++
inc/rest/routes/OptionsRoutes.php | 4
assets/js/concise/SimpleCache.js | 415 -
assets/js/min/utility.min.js | 2
src/faq/edit.js | 145
inc/rest/routes/ImporterRoutes.php | 326
inc/blocks/RegisterBlocks.php | 14
src/faq/view.js | 84
build/gmbreviews/style-index-rtl.css | 1
assets/js/min/cache.min.js | 2
inc/managers/NewsRelationships.php | 15
inc/managers/TaxonomyRelationships.php | 10
inc/managers/NotificationManager.php | 9
inc/importers/JaneAppSalesImporter.php | 574 +
inc/helpers/all.php | 2
inc/helpers/time.php | 4
assets/js/min/selector.min.js | 2
inc/managers/AjaxRateLimiter.php | 325
build/gmbreviews/block.json | 74
src/gmbreviews/render.php | 203
assets/js/concise/FormController.js | 16
inc/managers/DirectoryManager.php | 2
inc/registry/CheckCustomTables.php | 479
inc/rest/routes/FormRoutes.php | 4
inc/blocks/FeedBlock.php | 2
icons.php | 6
inc/registry/PostTypeRegistrar.php | 141
inc/managers/UserTermsManager.php | 22
inc/utility/Validator.php | 4
assets/js/concise/UserSettings.js | 234
assets/js/min/crud.min.js | 2
assets/js/concise/PopulateForm.js | 8
assets/js/dash/CRUD.js | 23
inc/forms/PostSelector.php | 2
inc/helpers/renderFields.php | 105
src/video/style.scss | 137
build/glossary/view.asset.php | 1
build/glossary/index.asset.php | 1
inc/rest/routes/FavouritesRoutes.php | 52
src/video/edit.js | 2
inc/helpers/media.php | 28
build/summary/index.asset.php | 2
inc/managers/ReferralManager.php | 366 +
assets/js/dash/Integrations.js | 164
inc/helpers/members.php | 125
inc/blocks/MenuBlock.php | 2
inc/utility/Features.php | 281
inc/managers/AdminPages.php | 3
assets/css/nav.min.css | 2
build/glossary/style-index.css | 1
inc/managers/LoginManagerOld.php | 1061 +++
assets/js/min/uploader.min.js | 2
src/gmbreviews/editor.scss | 0
build/faq/index.asset.php | 1
assets/js/min/creator.min.js | 2
src/faq/editor.scss | 99
templates/dashboard/sections/news.php | 5
/dev/null | 125
build/video/index.js | 2
inc/rest/RestRouteManager.php | 332
package.json | 2
build/faq/view.asset.php | 1
build/gmbreviews/view.asset.php | 1
build/faq/block.json | 41
inc/blocks/FormBlock.php | 9
inc/importers/_setup.php | 3
src/glossary/render.php | 8
173 files changed, 15,200 insertions(+), 5,139 deletions(-)
diff --git a/JVBase.php b/JVBase.php
index e6fe838..8fd1666 100644
--- a/JVBase.php
+++ b/JVBase.php
@@ -3,6 +3,7 @@
use JVBase\integrations\BlueSky;
use JVBase\managers\ErrorHandler;
+use JVBase\managers\LoginManager;
use JVBase\managers\OperationQueue;
use JVBase\managers\DashboardManager;
use JVBase\managers\ReferralManager;
@@ -276,6 +277,9 @@
public function additionalActions():void
{
+ if (LoginManager::isLogin()) {
+ return;
+ }
$extras = apply_filters('jvbAdditionalActions', []);
$extras = array_filter($extras, function ($extra) {
return is_array($extra) && array_key_exists('button', $extra) && array_key_exists('content', $extra);
diff --git a/assets/css/dash.min.css b/assets/css/dash.min.css
index 55cbc7a..e0f5864 100644
--- a/assets/css/dash.min.css
+++ b/assets/css/dash.min.css
@@ -1 +1 @@
-:target{outline:0!important;padding:0!important}.dashboard h1:first-of-type{margin-top:0!important}main>footer{max-width:100%!important;position:fixed;z-index:var(--z-top);bottom:0;left:0;right:0;width:100%;margin:4rem 0 0 0!important;height:var(--height);padding:0!important;background-color:var(--base);box-shadow:var(--shadow)}main>*{max-width:min(768px,90vw)!important;margin:0 auto!important}main h1{margin:0!important;font-size:var(--large)}.item-grid .item{position:relative}img{width:100%;height:auto;aspect-ratio:1;object-fit:cover}.replace{margin-bottom:var(--offHeight)!important}.item-grid:has(.select-item:checked) .item{padding:.75rem;opacity:.8;filter:var(--filter)}.item-grid .item:has(.select-item:checked){padding:.5rem;filter:none;opacity:1;background-color:var(--action-0)}.grid-view .item>input[type=checkbox]:not(.label-button)+label{padding-left:0;margin:0}.grid-view .item>input[type=checkbox]+label::before{transform:unset;top:.5rem;left:.5rem}.grid-view .item>input[type=checkbox]+label::after{top:.5rem;left:.75rem;transform:translateY(20%) rotate(45deg)}.grid-view .item .item-actions{position:absolute;bottom:0;right:0}.list-view h3,.list-view p{margin:0!important}@media (min-width:768px){.grid-view{grid-template-columns:repeat(auto-fill,minmax(200px,1fr))}.grid-view .item .item-actions{bottom:unset;top:0}}.bulk-controls{margin:1rem 0}.bulk-controls .selected-count{font-weight:400;font-size:var(--small);text-transform:none;font-style:italic;display:flex;gap:.25rem;margin-left:2rem}.selected-count::before{content:'{'}.selected-count::after{content:'}'}.bulk-edit-form .selected{display:grid;grid-template-columns:repeat(auto-fill,minmax(100px,1fr));gap:4px}.selected label{padding:.5rem;opacity:.6;filter:var(--filter);border:2px solid transparent;transition:filter var(--transition-base),opacity var(--transition-base),border var(--transition-base),padding var(--transition-base)}.selected label:has(:checked){border-color:var(--action-0);padding:0;opacity:1;filter:none;transition:filter var(--transition-base),opacity var(--transition-base),border var(--transition-base),padding var(--transition-base)}form.table img{max-height:4rem}.all-filters{margin:2rem 0;padding:1rem 0;border-top:1px solid var(--base-200);border-bottom:1px solid var(--base-200)}details.uploader+.items-list .all-filters{border-top:none}.all-filters .filters{width:100%}.controls .radio-options,.filters.row.start{--align:center;--justify:flex-start;--gap:.5rem}.all-filters span.label{text-transform:uppercase;font-size:var(--small);font-weight:900;width:15vw;display:inline-flex;align-items:center;padding-right:2rem}.controls .icon{--w:1.4rem}.search-container:not(.open) .clear-search,.search-container:not(.open) input[type=search]{transform:scaleX(0);transform-origin:left;width:0;padding:0;transition:transform var(--transition-base),width var(--transition-base),padding var(--transition-base)}.search-container button{padding:.5rem}.search-container .icon{--w:1.5rem}.search-container.open .clear-search,.search-container.open input[type=search]{transform:scaleX(1);transform-origin:left;transition:transform var(--transition-base),width var(--transition-base),padding var(--transition-base)}.all-filters>.search,.search-container,input[type=search]{width:100%}form.table textarea{width:250px;padding:.5rem}.multi-select summary{--gap:2rem;padding-right:2.5rem}dialog.bulk-edit[open],dialog.create[open],dialog.edit[open]{height:85vh;top:5vh}.tab-content h2{display:none}.create-item{left:auto!important;right:1rem;bottom:var(--offHeight)!important}.group-fields.hours .group-fields,.group-fields.hours .group-fields .field{display:flex;justify-content:space-between;align-items:center}.group-fields.hours .group-fields{padding:1rem .5rem;gap:1rem}.group-fields.hours .group-fields:nth-of-type(2n+1){background-color:var(--base)}.group-fields.hours .group-fields .field{margin:0}.group-fields.hours .true-false{flex:1}.group-fields.hours .time{position:relative}.group-fields.hours .time label{margin:0;font-size:var(--small);position:absolute;top:-1rem;left:0;color:var(--contrast-200)}.today_hours{width:min(500px,90vw)}.today_hours .group-fields{width:100%;padding:0;display:flex;justify-content:center;gap:.5rem}@media (min-width:768px){.today_hours .group-fields{padding:2rem}}.today_hours .field{margin:0}.dash .true-false{margin:0}.dash [type=submit]{width:90%}.dashboard.settings nav.tabs{--height:3.5rem;--x:var(--offHeight);position:fixed;bottom:var(--height);left:var(--x);right:var(--x);z-index:99;width:calc(100% - var(--x) - var(--x));background-color:var(--base)}nav.integrations,nav.integrations a,nav.integrations li,nav.integrations ul{height:auto}.replace{overflow:hidden}body.dash form#options{display:flex;flex-flow:column nowrap;justify-content:center;align-items:center}.item-grid.integrations{grid-template-columns:repeat(2,1fr);gap:2rem}.integration{background:var(--base);border:2px solid var(--base-200);border-radius:var(--outerRadius);padding:1rem;position:relative;transition:all var(--transition-base);box-shadow:var(--shadow)}.integration.connected{border-color:var(--success)}.integration.disconnected,.integration.error{border-color:var(--error)}.integration.hasChanges{border-color:var(--warning)}.integration .header{margin-bottom:.75rem;padding-bottom:.75rem;border-bottom:2px solid var(--base-200)}.integration h3{letter-spacing:1px;font-size:var(--medium);margin:0}.integration .meta{margin-bottom:1rem;text-align:right;color:var(--contrast-200);font-size:var(--small)}.integration .setup{font-size:var(--small);font-weight:700;text-transform:uppercase}.integration .setup .indicator{font-size:var(--medium)}.integration .connected .indicator,.integration .setup .connected{color:var(--success)}.integration .disconnected .indicator,.integration .setup .disconnected{color:var(--error)}.integration.hasChanges .disconnected{color:var(--warning)}.connection-status.connected{background-color:var(--successBack);color:var(--successText)}.connection-status.disconnected{background-color:var(--errorBack);color:var(--errorText)}.integration code{display:inline-block;width:90%;margin:0 .5rem;user-select:all;padding:.75rem;border:2px solid var(--base);background-color:var(--base-200);word-break:break-all}.integration details+details{margin-top:1rem}.integration .actions{margin-top:1rem}.hint{line-height:1.2;font-style:italic;font-size:var(--small)}.hasChanges button[data-action=save_credentials]{border-color:var(--warning);animation:pulse-color 1s infinite;animation-delay:1s}.flash{animation:flash .5s}.flash.connected{--b:var(--success)}.flash.disconnected{--b:var(--error)}.flash.syncing{--b:var(--success)}.flash.error,.flash.hasChanges{--b:var(--warning)}@keyframes flash{0%,100%{border-color:inherit}50%{border-color:var(--b)}}.location.field{width:80vw}.location.field>p{text-align:center}.location.field>p+p{margin:0 .5rem 0 0}.location.field .location-map{height:20vh}.location.field .location-links{padding:.5rem 0;display:flex;justify-content:space-evenly}.field.upload [data-upload-id],.item-grid .item{touch-action:none}
\ No newline at end of file
+:target{outline:0!important;padding:0!important}.dashboard h1:first-of-type{margin-top:4rem!important}main>footer{max-width:100%!important;position:fixed;z-index:var(--z-top);bottom:0;left:0;right:0;width:100%;margin:4rem 0 0 0!important;height:var(--height);padding:0!important;background-color:var(--base);box-shadow:var(--shadow)}main>*{max-width:min(768px,90vw)!important;margin:0 auto!important}main h1{margin:0!important;font-size:var(--large)}.item-grid .item{position:relative}img{width:100%;height:auto;aspect-ratio:1;object-fit:cover}.replace{margin-bottom:var(--offHeight)!important}.item-grid{margin-bottom:4rem}.item-grid:has(.select-item:checked) .item{padding:.75rem;opacity:.8;filter:var(--filter)}.item-grid .item:has(.select-item:checked){padding:.5rem;filter:none;opacity:1;background-color:var(--action-0)}.grid-view .item>input[type=checkbox]:not(.label-button)+label{padding-left:0;margin:0}.grid-view .item>input[type=checkbox]+label::before{transform:unset;top:.5rem;left:.5rem}.grid-view .item>input[type=checkbox]+label::after{top:.5rem;left:.75rem;transform:translateY(20%) rotate(45deg)}.grid-view .item .item-actions{position:absolute;bottom:0;right:0}.list-view h3,.list-view p{margin:0!important}@media (min-width:768px){.grid-view{grid-template-columns:repeat(auto-fill,minmax(200px,1fr))}.grid-view .item .item-actions{bottom:unset;top:0}}.bulk-controls{margin:1rem 0}.bulk-controls .selected-count{font-weight:400;font-size:var(--small);text-transform:none;font-style:italic;display:flex;gap:.25rem;margin-left:2rem}.selected-count::before{content:'{'}.selected-count::after{content:'}'}.bulk-edit-form .selected{display:grid;grid-template-columns:repeat(auto-fill,minmax(100px,1fr));gap:4px}.selected label{padding:.5rem;opacity:.6;filter:var(--filter);border:2px solid transparent;transition:filter var(--transition-base),opacity var(--transition-base),border var(--transition-base),padding var(--transition-base)}.selected label:has(:checked){border-color:var(--action-0);padding:0;opacity:1;filter:none;transition:filter var(--transition-base),opacity var(--transition-base),border var(--transition-base),padding var(--transition-base)}form.table img{max-height:4rem}.all-filters{margin:2rem 0;padding:1rem 0;border-top:1px solid var(--base-200);border-bottom:1px solid var(--base-200)}details.uploader+.items-list .all-filters{border-top:none}.all-filters .filters{width:100%}.controls .radio-options,.filters.row.start{--align:center;--justify:flex-start;--gap:.5rem}.all-filters span.label{text-transform:uppercase;font-size:var(--small);font-weight:900;width:15vw;display:inline-flex;align-items:center;padding-right:2rem}.controls .icon{--w:1.4rem}.all-filters .btn+label,.all-filters button{height:fit-content;padding:.5rem!important;min-width:0;min-height:0}.all-filters .btn+label:focus,.all-filters .btn+label:hover,.all-filters button:focus,.all-filters button:hover{background-color:transparent;color:var(--action-0);border-color:var(--action-0)}.search-container:not(.open) .clear-search,.search-container:not(.open) input[type=search]{transform:scaleX(0);transform-origin:left;width:0;padding:0;transition:transform var(--transition-base),width var(--transition-base),padding var(--transition-base)}.search-container button{padding:.5rem}.search-container .icon{--w:1.5rem}.search-container.open .clear-search,.search-container.open input[type=search]{transform:scaleX(1);transform-origin:left;transition:transform var(--transition-base),width var(--transition-base),padding var(--transition-base)}.all-filters>.search,.search-container,input[type=search]{width:100%}form.table textarea{width:250px;padding:.5rem}.multi-select summary{--gap:2rem;padding-right:2.5rem}dialog.bulk-edit[open],dialog.create[open],dialog.edit[open]{height:85vh;top:5vh}.tab-content h2{display:none}.group-fields.hours .group-fields,.group-fields.hours .group-fields .field{display:flex;justify-content:space-between;align-items:center}.group-fields.hours .group-fields{padding:1rem .5rem;gap:1rem}.group-fields.hours .group-fields:nth-of-type(2n+1){background-color:var(--base)}.group-fields.hours .group-fields .field{margin:0}.group-fields.hours .true-false{flex:1}.group-fields.hours .time{position:relative}.group-fields.hours .time label{margin:0;font-size:var(--small);position:absolute;top:-1rem;left:0;color:var(--contrast-200)}.today_hours{width:min(500px,90vw)}.today_hours .group-fields{width:100%;padding:0;display:flex;justify-content:center;gap:.5rem}@media (min-width:768px){.today_hours .group-fields{padding:2rem}}.today_hours .field{margin:0}.dash .true-false{margin:0}.dash [type=submit]{width:90%}.dashboard.dash h2{text-transform:none;font-size:var(--large)}.dashboard.dash .replace>ul{display:flex;list-style:none;align-items:flex-start;justify-content:flex-start;flex-wrap:wrap;gap:.5rem}.dashboard.settings nav.tabs{--height:3.5rem;--x:var(--offHeight);position:fixed;bottom:var(--height);left:var(--x);right:var(--x);z-index:99;width:calc(100% - var(--x) - var(--x));background-color:var(--base)}nav.integrations,nav.integrations a,nav.integrations li,nav.integrations ul{height:auto}.replace{overflow:hidden}body.dash form#options{display:flex;flex-flow:column nowrap;justify-content:center;align-items:center}.item-grid.integrations{grid-template-columns:repeat(2,1fr);gap:2rem}.integration{background:var(--base);border:2px solid var(--base-200);border-radius:var(--outerRadius);padding:1rem;position:relative;transition:all var(--transition-base);box-shadow:var(--shadow)}.integration.connected{border-color:var(--success)}.integration.disconnected,.integration.error{border-color:var(--error)}.integration.hasChanges{border-color:var(--warning)}.integration .header{margin-bottom:.75rem;padding-bottom:.75rem;border-bottom:2px solid var(--base-200)}.integration h3{letter-spacing:1px;font-size:var(--medium);margin:0}.integration .meta{margin-bottom:1rem;text-align:right;color:var(--contrast-200);font-size:var(--small)}.integration .setup{font-size:var(--small);font-weight:700;text-transform:uppercase}.integration .setup .indicator{font-size:var(--medium)}.integration .connected .indicator,.integration .setup .connected{color:var(--success)}.integration .disconnected .indicator,.integration .setup .disconnected{color:var(--error)}.integration.hasChanges .disconnected{color:var(--warning)}.connection-status.connected{background-color:var(--successBack);color:var(--successText)}.connection-status.disconnected{background-color:var(--errorBack);color:var(--errorText)}.integration code{display:inline-block;width:90%;margin:0 .5rem;user-select:all;padding:.75rem;border:2px solid var(--base);background-color:var(--base-200);word-break:break-all}.integration details+details{margin-top:1rem}.integration .actions{margin-top:1rem}.hint{line-height:1.2;font-style:italic;font-size:var(--small)}.hasChanges button[data-action=save_credentials]{border-color:var(--warning);animation:pulse-color 1s infinite;animation-delay:1s}.flash{animation:flash .5s}.flash.connected{--b:var(--success)}.flash.disconnected{--b:var(--error)}.flash.syncing{--b:var(--success)}.flash.error,.flash.hasChanges{--b:var(--warning)}@keyframes flash{0%,100%{border-color:inherit}50%{border-color:var(--b)}}.location.field{width:80vw}.location.field>p{text-align:center}.location.field>p+p{margin:0 .5rem 0 0}.location.field .location-map{height:20vh}.location.field .location-links{padding:.5rem 0;display:flex;justify-content:space-evenly}.field.upload [data-upload-id],.item-grid .item{touch-action:none}.empty-state{grid-column:1/-1;padding:1rem 10vw;margin:0 10vw;border-radius:var(--outerRadius);background-color:var(--base-100)}.jvb-oauth-connect{position:relative;transition:opacity .2s}.jvb-oauth-connect.loading{opacity:.6;pointer-events:none}.jvb-oauth-connect.loading::after{content:'';position:absolute;right:-30px;top:50%;transform:translateY(-50%);width:16px;height:16px;border:2px solid #ccc;border-top-color:#0073aa;border-radius:50%;animation:oauth-spin .8s linear infinite}@keyframes oauth-spin{to{transform:translateY(-50%) rotate(360deg)}}.integration-status-message{padding:12px 16px;margin:16px 0;border-radius:4px;display:none;font-size:14px;line-height:1.5}.integration-status-message.success{display:block;background:#d4edda;color:#155724;border-left:4px solid #28a745}.integration-status-message.error{display:block;background:#f8d7da;color:#721c24;border-left:4px solid #dc3545}.integration-status-message.info{display:block;background:#d1ecf1;color:#0c5460;border-left:4px solid #17a2b8}.connection-status{display:inline-flex;align-items:center;gap:8px;padding:6px 12px;border-radius:4px;font-size:13px;font-weight:500}.connection-status.connected{background:#d4edda;color:#155724}.connection-status.disconnected{background:#f8d7da;color:#721c24}.status-indicator{font-size:10px;line-height:1}.connection-status.connected .status-indicator{color:#28a745}.connection-status.disconnected .status-indicator{color:#dc3545}
\ No newline at end of file
diff --git a/assets/css/forms.min.css b/assets/css/forms.min.css
index 188f3b8..1fae8da 100644
--- a/assets/css/forms.min.css
+++ b/assets/css/forms.min.css
@@ -1 +1 @@
-details.uploader .file-upload-container{margin:1rem 0;max-width:100%}@media (min-width:768px){details.uploader .file-upload-container{margin:1rem var(--mr) 1rem var(--ml);max-width:var(--maxWidth)}}.file-upload-wrapper{border:2px dashed var(--action-0);border-radius:4px;padding:2rem;text-align:center;transition:all .3s ease;background:rgba(var(--action-rgb),var(--rgb-subtle));position:relative;cursor:pointer}.file-upload-wrapper h2{margin:0!important;font-size:var(--large)}.dragover,.file-upload-wrapper:hover{background:rgba(var(--action-rgb),var(--rgb-subtle-hover));border-color:var(--action-0)!important}.file-upload-wrapper input[type=file]{position:absolute;left:0;top:0;width:100%;height:100%;opacity:0;cursor:pointer}.file-upload-text{color:var(--contrast);margin:0;font-family:var(--body)}.file-upload-text strong{color:var(--action-0);text-decoration:underline}.field.upload:has(.upload-item) .file-upload-container{display:none}.field.upload{position:relative}.field.upload:not(.uploading) .progress{display:none}.field.upload .actions{position:absolute;top:0;right:0}.item-grid.group,.item-grid.preview,.item-grid.restore{grid-template-columns:repeat(3,1fr)}.item-grid.group .item,.item-grid.preview .item,.item-grid.restore .item{display:block}.item-grid.group button,.item-grid.preview button,.item-grid.restore button{padding:.25rem .5rem}.item-grid.group button .icon,.item-grid.preview button .icon,.item-grid.restore button .icon{--w:1.1em}.item-grid.group .item .preview>input[type=checkbox]:not(.label-button)+label,.item-grid.preview .item .preview>input[type=checkbox]:not(.label-button)+label,.item-grid.restore .item .preview>input[type=checkbox]:not(.label-button)+label{padding-left:0;margin:0}.item-grid.group .item .preview>input[type=checkbox]+label:before,.item-grid.preview .item .preview>input[type=checkbox]+label:before,.item-grid.restore .item .preview>input[type=checkbox]+label:before{transform:unset;top:.5rem;left:.5rem}.item-grid.group .item .preview>input[type=checkbox]+label::after,.item-grid.preview .item .preview>input[type=checkbox]+label::after,.item-grid.restore .item .preview>input[type=checkbox]+label::after{top:.5rem;left:.75rem;transform:translateY(20%) rotate(45deg)}.item-grid.group .item .item-actions,.item-grid.preview .item .item-actions,.item-grid.restore .item .item-actions{position:absolute;top:0;right:0}.item-grid.group summary,.item-grid.preview summary,.item-grid.restore summary{padding:.5rem}.item-grid.group:has([type=checkbox]:checked),.item-grid.preview:has([type=checkbox]:checked),.item-grid.restore:has([type=checkbox]:checked){padding:1rem;background-color:rgba(var(--contrast-rgb),var(--rgb-subtle))}.item-grid.group:has([type=checkbox]:checked) .item,.item-grid.preview:has([type=checkbox]:checked) .item,.item-grid.restore:has([type=checkbox]:checked) .item{padding:.75rem;opacity:.8}.item-grid.group:has([type=checkbox]:checked) .item img,.item-grid.preview:has([type=checkbox]:checked) .item img,.item-grid.restore:has([type=checkbox]:checked) .item img{filter:var(--filter)}.item-grid.group:has([type=checkbox]:checked) details,.item-grid.preview:has([type=checkbox]:checked) details,.item-grid.restore:has([type=checkbox]:checked) details{display:none}.item-grid.group .item:has([type=checkbox]:checked),.item-grid.preview .item:has([type=checkbox]:checked),.item-grid.restore .item:has([type=checkbox]:checked){padding:.5rem;background-color:rgba(var(--action-rgb),var(--rgb-medium));opacity:1}.item-grid.group .item:has([type=checkbox]:checked) img,.item-grid.preview .item:has([type=checkbox]:checked) img,.item-grid.restore .item:has([type=checkbox]:checked) img{filter:none}[type=radio].featured+label .star+.star,[type=radio].featured:checked+label .star{display:none}[type=radio].featured+label .star,[type=radio].featured:checked+label .star+.star{display:inline-block}.restore.item,.upload.item{border-radius:var(--innerRadius);overflow:hidden;background:var(--base);border:1px solid var(--base-200)}.restore.item img,.upload.item img{transition:transform var(--transition-base)}.restore.item:hover img,.upload.item:hover img{transform:scale(1.02);transition:transform var(--transition-base)}.upload-group{background-image:var(--dashed-action);padding:5px;border-radius:var(--innerRadius);background-color:rgba(var(--action-rgb),var(--rgb-subtle))}.submit-uploads{position:fixed;bottom:var(--offHeight);right:var(--offHeight);z-index:var(--z-6);height:var(--height);box-shadow:var(--shadow);border-radius:var(--innerRadius);animation:pulse-color 5s infinite;animation-delay:1s;background-color:var(--action-0);color:var(--action-contrast)}.submit-uploads:hover{background-color:var(--base-200);color:var(--contrast-200)}.empty-group{grid-column:1/-1;padding:20px;background-image:var(--dashed-action);border-radius:var(--innerRadius);margin:10px 0;cursor:pointer;transition:all var(--transition-base);text-align:center;background-color:rgba(var(--action-rgb),var(--rgb-subtle))}.group-display:not([hidden])~.file-upload-container{display:none}.dragging,.upload.item.dragging{opacity:.7;transform:scale(.95) rotate(3deg);z-index:var(--z-top);box-shadow:0 8px 25px rgba(0,0,0,.3)}.dragover{background:rgba(var(--action-rgb),var(--rgb-light))!important;border-color:var(--action-0)!important;transform:scale(1.05);animation:drop-pulse .8s infinite ease-in-out}.drag-preview{position:fixed;z-index:var(--zz-top);width:fit-content;overflow:visible;pointer-events:none;opacity:.9;transform:scale(1.05);transition:transform .2s ease}.drag-preview .drag-items{width:max-content;height:max-content;position:relative}.drag-preview .drag-items .drag-item{width:120px;height:120px;position:absolute;top:0;left:0;background:var(--base);border-radius:var(--outerRadius);box-shadow:var(--shadow)}.drag-preview .drag-items .drag-item:nth-child(1){transform:rotate(-3deg);z-index:3}.drag-preview .drag-items .drag-item:nth-child(2){left:8px;top:-4px;transform:rotate(4deg);z-index:2;transition-delay:30ms}.drag-preview .drag-items .drag-item:nth-child(3){left:-6px;top:-8px;transform:rotate(-5deg);z-index:1;transition-delay:60ms}.drag-preview .drag-items .drag-item:nth-child(4){left:12px;top:-12px;transform:rotate(3deg);z-index:0;transition-delay:90ms}.drag-preview .drag-items .drag-item:nth-child(n+5){left:-10px;top:-16px;transform:rotate(-4deg);z-index:0;opacity:.8}.drag-preview .drag-items img,.drag-preview .drag-items video{width:100%;height:100%;object-fit:cover;display:block}.drag-preview .drag-count{position:absolute;top:-8px;right:-8px;background:var(--base-200);color:var(--contrast);border-radius:50%;width:24px;height:24px;display:flex;align-items:center;justify-content:center;font-size:12px;font-weight:700;box-shadow:var(--shadow);z-index:var(--z-3)}.item.dragging{opacity:.5;transform:scale(.95);filter:grayscale(50%);transition:opacity .2s ease,transform .2s ease,filter .2s ease}@keyframes drop-pulse{0%,100%{background-color:rgba(var(--action-rgb),var(--rgb-light));transform:scale(1.02)}50%{background-color:var(rgba(var(--action-rgb),var(--rgb-medium)));transform:scale(1.04)}}.group-actions{display:flex;gap:.25rem}@media (max-width:767px){body:not(.uploading):has(.group-display:not([hidden])){overflow:hidden}body:not(.uploading):has(.group-display:not([hidden])) .qtoggle{z-index:var(--z-1)}.group-display.group-display{position:fixed;top:var(--height);bottom:var(--height);left:0;right:0;max-height:var(--maxHeight);overflow:hidden;z-index:var(--z-6);width:calc(100% - 1rem);height:calc(100% - 1rem);padding:0 0 3rem;--justify:flex-start;--align:flex-start;--gap:0}.group-display::before{content:'';display:block;z-index:-1;top:-.5rem;bottom:-.5rem;left:-.5rem;right:-.5rem;position:absolute;background-color:rgba(var(--base-rgb),var(--rgb-heavy));filter:blur(5px)}.group-display .preview-wrap,.group-display .sidebar{height:50%;overflow:hidden auto;position:relative;padding:.5rem}.group-display .preview-wrap{top:0}.group-display .preview-wrap .selected{display:flex;justify-content:space-between;align-items:center}.group-display .sidebar{bottom:0;flex-wrap:nowrap;overflow:hidden auto;background-color:var(--contrast-200);color:var(--base)}.group-display .sidebar>.hint{color:var(--contrast)}.group-display .sidebar .header{display:none}.group-display .preview-actions{top:0;flex-shrink:0}.group-display .preview-wrap>.hint,.group-display .sidebar>.hint{bottom:0;margin:0;text-align:center}.group-display .preview-actions,.group-display .preview-wrap>.hint,.group-display .sidebar>.hint{position:absolute;left:0;right:0;background-color:rgba(var(--base-rgb),var(--rgb-heavy));z-index:var(--z-3);box-shadow:var(--shadow)}.group-display .item-grid{height:100%;overflow:hidden auto;grid-template-columns:repeat(3,1fr);padding:2rem 0}.group-display .sidebar>.item-grid{grid-template-columns:repeat(1,1fr);gap:1rem;padding:0}.group-display .sidebar .empty-group{order:0;position:sticky;height:fit-content;top:0;z-index:var(--z-3);background-color:rgba(var(--action-rgb),var(--rgb-heavy))}.group-display .sidebar .upload-group{order:1}.group-display .sidebar .empty-group p{margin:0}.group-display .field,.group-display .field label{margin:0;padding:0}.group-display .sidebar h4{margin:.25rem}.group-display .item{width:100%;height:max-content}.submit-uploads{bottom:var(--height);left:0;right:0;width:100%;height:3rem}body.uploading .group-display.group-display{position:relative;top:unset;bottom:unset;right:unset;left:unset}}@media (min-width:768px){.group-display.group-display{--wrap:nowrap;--dir:row;--gap:1rem;--align:flex-start}.group-display .preview-wrap,.group-display .sidebar{--justify:flex-start;max-height:calc(100vh - var(--doubleHeight));overflow:hidden auto}.group-display .preview-wrap,.group-display .sidebar{width:50%}.preview-actions,.preview-wrap .hint{position:sticky;z-index:var(--z-3);box-shadow:var(--shadow);background-color:var(--base);width:100%}.preview-actions{top:0;left:0;right:0}.preview-actions .field{margin:0}.preview-wrap .hint,.sidebar>.hint{bottom:-1rem;padding-bottom:1rem;margin:0;left:0;right:0;text-align:center}}.restore-uploads{position:fixed;top:var(--offHeight);bottom:var(--offHeight);left:1rem;right:1rem;border-radius:var(--outerRadius);padding:1rem;z-index:var(--z-top);box-shadow:var(--shadow);background-color:var(--base-200);overflow:hidden auto}dialog nav.tabs{position:sticky;top:0;background-color:var(--base-50);z-index:var(--z-6);box-shadow:var(--shadow-down);margin-bottom:2rem}.editor-container .ql-toolbar{display:flex;background-color:var(--base-50);justify-content:flex-start;flex-wrap:wrap;padding:.25rem;gap:.5rem 1rem;border-top-left-radius:var(--innerRadius);border-top-right-radius:var(--innerRadius);border-bottom:4px solid var(--base-50)}.ql-toolbar .ql-formats{display:flex;gap:.25rem}.editor-container .ql-container{--padding:1rem;background-color:var(--base);border-bottom-left-radius:var(--innerRadius);border-bottom-right-radius:var(--innerRadius);height:fit-content;padding:2px;border:1px solid var(--base-200)}.editor-container .ql-container .ql-editor{padding:var(--padding);width:100%;height:100%}.ql-editor img{max-width:50%;height:auto}.ql-clipboard{left:-100000px;height:1px;overflow-y:hidden;position:absolute;top:50%}.ql-hidden{display:none}.ql-tooltip{position:absolute;transform:translateY(10px);background-color:var(--base-100);border:1px solid var(--base);box-shadow:0 0 5px var(--overlay-heavy);color:var(--contrast);padding:5px 12px;white-space:nowrap}[data-type=single] .item-grid{display:flex}.repeater-row details summary::after{margin-left:0}.repeater-row details summary button{margin-left:auto}/*!* Group actions buttons - more visible *!*//*!* Group item grid - distinct from preview grid *!*//*!* Group count hint *!*//*!* ============================================================================*//*!* Base drag preview *!*//*!* Single item drag preview *!*//*!* Multi-item drag preview container *!*//*!* Items being dragged - reduce opacity on originals *!*//*!* Count badge on multi-item preview *!*//*!* ============================================================================*//*!* Ensure progress bar is visible when needed *!*//*!* Progress bar track *!*//*!* Progress bar fill *!*//*!* Progress details - styled for row layout with text and count *!*//*!* Individual item progress - overlay style *!*//*!* Item progress icon and status text *!*//*!* ============================================================================*//*!* Hide uploader when we have uploads *!*//*!* Show group display when we have uploads *!*//*!* ============================================================================*//*!* Selected items - more obvious *!*//*!* Selection checkbox - always visible on hover or when checked *!*//*!* Selection controls - more prominent *!*//*!* ============================================================================*//*!* Smooth dragover animation *!*//*!* ============================================================================*//*!* ============================================================================*//*!* Notification container - fixed overlay *!*//*!* Content card *!*//*!* Message section *!*//*!* Scrollable field list *!*//*!* Item grid for restore preview *!*//*!* Restore item *!*//*!* Checked state *!*//*!* Preview section *!*//*!* Item info *!*//*!* Checkbox controls *!*//*!* Actions section *!*//*!* Selection controls *!*//*!* Action buttons *!*//*!* Restore button - primary action *!*//*!* Scrap cache button - destructive action *!*//*!* Dismiss button - secondary action *!*//*!* Mobile responsive *!*//*!* Animation *!*//*!* Scrollbar styling for restore field list *!*/form{--step-size:2.5rem}.form-progress{padding:0 1rem}.form-progress .progress{background:var(--base-100);border-radius:var(--innerRadius);padding:1rem}.form-progress .bar{height:6px;background:var(--base-200);border-radius:3px;overflow:hidden;margin-bottom:.5rem}.form-progress .fill{height:100%;background:linear-gradient(90deg,var(--action-0),var(--action-200));width:0%;transition:width .4s ease;border-radius:3px}.form-progress .step-text{font-size:var(--small);font-weight:600;color:var(--contrast-200)}form nav.tabs{position:relative;top:0;left:0;right:0;padding:1rem 0;gap:0;z-index:0}form nav.tabs button{position:relative;background:0 0;border:none;padding:.5rem 1rem .5rem 3rem;z-index:1}form nav.tabs .step-number{width:2.5rem;height:100%;border-radius:50% 0 0 50%;position:absolute;left:0;top:0;background:var(--base-200);color:var(--contrast-50);display:flex;align-items:center;justify-content:center;font-weight:700;font-size:var(--small);border:3px solid var(--base)}form nav.tabs button.pending .step-number{background:var(--base-100);color:var(--contrast-200)}form nav.tabs button.active .step-number,form nav.tabs button.current .step-number{background:var(--action-0);color:var(--action-contrast);border-color:var(--action-200)}form nav.tabs button.completed .step-number{background:var(--successBack);color:var(--successBack);border-color:var(--successText)}form nav.tabs button.completed .step-number::before{content:'✓';font-size:1.2rem;color:var(--successText);position:absolute}form nav.tabs button.completed h2{color:var(--contrast-200)}.step-navigation{margin-top:2rem;padding-top:2rem;border-top:1px solid var(--base-200);gap:1rem}.step-navigation .prev-step{background:var(--base-100)}.step-navigation .next-step,.step-navigation button[type=submit]{margin-left:auto}.field input.error,.field select.error,.field textarea.error{border-color:var(--errorBack)}.error-message{color:var(--errorText);font-size:var(--small);margin-top:.25rem;display:block}@media (max-width:768px){form nav.tabs button{min-width:80px;font-size:var(--small)}form nav.tabs button h2{font-size:var(--small)}form{--step-size:2rem}}.field-input-wrapper{position:relative;display:flex;align-items:center;gap:.5rem}.field-input-wrapper input,.field-input-wrapper select,.field-input-wrapper textarea{flex:1}.validation-icon{display:flex;align-items:center;justify-content:center;font-size:1.25rem;animation:scaleIn .3s ease;--w:1.25rem}.validation-icon.error{color:var(--error)}.validation-icon.success{color:var(--success)}@keyframes scaleIn{from{transform:scale(0);opacity:0}to{transform:scale(1);opacity:1}}.validation-message{color:var(--error-0);font-size:var(--small);margin-top:.25rem;display:block;animation:slideDown .2s ease}@keyframes slideDown{from{opacity:0;transform:translateY(-4px)}to{opacity:1;transform:translateY(0)}}.field.has-error input,.field.has-error select,.field.has-error textarea{border-color:var(--error);background-color:var(--errorBack)}.field.has-error input:focus,.field.has-error select:focus,.field.has-error textarea:focus{outline-color:var(--error);box-shadow:0 0 0 3px rgba(var(--error-rgb),.2)}.field.has-success input,.field.has-success select,.field.has-success textarea{border-color:var(--success)}.field label .required{color:var(--error);margin-left:.25rem}.form-summary{padding:2rem;border-radius:8px;margin-top:2rem;border:2px dashed var(--contrast-200)}.form-summary .message{margin-bottom:2rem}.form-summary .result+.result{position:relative;margin-top:1.5rem;padding-top:1.5rem}.form-summary .result+.result::before{position:absolute;top:0;left:16.5%;content:'';width:67%;height:1px;border-bottom:1px solid var(--base-200)}.form-summary h2{margin:1rem 0}.form-summary h4{background-color:var(--base-100);padding:.5rem 2rem;position:relative;left:-2rem;color:var(--contrast-200);font-size:.875rem;text-transform:uppercase;letter-spacing:.05em;margin-bottom:.75rem}.form-summary p{color:var(--text);margin:0}.group-summary,.repeater-summary{background:var(--base-100);padding:1rem;border-radius:4px;margin-top:.5rem}.repeater-row{margin-bottom:1rem}.repeater-row:last-child{margin-bottom:0}
\ No newline at end of file
+details.uploader .file-upload-container{margin:1rem 0;max-width:100%}@media (min-width:768px){details.uploader .file-upload-container{margin:1rem var(--mr) 1rem var(--ml);max-width:var(--maxWidth)}}.file-upload-wrapper{border:2px dashed var(--action-0);border-radius:4px;padding:2rem;text-align:center;transition:all .3s ease;background:rgba(var(--action-rgb),var(--rgb-subtle));position:relative;cursor:pointer}.file-upload-wrapper h2{margin:0!important;font-size:var(--large)}.dragover,.file-upload-wrapper:hover{background:rgba(var(--action-rgb),var(--rgb-subtle-hover));border-color:var(--action-0)!important}.file-upload-wrapper input[type=file]{position:absolute;left:0;top:0;width:100%;height:100%;opacity:0;cursor:pointer}.file-upload-text{color:var(--contrast);margin:0;font-family:var(--body)}.file-upload-text strong{color:var(--action-0);text-decoration:underline}.field.upload:has(.upload-item) .file-upload-container{display:none}.field.upload{position:relative}.field.upload:not(.uploading) .progress{display:none}.field.upload .actions{position:absolute;top:0;right:0}.item-grid.group,.item-grid.preview,.item-grid.restore{grid-template-columns:repeat(3,1fr)}.item-grid.group .item,.item-grid.preview .item,.item-grid.restore .item{display:block}.item-grid.group button,.item-grid.preview button,.item-grid.restore button{padding:.25rem .5rem}.item-grid.group button .icon,.item-grid.preview button .icon,.item-grid.restore button .icon{--w:1.1em}.item-grid.group .item .preview>input[type=checkbox]:not(.label-button)+label,.item-grid.preview .item .preview>input[type=checkbox]:not(.label-button)+label,.item-grid.restore .item .preview>input[type=checkbox]:not(.label-button)+label{padding-left:0;margin:0}.item-grid.group .item .preview>input[type=checkbox]+label:before,.item-grid.preview .item .preview>input[type=checkbox]+label:before,.item-grid.restore .item .preview>input[type=checkbox]+label:before{transform:unset;top:.5rem;left:.5rem}.item-grid.group .item .preview>input[type=checkbox]+label::after,.item-grid.preview .item .preview>input[type=checkbox]+label::after,.item-grid.restore .item .preview>input[type=checkbox]+label::after{top:.5rem;left:.75rem;transform:translateY(20%) rotate(45deg)}.item-grid.group .item .item-actions,.item-grid.preview .item .item-actions,.item-grid.restore .item .item-actions{position:absolute;top:0;right:0}.item-grid.group summary,.item-grid.preview summary,.item-grid.restore summary{padding:.5rem}.item-grid.group:has([type=checkbox]:checked),.item-grid.preview:has([type=checkbox]:checked),.item-grid.restore:has([type=checkbox]:checked){padding:1rem;background-color:rgba(var(--contrast-rgb),var(--rgb-subtle))}.item-grid.group:has([type=checkbox]:checked) .item,.item-grid.preview:has([type=checkbox]:checked) .item,.item-grid.restore:has([type=checkbox]:checked) .item{padding:.75rem;opacity:.8}.item-grid.group:has([type=checkbox]:checked) .item img,.item-grid.preview:has([type=checkbox]:checked) .item img,.item-grid.restore:has([type=checkbox]:checked) .item img{filter:var(--filter)}.item-grid.group:has([type=checkbox]:checked) details,.item-grid.preview:has([type=checkbox]:checked) details,.item-grid.restore:has([type=checkbox]:checked) details{display:none}.item-grid.group .item:has([type=checkbox]:checked),.item-grid.preview .item:has([type=checkbox]:checked),.item-grid.restore .item:has([type=checkbox]:checked){padding:.5rem;background-color:rgba(var(--action-rgb),var(--rgb-medium));opacity:1}.item-grid.group .item:has([type=checkbox]:checked) img,.item-grid.preview .item:has([type=checkbox]:checked) img,.item-grid.restore .item:has([type=checkbox]:checked) img{filter:none}[type=radio].featured+label .star+.star,[type=radio].featured:checked+label .star{display:none}[type=radio].featured+label .star,[type=radio].featured:checked+label .star+.star{display:inline-block}.restore.item,.upload.item{border-radius:var(--innerRadius);overflow:hidden;background:var(--base);border:1px solid var(--base-200)}.restore.item img,.upload.item img{transition:transform var(--transition-base)}.restore.item:hover img,.upload.item:hover img{transform:scale(1.02);transition:transform var(--transition-base)}.upload-group{background-image:var(--dashed-action);padding:5px;border-radius:var(--innerRadius);background-color:rgba(var(--action-rgb),var(--rgb-subtle))}.submit-uploads{position:fixed;bottom:var(--offHeight);right:var(--offHeight);z-index:var(--z-6);height:var(--height);box-shadow:var(--shadow);border-radius:var(--innerRadius);animation:pulse-color 5s infinite;animation-delay:1s;background-color:var(--action-0);color:var(--action-contrast)}.submit-uploads:hover{background-color:var(--base-200);color:var(--contrast-200)}.empty-group{grid-column:1/-1;padding:20px;background-image:var(--dashed-action);border-radius:var(--innerRadius);margin:10px 0;cursor:pointer;transition:all var(--transition-base);text-align:center;background-color:rgba(var(--action-rgb),var(--rgb-subtle))}.group-display:not([hidden])~.file-upload-container{display:none}.dragging,.upload.item.dragging{opacity:.7;transform:scale(.95) rotate(3deg);z-index:var(--z-top);box-shadow:0 8px 25px rgba(0,0,0,.3)}.dragover{background:rgba(var(--action-rgb),var(--rgb-light))!important;border-color:var(--action-0)!important;transform:scale(1.05);animation:drop-pulse .8s infinite ease-in-out}.drag-preview{position:fixed;z-index:var(--zz-top);width:fit-content;overflow:visible;pointer-events:none;opacity:.9;transform:scale(1.05);transition:transform .2s ease}.drag-preview .drag-items{width:max-content;height:max-content;position:relative}.drag-preview .drag-items .drag-item{width:120px;height:120px;position:absolute;top:0;left:0;background:var(--base);border-radius:var(--outerRadius);box-shadow:var(--shadow)}.drag-preview .drag-items .drag-item:nth-child(1){transform:rotate(-3deg);z-index:3}.drag-preview .drag-items .drag-item:nth-child(2){left:8px;top:-4px;transform:rotate(4deg);z-index:2;transition-delay:30ms}.drag-preview .drag-items .drag-item:nth-child(3){left:-6px;top:-8px;transform:rotate(-5deg);z-index:1;transition-delay:60ms}.drag-preview .drag-items .drag-item:nth-child(4){left:12px;top:-12px;transform:rotate(3deg);z-index:0;transition-delay:90ms}.drag-preview .drag-items .drag-item:nth-child(n+5){left:-10px;top:-16px;transform:rotate(-4deg);z-index:0;opacity:.8}.drag-preview .drag-items img,.drag-preview .drag-items video{width:100%;height:100%;object-fit:cover;display:block}.drag-preview .drag-count{position:absolute;top:-8px;right:-8px;background:var(--base-200);color:var(--contrast);border-radius:50%;width:24px;height:24px;display:flex;align-items:center;justify-content:center;font-size:12px;font-weight:700;box-shadow:var(--shadow);z-index:var(--z-3)}.item.dragging{opacity:.5;transform:scale(.95);filter:grayscale(50%);transition:opacity .2s ease,transform .2s ease,filter .2s ease}@keyframes drop-pulse{0%,100%{background-color:rgba(var(--action-rgb),var(--rgb-light));transform:scale(1.02)}50%{background-color:var(rgba(var(--action-rgb),var(--rgb-medium)));transform:scale(1.04)}}.group-actions{display:flex;gap:.25rem}@media (max-width:767px){body:not(.uploading):has(.group-display:not([hidden])){overflow:hidden}body:not(.uploading):has(.group-display:not([hidden])) .qtoggle{z-index:var(--z-1)}.group-display.group-display{position:fixed;top:var(--height);bottom:var(--height);left:0;right:0;max-height:var(--maxHeight);overflow:hidden;z-index:var(--z-6);width:calc(100% - 1rem);height:calc(100% - 1rem);padding:0 0 3rem;--justify:flex-start;--align:flex-start;--gap:0}.group-display::before{content:'';display:block;z-index:-1;top:-.5rem;bottom:-.5rem;left:-.5rem;right:-.5rem;position:absolute;background-color:rgba(var(--base-rgb),var(--rgb-heavy));filter:blur(5px)}.group-display .preview-wrap,.group-display .sidebar{height:50%;overflow:hidden auto;position:relative;padding:.5rem}.group-display .preview-wrap{top:0}.group-display .preview-wrap .selected{display:flex;justify-content:space-between;align-items:center}.group-display .sidebar{bottom:0;flex-wrap:nowrap;overflow:hidden auto;background-color:var(--contrast-200);color:var(--base)}.group-display .sidebar>.hint{color:var(--contrast)}.group-display .sidebar .header{display:none}.group-display .preview-actions{top:0;flex-shrink:0}.group-display .preview-wrap>.hint,.group-display .sidebar>.hint{bottom:0;margin:0;text-align:center}.group-display .preview-actions,.group-display .preview-wrap>.hint,.group-display .sidebar>.hint{position:absolute;left:0;right:0;background-color:rgba(var(--base-rgb),var(--rgb-heavy));z-index:var(--z-3);box-shadow:var(--shadow)}.group-display .item-grid{height:100%;overflow:hidden auto;grid-template-columns:repeat(3,1fr);padding:2rem 0}.group-display .sidebar>.item-grid{grid-template-columns:repeat(1,1fr);gap:1rem;padding:0}.group-display .sidebar .empty-group{order:0;position:sticky;height:fit-content;top:0;z-index:var(--z-3);background-color:rgba(var(--action-rgb),var(--rgb-heavy))}.group-display .sidebar .upload-group{order:1}.group-display .sidebar .empty-group p{margin:0}.group-display .field,.group-display .field label{margin:0;padding:0}.group-display .sidebar h4{margin:.25rem}.group-display .item{width:100%;height:max-content}.submit-uploads{bottom:var(--height);left:0;right:0;width:100%;height:3rem}body.uploading .group-display.group-display{position:relative;top:unset;bottom:unset;right:unset;left:unset}}@media (min-width:768px){.group-display.group-display{--wrap:nowrap;--dir:row;--gap:1rem;--align:flex-start}.group-display .preview-wrap,.group-display .sidebar{--justify:flex-start;max-height:calc(100vh - var(--doubleHeight));overflow:hidden auto}.group-display .preview-wrap,.group-display .sidebar{width:50%}.preview-actions,.preview-wrap .hint{position:sticky;z-index:var(--z-3);box-shadow:var(--shadow);background-color:var(--base);width:100%}.preview-actions{top:0;left:0;right:0}.preview-actions .field{margin:0}.preview-wrap .hint,.sidebar>.hint{bottom:-1rem;padding-bottom:1rem;margin:0;left:0;right:0;text-align:center}}.restore-uploads{position:fixed;top:var(--offHeight);bottom:var(--offHeight);left:1rem;right:1rem;border-radius:var(--outerRadius);padding:1rem;z-index:var(--z-top);box-shadow:var(--shadow);background-color:var(--base-200);overflow:hidden auto}dialog nav.tabs{position:sticky;top:0;background-color:var(--base-50);z-index:var(--z-6);box-shadow:var(--shadow-down);margin-bottom:2rem}.editor-container .ql-toolbar{display:flex;background-color:var(--base-50);justify-content:flex-start;flex-wrap:wrap;padding:.25rem;gap:.5rem 1rem;border-top-left-radius:var(--innerRadius);border-top-right-radius:var(--innerRadius);border-bottom:4px solid var(--base-50)}.ql-toolbar .ql-formats{display:flex;gap:.25rem}.editor-container .ql-container{--padding:1rem;background-color:var(--base);border-bottom-left-radius:var(--innerRadius);border-bottom-right-radius:var(--innerRadius);height:fit-content;padding:2px;border:1px solid var(--base-200)}.editor-container .ql-container .ql-editor{padding:var(--padding);width:100%;height:100%}.ql-editor img{max-width:50%;height:auto}.ql-clipboard{left:-100000px;height:1px;overflow-y:hidden;position:absolute;top:50%}.ql-hidden{display:none}.ql-tooltip{position:absolute;transform:translateY(10px);background-color:var(--base-100);border:1px solid var(--base);box-shadow:0 0 5px var(--overlay-heavy);color:var(--contrast);padding:5px 12px;white-space:nowrap}[data-type=single] .item-grid{display:flex}.repeater-row details summary::after{margin-left:0}.repeater-row details summary button{margin-left:auto}/*!* Group actions buttons - more visible *!*//*!* Group item grid - distinct from preview grid *!*//*!* Group count hint *!*//*!* ============================================================================*//*!* Base drag preview *!*//*!* Single item drag preview *!*//*!* Multi-item drag preview container *!*//*!* Items being dragged - reduce opacity on originals *!*//*!* Count badge on multi-item preview *!*//*!* ============================================================================*//*!* Ensure progress bar is visible when needed *!*//*!* Progress bar track *!*//*!* Progress bar fill *!*//*!* Progress details - styled for row layout with text and count *!*//*!* Individual item progress - overlay style *!*//*!* Item progress icon and status text *!*//*!* ============================================================================*//*!* Hide uploader when we have uploads *!*//*!* Show group display when we have uploads *!*//*!* ============================================================================*//*!* Selected items - more obvious *!*//*!* Selection checkbox - always visible on hover or when checked *!*//*!* Selection controls - more prominent *!*//*!* ============================================================================*//*!* Smooth dragover animation *!*//*!* ============================================================================*//*!* ============================================================================*//*!* Notification container - fixed overlay *!*//*!* Content card *!*//*!* Message section *!*//*!* Scrollable field list *!*//*!* Item grid for restore preview *!*//*!* Restore item *!*//*!* Checked state *!*//*!* Preview section *!*//*!* Item info *!*//*!* Checkbox controls *!*//*!* Actions section *!*//*!* Selection controls *!*//*!* Action buttons *!*//*!* Restore button - primary action *!*//*!* Scrap cache button - destructive action *!*//*!* Dismiss button - secondary action *!*//*!* Mobile responsive *!*//*!* Animation *!*//*!* Scrollbar styling for restore field list *!*/form{--step-size:2.5rem}.form-progress{padding:0 1rem}.form-progress .progress{background:var(--base-100);border-radius:var(--innerRadius);padding:1rem}.form-progress .bar{height:6px;background:var(--base-200);border-radius:3px;overflow:hidden;margin-bottom:.5rem}.form-progress .fill{height:100%;background:linear-gradient(90deg,var(--action-0),var(--action-200));width:0%;transition:width .4s ease;border-radius:3px}.form-progress .step-text{font-size:var(--small);font-weight:600;color:var(--contrast-200)}form nav.tabs{position:relative;top:0;left:0;right:0;padding:1rem 0;gap:0;z-index:0}form nav.tabs button{position:relative;background:0 0;border:none;padding:.5rem 1rem .5rem 3rem;z-index:1}form nav.tabs .step-number{width:2.5rem;height:100%;border-radius:50% 0 0 50%;position:absolute;left:0;top:0;background:var(--base-200);color:var(--contrast-50);display:flex;align-items:center;justify-content:center;font-weight:700;font-size:var(--small);border:3px solid var(--base)}form nav.tabs button.pending .step-number{background:var(--base-100);color:var(--contrast-200)}form nav.tabs button.active .step-number,form nav.tabs button.current .step-number{background:var(--action-0);color:var(--action-contrast);border-color:var(--action-200)}form nav.tabs button.completed .step-number{background:var(--successBack);color:var(--successBack);border-color:var(--successText)}form nav.tabs button.completed .step-number::before{content:'✓';font-size:1.2rem;color:var(--successText);position:absolute}form nav.tabs button.completed h2{color:var(--contrast-200)}.step-navigation{margin-top:2rem;padding-top:2rem;border-top:1px solid var(--base-200);gap:1rem}.step-navigation .prev-step{background:var(--base-100)}.step-navigation .next-step,.step-navigation button[type=submit]{margin-left:auto}.field input.error,.field select.error,.field textarea.error{border-color:var(--errorBack)}.error-message{color:var(--errorText);font-size:var(--small);margin-top:.25rem;display:block}@media (max-width:768px){form nav.tabs button{min-width:80px;font-size:var(--small)}form nav.tabs button h2{font-size:var(--small)}form{--step-size:2rem}}.field-input-wrapper{position:relative;display:flex;align-items:center;gap:.5rem}.field-input-wrapper input,.field-input-wrapper select,.field-input-wrapper textarea{flex:1}.validation-icon{display:flex;align-items:center;justify-content:center;font-size:1.25rem;animation:scaleIn .3s ease;--w:1.25rem}.validation-icon.error{color:var(--error)}.validation-icon.success{color:var(--success)}@keyframes scaleIn{from{transform:scale(0);opacity:0}to{transform:scale(1);opacity:1}}.validation-message{color:var(--error-0);font-size:var(--small);margin-top:.25rem;display:block;animation:slideDown .2s ease}@keyframes slideDown{from{opacity:0;transform:translateY(-4px)}to{opacity:1;transform:translateY(0)}}.field.has-error input,.field.has-error select,.field.has-error textarea{border-color:var(--error);background-color:var(--errorBack)}.field.has-error input:focus,.field.has-error select:focus,.field.has-error textarea:focus{outline-color:var(--error);box-shadow:0 0 0 3px rgba(var(--error-rgb),.2)}.field.has-success input,.field.has-success select,.field.has-success textarea{border-color:var(--success)}.field label .required{color:var(--error);margin-left:.25rem}.form-summary{padding:2rem;border-radius:8px;margin-top:2rem;border:2px dashed var(--contrast-200)}.form-summary .message{margin-bottom:2rem}.form-summary .result+.result{position:relative;margin-top:1.5rem;padding-top:1.5rem}.form-summary .result+.result::before{position:absolute;top:0;left:16.5%;content:'';width:67%;height:1px;border-bottom:1px solid var(--base-200)}.form-summary h2{margin:1rem 0}.form-summary h4{background-color:var(--base-100);padding:.5rem 2rem;position:relative;left:-2rem;color:var(--contrast-200);font-size:.875rem;text-transform:uppercase;letter-spacing:.05em;margin-bottom:.75rem}.form-summary p{color:var(--text);margin:0}.group-summary,.repeater-summary{background:var(--base-100);padding:1rem;border-radius:4px;margin-top:.5rem}.repeater-row{margin-bottom:1rem}.repeater-row:last-child{margin-bottom:0}.ql-toolbar button{--height:fit-content;padding:.5rem}
\ No newline at end of file
diff --git a/assets/css/nav.min.css b/assets/css/nav.min.css
index 81f1362..e44a3cd 100644
--- a/assets/css/nav.min.css
+++ b/assets/css/nav.min.css
@@ -1 +1 @@
-nav{--py:.25rem;--px:1rem;max-width:100%}nav,nav a,nav li,nav ol,nav ul{height:var(--height);display:flex;justify-content:var(--justify);align-items:var(--align);gap:var(--gap);flex-wrap:var(--wrap);flex-direction:var(--dir)}nav:not(:has(>ul)),nav>ul{--justify:flex-start;--align:center;--wrap:nowrap;--w:1em;--dir:row;position:relative;overflow:auto hidden;touch-action:pan-x}nav a{padding:0 var(--px);white-space:nowrap;text-transform:uppercase}nav .current a,nav a.current,nav a:focus,nav a:focus:visited,nav a:hover,nav a:hover:visited{background-color:var(--action-0);color:var(--action-contrast);transition:background-color var(--transition-base),color var(--transition-base)}nav ol,nav ul{list-style:none;margin:0;padding:0}.has-submenu button:hover,nav a:hover{background-color:var(--action-0);color:var(--action-contrast)}.has-submenu button{height:var(--height);width:var(--height);padding:0;background-color:var(--base);color:var(--contrast);border-radius:0}.toggle svg{transform:rotate(0);transition:transform var(--timing) var(--function);transition-property:transform,background-color,color}.has-submenu.open>button:not(.notifications,.quick-help) svg,.has-submenu:hover>button:not(.notifications,.quick-help) svg{transform:rotate(900deg)}ul.submenu{--dir:column;--wrap:nowrap;--gap:0;position:absolute;top:100%;left:0;max-height:0;transform:scaleY(0);transform-origin:top;width:max-content;min-width:100%;background-color:var(--overlay-light);border:2px solid var(--overlay-light);transition:all var(--timing) var(--function);box-shadow:var(--shadow-none)}.submenu li{background-color:var(--overlay-heavy);border:1px solid var(--base-50)}.submenu li:hover{--c:var(--action-rgb);background-color:var(--overlay-heavy)}.submenu a:hover{background-color:transparent}.wp-site-blocks>header ul.submenu{right:0;left:auto}.has-submenu.open>ul.submenu,.has-submenu:hover>ul.submenu{transform:scaleY(1);max-height:1000%;box-shadow:var(--shadow)}nav#breadcrumbs{--height:1.5em;--w:20px;width:fit-content;max-width:var(--full);position:absolute;background-color:var(--overlay-medium);font-size:var(--small);padding:.125em;overflow:visible;--gap:0}nav#breadcrumbs li+li::before{content:'/';color:var(--contrast-200)}nav#breadcrumbs li:last-of-type{margin-right:.5em}nav#breadcrumbs a,nav#breadcrumbs span{padding:0 .125rem;white-space:nowrap;height:2em;color:var(--contrast);text-transform:none;width:max-content}nav#breadcrumbs span{display:flex;align-items:center;padding-left:.5em}nav#breadcrumbs a:focus,nav#breadcrumbs a:focus:visited,nav#breadcrumbs a:hover,nav#breadcrumbs a:hover:visited{background-color:transparent;color:var(--action-0)}nav#breadcrumbs a:has(.icon){width:2rem}nav.always{z-index:9999;position:fixed;width:var(--height);bottom:0;right:0}nav.always>ul{--dir:column;--wrap:nowrap;--justify:flex-end;--align:center;position:fixed;background-color:var(--overlay-heavy);backdrop-filter:blur(5px);z-index:var(--zz-top);top:0;right:-300vw;padding:0;width:100%;height:100vh;overflow:hidden auto;transition:right var(--timing) var(--function)}@media (min-width:768px){nav.always>ul{--justify:flex-start}}nav.always.open>ul{width:100%;right:0;gap:0}nav.always>ul li.active,nav.always>ul li:focus-within,nav.always>ul li:hover{background-color:var(--overlay-heavy)}nav.always li{width:100%;height:fit-content}nav.always a{--py:1rem;width:100%}nav.always>button{position:fixed;bottom:0;right:0;width:var(--height);height:var(--height);border-radius:0;background-color:var(--base);color:var(--contrast);transition:width var(--timing) var(--function);transition-property:width,background-color;box-shadow:var(--shadow)}nav.always>button:hover{background-color:var(--action-0);color:var(--action-contrast)}nav.always.open>button{--c:var(--action-rgb);z-index:1000000;width:100%;background-color:var(--overlay-heavy);color:var(--contrast);backdrop-filter:blur(5px)}nav.always.open>button:focus,nav.always.open>button:hover{background-color:var(--action-0);color:var(--action-contrast)}nav.always.open>button .list,nav.always>button .x{transform:scale(0);height:0;width:0;position:absolute}nav.always.open>button .x,nav.always>button .list{transform:scale(1);height:32px;width:32px}@media (min-width:768px){nav.always a{padding:2rem 0}nav.always>ul{padding:var(--height) 0}}nav.fixed.bottom,nav.on-this-page{--dir:row;--gap:0;width:calc(100% - var(--height));left:0;bottom:0;position:fixed;box-shadow:var(--shadow);z-index:999}nav.fixed.bottom ul{width:100%;--justify:space-between;background-color:var(--base);padding:0 .25rem}nav.fixed a,nav.fixed li{--justify:center;width:100%}nav.fixed.bottom a,nav.fixed.bottom a:visited{color:var(--contrast);font-size:var(--small);padding:0}nav.fixed.bottom a:focus,nav.fixed.bottom a:focus:visited,nav.fixed.bottom a:hover,nav.fixed.bottom a:hover:visited{color:var(--action-contrast)}.fixed.bottom li{flex:1}@media (min-width:768px){nav.fixed.bottom a{font-size:var(--large)}}nav.on-this-page{--justify:space-between;max-width:none;z-index:99;margin:0;padding:0 .5rem;background-color:var(--overlay-medium);color:var(--base-200)}body:has(nav.fixed) nav.on-this-page{bottom:var(--offHeight)}.on-this-page ul{--justify:flex-start;gap:0;width:100%}.on-this-page li:not(.has){padding:0}nav.letters li{width:100%;max-width:calc(7.69% - 2px)}.on-this-page .active a{--c:var(--action-rgb);background-color:var(--overlay-heavy);color:var(--action-contrast)}@media (min-width:768px){nav.letters li{max-width:none;width:fit-content}nav.letters a,nav.letters li:not(.has){padding:.25rem .66rem}}nav.index{--justify:flex-start;--px:0;background-color:var(--overlay-heavy)}.index ul{--justify:flex-start;width:fit-content}.index li{flex-shrink:0;transform:scaleX(0);transform-origin:right;max-width:0;overflow:hidden;transition:transform var(--timing) var(--function)}.index li.active{transform:scaleX(1);transform-origin:left;width:100%;flex-shrink:1;max-width:fit-content}@media (min-width:768px){.index li.adj{transform:scaleX(1);transform-origin:left;width:100%;flex-shrink:1;max-width:fit-content}}.index a{border-bottom:4px solid transparent}.index .active a{border-color:var(--action-0);color:var(--contrast)}.index .active a:hover,.index a:hover{background-color:var(--action-0);color:var(--action-contrast)}.index label{display:flex;color:var(--contrast);align-items:center;margin:0}.index label button{margin-left:1em}.index.open{--dir:column-reverse;height:calc(100% - 8rem);z-index:99999999;width:100%;background-color:var(--overlay-heavy);backdrop-filter:blur(5px);align-items:flex-end}.index.open label{max-width:90%;margin-top:1rem;margin-right:2rem}.index.open .toggle svg{transform:rotate(45deg)}.index.open ul{--dir:column;--justify:flex-end;height:100%;max-width:100%;width:100%}.index.open li{background-color:transparent;max-width:100%!important;width:100%;height:var(--height);transform:scaleX(1);flex-shrink:1;overflow:visible}.index.open a{--justify:flex-end;background-color:transparent;padding:0 2rem 0 0}.is-style-condensed{--dir:row;--wrap:wrap;--height:1.2em;--py:.2rem;--px:1rem}.is-style-condensed>ul{--wrap:wrap}.is-style-condensed ul{--justify:center;--gap:0}.is-style-condensed li{width:fit-content}.is-style-condensed li+li::before{content:'·';display:block;padding:0 .5em}.is-style-condensed a{text-transform:none;white-space:nowrap;border-bottom:2px solid transparent}.dashboard-nav{width:100%;--dir:row;--justify:flex-start;--wrap:nowrap}.wp-site-blocks>header,body>header{--dir:row;position:sticky;top:0;left:0;right:0;height:var(--height);display:flex;justify-content:space-between;align-items:center;padding:0 .5rem;background-color:var(--base);z-index:9999;box-shadow:var(--shadow);border-bottom:1px solid var(--action-0)}body>header{justify-content:space-between}header .title{--w:5em;margin:0;position:absolute;width:100%;height:100%;display:flex;justify-content:center;align-items:flex-start;max-inline-size:none}.current-hours{position:sticky;top:var(--height);bottom:unset;width:unset;z-index:100;background-color:var(--action-0);color:var(--action-contrast);box-shadow:var(--shadow);padding:.25rem 1rem;display:flex;justify-content:space-between}.current-hours p{margin:0;display:flex;flex-wrap:wrap;flex:1}.current-hours p+p{justify-content:flex-end}.current-hours a{color:var(--action-contrast)}.current-hours a:hover{color:var(--action-200)}.current-hours b{margin-right:.25rem}.find-us{display:flex;align-items:center;gap:0 .5rem}.find-us a{display:flex;padding:.25rem 1rem;border:1px solid var(--action-contrast);border-radius:var(--innerRadius)}.find-us a:hover{background-color:var(--base);color:var(--contrast);border-color:var(--contrast)}nav.menu{--justify:flex-start}nav.menu a{padding:.5rem .66rem}nav.tabs{--gap:0;--wrap:nowrap;padding-bottom:2px;z-index:var(--z-6);position:fixed;bottom:var(--height);left:var(--doubleHeight);right:var(--doubleHeight)}nav.term-navigation:has([hidden]){display:none}
\ No newline at end of file
+nav{--py:.25rem;--px:1rem;max-width:100%;font-family:var(--heading)}nav,nav a,nav li,nav ol,nav ul{height:var(--height);display:flex;justify-content:var(--justify);align-items:var(--align);gap:var(--gap);flex-wrap:var(--wrap);flex-direction:var(--dir)}ul.socials{--w:1.2em;--height:fit-content;gap:.5rem;display:flex;justify-content:center;align-items:center;flex-wrap:nowrap;flex-direction:row;overflow:auto hidden;touch-action:pan-x;list-style:none}nav:not(:has(>ul)),nav>ul{--justify:flex-start;--align:center;--wrap:nowrap;--w:1em;--dir:row;position:relative;overflow:auto hidden;touch-action:pan-x}nav a{padding:0 var(--px);white-space:nowrap;text-transform:uppercase}nav .current a,nav a.current,nav a:focus,nav a:focus:visited,nav a:hover,nav a:hover:visited{background-color:var(--action-0);color:var(--action-contrast);transition:background-color var(--transition-base),color var(--transition-base)}nav ol,nav ul{list-style:none;margin:0;padding:0}.has-submenu button:hover,nav a:hover{background-color:var(--action-0);color:var(--action-contrast)}.has-submenu button{height:var(--height);width:var(--height);padding:0;border:2px solid var(--base);color:var(--contrast);border-radius:0}.toggle svg{transform:rotate(0);transition:transform var(--timing) var(--function);transition-property:transform,background-color,color}.has-submenu.open>button:not(.notifications,.quick-help) svg,.has-submenu:hover>button:not(.notifications,.quick-help) svg{transform:rotate(900deg)}ul.submenu{--dir:column;--wrap:nowrap;--gap:0;position:absolute;top:100%;left:0;max-height:0;transform:scaleY(0);transform-origin:top;width:max-content;min-width:100%;background-color:var(--overlay-light);border:2px solid var(--overlay-light);transition:all var(--timing) var(--function);box-shadow:var(--shadow-none)}.always ul.submenu{position:relative;top:0;left:0}.submenu li{background-color:var(--overlay-heavy);border:1px solid var(--base-50)}.submenu li:hover{--c:var(--action-rgb);background-color:var(--overlay-heavy)}.submenu a:hover{background-color:transparent}.wp-site-blocks>header ul.submenu{right:0;left:auto}.has-submenu.open>ul.submenu,.has-submenu:hover>ul.submenu{transform:scaleY(1);max-height:1000%;box-shadow:var(--shadow)}nav.fixed.bottom,nav.on-this-page{--dir:row;--gap:0;width:calc(100% - var(--height));left:0;bottom:0;position:fixed;box-shadow:var(--shadow);z-index:var(--zz-top)}nav.fixed.bottom ul{width:100%;--justify:space-between;background-color:var(--base);padding:0 .25rem}nav.fixed a,nav.fixed li{--justify:center;width:100%}nav.fixed.bottom a,nav.fixed.bottom a:visited{color:var(--contrast);font-size:var(--small);padding:0}@media (min-width:768px){nav.fixed.bottom a,nav.fixed.bottom a:visited{font-size:var(--medium)}}nav.fixed.bottom a:focus,nav.fixed.bottom a:focus:visited,nav.fixed.bottom a:hover,nav.fixed.bottom a:hover:visited{color:var(--action-contrast)}.fixed.bottom li{flex:1}nav.always a{padding:0;--justify:center}nav.always .socials{width:100%}nav.always .socials li{width:100%}nav.always li{gap:0;--justify:flex-start}nav.always>ul>li>a{width:80%}nav.always .submenu{width:80%;min-width:80%;box-shadow:none!important;border:2px solid var(--action-0);background-color:rgba(var(--contrast-rgb),var(--rgb-subtle))}nav.always .submenu li{background-color:var(--overlay-light)}nav.always .has-submenu>a,nav.fixed .has-submenu>a{width:80%}.has-submenu>button{width:20%}nav#breadcrumbs{--height:1.5em;--w:20px;width:fit-content;max-width:var(--full);position:absolute;background-color:var(--overlay-medium);font-size:var(--small);padding:.125em;overflow:visible;--gap:0}nav#breadcrumbs li+li::before{content:'/';color:var(--contrast-200)}nav#breadcrumbs li:last-of-type{margin-right:.5em}nav#breadcrumbs a,nav#breadcrumbs span{padding:0 .125rem;white-space:nowrap;height:2em;color:var(--contrast);text-transform:none;width:max-content}nav#breadcrumbs span{display:flex;align-items:center;padding-left:.5em}nav#breadcrumbs a:focus,nav#breadcrumbs a:focus:visited,nav#breadcrumbs a:hover,nav#breadcrumbs a:hover:visited{background-color:transparent;color:var(--action-0)}nav#breadcrumbs a:has(.icon){width:2rem}nav.always{z-index:var(--z-above);position:fixed;width:var(--height);bottom:0;right:0}nav.always.open{width:100vw;height:100vh;padding-bottom:var(--offHeight);background-color:var(--overlay-heavy);backdrop-filter:blur(5px);justify-content:flex-end;flex-direction:column;z-index:var(--z-above)}nav.always>ul{--dir:column;--wrap:nowrap;--justify:flex-start;--align:center;--gap:0;position:relative;right:-300vw;padding:1rem 0 0;width:100vw;height:fit-content;max-height:100%;overflow:hidden auto;transition:right var(--transition-base)}nav.always.open>ul{right:0;transition:right var(--transition-base)}nav.always li{max-inline-size:none;width:100%;height:fit-content;--dir:row;--wrap:wrap}nav.always a{--py:1rem;width:100%;min-height:var(--height)}nav.always>button{position:fixed;bottom:0;right:0;width:var(--height);height:var(--height);border-radius:0;background-color:var(--base);color:var(--contrast);transition:width var(--timing) var(--function);transition-property:width,background-color;box-shadow:var(--shadow)}nav.always>button:hover{background-color:var(--action-0);color:var(--action-contrast)}nav.always.open>button{--c:var(--action-rgb);z-index:1000000;width:100%;background-color:var(--overlay-heavy);color:var(--contrast);backdrop-filter:blur(5px)}nav.always.open>button:focus,nav.always.open>button:hover{background-color:var(--action-0);color:var(--action-contrast)}nav.always.open>button .list,nav.always>button .x{transform:scale(0);height:0;width:0;position:absolute}nav.always.open>button .x,nav.always>button .list{transform:scale(1);height:32px;width:32px}nav.always .has-submenu.open>.submenu,nav.always .has-submenu:hover>.submenu{height:max-content}nav.always .has-submenu:hover>a,nav.always .submenu>li>a:focus,nav.always .submenu>li>a:hover{background-color:var(--action-0);color:var(--action-contrast)}@media (min-width:768px){nav.always>ul{padding:var(--height) 0 0}}nav.on-this-page{--justify:space-between;max-width:none;z-index:var(--z-6);margin:0;padding:0 .5rem;background-color:var(--overlay-medium);color:var(--base-200)}body:has(nav.fixed) nav.on-this-page{bottom:var(--offHeight)}.on-this-page ul{--justify:flex-start;gap:0;width:100%}.on-this-page li:not(.has){padding:0}nav.letters li{width:100%;max-width:calc(7.69% - 2px)}.on-this-page .active a{--c:var(--action-rgb);background-color:var(--overlay-heavy);color:var(--action-contrast)}@media (min-width:768px){nav.letters li{max-width:none;width:fit-content}nav.letters a,nav.letters li:not(.has){padding:.25rem .66rem}}nav.index{--justify:flex-start;--px:0;background-color:var(--overlay-heavy)}.index ul{--justify:flex-start;width:fit-content}.index li{flex-shrink:0;transform:scaleX(0);transform-origin:right;max-width:0;overflow:hidden;transition:transform var(--timing) var(--function)}.index li.active{transform:scaleX(1);transform-origin:left;width:100%;flex-shrink:1;max-width:fit-content}@media (min-width:768px){.index li.adj{transform:scaleX(1);transform-origin:left;width:100%;flex-shrink:1;max-width:fit-content}}.index a{border-bottom:4px solid transparent}.index .active a{border-color:var(--action-0);color:var(--contrast)}.index .active a:hover,.index a:hover{background-color:var(--action-0);color:var(--action-contrast)}.index label{display:flex;color:var(--contrast);align-items:center;margin:0}.index label button{margin-left:1em}.index.open{--dir:column-reverse;height:calc(100% - 8rem);z-index:var(--z-above);width:100%;background-color:var(--overlay-heavy);backdrop-filter:blur(5px);align-items:flex-end}.index.open label{max-width:90%;margin-top:1rem;margin-right:2rem}.index.open .toggle svg{transform:rotate(45deg)}.index.open ul{--dir:column;--justify:flex-end;height:100%;max-width:100%;width:100%}.index.open li{background-color:transparent;max-width:100%!important;width:100%;height:var(--height);transform:scaleX(1);flex-shrink:1;overflow:visible}.index.open a{--justify:flex-end;background-color:transparent;padding:0 2rem 0 0}.condensed{--dir:row;--wrap:wrap;--height:1.2em;--py:.25rem;--px:.25rem;height:fit-content}.condensed>ul{--wrap:wrap;height:fit-content}.condensed ul{--justify:center;--gap:0}.condensed li{width:fit-content}.condensed li+li::before{content:'·';display:block;padding:0 .25em}nav.condensed a{text-transform:none;white-space:nowrap;border-bottom:2px solid transparent}.condensed a:focus,.condensed a:focus:visited,.condensed a:hover,.condensed a:hover:visited{border-color:var(--action-0)}.dashboard-nav{width:100%;--dir:row;--justify:flex-start;--wrap:nowrap}.wp-site-blocks>header,body>header{--dir:row;position:sticky;top:0;left:0;right:0;height:var(--height);width:100vw;display:flex;justify-content:space-between;align-items:center;padding:0 .5rem;background-color:var(--base);z-index:var(--zz-top);box-shadow:var(--shadow);border-bottom:1px solid var(--action-0)}body>header{justify-content:space-between}header a[rel=home],header>img{position:absolute;width:var(--offHeight);left:calc(50% - (var(--offHeight)/ 2))}header .title{--w:5em;margin:0;position:absolute;width:100%;height:100%;display:flex;justify-content:center;align-items:flex-start;max-inline-size:none}.current-hours{position:sticky;top:var(--height);bottom:unset;width:unset;z-index:100;background-color:var(--action-0);color:var(--action-contrast);box-shadow:var(--shadow);padding:.25rem 1rem;display:flex;justify-content:space-between}.current-hours p{margin:0;display:flex;flex-wrap:wrap;flex:1}.current-hours p+p{justify-content:flex-end}.current-hours a{color:var(--action-contrast)}.current-hours a:hover{color:var(--action-200)}.current-hours b{margin-right:.25rem}.find-us{display:flex;align-items:center;gap:0 .5rem}.find-us a{display:flex;padding:.25rem 1rem;border:1px solid var(--action-contrast);border-radius:var(--innerRadius)}.find-us a:hover{background-color:var(--base);color:var(--contrast);border-color:var(--contrast)}nav.menu{--justify:flex-start}nav.menu a{padding:.5rem .66rem}nav.tabs{--gap:0;--wrap:nowrap;padding-bottom:2px;z-index:var(--z-6);position:fixed;bottom:var(--height);left:var(--doubleHeight);right:var(--doubleHeight)}nav.term-navigation:has([hidden]){display:none}ul.socials a{padding:.5rem}ul.socials a .icon{margin:0}
\ No newline at end of file
diff --git a/assets/js/concise/DataStore.js b/assets/js/concise/DataStore.js
index 946bf0e..fe9b22e 100644
--- a/assets/js/concise/DataStore.js
+++ b/assets/js/concise/DataStore.js
@@ -6,6 +6,29 @@
* - Built-in ETag and If-Modified-Since support
* - Automatic DOM reference stripping
* - TTL-based cache invalidation
+ *
+ * All notifications:
+ *
+ this.store.subscribe((event, data) => {
+ switch (event) {
+ case 'data-loaded':
+ break;
+ case 'item-saved':
+ break;
+ case 'items-saved':
+ break;
+ case 'item-deleted':
+ break;
+ case 'data-cleared':
+ break;
+ case 'filters-changed':
+ break;
+ case 'filters-cleared':
+ break;
+ case 'cache-cleared':
+ break;
+ }
+ });
*/
class DataStore {
constructor(config = {}) {
@@ -20,9 +43,13 @@
// API configuration
endpoint: null,
+ saveToServer: false,
apiBase: jvbSettings.api,
headers: {},
filters: {},
+ required: null, //any required filters before fetching
+ icon: null,
+ getBlobs: null,
// Cache configuration
TTL: 3600000, // 1 hour default
@@ -43,6 +70,8 @@
this.db = null;
this.data = new Map();
this.cache = new Map();
+ this.isFetching = false;
+ this.pendingFetch = null;
this.httpHeaders = new Map();
this.subscribers = new Set();
this.currentRequest = null;
@@ -118,9 +147,28 @@
}
};
- request.onsuccess = (e) => {
+ request.onsuccess = async (e) => {
this.db = e.target.result;
- this.loadFromDB();
+
+ // Load cache and headers BEFORE fetching (only if stores exist)
+ const loadTasks = [this.loadFromDB()];
+
+ if (this.db.objectStoreNames.contains('cache')) {
+ loadTasks.push(this.loadCache());
+ }
+
+ if (this.config.useHttpCaching && this.db.objectStoreNames.contains('headers')) {
+ loadTasks.push(this.loadHeaders());
+ }
+
+ await Promise.all(loadTasks);
+
+ this.notify('db-init');
+
+ // Now fetch if needed (cache might already have data)
+ if (this.config.endpoint) {
+ this.fetch();
+ }
};
request.onerror = (e) => {
@@ -137,29 +185,33 @@
async loadFromDB() {
if (!this.db) return;
- const loadPromises = [
- this.loadData()
- ];
+ return new Promise(async (resolve, reject) => {
+ const tx = this.db.transaction([this.config.storeName], 'readonly');
+ const store = tx.objectStore(this.config.storeName);
+ const request = store.getAll();
- if (this.config.endpoint) {
- loadPromises.push(this.loadCache());
- }
+ request.onsuccess = async (e) => {
+ const items = e.target.result;
- if (this.config.useHttpCaching) {
- loadPromises.push(this.loadHeaders());
- }
+ // Restore FormData for ALL items on startup
+ for (const item of items) {
+ if (item.data?._isFormData && this.config.getBlobs) {
+ item.data = await this.objectToFormData(item.data);
+ }
+ const key = this.getItemKey(item);
+ this.data.set(key, item);
+ }
- try {
- await Promise.all(loadPromises);
- this.notify('data-loaded', {
- count: this.data.size,
- store: this.config.storeName
- });
- } catch (error) {
- console.error('Error loading from DB:', error);
- }
+ this.notify('data-loaded', { count: items.length });
+ resolve(items);
+ };
+
+ request.onerror = (e) => reject(e);
+ });
}
+
+
/**
* Load main data from IndexedDB
*/
@@ -234,9 +286,13 @@
return true;
}
- // Check key names
+ // Check key names - use exact match or word boundaries
const domKeys = ['element', 'el', 'dom', 'node', 'ui', 'container', 'wrapper'];
- if (domKeys.some(k => key.toLowerCase().includes(k))) {
+ const lowerKey = key.toLowerCase();
+
+ // Only match if it's the exact key OR starts/ends with the pattern
+ if (domKeys.includes(lowerKey) ||
+ domKeys.some(k => lowerKey === k || lowerKey.startsWith(k + '_') || lowerKey.endsWith('_' + k))) {
return true;
}
@@ -265,27 +321,102 @@
/**
* Save a single item
*/
+ /**
+ * Save a single item
+ */
async save(item) {
const key = this.getItemKey(item);
- // Strip DOM references if configured
- const cleaned = this.config.stripDOMReferences
- ? this.stripDOMReferences(item)
- : item;
+ // Keep ORIGINAL item in memory (with FormData intact)
+ this.data.set(key, item); // ← Store original
- // Store in memory
- this.data.set(key, cleaned);
+ // Create cleaned version ONLY for IndexedDB
+ let cleaned = { ...item };
+ if (cleaned.data instanceof FormData) {
+ cleaned.data = this.formDataToObject(cleaned.data);
+ }
- // Persist to IndexedDB
+ if (this.config.stripDOMReferences) {
+ cleaned = this.stripDOMReferences(cleaned);
+ }
+
+ // Persist cleaned version to IndexedDB
await this.saveToDB(cleaned);
- // Notify subscribers
+ if(this.config.endpoint){
+ this.saveToServer(item);
+ }
+
this.notify('item-saved', { item: cleaned, key });
return cleaned;
}
/**
+ * Convert FormData to plain object for storage
+ */
+ formDataToObject(formData) {
+ const obj = {
+ _isFormData: true, // Flag to reconstruct later
+ entries: {}
+ };
+
+ for (const [key, value] of formData.entries()) {
+ // Skip File/Blob objects - they're stored separately
+ if (value instanceof File || value instanceof Blob) {
+ continue;
+ }
+
+ // Handle multiple values for same key
+ if (obj.entries[key]) {
+ if (!Array.isArray(obj.entries[key])) {
+ obj.entries[key] = [obj.entries[key]];
+ }
+ obj.entries[key].push(value);
+ } else {
+ obj.entries[key] = value;
+ }
+ }
+
+ return obj;
+ }
+
+ /**
+ * Convert stored object back to FormData
+ */
+ async objectToFormData(obj) {
+ if (!obj._isFormData) return obj;
+
+ const formData = new FormData();
+
+ for (const [key, value] of Object.entries(obj.entries)) {
+ if (Array.isArray(value)) {
+ value.forEach(v => formData.append(key, v));
+ } else {
+ formData.append(key, value);
+ }
+ }
+ // Restore files from external blob store (UploadManager)
+ if (this.config.getBlobs && obj.entries.upload_ids) {
+ const uploadIds = JSON.parse(obj.entries.upload_ids);
+ const blobs = await this.config.getBlobs(uploadIds); // ← Await here
+
+ for (const blobData of blobs) {
+ if (blobData) {
+ const file = new File(
+ [blobData.data],
+ blobData.name,
+ { type: blobData.type, lastModified: blobData.lastModified }
+ );
+ formData.append('files[]', file);
+ }
+ }
+ }
+
+ return formData;
+ }
+
+ /**
* Save item to IndexedDB
*/
async saveToDB(item) {
@@ -329,7 +460,7 @@
* Get a single item
*/
get(key) {
- return this.data.get(key);
+ return this.data.get(key); // ← Returns original with FormData
}
/**
@@ -362,7 +493,13 @@
const tx = this.db.transaction(['blobs'], 'readwrite');
const store = tx.objectStore('blobs');
- await store.put({ key, data: blob, type: blob.type, name: blob.name });
+ await store.put({
+ uploadId: key, // Match keyPath
+ data: blob,
+ type: blob.type,
+ name: blob.name,
+ lastModified: blob.lastModified || Date.now()
+ });
}
async getBlob(key) {
@@ -416,15 +553,44 @@
headers = {},
} = options;
+ if (this.config.required && this.filters[this.config.required] === ''){
+ console.log(this.config.storeName+ ': Not fetch as we don\'t have the required items');
+ return;
+ }
+
+ // PREVENT CONCURRENT FETCHES FOR SAME DATA
+ const cacheKey = this.generateCacheKey(filters);
+ console.log('CacheKey: ', cacheKey);
+
+ // If already fetching this exact query, return a promise that resolves when done
+ if (this.isFetching && this.currentCacheKey === cacheKey) {
+ return new Promise((resolve) => {
+ // Store multiple waiting promises if needed
+ if (!this.pendingFetches) {
+ this.pendingFetches = [];
+ }
+ this.pendingFetches.push(resolve);
+ });
+ }
+
+ this.isFetching = true;
+ this.currentCacheKey = cacheKey;
+ let fetchResult = null; // Capture result for pending fetches
+
if (this.config.showLoading) {
this.setLoading(true);
}
- const cacheKey = this.generateCacheKey(filters);
-
//Check Cached data
const cachedData = this.cache.get(cacheKey);
+ console.log('Cached Data: ', cachedData);
if (cachedData && this.isCacheValid(cachedData)) {
+ console.log('Returning cached data: ');
+ this.isFetching = false;
+ this.currentCacheKey = null;
+ if (this.config.showLoading) {
+ this.setLoading(false);
+ }
return cachedData.data;
}
@@ -447,7 +613,6 @@
}
// Build URL with filters
-
const cleanedFilters = this.cleanFilters(filters);
const params = new URLSearchParams(cleanedFilters);
const url = `${this.config.apiBase}${this.config.endpoint}${params.toString() ? '?' + params : ''}`;
@@ -462,7 +627,12 @@
if (response.status === 304 && cachedData) {
// Update timestamp but keep existing data
cachedData.timestamp = Date.now();
+ cachedData.fromCache = true;
+ cachedData.isError = false;
this.saveCache(cacheKey, cachedData);
+ console.log(this.config.storeName+' Data loaded from cache');
+ this.notify('data-loaded', cachedData);
+ fetchResult = cachedData.data;
return cachedData.data;
}
@@ -485,17 +655,26 @@
endpoint: this.config.endpoint,
filters: filters
};
+ console.log(this.config.storeName + 'Fetched fresh from server');
this.cache.set(cacheKey, cacheEntry);
this.saveCache(cacheKey, cacheEntry);
- // Process and store items
- if (Array.isArray(data)) {
- await this.saveMany(data);
- } else if (data.items) {
- await this.saveMany(data.items);
- }
+ let items = (Array.isArray(data)) ? data : data.items;
+ await this.saveMany(items);
+ this.notify('data-loaded', {
+ data: {
+ items: items,
+ ...data
+ },
+ count: items.length,
+ filters: filters,
+ fromCache: false,
+ isError: false
+ });
+
+ fetchResult = data;
return data;
} catch (error) {
@@ -504,6 +683,9 @@
// Return cached data if available, even if expired
if (cachedData) {
console.warn('Using stale cache due to fetch error');
+ cachedData.isError = true;
+ this.notify('data-loaded', cachedData);
+ fetchResult = cachedData.data;
return cachedData.data;
}
@@ -512,9 +694,72 @@
if (this.config.showLoading) {
this.setLoading(false);
}
+
+ this.isFetching = false;
+ this.currentCacheKey = null;
+
+ // Resolve any pending fetches that were waiting
+ if (this.pendingFetches && this.pendingFetches.length > 0) {
+ this.pendingFetches.forEach(resolve => resolve(fetchResult));
+ this.pendingFetches = [];
+ }
}
}
+ /**
+ * Fetch data from server with HTTP caching
+ */
+ async saveToServer(item) {
+ if (!this.config.saveToServer || !jvbSettings.currentUser) {
+ return;
+ }
+ if (!this.config.endpoint && this.config.saveToServer) {
+ throw new Error('No endpoint configured for saving to server');
+ }
+
+ let requestBody;
+ let headers = this.config.headers;
+ headers['X-WP-Nonce'] = jvbSettings.nonce;
+ if (item instanceof FormData) {
+ item.append('user', jvbSettings.currentUser);
+ requestBody = item;
+
+ // console.log('Sending formData: ');
+ // for (const pair of requestBody.entries()) {
+ // console.log(pair[0], pair[1]);
+ // }
+ } else {
+ requestBody = JSON.stringify({
+ ...item,
+ user: jvbSettings.currentUser
+ });
+ // console.log('Sending data: ', {
+ // ...operation.data,
+ // id: operation.id,
+ // user: this.user
+ // });
+
+ headers['Content-Type'] = 'application/json';
+ }
+
+ const response = await fetch(
+ `${this.config.apiBase}${this.config.endpoint}`,
+ {
+ method: 'POST',
+ headers: headers,
+ body: requestBody
+ }
+ );
+
+ const result = await response.json();
+ this.notify(
+ 'saved-to-server',
+ {
+ success: result.ok && result.success
+ }
+ );
+ }
+
cleanFilters(filters) {
const cleaned = {};
Object.entries(filters).forEach(([key, value]) => {
@@ -563,8 +808,9 @@
this.filters = {};
}
const oldValue = this.filters[key];
-
- if (value === '' || value === null || value === undefined) {
+ if (oldValue === value) {
+ return;
+ }else if (value === '' || value === null || value === undefined) {
delete this.filters[key];
} else {
this.filters[key] = value;
@@ -577,10 +823,15 @@
// Auto-fetch if endpoint is configured
if (this.config.endpoint) {
- this.fetch();
+ window.debouncer.schedule(
+ this.config.endpoint,
+ this.fetch.bind(this),
+ 100
+ );
}
}
+
/**
* Remove a filter
*/
@@ -596,7 +847,11 @@
// Auto-fetch if endpoint is configured
if (this.config.endpoint) {
- this.fetch();
+ window.debouncer.schedule(
+ this.config.endpoint,
+ this.fetch.bind(this),
+ 100
+ );
}
}
}
@@ -623,10 +878,29 @@
/**
* Set multiple filters at once
*/
- setFilters(filters) {
+ async setFilters(filters) {
+ const hasChanges = Object.keys(filters).some(
+ key => this.filters[key] !== filters[key]
+ );
+
+ if (!hasChanges) {
+ return;
+ }
+
this.filters = { ...this.filters, ...filters };
- if (this.config.autoFetch !== false) {
- return this.fetch(this.filters);
+
+ this.notify('filters-changed', {
+ filters: this.filters,
+ changed: filters,
+ });
+
+ // Only fetch if endpoint configured
+ if (this.config.endpoint) {
+ window.debouncer.schedule(
+ this.config.endpoint,
+ this.fetch.bind(this),
+ 100
+ );
}
}
@@ -653,7 +927,7 @@
this.httpHeaders.set(key, headers);
- if (this.db) {
+ if (this.db && this.db.objectStoreNames.contains('headers')) {
const tx = this.db.transaction(['headers'], 'readwrite');
const store = tx.objectStore('headers');
store.put(headers);
@@ -664,7 +938,7 @@
* Save cache entry to IndexedDB
*/
async saveCache(key, data) {
- if (!this.db) return;
+ if (!this.db || !this.db.objectStoreNames.contains('cache')) return;
const tx = this.db.transaction(['cache'], 'readwrite');
const store = tx.objectStore('cache');
@@ -786,6 +1060,7 @@
setLoading(on) {
+ console.log('Setting Loading ' + (on) ? 'on' : 'off' + ' from '.this.config.storeName);
this.body.classList.toggle('loading', on);
if (on) {
this.loading.showModal();
diff --git a/assets/js/concise/FormController.js b/assets/js/concise/FormController.js
index cdae988..9a3b075 100644
--- a/assets/js/concise/FormController.js
+++ b/assets/js/concise/FormController.js
@@ -197,9 +197,9 @@
element: formElement,
id: formId,
options: {
- autoSave: true,
+ autoSave: 'autosave' in formElement.dataset,
saveDelay: this.autoSaveDefaults.delay,
- endpoint: formElement.dataset.save,
+ endpoint: formElement.dataset.save??'',
cache: true,
...options
},
@@ -255,7 +255,7 @@
// Scan for existing selector fields
if (window.jvbSelector) {
- window.jvbSelector.scanExistingFields();
+ window.jvbSelector.scanExistingFields(form);
}
}
@@ -746,6 +746,9 @@
}
handleChange(event) {
+ if (event.target.closest('[data-ignore]')) {
+ return;
+ }
if (this.subscribers.size > 0) {
const target = event.target;
const form = target.form || target.closest('form');
@@ -780,6 +783,9 @@
}
handleBlur(e) {
+ if (e.target.closest('[data-ignore]')) {
+ return;
+ }
const target = e.target;
const form = target.form || target.closest('form');
@@ -813,6 +819,9 @@
}
handleInput(e) {
+ if (e.target.closest('[data-ignore]') || ! e.target.closest('form')) {
+ return;
+ }
const input = e.target.closest('input, textarea, select');
if (!input) return;
@@ -2109,4 +2118,5 @@
document.addEventListener('DOMContentLoaded', () => {
window.jvbForm = FormController;
+ console.log('FormController in window');
});
diff --git a/assets/js/concise/PopulateForm.js b/assets/js/concise/PopulateForm.js
index 838e5b6..4a56a10 100644
--- a/assets/js/concise/PopulateForm.js
+++ b/assets/js/concise/PopulateForm.js
@@ -3,6 +3,10 @@
**********************************************/
class PopulateForm {
constructor(form, fieldData = {}, imageData = {}, options = {}) {
+ console.log('Populating field... ', form);
+ console.log('fieldData: ', fieldData);
+ console.log('imageData: ', imageData);
+ console.log('options: ', options);
for (let [fieldName, fieldValue] of Object.entries(fieldData)) {
let wrapper = form.querySelector(`[data-field="${fieldName}"]`);
if (wrapper) {
@@ -483,6 +487,10 @@
* Populate repeater fields
*/
populateRepeaterField(fieldWrapper, fieldName, fieldValue, options = {}) {
+ console.log('fieldWrapper', fieldWrapper);
+ console.log('fieldName', fieldName);
+ console.log('fieldValue', fieldValue);
+ console.log('options', options);
if (!fieldValue || !Array.isArray(fieldValue)) {
return;
}
diff --git a/assets/js/concise/Queue.js b/assets/js/concise/Queue.js
index b817cbb..d1a3e70 100644
--- a/assets/js/concise/Queue.js
+++ b/assets/js/concise/Queue.js
@@ -16,6 +16,7 @@
...config
};
this.user = jvbSettings.currentUser;
+ console.log(this.user);
this.headers = {
@@ -32,16 +33,27 @@
storeName: 'operations',
keyPath: 'id',
endpoint: this.config.endpoint,
- TTL: Infinity, //Queue data doesn't expire,
+ TTL: Infinity,
indexes: [
{name: 'status', keyPath: 'status'},
{name: 'type', keyPath: 'type'},
],
showLoading: false,
+ getBlobs: async (ids) => {
+ if (window.jvbUploadBlobs) {
+ if (!Array.isArray(ids) && typeof ids === 'string') {
+ ids = [ids];
+ }
+ // Get individual blobs (not all items)
+ const blobs = await Promise.all(
+ ids.map(id => window.jvbUploadBlobs.getBlob(id))
+ );
+ return blobs.filter(Boolean); // Remove nulls
+ }
+ return null;
+ }
});
- this.queue = new Map();
-
this.classes = [
'offline',
'synced',
@@ -96,27 +108,30 @@
this.store.subscribe((event, data) => {
switch (event) {
- case 'data-fetched':
- case 'data-cached':
- this.updateOperationsFromServer(data.data.items);
+ case 'data-loaded':
+ // Initial load from IndexedDB
+ const incomplete = this.getOperationsByStatus(['completed', 'failed_permanent'], false);
+ if (incomplete.length > 0) {
+ this.startPolling();
+ }
+ this.updateUI();
break;
- case 'items-updated':
- this.updateOperationsFromServer(data.items);
- break;
- case 'item-stored':
- this.updateOperationsFromServer([data])
+ case 'item-saved':
+ if (this.hasQueuedOperations()) {
+ this.startPolling();
+ }
+ default:
+ this.updateUI();
break;
}
});
-
- this.store.fetch();
this.notify('queue-initialized', {operations: incomplete});
}
/**
*
* @param {object} operation
- * @param {string} operation.endpoint The endpoint, excluding the apiBase
+ * @param {string} operatio n.endpoint The endpoint, excluding the apiBase
* @param {object} operation.data The data to save
* @param {boolean} operation.canMerge Whether data can merge
* @param {string} operation.title The title of the operation for the Queue Panel
@@ -152,7 +167,7 @@
return null;
}
- const existingOps = Array.from(this.queue.values()).filter(op=>
+ const existingOps = Array.from(this.store.data.values()).filter(op=>
op.status === 'queued' &&
op.endpoint === item.endpoint &&
op.canMerge
@@ -185,32 +200,26 @@
}
setQueue(item) {
- this.queue.set(item.id, item);
- this.store.save(item.id, item);
+ this.store.save(item); // Remove first parameter
}
updateOperationStatus(itemID, status) {
- let item = this.queue.get(itemID);
+ let item = this.store.get(itemID);
if (!item){
return;
}
item.status = status;
+
this.notify('operation-status', item);
this.updateOperationUI(item);
}
getQueue(itemID) {
- if (this.queue.has(itemID)) {
- return this.queue.get(itemID);
- }
- return this.store.getItem(itemID);
+ return this.store.get(itemID);
}
clearQueue(itemID) {
- if (this.queue.has(itemID)) {
- this.queue.delete(itemID);
- }
- this.store.clearItem(itemID);
+ this.store.delete(itemID);
}
startActivityTracking() {
@@ -287,10 +296,13 @@
//update to uploading
this.updateOperationStatus(operation.id, 'uploading');
+ // Get fresh copy from store to restore FormData
+ operation = this.getQueue(operation.id);
+
//build request
const url = `${this.config.apiBase}${operation.endpoint}`;
let requestBody;
-
+ console.log(operation.data);
if (operation.data instanceof FormData) {
operation.data.append('id', operation.id);
operation.data.append('user', this.user);
@@ -299,6 +311,11 @@
// for (const pair of requestBody.entries()) {
// console.log(pair[0], pair[1]);
// }
+
+ console.log('Sending to server:');
+ for (var [key, value] of requestBody.entries()) {
+ console.log(key, value);
+ }
} else {
requestBody = JSON.stringify({
...operation.data,
@@ -313,6 +330,8 @@
operation.headers['Content-Type'] = 'application/json';
}
+
+
const response = await fetch(url, {
method: operation.method,
headers: operation.headers,
@@ -384,74 +403,23 @@
startPolling() {
if (this.isPolling) return;
+
this.isPolling = true;
- this.pollServer();
- this.pollTimer = setInterval(() => {
- this.pollServer();
- }, this.config.pollInterval);
-
- this.updateCountdown();
- }
-
- pollServer(force = false) {
- const operations = this.getOperationsByStatus(['pending', 'processing', 'uploading']);
-
- if (operations.length === 0 && !force) {
- this.stopPolling();
- return;
- }
this.updateStatusPanel('pending');
- try {
- // const operationIds = operations.map(op => op.id);
- // this.store.setFilter('operation_ids', operationIds.join(','));
- this.store.fetch();
- } catch (error) {
- console.error('Polling error:', error);
- } finally {
- this.updateStatusPanel();
- }
- }
+ this.pollTimer = setInterval(async () => {
+ try {
+ await this.store.fetch(); // Fetches from server, updates store.data
- async updateOperationsFromServer(serverOperations) {
- let hasChanges = false;
- const processedIds = new Set();
- for (const serverOp of serverOperations) {
- let operation = (this.queue.has(serverOp.id)) ? this.queue.get(serverOp.id) : {};
- processedIds.add(serverOp.id);
- if (serverOp.status !== operation.status) {
- operation = {
- ... operation,
- ... serverOp
- };
- // Update in DataStore
- this.queue.set(operation.id, operation);
-
- // Update UI for this operation
- this.updateOperationStatus(operation.id, operation.status);
+ const incomplete = this.getOperationsByStatus(['completed', 'failed_permanent'], false);
+ if (incomplete.length === 0) {
+ this.stopPolling();
+ this.updateStatusPanel('synced');
+ }
+ } catch (error) {
+ console.error('Polling error:', error);
}
- }
-
- // Clean up operations that were completed/dismissed on server
- const localOps = this.getOperationsByStatus(['pending', 'processing', 'uploading']);
- for (const localOp of localOps) {
- if (!processedIds.has(localOp.id)) {
- localOp.status = 'completed';
- localOp.completedAt = Date.now();
- this.setQueue(localOp);
- hasChanges = true;
- this.updateOperationStatus(localOp.id, localOp.status);
- }
- }
-
- // Check if all operations are completed
- const pendingOps = this.getOperationsByStatus(['pending', 'processing', 'uploading']);
-
- if (pendingOps.length === 0) {
- this.stopPolling();
- }
-
- this.updateUI();
+ }, this.config.pollInterval);
}
stopPolling() {
@@ -602,8 +570,11 @@
window.addEventListener('beforeunload', this.handleBeforeUnload);
}
handleClick(e) {
+ if (!e.target.closest(this.selectors.panel, this.selectors.toggle)) {
+ return;
+ }
if (e.target.closest(this.selectors.refreshButton)) {
- this.pollServer(true);
+ this.store.fetch();
} else if (e.target.closest(this.selectors.clearButton)) {
const completedOps = this.getOperationsByStatus('completed');
if (completedOps.length > 0) {
@@ -790,14 +761,14 @@
stats[status] = 0;
});
- Array.from(this.store.items.values())
+ Array.from(this.store.data.values()) // Change items to data
.forEach(op => {
if (stats.hasOwnProperty(op.status)) {
stats[op.status]++;
}
});
- stats.total = Array.from(this.store.items.values()).length;
+ stats.total = Array.from(this.store.data.values()).length; // Change items to data
return stats;
}
@@ -986,7 +957,7 @@
}
getFilteredOperations(filter) {
- const operations = Array.from(this.store.items.values());
+ const operations = Array.from(this.store.data.values()); // Change items to data
if (filter === 'all') {
return operations;
@@ -1017,20 +988,16 @@
**************************************************************************/
getOperationsByStatus(status, include = true) {
- status = Array.isArray(status) ? status : ((status.includes(',')) ? status.split(',') : [status]);
- if (include) {
- return Array.from(this.queue.values()).filter(op =>
- status.includes(op.status)
- );
+ if (!Array.isArray(status) && typeof status === 'string') {
+ status = [status];
}
- return Array.from(this.queue.values()).filter(op =>
- !status.includes(op.status)
- );
+ return (include)
+ ? Array.from(this.store.data.values()).filter((item) => status.includes(item.status))
+ : Array.from(this.store.data.values()).filter((item) => !status.includes(item.status));
}
- hasQueuedOperations() {
- return this.queue.some(op =>
- op.status === 'queued'
- );
+ async hasQueuedOperations() {
+ const queued = await this.store.query('status', 'queued');
+ return queued.length > 0;
}
subscribe(callback) {
this.subscribers.add(callback);
diff --git a/assets/js/concise/SimpleCache.js b/assets/js/concise/SimpleCache.js
index e4c018f..30f074c 100644
--- a/assets/js/concise/SimpleCache.js
+++ b/assets/js/concise/SimpleCache.js
@@ -24,15 +24,9 @@
...config
};
- // Check if Cache API is available
- this.cacheAvailable = 'caches' in window;
-
- if(!this.cacheAvailable){
- console.warn('Browser Cache API unavailable, reverting to LocalStorage');
- }
// Initialize memory cache
- this._memoryCache = new Map();
+ this._cache = new Map();
this.subscribers = new Set();
}
@@ -41,8 +35,8 @@
* @returns {number} Number of items cleared
*/
clearMemoryCache() {
- const count = this._memoryCache.size;
- this._memoryCache.clear();
+ const count = this._cache.size;
+ this._cache.clear();
console.log(`Cleared ${count} items from memory cache`);
return count;
@@ -51,295 +45,40 @@
/**
* Get a setting value
*/
- async get(key) {
+ get(key) {
+ //Check memory cache first
+ if (this._cache.has(key)) {
+ return this._cache.get(key);
+ }
+
let cacheKey = `${this.base}_${key}`;
-
- const cachedItem = await this.getCacheItem(cacheKey);
-
- if (!cachedItem) {
+ let item;
+ try {
+ item = localStorage.getItem(cacheKey);
+ if (!item) {
+ return null;
+ }
+ item = JSON.parse(item);
+ } catch (error) {
+ console.warn('Error getting from localStorage:', error);
return null;
}
- // Deserialize data back to original type
- return this.deserializeData(cachedItem.data, cachedItem.dataType);
+ if (item) {
+ this._cache.set(key, item);
+ }
+ return item;
}
/**
* Set a setting value
*/
- async set(key, value) {
+ set(key, value) {
+ this._cache.set(key, value);
let cacheKey = `${this.base}_${key}`;
- // Serialize data with type preservation
- const serializedData = this.serializeData(value);
- const cacheItem = {
- data: serializedData.data,
- dataType: serializedData.type,
- timestamp: Date.now(),
- };
-
- await this.setCacheItem(cacheKey, cacheItem);
-
- // Notify subscribers
- this.notify('cache-saved', { key, value });
- }
-
- remove(key) {
- let cacheKey = `${this.base}_${key}`;
- //TODO: Actually remove it
- }
-
- serializeData(data) {
- // Handle null/undefined
- if (data === null || data === undefined) {
- return { data, type: 'primitive' };
- }
-
- // Handle Maps
- if (data instanceof Map) {
- return {
- data: Array.from(data.entries()),
- type: 'Map'
- };
- }
-
- // Handle Sets
- if (data instanceof Set) {
- return {
- data: Array.from(data),
- type: 'Set'
- };
- }
-
- // Handle Dates
- if (data instanceof Date) {
- return {
- data: data.toISOString(),
- type: 'Date'
- };
- }
-
- // Handle RegExp
- if (data instanceof RegExp) {
- return {
- data: { source: data.source, flags: data.flags },
- type: 'RegExp'
- };
- }
-
- // Handle Arrays (check before general objects)
- if (Array.isArray(data)) {
- // Recursively serialize array elements that might contain Maps/Sets
- return {
- data: data.map(item => this.serializeData(item)),
- type: 'Array'
- };
- }
-
- // Handle plain objects
- if (data && typeof data === 'object' && data.constructor === Object) {
- const serializedObj = {};
- for (const [key, value] of Object.entries(data)) {
- serializedObj[key] = this.serializeData(value);
- }
- return {
- data: serializedObj,
- type: 'Object'
- };
- }
-
- // Handle primitives (string, number, boolean)
- return { data, type: 'primitive' };
- }
-
- deserializeData(data, type) {
- if (!type || type === 'primitive') {
- return data;
- }
-
- switch (type) {
- case 'Map':
- return new Map(data);
-
- case 'Set':
- return new Set(data);
-
- case 'Date':
- return new Date(data);
-
- case 'RegExp':
- return new RegExp(data.source, data.flags);
-
- case 'Array':
- // Recursively deserialize array elements
- return data.map(item =>
- this.deserializeData(item.data, item.type)
- );
-
- case 'Object':
- const restoredObj = {};
- for (const [key, value] of Object.entries(data)) {
- restoredObj[key] = this.deserializeData(value.data, value.type);
- }
- return restoredObj;
-
- default:
- console.warn(`Unknown data type: ${type}, returning as-is`);
- return data;
- }
- }
- /**********************************************************
- CACHE
- **********************************************************/
- /**
- * Main method to get an item from cache
- * Tries Browser Cache API first, falls back to localStorage
- *
- * @param {string} key - Cache key
- * @returns {Promise<any>} - Cached item or null
- */
- async getCacheItem(key) {
- //Check memory cache first
- if (this._memoryCache.has(key)) {
- return this._memoryCache.get(key);
- }
-
- // Then check persistent cache
- const item = this.cacheAvailable ?
- await this.getBrowserCacheItem(key) :
- this.getLocalStorageItem(key);
-
- // Store in memory cache if found
- if (item) {
- this._memoryCache.set(key, item);
- }
-
- return item;
- }
-
- /**
- * Main method to set an item in cache
- * Uses Browser Cache API if available, falls back to localStorage
- *
- * @param {string} key - Cache key
- * @param {any} item - Item to cache
- * @returns {Promise<void>}
- */
- async setCacheItem(key, item) {
- // Always store in memory cache
- this._memoryCache.set(key, item);
-
- return this.cacheAvailable ?
- await this.setBrowserCacheItem(key, item) :
- this.setLocalStorageItem(key, item);
- }
-
- /**
- * Remove an item from cache
- *
- * @param {string} key - Cache key
- * @returns {Promise<void>}
- */
- async removeCacheItem(key) {
- // Remove from memory cache
- this._memoryCache.delete(key);
-
- return this.cacheAvailable ?
- await this.removeBrowserCacheItem(key) :
- this.removeLocalStorageItem(key);
- }
- /*********************************************************************
- BROWSER CACHE
- *********************************************************************/
- /**
- * Get item from Browser Cache API
- *
- * @param {string} key - Cache key
- * @returns {Promise<any>} - Cached item or null
- */
- async getBrowserCacheItem(key) {
try {
- const cache = await caches.open(this.config.namespace);
- const response = await cache.match(key);
-
- if (!response) {
- return null;
- }
-
- return await response.json();
- } catch (error) {
- console.warn('Error getting from Browser Cache API:', error);
- return null;
- }
- }
-
- /**
- * Set item in Browser Cache API
- *
- * @param {string} key - Cache key
- * @param {any} item - Item to cache
- * @returns {Promise<void>}
- */
- async setBrowserCacheItem(key, item) {
- try {
- const cache = await caches.open(this.config.namespace);
- const response = new Response(JSON.stringify(item), {
- headers: { 'Content-Type': 'application/json' }
- });
-
- await cache.put(key, response);
- } catch (error) {
- console.warn('Error setting in Browser Cache API:', error);
- }
- }
-
- /**
- * Remove item from Browser Cache API
- *
- * @param {string} key - Cache key
- * @returns {Promise<void>}
- */
- async removeBrowserCacheItem(key) {
- try {
- const cache = await caches.open(this.config.namespace);
- await cache.delete(key);
- } catch (error) {
- console.warn('Error removing from Browser Cache API:', error);
- }
- }
- /*************************************************************************
- LOCAL STORAGE
- *************************************************************************/
- /**
- * Get item from localStorage
- *
- * @param {string} key - Cache key
- * @returns {object|null} - Cached item or null
- */
- getLocalStorageItem(key) {
- try {
- const stored = localStorage.getItem(key);
-
- if (!stored) {
- return null;
- }
-
- return JSON.parse(stored);
- } catch (error) {
- console.warn('Error getting from localStorage:', error);
- return null;
- }
- }
-
- /**
- * Set item in localStorage
- *
- * @param {string} key - Cache key
- * @param {any} item - Item to cache
- */
- setLocalStorageItem(key, item) {
- try {
- localStorage.setItem(key, JSON.stringify(item));
+ localStorage.setItem(cacheKey, JSON.stringify(value));
} catch (error) {
// Handle quota exceeded
if (error instanceof DOMException && error.code === 22) {
@@ -353,16 +92,15 @@
console.warn('Error setting localStorage item:', error);
}
}
+
+ // Notify subscribers
+ this.notify('cache-saved', { key, value });
}
- /**
- * Remove item from localStorage
- *
- * @param {string} key - Cache key
- */
- removeLocalStorageItem(key) {
+ remove(key) {
+ let cacheKey = `${this.base}_${key}`;
try {
- localStorage.removeItem(key);
+ localStorage.removeItem(cacheKey);
} catch (error) {
console.warn('Error removing localStorage item:', error);
}
@@ -403,65 +141,20 @@
console.warn('Error cleaning up localStorage:', error);
}
}
- /************************************************************************
- CLEANUP
- ************************************************************************/
- /**
- * Clean expired items from cache
- *
- * @returns {Promise<void>}
- */
- async cleanExpired() {
- const now = Date.now();
- const maxAge = this.config.TTL;
- if (this.cacheAvailable) {
- try {
- const cache = await caches.open(this.options.namespace);
- const keys = await cache.keys();
-
- for (const request of keys) {
- const response = await cache.match(request);
-
- try {
- const cacheItem = await response.json();
- if (now - cacheItem.timestamp > maxAge) {
- await cache.delete(request);
- }
- } catch (e) {
- // If we can't parse it, just leave it alone
- }
+ async loadFromCache() {
+ for (let i = 0; i < localStorage.length; i++) {
+ const key = localStorage.key(i);
+ // Check if key starts with this cache's base prefix
+ if (key.startsWith(`${this.base}_`)) {
+ let cleanKey = key.replace(`${this.base}_`, '');
+ try {
+ // Parse the JSON value before caching
+ const value = JSON.parse(localStorage.getItem(key));
+ this._cache.set(cleanKey, value);
+ } catch (error) {
+ console.warn(`Failed to parse cached value for ${key}:`, error);
}
- } catch (error) {
- console.warn('Error cleaning browser cache:', error);
- }
- } else {
- // Clean localStorage
- try {
- for (let i = 0; i < localStorage.length; i++) {
- const key = localStorage.key(i);
-
- if (key && key.startsWith(this.options.namespace)) {
- try {
- const item = JSON.parse(localStorage.getItem(key));
-
- if (now - item.timestamp > maxAge) {
- localStorage.removeItem(key);
- }
- } catch (e) {
- // Skip invalid items
- }
- }
- }
- } catch (error) {
- console.warn('Error cleaning localStorage cache:', error);
- }
- }
-
- // Clean memory cache
- for (const [key, item] of this._memoryCache.entries()) {
- if (now - item.timestamp > maxAge) {
- this._memoryCache.delete(key);
}
}
}
@@ -472,29 +165,12 @@
* @returns {Promise<void>}
*/
async clear() {
- // Clear memory cache
- this._memoryCache.clear();
+ this._cache.clear();
- if (this.cacheAvailable) {
- try {
- await caches.delete(this.options.namespace);
- } catch (error) {
- console.warn('Error clearing browser cache:', error);
- }
- }
-
- // Also clear localStorage in case we've used it as fallback
- this.clearLocalStorage();
- }
-
- /**
- * Clear just localStorage cache
- */
- clearLocalStorage() {
try {
for (let i = localStorage.length - 1; i >= 0; i--) {
const key = localStorage.key(i);
- if (key && key.startsWith(this.options.namespace)) {
+ if (key && key.startsWith(this.config.namespace)) {
localStorage.removeItem(key);
}
}
@@ -502,7 +178,6 @@
console.warn('Error clearing localStorage cache:', error);
}
}
- /************************** OLD **********************************/
/**
* Subscribe to setting changes
diff --git a/assets/js/concise/TaxonomySelector.js b/assets/js/concise/TaxonomySelector.js
index b571e2c..3262ea8 100644
--- a/assets/js/concise/TaxonomySelector.js
+++ b/assets/js/concise/TaxonomySelector.js
@@ -8,10 +8,15 @@
this.error = window.jvbError;
this.index = -1;
+ this.hasAutocomplete = false;
+ this.isInitializing = true;
+ this.taxonomiesToFetch = new Set();
+
this.store = new window.jvbStore({
name: `taxonomies`,
storeName: `terms`,
keyPath: 'id',
+ showLoading: false,
indexes: [
{name: 'taxonomy', keyPath: 'taxonomy'},
{name: 'parent', keyPath: 'parent'},
@@ -25,7 +30,8 @@
page: 1,
search: '',
parent: 0
- }
+ },
+ required: 'taxonomy'
});
// Central field management
@@ -44,6 +50,8 @@
// Search debouncing
this.searchHandler = null;
+ this.autocompleteHandler = null;
+ this.isAutocompleteActive = false;
this.init();
}
@@ -57,33 +65,45 @@
this.initGlobalListeners();
this.store.subscribe(this.handleStoreEvent.bind(this));
+ // Complete initialization
+ this.isInitializing = false;
+ this.batchFetchTaxonomies();
}
/**
* Handle DataStore events
*/
- handleStoreEvent(taxonomy, event, data) {
- // Only process events for the active taxonomy in modal
- if (this.activeStore && this.activeStore.config.name === `tax_${taxonomy}`) {
- switch (event) {
- case 'items-loaded':
- case 'data-fetched':
- case 'data-cached':
- case 'stale-cache-used':
+ handleStoreEvent(event, data) {
+ switch (event) {
+ case 'data-loaded':
+ // Only render if modal is open OR if it's an autocomplete request
+ if (this.modal?.open) {
this.handleTermsLoaded(data);
- break;
- case 'fetch-error':
- this.handleFetchError(data.error);
- break;
- case 'filters-changed':
- // Could trigger UI updates for active filters
- break;
- }
- }
+ }
+ // Handle autocomplete results
+ if (this.isAutocompleteActive && this.activeField) {
+ const field = this.fields.get(this.activeField);
+ const terms = data.data?.items || [];
+ const query = data.filters?.search || '';
+ this.showAutocompleteResults(field, terms, query);
+ this.isAutocompleteActive = false;
+ }
+ break;
- // Handle field-specific updates outside modal
- if (event === 'items-updated' || event === 'items-loaded') {
- this.updateFieldsForTaxonomy(taxonomy, data.items);
+ case 'filters-changed':
+ // Modal UI updates happen here if needed
+ if (this.modal?.open) {
+ this.showLoading();
+ }
+ break;
+
+ case 'fetch-error':
+ if (this.isAutocompleteActive && this.activeField) {
+ this.showAutocompleteError(this.activeField);
+ this.isAutocompleteActive = false;
+ }
+ this.handleFetchError(data.error);
+ break;
}
}
@@ -158,8 +178,12 @@
/**
* Scan page for existing taxonomy fields and register them
*/
- scanExistingFields() {
- const selectors = document.querySelectorAll('.field.taxonomy, .field.post');
+ scanExistingFields(container = null) {
+ if (!container) {
+ container = document.body;
+ }
+ const selectors = container.querySelectorAll('.field.taxonomy, .field.post');
+
selectors.forEach(selector => {
try {
this.registerField(selector);
@@ -196,6 +220,8 @@
name: field.dataset.field,
maxSelection: parseInt(button.dataset.max) || 0,
canSearch: 'search' in button.dataset,
+ hasAutocomplete: 'autocomplete' in button.dataset,
+ autocompleteDropdown: field.querySelector('.autocomplete-dropdown')??false,
canCreate: 'creatable' in button.dataset,
isRequired: 'required' in button.dataset,
selectedTerms: new Set(),
@@ -204,6 +230,11 @@
...options
};
+ if (!this.hasAutocomplete && config.hasAutocomplete) {
+ this.hasAutocomplete = true;
+ this.initAutocomplete();
+ }
+
// Parse initial selected values
const value = input.value.trim();
if (value !== '') {
@@ -216,7 +247,11 @@
this.fields.set(fieldId, config);
// Ensure store exists for this taxonomy
- this.store.setFilter('taxonomy', config.taxonomy);
+ if (this.isInitializing) {
+ this.taxonomiesToFetch.add(config.taxonomy);
+ } else {
+ this.store.setFilter('taxonomy', config.taxonomy);
+ }
// Initialize display for any pre-selected values
if (config.selectedTerms.size > 0) {
@@ -227,6 +262,35 @@
}
/**
+ * Batch fetch all unique taxonomies collected during init
+ */
+ async batchFetchTaxonomies() {
+ if (this.taxonomiesToFetch.size === 0) return;
+
+ const taxonomies = Array.from(this.taxonomiesToFetch);
+ this.taxonomiesToFetch.clear();
+
+ console.log(`Batch fetching ${taxonomies.length} unique taxonomies:`, taxonomies);
+
+ // Fetch each taxonomy sequentially (cache will prevent duplicates)
+ for (const taxonomy of taxonomies) {
+ await this.store.setFilters({
+ taxonomy: taxonomy,
+ page: 1,
+ search: '',
+ parent: 0
+ });
+ }
+
+ // Now initialize field displays
+ this.fields.forEach((config, fieldId) => {
+ if (config.selectedTerms.size > 0) {
+ this.initFieldDisplay(fieldId);
+ }
+ });
+ }
+
+ /**
* Create unique field ID
*/
createFieldId(field) {
@@ -242,46 +306,21 @@
if (!field || field.selectedTerms.size === 0) return;
const selectedIds = Array.from(field.selectedTerms);
-
- // Check store for cached terms first
const cachedTerms = [];
- const needsFetch = [];
selectedIds.forEach(termId => {
- const term = this.store.getItem(termId);
+ const term = this.store.data.get(termId);
if (term) {
cachedTerms.push(term);
- } else {
- needsFetch.push(termId);
}
});
- // Display cached terms immediately
+ // Display all found terms
cachedTerms.forEach(term => {
this.addTermToDisplay(fieldId, term.id, term.name, term.path);
});
- // Fetch missing terms if needed
- if (needsFetch.length > 0) {
- try {
-
- const response = await this.store.fetch({
- filters: {
- taxonomy: field.taxonomy,
- termIDs: needsFetch.join(',')
- }
- });
-
- if (response.terms) {
- response.terms.forEach(term => {
- this.store.setItem(term.id, term);
- this.addTermToDisplay(fieldId, term.id, term.name, term.path);
- });
- }
- } catch (error) {
- console.error('Failed to fetch missing terms:', error);
- }
- }
+ // Don't fetch missing terms here - they should be loaded by batchFetchTaxonomies
}
/**
@@ -378,6 +417,17 @@
initGlobalListeners() {
document.addEventListener('click', this.handleClick.bind(this));
document.addEventListener('change', this.handleChange.bind(this));
+ if (this.hasAutocomplete) {
+ this.initAutocomplete();
+ }
+ }
+
+ initAutocomplete()
+ {
+ console.log('Autocomplete init');
+ this.autocompleteHandler = window.debounce((e) => this.handleAutocomplete(e), 300);
+ document.addEventListener('input', this.autocompleteHandler);
+ document.addEventListener('blur', this.cleanupAutocomplete.bind(this));
}
/**
@@ -465,9 +515,6 @@
this.activeField = fieldId;
this.currentConfig = this.fields.get(fieldId);
- console.log('Current Taxonomy:',this.currentConfig.taxonomy);
- console.log('Labels: ',jvbSettings.labels[this.currentConfig.taxonomy]);
-
this.currentSingular = jvbSettings.labels[this.currentConfig.taxonomy].single;
this.currentPlural = jvbSettings.labels[this.currentConfig.taxonomy].plural;
@@ -569,6 +616,8 @@
name: `filter_${taxonomy}`,
maxSelection: 0, // No limit for filters
canSearch: true,
+ hasAutocomplete: false,
+ autocompleteDropdown: document.querySelector('.autocomplete-dropdown')??false,
canCreate: false, // Disable creation for filters
isRequired: false,
selectedTerms: new Set(preselected),
@@ -585,38 +634,36 @@
/**
* Open modal and initialize
*/
- openModal() {
- if (!this.activeField || !this.currentConfig) {
- console.error('No active field set for modal');
- return;
- }
-
- this.resetModalState();
- this.updateModalForTaxonomy();
-
- // Reset store filters to default state
- this.activeStore.clearFilters();
-
- // Set up search if enabled
- if (this.currentConfig.canSearch) {
- this.ui.search.input.focus();
- this.searchHandler = window.debounce(() => this.handleSearch(), 300);
- this.ui.search.input.addEventListener('input', this.searchHandler);
- }
-
+ openModal(config) {
+ this.activeField = config.fieldId;
+ this.currentConfig = config;
// Initialize creator if available
- if (this.currentConfig.canCreate && 'jvbTaxCreator' in window) {
+ if (config.canCreate && 'jvbTaxCreator' in window) {
this.creator = new window.jvbTaxCreator(this);
+ } else if (this.creator) {
+ delete this.creator;
}
- // Display current selections
- this.updateModalSelections();
+ // Load selected terms into modal state
+ this.selectedTerms = new Set(config.selectedTerms);
- // Start observing for infinite scroll
- this.observer.observe(this.ui.sentinel);
+ // Only fetch if taxonomy changed
+ const currentTaxonomy = this.store.filters.taxonomy;
+ if (currentTaxonomy !== config.taxonomy) {
+ this.store.setFilters({
+ taxonomy: config.taxonomy,
+ page: 1,
+ search: '',
+ parent: 0
+ });
+ }
- // Fetch initial terms
- this.fetchCurrentTerms();
+ // Reset UI
+ window.removeChildren(this.ui.termsList);
+ this.ui.search.value = '';
+ this.updateSelectionCount();
+
+ this.modalInstance.open();
}
/**
@@ -627,13 +674,10 @@
window.removeChildren(this.ui.termsList);
if (this.currentConfig?.isFilterMode) {
- // Call the filter callback with selected terms
if (this.currentConfig.filterCallback) {
const selectedIds = Array.from(this.selectedTerms.keys());
this.currentConfig.filterCallback(selectedIds, this.currentConfig.taxonomy);
}
-
- // Clean up the virtual field
this.fields.delete(this.activeField);
} else if (this.activeField) {
this.saveSelectionsToField(this.activeField);
@@ -648,7 +692,7 @@
delete this.creator;
}
- this.activeStore = null;
+ // Remove: this.activeStore = null;
this.activeField = null;
this.currentConfig = null;
}
@@ -886,45 +930,164 @@
/**
* Handle search input
*/
- handleSearch() {
- const query = this.ui.searchInput.value.trim();
+ handleSearch(e) {
+ const query = e.target.value.trim();
- if (query.length >= 2 || query.length === 0) {
- // Reset pagination when searching
- this.activeStore.setFilter('page', 1);
- this.activeStore.setFilter('search', query);
+ // Clear existing debounce
+ if (this.searchHandler) {
+ clearTimeout(this.searchHandler);
+ }
+
+ this.searchHandler = setTimeout(() => {
+ // Single call - auto-fetches
+ this.store.setFilters({
+ search: query,
+ page: 1,
+ parent: query ? 0 : (this.store.filters.parent || 0)
+ });
window.removeChildren(this.ui.termsList);
+ }, 300);
+ }
- if (query.length >= 2) {
- this.showLoading();
- this.fetchCurrentTerms();
- } else if (query.length === 0) {
- // Clear search and reload
- this.showLoading();
- this.fetchCurrentTerms();
- }
- } else {
- this.hideLoading();
- this.showEmptyState('Enter at least 2 characters to search.');
+ async handleAutocomplete(e) {
+ if (!('autocomplete' in e.target.dataset)) {
+ return;
}
+
+ const fieldId = this.getFieldId(e.target);
+ const field = this.fields.get(fieldId);
+
+ if (!field) return;
+
+
+ const query = e.target.value.trim();
+
+ if (query.length < 2) {
+ if (field.autocompleteDropdown) {
+ field.autocompleteDropdown.hidden = true;
+ }
+ this.isAutocompleteActive = false;
+ return;
+ }
+
+ this.activeField = fieldId;
+ this.currentConfig = field;
+
+
+ if (field.canCreate && ! this.creator) {
+ this.creator = new window.jvbTaxCreator(this);
+ }
+ this.isAutocompleteActive = true;
+
+ if (field.autocompleteDropdown) {
+ field.autocompleteDropdown.hidden = false;
+ }
+
+ this.store.setFilters({
+ taxonomy: field.taxonomy,
+ search: query,
+ page: 1
+ });
+ }
+
+ cleanupAutocomplete(e) {
+ if (!('autocomplete' in e.target.dataset)) {
+ return;
+ }
+
+ const fieldId = this.getFieldId(e.target);
+ const field = this.fields.get(fieldId);
+
+ if (!field) return;
+
+ if (this.creator) {
+ delete this.creator;
+ }
+ }
+
+ showAutocompleteError(fieldId) {
+
+ const field = this.fields.get(fieldId);
+ if (!field) {
+ return;
+ }
+ if (!field.config.autocompleteDropdown) {
+ field.config.autocompleteDropdown = field.element.querySelector('.autocomplete-dropdown');
+ }
+ const dropdown = field.config.autocompleteDropdown;
+ if (dropdown) {
+ window.removeChildren(dropdown);
+ this.showEmptyState('Hmmm... something went wrong', dropdown);
+ }
+ }
+
+ showAutocompleteResults(field, terms, query) {
+ if (!field || !field.autocompleteDropdown) {
+ return;
+ }
+
+ const dropdown = field.autocompleteDropdown;
+ window.removeChildren(dropdown);
+
+ if (terms.length === 0) {
+ this.showEmptyState('No items found.', dropdown);
+ } else {
+ terms.forEach(term => {
+ const element = this.createAutocompleteTermElement(field, term);
+ if (element) {
+ dropdown.appendChild(element);
+ }
+ });
+ }
+
+ // Offer to create new term if creator is available
+ if (this.creator) {
+ const createOption = this.creator.createAutocompleteOption(query, field);
+ dropdown.appendChild(createOption);
+ }
+
+ dropdown.hidden = false;
+ }
+
+ createAutocompleteTermElement(field, term) {
+ const item = document.createElement('button');
+ item.type = 'button';
+ item.className = 'autocomplete-item';
+ item.dataset.id = term.id;
+ item.dataset.name = term.name;
+ item.dataset.path = term.path || term.name;
+ item.textContent = term.path || term.name;
+
+ item.addEventListener('click', () => {
+ // Add term to field
+ field.selectedTerms.add(parseInt(term.id));
+ this.addTermToDisplay(field.id, term.id, term.name, term.path);
+
+ // Update input
+ field.input.value = Array.from(field.selectedTerms).join(',');
+ field.input.dispatchEvent(new Event('change', { bubbles: true }));
+
+ // Clear and hide dropdown
+ field.autocompleteDropdown.hidden = true;
+ const input = field.container.querySelector('input[data-autocomplete]');
+ if (input) input.value = '';
+ });
+
+ return item;
}
/**
* Navigate to parent term
*/
navigateToParent() {
- const currentParent = this.activeStore.filters.parent || 0;
-
- // Find parent of current parent (could enhance this with breadcrumb tracking)
- this.activeStore.setFilter('parent', 0);
- this.activeStore.setFilter('page', 1);
+ // Single call instead of two setFilter + manual fetch
+ this.store.setFilters({
+ parent: 0,
+ page: 1
+ });
window.removeChildren(this.ui.termsList);
- this.showLoading();
- this.fetchCurrentTerms();
-
- // Update breadcrumbs
this.ui.breadcrumbs.back.hidden = true;
}
@@ -932,14 +1095,13 @@
* Navigate to child term
*/
navigateToChild(termId, termName) {
- this.activeStore.setFilter('parent', termId);
- this.activeStore.setFilter('page', 1);
+ // Single call - auto-fetches
+ this.store.setFilters({
+ parent: termId,
+ page: 1
+ });
window.removeChildren(this.ui.termsList);
- this.showLoading();
- this.fetchCurrentTerms();
-
- // Update breadcrumbs
this.updateBreadcrumbs(termId, termName);
this.ui.breadcrumbs.back.hidden = false;
}
@@ -950,37 +1112,25 @@
navigateToPath(pathLevel) {
const parentId = parseInt(pathLevel.dataset.id) || 0;
- this.activeStore.setFilter('parent', parentId);
- this.activeStore.setFilter('page', 1);
+ // Single call - auto-fetches
+ this.store.setFilters({
+ parent: parentId,
+ page: 1
+ });
window.removeChildren(this.ui.termsList);
- this.showLoading();
- this.fetchCurrentTerms();
-
- // Update breadcrumbs to this level
- // You'd need to track the full path to properly implement this
this.ui.breadcrumbs.back.hidden = parentId === 0;
}
/**
- * Fetch terms using current store filters
- */
- fetchCurrentTerms() {
- if (!this.activeStore) return;
-
- this.showLoading();
- this.activeStore.fetch();
- }
-
- /**
* Load more terms (pagination)
*/
loadMoreTerms() {
if (!this.activeStore) return;
const currentPage = this.activeStore.filters.page || 1;
- this.activeStore.setFilter('page', currentPage + 1);
- // fetch() will be called automatically by setFilter
+ this.store.setFilter('page', currentPage + 1);
+
}
/**
@@ -998,36 +1148,21 @@
return;
}
- // Update breadcrumbs if needed
- const currentParent = this.activeStore.filters.parent || 0;
+ // Use this.store instead of this.activeStore
+ const currentParent = this.store.filters.parent || 0;
this.ui.breadcrumbs.back.hidden = currentParent === 0;
terms.forEach(term => {
- // Check if we have a cached DOM element
- const cachedElement = this.activeStore.getDOMElement(term.id, 'list-item');
+ const element = this.createTermElement({
+ id: parseInt(term.id),
+ name: term.name,
+ hasChildren: term.hasChildren,
+ path: term.path || null,
+ show: showPath
+ });
- if (cachedElement) {
- // Update checkbox state if needed
- const checkbox = cachedElement.querySelector('input[type="checkbox"]');
- if (checkbox) {
- checkbox.checked = this.selectedTerms.has(term.id);
- checkbox.disabled = !checkbox.checked && this.disabled;
- }
- this.ui.termsList.appendChild(cachedElement);
- } else {
- // Create new element and cache it
- const element = this.createTermElement({
- id: parseInt(term.id),
- name: term.name,
- hasChildren: term.hasChildren,
- path: term.path || null,
- show: showPath
- });
-
- if (element) {
- this.activeStore.storeDOMElement(term.id, 'list-item', element);
- this.ui.termsList.appendChild(element);
- }
+ if (element) {
+ this.ui.termsList.appendChild(element);
}
});
}
@@ -1114,8 +1249,8 @@
this.ui.loading.loading.hidden = false;
this.modal.classList.add('loading');
- const searchQuery = this.activeStore?.filters?.search || '';
- const currentParent = this.activeStore?.filters?.parent || 0;
+ const searchQuery = this.store?.filters?.search || '';
+ const currentParent = this.store?.filters?.parent || 0;
let message = searchQuery !== '' ?
`searching for "${searchQuery}" items` :
@@ -1145,14 +1280,17 @@
/**
* Show empty state message
*/
- showEmptyState(message = 'No items found.') {
+ showEmptyState(message = 'No items found.', container = null) {
+ if (!container) {
+ container = this.ui.termsList;
+ }
const emptyElement = window.getTemplate('noResults').cloneNode(true);
if (message && emptyElement.querySelector('span')) {
emptyElement.querySelector('span').textContent = message;
}
- this.ui.termsList.appendChild(emptyElement);
+ container.appendChild(emptyElement);
}
/**
@@ -1195,7 +1333,5 @@
* Initialize singleton
*/
document.addEventListener('DOMContentLoaded', function() {
- if (!window.jvbSelector) {
- window.jvbSelector = new TaxonomySelector();
- }
+ window.jvbSelector = new TaxonomySelector();
});
diff --git a/assets/js/concise/UploadManager.js b/assets/js/concise/UploadManager.js
index 7144428..0fd8f6e 100644
--- a/assets/js/concise/UploadManager.js
+++ b/assets/js/concise/UploadManager.js
@@ -38,6 +38,8 @@
],
});
+ window.jvbUploadBlobs = this.uploadStore;
+
// Subscribe to store events
this.fieldStore.subscribe(this.handleFieldStoreEvent.bind(this));
this.uploadStore.subscribe(this.handleUploadStoreEvent.bind(this));
@@ -48,7 +50,6 @@
// Core data structures
this.fields = new Map();
this.uploads = new Map();
- this.uploadBlobs = new Map();
this.groups = new Map();
this.selected = new Map();
this.selectionHandlers = new Map();
@@ -1750,6 +1751,9 @@
formData.append('posts', JSON.stringify(posts));
formData.append('upload_ids', JSON.stringify(uploadMap));
+ for (const [key, value] of formData.entries()) {
+ console.log(key, value);
+ }
const operation = {
endpoint: 'uploads/groups',
method: 'POST',
@@ -2861,11 +2865,14 @@
}
}
async saveUpload(upload) {
- // Handle blob data separately
- if (upload.file instanceof File || upload.file instanceof Blob) {
- await this.uploadStore.saveBlob(upload.id, upload.file);
- // Don't store the file in the main store
- const { file, originalFile, ...cleanUpload } = upload;
+ // Use the processed file if available, otherwise original
+ const fileToStore = upload.processedFile || upload.originalFile || upload.file;
+
+ if (fileToStore instanceof File || fileToStore instanceof Blob) {
+ await this.uploadStore.saveBlob(upload.id, fileToStore);
+
+ // Don't store file objects in main store
+ const { file, originalFile, processedFile, ...cleanUpload } = upload;
await this.uploadStore.save(cleanUpload);
} else {
await this.uploadStore.save(upload);
diff --git a/assets/js/concise/UserSettings.js b/assets/js/concise/UserSettings.js
index 91e950e..51763b0 100644
--- a/assets/js/concise/UserSettings.js
+++ b/assets/js/concise/UserSettings.js
@@ -1,38 +1,130 @@
class UserSettings {
constructor() {
this.cache = new window.jvbCache('settings');
+ this.cache.loadFromCache();
+ this.findSettings();
+
this.debouncer = window.debouncer;
this.isLoggedIn = jvbSettings.currentUser !== null;
this.initListeners();
-
+ this.loadSettings();
this.subscribers = new Set();
}
+ findSettings() {
+ this.settings = document.querySelectorAll('[data-setting]')??[];
+ }
+ addSetting(element, name = '', value = null) {
+ name = name === '' ? element.name : name;
+ element.dataset.setting = name;
+ let cached = this.cache.get(name);
+ if (cached) {
+ if (element.tagName === 'INPUT' && ['checkbox', 'radio'].includes(element.type)) {
+ element.checked = (cached === element.value);
+ } else if (element.tagName === 'DETAILS') {
+ element.open = (cached === 'on');
+ }
+
+ }
+ this.debouncer.schedule(
+ 'add-setting',
+ () => {
+ this.findSettings.bind(this);
+ },
+ 300
+ );
+ }
+ loadSettings()
+ {
+ for (const input of this.settings) {
+ let setting = input.name;
+ if (Object.hasOwn(input.dataset, 'theme')) {
+ this.checkTheme(input);
+ } else {
+ let stored = this.cache.get(setting);
+
+ if (stored) {
+ if (input.value === 'on') {
+ input.checked = stored === 'on';
+ } else if (['checkbox', 'radio'].includes(input.tagName)) {
+ input.checked = input.value === stored;
+ } else {
+ input.value = stored;
+ }
+ }
+ }
+ }
+ }
+
+ checkTheme(themeSwitch)
+ {
+ const prefersDark = window.matchMedia('(prefers-color-scheme: dark)');
+ let stored = this.cache.get('dark-mode');
+ if (prefersDark && (!stored || stored !== 'off')) {
+ themeSwitch.checked = true;
+ } else if (stored === 'on') {
+ themeSwitch.checked = true;
+ }
+ }
initListeners() {
this.changeHandler = this.handleChange.bind(this);
-
document.addEventListener('change', this.changeHandler);
}
handleChange(e) {
+ if (!Object.hasOwn(e.target.dataset, 'setting')) {
+ return;
+ }
+ let value = e.target.value;
+ if (e.target.value === 'on') {
+ value = e.target.checked ? 'on' : 'off';
+ }
+ this.saveSetting(e.target.name, value);
}
saveSetting(name, value) {
- this.cache.setItem(name, value);
- if (this.isLoggedIn) {
+ let old;
+ if (this.isLoggedIn){
+ old = this.cache.get(name);
+ }
+ this.cache.set(name, value);
+ if (this.isLoggedIn && old && old !== value) {
+ this.saveToServer(name, value);
}
}
- loadSetting(name) {
- let value = this.cache.getItem(name);
-
- if (this.isLoggedIn) {
-
+ async saveToServer(name, value)
+ {
+ if (!this.isLoggedIn || !['dark-mode'].includes(name)){
+ return;
}
+ const headers = {
+ 'X-WP-Nonce': jvbSettings?.nonce,
+ 'Content-Type': 'application/json'
+ };
+ const body = {
+ user: jvbSettings.currentUser,
+ setting: name,
+ value: value
+ };
+ const response = await fetch(
+ `${jvbSettings.api}settings`,
+ {
+ method: 'POST',
+ headers: headers,
+ body: JSON.stringify(body)
+ }
+ );
+ const result = await response.json();
+
+ }
+
+ loadSetting(name) {
+ return this.cache.get(name);
}
loadUserSetting(name) {
@@ -63,65 +155,65 @@
document.addEventListener('DOMContentLoaded', function() {
window.jvbUserSettings = new UserSettings();
});
-
-// Theme switching functionality
-document.addEventListener('DOMContentLoaded', function() {
- console.log('Theme switch initiated');
- const themeSwitch = document.getElementById('theme-switch');
-
- if (!themeSwitch) return;
-
- // Initialize theme from localStorage or system preference
- const prefersDark = window.matchMedia('(prefers-color-scheme: dark)');
- const storedTheme = localStorage.getItem('theme');
-
- if (storedTheme) {
- document.documentElement.classList.toggle('dark', storedTheme === 'dark');
- themeSwitch.checked = storedTheme === 'dark';
- } else {
- document.documentElement.classList.toggle('dark', prefersDark.matches);
- themeSwitch.checked = prefersDark.matches;
- }
-
- // Handle theme switch changes
- themeSwitch.addEventListener('change', async function () {
- const isDark = this.checked;
- document.documentElement.classList.toggle('dark', isDark);
- localStorage.setItem('theme', isDark ? 'dark' : 'light');
-
- // If user is logged in, save preference
- if (jvbSettings.currentUser !== null) {
- try {
- await fetch(`${jvbSettings.api}settings`, {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/json',
- 'X-WP-Nonce': jvbSettings.nonce,
- 'action_nonce': jvbSettings.dash,
- },
- body: JSON.stringify({
- dark_mode: isDark,
- user: jvbSettings.currentUser
- })
- });
- } catch (error) {
- console.error('Failed to save theme preference:', error);
- }
- }
-
- // Update label
- const label = document.getElementById('theme-switch');
- if (label) {
- label.title = isDark ? 'Toggle Light Mode' : 'Toggle Dark Mode';
- }
- });
-
- // Handle system theme changes
- prefersDark.addEventListener('change', (e) => {
- if (!localStorage.getItem('theme')) {
- const isDark = e.matches;
- document.documentElement.classList.toggle('dark', isDark);
- themeSwitch.checked = isDark;
- }
- });
-});
+//
+// // Theme switching functionality
+// document.addEventListener('DOMContentLoaded', function() {
+// console.log('Theme switch initiated');
+// const themeSwitch = document.getElementById('theme-switch');
+//
+// if (!themeSwitch) return;
+//
+// // Initialize theme from localStorage or system preference
+// const prefersDark = window.matchMedia('(prefers-color-scheme: dark)');
+// const storedTheme = localStorage.getItem('theme');
+//
+// if (storedTheme) {
+// document.documentElement.classList.toggle('dark', storedTheme === 'dark');
+// themeSwitch.checked = storedTheme === 'dark';
+// } else {
+// document.documentElement.classList.toggle('dark', prefersDark.matches);
+// themeSwitch.checked = prefersDark.matches;
+// }
+//
+// // Handle theme switch changes
+// themeSwitch.addEventListener('change', async function () {
+// const isDark = this.checked;
+// document.documentElement.classList.toggle('dark', isDark);
+// localStorage.setItem('theme', isDark ? 'dark' : 'light');
+//
+// // If user is logged in, save preference
+// if (jvbSettings.currentUser !== null) {
+// try {
+// await fetch(`${jvbSettings.api}settings`, {
+// method: 'POST',
+// headers: {
+// 'Content-Type': 'application/json',
+// 'X-WP-Nonce': jvbSettings.nonce,
+// 'action_nonce': jvbSettings.dash,
+// },
+// body: JSON.stringify({
+// dark_mode: isDark,
+// user: jvbSettings.currentUser
+// })
+// });
+// } catch (error) {
+// console.error('Failed to save theme preference:', error);
+// }
+// }
+//
+// // Update label
+// const label = document.getElementById('theme-switch');
+// if (label) {
+// label.title = isDark ? 'Toggle Light Mode' : 'Toggle Dark Mode';
+// }
+// });
+//
+// // Handle system theme changes
+// prefersDark.addEventListener('change', (e) => {
+// if (!localStorage.getItem('theme')) {
+// const isDark = e.matches;
+// document.documentElement.classList.toggle('dark', isDark);
+// themeSwitch.checked = isDark;
+// }
+// });
+// });
diff --git a/assets/js/concise/View.js b/assets/js/concise/View.js
index 70c8e2f..96bbdc8 100644
--- a/assets/js/concise/View.js
+++ b/assets/js/concise/View.js
@@ -8,8 +8,10 @@
this.container = container;
this.initElements();
+ this.settings = window.jvbUserSettings;
this.store = store;
+
this.items = {
list: new Map(),
grid: new Map(),
@@ -45,12 +47,11 @@
// Subscribe to store updates
this.store.subscribe((event, data) => {
switch(event) {
- case 'data-loaded':
case 'items-saved':
- this.handleDataUpdate(data);
+ // this.handleDataUpdate(data);
break;
- case 'items-updated':
- this.handleItemsUpdate(data.items);
+ case 'data-loaded':
+ this.handleItemsUpdate();
break;
case 'item-saved':
// this.updateItem(data.item);
@@ -140,6 +141,7 @@
setupViewSwitcher() {
document.querySelectorAll('[data-view]').forEach(btn => {
+ this.settings.addSetting(btn);
btn.addEventListener('click', () => {
this.currentView = btn.dataset.view;
this.render();
@@ -151,33 +153,32 @@
* Handle data updates from store
*/
handleDataUpdate(data) {
- if (data.data && data.data.items) {
- this.render(data.data.items);
- }
+ console.log(data);
+ const items = data.data?.items || data.items || [];
+ this.render(items);
}
/**
* Handle items update
*/
- handleItemsUpdate(items) {
- this.render(items);
+ handleItemsUpdate() {
+ console.log(this.store.data);
+ this.render(this.store.data);
}
- render(items = null) {
+ render(items = []) {
if (!this.store) {
console.error('No store connected to renderer');
return;
}
- // Get items from store if not provided
- if (!items) {
- const currentRequest = this.store.getCurrentRequest();
- if (currentRequest && currentRequest.data && currentRequest.data.items) {
- items = currentRequest.data.items;
- } else {
- return;
- }
+
+ // Handle empty state
+ if (items.length === 0) {
+ this.renderEmpty();
+ return;
}
+
switch(this.currentView) {
case 'grid':
this.renderGrid(items);
@@ -193,6 +194,17 @@
this.updateSelectionUI();
}
+ renderEmpty() {
+ this.toggleTable(false);
+ window.removeChildren(this.ui.grid);
+
+ const empty = window.getTemplate('emptyState');
+ if (empty) {
+ this.ui.grid.appendChild(empty);
+ this.a11y?.announce('No items found');
+ }
+ }
+
renderGrid(items) {
this.toggleGrid();
this.toggleTable(false);
@@ -246,8 +258,6 @@
checkbox.id,
checkbox.checked,
label.htmlFor,
- img.src,
- img.alt,
edit.dataset.id,
trash.dataset.id
] = [
@@ -255,11 +265,26 @@
`select-${item.id}`,
this.selectedItems.has(`${item.id}`),
`select-${item.id}`,
- item.images[item.fields.post_thumbnail]?.medium??'',
- item.images[item.fields.post_thumbnail]?.alt??'',
item.id,
item.id
];
+ if (this.store.config.storeName === 'progress') {
+ [
+ img.src,
+ img.alt,
+ ] = [
+ item.images[item.fields['timeline'][0].post_thumbnail]?.medium??'',
+ item.images[item.fields['timeline'][0].post_thumbnail]?.alt??'',
+ ];
+ } else {
+ [
+ img.src,
+ img.alt,
+ ] = [
+ item.images[item.fields.post_thumbnail]?.medium??'',
+ item.images[item.fields.post_thumbnail]?.alt??'',
+ ];
+ }
return card;
}
diff --git a/assets/js/concise/navigation.js b/assets/js/concise/navigation.js
index d1fd398..ab1e141 100644
--- a/assets/js/concise/navigation.js
+++ b/assets/js/concise/navigation.js
@@ -2,7 +2,7 @@
constructor() {
this.counter = 0;
this.initElements();
- if (this.navs.length === 0) {
+ if (this.navs.size === 0) {
return;
}
@@ -57,6 +57,9 @@
document.addEventListener('click', this.clickListener);
}
handleClick(e) {
+ if (this.navs.size === 0) {
+ return;
+ }
if (this.openNav && !e.target.closest(this.openNav)) {
this.toggleNav(false);
}
@@ -69,6 +72,13 @@
let nav = toggle.closest('nav');
this.toggleNav(!nav.classList.contains('open'), nav.id);
}
+
+ let submenuToggle = e.target.closest('[data-action="toggle-submenu"]')
+ if (submenuToggle) {
+ let li = submenuToggle.closest('li');
+ this.toggleSubmenu(!li.classList.contains('open'), li);
+ }
+
}
handleHoverOn(e) {
diff --git a/assets/js/dash/CRUD.js b/assets/js/dash/CRUD.js
index e9f39d7..670f625 100644
--- a/assets/js/dash/CRUD.js
+++ b/assets/js/dash/CRUD.js
@@ -7,6 +7,7 @@
console.log(this.queue);
this.config = config;
this.content = config.content || false;
+ this.settings = window.jvbUserSettings;
if (!this.content) {
return;
@@ -41,7 +42,7 @@
this.filterTimeout = null;
this.viewController = new window.jvbViews(this.ui.container, this.store);
- this.formController = new window.jvbForm(this.store);
+ this.formController = new window.jvbForm();
this.formController.subscribe((event, data) => {
switch(event) {
@@ -180,16 +181,27 @@
create: 'dialog.create form',
edit: 'dialog.edit form',
bulkEdit: 'dialog.bulkEdit form'
- }
+ },
+ uploader: 'details.uploader'
};
this.ui = window.uiFromSelectors(this.elements);
}
init() {
+ this.settings.addSetting(this.ui.uploader, 'open');
+ this.ui.uploader.addEventListener('toggle', (e) =>{
+ console.log(e);
+ console.log('Is Open: ', this.ui.uploader.open);
+ console.log(this.ui.uploader.open ? 'on' : 'off');
+ this.settings.saveSetting('open', this.ui.uploader.open ? 'on' : 'off');
+ });
+
+
// Set up filter controls
this.filterHandler = this.handleFilterChange.bind(this);
this.changeHandler = this.handleChange.bind(this);
+
this.modals = {};
for (let [name, modal] of Object.entries(this.ui.modals)) {
this.modals[name] = new window.jvbModal(modal);
@@ -213,8 +225,6 @@
this.setupFilters();
- // Load initial data
- this.store.fetch();
this.queue.subscribe((event, data) => {
switch (event) {
@@ -517,7 +527,9 @@
}
populateEditForm(itemID) {
- let item = this.store.get(itemID);
+ console.log(itemID);
+
+ let item = this.store.get(parseInt(itemID));
console.log(item);
if (item) {
this.ui.modals.edit.dataset.itemID = itemID;
@@ -539,6 +551,7 @@
setupFilters() {
document.querySelectorAll('[data-filter]').forEach(filter => {
+ this.settings.addSetting(filter)
filter.addEventListener('change', (e) => {
if (this.filterTimeout) {
clearTimeout(this.filterTimeout);
diff --git a/assets/js/dash/Integrations.js b/assets/js/dash/Integrations.js
index 079afcc..e0d9ed6 100644
--- a/assets/js/dash/Integrations.js
+++ b/assets/js/dash/Integrations.js
@@ -53,11 +53,21 @@
const error = urlParams.get('error');
if (success) {
- this.showNotification(success, 'success');
+ this.showNotification(success, 'success', 5000);
+
// Clean URL without reloading
this.cleanURL();
+
+ // Refresh the integration status display
+ const forms = document.querySelectorAll('form.integration');
+ forms.forEach(form => {
+ // Update UI to show connected state
+ this.updateUI(form, 'connected');
+ });
+
} else if (error) {
- this.showNotification(error, 'error');
+ this.showNotification(error, 'error', 8000);
+
// Clean URL without reloading
this.cleanURL();
}
@@ -76,26 +86,42 @@
/**
* Show notification message
*/
- showNotification(message, type = 'info') {
- // If you have a notification system, use it
+ showNotification(message, type = 'info', duration = 5000) {
+ // Find or create notification container
+ let container = document.querySelector('.integration-status-message');
+
+ if (!container) {
+ // Create notification container
+ container = document.createElement('div');
+ container.className = 'integration-status-message';
+
+ // Insert at top of main content or integrations container
+ const target = document.querySelector('.integration-settings') ||
+ document.querySelector('main') ||
+ document.body;
+ target.insertBefore(container, target.firstChild);
+ }
+
+ // Update content and type
+ container.textContent = message;
+ container.className = `integration-status-message ${type}`;
+
+ // Clear any existing timeout
+ if (this.notificationTimeout) {
+ clearTimeout(this.notificationTimeout);
+ }
+
+ // Auto-hide after duration
+ if (duration > 0) {
+ this.notificationTimeout = setTimeout(() => {
+ container.className = 'integration-status-message';
+ container.textContent = '';
+ }, duration);
+ }
+
+ // Also use popup if available
if (this.popup) {
- this.addPopup(message, type === 'error' ? 5000 : 3000);
- } else {
- // Fallback to console or alert
- console.log(`[${type}]`, message);
-
- // Update any status elements on the page
- const statusElements = document.querySelectorAll('.integration-status-message');
- statusElements.forEach(el => {
- el.textContent = message;
- el.className = `integration-status-message ${type}`;
-
- // Auto-hide after delay
- setTimeout(() => {
- el.textContent = '';
- el.className = 'integration-status-message';
- }, 5000);
- });
+ this.addPopup(message, duration);
}
}
@@ -122,9 +148,21 @@
}
clickHandler(e) {
+ // // Check for OAuth authorization link
+ // if (e.target.classList.contains('jvb-oauth-connect') ||
+ // e.target.closest('.jvb-oauth-connect')) {
+ // e.preventDefault();
+ // const link = e.target.classList.contains('jvb-oauth-connect')
+ // ? e.target
+ // : e.target.closest('.jvb-oauth-connect');
+ // return this.handleOAuthClick(link);
+ // }
+
+ // Existing integration form handling
if (!e.target.closest(this.selectors.form)) {
return;
}
+
console.log('Clicked!');
if (e.target.tagName === 'BUTTON' || e.target.closest('button')) {
e.preventDefault();
@@ -159,6 +197,90 @@
return this.forms.get(service)??false;
}
+ /**
+ * Handle OAuth authorization link clicks
+ * Opens OAuth in a popup window with proper handling
+ */
+ handleOAuthClick(link) {
+ const service = link.dataset.service;
+ const href = link.href;
+
+ // Calculate center position for popup
+ const width = 600;
+ const height = 700;
+ const left = (screen.width - width) / 2;
+ const top = (screen.height - height) / 2;
+
+ // Show loading notification
+ this.showNotification('Opening authorization window...', 'info');
+
+ // Add loading state to the link
+ link.classList.add('loading');
+ link.setAttribute('aria-busy', 'true');
+
+ // Open OAuth in popup
+ const popup = window.open(
+ href,
+ 'oauth_' + service,
+ `width=${width},height=${height},left=${left},top=${top},toolbar=no,menubar=no,location=yes,status=yes,resizable=yes`
+ );
+
+ if (!popup) {
+ // Popup was blocked
+ this.showNotification('Popup was blocked. Please allow popups and try again.', 'error');
+ link.classList.remove('loading');
+ link.removeAttribute('aria-busy');
+ return true; // Allow default behavior as fallback
+ }
+
+ // Focus the popup
+ popup.focus();
+
+ // Update notification
+ this.showNotification('Waiting for authorization...', 'info');
+
+ // Poll for popup close
+ const pollTimer = setInterval(() => {
+ try {
+ if (popup.closed) {
+ clearInterval(pollTimer);
+
+ // Remove loading state
+ link.classList.remove('loading');
+ link.removeAttribute('aria-busy');
+
+ // Show checking notification
+ this.showNotification('Checking authorization status...', 'info');
+
+ // Wait a moment for redirect to complete, then check for messages
+ setTimeout(() => {
+ this.checkForOAuthMessages();
+
+ // If no messages found, reload to get updated connection status
+ setTimeout(() => {
+ const urlParams = new URLSearchParams(window.location.search);
+ if (!urlParams.has('success') && !urlParams.has('error')) {
+ // No messages in URL, reload to check server-side status
+ window.location.reload();
+ }
+ }, 500);
+ }, 500);
+ }
+ } catch (error) {
+ // Ignore cross-origin errors during polling
+ }
+ }, 500);
+
+ // Safety timeout - stop polling after 5 minutes
+ setTimeout(() => {
+ clearInterval(pollTimer);
+ link.classList.remove('loading');
+ link.removeAttribute('aria-busy');
+ }, 300000);
+
+ return false; // Prevent default link behavior
+ }
+
async handleAction(input) {
const form = input.closest('form');
const service = form.dataset.service;
diff --git a/assets/js/dash/TaxonomyCreator.js b/assets/js/dash/TaxonomyCreator.js
index d14ac9d..3c783c7 100644
--- a/assets/js/dash/TaxonomyCreator.js
+++ b/assets/js/dash/TaxonomyCreator.js
@@ -1,6 +1,6 @@
/**
- * This separates out all create logic from the base TaxonomySelector.js, so that we only enqueue create logic if it's creatable
- * Updated to work with the refactored centralized TaxonomySelector
+ * This separates out all create logic from the base TaxonomySelector.js
+ * Updated to work with centralized DataStore architecture
*/
class TaxonomyCreator {
@@ -24,7 +24,8 @@
}
initListeners() {
- document.addEventListener('click', this.handleClick.bind(this));
+ this.clickHandler = this.handleClick.bind(this);
+ document.addEventListener('click', this.clickHandler);
}
handleClick(e) {
@@ -42,37 +43,52 @@
async handleTermCreation(e) {
const termName = this.form.querySelector('input[name="term_name"]').value.trim();
- const parentId = this.form.querySelector('input#select_parent')?.value;
+ const parentId = parseInt(this.form.querySelector('input#select_parent')?.value) || 0;
+
+ if (!termName) return;
try {
this.form.querySelector('button').disabled = true;
const response = await this.createTerm(termName, parentId);
- if (response.success) {
+ if (response.success && response.term) {
let term = response.term;
// Close the create new section
this.createNew.open = false;
- // Add to the terms list UI
- this.selector.createTermElement({
- id: parseInt(term.id),
- name: term.name,
- hasChildren: term.hasChildren || false,
- path: term.path || term.name,
- show: false
- });
+ // Invalidate the cache for this taxonomy
+ await this.selector.store.invalidate({ taxonomy: this.taxonomy });
// Add to current modal selection
- this.selector.addSelectedTermToModal(term.id, term.name, term.path);
+ this.selector.addSelectedTermToModal(term.id, term.name, term.path || term.name);
+
+ // If we're viewing the parent category where this was created, refresh the list
+ const currentParent = this.selector.store.filters.parent || 0;
+ if (currentParent === parentId) {
+ await this.selector.store.setFilters({
+ taxonomy: this.taxonomy,
+ parent: parentId,
+ page: 1,
+ search: ''
+ });
+ }
// Clear the form
this.form.querySelector('input[name="term_name"]').value = '';
+
+ // Clear suggestions
+ const suggestionContainer = this.createNew.querySelector('.term-suggestions');
+ if (suggestionContainer) {
+ suggestionContainer.hidden = true;
+ }
}
} catch (error) {
console.error('Error creating term:', error);
- this.selector.showError?.('Failed to create term') ||
- console.error('Failed to create term');
+ this.selector.error?.log(error, {
+ component: 'TaxonomyCreator',
+ action: 'handleTermCreation'
+ }) || console.error('Failed to create term');
} finally {
this.form.querySelector('button').disabled = false;
}
@@ -100,25 +116,39 @@
window.removeChildren(select);
select.append(defaultOption.cloneNode(true));
- // Add current parent if we're in a sub-category
- if (this.selector.currentParentName !== '') {
- let parentOption = defaultOption.cloneNode(true);
- parentOption.value = this.selector.currentParent;
- parentOption.textContent = this.selector.currentParentName;
- select.append(parentOption);
+ // Get current parent from store filters
+ const currentParent = this.selector.store.filters.parent || 0;
+
+ // If we're in a sub-category, add the current parent as an option
+ if (currentParent !== 0) {
+ const parentTerm = this.selector.store.data.get(currentParent);
+ if (parentTerm) {
+ let parentOption = defaultOption.cloneNode(true);
+ parentOption.value = parentTerm.id;
+ parentOption.textContent = parentTerm.name;
+ select.append(parentOption);
+ }
}
- // Add terms from current taxonomy cache
- const taxonomyTerms = this.selector.currentTerms;
- if (taxonomyTerms && taxonomyTerms.length > 0) {
- taxonomyTerms.forEach(term => {
- let option = defaultOption.cloneNode(true);
- option.id = `select-parent-${term.id}`;
- option.value = term.id;
- option.textContent = ' — ' + term.name;
- select.append(option);
- });
- }
+ // Add all terms currently visible in the taxonomy (from store cache)
+ const visibleTerms = [];
+ this.selector.store.data.forEach(term => {
+ if (term.taxonomy === this.taxonomy && term.parent === currentParent) {
+ visibleTerms.push(term);
+ }
+ });
+
+ // Sort by name
+ visibleTerms.sort((a, b) => a.name.localeCompare(b.name));
+
+ // Add to select
+ visibleTerms.forEach(term => {
+ let option = defaultOption.cloneNode(true);
+ option.id = `select-parent-${term.id}`;
+ option.value = term.id;
+ option.textContent = ' — ' + term.name;
+ select.append(option);
+ });
}
async createTerm(name, parent = 0) {
@@ -136,36 +166,22 @@
text.textContent = 'Checking term...';
}
- // Check if term already exists by searching
- const originalSearchQuery = this.selector.searchQuery;
- const originalFetchSpecific = this.selector.fetchSpecificTerms;
+ // Search for existing terms with this name
+ const searchResults = await this.searchExistingTerms(name);
- this.selector.searchQuery = name;
- this.selector.fetchSpecificTerms = false; // We want to search, not fetch specific IDs
-
- const existingTerms = await this.selector.fetchTerms(
- this.selector.activeField,
- false,
- true // isSearch = true
- );
-
- // Restore original search state
- this.selector.searchQuery = originalSearchQuery;
- this.selector.fetchSpecificTerms = originalFetchSpecific;
-
- // Check if any existing terms match exactly
- const exactMatches = existingTerms.filter(term =>
+ // Check for exact matches
+ const exactMatches = searchResults.filter(term =>
term.name.toLowerCase() === name.toLowerCase()
);
if (exactMatches.length > 0) {
- this.showTermSuggestions(exactMatches);
+ this.showTermSuggestions(exactMatches, true);
return { success: false, reason: 'exists' };
}
// Show similar terms if found
- if (existingTerms.length > 0) {
- this.showTermSuggestions(existingTerms);
+ if (searchResults.length > 0) {
+ this.showTermSuggestions(searchResults, false);
return { success: false, reason: 'similar' };
}
@@ -191,8 +207,7 @@
throw new Error(`Server error: ${response.status}`);
}
- const result = await response.json();
- return result;
+ return await response.json();
} catch (error) {
console.error('Error creating term:', error);
@@ -205,8 +220,35 @@
}
}
- // Helper method to show term suggestions when similar terms exist
- showTermSuggestions(suggestions) {
+ /**
+ * Search for existing terms using the store
+ */
+ async searchExistingTerms(searchQuery) {
+ return new Promise((resolve) => {
+ // Set up a one-time listener for the search results
+ const handleSearchResults = (event, data) => {
+ if (event === 'data-loaded') {
+ this.selector.store.unsubscribe(handleSearchResults);
+ resolve(data.data?.items || []);
+ }
+ };
+
+ this.selector.store.subscribe(handleSearchResults);
+
+ // Trigger search
+ this.selector.store.setFilters({
+ taxonomy: this.taxonomy,
+ search: searchQuery,
+ page: 1,
+ parent: 0
+ });
+ });
+ }
+
+ /**
+ * Show term suggestions when similar terms exist
+ */
+ showTermSuggestions(suggestions, isExact = false) {
const suggestionContainer = this.createNew.querySelector('.term-suggestions') ||
this.createSuggestionContainer();
@@ -215,7 +257,9 @@
// Add heading
const heading = document.createElement('h4');
- heading.textContent = 'Similar terms already exist:';
+ heading.textContent = isExact ?
+ 'This term already exists:' :
+ 'Similar terms already exist:';
suggestionContainer.appendChild(heading);
// Create list of suggestions
@@ -225,18 +269,15 @@
suggestions.forEach(term => {
const item = document.createElement('li');
- // Create term path display if available
- let termDisplay = term.path || term.name;
-
const button = document.createElement('button');
button.type = 'button';
button.className = 'use-existing-term';
button.setAttribute('data-id', term.id);
- button.textContent = termDisplay;
+ button.textContent = term.path || term.name;
button.addEventListener('click', () => {
// Add this term to modal selection
- this.selector.addSelectedTermToModal(term.id, term.name, term.path);
+ this.selector.addSelectedTermToModal(term.id, term.name, term.path || term.name);
// Close the create new section
this.createNew.open = false;
@@ -256,7 +297,9 @@
suggestionContainer.hidden = false;
}
- // Create container for term suggestions if it doesn't exist
+ /**
+ * Create container for term suggestions if it doesn't exist
+ */
createSuggestionContainer() {
const container = document.createElement('div');
container.className = 'term-suggestions';
@@ -268,10 +311,79 @@
}
/**
+ * Create "Create new term" option for autocomplete dropdown
+ */
+ createAutocompleteOption(query, field) {
+ const button = document.createElement('button');
+ button.type = 'button';
+ button.className = 'autocomplete-item create-term';
+ button.innerHTML = `<span>Create "${query}"</span>`;
+ button.dataset.query = query;
+ button.dataset.fieldId = field.id;
+
+ button.addEventListener('click', async () => {
+ await this.handleAutocompleteCreate(button, query, field);
+ });
+
+ return button;
+ }
+
+ /**
+ * Handle term creation from autocomplete
+ */
+ async handleAutocompleteCreate(button, termName, field) {
+ if (!field) return;
+
+ const originalHTML = button.innerHTML;
+
+ try {
+ button.disabled = true;
+ button.innerHTML = '<span>Creating...</span>';
+
+ const parentId = 0; // Autocomplete always creates at root level
+ const result = await this.createTerm(termName, parentId);
+
+ if (result.success && result.term) {
+ const term = result.term;
+
+ // Add to field
+ field.selectedTerms.add(parseInt(term.id));
+ this.selector.addTermToDisplay(field.id, term.id, term.name, term.path || term.name);
+
+ // Update input
+ field.input.value = Array.from(field.selectedTerms).join(',');
+ field.input.dispatchEvent(new Event('change', { bubbles: true }));
+
+ // Invalidate cache
+ await this.selector.store.invalidate({ taxonomy: field.taxonomy });
+
+ // Clear and hide dropdown
+ field.autocompleteDropdown.hidden = true;
+ const input = field.container.querySelector('input[data-autocomplete]');
+ if (input) input.value = '';
+ }
+ // If result.success is false, suggestions are already shown
+
+ } catch (error) {
+ console.error('Error creating term:', error);
+ button.innerHTML = originalHTML;
+ button.disabled = false;
+ this.selector.error?.log(error, {
+ component: 'TaxonomyCreator',
+ action: 'handleAutocompleteCreate'
+ });
+ }
+ }
+
+ /**
* Clean up when modal closes
*/
destroy() {
- // Remove event listeners if needed
+ // Remove event listeners
+ if (this.clickHandler) {
+ document.removeEventListener('click', this.clickHandler);
+ }
+
// Clear any pending operations
const loadingMessage = this.createNew?.querySelector('.loading-message.create-term');
if (loadingMessage) {
diff --git a/assets/js/dash/UtilityFunctions.js b/assets/js/dash/UtilityFunctions.js
index b682acc..4c337dd 100644
--- a/assets/js/dash/UtilityFunctions.js
+++ b/assets/js/dash/UtilityFunctions.js
@@ -816,8 +816,6 @@
schedule(key, callback, delay = 1000) {
this.cancel(key);
- console.log('Scheduling action: ', key);
- console.log('With callback', callback);
this.timeouts.set(key, setTimeout(() => {
callback();
this.timeouts.delete(key);
@@ -826,7 +824,6 @@
cancel(key) {
if (this.timeouts.has(key)) {
- console.log('Cancelling ', key);
clearTimeout(this.timeouts.get(key));
this.timeouts.delete(key);
}
@@ -834,7 +831,6 @@
cleanup() {
for (let timeout of this.timeouts.values()) {
- console.log('clearing timeout: ', timeout);
clearTimeout(timeout);
}
this.timeouts.clear();
diff --git a/assets/js/min/cache.min.js b/assets/js/min/cache.min.js
index 3c69458..1df21e6 100644
--- a/assets/js/min/cache.min.js
+++ b/assets/js/min/cache.min.js
@@ -1 +1 @@
-window.jvbCache=class{constructor(e,t={}){this.base=e,this.config={namespace:"jvb_cache",TTL:36e5,maxSize:100,...t},this.cacheAvailable="caches"in window,this.cacheAvailable||console.warn("Browser Cache API unavailable, reverting to LocalStorage"),this._memoryCache=new Map,this.subscribers=new Set}clearMemoryCache(){const e=this._memoryCache.size;return this._memoryCache.clear(),console.log(`Cleared ${e} items from memory cache`),e}async get(e){let t=`${this.base}_${e}`;const a=await this.getCacheItem(t);return a?this.deserializeData(a.data,a.dataType):null}async set(e,t){let a=`${this.base}_${e}`;const r=this.serializeData(t),c={data:r.data,dataType:r.type,timestamp:Date.now()};await this.setCacheItem(a,c),this.notify("cache-saved",{key:e,value:t})}remove(e){this.base}serializeData(e){if(null==e)return{data:e,type:"primitive"};if(e instanceof Map)return{data:Array.from(e.entries()),type:"Map"};if(e instanceof Set)return{data:Array.from(e),type:"Set"};if(e instanceof Date)return{data:e.toISOString(),type:"Date"};if(e instanceof RegExp)return{data:{source:e.source,flags:e.flags},type:"RegExp"};if(Array.isArray(e))return{data:e.map((e=>this.serializeData(e))),type:"Array"};if(e&&"object"==typeof e&&e.constructor===Object){const t={};for(const[a,r]of Object.entries(e))t[a]=this.serializeData(r);return{data:t,type:"Object"}}return{data:e,type:"primitive"}}deserializeData(e,t){if(!t||"primitive"===t)return e;switch(t){case"Map":return new Map(e);case"Set":return new Set(e);case"Date":return new Date(e);case"RegExp":return new RegExp(e.source,e.flags);case"Array":return e.map((e=>this.deserializeData(e.data,e.type)));case"Object":const a={};for(const[t,r]of Object.entries(e))a[t]=this.deserializeData(r.data,r.type);return a;default:return console.warn(`Unknown data type: ${t}, returning as-is`),e}}async getCacheItem(e){if(this._memoryCache.has(e))return this._memoryCache.get(e);const t=this.cacheAvailable?await this.getBrowserCacheItem(e):this.getLocalStorageItem(e);return t&&this._memoryCache.set(e,t),t}async setCacheItem(e,t){return this._memoryCache.set(e,t),this.cacheAvailable?await this.setBrowserCacheItem(e,t):this.setLocalStorageItem(e,t)}async removeCacheItem(e){return this._memoryCache.delete(e),this.cacheAvailable?await this.removeBrowserCacheItem(e):this.removeLocalStorageItem(e)}async getBrowserCacheItem(e){try{const t=await caches.open(this.config.namespace),a=await t.match(e);return a?await a.json():null}catch(e){return console.warn("Error getting from Browser Cache API:",e),null}}async setBrowserCacheItem(e,t){try{const a=await caches.open(this.config.namespace),r=new Response(JSON.stringify(t),{headers:{"Content-Type":"application/json"}});await a.put(e,r)}catch(e){console.warn("Error setting in Browser Cache API:",e)}}async removeBrowserCacheItem(e){try{const t=await caches.open(this.config.namespace);await t.delete(e)}catch(e){console.warn("Error removing from Browser Cache API:",e)}}getLocalStorageItem(e){try{const t=localStorage.getItem(e);return t?JSON.parse(t):null}catch(e){return console.warn("Error getting from localStorage:",e),null}}setLocalStorageItem(e,t){try{localStorage.setItem(e,JSON.stringify(t))}catch(a){if(a instanceof DOMException&&22===a.code){this.clearOldestLocalStorageItems();try{localStorage.setItem(e,JSON.stringify(t))}catch(e){console.warn("Still failed to set localStorage item after cleanup:",e)}}else console.warn("Error setting localStorage item:",a)}}removeLocalStorageItem(e){try{localStorage.removeItem(e)}catch(e){console.warn("Error removing localStorage item:",e)}}clearOldestLocalStorageItems(){try{const e=[];for(let t=0;t<localStorage.length;t++){const a=localStorage.key(t);if(a.startsWith(this.config.namespace))try{const t=JSON.parse(localStorage.getItem(a));e.push({key:a,timestamp:t.timestamp||0})}catch(t){e.push({key:a,timestamp:0})}}e.sort(((e,t)=>e.timestamp-t.timestamp));const t=Math.max(1,Math.ceil(.2*e.length));for(let a=0;a<t;a++)e[a]&&localStorage.removeItem(e[a].key)}catch(e){console.warn("Error cleaning up localStorage:",e)}}async cleanExpired(){const e=Date.now(),t=this.config.TTL;if(this.cacheAvailable)try{const a=await caches.open(this.options.namespace),r=await a.keys();for(const c of r){const r=await a.match(c);try{e-(await r.json()).timestamp>t&&await a.delete(c)}catch(e){}}}catch(e){console.warn("Error cleaning browser cache:",e)}else try{for(let a=0;a<localStorage.length;a++){const r=localStorage.key(a);if(r&&r.startsWith(this.options.namespace))try{e-JSON.parse(localStorage.getItem(r)).timestamp>t&&localStorage.removeItem(r)}catch(e){}}}catch(e){console.warn("Error cleaning localStorage cache:",e)}for(const[a,r]of this._memoryCache.entries())e-r.timestamp>t&&this._memoryCache.delete(a)}async clear(){if(this._memoryCache.clear(),this.cacheAvailable)try{await caches.delete(this.options.namespace)}catch(e){console.warn("Error clearing browser cache:",e)}this.clearLocalStorage()}clearLocalStorage(){try{for(let e=localStorage.length-1;e>=0;e--){const t=localStorage.key(e);t&&t.startsWith(this.options.namespace)&&localStorage.removeItem(t)}}catch(e){console.warn("Error clearing localStorage cache:",e)}}subscribe(e){return this.subscribers.add(e),()=>this.subscribers.delete(e)}notify(e,t){this.subscribers.forEach((a=>a(e,t)))}};
\ No newline at end of file
+window.jvbCache=class{constructor(e,t={}){this.base=e,this.config={namespace:"jvb_cache",TTL:36e5,maxSize:100,...t},this._cache=new Map,this.subscribers=new Set}clearMemoryCache(){const e=this._cache.size;return this._cache.clear(),console.log(`Cleared ${e} items from memory cache`),e}get(e){if(this._cache.has(e))return this._cache.get(e);let t,a=`${this.base}_${e}`;try{if(t=localStorage.getItem(a),!t)return null;t=JSON.parse(t)}catch(e){return console.warn("Error getting from localStorage:",e),null}return t&&this._cache.set(e,t),t}set(e,t){this._cache.set(e,t);let a=`${this.base}_${e}`;try{localStorage.setItem(a,JSON.stringify(t))}catch(t){if(t instanceof DOMException&&22===t.code){this.clearOldestLocalStorageItems();try{localStorage.setItem(e,JSON.stringify(item))}catch(e){console.warn("Still failed to set localStorage item after cleanup:",e)}}else console.warn("Error setting localStorage item:",t)}this.notify("cache-saved",{key:e,value:t})}remove(e){let t=`${this.base}_${e}`;try{localStorage.removeItem(t)}catch(e){console.warn("Error removing localStorage item:",e)}}clearOldestLocalStorageItems(){try{const e=[];for(let t=0;t<localStorage.length;t++){const a=localStorage.key(t);if(a.startsWith(this.config.namespace))try{const t=JSON.parse(localStorage.getItem(a));e.push({key:a,timestamp:t.timestamp||0})}catch(t){e.push({key:a,timestamp:0})}}e.sort(((e,t)=>e.timestamp-t.timestamp));const t=Math.max(1,Math.ceil(.2*e.length));for(let a=0;a<t;a++)e[a]&&localStorage.removeItem(e[a].key)}catch(e){console.warn("Error cleaning up localStorage:",e)}}async loadFromCache(){for(let e=0;e<localStorage.length;e++){const t=localStorage.key(e);if(t.startsWith(`${this.base}_`)){let e=t.replace(`${this.base}_`,"");try{const a=JSON.parse(localStorage.getItem(t));this._cache.set(e,a)}catch(e){console.warn(`Failed to parse cached value for ${t}:`,e)}}}}async clear(){this._cache.clear();try{for(let e=localStorage.length-1;e>=0;e--){const t=localStorage.key(e);t&&t.startsWith(this.config.namespace)&&localStorage.removeItem(t)}}catch(e){console.warn("Error clearing localStorage cache:",e)}}subscribe(e){return this.subscribers.add(e),()=>this.subscribers.delete(e)}notify(e,t){this.subscribers.forEach((a=>a(e,t)))}};
\ No newline at end of file
diff --git a/assets/js/min/creator.min.js b/assets/js/min/creator.min.js
index 59cb9ab..9f9f39f 100644
--- a/assets/js/min/creator.min.js
+++ b/assets/js/min/creator.min.js
@@ -1 +1 @@
-window.jvbTaxCreator=class{constructor(e){this.selector=e,this.taxonomy=e.currentConfig?.taxonomy,this.taxonomy?(this.createNew=e.modal.querySelector(".create-new-term"),this.toggle=e.modal.querySelector(".new-term-toggle"),this.form=this.createNew.querySelector(".create-new-term-section"),this.initListeners(),this.initTermCreation()):console.error("TaxonomyCreator: No active field or taxonomy found")}initListeners(){document.addEventListener("click",this.handleClick.bind(this))}handleClick(e){window.targetCheck(e,".create-new-term summary")&&(this.createNew.open&&this.createNew.querySelector('input[name="term_name"]').focus(),this.resetParentOptions()),window.targetCheck(e,".submit-term")&&this.handleTermCreation(e)}async handleTermCreation(e){const t=this.form.querySelector('input[name="term_name"]').value.trim(),r=this.form.querySelector("input#select_parent")?.value;try{this.form.querySelector("button").disabled=!0;const e=await this.createTerm(t,r);if(e.success){let t=e.term;this.createNew.open=!1,this.selector.createTermElement({id:parseInt(t.id),name:t.name,hasChildren:t.hasChildren||!1,path:t.path||t.name,show:!1}),this.selector.addSelectedTermToModal(t.id,t.name,t.path),this.form.querySelector('input[name="term_name"]').value=""}}catch(e){console.error("Error creating term:",e),this.selector.showError?.("Failed to create term")||console.error("Failed to create term")}finally{this.form.querySelector("button").disabled=!1}}initTermCreation(){this.form&&this.form.addEventListener("change",(e=>{e.preventDefault(),e.stopPropagation()}))}resetParentOptions(){let e=this.createNew.querySelector("#select_parent");if(!e)return;let t=e.querySelector("option");if(!t)return;if(window.removeChildren(e),e.append(t.cloneNode(!0)),""!==this.selector.currentParentName){let r=t.cloneNode(!0);r.value=this.selector.currentParent,r.textContent=this.selector.currentParentName,e.append(r)}const r=this.selector.currentTerms;r&&r.length>0&&r.forEach((r=>{let n=t.cloneNode(!0);n.id=`select-parent-${r.id}`,n.value=r.id,n.textContent=" — "+r.name,e.append(n)}))}async createTerm(e,t=0){let r=this.createNew.querySelector(".loading-message.create-term"),n=r?.querySelector("span");try{r&&(r.hidden=!1),n&&window.typeText?window.typeText(n,"Checking term..."):n&&(n.textContent="Checking term...");const o=this.selector.searchQuery,s=this.selector.fetchSpecificTerms;this.selector.searchQuery=e,this.selector.fetchSpecificTerms=!1;const i=await this.selector.fetchTerms(this.selector.activeField,!1,!0);this.selector.searchQuery=o,this.selector.fetchSpecificTerms=s;const a=i.filter((t=>t.name.toLowerCase()===e.toLowerCase()));if(a.length>0)return this.showTermSuggestions(a),{success:!1,reason:"exists"};if(i.length>0)return this.showTermSuggestions(i),{success:!1,reason:"similar"};n&&(n.textContent="Creating term...");const c=await fetch(`${jvbSettings.api}terms`,{method:"POST",headers:{"Content-Type":"application/json","X-WP-Nonce":jvbSettings.nonce},body:JSON.stringify({taxonomy:this.taxonomy,name:e,parent:t})});if(!c.ok)throw new Error(`Server error: ${c.status}`);return await c.json()}catch(e){throw console.error("Error creating term:",e),e}finally{this.form.querySelector("button").disabled=!1,r&&(r.hidden=!0)}}showTermSuggestions(e){const t=this.createNew.querySelector(".term-suggestions")||this.createSuggestionContainer();window.removeChildren(t);const r=document.createElement("h4");r.textContent="Similar terms already exist:",t.appendChild(r);const n=document.createElement("ul");n.className="term-suggestion-list",e.forEach((e=>{const r=document.createElement("li");let o=e.path||e.name;const s=document.createElement("button");s.type="button",s.className="use-existing-term",s.setAttribute("data-id",e.id),s.textContent=o,s.addEventListener("click",(()=>{this.selector.addSelectedTermToModal(e.id,e.name,e.path),this.createNew.open=!1,t.hidden=!0,this.form.querySelector('input[name="term_name"]').value=""})),r.appendChild(s),n.appendChild(r)})),t.appendChild(n),t.hidden=!1}createSuggestionContainer(){const e=document.createElement("div");return e.className="term-suggestions",e.hidden=!0,this.createNew.querySelector("form").after(e),e}destroy(){const e=this.createNew?.querySelector(".loading-message.create-term");e&&(e.hidden=!0);const t=this.createNew?.querySelector(".term-suggestions");t&&(t.hidden=!0)}};
\ No newline at end of file
+window.jvbTaxCreator=class{constructor(e){this.selector=e,this.taxonomy=e.currentConfig?.taxonomy,this.taxonomy?(this.createNew=e.modal.querySelector(".create-new-term"),this.toggle=e.modal.querySelector(".new-term-toggle"),this.form=this.createNew.querySelector(".create-new-term-section"),this.initListeners(),this.initTermCreation()):console.error("TaxonomyCreator: No active field or taxonomy found")}initListeners(){this.clickHandler=this.handleClick.bind(this),document.addEventListener("click",this.clickHandler)}handleClick(e){window.targetCheck(e,".create-new-term summary")&&(this.createNew.open&&this.createNew.querySelector('input[name="term_name"]').focus(),this.resetParentOptions()),window.targetCheck(e,".submit-term")&&this.handleTermCreation(e)}async handleTermCreation(e){const t=this.form.querySelector('input[name="term_name"]').value.trim(),r=parseInt(this.form.querySelector("input#select_parent")?.value)||0;if(t)try{this.form.querySelector("button").disabled=!0;const e=await this.createTerm(t,r);if(e.success&&e.term){let t=e.term;this.createNew.open=!1,await this.selector.store.invalidate({taxonomy:this.taxonomy}),this.selector.addSelectedTermToModal(t.id,t.name,t.path||t.name),(this.selector.store.filters.parent||0)===r&&await this.selector.store.setFilters({taxonomy:this.taxonomy,parent:r,page:1,search:""}),this.form.querySelector('input[name="term_name"]').value="";const n=this.createNew.querySelector(".term-suggestions");n&&(n.hidden=!0)}}catch(e){console.error("Error creating term:",e),this.selector.error?.log(e,{component:"TaxonomyCreator",action:"handleTermCreation"})||console.error("Failed to create term")}finally{this.form.querySelector("button").disabled=!1}}initTermCreation(){this.form&&this.form.addEventListener("change",(e=>{e.preventDefault(),e.stopPropagation()}))}resetParentOptions(){let e=this.createNew.querySelector("#select_parent");if(!e)return;let t=e.querySelector("option");if(!t)return;window.removeChildren(e),e.append(t.cloneNode(!0));const r=this.selector.store.filters.parent||0;if(0!==r){const n=this.selector.store.data.get(r);if(n){let r=t.cloneNode(!0);r.value=n.id,r.textContent=n.name,e.append(r)}}const n=[];this.selector.store.data.forEach((e=>{e.taxonomy===this.taxonomy&&e.parent===r&&n.push(e)})),n.sort(((e,t)=>e.name.localeCompare(t.name))),n.forEach((r=>{let n=t.cloneNode(!0);n.id=`select-parent-${r.id}`,n.value=r.id,n.textContent=" — "+r.name,e.append(n)}))}async createTerm(e,t=0){let r=this.createNew.querySelector(".loading-message.create-term"),n=r?.querySelector("span");try{r&&(r.hidden=!1),n&&window.typeText?window.typeText(n,"Checking term..."):n&&(n.textContent="Checking term...");const o=await this.searchExistingTerms(e),a=o.filter((t=>t.name.toLowerCase()===e.toLowerCase()));if(a.length>0)return this.showTermSuggestions(a,!0),{success:!1,reason:"exists"};if(o.length>0)return this.showTermSuggestions(o,!1),{success:!1,reason:"similar"};n&&(n.textContent="Creating term...");const s=await fetch(`${jvbSettings.api}terms`,{method:"POST",headers:{"Content-Type":"application/json","X-WP-Nonce":jvbSettings.nonce},body:JSON.stringify({taxonomy:this.taxonomy,name:e,parent:t})});if(!s.ok)throw new Error(`Server error: ${s.status}`);return await s.json()}catch(e){throw console.error("Error creating term:",e),e}finally{this.form.querySelector("button").disabled=!1,r&&(r.hidden=!0)}}async searchExistingTerms(e){return new Promise((t=>{const r=(e,n)=>{"data-loaded"===e&&(this.selector.store.unsubscribe(r),t(n.data?.items||[]))};this.selector.store.subscribe(r),this.selector.store.setFilters({taxonomy:this.taxonomy,search:e,page:1,parent:0})}))}showTermSuggestions(e,t=!1){const r=this.createNew.querySelector(".term-suggestions")||this.createSuggestionContainer();window.removeChildren(r);const n=document.createElement("h4");n.textContent=t?"This term already exists:":"Similar terms already exist:",r.appendChild(n);const o=document.createElement("ul");o.className="term-suggestion-list",e.forEach((e=>{const t=document.createElement("li"),n=document.createElement("button");n.type="button",n.className="use-existing-term",n.setAttribute("data-id",e.id),n.textContent=e.path||e.name,n.addEventListener("click",(()=>{this.selector.addSelectedTermToModal(e.id,e.name,e.path||e.name),this.createNew.open=!1,r.hidden=!0,this.form.querySelector('input[name="term_name"]').value=""})),t.appendChild(n),o.appendChild(t)})),r.appendChild(o),r.hidden=!1}createSuggestionContainer(){const e=document.createElement("div");return e.className="term-suggestions",e.hidden=!0,this.createNew.querySelector("form").after(e),e}createAutocompleteOption(e,t){const r=document.createElement("button");return r.type="button",r.className="autocomplete-item create-term",r.innerHTML=`<span>Create "${e}"</span>`,r.dataset.query=e,r.dataset.fieldId=t.id,r.addEventListener("click",(async()=>{await this.handleAutocompleteCreate(r,e,t)})),r}async handleAutocompleteCreate(e,t,r){if(!r)return;const n=e.innerHTML;try{e.disabled=!0,e.innerHTML="<span>Creating...</span>";const n=0,o=await this.createTerm(t,n);if(o.success&&o.term){const e=o.term;r.selectedTerms.add(parseInt(e.id)),this.selector.addTermToDisplay(r.id,e.id,e.name,e.path||e.name),r.input.value=Array.from(r.selectedTerms).join(","),r.input.dispatchEvent(new Event("change",{bubbles:!0})),await this.selector.store.invalidate({taxonomy:r.taxonomy}),r.autocompleteDropdown.hidden=!0;const t=r.container.querySelector("input[data-autocomplete]");t&&(t.value="")}}catch(t){console.error("Error creating term:",t),e.innerHTML=n,e.disabled=!1,this.selector.error?.log(t,{component:"TaxonomyCreator",action:"handleAutocompleteCreate"})}}destroy(){this.clickHandler&&document.removeEventListener("click",this.clickHandler);const e=this.createNew?.querySelector(".loading-message.create-term");e&&(e.hidden=!0);const t=this.createNew?.querySelector(".term-suggestions");t&&(t.hidden=!0)}};
\ No newline at end of file
diff --git a/assets/js/min/crud.min.js b/assets/js/min/crud.min.js
index 599b61c..755b286 100644
--- a/assets/js/min/crud.min.js
+++ b/assets/js/min/crud.min.js
@@ -1 +1 @@
-(()=>{class e{constructor(e){this.queue=window.jvbQueue,console.log(this.queue),this.config=e,this.content=e.content||!1,this.content&&(this.initElements(),this.updateBulkOptions(),this.store=new window.jvbStore({name:this.content,storeName:this.content,endpoint:"content",headers:{action_nonce:jvbSettings.dash},indexes:[{name:"status",keyPath:"post_status"},{name:"modified",keyPath:"modified"}],filters:{content:this.content,user:jvbSettings.currentUser,page:1,status:"all"},TTL:36e5,cacheDOM:!0}),this.status="all",this.filterTimeout=null,this.viewController=new window.jvbViews(this.ui.container,this.store),this.formController=new window.jvbForm(this.store),this.formController.subscribe(((e,t)=>{switch(e){case"form-submit":case"form-autosave":this.handleFormChange(e,t)}})),window.jvbQueue&&window.jvbQueue.subscribe(((e,t)=>{"operation-completed"===e&&"form"===t.source?this.handleQueueSuccess(e,t):"operation-failed-permanent"===e&&"form"===t.source&&this.handleQueueFailure(e,t)})),this.initialized=!1,this.init())}handleFormChange(e,t){t.changes.content=this.content;let s={},i="",o=[];switch(!0){case t.config.element===this.ui.forms.edit:let l=t.config.id.replace("edit-","");console.log(l),s[l]=t.changes,i=`Saving ${t.fullData.post_title} Changes`,t.changes.post_status&&this.shouldRemoveItem(t.changes.post_status)&&o.push(l);break;case t.config.element===this.ui.forms.bulkEdit:let n=t.config.element.querySelectorAll(".selected input:checked");n.forEach((e=>{s[e.value]=t.changes,t.changes.post_status&&this.shouldRemoveItem(t.changes.post_status)&&o.push(e.value)})),i=`Updating ${n.length} ${this.config.plural??"posts"} Changes`;break;case t.config.element===this.ui.forms.create:"form-submit"===e&&(s[t.config.data["form-id"]]=t.fullData,i=`Saving ${t.fullData.post_title} Changes`)}if(o.length>0){let e=0;o.forEach((t=>{setTimeout((()=>{const e=document.querySelector(`.item[data-id="${t}"]`);e&&window.fade(e,!1)}),e),e+=50})),t.config.element===this.ui.forms.bulkEdit&&setTimeout((()=>{this.viewController.clearSelection()}),e+100)}window.isEmptyObject(s)||this.savePosts(s,i)}shouldRemoveItem(e){return"all"===this.status&&!["publish","draft"].includes(e)||e!==this.status}savePosts(e,t){if(window.isEmptyObject(e))return;let s={endpoint:"content",headers:{action_nonce:jvbSettings.dash},data:{posts:e},popup:"Saving changes",title:t};this.queue.addToQueue(s)}handleQueueSuccess(e,t){console.log("Handling queue success..."),console.log("Event",e),console.log("Data",t)}handleQueueFailure(e,t){console.log("Handling queue failure..."),console.log("Event",e),console.log("Data",t)}initElements(){this.elements={modals:{create:"dialog.create",edit:"dialog.edit",bulkEdit:"dialog.bulkEdit"},container:".crud[data-content]",grid:".item-grid",bulkSelectActions:".bulk-action-select",forms:{create:"dialog.create form",edit:"dialog.edit form",bulkEdit:"dialog.bulkEdit form"}},this.ui=window.uiFromSelectors(this.elements)}init(){this.filterHandler=this.handleFilterChange.bind(this),this.changeHandler=this.handleChange.bind(this),this.modals={};for(let[e,t]of Object.entries(this.ui.modals))this.modals[e]=new window.jvbModal(t),this.modals[e].subscribe(((t,s)=>{if("modal-close"===t)this.formController.cleanupForm(this.modals[e].modal.querySelector("form").dataset.formId),console.log("Data on modal close: ",s)}));this.setupEventDelegation(),this.setupFilters(),this.store.fetch(),this.queue.subscribe(((e,t)=>{e})),this.initialized=!0}setupEventDelegation(){document.addEventListener("change",this.changeHandler),document.addEventListener("click",(e=>{const t=e.target.closest("[data-action]");if(t){e.preventDefault();const s=t.dataset.action,i=t.dataset.id;switch(s){case"edit":this.populateEditForm(i),this.modals.edit.handleOpen();break;case"delete":if(confirm("Delete this item?")){let e={};e[t.dataset.id]={post_status:"delete",content:this.content},window.fade(t.closest(".item"),!1),this.savePosts(e,`Sending ${this.singular} to trash...`),this.store.delete(i)}break;case"trash":let e={};e[t.dataset.id]={post_status:"trash",content:this.content},window.fade(t.closest(".item"),!1),this.savePosts(e,`Sending ${this.singular} to trash...`);break;case"create":this.modals.create.dataset.itemID="new",this.modals.create.dataset.content=this.content,this.modals.create.handleOpen();break;case"bulk-edit":Array.from(this.viewController.selectedItems).length>0&&this.modals.bulkEdit.handleOpen();break;case"bulk-delete":const s=Array.from(this.viewController.selectedItems);s.length>0&&confirm(`Delete ${s.length} items?`)&&(s.forEach((e=>this.store.delete(e))),this.viewController.clearSelection());break;case"sync":break;case"refresh":this.store.fetch()}}e.target.closest(".create-item")&&(this.formController.registerForm(this.ui.forms.create),this.modals.create.handleOpen()),e.target.closest(".cancel-bulk")&&this.viewController.selectAll(!1)})),document.addEventListener("keydown",(e=>{(e.ctrlKey||e.metaKey)&&"a"===e.key&&this.ui.container&&this.ui.container.contains(document.activeElement)&&(e.preventDefault(),this.viewController.selectAll()),"Escape"===e.key&&this.viewController?.selectedItems.size>0&&0===window.jvbModal.getAllModals().length&&this.viewController.clearSelection()}))}handleChange(e){if(e.target.classList.contains("bulk-action-select")){if(e.target.value.startsWith("tax-")){const t=e.target.value.replace("tax-","");return this.openTaxonomyModal(t),void(e.target.value="")}switch(e.target.value){case"edit":this.populateBulkEdit(),this.modals.bulkEdit.handleOpen();break;case"publish":this.setBulkStatus("publish");break;case"draft":case"restore":this.setBulkStatus("draft");break;case"trash":this.setBulkStatus("trash");break;case"delete":this.setBulkStatus("delete")}}}openTaxonomyModal(e){window.jvbSelector?window.jvbSelector.openForFilter(e,((e,t)=>this.handleBulkTaxonomy(e,t))):console.error("TaxonomySelector not initialized")}handleBulkTaxonomy(e,t){if(console.log(t,e),e.length>0){e=e.join(",");let s={},i=Array.from(this.viewController.selectedItems);console.log("selected",i),i.forEach((i=>{s[i]={content:this.content},s[i][t]=e})),console.log("Taxonomy changes: ",s);let o=`Adding ${i.length} ${this.config.plural??"posts"} to ${e.length} ${jvbSettings.labels[t].plural}`;this.viewController.clearSelection(),this.savePosts(s,o)}}setBulkStatus(e){if(!["publish","draft","trash","delete"].includes(e))return;console.log(`Setting status: ${e}`);let t,s={};for(let t of this.viewController.selectedItems)s[t]={post_status:e,content:this.content};if("delete"===e)t="Deleting";else t=window.uppercaseFirst(e)+"ing";if(console.log(this.status),"all"===this.status&&!["publish","draft"].includes(e)||e!==this.status){let e=0;for(let t of this.viewController.selectedItems)setTimeout((()=>{const e=document.querySelector(`.item[data-id="${t}"]`);e&&window.fade(e,!1)}),e),e+=50}this.viewController.clearSelection(),window.isEmptyObject(s)||this.savePosts(s,`${t} ${this.viewController.selectedItems.size} ${this.plural}...`)}handleFilterChange(e){let t=e.target,s=t.dataset.filter;if("taxonomies"===s){let e=t.dataset.taxonomy;this.store.setFilter(`tax_${e}`,s.value)}else this[t.dataset.filter]=t.value,this.store.setFilter(t.dataset.filter,t.value),"status"===t.dataset.filter&&this.updateBulkOptions(t.value)}updateBulkOptions(e="all"){if("trash"===e){if(this.ui.bulkSelectActions.querySelector('[value="edit"]')){window.removeChildren(this.ui.bulkSelectActions),window.getTemplate("trashOptions").querySelectorAll("option").forEach(((e,t)=>{0===t&&(e.checked=!0),this.ui.bulkSelectActions.append(e)}))}}else if(!this.ui.bulkSelectActions.querySelector('[value="edit"]')){window.removeChildren(this.ui.bulkSelectActions),window.getTemplate("notTrashOptions").querySelectorAll("option").forEach(((e,t)=>{this.ui.bulkSelectActions.append(e)}))}this.ui.bulkSelectActions.value=""}populateBulkEdit(){const e=this.modals.bulkEdit.modal.querySelector("form .selected");if(!e)return;window.removeChildren(e);for(let t of this.viewController.selectedItems){console.log(t);let s=this.store.get(t);console.log(s);const i=window.getTemplate("bulkItem");if(!i)return;const o=i.querySelector("input[type=checkbox]"),l=i.querySelector("img");o&&(o.id=`bulk_${s.id}`,o.value=s.id,o.checked=!0),l&&s.thumbnail&&(l.src=s.thumbnail,l.alt=s.alt||""),e.append(i)}let t=this.modals.bulkEdit.modal;[t.querySelector("h2 span").textContent]=[this.viewController.selectedItems.size],this.formController.registerForm(this.ui.forms.bulkEdit),console.log("Bulk Edit form registered")}populateEditForm(e){let t=this.store.get(e);if(console.log(t),t){this.ui.modals.edit.dataset.itemID=e,this.ui.modals.edit.dataset.content=this.content;let s=this.ui.modals.edit.querySelector("form");[this.ui.modals.edit.querySelector("h2").textContent]=[`Editing ${t.fields.post_title}`],s.dataset.formId=`edit-${e}`,console.log(s.dataset.formId),new window.jvbPopulate(s,t.fields,t.images),this.formController.registerForm(this.ui.forms.edit),console.log("Edit form registered")}}setupFilters(){document.querySelectorAll("[data-filter]").forEach((e=>{e.addEventListener("change",(e=>{this.filterTimeout&&clearTimeout(this.filterTimeout),this.filterTimeout=setTimeout((()=>{this.filterHandler(e)}),300)}))}));const e=document.querySelector('input[type="search"]');if(e){let t;e.addEventListener("input",(()=>{e.value.length>3?(clearTimeout(t),t=setTimeout((()=>{this.store.setFilter("search",e.value)}),300)):0===e.value.length&&this.store.removeFilter("search")}))}}destroy(){document.querySelectorAll("[data-filter]").forEach((e=>{e.removeEventListener("change",this.filterHandler)})),this.store.subscribers.clear()}}document.addEventListener("DOMContentLoaded",(()=>{let t=document.querySelector("[data-content]");t&&(window.crudManager=new e({content:t.dataset.content}))}))})();
\ No newline at end of file
+(()=>{class e{constructor(e){this.queue=window.jvbQueue,console.log(this.queue),this.config=e,this.content=e.content||!1,this.settings=window.jvbUserSettings,this.content&&(this.initElements(),this.updateBulkOptions(),this.store=new window.jvbStore({name:this.content,storeName:this.content,endpoint:"content",headers:{action_nonce:jvbSettings.dash},indexes:[{name:"status",keyPath:"post_status"},{name:"modified",keyPath:"modified"}],filters:{content:this.content,user:jvbSettings.currentUser,page:1,status:"all"},TTL:36e5,cacheDOM:!0}),this.status="all",this.filterTimeout=null,this.viewController=new window.jvbViews(this.ui.container,this.store),this.formController=new window.jvbForm,this.formController.subscribe(((e,t)=>{switch(e){case"form-submit":case"form-autosave":this.handleFormChange(e,t)}})),window.jvbQueue&&window.jvbQueue.subscribe(((e,t)=>{"operation-completed"===e&&"form"===t.source?this.handleQueueSuccess(e,t):"operation-failed-permanent"===e&&"form"===t.source&&this.handleQueueFailure(e,t)})),this.initialized=!1,this.init())}handleFormChange(e,t){t.changes.content=this.content;let s={},i="",o=[];switch(!0){case t.config.element===this.ui.forms.edit:let l=t.config.id.replace("edit-","");console.log(l),s[l]=t.changes,i=`Saving ${t.fullData.post_title} Changes`,t.changes.post_status&&this.shouldRemoveItem(t.changes.post_status)&&o.push(l);break;case t.config.element===this.ui.forms.bulkEdit:let n=t.config.element.querySelectorAll(".selected input:checked");n.forEach((e=>{s[e.value]=t.changes,t.changes.post_status&&this.shouldRemoveItem(t.changes.post_status)&&o.push(e.value)})),i=`Updating ${n.length} ${this.config.plural??"posts"} Changes`;break;case t.config.element===this.ui.forms.create:"form-submit"===e&&(s[t.config.data["form-id"]]=t.fullData,i=`Saving ${t.fullData.post_title} Changes`)}if(o.length>0){let e=0;o.forEach((t=>{setTimeout((()=>{const e=document.querySelector(`.item[data-id="${t}"]`);e&&window.fade(e,!1)}),e),e+=50})),t.config.element===this.ui.forms.bulkEdit&&setTimeout((()=>{this.viewController.clearSelection()}),e+100)}window.isEmptyObject(s)||this.savePosts(s,i)}shouldRemoveItem(e){return"all"===this.status&&!["publish","draft"].includes(e)||e!==this.status}savePosts(e,t){if(window.isEmptyObject(e))return;let s={endpoint:"content",headers:{action_nonce:jvbSettings.dash},data:{posts:e},popup:"Saving changes",title:t};this.queue.addToQueue(s)}handleQueueSuccess(e,t){console.log("Handling queue success..."),console.log("Event",e),console.log("Data",t)}handleQueueFailure(e,t){console.log("Handling queue failure..."),console.log("Event",e),console.log("Data",t)}initElements(){this.elements={modals:{create:"dialog.create",edit:"dialog.edit",bulkEdit:"dialog.bulkEdit"},container:".crud[data-content]",grid:".item-grid",bulkSelectActions:".bulk-action-select",forms:{create:"dialog.create form",edit:"dialog.edit form",bulkEdit:"dialog.bulkEdit form"},uploader:"details.uploader"},this.ui=window.uiFromSelectors(this.elements)}init(){this.settings.addSetting(this.ui.uploader,"open"),this.ui.uploader.addEventListener("toggle",(e=>{console.log(e),console.log("Is Open: ",this.ui.uploader.open),console.log(this.ui.uploader.open?"on":"off"),this.settings.saveSetting("open",this.ui.uploader.open?"on":"off")})),this.filterHandler=this.handleFilterChange.bind(this),this.changeHandler=this.handleChange.bind(this),this.modals={};for(let[e,t]of Object.entries(this.ui.modals))this.modals[e]=new window.jvbModal(t),this.modals[e].subscribe(((t,s)=>{if("modal-close"===t)this.formController.cleanupForm(this.modals[e].modal.querySelector("form").dataset.formId),console.log("Data on modal close: ",s)}));this.setupEventDelegation(),this.setupFilters(),this.queue.subscribe(((e,t)=>{e})),this.initialized=!0}setupEventDelegation(){document.addEventListener("change",this.changeHandler),document.addEventListener("click",(e=>{const t=e.target.closest("[data-action]");if(t){e.preventDefault();const s=t.dataset.action,i=t.dataset.id;switch(s){case"edit":this.populateEditForm(i),this.modals.edit.handleOpen();break;case"delete":if(confirm("Delete this item?")){let e={};e[t.dataset.id]={post_status:"delete",content:this.content},window.fade(t.closest(".item"),!1),this.savePosts(e,`Sending ${this.singular} to trash...`),this.store.delete(i)}break;case"trash":let e={};e[t.dataset.id]={post_status:"trash",content:this.content},window.fade(t.closest(".item"),!1),this.savePosts(e,`Sending ${this.singular} to trash...`);break;case"create":this.modals.create.dataset.itemID="new",this.modals.create.dataset.content=this.content,this.modals.create.handleOpen();break;case"bulk-edit":Array.from(this.viewController.selectedItems).length>0&&this.modals.bulkEdit.handleOpen();break;case"bulk-delete":const s=Array.from(this.viewController.selectedItems);s.length>0&&confirm(`Delete ${s.length} items?`)&&(s.forEach((e=>this.store.delete(e))),this.viewController.clearSelection());break;case"sync":break;case"refresh":this.store.fetch()}}e.target.closest(".create-item")&&(this.formController.registerForm(this.ui.forms.create),this.modals.create.handleOpen()),e.target.closest(".cancel-bulk")&&this.viewController.selectAll(!1)})),document.addEventListener("keydown",(e=>{(e.ctrlKey||e.metaKey)&&"a"===e.key&&this.ui.container&&this.ui.container.contains(document.activeElement)&&(e.preventDefault(),this.viewController.selectAll()),"Escape"===e.key&&this.viewController?.selectedItems.size>0&&0===window.jvbModal.getAllModals().length&&this.viewController.clearSelection()}))}handleChange(e){if(e.target.classList.contains("bulk-action-select")){if(e.target.value.startsWith("tax-")){const t=e.target.value.replace("tax-","");return this.openTaxonomyModal(t),void(e.target.value="")}switch(e.target.value){case"edit":this.populateBulkEdit(),this.modals.bulkEdit.handleOpen();break;case"publish":this.setBulkStatus("publish");break;case"draft":case"restore":this.setBulkStatus("draft");break;case"trash":this.setBulkStatus("trash");break;case"delete":this.setBulkStatus("delete")}}}openTaxonomyModal(e){window.jvbSelector?window.jvbSelector.openForFilter(e,((e,t)=>this.handleBulkTaxonomy(e,t))):console.error("TaxonomySelector not initialized")}handleBulkTaxonomy(e,t){if(console.log(t,e),e.length>0){e=e.join(",");let s={},i=Array.from(this.viewController.selectedItems);console.log("selected",i),i.forEach((i=>{s[i]={content:this.content},s[i][t]=e})),console.log("Taxonomy changes: ",s);let o=`Adding ${i.length} ${this.config.plural??"posts"} to ${e.length} ${jvbSettings.labels[t].plural}`;this.viewController.clearSelection(),this.savePosts(s,o)}}setBulkStatus(e){if(!["publish","draft","trash","delete"].includes(e))return;console.log(`Setting status: ${e}`);let t,s={};for(let t of this.viewController.selectedItems)s[t]={post_status:e,content:this.content};if("delete"===e)t="Deleting";else t=window.uppercaseFirst(e)+"ing";if(console.log(this.status),"all"===this.status&&!["publish","draft"].includes(e)||e!==this.status){let e=0;for(let t of this.viewController.selectedItems)setTimeout((()=>{const e=document.querySelector(`.item[data-id="${t}"]`);e&&window.fade(e,!1)}),e),e+=50}this.viewController.clearSelection(),window.isEmptyObject(s)||this.savePosts(s,`${t} ${this.viewController.selectedItems.size} ${this.plural}...`)}handleFilterChange(e){let t=e.target,s=t.dataset.filter;if("taxonomies"===s){let e=t.dataset.taxonomy;this.store.setFilter(`tax_${e}`,s.value)}else this[t.dataset.filter]=t.value,this.store.setFilter(t.dataset.filter,t.value),"status"===t.dataset.filter&&this.updateBulkOptions(t.value)}updateBulkOptions(e="all"){if("trash"===e){if(this.ui.bulkSelectActions.querySelector('[value="edit"]')){window.removeChildren(this.ui.bulkSelectActions),window.getTemplate("trashOptions").querySelectorAll("option").forEach(((e,t)=>{0===t&&(e.checked=!0),this.ui.bulkSelectActions.append(e)}))}}else if(!this.ui.bulkSelectActions.querySelector('[value="edit"]')){window.removeChildren(this.ui.bulkSelectActions),window.getTemplate("notTrashOptions").querySelectorAll("option").forEach(((e,t)=>{this.ui.bulkSelectActions.append(e)}))}this.ui.bulkSelectActions.value=""}populateBulkEdit(){const e=this.modals.bulkEdit.modal.querySelector("form .selected");if(!e)return;window.removeChildren(e);for(let t of this.viewController.selectedItems){console.log(t);let s=this.store.get(t);console.log(s);const i=window.getTemplate("bulkItem");if(!i)return;const o=i.querySelector("input[type=checkbox]"),l=i.querySelector("img");o&&(o.id=`bulk_${s.id}`,o.value=s.id,o.checked=!0),l&&s.thumbnail&&(l.src=s.thumbnail,l.alt=s.alt||""),e.append(i)}let t=this.modals.bulkEdit.modal;[t.querySelector("h2 span").textContent]=[this.viewController.selectedItems.size],this.formController.registerForm(this.ui.forms.bulkEdit),console.log("Bulk Edit form registered")}populateEditForm(e){console.log(e);let t=this.store.get(parseInt(e));if(console.log(t),t){this.ui.modals.edit.dataset.itemID=e,this.ui.modals.edit.dataset.content=this.content;let s=this.ui.modals.edit.querySelector("form");[this.ui.modals.edit.querySelector("h2").textContent]=[`Editing ${t.fields.post_title}`],s.dataset.formId=`edit-${e}`,console.log(s.dataset.formId),new window.jvbPopulate(s,t.fields,t.images),this.formController.registerForm(this.ui.forms.edit),console.log("Edit form registered")}}setupFilters(){document.querySelectorAll("[data-filter]").forEach((e=>{this.settings.addSetting(e),e.addEventListener("change",(e=>{this.filterTimeout&&clearTimeout(this.filterTimeout),this.filterTimeout=setTimeout((()=>{this.filterHandler(e)}),300)}))}));const e=document.querySelector('input[type="search"]');if(e){let t;e.addEventListener("input",(()=>{e.value.length>3?(clearTimeout(t),t=setTimeout((()=>{this.store.setFilter("search",e.value)}),300)):0===e.value.length&&this.store.removeFilter("search")}))}}destroy(){document.querySelectorAll("[data-filter]").forEach((e=>{e.removeEventListener("change",this.filterHandler)})),this.store.subscribers.clear()}}document.addEventListener("DOMContentLoaded",(()=>{let t=document.querySelector("[data-content]");t&&(window.crudManager=new e({content:t.dataset.content}))}))})();
\ No newline at end of file
diff --git a/assets/js/min/dataStore.min.js b/assets/js/min/dataStore.min.js
index 00c15b9..6ddf722 100644
--- a/assets/js/min/dataStore.min.js
+++ b/assets/js/min/dataStore.min.js
@@ -1 +1 @@
-window.jvbStore=class{constructor(e={}){this.config={name:"default",version:1,storeName:"items",keyPath:"id",indexes:[],endpoint:null,apiBase:jvbSettings.api,headers:{},filters:{},TTL:36e5,useHttpCaching:!0,cacheKeyStrategy:"filters",showLoading:!0,stripDOMReferences:!0,storeBlobs:!1,...e},this.db=null,this.data=new Map,this.cache=new Map,this.httpHeaders=new Map,this.subscribers=new Set,this.currentRequest=null,this.filters=this.config.filters??{},this.headers={"X-WP-Nonce":jvbSettings?.nonce,...this.config.headers},this.body=document.body,this.loading=document.querySelector("dialog.loading"),this.initDB(),window.addEventListener("beforeunload",(()=>this.destroy()))}async initDB(){if(!("indexedDB"in window))return void console.warn("IndexedDB not supported");const e=`jvb_${this.config.name}_db`,t=indexedDB.open(e,this.config.version);t.onupgradeneeded=e=>{const t=e.target.result;if(!t.objectStoreNames.contains(this.config.storeName)){const e=t.createObjectStore(this.config.storeName,{keyPath:this.config.keyPath});this.config.indexes.forEach((t=>{e.createIndex(t.name,t.keyPath||t.name,{unique:t.unique||!1})}))}if(this.config.endpoint&&!t.objectStoreNames.contains("cache")){const e=t.createObjectStore("cache",{keyPath:"key"});e.createIndex("timestamp","timestamp",{unique:!1}),e.createIndex("endpoint","endpoint",{unique:!1}),e.createIndex("filters","filters",{unique:!1})}this.config.useHttpCaching&&!t.objectStoreNames.contains("headers")&&t.createObjectStore("headers",{keyPath:"key"}),this.config.storeBlobs&&!t.objectStoreNames.contains("blobs")&&t.createObjectStore("blobs",{keyPath:"uploadId"}),this.config.onUpgrade&&this.config.onUpgrade(t,e.oldVersion,e.newVersion)},t.onsuccess=e=>{this.db=e.target.result,this.loadFromDB()},t.onerror=t=>{console.error(`IndexedDB error for ${e}:`,t),this.config.onError&&this.config.onError(t)}}async loadFromDB(){if(!this.db)return;const e=[this.loadData()];this.config.endpoint&&e.push(this.loadCache()),this.config.useHttpCaching&&e.push(this.loadHeaders());try{await Promise.all(e),this.notify("data-loaded",{count:this.data.size,store:this.config.storeName})}catch(e){console.error("Error loading from DB:",e)}}async loadData(){if(this.db)return new Promise(((e,t)=>{const s=this.db.transaction([this.config.storeName],"readonly").objectStore(this.config.storeName).getAll();s.onsuccess=t=>{t.target.result.forEach((e=>{const t=this.config.stripDOMReferences?this.stripDOMReferences(e):e,s=this.getItemKey(t);this.data.set(s,t)})),e()},s.onerror=e=>t(e)}))}stripDOMReferences(e){if(!e||"object"!=typeof e)return e;if(Array.isArray(e))return e.map((e=>this.stripDOMReferences(e)));const t={};for(const[s,i]of Object.entries(e))this.isDOMReference(s,i)||(i instanceof Set?t[s]=Array.from(i):i instanceof Map?t[s]=Object.fromEntries(i):t[s]="object"==typeof i&&null!==i?this.stripDOMReferences(i):i);return t}isDOMReference(e,t){return!!(t instanceof HTMLElement||t instanceof NodeList||t instanceof HTMLCollection||t&&void 0!==t.nodeType)||!!["element","el","dom","node","ui","container","wrapper"].some((t=>e.toLowerCase().includes(t)))}getItemKey(e){if("function"==typeof this.config.keyPath)return this.config.keyPath(e);const t=this.config.keyPath.split(".");let s=e;for(const e of t)s=s?.[e];return s}async save(e){const t=this.getItemKey(e),s=this.config.stripDOMReferences?this.stripDOMReferences(e):e;return this.data.set(t,s),await this.saveToDB(s),this.notify("item-saved",{item:s,key:t}),s}async saveToDB(e){if(this.db)return new Promise(((t,s)=>{const i=this.db.transaction([this.config.storeName],"readwrite").objectStore(this.config.storeName).put(e);i.onsuccess=()=>t(),i.onerror=e=>s(e)}))}async saveMany(e){if(!this.db)return;const t=this.db.transaction([this.config.storeName],"readwrite").objectStore(this.config.storeName),s=e.map((e=>{const s=this.config.stripDOMReferences?this.stripDOMReferences(e):e,i=this.getItemKey(s);return this.data.set(i,s),t.put(s)}));await Promise.all(s),this.notify("items-saved",{count:e.length})}get(e){return this.data.get(e)}getAll(){return Array.from(this.data.values())}async delete(e,t=null){if(this.data.delete(e),t||(t=this.config.storeName),this.db){const s=this.db.transaction([t],"readwrite").objectStore(t);await s.delete(e)}this.notify("item-deleted",{key:e})}async saveBlob(e,t){if(!this.db)return;const s=this.db.transaction(["blobs"],"readwrite").objectStore("blobs");await s.put({key:e,data:t,type:t.type,name:t.name})}async getBlob(e){return this.db?new Promise((t=>{const s=this.db.transaction(["blobs"],"readonly").objectStore("blobs").get(e);s.onsuccess=()=>t(s.result),s.onerror=()=>t(null)})):null}async clear(){if(this.data.clear(),this.cache.clear(),this.httpHeaders.clear(),this.domCache&&this.domCache.clear(),this.db){const e=[this.config.storeName];this.config.endpoint&&e.push("cache"),this.config.useHttpCaching&&e.push("headers");const t=this.db.transaction(e,"readwrite");e.forEach((e=>{this.db.objectStoreNames.contains(e)&&t.objectStore(e).clear()}))}this.notify("data-cleared")}async fetch(e={}){if(!this.config.endpoint)throw new Error("No endpoint configured for fetch");const{filters:t=this.filters,headers:s={}}=e;this.config.showLoading&&this.setLoading(!0);const i=this.generateCacheKey(t),r=this.cache.get(i);if(r&&this.isCacheValid(r))return r.data;const o={...this.headers,...s};if(this.config.useHttpCaching){const e=this.httpHeaders.get(i);e&&(e.etag&&(o["If-None-Match"]=e.etag),e.lastModified&&(o["If-Modified-Since"]=e.lastModified))}const n=this.cleanFilters(t),a=new URLSearchParams(n),c=`${this.config.apiBase}${this.config.endpoint}${a.toString()?"?"+a:""}`;try{const e=await fetch(c,{method:"GET",headers:o});if(304===e.status&&r)return r.timestamp=Date.now(),this.saveCache(i,r),r.data;if(!e.ok)throw new Error(`HTTP ${e.status}: ${e.statusText}`);const s=await e.json();this.config.useHttpCaching&&this.storeResponseHeaders(i,e);const n={key:i,data:s,timestamp:Date.now(),endpoint:this.config.endpoint,filters:t};return this.cache.set(i,n),this.saveCache(i,n),Array.isArray(s)?await this.saveMany(s):s.items&&await this.saveMany(s.items),s}catch(e){if(console.error("Fetch error:",e),r)return console.warn("Using stale cache due to fetch error"),r.data;throw e}finally{this.config.showLoading&&this.setLoading(!1)}}cleanFilters(e){const t={};return Object.entries(e).forEach((([e,s])=>{null!=s&&""!==s&&("taxonomies"===e&&"object"==typeof s?Object.entries(s).forEach((([e,s])=>{Array.isArray(s)&&s.length>0?t[`tax_${e}`]=s.join(","):s&&(t[`tax_${e}`]=s)})):"date"===e&&"object"==typeof s?(s.after&&(t.after=s.after),s.before&&(t.before=s.before)):t[e]=s)})),t}generateCacheKey(e){if("custom"===this.config.cacheKeyStrategy&&this.config.generateCacheKey)return this.config.generateCacheKey(e);const t=Object.keys(e).sort().reduce(((t,s)=>(t[s]=e[s],t)),{});return JSON.stringify(t)}setFilter(e,t){this.filters||(this.filters={});const s=this.filters[e];""===t||null==t?delete this.filters[e]:this.filters[e]=t,this.notify("filters-changed",{filters:this.filters,changed:{key:e,oldValue:s,newValue:t}}),this.config.endpoint&&this.fetch()}removeFilter(e){const t=this.filters[e];void 0!==t&&(delete this.filters[e],this.notify("filters-changed",{filters:this.filters,removed:{key:e,oldValue:t}}),this.config.endpoint&&this.fetch())}clearFilters(){const e={...this.filters};this.filters=this.config.filters,this.notify("filters-cleared",{oldFilters:e,filters:this.filters}),this.config.endpoint&&this.fetch()}setFilters(e){if(this.filters={...this.filters,...e},!1!==this.config.autoFetch)return this.fetch(this.filters)}isCacheValid(e){return!(!e||!e.timestamp)&&Date.now()-e.timestamp<this.config.TTL}storeResponseHeaders(e,t){const s={key:e,etag:t.headers.get("ETag"),lastModified:t.headers.get("Last-Modified"),timestamp:Date.now()};this.httpHeaders.set(e,s),this.db&&this.db.transaction(["headers"],"readwrite").objectStore("headers").put(s)}async saveCache(e,t){if(!this.db)return;const s=this.db.transaction(["cache"],"readwrite").objectStore("cache");await s.put(t)}async loadCache(){if(this.db)return new Promise((e=>{this.db.transaction(["cache"],"readonly").objectStore("cache").getAll().onsuccess=t=>{t.target.result.forEach((e=>{this.isCacheValid(e)&&this.cache.set(e.key,e)})),e()}}))}async loadHeaders(){if(this.db)return new Promise((e=>{this.db.transaction(["headers"],"readonly").objectStore("headers").getAll().onsuccess=t=>{t.target.result.forEach((e=>{this.httpHeaders.set(e.key,e)})),e()}}))}subscribe(e){return this.subscribers.add(e),()=>this.subscribers.delete(e)}notify(e,t={}){this.subscribers.forEach((s=>{try{s(e,t)}catch(e){console.error("Subscriber error:",e)}}))}async query(e,t){return this.db?new Promise(((s,i)=>{const r=this.db.transaction([this.config.storeName],"readonly").objectStore(this.config.storeName);if(!r.indexNames.contains(e))return void i(new Error(`Index ${e} does not exist`));const o=r.index(e),n=void 0!==t?o.getAll(t):o.getAll();n.onsuccess=e=>{const t=e.target.result.map((e=>this.config.stripDOMReferences?this.stripDOMReferences(e):e));s(t)},n.onerror=e=>i(e)})):[]}async count(){return this.db?new Promise(((e,t)=>{const s=this.db.transaction([this.config.storeName],"readonly").objectStore(this.config.storeName).count();s.onsuccess=t=>e(t.target.result),s.onerror=e=>t(e)})):this.data.size}setLoading(e){this.body.classList.toggle("loading",e),e?this.loading.showModal():this.loading.close()}destroy(){this.currentRequest&&this.currentRequest.abort(),this.subscribers.clear(),this.data.clear(),this.cache.clear(),this.httpHeaders.clear(),this.db&&(this.db.close(),this.db=null)}clearCache(){this.cache.clear(),this.db&&this.db.transaction(["cache"],"readwrite").objectStore("cache").clear(),this.notify("cache-cleared")}};
\ No newline at end of file
+window.jvbStore=class{constructor(e={}){this.config={name:"default",version:1,storeName:"items",keyPath:"id",indexes:[],endpoint:null,saveToServer:!1,apiBase:jvbSettings.api,headers:{},filters:{},required:null,icon:null,getBlobs:null,TTL:36e5,useHttpCaching:!0,cacheKeyStrategy:"filters",showLoading:!0,stripDOMReferences:!0,storeBlobs:!1,...e},this.db=null,this.data=new Map,this.cache=new Map,this.isFetching=!1,this.pendingFetch=null,this.httpHeaders=new Map,this.subscribers=new Set,this.currentRequest=null,this.filters=this.config.filters??{},this.headers={"X-WP-Nonce":jvbSettings?.nonce,...this.config.headers},this.body=document.body,this.loading=document.querySelector("dialog.loading"),this.initDB(),window.addEventListener("beforeunload",(()=>this.destroy()))}async initDB(){if(!("indexedDB"in window))return void console.warn("IndexedDB not supported");const e=`jvb_${this.config.name}_db`,t=indexedDB.open(e,this.config.version);t.onupgradeneeded=e=>{const t=e.target.result;if(!t.objectStoreNames.contains(this.config.storeName)){const e=t.createObjectStore(this.config.storeName,{keyPath:this.config.keyPath});this.config.indexes.forEach((t=>{e.createIndex(t.name,t.keyPath||t.name,{unique:t.unique||!1})}))}if(this.config.endpoint&&!t.objectStoreNames.contains("cache")){const e=t.createObjectStore("cache",{keyPath:"key"});e.createIndex("timestamp","timestamp",{unique:!1}),e.createIndex("endpoint","endpoint",{unique:!1}),e.createIndex("filters","filters",{unique:!1})}this.config.useHttpCaching&&!t.objectStoreNames.contains("headers")&&t.createObjectStore("headers",{keyPath:"key"}),this.config.storeBlobs&&!t.objectStoreNames.contains("blobs")&&t.createObjectStore("blobs",{keyPath:"uploadId"}),this.config.onUpgrade&&this.config.onUpgrade(t,e.oldVersion,e.newVersion)},t.onsuccess=async e=>{this.db=e.target.result;const t=[this.loadFromDB()];this.db.objectStoreNames.contains("cache")&&t.push(this.loadCache()),this.config.useHttpCaching&&this.db.objectStoreNames.contains("headers")&&t.push(this.loadHeaders()),await Promise.all(t),this.notify("db-init"),this.config.endpoint&&this.fetch()},t.onerror=t=>{console.error(`IndexedDB error for ${e}:`,t),this.config.onError&&this.config.onError(t)}}async loadFromDB(){if(this.db)return new Promise((async(e,t)=>{const s=this.db.transaction([this.config.storeName],"readonly").objectStore(this.config.storeName).getAll();s.onsuccess=async t=>{const s=t.target.result;for(const e of s){e.data?._isFormData&&this.config.getBlobs&&(e.data=await this.objectToFormData(e.data));const t=this.getItemKey(e);this.data.set(t,e)}this.notify("data-loaded",{count:s.length}),e(s)},s.onerror=e=>t(e)}))}async loadData(){if(this.db)return new Promise(((e,t)=>{const s=this.db.transaction([this.config.storeName],"readonly").objectStore(this.config.storeName).getAll();s.onsuccess=t=>{t.target.result.forEach((e=>{const t=this.config.stripDOMReferences?this.stripDOMReferences(e):e,s=this.getItemKey(t);this.data.set(s,t)})),e()},s.onerror=e=>t(e)}))}stripDOMReferences(e){if(!e||"object"!=typeof e)return e;if(Array.isArray(e))return e.map((e=>this.stripDOMReferences(e)));const t={};for(const[s,i]of Object.entries(e))this.isDOMReference(s,i)||(i instanceof Set?t[s]=Array.from(i):i instanceof Map?t[s]=Object.fromEntries(i):t[s]="object"==typeof i&&null!==i?this.stripDOMReferences(i):i);return t}isDOMReference(e,t){if(t instanceof HTMLElement||t instanceof NodeList||t instanceof HTMLCollection||t&&void 0!==t.nodeType)return!0;const s=["element","el","dom","node","ui","container","wrapper"],i=e.toLowerCase();return!(!s.includes(i)&&!s.some((e=>i===e||i.startsWith(e+"_")||i.endsWith("_"+e))))}getItemKey(e){if("function"==typeof this.config.keyPath)return this.config.keyPath(e);const t=this.config.keyPath.split(".");let s=e;for(const e of t)s=s?.[e];return s}async save(e){const t=this.getItemKey(e);this.data.set(t,e);let s={...e};return s.data instanceof FormData&&(s.data=this.formDataToObject(s.data)),this.config.stripDOMReferences&&(s=this.stripDOMReferences(s)),await this.saveToDB(s),this.config.endpoint&&this.saveToServer(e),this.notify("item-saved",{item:s,key:t}),s}formDataToObject(e){const t={_isFormData:!0,entries:{}};for(const[s,i]of e.entries())i instanceof File||i instanceof Blob||(t.entries[s]?(Array.isArray(t.entries[s])||(t.entries[s]=[t.entries[s]]),t.entries[s].push(i)):t.entries[s]=i);return t}async objectToFormData(e){if(!e._isFormData)return e;const t=new FormData;for(const[s,i]of Object.entries(e.entries))Array.isArray(i)?i.forEach((e=>t.append(s,e))):t.append(s,i);if(this.config.getBlobs&&e.entries.upload_ids){const s=JSON.parse(e.entries.upload_ids),i=await this.config.getBlobs(s);for(const e of i)if(e){const s=new File([e.data],e.name,{type:e.type,lastModified:e.lastModified});t.append("files[]",s)}}return t}async saveToDB(e){if(this.db)return new Promise(((t,s)=>{const i=this.db.transaction([this.config.storeName],"readwrite").objectStore(this.config.storeName).put(e);i.onsuccess=()=>t(),i.onerror=e=>s(e)}))}async saveMany(e){if(!this.db)return;const t=this.db.transaction([this.config.storeName],"readwrite").objectStore(this.config.storeName),s=e.map((e=>{const s=this.config.stripDOMReferences?this.stripDOMReferences(e):e,i=this.getItemKey(s);return this.data.set(i,s),t.put(s)}));await Promise.all(s),this.notify("items-saved",{count:e.length})}get(e){return this.data.get(e)}getAll(){return Array.from(this.data.values())}async delete(e,t=null){if(this.data.delete(e),t||(t=this.config.storeName),this.db){const s=this.db.transaction([t],"readwrite").objectStore(t);await s.delete(e)}this.notify("item-deleted",{key:e})}async saveBlob(e,t){if(!this.db)return;const s=this.db.transaction(["blobs"],"readwrite").objectStore("blobs");await s.put({uploadId:e,data:t,type:t.type,name:t.name,lastModified:t.lastModified||Date.now()})}async getBlob(e){return this.db?new Promise((t=>{const s=this.db.transaction(["blobs"],"readonly").objectStore("blobs").get(e);s.onsuccess=()=>t(s.result),s.onerror=()=>t(null)})):null}async clear(){if(this.data.clear(),this.cache.clear(),this.httpHeaders.clear(),this.domCache&&this.domCache.clear(),this.db){const e=[this.config.storeName];this.config.endpoint&&e.push("cache"),this.config.useHttpCaching&&e.push("headers");const t=this.db.transaction(e,"readwrite");e.forEach((e=>{this.db.objectStoreNames.contains(e)&&t.objectStore(e).clear()}))}this.notify("data-cleared")}async fetch(e={}){if(!this.config.endpoint)throw new Error("No endpoint configured for fetch");const{filters:t=this.filters,headers:s={}}=e;if(this.config.required&&""===this.filters[this.config.required])return void console.log(this.config.storeName+": Not fetch as we don't have the required items");const i=this.generateCacheKey(t);if(console.log("CacheKey: ",i),this.isFetching&&this.currentCacheKey===i)return new Promise((e=>{this.pendingFetches||(this.pendingFetches=[]),this.pendingFetches.push(e)}));this.isFetching=!0,this.currentCacheKey=i;let n=null;this.config.showLoading&&this.setLoading(!0);const o=this.cache.get(i);if(console.log("Cached Data: ",o),o&&this.isCacheValid(o))return console.log("Returning cached data: "),this.isFetching=!1,this.currentCacheKey=null,this.config.showLoading&&this.setLoading(!1),o.data;const r={...this.headers,...s};if(this.config.useHttpCaching){const e=this.httpHeaders.get(i);e&&(e.etag&&(r["If-None-Match"]=e.etag),e.lastModified&&(r["If-Modified-Since"]=e.lastModified))}const a=this.cleanFilters(t),c=new URLSearchParams(a),h=`${this.config.apiBase}${this.config.endpoint}${c.toString()?"?"+c:""}`;try{const e=await fetch(h,{method:"GET",headers:r});if(304===e.status&&o)return o.timestamp=Date.now(),o.fromCache=!0,o.isError=!1,this.saveCache(i,o),console.log(this.config.storeName+" Data loaded from cache"),this.notify("data-loaded",o),n=o.data,o.data;if(!e.ok)throw new Error(`HTTP ${e.status}: ${e.statusText}`);const s=await e.json();this.config.useHttpCaching&&this.storeResponseHeaders(i,e);const a={key:i,data:s,timestamp:Date.now(),endpoint:this.config.endpoint,filters:t};console.log(this.config.storeName+"Fetched fresh from server"),this.cache.set(i,a),this.saveCache(i,a);let c=Array.isArray(s)?s:s.items;return await this.saveMany(c),this.notify("data-loaded",{data:{items:c,...s},count:c.length,filters:t,fromCache:!1,isError:!1}),n=s,s}catch(e){if(console.error("Fetch error:",e),o)return console.warn("Using stale cache due to fetch error"),o.isError=!0,this.notify("data-loaded",o),n=o.data,o.data;throw e}finally{this.config.showLoading&&this.setLoading(!1),this.isFetching=!1,this.currentCacheKey=null,this.pendingFetches&&this.pendingFetches.length>0&&(this.pendingFetches.forEach((e=>e(n))),this.pendingFetches=[])}}async saveToServer(e){if(!this.config.saveToServer||!jvbSettings.currentUser)return;if(!this.config.endpoint&&this.config.saveToServer)throw new Error("No endpoint configured for saving to server");let t,s=this.config.headers;s["X-WP-Nonce"]=jvbSettings.nonce,e instanceof FormData?(e.append("user",jvbSettings.currentUser),t=e):(t=JSON.stringify({...e,user:jvbSettings.currentUser}),s["Content-Type"]="application/json");const i=await fetch(`${this.config.apiBase}${this.config.endpoint}`,{method:"POST",headers:s,body:t}),n=await i.json();this.notify("saved-to-server",{success:n.ok&&n.success})}cleanFilters(e){const t={};return Object.entries(e).forEach((([e,s])=>{null!=s&&""!==s&&("taxonomies"===e&&"object"==typeof s?Object.entries(s).forEach((([e,s])=>{Array.isArray(s)&&s.length>0?t[`tax_${e}`]=s.join(","):s&&(t[`tax_${e}`]=s)})):"date"===e&&"object"==typeof s?(s.after&&(t.after=s.after),s.before&&(t.before=s.before)):t[e]=s)})),t}generateCacheKey(e){if("custom"===this.config.cacheKeyStrategy&&this.config.generateCacheKey)return this.config.generateCacheKey(e);const t=Object.keys(e).sort().reduce(((t,s)=>(t[s]=e[s],t)),{});return JSON.stringify(t)}setFilter(e,t){this.filters||(this.filters={});const s=this.filters[e];s!==t&&(""===t||null==t?delete this.filters[e]:this.filters[e]=t,this.notify("filters-changed",{filters:this.filters,changed:{key:e,oldValue:s,newValue:t}}),this.config.endpoint&&window.debouncer.schedule(this.config.endpoint,this.fetch.bind(this),100))}removeFilter(e){const t=this.filters[e];void 0!==t&&(delete this.filters[e],this.notify("filters-changed",{filters:this.filters,removed:{key:e,oldValue:t}}),this.config.endpoint&&window.debouncer.schedule(this.config.endpoint,this.fetch.bind(this),100))}clearFilters(){const e={...this.filters};this.filters=this.config.filters,this.notify("filters-cleared",{oldFilters:e,filters:this.filters}),this.config.endpoint&&this.fetch()}async setFilters(e){Object.keys(e).some((t=>this.filters[t]!==e[t]))&&(this.filters={...this.filters,...e},this.notify("filters-changed",{filters:this.filters,changed:e}),this.config.endpoint&&window.debouncer.schedule(this.config.endpoint,this.fetch.bind(this),100))}isCacheValid(e){return!(!e||!e.timestamp)&&Date.now()-e.timestamp<this.config.TTL}storeResponseHeaders(e,t){const s={key:e,etag:t.headers.get("ETag"),lastModified:t.headers.get("Last-Modified"),timestamp:Date.now()};this.httpHeaders.set(e,s),this.db&&this.db.objectStoreNames.contains("headers")&&this.db.transaction(["headers"],"readwrite").objectStore("headers").put(s)}async saveCache(e,t){if(!this.db||!this.db.objectStoreNames.contains("cache"))return;const s=this.db.transaction(["cache"],"readwrite").objectStore("cache");await s.put(t)}async loadCache(){if(this.db)return new Promise((e=>{this.db.transaction(["cache"],"readonly").objectStore("cache").getAll().onsuccess=t=>{t.target.result.forEach((e=>{this.isCacheValid(e)&&this.cache.set(e.key,e)})),e()}}))}async loadHeaders(){if(this.db)return new Promise((e=>{this.db.transaction(["headers"],"readonly").objectStore("headers").getAll().onsuccess=t=>{t.target.result.forEach((e=>{this.httpHeaders.set(e.key,e)})),e()}}))}subscribe(e){return this.subscribers.add(e),()=>this.subscribers.delete(e)}notify(e,t={}){this.subscribers.forEach((s=>{try{s(e,t)}catch(e){console.error("Subscriber error:",e)}}))}async query(e,t){return this.db?new Promise(((s,i)=>{const n=this.db.transaction([this.config.storeName],"readonly").objectStore(this.config.storeName);if(!n.indexNames.contains(e))return void i(new Error(`Index ${e} does not exist`));const o=n.index(e),r=void 0!==t?o.getAll(t):o.getAll();r.onsuccess=e=>{const t=e.target.result.map((e=>this.config.stripDOMReferences?this.stripDOMReferences(e):e));s(t)},r.onerror=e=>i(e)})):[]}async count(){return this.db?new Promise(((e,t)=>{const s=this.db.transaction([this.config.storeName],"readonly").objectStore(this.config.storeName).count();s.onsuccess=t=>e(t.target.result),s.onerror=e=>t(e)})):this.data.size}setLoading(e){console.log("on"),this.body.classList.toggle("loading",e),e?this.loading.showModal():this.loading.close()}destroy(){this.currentRequest&&this.currentRequest.abort(),this.subscribers.clear(),this.data.clear(),this.cache.clear(),this.httpHeaders.clear(),this.db&&(this.db.close(),this.db=null)}clearCache(){this.cache.clear(),this.db&&this.db.transaction(["cache"],"readwrite").objectStore("cache").clear(),this.notify("cache-cleared")}};
\ No newline at end of file
diff --git a/assets/js/min/form.min.js b/assets/js/min/form.min.js
index ff139e4..bc32190 100644
--- a/assets/js/min/form.min.js
+++ b/assets/js/min/form.min.js
@@ -1 +1 @@
-(()=>{class e{constructor(){this.store=new window.jvbStore({name:"forms",storeName:"forms",keyPath:"formId",indexes:[{name:"status",keyPath:"status"},{name:"operationId",keyPath:"operationId"},{name:"timestamp",keyPath:"timestamp"},{name:"formType",keyPath:"type"}],TTL:6048e5}),this.debouncer=window.debouncer,this.ignore=[],this.populateForm=window.jvbPopulate,this.subscribers=new Set,this.forms=new Map,this.specialFields=new Map,this.dependencies=new Map,this.validators=this.initValidators(),this.touchedFields=new Set,this.autoSaveDefaults={delay:3e3,typingDelay:1500,enabled:!0},this.activeRepeaters=new Map,this.repeaterDelays={change:6e3,typing:3e3,blur:1500,add:500,remove:800,reorder:1e3},this.clickHandler=this.handleClick.bind(this),this.changeHandler=this.handleChange.bind(this),this.submitHandler=this.handleSubmit.bind(this),this.inputHandler=this.handleInput.bind(this),this.focusHandler=this.handleFocus.bind(this),this.blurHandler=this.handleBlur.bind(this),this.init()}async init(){await this.checkPendingOperations(),this.store.subscribe(this.handleStoreEvent.bind(this)),this.initListeners()}handleStoreEvent(e,t){switch(e){case"item-saved":t.item.status;break;case"data-loaded":this.checkPendingForms()}}async checkPendingForms(){(await this.store.query("status","draft")).forEach((e=>{let t=this.forms.get(e.formId);t&&t.element&&(t.element.querySelector(".restore-form").hidden=!1,new this.populateForm(t.element,e.data))}))}async checkPendingOperations(){const e=await this.store.query("status","pending");if(0===e.length)return;const t=this.groupPendingForms(e);this.showPendingNotification(t)}showPendingNotification(e){const t=document.querySelector(`[data-form-id="${e.formId}"]`);if(!t)return;const r=document.createElement("div");r.className="pending-changes-notification",r.innerHTML=`\n\t\t\t<p>We noticed unsaved changes from last time. Would you like to restore them?</p>\n\t\t\t<button class="restore-changes" data-form-id="${e.formId}">Restore</button>\n\t\t\t<button class="discard-changes" data-form-id="${e.formId}">Discard</button>\n\t\t`,t.insertBefore(r,t.firstChild),r.querySelector(".restore-changes").addEventListener("click",(()=>{this.restorePendingForm(e),r.remove()})),r.querySelector(".discard-changes").addEventListener("click",(()=>{this.discardPendingForm(e.formId),r.remove()}))}restorePendingForm(e){const t=document.querySelector(`[data-form-id="${e.formId}"]`);t&&(new this.populateForm(t,e.formData),e.status="restored",this.pendingForms.set(e.formId,e),window.jvbA11y&&window.jvbA11y.announce("Previous changes restored"))}async discardPendingForm(e){this.store.delete(e),window.jvbA11y&&window.jvbA11y.announce("Previous changes discarded")}initListeners(){this.globalHandlersAdded||(document.addEventListener("click",this.clickHandler),document.addEventListener("change",this.changeHandler),document.addEventListener("focus",this.focusHandler,!0),document.addEventListener("blur",this.blurHandler,!0),document.addEventListener("input",this.inputHandler),this.globalHandlersAdded=!0)}registerForm(e,t={}){const r=e.dataset.formId||`form_${Date.now()}`;e.dataset.formId=r,e.addEventListener("submit",this.submitHandler);const s={element:e,id:r,options:{autoSave:!0,saveDelay:this.autoSaveDefaults.delay,endpoint:e.dataset.save,cache:!0,...t},dependencies:new Map,data:this.collectFormData(e),isDirty:!1};if(this.initializeFormFields(e,s),this.forms.set(r,s),this.store&&s.options.cache){const e=this.store.get(r);e&&e.formData&&this.showPendingNotification(e)}return s}initializeFormFields(e,t=null){this.initQuillEditors(e),this.initRepeaterFields(e,t),t&&this.initConditionalFields(e,t),this.initCharacterLimits(e),this.initImageUploadFields(e),window.jvbTabs&&e.querySelector("nav.tabs")&&(t.tabs=new window.jvbTabs(e),this.forms.set(t.formId,t),this.initSteppedForm(t.formId)),window.jvbSelector&&window.jvbSelector.scanExistingFields()}initSteppedForm(e){const t=this.forms.get(e),r=t.element,s=t.tabs,a=r.querySelectorAll(".tab-content").length,i=r.querySelector(".form-progress .fill"),n=r.querySelector(".step-text .current"),o=r.querySelectorAll("nav.tabs button"),l=e=>{const t=e/a*100;i&&(i.style.width=t+"%"),n&&(n.textContent=e),o.forEach(((t,r)=>{const s=r+1;t.classList.remove("current","completed","pending"),s<e?t.classList.add("completed"):s===e?t.classList.add("current"):t.classList.add("pending")}))};r.addEventListener("click",(e=>{const t=e.target.closest('[data-action="next-step"]'),a=e.target.closest('[data-action="prev-step"]');if(t){e.preventDefault();const a=t.closest(".tab-content"),i=parseInt(a.dataset.step),n=r.querySelector(`.tab-content[data-step="${i+1}"]`);if(n&&this.validateStep(a)){const e=n.dataset.tab;s.switchTab(e,!0),l(i+1),r.scrollIntoView({behavior:"smooth",block:"start"})}}if(a){e.preventDefault();const t=a.closest(".tab-content"),i=parseInt(t.dataset.step),n=r.querySelector(`.tab-content[data-step="${i-1}"]`);if(n){const e=n.dataset.tab;s.switchTab(e,!0),l(i-1),r.scrollIntoView({behavior:"smooth",block:"start"})}}}));const c=s.switchTab.bind(s);s.switchTab=(e,t)=>{c(e,t);const s=r.querySelector(`.tab-content[data-tab="${e}"]`);if(s){const e=parseInt(s.dataset.step);l(e)}},l(1)}validateStep(e){const t=e.querySelectorAll(".field");let r=!0;return t.forEach((e=>{const t=e.querySelector("input, textarea, select");if(t&&!t.closest("[hidden]")){this.validateField(t,e)||(r=!1)}})),r}initQuillEditors(e){window.jvbQuill(e)}initRepeaterFields(e,t){e.querySelectorAll(".repeater").forEach((e=>{const r=e.querySelector(".add-repeater-row"),s=e.querySelector(".repeater-items"),a=e.querySelector("template");r&&a&&s&&(window.Sortable&&new Sortable(s,{handle:".repeater-row-header",animation:150,onEnd:()=>{this.updateRepeaterOrder(e,t)}}),r.addEventListener("click",(()=>{this.addRepeaterRow(e,t)})),s.addEventListener("click",(e=>{e.target.closest(".remove-row")&&this.removeRepeaterRow(e.target.closest(".repeater-row"),t)})))}))}addRepeaterRow(e,t){const r=e.querySelector(".repeater-items"),s=e.querySelector("template"),a=r.children.length,i=e.dataset.field,n=s.content.cloneNode(!0).firstElementChild;n.dataset.index=a,n.querySelectorAll("input, select, textarea").forEach((e=>{const t=e.name;e.name=`${i}:${a}:${t}`,e.id=`${i}-${a}-${t}`;const r=e.nextElementSibling;r&&"LABEL"===r.tagName&&(r.htmlFor=e.id)})),r.appendChild(n),t&&t.options.autoSave&&this.scheduleSave(t,{type:"repeater",action:"add",fieldName:i,delay:this.repeaterDelays.add}),window.jvbA11y&&window.jvbA11y.announce("Row added")}removeRepeaterRow(e,t){const r=e.closest(".repeater"),s=r.dataset.field;e.remove(),this.updateRepeaterOrder(r,t),t&&t.options.autoSave&&this.scheduleSave(t,{type:"repeater",action:"remove",fieldName:s,delay:this.repeaterDelays.remove}),window.jvbA11y&&window.jvbA11y.announce("Row removed")}updateRepeaterOrder(e,t){const r=e.querySelector(".repeater-items"),s=e.dataset.field;Array.from(r.children).forEach(((e,t)=>{e.dataset.index=t,e.querySelectorAll("input, select, textarea").forEach((e=>{const r=e.name.split(":");if(3===r.length){const a=r[2];e.name=`${s}:${t}:${a}`,e.id=`${s}-${t}-${a}`;const i=e.nextElementSibling;i&&"LABEL"===i.tagName&&(i.htmlFor=e.id)}}))})),t&&t.options.autoSave&&this.scheduleSave(t,{type:"repeater",action:"reorder",fieldName:s,delay:this.repeaterDelays.reorder})}initConditionalFields(e,t){e.querySelectorAll("[data-depends-on]").forEach((r=>{const s=r.dataset.dependsOn,a=r.dataset.dependsValue,i=r.dataset.dependsOperator||"==";t.dependencies.has(s)||t.dependencies.set(s,[]),t.dependencies.get(s).push({field:r,requiredValue:a,operator:i}),this.checkFieldDependency(e,r,s,a,i)}))}checkFieldDependency(e,t,r,s,a){const i=e.querySelector(`[name="${r}"]`);if(!i)return;const n=this.getFieldValue(i),o=this.evaluateCondition(n,s,a);this.toggleFieldVisibility(t,o)}evaluateCondition(e,t,r){const s=String(e||""),a=String(t||"");switch(r){case"==":default:return s==a;case"!=":return s!=a;case">":return parseFloat(s)>parseFloat(a);case"<":return parseFloat(s)<parseFloat(a);case">=":return parseFloat(s)>=parseFloat(a);case"<=":return parseFloat(s)<=parseFloat(a);case"contains":return s.includes(a);case"empty":return""===s;case"not_empty":return""!==s}}toggleFieldVisibility(e,t){const r=e.closest(".field, fieldset");r&&(r.hidden=!t,r.querySelectorAll("input, select, textarea").forEach((e=>{e.disabled=!t,!t&&e.hasAttribute("required")?(e.dataset.wasRequired="true",e.removeAttribute("required")):t&&"true"===e.dataset.wasRequired&&(e.setAttribute("required",""),delete e.dataset.wasRequired)})))}initCharacterLimits(e){e.querySelectorAll("[data-limit]").forEach((e=>{const t=parseInt(e.dataset.limit,10),r=e.closest(".field");let s=r?.querySelector(".char-count");!s&&r&&(s=document.createElement("div"),s.className="char-count",s.innerHTML=`<span class="current">0</span> / <span class="limit">${t}</span>`,r.appendChild(s));const a=()=>{const r=e.value.length;s&&(s.querySelector(".current").textContent=r,s.classList.toggle("exceeded",r>t)),r>t&&(e.value=e.value.substring(0,t),s&&(s.querySelector(".current").textContent=t))};e.addEventListener("input",a),a()}))}initImageUploadFields(e){window.jvbUploads.scanFields(e)}handleSubmit(e){if(this.subscribers.size>0){const t=e.target;if(!t.dataset.formId)return;e.preventDefault();const r=this.forms.get(t.dataset.formId);if(!r)return;const s=this.collectFormData(t);this.notify("form-submit",{formId:r.id,data:s,config:r})}}handleClick(e){if(window.targetCheck(e,"div.quantity")){let t=window.targetCheck(e,"div.quantity");this.handleNumberClick(e,t.querySelector("input"))}else if(window.targetCheck(e,"[data-action]")){let t=window.targetCheck(e,"[data-action]");switch(t=t.dataset.action,t){case"clear-form":let t=e.target.closest("form");this.store.delete(t.dataset.formId),t?.reset(),e.target.closest(".restore-form").hidden=!0;break;case"dismiss-restore":e.target.closest(".restore-form").hidden=!0}}}handleNumberClick(e,t){let r=0;if(e.target.closest(".increase")?r+=1:e.target.closest(".decrease")&&(r-=1),0!==r){let s=parseFloat(t.step);s=Math.max(s,1),e.ctrlKey&&e.shiftKey?s*=50:e.ctrlKey?s*=5:e.shiftKey&&(s*=10);let a=""===t.value?0:parseFloat(t.value);t.value=a+s*r,this.handleNumberLimits(t)}}handleNumberLimits(e){let[t,r,s,a]=[e.min,e.max,e.closest(".quantity")?.querySelector(".increase"),e.closest(".quantity")?.querySelector(".decrease")],i=parseFloat(e.value);i<t?(e.value=t,a.disabled=!0):i>r?(e.value=r,s.disabled=!1):s.disabled?s.disabled=!1:a.disabled&&(a.disabled=!1)}handleChange(e){if(this.subscribers.size>0){const t=e.target,r=t.form||t.closest("form");if(!r)return;const s=this.forms?.get(r.dataset.formId);if(!s)return;const a=s.dependencies.get(t.name);if(a&&a.forEach((e=>{this.checkFieldDependency(r,e.field,t.name,e.requiredValue,e.operator)})),s.options.autoSave&&!r.dataset.noautosave){const e=this.getDelayForField(t);this.scheduleSave(s,e)}}}handleFocus(e){const t=e.target;t.matches("input, textarea, select")&&(this.currentFocus=t)}handleBlur(e){const t=e.target,r=t.form||t.closest("form");if(!r)return;const s=e.target.closest("input, textarea, select");if(s){const e=this.findFieldWrapper(s);if(e){const t=e.dataset.field;t&&(this.shouldDebounce(s)&&window.debouncer.cancel(`validate_${t}`),this.touchedFields.add(t)),this.validateField(s,e)}const a=this.forms?.get(r.dataset.formId);a&&a.options.autoSave&&!r.dataset.noautosave&&this.scheduleSave(a,{type:"blur",fieldName:t.name,delay:1500})}}handleInput(e){const t=e.target.closest("input, textarea, select");if(!t)return;let r=t.closest("form");this.showFormStatus(r.dataset.formId,"pending");const s=this.findFieldWrapper(t);if(!s)return;const a=s.dataset.field;a&&this.touchedFields.add(a),this.shouldDebounce(t)&&window.debouncer.schedule(`validate_${a}`,((e,t)=>this.validateField.bind(this)),500)}initValidators(){return{email:{pattern:/^[^\s@]+@[^\s@]+\.[^\s@]+$/,message:"Please enter a valid email address"},url:{pattern:/^https?:\/\/.+\..+/,message:"Please enter a valid URL starting with http:// or https://"},phone:{pattern:/^[\d\s\-\+\(\)\.]+$/,message:"Please enter a valid phone number"},number:{test:(e,t)=>{const r=parseFloat(e);if(isNaN(r))return"Please enter a valid number";const s=t.dataset.min,a=t.dataset.max;return void 0!==s&&r<parseFloat(s)?`Value must be at least ${s}`:!(void 0!==a&&r>parseFloat(a))||`Value must be at most ${a}`}},text:{test:(e,t)=>{const r=t.dataset.minlength,s=t.dataset.maxlength;return r&&e.length<parseInt(r)?`Must be at least ${r} characters`:!(s&&e.length>parseInt(s))||`Must be no more than ${s} characters`}}}}findFieldWrapper(e){let t=e.closest(".field");return t||(t=e.closest("[data-field]")),t}shouldDebounce(e){return["text","email","url","tel","search"].includes(e.type)||"TEXTAREA"===e.tagName}validateField(e,t){const r=this.getFieldValue(e),s=t.dataset.field;if(!this.touchedFields.has(s)&&!e.required)return!0;if(!r&&!e.required)return this.clearValidation(t),!0;if(e.required&&!r)return this.showError(t,"This field is required"),!1;if(e.checkValidity&&!e.checkValidity())return this.showError(t,e.validationMessage),!1;const a=t.dataset.pattern;if(a&&r){if(!new RegExp(a).test(r)){const e=t.dataset.validationMessage||"Invalid format";return this.showError(t,e),!1}}const i=t.dataset.validate||e.type;if(i&&this.validators[i]){const e=this.validators[i];if(e.pattern&&!e.pattern.test(r))return this.showError(t,e.message),!1;if(e.test){const s=e.test(r,t);if(!0!==s)return this.showError(t,s),!1}}return this.showSuccess(t),!0}getFieldValue(e){if(!e)return"";if("checkbox"===e.type)return e.checked?e.value||"1":"";if("radio"===e.type){const t=e.form?.querySelector(`[name="${e.name}"]:checked`);return t?t.value:""}return"select-multiple"===e.type?Array.from(e.selectedOptions).map((e=>e.value)):e.value?.trim()||""}showSuccess(e){if(!e)return;const t=e.querySelector(".validation-icon.success"),r=e.querySelector(".validation-icon.error"),s=e.querySelector(".validation-message"),a=e.querySelector("input, textarea, select");e.classList.remove("has-error"),a?.classList.remove("error"),e.classList.add("has-success"),t&&(t.hidden=!1),r&&(r.hidden=!0),s&&(s.hidden=!0,s.textContent="")}showError(e,t){if(!e)return;const r=e.querySelector(".validation-icon.success"),s=e.querySelector(".validation-icon.error"),a=e.querySelector(".validation-message"),i=e.querySelector("input, textarea, select");e.classList.remove("has-success"),e.classList.add("has-error"),i?.classList.add("error"),r&&(r.hidden=!0),s&&(s.hidden=!1),a&&(a.hidden=!1,a.textContent=t)}clearValidation(e){if(!e)return;const t=e.querySelector(".validation-icon"),r=e.querySelector(".validation-message"),s=e.querySelector("input, textarea, select");e.classList.remove("has-error","has-success"),s?.classList.remove("error"),t&&(t.hidden=!0),r&&(r.hidden=!0,r.textContent="")}validateAllFields(e){if(!e)return!0;const t=e.querySelectorAll(".field:not([hidden])");let r=!0;return t.forEach((e=>{if(this.isComplexFieldWrapper(e))return;const t=e.querySelector('input:not([type="hidden"]), textarea, select');if(t&&!t.closest("[hidden]")){const s=e.dataset.field;s&&this.touchedFields.add(s);this.validateField(t,e)||(r=!1,!1===r&&(t.scrollIntoView({behavior:"smooth",block:"center"}),t.focus()))}})),r}isComplexFieldWrapper(e){return e.classList.contains("repeater")||e.classList.contains("group")||e.classList.contains("upload")}attachRepeaterValidation(e){e.addEventListener("click",(t=>{t.target.closest(".add-repeater-row")&&setTimeout((()=>{e.querySelectorAll(".repeater-row").forEach((e=>{e.querySelectorAll("input, textarea, select").forEach((e=>{const t=this.findFieldWrapper(e);t&&this.clearValidation(t)}))}))}),100)}))}attachGroupValidation(e){e.addEventListener("change",(t=>{const r=t.target.closest("input, select");if(!r)return;const s=r.name;if(!s)return;e.querySelectorAll(`[data-show-if*="${s}"]`).forEach((e=>{e.hidden&&this.clearValidation(e)}))}))}resetForm(e){if(!e)return;this.touchedFields.clear();e.querySelectorAll(".field").forEach((e=>{this.clearValidation(e)}))}getFormErrors(e){const t={};return e.querySelectorAll(".field.has-error").forEach((e=>{const r=e.dataset.field,s=e.querySelector(".validation-message");r&&s&&(t[r]=s.textContent)})),t}addValidator(e,t){this.validators[e]=t}getDelayForField(e){return"text"===e.type||"textarea"===e.type?this.autoSaveDefaults.typingDelay:["checkbox","radio","select-one","select-multiple"].includes(e.type)?1e3:this.autoSaveDefaults.delay}scheduleSave(e,t=this.autoSaveDefaults.delay){document.addEventListener("input",this.saveCheck,{passive:!0});const r=`autosave_${e.id}`;this.debouncer.schedule(r,(()=>this.autosave(e)),t)}saveCheck(e){let t=e.target.closest("form[data-id]");t&&this.scheduleSave(this.forms.get(t.dataset.id))}async autosave(e){const t=this.collectFormData(e.element);this.showFormStatus(e.id,"saving"),await this.store.save({formId:e.id,data:t,status:"draft",timestamp:Date.now()}).then((()=>{this.showFormStatus(e.id,"autosaved")}));const r=this.getChangedFields(e.data,t);if(0!==Object.keys(r).length){e.data=t,this.forms.set(e.id,e),document.removeEventListener("input",this.handleInput);for(let[e,s]of Object.entries(t))"object"==typeof s&&(r[e]=s);this.notify("form-autosave",{formId:e.id,changes:r,fullData:t,config:e})}}hasUnsavedChanges(e){const t=this.forms.get(e);if(!t)return!1;if(t.operations?.size>0)return!0;const r=this.collectFormData(t.element),s=this.getChangedFields(t.lastSnapshot,r);return Object.keys(s).length>0}showFormStatus(e,t){let r=this.forms.get(e);console.log("Setting status: ",t);const s=r.element.querySelector(".fstatus");s.hidden=!1;const a=s.querySelector(".message");a.textContent="",s.querySelector(".icon")?.remove();const i={saving:"Saving changes...",autosaved:"Changes saved locally. Submit form to send to server.",uploading:"Uploading your form to server",submitted:"Successfully sent to server",pending:"Unsaved changes",error:"Failed to save changes. Refresh and try again?",offline:"Changes will be saved when online"},n={autosaved:"check",submitted:"check",error:"close",offline:"cloud-slash",pending:"exclamation-mark"};let o=window.getIcon(n[t]);o&&s.prepend(o),console.log(t,i[t]),console.log(t,n[t]),a.textContent=i[t]||t,s.classList.toggle("loading",["uploading","saving"].includes(t)),"submitted"===t&&setTimeout((()=>s.hidden=!0),3e3)}cleanupSpecialFields(){this.specialFields.forEach((e=>{if("quill"===e.type&&e.instance){const t=e.instance.container.previousSibling;t?.classList.contains("ql-toolbar")&&t.remove()}})),this.uploader?.destroy(),this.specialFields.clear()}collectFormData(e){const t=new FormData(e);let r={};const s={},a={};for(let[i,n]of t.entries()){if(this.ignore.includes(i)||i.endsWith("_temp"))continue;this.getFieldProcessor(i)(i,n,r,s,a,e)}return window.isEmptyObject(a)?this.mergeRepeaterData(r,s):(r=this.mergeRepeaterData(r,s),this.mergePostData(r,a))}getFieldProcessor(e){return e.includes("|")?this.processTableField:e.includes("::")?this.processGroupField:e.includes(":")?this.processRepeaterField:/\[[^\]]+\]/.test(e)?this.processLocationField:this.processRegularField}mergeRepeaterData(e,t){return Object.keys(t).forEach((r=>{const s={};Object.keys(t[r]).forEach((e=>{const a=t[r][e];Object.keys(a).length>0&&(s[e]=a)})),e[r]=Object.values(s)})),e}mergePostData(e,t){for(let[t,r]in Object.entries(r))e[t]=r;return e}processTableField(e,t,r,s,a,i){let[n,o]=e.split("|");!n in a&&(a[n]={});this.getFieldProcessor(o)(o,t,a,s,a,i)}processRepeaterField(e,t,r,s,a,i){let[n,o,l]=e.split(":");const c=l.endsWith("[]");l=l.replace("[]",""),s[n]||(s[n]={}),s[n][o]||(s[n][o]={}),c||s[n][o][l]?(s[n][o][l]?Array.isArray(s[n][o][l])||(s[n][o][l]=[s[n][o][l]]):s[n][o][l]=[],s[n][o][l].push(t)):s[n][o][l]=t}processGroupField(e,t,r,s,a,i){const n=e.split("::"),o=n[0];r[o]||(r[o]={});let l=r[o];for(let e=1;e<n.length-1;e++){const t=n[e];l[t]||(l[t]={}),l=l[t]}const c=n[n.length-1];void 0!==l[c]?(Array.isArray(l[c])||(l[c]=[l[c]]),l[c].push(t)):l[c]=t}processLocationField(e,t,r,s,a,i){let[n,o]=e.split("[");o=o.replace("]",""),Object.hasOwn(r,n)||(r[n]={},Object.hasOwn(r,"sendAll")?r.sendAll.includes(n)||r.sendAll.push(n):r.sendAll=[n]),r[n][o]=t}processRegularField(e,t,r,s,a,i){r[e=e.replace("[]","")]?(Array.isArray(r[e])||(r[e]=[r[e]]),r[e].push(t)):r[e]=t}getFieldValue(e){if(!e)return"";if("checkbox"===e.type)return e.checked?e.value||"1":"";if("radio"===e.type){const t=e.form.querySelector(`[name="${e.name}"]:checked`);return t?t.value:""}return"select-multiple"===e.type?Array.from(e.selectedOptions).map((e=>e.value)):e.value}getChangedFields(e,t){return window.getDifferences?.map(e,t)||{}}showSummary(e,t="form"){const r=this.forms.get(e);if(!r)return;const s=r.element||document.querySelector(`[data-form-id="${e}"]`),a=window.getTemplate("formSummary"),[i,n,o]=[a.querySelector("h2"),a.querySelector(".summary"),a.querySelector(".result")],l=["sendAll",...this.ignore];for(const[e,t]of Object.entries(r.data)){if(l.includes(e)||this.isEmptyValue(t))continue;const r=this.getFieldInfo(s,e);if(!r.label)continue;const a=this.createResultElement(o,r,t,s);a&&n.appendChild(a)}o.remove(),(t="form"!==t?s.closest(t)??s:s).after(a),window.fade(t,!1)}isEmptyValue(e){return null==e||""===e||(!(!Array.isArray(e)||0!==e.length)||"object"==typeof e&&0===Object.keys(e).length)}getFieldInfo(e,t){let r=e.querySelector(`label[for="${t}"]`),s=null,a=null;if(s||(s=e.querySelector(`[name="${t}"]`)),s||(s=e.querySelector(`[name="${t}[]"]`)),!s){const a=e.querySelector(`fieldset[data-field="${t}"]`);a&&(r=a.querySelector("legend"),s=a.querySelector("input, select, textarea"))}if(!r&&s){const e=s.closest(".field, fieldset");e&&(r=e.querySelector("label, legend"))}a=e.querySelector(`.field[data-field="${t}"], fieldset[data-field="${t}"]`);let i="text";return a?.dataset.type?i=a.dataset.type:s&&(i="checkbox"===s.type&&s.name.endsWith("[]")?"checkbox":"checkbox"===s.type?"true_false":"SELECT"===s.tagName&&s.multiple?"select":s.type||"text"),{label:r?.textContent.replace("*","").trim()||null,type:i,wrapper:a,input:s}}createResultElement(e,t,r,s){const a=e.cloneNode(!0),i=a.querySelector("h4"),n=a.querySelector("p");i.textContent=t.label;const o=this.formatFieldValue(r,t.type,s);return this.isHtmlContent(o)?n.innerHTML=o:n.textContent=o,a}isHtmlContent(e){return"string"==typeof e&&(e.includes("<br>")||e.includes("<p>")||e.includes("<ul>")||e.includes("<ol>")||e.includes("<a ")||e.includes("<strong>")||e.includes("<em>")||e.includes("<div"))}formatFieldValue(e,t,r){switch(t){case"textarea":case"wysiwyg":return this.formatTextareaValue(e,t);case"true_false":return"1"===e||1===e||!0===e?"Yes":"No";case"checkbox":return Array.isArray(e)?this.formatArrayValue(e):"1"===e||1===e||!0===e?"Yes":"No";case"select":return Array.isArray(e)?this.formatArrayValue(e):this.getSelectLabel(e,r,t);case"date":case"datetime":case"time":return window.formatDate?window.formatDate(e):e;case"radio":return this.getSelectLabel(e,r,t);case"repeater":return this.formatRepeaterValue(e);case"group":return this.formatGroupValue(e);case"location":return this.formatLocationValue(e);case"file":case"image":return this.formatFileValue(e);case"number":return this.formatNumber(e);case"email":return`<a href="mailto:${e}">${e}</a>`;case"url":return`<a href="${e}" target="_blank" rel="noopener">${e}</a>`;case"phone":return`<a href="tel:${e.replace(/\D/g,"")}">${e}</a>`;default:return Array.isArray(e)?this.formatArrayValue(e):e}}formatRepeaterValue(e){if(!Array.isArray(e)||0===e.length)return"<em>No entries</em>";let t='<div class="repeater-summary">';return e.forEach(((e,r)=>{t+='<div class="repeater-row">',t+=`<strong>Entry ${r+1}:</strong><ul>`;for(const[r,s]of Object.entries(e))if(!this.isEmptyValue(s)){const e=r.replace(/_/g," ").replace(/\b\w/g,(e=>e.toUpperCase()));t+=`<li><strong>${e}:</strong> ${s}</li>`}t+="</ul></div>"})),t+="</div>",t}formatGroupValue(e){if("object"!=typeof e||0===Object.keys(e).length)return"<em>No data</em>";let t='<div class="group-summary"><ul>';for(const[r,s]of Object.entries(e))if(!this.isEmptyValue(s)){const e=r.replace(/_/g," ").replace(/\b\w/g,(e=>e.toUpperCase()));"object"!=typeof s||Array.isArray(s)?t+=`<li><strong>${e}:</strong> ${s}</li>`:t+=`<li><strong>${e}:</strong> ${this.formatGroupValue(s)}</li>`}return t+="</ul></div>",t}formatLocationValue(e){if("object"!=typeof e)return e;const t=[];return["address","city","state","zip","country"].forEach((r=>{e[r]&&t.push(e[r])})),t.join(", ")}formatFileValue(e){return"string"==typeof e?e.startsWith("http")?`<a href="${e}" target="_blank">View file</a>`:e:Array.isArray(e)?e.map((e=>"string"==typeof e?`<a href="${e}" target="_blank">View file</a>`:e.name||"File")).join(", "):"File uploaded"}formatNumber(e){const t=parseFloat(e);return isNaN(t)?e:e.toString().includes(".")&&2===e.toString().split(".")[1].length?new Intl.NumberFormat("en-CA",{style:"currency",currency:"USD"}).format(t):new Intl.NumberFormat("en-CA").format(t)}formatArrayValue(e,t=null,r=null){if(0===e.length)return"<em>None selected</em>";if(t&&r&&r.input){return"<ul><li>"+e.map((e=>this.getSelectLabel(e,t,r.type))).join("</li><li>")+"</li></ul>"}return"<ul><li>"+e.join("</li><li>")+"</li></ul>"}getSelectLabel(e,t,r){if("select"===r){const r=t.querySelector(`option[value="${e}"]`);return r?.textContent||e}if("radio"===r){const r=t.querySelector(`input[type="radio"][value="${e}"]`),s=r?.nextElementSibling;return s?.textContent||e}if("checkbox"===r){const r=t.querySelector(`input[type="checkbox"][value="${e}"]`);if(r){const e=t.querySelector(`label[for="${r.id}"]`);if(e)return e.textContent.trim();const s=r.nextElementSibling;if("LABEL"===s?.tagName)return s.textContent.trim()}}return e}formatTextareaValue(e,t){return e?"wysiwyg"===t||this.containsHtml(e)?e:this.formatPlainText(e):"<em>Empty</em>"}containsHtml(e){return/<(p|strong|em|u|s|ol|ul|li|blockquote|h[1-6]|a|br|span)\b[^>]*>/i.test(e)}formatPlainText(e){if(!e)return"";const t=(e=e.replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">")).split(/\n\n+/);return t.length>1?t.map((e=>`<p>${e.replace(/\n/g,"<br>")}</p>`)).join(""):e.replace(/\n/g,"<br>")}nl2br(e){return this.formatPlainText(e)}subscribe(e){return this.subscribers.add(e),()=>this.subscribers.delete(e)}notify(e,t){this.subscribers.forEach((r=>r(e,t)))}cleanupForm(e){const t=this.forms.get(e);t&&(this.hasUnsavedChanges(e)&&this.autosave(t),this.cleanupSpecialFields(),this.forms.delete(e))}destroy(){this.globalHandlersAdded&&(document.removeEventListener("change",this.changeHandler),document.removeEventListener("focus",this.focusHandler,!0),document.removeEventListener("blur",this.blurHandler,!0),document.removeEventListener("input",this.inputHandler,!0)),this.forms.forEach((e=>{let t=e.element;t&&t.removeEventListener("submit",this.submitHandler)})),this.specialFields.clear(),this.forms.clear(),this.activeRepeaters.clear(),this.forms&&this.forms.clear()}}document.addEventListener("DOMContentLoaded",(()=>{window.jvbForm=e}))})();
\ No newline at end of file
+(()=>{class e{constructor(){this.store=new window.jvbStore({name:"forms",storeName:"forms",keyPath:"formId",indexes:[{name:"status",keyPath:"status"},{name:"operationId",keyPath:"operationId"},{name:"timestamp",keyPath:"timestamp"},{name:"formType",keyPath:"type"}],TTL:6048e5}),this.debouncer=window.debouncer,this.ignore=[],this.populateForm=window.jvbPopulate,this.subscribers=new Set,this.forms=new Map,this.specialFields=new Map,this.dependencies=new Map,this.validators=this.initValidators(),this.touchedFields=new Set,this.autoSaveDefaults={delay:3e3,typingDelay:1500,enabled:!0},this.activeRepeaters=new Map,this.repeaterDelays={change:6e3,typing:3e3,blur:1500,add:500,remove:800,reorder:1e3},this.clickHandler=this.handleClick.bind(this),this.changeHandler=this.handleChange.bind(this),this.submitHandler=this.handleSubmit.bind(this),this.inputHandler=this.handleInput.bind(this),this.focusHandler=this.handleFocus.bind(this),this.blurHandler=this.handleBlur.bind(this),this.scanForms(),this.init()}async init(){await this.checkPendingOperations(),this.store.subscribe(this.handleStoreEvent.bind(this)),this.initListeners()}handleStoreEvent(e,t){switch(e){case"item-saved":t.item.status;break;case"data-loaded":this.checkPendingForms()}}async checkPendingForms(){(await this.store.query("status","draft")).forEach((e=>{let t=this.forms.get(e.formId);t&&t.element&&(t.element.querySelector(".restore-form").hidden=!1,new this.populateForm(t.element,e.data))}))}async checkPendingOperations(){const e=await this.store.query("status","pending");if(0===e.length)return;const t=this.groupPendingForms(e);this.showPendingNotification(t)}showPendingNotification(e){const t=document.querySelector(`[data-form-id="${e.formId}"]`);if(!t)return;const r=document.createElement("div");r.className="pending-changes-notification",r.innerHTML=`\n\t\t\t<p>We noticed unsaved changes from last time. Would you like to restore them?</p>\n\t\t\t<button class="restore-changes" data-form-id="${e.formId}">Restore</button>\n\t\t\t<button class="discard-changes" data-form-id="${e.formId}">Discard</button>\n\t\t`,t.insertBefore(r,t.firstChild),r.querySelector(".restore-changes").addEventListener("click",(()=>{this.restorePendingForm(e),r.remove()})),r.querySelector(".discard-changes").addEventListener("click",(()=>{this.discardPendingForm(e.formId),r.remove()}))}restorePendingForm(e){const t=document.querySelector(`[data-form-id="${e.formId}"]`);t&&(new this.populateForm(t,e.formData),e.status="restored",this.pendingForms.set(e.formId,e),window.jvbA11y&&window.jvbA11y.announce("Previous changes restored"))}async discardPendingForm(e){this.store.delete(e),window.jvbA11y&&window.jvbA11y.announce("Previous changes discarded")}initListeners(){this.globalHandlersAdded||(document.addEventListener("click",this.clickHandler),document.addEventListener("change",this.changeHandler),document.addEventListener("focus",this.focusHandler,!0),document.addEventListener("blur",this.blurHandler,!0),document.addEventListener("input",this.inputHandler),this.globalHandlersAdded=!0)}scanForms(){document.querySelectorAll("form").forEach((e=>{console.log("Registering form...",e),this.registerForm(e)}))}registerForm(e,t={}){const r=e.dataset.formId||`form_${Date.now()}`;e.dataset.formId=r,e.addEventListener("submit",this.submitHandler);const s={element:e,id:r,options:{autoSave:"autosave"in e.dataset,saveDelay:this.autoSaveDefaults.delay,endpoint:e.dataset.save??"",cache:!0,...t},dependencies:new Map,data:this.collectFormData(e),isDirty:!1};if(this.initializeFormFields(e,s),this.forms.set(r,s),this.store&&s.options.cache){const e=this.store.get(r);e&&e.formData&&this.showPendingNotification(e)}return s}initializeFormFields(e,t=null){this.initQuillEditors(e),this.initRepeaterFields(e,t),t&&this.initConditionalFields(e,t),this.initCharacterLimits(e),this.initImageUploadFields(e),window.jvbTabs&&e.querySelector("nav.tabs")&&(t.tabs=new window.jvbTabs(e),this.forms.set(t.formId,t),this.initSteppedForm(t.formId)),window.jvbSelector&&window.jvbSelector.scanExistingFields(e)}initSteppedForm(e){const t=this.forms.get(e),r=t.element,s=t.tabs,a=r.querySelectorAll(".tab-content").length,i=r.querySelector(".form-progress .fill"),n=r.querySelector(".step-text .current"),o=r.querySelectorAll("nav.tabs button"),l=e=>{const t=e/a*100;i&&(i.style.width=t+"%"),n&&(n.textContent=e),o.forEach(((t,r)=>{const s=r+1;t.classList.remove("current","completed","pending"),s<e?t.classList.add("completed"):s===e?t.classList.add("current"):t.classList.add("pending")}))};r.addEventListener("click",(e=>{const t=e.target.closest('[data-action="next-step"]'),a=e.target.closest('[data-action="prev-step"]');if(t){e.preventDefault();const a=t.closest(".tab-content"),i=parseInt(a.dataset.step),n=r.querySelector(`.tab-content[data-step="${i+1}"]`);if(n&&this.validateStep(a)){const e=n.dataset.tab;s.switchTab(e,!0),l(i+1),r.scrollIntoView({behavior:"smooth",block:"start"})}}if(a){e.preventDefault();const t=a.closest(".tab-content"),i=parseInt(t.dataset.step),n=r.querySelector(`.tab-content[data-step="${i-1}"]`);if(n){const e=n.dataset.tab;s.switchTab(e,!0),l(i-1),r.scrollIntoView({behavior:"smooth",block:"start"})}}}));const c=s.switchTab.bind(s);s.switchTab=(e,t)=>{c(e,t);const s=r.querySelector(`.tab-content[data-tab="${e}"]`);if(s){const e=parseInt(s.dataset.step);l(e)}},l(1)}validateStep(e){const t=e.querySelectorAll(".field");let r=!0;return t.forEach((e=>{const t=e.querySelector("input, textarea, select");if(t&&!t.closest("[hidden]")){this.validateField(t,e)||(r=!1)}})),r}initQuillEditors(e){window.jvbQuill(e)}initRepeaterFields(e,t){e.querySelectorAll(".repeater").forEach((e=>{const r=e.querySelector(".add-repeater-row"),s=e.querySelector(".repeater-items"),a=e.querySelector("template");r&&a&&s&&(window.Sortable&&new Sortable(s,{handle:".repeater-row-header",animation:150,onEnd:()=>{this.updateRepeaterOrder(e,t)}}),r.addEventListener("click",(()=>{this.addRepeaterRow(e,t)})),s.addEventListener("click",(e=>{e.target.closest(".remove-row")&&this.removeRepeaterRow(e.target.closest(".repeater-row"),t)})))}))}addRepeaterRow(e,t){const r=e.querySelector(".repeater-items"),s=e.querySelector("template"),a=r.children.length,i=e.dataset.field,n=s.content.cloneNode(!0).firstElementChild;n.dataset.index=a,n.querySelectorAll("input, select, textarea").forEach((e=>{const t=e.name;e.name=`${i}:${a}:${t}`,e.id=`${i}-${a}-${t}`;const r=e.nextElementSibling;r&&"LABEL"===r.tagName&&(r.htmlFor=e.id)})),r.appendChild(n),t&&t.options.autoSave&&this.scheduleSave(t,{type:"repeater",action:"add",fieldName:i,delay:this.repeaterDelays.add}),window.jvbA11y&&window.jvbA11y.announce("Row added")}removeRepeaterRow(e,t){const r=e.closest(".repeater"),s=r.dataset.field;e.remove(),this.updateRepeaterOrder(r,t),t&&t.options.autoSave&&this.scheduleSave(t,{type:"repeater",action:"remove",fieldName:s,delay:this.repeaterDelays.remove}),window.jvbA11y&&window.jvbA11y.announce("Row removed")}updateRepeaterOrder(e,t){const r=e.querySelector(".repeater-items"),s=e.dataset.field;Array.from(r.children).forEach(((e,t)=>{e.dataset.index=t,e.querySelectorAll("input, select, textarea").forEach((e=>{const r=e.name.split(":");if(3===r.length){const a=r[2];e.name=`${s}:${t}:${a}`,e.id=`${s}-${t}-${a}`;const i=e.nextElementSibling;i&&"LABEL"===i.tagName&&(i.htmlFor=e.id)}}))})),t&&t.options.autoSave&&this.scheduleSave(t,{type:"repeater",action:"reorder",fieldName:s,delay:this.repeaterDelays.reorder})}initConditionalFields(e,t){e.querySelectorAll("[data-depends-on]").forEach((r=>{const s=r.dataset.dependsOn,a=r.dataset.dependsValue,i=r.dataset.dependsOperator||"==";t.dependencies.has(s)||t.dependencies.set(s,[]),t.dependencies.get(s).push({field:r,requiredValue:a,operator:i}),this.checkFieldDependency(e,r,s,a,i)}))}checkFieldDependency(e,t,r,s,a){const i=e.querySelector(`[name="${r}"]`);if(!i)return;const n=this.getFieldValue(i),o=this.evaluateCondition(n,s,a);this.toggleFieldVisibility(t,o)}evaluateCondition(e,t,r){const s=String(e||""),a=String(t||"");switch(r){case"==":default:return s==a;case"!=":return s!=a;case">":return parseFloat(s)>parseFloat(a);case"<":return parseFloat(s)<parseFloat(a);case">=":return parseFloat(s)>=parseFloat(a);case"<=":return parseFloat(s)<=parseFloat(a);case"contains":return s.includes(a);case"empty":return""===s;case"not_empty":return""!==s}}toggleFieldVisibility(e,t){const r=e.closest(".field, fieldset");r&&(r.hidden=!t,r.querySelectorAll("input, select, textarea").forEach((e=>{e.disabled=!t,!t&&e.hasAttribute("required")?(e.dataset.wasRequired="true",e.removeAttribute("required")):t&&"true"===e.dataset.wasRequired&&(e.setAttribute("required",""),delete e.dataset.wasRequired)})))}initCharacterLimits(e){e.querySelectorAll("[data-limit]").forEach((e=>{const t=parseInt(e.dataset.limit,10),r=e.closest(".field");let s=r?.querySelector(".char-count");!s&&r&&(s=document.createElement("div"),s.className="char-count",s.innerHTML=`<span class="current">0</span> / <span class="limit">${t}</span>`,r.appendChild(s));const a=()=>{const r=e.value.length;s&&(s.querySelector(".current").textContent=r,s.classList.toggle("exceeded",r>t)),r>t&&(e.value=e.value.substring(0,t),s&&(s.querySelector(".current").textContent=t))};e.addEventListener("input",a),a()}))}initImageUploadFields(e){window.jvbUploads.scanFields(e)}handleSubmit(e){if(this.subscribers.size>0){const t=e.target;if(!t.dataset.formId)return;e.preventDefault();const r=this.forms.get(t.dataset.formId);if(!r)return;const s=this.collectFormData(t);this.notify("form-submit",{formId:r.id,data:s,config:r})}}handleClick(e){if(window.targetCheck(e,"div.quantity")){let t=window.targetCheck(e,"div.quantity");this.handleNumberClick(e,t.querySelector("input"))}else if(window.targetCheck(e,"[data-action]")){let t=window.targetCheck(e,"[data-action]");switch(t=t.dataset.action,t){case"clear-form":let t=e.target.closest("form");this.store.delete(t.dataset.formId),t?.reset(),e.target.closest(".restore-form").hidden=!0;break;case"dismiss-restore":e.target.closest(".restore-form").hidden=!0}}}handleNumberClick(e,t){let r=0;if(e.target.closest(".increase")?r+=1:e.target.closest(".decrease")&&(r-=1),0!==r){let s=parseFloat(t.step);s=Math.max(s,1),e.ctrlKey&&e.shiftKey?s*=50:e.ctrlKey?s*=5:e.shiftKey&&(s*=10);let a=""===t.value?0:parseFloat(t.value);t.value=a+s*r,this.handleNumberLimits(t)}}handleNumberLimits(e){let[t,r,s,a]=[e.min,e.max,e.closest(".quantity")?.querySelector(".increase"),e.closest(".quantity")?.querySelector(".decrease")],i=parseFloat(e.value);i<t?(e.value=t,a.disabled=!0):i>r?(e.value=r,s.disabled=!1):s.disabled?s.disabled=!1:a.disabled&&(a.disabled=!1)}handleChange(e){if(!e.target.closest("[data-ignore]")&&this.subscribers.size>0){const t=e.target,r=t.form||t.closest("form");if(!r)return;const s=this.forms?.get(r.dataset.formId);if(!s)return;const a=s.dependencies.get(t.name);if(a&&a.forEach((e=>{this.checkFieldDependency(r,e.field,t.name,e.requiredValue,e.operator)})),s.options.autoSave&&!r.dataset.noautosave){const e=this.getDelayForField(t);this.scheduleSave(s,e)}}}handleFocus(e){const t=e.target;t.matches("input, textarea, select")&&(this.currentFocus=t)}handleBlur(e){if(e.target.closest("[data-ignore]"))return;const t=e.target,r=t.form||t.closest("form");if(!r)return;const s=e.target.closest("input, textarea, select");if(s){const e=this.findFieldWrapper(s);if(e){const t=e.dataset.field;t&&(this.shouldDebounce(s)&&window.debouncer.cancel(`validate_${t}`),this.touchedFields.add(t)),this.validateField(s,e)}const a=this.forms?.get(r.dataset.formId);a&&a.options.autoSave&&!r.dataset.noautosave&&this.scheduleSave(a,{type:"blur",fieldName:t.name,delay:1500})}}handleInput(e){if(e.target.closest("[data-ignore]")||!e.target.closest("form"))return;const t=e.target.closest("input, textarea, select");if(!t)return;let r=t.closest("form");this.showFormStatus(r.dataset.formId,"pending");const s=this.findFieldWrapper(t);if(!s)return;const a=s.dataset.field;a&&this.touchedFields.add(a),this.shouldDebounce(t)&&window.debouncer.schedule(`validate_${a}`,((e,t)=>this.validateField.bind(this)),500)}initValidators(){return{email:{pattern:/^[^\s@]+@[^\s@]+\.[^\s@]+$/,message:"Please enter a valid email address"},url:{pattern:/^https?:\/\/.+\..+/,message:"Please enter a valid URL starting with http:// or https://"},phone:{pattern:/^[\d\s\-\+\(\)\.]+$/,message:"Please enter a valid phone number"},number:{test:(e,t)=>{const r=parseFloat(e);if(isNaN(r))return"Please enter a valid number";const s=t.dataset.min,a=t.dataset.max;return void 0!==s&&r<parseFloat(s)?`Value must be at least ${s}`:!(void 0!==a&&r>parseFloat(a))||`Value must be at most ${a}`}},text:{test:(e,t)=>{const r=t.dataset.minlength,s=t.dataset.maxlength;return r&&e.length<parseInt(r)?`Must be at least ${r} characters`:!(s&&e.length>parseInt(s))||`Must be no more than ${s} characters`}}}}findFieldWrapper(e){let t=e.closest(".field");return t||(t=e.closest("[data-field]")),t}shouldDebounce(e){return["text","email","url","tel","search"].includes(e.type)||"TEXTAREA"===e.tagName}validateField(e,t){const r=this.getFieldValue(e),s=t.dataset.field;if(!this.touchedFields.has(s)&&!e.required)return!0;if(!r&&!e.required)return this.clearValidation(t),!0;if(e.required&&!r)return this.showError(t,"This field is required"),!1;if(e.checkValidity&&!e.checkValidity())return this.showError(t,e.validationMessage),!1;const a=t.dataset.pattern;if(a&&r){if(!new RegExp(a).test(r)){const e=t.dataset.validationMessage||"Invalid format";return this.showError(t,e),!1}}const i=t.dataset.validate||e.type;if(i&&this.validators[i]){const e=this.validators[i];if(e.pattern&&!e.pattern.test(r))return this.showError(t,e.message),!1;if(e.test){const s=e.test(r,t);if(!0!==s)return this.showError(t,s),!1}}return this.showSuccess(t),!0}getFieldValue(e){if(!e)return"";if("checkbox"===e.type)return e.checked?e.value||"1":"";if("radio"===e.type){const t=e.form?.querySelector(`[name="${e.name}"]:checked`);return t?t.value:""}return"select-multiple"===e.type?Array.from(e.selectedOptions).map((e=>e.value)):e.value?.trim()||""}showSuccess(e){if(!e)return;const t=e.querySelector(".validation-icon.success"),r=e.querySelector(".validation-icon.error"),s=e.querySelector(".validation-message"),a=e.querySelector("input, textarea, select");e.classList.remove("has-error"),a?.classList.remove("error"),e.classList.add("has-success"),t&&(t.hidden=!1),r&&(r.hidden=!0),s&&(s.hidden=!0,s.textContent="")}showError(e,t){if(!e)return;const r=e.querySelector(".validation-icon.success"),s=e.querySelector(".validation-icon.error"),a=e.querySelector(".validation-message"),i=e.querySelector("input, textarea, select");e.classList.remove("has-success"),e.classList.add("has-error"),i?.classList.add("error"),r&&(r.hidden=!0),s&&(s.hidden=!1),a&&(a.hidden=!1,a.textContent=t)}clearValidation(e){if(!e)return;const t=e.querySelector(".validation-icon"),r=e.querySelector(".validation-message"),s=e.querySelector("input, textarea, select");e.classList.remove("has-error","has-success"),s?.classList.remove("error"),t&&(t.hidden=!0),r&&(r.hidden=!0,r.textContent="")}validateAllFields(e){if(!e)return!0;const t=e.querySelectorAll(".field:not([hidden])");let r=!0;return t.forEach((e=>{if(this.isComplexFieldWrapper(e))return;const t=e.querySelector('input:not([type="hidden"]), textarea, select');if(t&&!t.closest("[hidden]")){const s=e.dataset.field;s&&this.touchedFields.add(s);this.validateField(t,e)||(r=!1,!1===r&&(t.scrollIntoView({behavior:"smooth",block:"center"}),t.focus()))}})),r}isComplexFieldWrapper(e){return e.classList.contains("repeater")||e.classList.contains("group")||e.classList.contains("upload")}attachRepeaterValidation(e){e.addEventListener("click",(t=>{t.target.closest(".add-repeater-row")&&setTimeout((()=>{e.querySelectorAll(".repeater-row").forEach((e=>{e.querySelectorAll("input, textarea, select").forEach((e=>{const t=this.findFieldWrapper(e);t&&this.clearValidation(t)}))}))}),100)}))}attachGroupValidation(e){e.addEventListener("change",(t=>{const r=t.target.closest("input, select");if(!r)return;const s=r.name;if(!s)return;e.querySelectorAll(`[data-show-if*="${s}"]`).forEach((e=>{e.hidden&&this.clearValidation(e)}))}))}resetForm(e){if(!e)return;this.touchedFields.clear();e.querySelectorAll(".field").forEach((e=>{this.clearValidation(e)}))}getFormErrors(e){const t={};return e.querySelectorAll(".field.has-error").forEach((e=>{const r=e.dataset.field,s=e.querySelector(".validation-message");r&&s&&(t[r]=s.textContent)})),t}addValidator(e,t){this.validators[e]=t}getDelayForField(e){return"text"===e.type||"textarea"===e.type?this.autoSaveDefaults.typingDelay:["checkbox","radio","select-one","select-multiple"].includes(e.type)?1e3:this.autoSaveDefaults.delay}scheduleSave(e,t=this.autoSaveDefaults.delay){document.addEventListener("input",this.saveCheck,{passive:!0});const r=`autosave_${e.id}`;this.debouncer.schedule(r,(()=>this.autosave(e)),t)}saveCheck(e){let t=e.target.closest("form[data-id]");t&&this.scheduleSave(this.forms.get(t.dataset.id))}async autosave(e){const t=this.collectFormData(e.element);this.showFormStatus(e.id,"saving"),await this.store.save({formId:e.id,data:t,status:"draft",timestamp:Date.now()}).then((()=>{this.showFormStatus(e.id,"autosaved")}));const r=this.getChangedFields(e.data,t);if(0!==Object.keys(r).length){e.data=t,this.forms.set(e.id,e),document.removeEventListener("input",this.handleInput);for(let[e,s]of Object.entries(t))"object"==typeof s&&(r[e]=s);this.notify("form-autosave",{formId:e.id,changes:r,fullData:t,config:e})}}hasUnsavedChanges(e){const t=this.forms.get(e);if(!t)return!1;if(t.operations?.size>0)return!0;const r=this.collectFormData(t.element),s=this.getChangedFields(t.lastSnapshot,r);return Object.keys(s).length>0}showFormStatus(e,t){let r=this.forms.get(e);console.log("Setting status: ",t);const s=r.element.querySelector(".fstatus");s.hidden=!1;const a=s.querySelector(".message");a.textContent="",s.querySelector(".icon")?.remove();const i={saving:"Saving changes...",autosaved:"Changes saved locally. Submit form to send to server.",uploading:"Uploading your form to server",submitted:"Successfully sent to server",pending:"Unsaved changes",error:"Failed to save changes. Refresh and try again?",offline:"Changes will be saved when online"},n={autosaved:"check",submitted:"check",error:"close",offline:"cloud-slash",pending:"exclamation-mark"};let o=window.getIcon(n[t]);o&&s.prepend(o),console.log(t,i[t]),console.log(t,n[t]),a.textContent=i[t]||t,s.classList.toggle("loading",["uploading","saving"].includes(t)),"submitted"===t&&setTimeout((()=>s.hidden=!0),3e3)}cleanupSpecialFields(){this.specialFields.forEach((e=>{if("quill"===e.type&&e.instance){const t=e.instance.container.previousSibling;t?.classList.contains("ql-toolbar")&&t.remove()}})),this.uploader?.destroy(),this.specialFields.clear()}collectFormData(e){const t=new FormData(e);let r={};const s={},a={};for(let[i,n]of t.entries()){if(this.ignore.includes(i)||i.endsWith("_temp"))continue;this.getFieldProcessor(i)(i,n,r,s,a,e)}return window.isEmptyObject(a)?this.mergeRepeaterData(r,s):(r=this.mergeRepeaterData(r,s),this.mergePostData(r,a))}getFieldProcessor(e){return e.includes("|")?this.processTableField:e.includes("::")?this.processGroupField:e.includes(":")?this.processRepeaterField:/\[[^\]]+\]/.test(e)?this.processLocationField:this.processRegularField}mergeRepeaterData(e,t){return Object.keys(t).forEach((r=>{const s={};Object.keys(t[r]).forEach((e=>{const a=t[r][e];Object.keys(a).length>0&&(s[e]=a)})),e[r]=Object.values(s)})),e}mergePostData(e,t){for(let[t,r]in Object.entries(r))e[t]=r;return e}processTableField(e,t,r,s,a,i){let[n,o]=e.split("|");!n in a&&(a[n]={});this.getFieldProcessor(o)(o,t,a,s,a,i)}processRepeaterField(e,t,r,s,a,i){let[n,o,l]=e.split(":");const c=l.endsWith("[]");l=l.replace("[]",""),s[n]||(s[n]={}),s[n][o]||(s[n][o]={}),c||s[n][o][l]?(s[n][o][l]?Array.isArray(s[n][o][l])||(s[n][o][l]=[s[n][o][l]]):s[n][o][l]=[],s[n][o][l].push(t)):s[n][o][l]=t}processGroupField(e,t,r,s,a,i){const n=e.split("::"),o=n[0];r[o]||(r[o]={});let l=r[o];for(let e=1;e<n.length-1;e++){const t=n[e];l[t]||(l[t]={}),l=l[t]}const c=n[n.length-1];void 0!==l[c]?(Array.isArray(l[c])||(l[c]=[l[c]]),l[c].push(t)):l[c]=t}processLocationField(e,t,r,s,a,i){let[n,o]=e.split("[");o=o.replace("]",""),Object.hasOwn(r,n)||(r[n]={},Object.hasOwn(r,"sendAll")?r.sendAll.includes(n)||r.sendAll.push(n):r.sendAll=[n]),r[n][o]=t}processRegularField(e,t,r,s,a,i){r[e=e.replace("[]","")]?(Array.isArray(r[e])||(r[e]=[r[e]]),r[e].push(t)):r[e]=t}getFieldValue(e){if(!e)return"";if("checkbox"===e.type)return e.checked?e.value||"1":"";if("radio"===e.type){const t=e.form.querySelector(`[name="${e.name}"]:checked`);return t?t.value:""}return"select-multiple"===e.type?Array.from(e.selectedOptions).map((e=>e.value)):e.value}getChangedFields(e,t){return window.getDifferences?.map(e,t)||{}}showSummary(e,t="form"){const r=this.forms.get(e);if(!r)return;const s=r.element||document.querySelector(`[data-form-id="${e}"]`),a=window.getTemplate("formSummary"),[i,n,o]=[a.querySelector("h2"),a.querySelector(".summary"),a.querySelector(".result")],l=["sendAll",...this.ignore];for(const[e,t]of Object.entries(r.data)){if(l.includes(e)||this.isEmptyValue(t))continue;const r=this.getFieldInfo(s,e);if(!r.label)continue;const a=this.createResultElement(o,r,t,s);a&&n.appendChild(a)}o.remove(),(t="form"!==t?s.closest(t)??s:s).after(a),window.fade(t,!1)}isEmptyValue(e){return null==e||""===e||(!(!Array.isArray(e)||0!==e.length)||"object"==typeof e&&0===Object.keys(e).length)}getFieldInfo(e,t){let r=e.querySelector(`label[for="${t}"]`),s=null,a=null;if(s||(s=e.querySelector(`[name="${t}"]`)),s||(s=e.querySelector(`[name="${t}[]"]`)),!s){const a=e.querySelector(`fieldset[data-field="${t}"]`);a&&(r=a.querySelector("legend"),s=a.querySelector("input, select, textarea"))}if(!r&&s){const e=s.closest(".field, fieldset");e&&(r=e.querySelector("label, legend"))}a=e.querySelector(`.field[data-field="${t}"], fieldset[data-field="${t}"]`);let i="text";return a?.dataset.type?i=a.dataset.type:s&&(i="checkbox"===s.type&&s.name.endsWith("[]")?"checkbox":"checkbox"===s.type?"true_false":"SELECT"===s.tagName&&s.multiple?"select":s.type||"text"),{label:r?.textContent.replace("*","").trim()||null,type:i,wrapper:a,input:s}}createResultElement(e,t,r,s){const a=e.cloneNode(!0),i=a.querySelector("h4"),n=a.querySelector("p");i.textContent=t.label;const o=this.formatFieldValue(r,t.type,s);return this.isHtmlContent(o)?n.innerHTML=o:n.textContent=o,a}isHtmlContent(e){return"string"==typeof e&&(e.includes("<br>")||e.includes("<p>")||e.includes("<ul>")||e.includes("<ol>")||e.includes("<a ")||e.includes("<strong>")||e.includes("<em>")||e.includes("<div"))}formatFieldValue(e,t,r){switch(t){case"textarea":case"wysiwyg":return this.formatTextareaValue(e,t);case"true_false":return"1"===e||1===e||!0===e?"Yes":"No";case"checkbox":return Array.isArray(e)?this.formatArrayValue(e):"1"===e||1===e||!0===e?"Yes":"No";case"select":return Array.isArray(e)?this.formatArrayValue(e):this.getSelectLabel(e,r,t);case"date":case"datetime":case"time":return window.formatDate?window.formatDate(e):e;case"radio":return this.getSelectLabel(e,r,t);case"repeater":return this.formatRepeaterValue(e);case"group":return this.formatGroupValue(e);case"location":return this.formatLocationValue(e);case"file":case"image":return this.formatFileValue(e);case"number":return this.formatNumber(e);case"email":return`<a href="mailto:${e}">${e}</a>`;case"url":return`<a href="${e}" target="_blank" rel="noopener">${e}</a>`;case"phone":return`<a href="tel:${e.replace(/\D/g,"")}">${e}</a>`;default:return Array.isArray(e)?this.formatArrayValue(e):e}}formatRepeaterValue(e){if(!Array.isArray(e)||0===e.length)return"<em>No entries</em>";let t='<div class="repeater-summary">';return e.forEach(((e,r)=>{t+='<div class="repeater-row">',t+=`<strong>Entry ${r+1}:</strong><ul>`;for(const[r,s]of Object.entries(e))if(!this.isEmptyValue(s)){const e=r.replace(/_/g," ").replace(/\b\w/g,(e=>e.toUpperCase()));t+=`<li><strong>${e}:</strong> ${s}</li>`}t+="</ul></div>"})),t+="</div>",t}formatGroupValue(e){if("object"!=typeof e||0===Object.keys(e).length)return"<em>No data</em>";let t='<div class="group-summary"><ul>';for(const[r,s]of Object.entries(e))if(!this.isEmptyValue(s)){const e=r.replace(/_/g," ").replace(/\b\w/g,(e=>e.toUpperCase()));"object"!=typeof s||Array.isArray(s)?t+=`<li><strong>${e}:</strong> ${s}</li>`:t+=`<li><strong>${e}:</strong> ${this.formatGroupValue(s)}</li>`}return t+="</ul></div>",t}formatLocationValue(e){if("object"!=typeof e)return e;const t=[];return["address","city","state","zip","country"].forEach((r=>{e[r]&&t.push(e[r])})),t.join(", ")}formatFileValue(e){return"string"==typeof e?e.startsWith("http")?`<a href="${e}" target="_blank">View file</a>`:e:Array.isArray(e)?e.map((e=>"string"==typeof e?`<a href="${e}" target="_blank">View file</a>`:e.name||"File")).join(", "):"File uploaded"}formatNumber(e){const t=parseFloat(e);return isNaN(t)?e:e.toString().includes(".")&&2===e.toString().split(".")[1].length?new Intl.NumberFormat("en-CA",{style:"currency",currency:"USD"}).format(t):new Intl.NumberFormat("en-CA").format(t)}formatArrayValue(e,t=null,r=null){if(0===e.length)return"<em>None selected</em>";if(t&&r&&r.input){return"<ul><li>"+e.map((e=>this.getSelectLabel(e,t,r.type))).join("</li><li>")+"</li></ul>"}return"<ul><li>"+e.join("</li><li>")+"</li></ul>"}getSelectLabel(e,t,r){if("select"===r){const r=t.querySelector(`option[value="${e}"]`);return r?.textContent||e}if("radio"===r){const r=t.querySelector(`input[type="radio"][value="${e}"]`),s=r?.nextElementSibling;return s?.textContent||e}if("checkbox"===r){const r=t.querySelector(`input[type="checkbox"][value="${e}"]`);if(r){const e=t.querySelector(`label[for="${r.id}"]`);if(e)return e.textContent.trim();const s=r.nextElementSibling;if("LABEL"===s?.tagName)return s.textContent.trim()}}return e}formatTextareaValue(e,t){return e?"wysiwyg"===t||this.containsHtml(e)?e:this.formatPlainText(e):"<em>Empty</em>"}containsHtml(e){return/<(p|strong|em|u|s|ol|ul|li|blockquote|h[1-6]|a|br|span)\b[^>]*>/i.test(e)}formatPlainText(e){if(!e)return"";const t=(e=e.replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">")).split(/\n\n+/);return t.length>1?t.map((e=>`<p>${e.replace(/\n/g,"<br>")}</p>`)).join(""):e.replace(/\n/g,"<br>")}nl2br(e){return this.formatPlainText(e)}subscribe(e){return this.subscribers.add(e),()=>this.subscribers.delete(e)}notify(e,t){this.subscribers.forEach((r=>r(e,t)))}cleanupForm(e){const t=this.forms.get(e);t&&(this.hasUnsavedChanges(e)&&this.autosave(t),this.cleanupSpecialFields(),this.forms.delete(e))}destroy(){this.globalHandlersAdded&&(document.removeEventListener("change",this.changeHandler),document.removeEventListener("focus",this.focusHandler,!0),document.removeEventListener("blur",this.blurHandler,!0),document.removeEventListener("input",this.inputHandler,!0)),this.forms.forEach((e=>{let t=e.element;t&&t.removeEventListener("submit",this.submitHandler)})),this.specialFields.clear(),this.forms.clear(),this.activeRepeaters.clear(),this.forms&&this.forms.clear()}}document.addEventListener("DOMContentLoaded",(()=>{window.jvbForm=e,console.log("FormController in window")}))})();
\ No newline at end of file
diff --git a/assets/js/min/integrations.min.js b/assets/js/min/integrations.min.js
index bd9d210..ac3d085 100644
--- a/assets/js/min/integrations.min.js
+++ b/assets/js/min/integrations.min.js
@@ -1 +1 @@
-window.jvbOAuthPopup=function(e,t){const s=(window.screen.width-600)/2,o=(window.screen.height-700)/2;e+=(e.indexOf("?")>-1?"&":"?")+"popup=1",console.log("Opening OAuth popup for",t,"with URL:",e);const n=window.open(e,t+"-oauth",`width=600,height=700,left=${s},top=${o},scrollbars=yes,resizable=yes,toolbar=no,menubar=no`);if(!n)return alert("Please allow popups for this site to complete the authorization process."),!1;window.jvbOAuthComplete=function(e,s,o){if(console.log("OAuth complete:",e,s,o),e===t)if(s){const e=document.querySelector(`.integration-card[data-service="${t}"] .setup .text`);e&&(e.textContent="Connection successful! Refreshing..."),setTimeout((()=>{jvbRefreshIntegration(t)}),1e3)}else alert("OAuth authorization failed: "+(o||"Unknown error")),jvbRefreshIntegration(t)};const i=setInterval((()=>{try{n.closed&&(clearInterval(i),console.log("OAuth popup closed"),setTimeout((()=>{jvbRefreshIntegration(t)}),1e3))}catch(e){}}),1e3);return!1},window.jvbRefreshIntegration=function(e){console.log("Refreshing integration:",e),fetch(jvbSettings.api+"integrations",{method:"POST",headers:{"Content-Type":"application/json","X-WP-Nonce":jvbSettings.nonce},body:JSON.stringify({service:e,action:"check_oauth_status"})}).then((e=>e.json())).then((t=>{if(console.log("OAuth status check result:",t),t.success&&t.authorized){const t=document.querySelector(`.integration-card[data-service="${e}"]`);if(t){t.classList.remove("disconnected"),t.classList.add("connected");const e=t.querySelector(".setup .text");e&&(e.textContent="Connected"),setTimeout((()=>{location.reload()}),1500)}}else location.reload()})).catch((e=>{console.error("Error checking OAuth status:",e),location.reload()}))},window.integrations=new class{constructor(){this.initElements(),this.initListeners(),this.init()}initElements(){this.selectors={form:"form.integration",action:"data-action"};let e=document.querySelectorAll(this.selectors.form);this.forms=new Map,e.forEach((e=>{this.forms.set(e.dataset.service,e)}))}initListeners(){this.handleClick=this.clickHandler.bind(this),this.handleChange=this.changeHandler.bind(this),this.handleSubmit=this.submitHandler.bind(this),document.addEventListener("click",this.handleClick),document.addEventListener("change",this.handleChange),document.addEventListener("submit",this.handleSubmit)}init(){document.addEventListener("DOMContentLoaded",(()=>{this.checkForOAuthMessages()}))}checkForOAuthMessages(){const e=new URLSearchParams(window.location.search),t=e.get("success"),s=e.get("error");t?(this.showNotification(t,"success"),this.cleanURL()):s&&(this.showNotification(s,"error"),this.cleanURL())}cleanURL(){const e=new URL(window.location);e.searchParams.delete("success"),e.searchParams.delete("error"),window.history.replaceState({},document.title,e.pathname+e.hash)}showNotification(e,t="info"){this.popup?this.addPopup(e,"error"===t?5e3:3e3):(console.log(`[${t}]`,e),document.querySelectorAll(".integration-status-message").forEach((s=>{s.textContent=e,s.className=`integration-status-message ${t}`,setTimeout((()=>{s.textContent="",s.className="integration-status-message"}),5e3)})))}addPopup(e,t=2e3){this.popup||(this.popup=document.querySelector(".integration-popup")||this.createPopupElement()),this.popup.textContent=e,this.popup.classList.add("showing"),setTimeout((()=>{this.popup.classList.remove("showing")}),t)}createPopupElement(){const e=document.createElement("div");return e.className="integration-popup",document.body.appendChild(e),e}clickHandler(e){if(e.target.closest(this.selectors.form)&&(console.log("Clicked!"),"BUTTON"===e.target.tagName||e.target.closest("button"))){e.preventDefault();let t="BUTTON"===e.target.tagName?e.target:e.target.closest("button");this.handleAction(t)}}changeHandler(e){if(e.target.closest(this.selectors.form))if("action"in e.target.dataset)this.handleAction(e.target);else{let t=this.getFormFromTarget(e.target);if(!t)return;t.classList.add("hasChanges"),t.querySelector(".setup .text").textContent="Unsaved Changes"}}submitHandler(e){e.target.closest(this.selectors.form)&&e.preventDefault()}getFormFromTarget(e){let t=e.closest("form")?.dataset.service;return this.forms.get(t)??!1}async handleAction(e){const t=e.closest("form"),s=t.dataset.service,o=e.dataset.action,n="BUTTON"===e.tagName,i=n&&"save_credentials"===o;if(!("confirm"in e.dataset)||confirm(e.dataset.confirm)){this.updateUI(t,"syncing");try{this.updateUI(t,"syncing");const a={service:s,action:o,user_id:jvbSettings.currentUser,data:{}};if(n||(a.data[e.name.replace(s+"_","")]=e.value),i){const e=new FormData(t);for(let[t,o]of e.entries())["service"].includes(t)||t.includes("nonce")||(a.data[t.replace(s+"_","")]=o)}console.log("Sending Data:",a);const r=await fetch(jvbSettings.api+"integrations",{method:"POST",headers:{"Content-Type":"application/json","X-WP-Nonce":jvbSettings.nonce},body:JSON.stringify(a)}),c=await r.json();if(r.ok&&c.success){let e="connected";switch(o){case"clear_credentials":e="disconnected";break;case"save_credentials":this.showNotification("Settings saved successfully","success")}console.log(c),this.updateUI(t,e),c.reload&&setTimeout((()=>{window.location.reload()}),50)}else console.log(c),this.updateUI(t,"error",c.message??""),this.showNotification(c.message||"Operation failed","error")}catch(e){this.updateUI(t,"error"),this.showNotification("Network error: "+e.message,"error"),console.error("API Error:",e)}}}updateUI(e,t,s=""){let o=["connected","disconnected","hasChanges","syncing","error"];if(!o.includes(t))return void console.log("Invalid state: ",t);s=""===s?{connected:"Set Up",disconnected:"Not Set Up",hasChanges:"Unsaved Changes",syncing:"Testing changes",error:"Something went wrong"}[t]:s,"syncing"===t?e.querySelectorAll("button").forEach((e=>{e.disabled=!0})):e.querySelectorAll("button[disabled]").forEach((e=>{e.disabled=!1})),e.classList.remove(...o),e.classList.add(t,"flash"),console.log(e);let n=e.querySelector(".setup .text");console.log(n),n.textContent=s,"syncing"===t?e.querySelectorAll("button").forEach((e=>e.disabled=!0)):e.querySelectorAll("button:disabled").forEach((e=>e.disabled=!1)),setTimeout((()=>e.classList.remove("flash")),600)}};
\ No newline at end of file
+window.jvbOAuthPopup=function(e,t){const s=(window.screen.width-600)/2,o=(window.screen.height-700)/2;e+=(e.indexOf("?")>-1?"&":"?")+"popup=1",console.log("Opening OAuth popup for",t,"with URL:",e);const n=window.open(e,t+"-oauth",`width=600,height=700,left=${s},top=${o},scrollbars=yes,resizable=yes,toolbar=no,menubar=no`);if(!n)return alert("Please allow popups for this site to complete the authorization process."),!1;window.jvbOAuthComplete=function(e,s,o){if(console.log("OAuth complete:",e,s,o),e===t)if(s){const e=document.querySelector(`.integration-card[data-service="${t}"] .setup .text`);e&&(e.textContent="Connection successful! Refreshing..."),setTimeout((()=>{jvbRefreshIntegration(t)}),1e3)}else alert("OAuth authorization failed: "+(o||"Unknown error")),jvbRefreshIntegration(t)};const i=setInterval((()=>{try{n.closed&&(clearInterval(i),console.log("OAuth popup closed"),setTimeout((()=>{jvbRefreshIntegration(t)}),1e3))}catch(e){}}),1e3);return!1},window.jvbRefreshIntegration=function(e){console.log("Refreshing integration:",e),fetch(jvbSettings.api+"integrations",{method:"POST",headers:{"Content-Type":"application/json","X-WP-Nonce":jvbSettings.nonce},body:JSON.stringify({service:e,action:"check_oauth_status"})}).then((e=>e.json())).then((t=>{if(console.log("OAuth status check result:",t),t.success&&t.authorized){const t=document.querySelector(`.integration-card[data-service="${e}"]`);if(t){t.classList.remove("disconnected"),t.classList.add("connected");const e=t.querySelector(".setup .text");e&&(e.textContent="Connected"),setTimeout((()=>{location.reload()}),1500)}}else location.reload()})).catch((e=>{console.error("Error checking OAuth status:",e),location.reload()}))},window.integrations=new class{constructor(){this.initElements(),this.initListeners(),this.init()}initElements(){this.selectors={form:"form.integration",action:"data-action"};let e=document.querySelectorAll(this.selectors.form);this.forms=new Map,e.forEach((e=>{this.forms.set(e.dataset.service,e)}))}initListeners(){this.handleClick=this.clickHandler.bind(this),this.handleChange=this.changeHandler.bind(this),this.handleSubmit=this.submitHandler.bind(this),document.addEventListener("click",this.handleClick),document.addEventListener("change",this.handleChange),document.addEventListener("submit",this.handleSubmit)}init(){document.addEventListener("DOMContentLoaded",(()=>{this.checkForOAuthMessages()}))}checkForOAuthMessages(){const e=new URLSearchParams(window.location.search),t=e.get("success"),s=e.get("error");t?(this.showNotification(t,"success",5e3),this.cleanURL(),document.querySelectorAll("form.integration").forEach((e=>{this.updateUI(e,"connected")}))):s&&(this.showNotification(s,"error",8e3),this.cleanURL())}cleanURL(){const e=new URL(window.location);e.searchParams.delete("success"),e.searchParams.delete("error"),window.history.replaceState({},document.title,e.pathname+e.hash)}showNotification(e,t="info",s=5e3){let o=document.querySelector(".integration-status-message");if(!o){o=document.createElement("div"),o.className="integration-status-message";const e=document.querySelector(".integration-settings")||document.querySelector("main")||document.body;e.insertBefore(o,e.firstChild)}o.textContent=e,o.className=`integration-status-message ${t}`,this.notificationTimeout&&clearTimeout(this.notificationTimeout),s>0&&(this.notificationTimeout=setTimeout((()=>{o.className="integration-status-message",o.textContent=""}),s)),this.popup&&this.addPopup(e,s)}addPopup(e,t=2e3){this.popup||(this.popup=document.querySelector(".integration-popup")||this.createPopupElement()),this.popup.textContent=e,this.popup.classList.add("showing"),setTimeout((()=>{this.popup.classList.remove("showing")}),t)}createPopupElement(){const e=document.createElement("div");return e.className="integration-popup",document.body.appendChild(e),e}clickHandler(e){if(e.target.closest(this.selectors.form)&&(console.log("Clicked!"),"BUTTON"===e.target.tagName||e.target.closest("button"))){e.preventDefault();let t="BUTTON"===e.target.tagName?e.target:e.target.closest("button");this.handleAction(t)}}changeHandler(e){if(e.target.closest(this.selectors.form))if("action"in e.target.dataset)this.handleAction(e.target);else{let t=this.getFormFromTarget(e.target);if(!t)return;t.classList.add("hasChanges"),t.querySelector(".setup .text").textContent="Unsaved Changes"}}submitHandler(e){e.target.closest(this.selectors.form)&&e.preventDefault()}getFormFromTarget(e){let t=e.closest("form")?.dataset.service;return this.forms.get(t)??!1}handleOAuthClick(e){const t=e.dataset.service,s=e.href,o=(screen.width-600)/2,n=(screen.height-700)/2;this.showNotification("Opening authorization window...","info"),e.classList.add("loading"),e.setAttribute("aria-busy","true");const i=window.open(s,"oauth_"+t,`width=600,height=700,left=${o},top=${n},toolbar=no,menubar=no,location=yes,status=yes,resizable=yes`);if(!i)return this.showNotification("Popup was blocked. Please allow popups and try again.","error"),e.classList.remove("loading"),e.removeAttribute("aria-busy"),!0;i.focus(),this.showNotification("Waiting for authorization...","info");const a=setInterval((()=>{try{i.closed&&(clearInterval(a),e.classList.remove("loading"),e.removeAttribute("aria-busy"),this.showNotification("Checking authorization status...","info"),setTimeout((()=>{this.checkForOAuthMessages(),setTimeout((()=>{const e=new URLSearchParams(window.location.search);e.has("success")||e.has("error")||window.location.reload()}),500)}),500))}catch(e){}}),500);return setTimeout((()=>{clearInterval(a),e.classList.remove("loading"),e.removeAttribute("aria-busy")}),3e5),!1}async handleAction(e){const t=e.closest("form"),s=t.dataset.service,o=e.dataset.action,n="BUTTON"===e.tagName,i=n&&"save_credentials"===o;if(!("confirm"in e.dataset)||confirm(e.dataset.confirm)){this.updateUI(t,"syncing");try{this.updateUI(t,"syncing");const a={service:s,action:o,user_id:jvbSettings.currentUser,data:{}};if(n||(a.data[e.name.replace(s+"_","")]=e.value),i){const e=new FormData(t);for(let[t,o]of e.entries())["service"].includes(t)||t.includes("nonce")||(a.data[t.replace(s+"_","")]=o)}console.log("Sending Data:",a);const r=await fetch(jvbSettings.api+"integrations",{method:"POST",headers:{"Content-Type":"application/json","X-WP-Nonce":jvbSettings.nonce},body:JSON.stringify(a)}),c=await r.json();if(r.ok&&c.success){let e="connected";switch(o){case"clear_credentials":e="disconnected";break;case"save_credentials":this.showNotification("Settings saved successfully","success")}console.log(c),this.updateUI(t,e),c.reload&&setTimeout((()=>{window.location.reload()}),50)}else console.log(c),this.updateUI(t,"error",c.message??""),this.showNotification(c.message||"Operation failed","error")}catch(e){this.updateUI(t,"error"),this.showNotification("Network error: "+e.message,"error"),console.error("API Error:",e)}}}updateUI(e,t,s=""){let o=["connected","disconnected","hasChanges","syncing","error"];if(!o.includes(t))return void console.log("Invalid state: ",t);s=""===s?{connected:"Set Up",disconnected:"Not Set Up",hasChanges:"Unsaved Changes",syncing:"Testing changes",error:"Something went wrong"}[t]:s,"syncing"===t?e.querySelectorAll("button").forEach((e=>{e.disabled=!0})):e.querySelectorAll("button[disabled]").forEach((e=>{e.disabled=!1})),e.classList.remove(...o),e.classList.add(t,"flash"),console.log(e);let n=e.querySelector(".setup .text");console.log(n),n.textContent=s,"syncing"===t?e.querySelectorAll("button").forEach((e=>e.disabled=!0)):e.querySelectorAll("button:disabled").forEach((e=>e.disabled=!1)),setTimeout((()=>e.classList.remove("flash")),600)}};
\ No newline at end of file
diff --git a/assets/js/min/navigation.min.js b/assets/js/min/navigation.min.js
index bccc195..cd96cb0 100644
--- a/assets/js/min/navigation.min.js
+++ b/assets/js/min/navigation.min.js
@@ -1 +1 @@
-(()=>{class e{constructor(){this.counter=0,this.initElements(),0!==this.navs.length&&(this.openNav=null,this.initListeners())}initElements(){this.navs=new Map,document.querySelectorAll("nav:has(.submenu), nav:has(.toggle)").forEach((e=>{let t=e.id;""===t&&(t=`nav-${this.counter}`,e.id=t,this.counter++),e.querySelector(".submenu")&&(e.addEventListener("mouseenter",this.hoverOnListener),e.addEventListener("mouseleave",this.hoverOffListener));let[s,n,i]=[e.querySelectorAll("nav .toggle"),e.querySelectorAll(".has-submenu"),e.querySelectorAll(".toggle:not(.main)")],a={nav:e,toggles:s,submenus:n,submenuToggles:i};this.navs.set(t,a),this.counter++}))}navIDs(){return Array.from(this.navs.keys()).map((e=>`#${e}`))}initListeners(){this.clickListener=this.handleClick.bind(this),this.escapeListener=this.handleEscape.bind(this),this.hoverOnListener=this.handleHoverOn.bind(this),this.hoverOffListener=this.handleHoverOff.bind(this),document.addEventListener("click",this.clickListener)}handleClick(e){if(this.openNav&&!e.target.closest(this.openNav)&&this.toggleNav(!1),!e.target.closest(...this.navIDs()))return;let t=e.target.closest(".toggle.main");if(t){let e=t.closest("nav");this.toggleNav(!e.classList.contains("open"),e.id)}}handleHoverOn(e){console.log(e.target);let t=e.target.closest("nav");t&&this.toggleNav(!0,t.id);let s=e.target.closest(".has-submenu");s&&this.toggleSubmenu(!0,s)}handleHoverOff(e){console.log(e.target);let t=e.target.closest("nav");t&&this.toggleNav(!1,t.id)}handleEscape(e){this.openNav&&"Escape"===e.key&&this.toggleNav(!1,this.openNav)}toggleNav(e,t){let s=this.navs.get(t);s&&(e&&t!==this.openNav&&this.toggleNav(!1,this.openNav),e?(this.openNav=t,document.addEventListener("keydown",this.escapeListener)):(this.openNav===t&&(this.openNav=null),document.removeEventListener("keydown",this.escapeListener),Array.from(s.submenus).forEach((e=>{e.classList.contains("open")&&this.toggleSubmenu(!1,e)}))),s.nav.ariaExpanded=e,s.nav.classList.toggle("open",e),s.ariaHidden=!e,e&&s.nav.querySelector("a:not(.skip-to-content)")?.focus())}toggleSubmenu(e,t){let[s,n]=[t.querySelector(".toggle"),t.querySelector("a")];t.classList.toggle("open",e),t.ariaHidden=!e,s.ariaExpanded=e,e&&n&&n.focus()}}document.addEventListener("DOMContentLoaded",(function(){window.jvbNav=new e}))})();
\ No newline at end of file
+(()=>{class e{constructor(){this.counter=0,this.initElements(),0!==this.navs.size&&(this.openNav=null,this.initListeners())}initElements(){this.navs=new Map,document.querySelectorAll("nav:has(.submenu), nav:has(.toggle)").forEach((e=>{let t=e.id;""===t&&(t=`nav-${this.counter}`,e.id=t,this.counter++),e.querySelector(".submenu")&&(e.addEventListener("mouseenter",this.hoverOnListener),e.addEventListener("mouseleave",this.hoverOffListener));let[s,n,i]=[e.querySelectorAll("nav .toggle"),e.querySelectorAll(".has-submenu"),e.querySelectorAll(".toggle:not(.main)")],a={nav:e,toggles:s,submenus:n,submenuToggles:i};this.navs.set(t,a),this.counter++}))}navIDs(){return Array.from(this.navs.keys()).map((e=>`#${e}`))}initListeners(){this.clickListener=this.handleClick.bind(this),this.escapeListener=this.handleEscape.bind(this),this.hoverOnListener=this.handleHoverOn.bind(this),this.hoverOffListener=this.handleHoverOff.bind(this),document.addEventListener("click",this.clickListener)}handleClick(e){if(0===this.navs.size)return;if(this.openNav&&!e.target.closest(this.openNav)&&this.toggleNav(!1),!e.target.closest(...this.navIDs()))return;let t=e.target.closest(".toggle.main");if(t){let e=t.closest("nav");this.toggleNav(!e.classList.contains("open"),e.id)}let s=e.target.closest('[data-action="toggle-submenu"]');if(s){let e=s.closest("li");this.toggleSubmenu(!e.classList.contains("open"),e)}}handleHoverOn(e){console.log(e.target);let t=e.target.closest("nav");t&&this.toggleNav(!0,t.id);let s=e.target.closest(".has-submenu");s&&this.toggleSubmenu(!0,s)}handleHoverOff(e){console.log(e.target);let t=e.target.closest("nav");t&&this.toggleNav(!1,t.id)}handleEscape(e){this.openNav&&"Escape"===e.key&&this.toggleNav(!1,this.openNav)}toggleNav(e,t){let s=this.navs.get(t);s&&(e&&t!==this.openNav&&this.toggleNav(!1,this.openNav),e?(this.openNav=t,document.addEventListener("keydown",this.escapeListener)):(this.openNav===t&&(this.openNav=null),document.removeEventListener("keydown",this.escapeListener),Array.from(s.submenus).forEach((e=>{e.classList.contains("open")&&this.toggleSubmenu(!1,e)}))),s.nav.ariaExpanded=e,s.nav.classList.toggle("open",e),s.ariaHidden=!e,e&&s.nav.querySelector("a:not(.skip-to-content)")?.focus())}toggleSubmenu(e,t){let[s,n]=[t.querySelector(".toggle"),t.querySelector("a")];t.classList.toggle("open",e),t.ariaHidden=!e,s.ariaExpanded=e,e&&n&&n.focus()}}document.addEventListener("DOMContentLoaded",(function(){window.jvbNav=new e}))})();
\ No newline at end of file
diff --git a/assets/js/min/populate.min.js b/assets/js/min/populate.min.js
index 8a2b288..f5d813a 100644
--- a/assets/js/min/populate.min.js
+++ b/assets/js/min/populate.min.js
@@ -1 +1 @@
-window.jvbPopulate=class{constructor(e,t={},a={},r={}){for(let[l,i]of Object.entries(t)){let t=e.querySelector(`[data-field="${l}"]`);t&&this.populateField(t,l,i,a,r)}}populateField(e,t,a,r={},l={}){if(e&&null!=a)switch(this.getFieldType(e)){case"image":this.populateImageField(e,t,a,r);break;case"gallery":this.populateGalleryField(e,t,a,r);break;case"repeater":this.populateRepeaterField(e,t,a,l);break;case"taxonomy":this.populateTaxonomyField(e,t,a);break;case"user":this.populateUserField(e,t,a);break;case"location":this.populateLocationField(e,t,a);break;case"set":case"checkbox":this.populateSetField(e,t,a);break;case"select":case"radio":this.populateSelectField(e,t,a);break;case"true_false":this.populateBooleanField(e,t,a);break;case"date":case"time":case"datetime":this.populateDateField(e,t,a);break;case"number":this.populateNumberField(e,t,a);break;case"textarea":e.querySelector(".editor-container")?this.populateEditorField(e,t,a):this.populateTextareaField(e,t,a);break;default:this.populateTextField(e,t,a)}}getFieldType(e){const t=["image","gallery","repeater","taxonomy","user","location","set","checkbox","select","radio","true_false","date","time","datetime","editor","number","text","textarea","email","url","tel","phone"];for(const a of t)if(e.classList.contains(a))return a;if(e.dataset.type)return e.dataset.type;const a=e.querySelector("input, select, textarea");if(a){if("TEXTAREA"===a.tagName)return"true"===a.dataset.editor?"editor":"textarea";if(a.type)return"checkbox"!==a.type||e.classList.contains("true_false")?a.type:"set"}return"text"}populateTextField(e,t,a){const r=e.querySelector(`[name="${t}"], input, textarea`);if(r&&(r.value=String(a||""),r.dataset.limit)){const t=e.querySelector(".char-count .current");t&&(t.textContent=r.value.length)}}populateTextareaField(e,t,a){const r=e.querySelector(`textarea[name="${t}"]`)||e.querySelector('textarea:not([data-editor="true"])');if(r){if(r.value,r.value=String(a||""),r.dispatchEvent(new Event("change",{bubbles:!0})),r.dataset.limit){const t=e.querySelector(".char-count .current");if(t){t.textContent=r.value.length;const a=parseInt(r.dataset.limit,10);r.value.length>=a?e.classList.add("reached"):e.classList.remove("reached")}}}else console.warn(`No textarea found for field ${t} in wrapper:`,e)}populateNumberField(e,t,a){const r=e.querySelector(`[name="${t}"], input[type="number"]`);r&&(r.value=Number(a)||0)}populateBooleanField(e,t,a){const r=e.querySelector(`[name="${t}"], input[type="checkbox"]`);r&&(r.checked=Boolean(a))}populateSelectField(e,t,a){const r=String(a||""),l=e.querySelector(`select[name="${t}"]`);if(l)return void(l.value=r);const i=e.querySelector(`input[type="radio"][name="${t}"][value="${r}"]`);i&&(i.checked=!0)}populateSetField(e,t,a){let r=a;if("string"==typeof a)try{r=JSON.parse(a)}catch(e){r=a.split(",").map((e=>e.trim()))}Array.isArray(r)||(r=[String(r)]),e.querySelectorAll(`input[type="checkbox"][name*="${t}"]`).forEach((e=>{e.checked=r.includes(e.value)}))}populateDateField(e,t,a){const r=e.querySelector(`[name="${t}"], input`);if(r&&a){let e=a;"object"==typeof a&&a.date&&(e=a.date);try{const t=new Date(e);if(!isNaN(t.getTime()))switch(r.type){case"date":r.value=t.toISOString().split("T")[0];break;case"time":r.value=t.toTimeString().slice(0,5);break;case"datetime-local":r.value=t.toISOString().slice(0,16);break;default:r.value=e}}catch(t){r.value=e}}}populateEditorField(e,t,a){const r=e.querySelector(`textarea[name="${t}"]`)||e.querySelector('textarea[data-editor="true"]')||e.querySelector("textarea");if(!r)return void console.warn(`Editor field ${t}: textarea not found`);const l=String(a||"");r.value=l;const i=e.querySelector(".editor");if(i){let e=null;if(i.__quill)e=i.__quill;else if(i.quill)e=i.quill;else if(window.Quill&&window.Quill.find)e=window.Quill.find(i);else if(window.Quill&&window.Quill.instances)for(let t of window.Quill.instances)if(t.container===i){e=t;break}e?(e.root.innerHTML=l,i.__quill=e):(console.warn(`Quill instance not found for ${t}, setting HTML directly`),i.innerHTML=l)}else console.warn(`Editor container not found for ${t}`);r.dispatchEvent(new Event("change",{bubbles:!0}))}populateLocationField(e,t,a){a&&"object"==typeof a&&["address","lat","lng","street","city","province","postal_code","country"].forEach((r=>{if(void 0!==a[r]){const l=e.querySelector(`[name="${t}_${r}"], [name="${r}"]`);l&&(l.value=String(a[r]||""))}}))}populateTaxonomyField(e,t,a){let r=[];if(Array.isArray(a))r=a.map((e=>String(e)));else if("string"==typeof a)try{const e=JSON.parse(a);r=Array.isArray(e)?e.map((e=>String(e))):[String(e)]}catch(e){r=a.split(",").map((e=>e.trim()))}else a&&(r=[String(a)]);if(0===r.length)return;const l=e.querySelector(`input[type="hidden"][name="${t}"]`);l&&(l.value=r.join(","))}populateUserField(e,t,a){this.populateTaxonomyField(e,t,a)}populateImageField(e,t,a,r={}){if(!a)return;const l=String(a).split(",").filter((e=>parseInt(e.trim())));if(0===l.length)return;const i=e.querySelector(`input[type="hidden"][name="${t}"]`);i&&(i.value=l.join(","));const o=e.querySelector(".item-grid"),n=e.querySelector(".file-upload-container");e.querySelector(".progress")?.remove(),o&&(window.removeChildren(o),l.forEach((e=>{let t=window.getTemplate("uploadItem"),a=t.querySelector("img"),l=t.querySelector("details"),i=window.getTemplate("uploadMeta");l.append(i),[a.src,a.alt,t.querySelector('[name="image-title"]').value,t.querySelector('[name="image-alt-text"]').value,t.querySelector('[name="image-caption"]').value]=[r[e].medium,r[e].alt,r[e].title,r[e].alt,r[e].caption],l.querySelector(".upload-meta > .hint")?.remove(),o.append(t)})),l.length>0&&n&&(n.hidden=!0))}populateGalleryField(e,t,a,r={}){this.populateImageField(e,t,a,r)}populateRepeaterField(e,t,a,r={}){if(!a||!Array.isArray(a))return;const l=e.querySelector(".repeater-items"),i=e.querySelector("template");l&&i?(window.removeChildren(l),a.forEach(((a,r)=>{if(!a||"object"!=typeof a)return;const o=window.getTemplate(i.className);if(!o)return void console.warn(`Repeater field ${t}: template not found`);o.id=`${e.closest("form").id}-${t}-row-${r}`,o.dataset.index=r;const n=o.querySelector(".row-number");n&&(n.textContent=`#${r+1}`),o.querySelectorAll("input, select, textarea").forEach((e=>{const l=e.name,i=`${t}:${r}:${l}`,o=`${t}-${r}-${l}-${e.value}`;e.name=i,e.id=o;const n=e.nextElementSibling;n&&"LABEL"===n.tagName&&(n.htmlFor=o),void 0!==a[l]&&this.populateRepeaterFieldValue(e,l,a[l])})),l.appendChild(o)}))):console.warn(`Repeater field ${t}: missing container or template`)}populateRepeaterFieldValue(e,t,a){switch(e.type){case"checkbox":e.checked=Boolean(a);break;case"radio":e.checked=e.value===String(a);break;default:e.value=String(a||"")}}};
\ No newline at end of file
+window.jvbPopulate=class{constructor(e,t={},a={},l={}){console.log("Populating field... ",e),console.log("fieldData: ",t),console.log("imageData: ",a),console.log("options: ",l);for(let[r,o]of Object.entries(t)){let t=e.querySelector(`[data-field="${r}"]`);t&&this.populateField(t,r,o,a,l)}}populateField(e,t,a,l={},r={}){if(e&&null!=a)switch(this.getFieldType(e)){case"image":this.populateImageField(e,t,a,l);break;case"gallery":this.populateGalleryField(e,t,a,l);break;case"repeater":this.populateRepeaterField(e,t,a,r);break;case"taxonomy":this.populateTaxonomyField(e,t,a);break;case"user":this.populateUserField(e,t,a);break;case"location":this.populateLocationField(e,t,a);break;case"set":case"checkbox":this.populateSetField(e,t,a);break;case"select":case"radio":this.populateSelectField(e,t,a);break;case"true_false":this.populateBooleanField(e,t,a);break;case"date":case"time":case"datetime":this.populateDateField(e,t,a);break;case"number":this.populateNumberField(e,t,a);break;case"textarea":e.querySelector(".editor-container")?this.populateEditorField(e,t,a):this.populateTextareaField(e,t,a);break;default:this.populateTextField(e,t,a)}}getFieldType(e){const t=["image","gallery","repeater","taxonomy","user","location","set","checkbox","select","radio","true_false","date","time","datetime","editor","number","text","textarea","email","url","tel","phone"];for(const a of t)if(e.classList.contains(a))return a;if(e.dataset.type)return e.dataset.type;const a=e.querySelector("input, select, textarea");if(a){if("TEXTAREA"===a.tagName)return"true"===a.dataset.editor?"editor":"textarea";if(a.type)return"checkbox"!==a.type||e.classList.contains("true_false")?a.type:"set"}return"text"}populateTextField(e,t,a){const l=e.querySelector(`[name="${t}"], input, textarea`);if(l&&(l.value=String(a||""),l.dataset.limit)){const t=e.querySelector(".char-count .current");t&&(t.textContent=l.value.length)}}populateTextareaField(e,t,a){const l=e.querySelector(`textarea[name="${t}"]`)||e.querySelector('textarea:not([data-editor="true"])');if(l){if(l.value,l.value=String(a||""),l.dispatchEvent(new Event("change",{bubbles:!0})),l.dataset.limit){const t=e.querySelector(".char-count .current");if(t){t.textContent=l.value.length;const a=parseInt(l.dataset.limit,10);l.value.length>=a?e.classList.add("reached"):e.classList.remove("reached")}}}else console.warn(`No textarea found for field ${t} in wrapper:`,e)}populateNumberField(e,t,a){const l=e.querySelector(`[name="${t}"], input[type="number"]`);l&&(l.value=Number(a)||0)}populateBooleanField(e,t,a){const l=e.querySelector(`[name="${t}"], input[type="checkbox"]`);l&&(l.checked=Boolean(a))}populateSelectField(e,t,a){const l=String(a||""),r=e.querySelector(`select[name="${t}"]`);if(r)return void(r.value=l);const o=e.querySelector(`input[type="radio"][name="${t}"][value="${l}"]`);o&&(o.checked=!0)}populateSetField(e,t,a){let l=a;if("string"==typeof a)try{l=JSON.parse(a)}catch(e){l=a.split(",").map((e=>e.trim()))}Array.isArray(l)||(l=[String(l)]),e.querySelectorAll(`input[type="checkbox"][name*="${t}"]`).forEach((e=>{e.checked=l.includes(e.value)}))}populateDateField(e,t,a){const l=e.querySelector(`[name="${t}"], input`);if(l&&a){let e=a;"object"==typeof a&&a.date&&(e=a.date);try{const t=new Date(e);if(!isNaN(t.getTime()))switch(l.type){case"date":l.value=t.toISOString().split("T")[0];break;case"time":l.value=t.toTimeString().slice(0,5);break;case"datetime-local":l.value=t.toISOString().slice(0,16);break;default:l.value=e}}catch(t){l.value=e}}}populateEditorField(e,t,a){const l=e.querySelector(`textarea[name="${t}"]`)||e.querySelector('textarea[data-editor="true"]')||e.querySelector("textarea");if(!l)return void console.warn(`Editor field ${t}: textarea not found`);const r=String(a||"");l.value=r;const o=e.querySelector(".editor");if(o){let e=null;if(o.__quill)e=o.__quill;else if(o.quill)e=o.quill;else if(window.Quill&&window.Quill.find)e=window.Quill.find(o);else if(window.Quill&&window.Quill.instances)for(let t of window.Quill.instances)if(t.container===o){e=t;break}e?(e.root.innerHTML=r,o.__quill=e):(console.warn(`Quill instance not found for ${t}, setting HTML directly`),o.innerHTML=r)}else console.warn(`Editor container not found for ${t}`);l.dispatchEvent(new Event("change",{bubbles:!0}))}populateLocationField(e,t,a){a&&"object"==typeof a&&["address","lat","lng","street","city","province","postal_code","country"].forEach((l=>{if(void 0!==a[l]){const r=e.querySelector(`[name="${t}_${l}"], [name="${l}"]`);r&&(r.value=String(a[l]||""))}}))}populateTaxonomyField(e,t,a){let l=[];if(Array.isArray(a))l=a.map((e=>String(e)));else if("string"==typeof a)try{const e=JSON.parse(a);l=Array.isArray(e)?e.map((e=>String(e))):[String(e)]}catch(e){l=a.split(",").map((e=>e.trim()))}else a&&(l=[String(a)]);if(0===l.length)return;const r=e.querySelector(`input[type="hidden"][name="${t}"]`);r&&(r.value=l.join(","))}populateUserField(e,t,a){this.populateTaxonomyField(e,t,a)}populateImageField(e,t,a,l={}){if(!a)return;const r=String(a).split(",").filter((e=>parseInt(e.trim())));if(0===r.length)return;const o=e.querySelector(`input[type="hidden"][name="${t}"]`);o&&(o.value=r.join(","));const i=e.querySelector(".item-grid"),n=e.querySelector(".file-upload-container");e.querySelector(".progress")?.remove(),i&&(window.removeChildren(i),r.forEach((e=>{let t=window.getTemplate("uploadItem"),a=t.querySelector("img"),r=t.querySelector("details"),o=window.getTemplate("uploadMeta");r.append(o),[a.src,a.alt,t.querySelector('[name="image-title"]').value,t.querySelector('[name="image-alt-text"]').value,t.querySelector('[name="image-caption"]').value]=[l[e].medium,l[e].alt,l[e].title,l[e].alt,l[e].caption],r.querySelector(".upload-meta > .hint")?.remove(),i.append(t)})),r.length>0&&n&&(n.hidden=!0))}populateGalleryField(e,t,a,l={}){this.populateImageField(e,t,a,l)}populateRepeaterField(e,t,a,l={}){if(console.log("fieldWrapper",e),console.log("fieldName",t),console.log("fieldValue",a),console.log("options",l),!a||!Array.isArray(a))return;const r=e.querySelector(".repeater-items"),o=e.querySelector("template");r&&o?(window.removeChildren(r),a.forEach(((a,l)=>{if(!a||"object"!=typeof a)return;const i=window.getTemplate(o.className);if(!i)return void console.warn(`Repeater field ${t}: template not found`);i.id=`${e.closest("form").id}-${t}-row-${l}`,i.dataset.index=l;const n=i.querySelector(".row-number");n&&(n.textContent=`#${l+1}`),i.querySelectorAll("input, select, textarea").forEach((e=>{const r=e.name,o=`${t}:${l}:${r}`,i=`${t}-${l}-${r}-${e.value}`;e.name=o,e.id=i;const n=e.nextElementSibling;n&&"LABEL"===n.tagName&&(n.htmlFor=i),void 0!==a[r]&&this.populateRepeaterFieldValue(e,r,a[r])})),r.appendChild(i)}))):console.warn(`Repeater field ${t}: missing container or template`)}populateRepeaterFieldValue(e,t,a){switch(e.type){case"checkbox":e.checked=Boolean(a);break;case"radio":e.checked=e.value===String(a);break;default:e.value=String(a||"")}}};
\ No newline at end of file
diff --git a/assets/js/min/queue.min.js b/assets/js/min/queue.min.js
index 84975eb..2e4f0da 100644
--- a/assets/js/min/queue.min.js
+++ b/assets/js/min/queue.min.js
@@ -1 +1 @@
-(()=>{class e{constructor(e={}){this.canUpdateUI=!0,console.log("jvbSettings",jvbSettings),this.config={apiBase:jvbSettings.api,maxRetries:3,pollInterval:5e3,activityDelay:2e3,autosync:!0,endpoint:"queue",...e},this.user=jvbSettings.currentUser,this.headers={"X-WP-Nonce":jvbSettings.nonce,...e.headers},this.a11y=window.jvbA11y,this.errors=window.jvbError,this.store=new window.jvbStore({name:"queue",storeName:"operations",keyPath:"id",endpoint:this.config.endpoint,TTL:1/0,indexes:[{name:"status",keyPath:"status"},{name:"type",keyPath:"type"}],showLoading:!1}),this.queue=new Map,this.classes=["offline","synced","pending"],this.isProcessing=!1,this.isPolling=!1,this.subscribers=new Set,this.statuses=["queued","localProcessing","uploading","pending","processing","completed","failed","failed_permanent"],this.initUI(),this.initListeners(),console.log(this.ui),this.ui.panel&&(this.popup=new window.jvbPopup({popup:this.ui.panel,toggle:this.ui.toggle,name:"Queue Panel"})),this.initQueue(),this.user&&(this.ui.toggle.hidden=!1,this.ui.panel.hidden=!1)}async initQueue(){const e=this.getOperationsByStatus(["completed","failed_permanent"],!1);e.length>0?this.startPolling():this.updateStatusPanel("synced"),this.store.subscribe(((e,t)=>{switch(e){case"data-fetched":case"data-cached":this.updateOperationsFromServer(t.data.items);break;case"items-updated":this.updateOperationsFromServer(t.items);break;case"item-stored":this.updateOperationsFromServer([t])}})),this.store.fetch(),this.notify("queue-initialized",{operations:e})}addToQueue(e){const t={id:`u${this.user}_${Date.now()}_${Math.random().toString(36).substring(2,9)}`,endpoint:null,method:"POST",headers:{},data:{},canMerge:!0,popup:"Saving changes...",title:"Operation",status:"queued",timestamp:Date.now(),retries:0,user:this.user,...e};if(t.headers={...this.headers,...t.headers},!t.endpoint||!t.data)return console.error("Invalid operation queued: missing endpoint or data"),null;const s=Array.from(this.queue.values()).filter((e=>"queued"===e.status&&e.endpoint===t.endpoint&&e.canMerge));if(s.length>0){const e=s[0];return e.data=window.deepMerge(e.data,t.data),e.timestamp=Date.now(),this.updateOperationStatus(e.id,e.status),this.updateUI(),this.startActivityTracking(),e.id}return console.log("Added to Queue: ",t),this.setQueue(t),this.updateOperationStatus(t.id,t.status),this.updateUI(),this.startActivityTracking(),t.id}setQueue(e){this.queue.set(e.id,e),this.store.save(e.id,e)}updateOperationStatus(e,t){let s=this.queue.get(e);s&&(s.status=t,this.notify("operation-status",s),this.updateOperationUI(s))}getQueue(e){return this.queue.has(e)?this.queue.get(e):this.store.getItem(e)}clearQueue(e){this.queue.has(e)&&this.queue.delete(e),this.store.clearItem(e)}startActivityTracking(){if(!this.activityListeners){const e=["mousedown","mousemove","keypress","scroll","touchstart"];this.activityListeners=e.map((e=>{const t=()=>this.resetActivityTimer();return document.addEventListener(e,t,{passive:!0}),{event:e,handler:t}}))}this.resetActivityTimer()}resetActivityTimer(){this.lastActivity=Date.now(),this.activityTimer&&clearTimeout(this.activityTimer),this.activityTimer=setTimeout((()=>{this.processQueue()}),this.config.activityDelay)}stopActivityTracking(){this.activityTimer&&(clearTimeout(this.activityTimer),this.activityTimer=null),this.activityListeners&&(this.activityListeners.forEach((({event:e,handler:t})=>{document.removeEventListener(e,t)})),this.activityListeners=null)}setProcessing(e){this.isProcessing=e,this.ui.toggle.classList.toggle("saving",e)}async processQueue(){if(this.isProcessing)return;const e=this.getOperationsByStatus("queued");if(0===e.length)return void this.stopActivityTracking();this.setProcessing(!0);for(const t of e)await this.processOperation(t);this.setProcessing(!1),this.stopActivityTracking();this.getOperationsByStatus(["queued","completed","failed_permanent"],!1).length>0&&this.startPolling()}async processOperation(e){try{this.updateOperationStatus(e.id,"uploading");const t=`${this.config.apiBase}${e.endpoint}`;let s;e.data instanceof FormData?(e.data.append("id",e.id),e.data.append("user",this.user),s=e.data):(s=JSON.stringify({...e.data,id:e.id,user:this.user}),e.headers["Content-Type"]="application/json");const i=await fetch(t,{method:e.method,headers:e.headers,body:s}),a=await i.json();if(!i.ok||!1===a.success)throw new Error(a.message||`HTTP ${i.status}`);if(a.id&&e.id!==a.id){const t=this.getQueue(a.id);t?(t.data=window.deepMerge(t.data,e.data),t.status="pending",t.serverData=a,this.updateOperationStatus(t.id,t.status),this.setQueue(t),this.removeOperationFromUI(e.id),e=t):(this.clearQueue(e.id),e.id=a.id,e.status="pending",e.serverData=a,this.updateOperationStatus(e.id,e.status),this.setQueue(e))}else e.status="pending",e.serverData=a,this.updateOperationStatus(e.id,"pending"),this.setQueue(e);this.a11y.announce(`${e.title} sent to server for processing.`)}catch(t){console.error("Operation failed:",t),e.retries++,e.lastError=t.message,e.retries>=this.config.maxRetries?e.status="failed_permanent":(e.status="failed",e.nextRetry=Date.now()+1e3*Math.pow(2,e.retries)),this.updateOperationStatus(e.id,e.status),this.setQueue(e)}}startPolling(){this.isPolling||(this.isPolling=!0,this.pollServer(),this.pollTimer=setInterval((()=>{this.pollServer()}),this.config.pollInterval),this.updateCountdown())}pollServer(e=!1){if(0!==this.getOperationsByStatus(["pending","processing","uploading"]).length||e){this.updateStatusPanel("pending");try{this.store.fetch()}catch(e){console.error("Polling error:",e)}finally{this.updateStatusPanel()}}else this.stopPolling()}async updateOperationsFromServer(e){let t=!1;const s=new Set;for(const t of e){let e=this.queue.has(t.id)?this.queue.get(t.id):{};s.add(t.id),t.status!==e.status&&(e={...e,...t},this.queue.set(e.id,e),this.updateOperationStatus(e.id,e.status))}const i=this.getOperationsByStatus(["pending","processing","uploading"]);for(const e of i)s.has(e.id)||(e.status="completed",e.completedAt=Date.now(),this.setQueue(e),t=!0,this.updateOperationStatus(e.id,e.status));0===this.getOperationsByStatus(["pending","processing","uploading"]).length&&this.stopPolling(),this.updateUI()}stopPolling(){this.isPolling&&(this.isPolling=!1,this.pollTimer&&(clearInterval(this.pollTimer),this.pollTimer=null),this.countdownTimer&&(clearInterval(this.countdownTimer),this.countdownTimer=null))}async updateServerOperations(e,t){if(0!==(e=(e=Array.isArray(e)?e:e.includes(",")?e.split(","):[e]).filter((e=>{let s=this.getQueue(e);return this.getAllowedActions(s.status).includes(t)}))).length){["cancel","dismiss"].includes(t)&&e.forEach((e=>{this.removeOperationFromUI(e)}));try{const s=`${this.config.apiBase}${this.config.endpoint}`,i=await fetch(s,{method:"POST",headers:{"Content-Type":"application/json",...this.headers},body:JSON.stringify({ids:e,action:t})});if(!i.ok){const e=await i.json().catch((()=>{}));throw new Error(e.message||`${t} failed: ${i.status}`)}const a=await i.json();if(!a.success)throw new Error(a.message||`${t} operation failed`);return["cancel","dismiss"].includes(t)?e.forEach((e=>{let s=this.getQueue(e);this.notify(`${t}-operation`,s),this.clearQueue(e)})):(e.forEach((e=>{let s=this.getQueue(e);this.notify(`${t}-operation`,s),s.status="queued",s.retries=0,this.setQueue(s),this.updateOperationStatus(s.id,s.status)})),this.startActivityTracking()),this.updateUI(),a}catch(s){const i=await window.jvbError.log(s,{component:"QueueManager",operation:"performQueueAction",action:t,operationIds:e,itemCount:e.length},(()=>this.updateServerOperations(e,t)));if(i.retried)return i;throw s}}}getAllowedActions(e){return{queued:["cancel"],localProcessing:["cancel"],pending:["cancel"],processing:[],completed:["dismiss"],failed:["retry","dismiss"],failed_permanent:["dismiss"]}[e]||[]}initListeners(){this.clickHandler=this.handleClick.bind(this),this.changeHandler=this.handleChange.bind(this),document.addEventListener("click",this.clickHandler),this.ui.panel?.addEventListener("change",this.changeHandler),this.handleOnline=()=>{this.updateStatusPanel(),this.hasQueuedOperations()&&this.processQueue()},this.handleOffline=()=>this.updateStatusPanel("offline"),this.handleBeforeUnload=e=>{if(this.getOperationsByStatus(["queued","uploading"]).length>0)return e.preventDefault(),"You have unsaved changes in the queue."},window.addEventListener("online",this.handleOnline),window.addEventListener("offline",this.handleOffline),window.addEventListener("beforeunload",this.handleBeforeUnload)}handleClick(e){if(e.target.closest(this.selectors.refreshButton))this.pollServer(!0);else if(e.target.closest(this.selectors.clearButton)){const e=this.getOperationsByStatus("completed");if(e.length>0){const t=e.map((e=>e.id));this.updateServerOperations(t,"dismiss")}}else if(e.target.closest(this.selectors.retryButton)){const e=this.getOperationsByStatus("failed");if(e.length>0){const t=e.map((e=>e.id));this.updateServerOperations(t,"retry")}}else if(e.target.closest("[data-action]")){const t=e.target.closest("[data-action]"),s=t.closest("[data-id]")?.dataset.id;s&&this.updateServerOperations(s,t.dataset.action)}else if(e.target.closest(".filters [data-filter]")){const t=e.target.closest("[data-filter]").dataset.filter;this.setFilter(t)}}handleChange(e){}initUI(){if(this.icons={queued:"refresh",localProcessing:"refresh",uploading:"syncing",pending:"cloud",processing:"syncing",completed:"synced",failed:"error",failed_permanent:"error"},this.selectors={panel:"aside#queue",toggle:"button.qtoggle",refreshButton:"button.refreshNow",countdown:".countdown",indicator:".qtoggle .indicator",count:".qtoggle .count",popup:".popup",itemsContainer:".qitems",clearButton:".dismiss-all",retryButton:".retry-all",filters:{all:'.filters [data-filter="all"]',received:'.filters [data-filter="queued"]',localProcessing:'.filters [data-filter="localProcessing"]',uploading:'.filters [data-filter="uploading"]',pending:'.filters [data-filter="pending"]',processing:'.filters [data-filter="processing"]',completed:'.filters [data-filter="completed"]',failed:'.filters [data-filter="failed"]'}},this.ui={panel:document.querySelector(this.selectors.panel),toggle:document.querySelector(this.selectors.toggle),count:document.querySelector(this.selectors.count),indicator:document.querySelector(this.selectors.indicator)},this.ui.panel){for(let[e,t]of Object.entries(this.selectors))if(!["panel","toggle","count","indicator"].includes(e))if("object"==typeof t){this.ui[e]={};for(let[s,i]of Object.entries(t))this.ui[e][s]=this.ui.panel.querySelector(i)}else this.ui[e]=this.ui.panel.querySelector(t)}else this.canUpdateUI=!1}updateUI(){if(!this.canUpdateUI)return;const e=this.getQueueStats();if(this.ui.count){const t=e.total-e.completed;this.ui.count.textContent=t>0?t:"",this.ui.count.style.display=t>0?"":"none"}if(this.ui.indicator){const t=e.queued>0||e.uploading>0||e.pending>0||e.processing>0;this.ui.indicator.classList.toggle("active",t)}let t=this.getOperationsByStatus("failed"),s=this.getOperationsByStatus("completed");this.ui.clearButton.disabled=0===s.length,this.ui.retryButton.disabled=0===t.length,Object.entries(this.ui.filters).forEach((([t,s])=>{const i="all"===t?e.total:e[t]||0,a=s.querySelector(".count");a&&(a.textContent=i>0?i:""),s.setAttribute("data-count",i)})),this.renderOperations()}getStatusLabel(e){return{queued:"Queued",localProcessing:"Processing locally",uploading:"Uploading",pending:"Waiting on server",processing:"Processing",completed:"Completed",failed:"Failed (will retry)",failed_permanent:"Failed permanently"}[e]||e}getItemMessage(e){if(e.message)return e.message;if(e.error_message)return e.error_message;switch(e.status){case"queued":return"Waiting to send...";case"uploading":return"Sending to server...";case"pending":return e.position?`Position ${e.position} in queue`:"In server queue";case"processing":return e.progress?`${e.progress}% complete`:"Processing...";case"completed":return"Successfully completed";case"failed":return`Failed: ${e.lastError||"Unknown error"} (Retry ${e.retries}/${this.config.maxRetries})`;case"failed_permanent":return`Failed: ${e.lastError||"Unknown error"}`;default:return""}}calculateProgress(e){if(e.progress)return e.progress;return{queued:10,uploading:25,pending:40,processing:70,completed:100,failed:0,failed_permanent:0}[e.status]||0}getQueueStats(){const e={};return this.statuses.forEach((t=>{e[t]=0})),Array.from(this.store.items.values()).forEach((t=>{e.hasOwnProperty(t.status)&&e[t.status]++})),e.total=Array.from(this.store.items.values()).length,e}renderOperations(){if(!this.ui.itemsContainer)return;const e=this.getActiveFilter(),t=this.getFilteredOperations(e);if(window.removeChildren(this.ui.itemsContainer),0===t.length){let e=window.getTemplate("emptyQueue");this.ui.itemsContainer.append(e),this.a11y.announce("Nothing queued.")}else{let e=this.ui.itemsContainer.querySelector(".emptyQueue");e&&e.remove(),t.forEach((e=>{const t=this.createOperationUI(e);this.ui.itemsContainer.appendChild(t)}))}}createOperationUI(e){const t=window.getTemplate("queueItem");return t.dataset.id=e.id,this.updateOperationUI(e,t),t}updateOperationUI(e,t=null){t||(t=this.ui.itemsContainer?.querySelector(`[data-id="${e.id}"]`)),t||(t=this.createOperationUI(e)),this.statuses.forEach((e=>t.classList.remove(e))),t.classList.add(e.status);let s="";e.updated_at?s=window.formatTimeAgo(new Date(e.updated_at)):e.created_at&&(s=window.formatTimeAgo(new Date(e.created_at)));const i=this.calculateProgress(e),a=t.querySelector(".type"),n=t.querySelector(".status"),r=t.querySelector(".info .details"),o=t.querySelector(".info .time"),u=t.querySelector(".progress .fill");if(a&&(a.textContent=e.title),n){n.querySelector(".icon")?.remove();let t=this.getStatusLabel(e.status);n.title=t,n.prepend(window.getIcon(this.icons[e.status])),n.querySelector("span").textContent=t}r&&(r.textContent=this.getItemMessage(e)),o&&(o.textContent=s),u&&(u.style.width=`${i}%`);const l=t.querySelector(".actions");l&&this.updateActionButtons(e,l)}updateActionButtons(e,t){switch(window.removeChildren(t),e.status){case"queued":case"localProcessing":case"pending":const s=window.getTemplate("button");s.classList.add("cancel"),s.dataset.action="cancel",s.textContent="Cancel",t.appendChild(s);break;case"failed":case"failed_permanent":const i=window.getTemplate("button"),a=window.getTemplate("button");i.classList.add("retry"),i.textContent="Retry",i.disabled=e.retries>=this.maxRetries,i.dataset.action="retry",a.classList.add("dismiss"),a.textContent="Dismiss",a.dataset.action="dismiss",t.appendChild(i),t.appendChild(a);break;case"completed":const n=window.getTemplate("button");n.dataset.action="dismiss",n.classList.add("dismiss"),n.textContent="Dismiss",t.appendChild(n)}}removeOperationFromUI(e){const t=this.ui.itemsContainer?.querySelector(`[data-id="${e}"]`);t&&(t.style.opacity="0",t.style.transform="scale(0.9)",setTimeout((()=>t.remove()),300))}updateCountdown(){if(!this.ui.countdown||!this.isPolling)return;let e=this.config.pollInterval/1e3;this.countdownTimer=setInterval((()=>{e--,this.ui.countdown.textContent=e,e<=0&&(clearInterval(this.countdownTimer),this.isPolling&&setTimeout((()=>this.updateCountdown()),100))}),1e3)}updateStatusPanel(e){this.ui.panel?.classList.remove(...this.classes),this.classes.includes(e)&&this.ui.panel?.classList.add(e)}setFilter(e){Object.values(this.ui.filters).forEach((t=>{t&&t.classList.toggle("active",t.dataset.filter===e)})),this.activeFilter=e,this.renderOperations()}getActiveFilter(){const e=this.ui.panel?.querySelector(".filter.active");return e?.dataset.filter||"all"}getFilteredOperations(e){const t=Array.from(this.store.items.values());return"all"===e?t:t.filter((t=>t.status===e))}showPopup(e,t="success"){if(!this.ui.popup)return;const s=this.ui.popup.querySelector("span");s&&(s.textContent=e),this.ui.popup.className=`popup ${t} show`,setTimeout((()=>{this.ui.popup.classList.remove("show")}),3e3)}getOperationsByStatus(e,t=!0){return e=Array.isArray(e)?e:e.includes(",")?e.split(","):[e],t?Array.from(this.queue.values()).filter((t=>e.includes(t.status))):Array.from(this.queue.values()).filter((t=>!e.includes(t.status)))}hasQueuedOperations(){return this.queue.some((e=>"queued"===e.status))}subscribe(e){return this.subscribers.add(e),()=>this.subscribers.delete(e)}notify(e,t){this.subscribers.forEach((s=>s(e,t)))}destroy(){this.stopPolling(),this.stopActivityTracking(),this.clickHandler&&document.removeEventListener("click",this.clickHandler),this.keyHandler&&document.removeEventListener("keydown",this.keyHandler),this.subscribers.clear()}}document.addEventListener("DOMContentLoaded",(function(){window.jvbQueue=new e}))})();
\ No newline at end of file
+(()=>{class t{constructor(t={}){this.canUpdateUI=!0,console.log("jvbSettings",jvbSettings),this.config={apiBase:jvbSettings.api,maxRetries:3,pollInterval:5e3,activityDelay:2e3,autosync:!0,endpoint:"queue",...t},this.user=jvbSettings.currentUser,console.log(this.user),this.headers={"X-WP-Nonce":jvbSettings.nonce,...t.headers},this.a11y=window.jvbA11y,this.errors=window.jvbError,this.store=new window.jvbStore({name:"queue",storeName:"operations",keyPath:"id",endpoint:this.config.endpoint,TTL:1/0,indexes:[{name:"status",keyPath:"status"},{name:"type",keyPath:"type"}],showLoading:!1,getBlobs:async t=>{if(window.jvbUploadBlobs){Array.isArray(t)||"string"!=typeof t||(t=[t]);return(await Promise.all(t.map((t=>window.jvbUploadBlobs.getBlob(t))))).filter(Boolean)}return null}}),this.classes=["offline","synced","pending"],this.isProcessing=!1,this.isPolling=!1,this.subscribers=new Set,this.statuses=["queued","localProcessing","uploading","pending","processing","completed","failed","failed_permanent"],this.initUI(),this.initListeners(),console.log(this.ui),this.ui.panel&&(this.popup=new window.jvbPopup({popup:this.ui.panel,toggle:this.ui.toggle,name:"Queue Panel"})),this.initQueue(),this.user&&(this.ui.toggle.hidden=!1,this.ui.panel.hidden=!1)}async initQueue(){const t=this.getOperationsByStatus(["completed","failed_permanent"],!1);t.length>0?this.startPolling():this.updateStatusPanel("synced"),this.store.subscribe(((t,e)=>{switch(t){case"data-loaded":this.getOperationsByStatus(["completed","failed_permanent"],!1).length>0&&this.startPolling(),this.updateUI();break;case"item-saved":this.hasQueuedOperations()&&this.startPolling();default:this.updateUI()}})),this.notify("queue-initialized",{operations:t})}addToQueue(t){const e={id:`u${this.user}_${Date.now()}_${Math.random().toString(36).substring(2,9)}`,endpoint:null,method:"POST",headers:{},data:{},canMerge:!0,popup:"Saving changes...",title:"Operation",status:"queued",timestamp:Date.now(),retries:0,user:this.user,...t};if(e.headers={...this.headers,...e.headers},!e.endpoint||!e.data)return console.error("Invalid operation queued: missing endpoint or data"),null;const s=Array.from(this.store.data.values()).filter((t=>"queued"===t.status&&t.endpoint===e.endpoint&&t.canMerge));if(s.length>0){const t=s[0];return t.data=window.deepMerge(t.data,e.data),t.timestamp=Date.now(),this.updateOperationStatus(t.id,t.status),this.updateUI(),this.startActivityTracking(),t.id}return console.log("Added to Queue: ",e),this.setQueue(e),this.updateOperationStatus(e.id,e.status),this.updateUI(),this.startActivityTracking(),e.id}setQueue(t){this.store.save(t)}updateOperationStatus(t,e){let s=this.store.get(t);s&&(s.status=e,this.notify("operation-status",s),this.updateOperationUI(s))}getQueue(t){return this.store.get(t)}clearQueue(t){this.store.delete(t)}startActivityTracking(){if(!this.activityListeners){const t=["mousedown","mousemove","keypress","scroll","touchstart"];this.activityListeners=t.map((t=>{const e=()=>this.resetActivityTimer();return document.addEventListener(t,e,{passive:!0}),{event:t,handler:e}}))}this.resetActivityTimer()}resetActivityTimer(){this.lastActivity=Date.now(),this.activityTimer&&clearTimeout(this.activityTimer),this.activityTimer=setTimeout((()=>{this.processQueue()}),this.config.activityDelay)}stopActivityTracking(){this.activityTimer&&(clearTimeout(this.activityTimer),this.activityTimer=null),this.activityListeners&&(this.activityListeners.forEach((({event:t,handler:e})=>{document.removeEventListener(t,e)})),this.activityListeners=null)}setProcessing(t){this.isProcessing=t,this.ui.toggle.classList.toggle("saving",t)}async processQueue(){if(this.isProcessing)return;const t=this.getOperationsByStatus("queued");if(0===t.length)return void this.stopActivityTracking();this.setProcessing(!0);for(const e of t)await this.processOperation(e);this.setProcessing(!1),this.stopActivityTracking();this.getOperationsByStatus(["queued","completed","failed_permanent"],!1).length>0&&this.startPolling()}async processOperation(t){try{this.updateOperationStatus(t.id,"uploading"),t=this.getQueue(t.id);const i=`${this.config.apiBase}${t.endpoint}`;let a;if(console.log(t.data),t.data instanceof FormData)for(var[e,s]of(t.data.append("id",t.id),t.data.append("user",this.user),a=t.data,console.log("Sending to server:"),a.entries()))console.log(e,s);else a=JSON.stringify({...t.data,id:t.id,user:this.user}),t.headers["Content-Type"]="application/json";const n=await fetch(i,{method:t.method,headers:t.headers,body:a}),r=await n.json();if(!n.ok||!1===r.success)throw new Error(r.message||`HTTP ${n.status}`);if(r.id&&t.id!==r.id){const e=this.getQueue(r.id);e?(e.data=window.deepMerge(e.data,t.data),e.status="pending",e.serverData=r,this.updateOperationStatus(e.id,e.status),this.setQueue(e),this.removeOperationFromUI(t.id),t=e):(this.clearQueue(t.id),t.id=r.id,t.status="pending",t.serverData=r,this.updateOperationStatus(t.id,t.status),this.setQueue(t))}else t.status="pending",t.serverData=r,this.updateOperationStatus(t.id,"pending"),this.setQueue(t);this.a11y.announce(`${t.title} sent to server for processing.`)}catch(e){console.error("Operation failed:",e),t.retries++,t.lastError=e.message,t.retries>=this.config.maxRetries?t.status="failed_permanent":(t.status="failed",t.nextRetry=Date.now()+1e3*Math.pow(2,t.retries)),this.updateOperationStatus(t.id,t.status),this.setQueue(t)}}startPolling(){this.isPolling||(this.isPolling=!0,this.updateStatusPanel("pending"),this.pollTimer=setInterval((async()=>{try{await this.store.fetch();0===this.getOperationsByStatus(["completed","failed_permanent"],!1).length&&(this.stopPolling(),this.updateStatusPanel("synced"))}catch(t){console.error("Polling error:",t)}}),this.config.pollInterval))}stopPolling(){this.isPolling&&(this.isPolling=!1,this.pollTimer&&(clearInterval(this.pollTimer),this.pollTimer=null),this.countdownTimer&&(clearInterval(this.countdownTimer),this.countdownTimer=null))}async updateServerOperations(t,e){if(0!==(t=(t=Array.isArray(t)?t:t.includes(",")?t.split(","):[t]).filter((t=>{let s=this.getQueue(t);return this.getAllowedActions(s.status).includes(e)}))).length){["cancel","dismiss"].includes(e)&&t.forEach((t=>{this.removeOperationFromUI(t)}));try{const s=`${this.config.apiBase}${this.config.endpoint}`,i=await fetch(s,{method:"POST",headers:{"Content-Type":"application/json",...this.headers},body:JSON.stringify({ids:t,action:e})});if(!i.ok){const t=await i.json().catch((()=>{}));throw new Error(t.message||`${e} failed: ${i.status}`)}const a=await i.json();if(!a.success)throw new Error(a.message||`${e} operation failed`);return["cancel","dismiss"].includes(e)?t.forEach((t=>{let s=this.getQueue(t);this.notify(`${e}-operation`,s),this.clearQueue(t)})):(t.forEach((t=>{let s=this.getQueue(t);this.notify(`${e}-operation`,s),s.status="queued",s.retries=0,this.setQueue(s),this.updateOperationStatus(s.id,s.status)})),this.startActivityTracking()),this.updateUI(),a}catch(s){const i=await window.jvbError.log(s,{component:"QueueManager",operation:"performQueueAction",action:e,operationIds:t,itemCount:t.length},(()=>this.updateServerOperations(t,e)));if(i.retried)return i;throw s}}}getAllowedActions(t){return{queued:["cancel"],localProcessing:["cancel"],pending:["cancel"],processing:[],completed:["dismiss"],failed:["retry","dismiss"],failed_permanent:["dismiss"]}[t]||[]}initListeners(){this.clickHandler=this.handleClick.bind(this),this.changeHandler=this.handleChange.bind(this),document.addEventListener("click",this.clickHandler),this.ui.panel?.addEventListener("change",this.changeHandler),this.handleOnline=()=>{this.updateStatusPanel(),this.hasQueuedOperations()&&this.processQueue()},this.handleOffline=()=>this.updateStatusPanel("offline"),this.handleBeforeUnload=t=>{if(this.getOperationsByStatus(["queued","uploading"]).length>0)return t.preventDefault(),"You have unsaved changes in the queue."},window.addEventListener("online",this.handleOnline),window.addEventListener("offline",this.handleOffline),window.addEventListener("beforeunload",this.handleBeforeUnload)}handleClick(t){if(t.target.closest(this.selectors.panel,this.selectors.toggle))if(t.target.closest(this.selectors.refreshButton))this.store.fetch();else if(t.target.closest(this.selectors.clearButton)){const t=this.getOperationsByStatus("completed");if(t.length>0){const e=t.map((t=>t.id));this.updateServerOperations(e,"dismiss")}}else if(t.target.closest(this.selectors.retryButton)){const t=this.getOperationsByStatus("failed");if(t.length>0){const e=t.map((t=>t.id));this.updateServerOperations(e,"retry")}}else if(t.target.closest("[data-action]")){const e=t.target.closest("[data-action]"),s=e.closest("[data-id]")?.dataset.id;s&&this.updateServerOperations(s,e.dataset.action)}else if(t.target.closest(".filters [data-filter]")){const e=t.target.closest("[data-filter]").dataset.filter;this.setFilter(e)}}handleChange(t){}initUI(){if(this.icons={queued:"refresh",localProcessing:"refresh",uploading:"syncing",pending:"cloud",processing:"syncing",completed:"synced",failed:"error",failed_permanent:"error"},this.selectors={panel:"aside#queue",toggle:"button.qtoggle",refreshButton:"button.refreshNow",countdown:".countdown",indicator:".qtoggle .indicator",count:".qtoggle .count",popup:".popup",itemsContainer:".qitems",clearButton:".dismiss-all",retryButton:".retry-all",filters:{all:'.filters [data-filter="all"]',received:'.filters [data-filter="queued"]',localProcessing:'.filters [data-filter="localProcessing"]',uploading:'.filters [data-filter="uploading"]',pending:'.filters [data-filter="pending"]',processing:'.filters [data-filter="processing"]',completed:'.filters [data-filter="completed"]',failed:'.filters [data-filter="failed"]'}},this.ui={panel:document.querySelector(this.selectors.panel),toggle:document.querySelector(this.selectors.toggle),count:document.querySelector(this.selectors.count),indicator:document.querySelector(this.selectors.indicator)},this.ui.panel){for(let[t,e]of Object.entries(this.selectors))if(!["panel","toggle","count","indicator"].includes(t))if("object"==typeof e){this.ui[t]={};for(let[s,i]of Object.entries(e))this.ui[t][s]=this.ui.panel.querySelector(i)}else this.ui[t]=this.ui.panel.querySelector(e)}else this.canUpdateUI=!1}updateUI(){if(!this.canUpdateUI)return;const t=this.getQueueStats();if(this.ui.count){const e=t.total-t.completed;this.ui.count.textContent=e>0?e:"",this.ui.count.style.display=e>0?"":"none"}if(this.ui.indicator){const e=t.queued>0||t.uploading>0||t.pending>0||t.processing>0;this.ui.indicator.classList.toggle("active",e)}let e=this.getOperationsByStatus("failed"),s=this.getOperationsByStatus("completed");this.ui.clearButton.disabled=0===s.length,this.ui.retryButton.disabled=0===e.length,Object.entries(this.ui.filters).forEach((([e,s])=>{const i="all"===e?t.total:t[e]||0,a=s.querySelector(".count");a&&(a.textContent=i>0?i:""),s.setAttribute("data-count",i)})),this.renderOperations()}getStatusLabel(t){return{queued:"Queued",localProcessing:"Processing locally",uploading:"Uploading",pending:"Waiting on server",processing:"Processing",completed:"Completed",failed:"Failed (will retry)",failed_permanent:"Failed permanently"}[t]||t}getItemMessage(t){if(t.message)return t.message;if(t.error_message)return t.error_message;switch(t.status){case"queued":return"Waiting to send...";case"uploading":return"Sending to server...";case"pending":return t.position?`Position ${t.position} in queue`:"In server queue";case"processing":return t.progress?`${t.progress}% complete`:"Processing...";case"completed":return"Successfully completed";case"failed":return`Failed: ${t.lastError||"Unknown error"} (Retry ${t.retries}/${this.config.maxRetries})`;case"failed_permanent":return`Failed: ${t.lastError||"Unknown error"}`;default:return""}}calculateProgress(t){if(t.progress)return t.progress;return{queued:10,uploading:25,pending:40,processing:70,completed:100,failed:0,failed_permanent:0}[t.status]||0}getQueueStats(){const t={};return this.statuses.forEach((e=>{t[e]=0})),Array.from(this.store.data.values()).forEach((e=>{t.hasOwnProperty(e.status)&&t[e.status]++})),t.total=Array.from(this.store.data.values()).length,t}renderOperations(){if(!this.ui.itemsContainer)return;const t=this.getActiveFilter(),e=this.getFilteredOperations(t);if(window.removeChildren(this.ui.itemsContainer),0===e.length){let t=window.getTemplate("emptyQueue");this.ui.itemsContainer.append(t),this.a11y.announce("Nothing queued.")}else{let t=this.ui.itemsContainer.querySelector(".emptyQueue");t&&t.remove(),e.forEach((t=>{const e=this.createOperationUI(t);this.ui.itemsContainer.appendChild(e)}))}}createOperationUI(t){const e=window.getTemplate("queueItem");return e.dataset.id=t.id,this.updateOperationUI(t,e),e}updateOperationUI(t,e=null){e||(e=this.ui.itemsContainer?.querySelector(`[data-id="${t.id}"]`)),e||(e=this.createOperationUI(t)),this.statuses.forEach((t=>e.classList.remove(t))),e.classList.add(t.status);let s="";t.updated_at?s=window.formatTimeAgo(new Date(t.updated_at)):t.created_at&&(s=window.formatTimeAgo(new Date(t.created_at)));const i=this.calculateProgress(t),a=e.querySelector(".type"),n=e.querySelector(".status"),r=e.querySelector(".info .details"),o=e.querySelector(".info .time"),l=e.querySelector(".progress .fill");if(a&&(a.textContent=t.title),n){n.querySelector(".icon")?.remove();let e=this.getStatusLabel(t.status);n.title=e,n.prepend(window.getIcon(this.icons[t.status])),n.querySelector("span").textContent=e}r&&(r.textContent=this.getItemMessage(t)),o&&(o.textContent=s),l&&(l.style.width=`${i}%`);const u=e.querySelector(".actions");u&&this.updateActionButtons(t,u)}updateActionButtons(t,e){switch(window.removeChildren(e),t.status){case"queued":case"localProcessing":case"pending":const s=window.getTemplate("button");s.classList.add("cancel"),s.dataset.action="cancel",s.textContent="Cancel",e.appendChild(s);break;case"failed":case"failed_permanent":const i=window.getTemplate("button"),a=window.getTemplate("button");i.classList.add("retry"),i.textContent="Retry",i.disabled=t.retries>=this.maxRetries,i.dataset.action="retry",a.classList.add("dismiss"),a.textContent="Dismiss",a.dataset.action="dismiss",e.appendChild(i),e.appendChild(a);break;case"completed":const n=window.getTemplate("button");n.dataset.action="dismiss",n.classList.add("dismiss"),n.textContent="Dismiss",e.appendChild(n)}}removeOperationFromUI(t){const e=this.ui.itemsContainer?.querySelector(`[data-id="${t}"]`);e&&(e.style.opacity="0",e.style.transform="scale(0.9)",setTimeout((()=>e.remove()),300))}updateCountdown(){if(!this.ui.countdown||!this.isPolling)return;let t=this.config.pollInterval/1e3;this.countdownTimer=setInterval((()=>{t--,this.ui.countdown.textContent=t,t<=0&&(clearInterval(this.countdownTimer),this.isPolling&&setTimeout((()=>this.updateCountdown()),100))}),1e3)}updateStatusPanel(t){this.ui.panel?.classList.remove(...this.classes),this.classes.includes(t)&&this.ui.panel?.classList.add(t)}setFilter(t){Object.values(this.ui.filters).forEach((e=>{e&&e.classList.toggle("active",e.dataset.filter===t)})),this.activeFilter=t,this.renderOperations()}getActiveFilter(){const t=this.ui.panel?.querySelector(".filter.active");return t?.dataset.filter||"all"}getFilteredOperations(t){const e=Array.from(this.store.data.values());return"all"===t?e:e.filter((e=>e.status===t))}showPopup(t,e="success"){if(!this.ui.popup)return;const s=this.ui.popup.querySelector("span");s&&(s.textContent=t),this.ui.popup.className=`popup ${e} show`,setTimeout((()=>{this.ui.popup.classList.remove("show")}),3e3)}getOperationsByStatus(t,e=!0){return Array.isArray(t)||"string"!=typeof t||(t=[t]),e?Array.from(this.store.data.values()).filter((e=>t.includes(e.status))):Array.from(this.store.data.values()).filter((e=>!t.includes(e.status)))}async hasQueuedOperations(){return(await this.store.query("status","queued")).length>0}subscribe(t){return this.subscribers.add(t),()=>this.subscribers.delete(t)}notify(t,e){this.subscribers.forEach((s=>s(t,e)))}destroy(){this.stopPolling(),this.stopActivityTracking(),this.clickHandler&&document.removeEventListener("click",this.clickHandler),this.keyHandler&&document.removeEventListener("keydown",this.keyHandler),this.subscribers.clear()}}document.addEventListener("DOMContentLoaded",(function(){window.jvbQueue=new t}))})();
\ No newline at end of file
diff --git a/assets/js/min/selector.min.js b/assets/js/min/selector.min.js
index 991ae3a..e1e2b59 100644
--- a/assets/js/min/selector.min.js
+++ b/assets/js/min/selector.min.js
@@ -1 +1 @@
-(()=>{class e{constructor(){this.a11y=window.jvbA11y,this.error=window.jvbError,this.index=-1,this.store=new window.jvbStore({name:"taxonomies",storeName:"terms",keyPath:"id",indexes:[{name:"taxonomy",keyPath:"taxonomy"},{name:"parent",keyPath:"parent"},{name:"slug",keyPath:"slug",unique:!0},{name:"count",keyPath:"count"}],endpoint:"terms",TTL:72e5,filters:{taxonomy:"",page:1,search:"",parent:0}}),this.fields=new Map,this.selectedTerms=new Map,this.activeField=null,this.currentConfig=null,this.currentSingular=null,this.currentPlural=null,this.activeStore=null,this.disabled=!1,this.searchHandler=null,this.init()}init(){this.initModal(),this.scanExistingFields(),this.initGlobalListeners(),this.store.subscribe(this.handleStoreEvent.bind(this))}handleStoreEvent(e,t,i){if(this.activeStore&&this.activeStore.config.name===`tax_${e}`)switch(t){case"items-loaded":case"data-fetched":case"data-cached":case"stale-cache-used":this.handleTermsLoaded(i);break;case"fetch-error":this.handleFetchError(i.error)}"items-updated"!==t&&"items-loaded"!==t||this.updateFieldsForTaxonomy(e,i.items)}handleTermsLoaded(e){this.hideLoading();const t=e.data?.items||[],i=e.data?.pagination||{},s=e.filters?.search&&e.filters.search.length>0,r=e.filters?.page>1;0===t.length?(r||this.showEmptyState(s?"No results found.":"No items available."),this.observer.unobserve(this.ui.sentinel)):(this.renderTerms(t,r,s),this.currentTerms=t,i.has_more?this.observer.observe(this.ui.sentinel):this.observer.unobserve(this.ui.sentinel)),this.a11y?.announce(t.length,r)}handleFetchError(e){console.error("Taxonomy fetch error:",e),this.hideLoading(),this.error?.log?this.error.log(e,{component:"TaxonomySelector",action:"fetchTerms"},(()=>this.fetchCurrentTerms())):this.showEmptyState("Error loading terms. Please try again.")}updateFieldsForTaxonomy(e,t){this.fields.forEach((i=>{i.taxonomy===e&&i.selectedTerms.size>0&&i.selectedTerms.forEach((e=>{const s=t.find((t=>t.id===e));if(s){const t=i.selectedContainer.querySelector(`[data-id="${e}"]`);t&&(t.dataset.path=s.path,t.querySelector("span").textContent=s.path)}}))}))}scanExistingFields(){document.querySelectorAll(".field.taxonomy, .field.post").forEach((e=>{try{this.registerField(e)}catch(t){this.error.log(t,{component:"TaxonomySelector",action:"scanExistingFields",container:e.dataset.name})}}))}registerField(e,t={}){let i=e.querySelector("input[type=hidden]");if(!i)return;"fieldId"in e.dataset||(e.dataset.fieldId=this.createFieldId(e));let s=e.dataset.fieldId,r=e.querySelector("button.taxonomy-toggle"),a={id:s,input:i,container:e,taxonomy:r.dataset.taxonomy,name:e.dataset.field,maxSelection:parseInt(r.dataset.max)||0,canSearch:"search"in r.dataset,canCreate:"creatable"in r.dataset,isRequired:"required"in r.dataset,selectedTerms:new Set,toggle:r,selectedContainer:e.querySelector(".selected-items"),...t};const n=i.value.trim();if(""!==n){n.split(",").map((e=>parseInt(e.trim()))).filter((e=>!isNaN(e))).forEach((e=>a.selectedTerms.add(e)))}return this.fields.set(s,a),this.store.setFilter("taxonomy",a.taxonomy),a.selectedTerms.size>0&&this.initFieldDisplay(s),s}createFieldId(e){return this.index++,"selector-"+this.index}async initFieldDisplay(e){const t=this.fields.get(e);if(!t||0===t.selectedTerms.size)return;const i=Array.from(t.selectedTerms),s=[],r=[];if(i.forEach((e=>{const t=this.store.getItem(e);t?s.push(t):r.push(e)})),s.forEach((t=>{this.addTermToDisplay(e,t.id,t.name,t.path)})),r.length>0)try{const i=await this.store.fetch({filters:{taxonomy:t.taxonomy,termIDs:r.join(",")}});i.terms&&i.terms.forEach((t=>{this.store.setItem(t.id,t),this.addTermToDisplay(e,t.id,t.name,t.path)}))}catch(e){console.error("Failed to fetch missing terms:",e)}}initModal(){this.modalID="dialog#jvb-selector",this.modal=document.querySelector(this.modalID),this.modal?(this.initModalElements(),this.modalInstance=new window.jvbModal(this.modal,{handleForm:!1,save:null,open:null}),this.modalInstance.subscribe(((e,t)=>{switch(e){case"modal-open":console.log(t),this.openModal(t);break;case"modal-close":this.closeModal(t)}}))):console.warn("Taxonomy selector modal not found")}initModalElements(){this.selectors={search:{input:"[type=search]",clear:".clear-search",container:".search-wrapper"},termsList:".items-container",termsWrap:".items-wrap",breadcrumbs:{nav:"nav.term-navigation",back:".back-to-parent"},loading:{loading:".loading",text:".loading span"},selectedTerms:".selected-items",sentinel:".scroll-sentinel",modal:{title:"#modal-title",content:".modal-content"},create:{details:".create-new-term",parent:"#select_parent",summary:".create-new-term summary",name:"#term_name",button:".submit-term",label:{name:"[for=term_name]",parent:"[for=select_parent]"}},favouriteTerms:".favourite-terms"},this.ui=window.uiFromSelectors(this.selectors),this.observer=new IntersectionObserver((e=>{e.forEach((e=>{e.isIntersecting&&this.activeStore&&this.loadMoreTerms()}))}),{root:this.ui.termsWrap,threshold:.5})}initGlobalListeners(){document.addEventListener("click",this.handleClick.bind(this)),document.addEventListener("change",this.handleChange.bind(this))}handleClick(e){const t=window.targetCheck(e,".taxonomy-toggle");if(t)return e.preventDefault(),void this.handleToggleClick(t);const i=window.targetCheck(e,"button.remove-item");if(i&&e.target.closest(".jvb-selector")){const e=this.getFieldId(i),t=i.closest(".selected-item").dataset.id;this.removeSelectedTerm(e,t)}else e.target.matches(".modal-close")?this.modalInstance&&this.modalInstance.handleClose():this.modal&&this.modal.contains(e.target)&&this.handleModalClick(e)}handleChange(e){if(window.targetCheck(e,".taxonomy.field, .post.field")&&"hidden"===e.target.type){const t=this.getFieldId(e.target);this.updateFieldFromInput(t)}else this.modal&&this.modal.contains(e.target)&&this.handleModalChange(e)}handleToggleClick(e){try{const t=this.getFieldId(e);if(!this.fields.get(t))return void console.error("Field not found for toggle:",t);this.setActiveField(t),this.modalInstance.handleOpen()}catch(e){console.error("Error handling toggle click:",e),this.error?.handleError(e,{component:"TaxonomySelector",action:"handleToggleClick"})}}setActiveField(e){if(this.activeField=e,this.currentConfig=this.fields.get(e),console.log("Current Taxonomy:",this.currentConfig.taxonomy),console.log("Labels: ",jvbSettings.labels[this.currentConfig.taxonomy]),this.currentSingular=jvbSettings.labels[this.currentConfig.taxonomy].single,this.currentPlural=jvbSettings.labels[this.currentConfig.taxonomy].plural,this.store.setFilter("taxonomy",this.currentConfig.taxonomy),this.selectedTerms.clear(),this.currentConfig.selectedTerms){let e=[];if(this.currentConfig.selectedTerms.forEach((t=>{const i=this.store.getItem(t);i?this.selectedTerms.set(t,{id:t,name:i.name,path:i.path}):e.push(t)})),e.length>0){this.fetchSpecificTerms(e).forEach((e=>{this.selectedTerms.set(e.id,{id:e.id,name:e.name,path:e.path})}))}}}fetchSpecificTerms(e){return[]}handleModalClick(e){if(window.targetCheck(e,".remove-item")){let t=window.targetCheck(e,".selected-item");t&&this.removeSelectedTermFromModal(t.dataset.id)}else if(window.targetCheck(e,".back-to-parent"))this.navigateToParent();else if(window.targetCheck(e,".toggle-children")){let t=e.target.closest("li");this.navigateToChild(parseInt(t.dataset.id),t.querySelector(".term-name").textContent)}else if(window.targetCheck(e,".path-level")){let t=window.targetCheck(e,".path-level");this.navigateToPath(t)}}handleModalChange(e){if(window.targetCheck(e,this.modalID)&&"checkbox"===e.target.type){e.preventDefault(),e.stopPropagation();const t=parseInt(e.target.closest("li").dataset.id),i=e.target.closest("li").querySelector("label");e.target.checked?this.addSelectedTermToModal(t,i.title,i.dataset.path):this.removeSelectedTermFromModal(t)}}openForFilter(e,t,i=[]){const s=`filter-${e}-${Date.now()}`;this.fields.set(s,{id:s,input:null,container:null,taxonomy:e,name:`filter_${e}`,maxSelection:0,canSearch:!0,canCreate:!1,isRequired:!1,selectedTerms:new Set(i),toggle:null,selectedContainer:null,isFilterMode:!0,filterCallback:t}),this.setActiveField(s),this.modalInstance.handleOpen()}openModal(){this.activeField&&this.currentConfig?(this.resetModalState(),this.updateModalForTaxonomy(),this.activeStore.clearFilters(),this.currentConfig.canSearch&&(this.ui.search.input.focus(),this.searchHandler=window.debounce((()=>this.handleSearch()),300),this.ui.search.input.addEventListener("input",this.searchHandler)),this.currentConfig.canCreate&&"jvbTaxCreator"in window&&(this.creator=new window.jvbTaxCreator(this)),this.updateModalSelections(),this.observer.observe(this.ui.sentinel),this.fetchCurrentTerms()):console.error("No active field set for modal")}closeModal(){if(this.observer.unobserve(this.ui.sentinel),window.removeChildren(this.ui.termsList),this.currentConfig?.isFilterMode){if(this.currentConfig.filterCallback){const e=Array.from(this.selectedTerms.keys());this.currentConfig.filterCallback(e,this.currentConfig.taxonomy)}this.fields.delete(this.activeField)}else this.activeField&&this.saveSelectionsToField(this.activeField);this.currentConfig?.canSearch&&this.searchHandler&&this.ui.search.input.removeEventListener("input",this.searchHandler),this.creator&&delete this.creator,this.activeStore=null,this.activeField=null,this.currentConfig=null}resetModalState(){this.disabled=!1,window.removeChildren(this.ui.termsList),window.removeChildren(this.ui.selectedTerms),this.ui.search.input.value="",window.removeChildren(this.ui.breadcrumbs.nav),this.ui.breadcrumbs.nav.appendChild(this.ui.breadcrumbs.back),this.ui.breadcrumbs.back.hidden=!0}updateModalForTaxonomy(){if(!this.currentConfig)return;this.ui.modal.title.textContent=`Select ${this.currentPlural}`,this.ui.search.container&&(this.ui.search.container.style.display=this.currentConfig.canSearch?"block":"none"),this.ui.create.details&&(this.ui.create.details.style.display=this.currentConfig.canCreate?"block":"none",this.ui.create.details.hidden=!this.currentConfig.canCreate,this.ui.create.summary&&(this.ui.create.summary.textContent=`Add new ${this.currentSingular}`),this.ui.create.label.name&&(this.ui.create.label.name.textContent=`Name this ${this.currentSingular}`),this.ui.create.label.parent&&(this.ui.create.label.parent.textContent="Nest it under"),this.ui.create.parent);const e=`Opened ${this.currentSingular} selection. Choose from checkboxes or search to filter results.`;this.a11y?.announce(e)}updateModalSelections(){window.removeChildren(this.ui.selectedTerms),this.selectedTerms.forEach(((e,t)=>{this.addTermToModalDisplay(t,e.name,e.path)})),this.checkSelectionLimits()}addSelectedTermToModal(e,t,i){this.selectedTerms.set(e,{id:e,name:t,path:i}),this.addTermToModalDisplay(e,t,i),this.checkSelectionLimits();const s=this.ui.termsList.querySelector(`input[value="${e}"]`);s&&(s.checked=!0)}removeSelectedTermFromModal(e){this.selectedTerms.delete(parseInt(e));const t=this.ui.selectedTerms.querySelector(`[data-id="${e}"]`);t&&t.remove();const i=this.ui.termsList.querySelector(`input[value="${e}"]`);i&&(i.checked=!1),this.checkSelectionLimits()}addTermToModalDisplay(e,t,i){const s=window.getTemplate("selectedTerm").cloneNode(!0);s.dataset.id=e,s.dataset.path=i,s.dataset.name=t,s.dataset.taxonomy=this.currentConfig.taxonomy,s.querySelector("span").textContent=i,s.querySelector("button").title=`Remove ${t}`,this.ui.selectedTerms.appendChild(s)}checkSelectionLimits(){this.currentConfig&&0!==this.currentConfig.maxSelection&&(this.disabled=this.selectedTerms.size>=this.currentConfig.maxSelection,this.setCheckboxes(this.disabled))}setCheckboxes(e){this.ui.termsList.querySelectorAll('input[type="checkbox"]').forEach((t=>{t.checked||(t.disabled=e)}))}saveSelectionsToField(e){const t=this.fields.get(e);if(!t)return;t.selectedTerms.clear(),window.removeChildren(t.selectedContainer),this.selectedTerms.forEach(((i,s)=>{t.selectedTerms.add(s),this.addTermToDisplay(e,s,i.name,i.path)}));const i=Array.from(t.selectedTerms);t.input.value=i.join(","),t.input.dispatchEvent(new Event("change",{bubbles:!0}))}removeSelectedTerm(e,t){const i=this.fields.get(e);if(!i)return;const s=parseInt(t);i.selectedTerms.delete(s);const r=i.selectedContainer.querySelector(`[data-id="${s}"]`);r&&r.remove();const a=Array.from(i.selectedTerms);i.input.value=a.join(","),i.input.dispatchEvent(new Event("change",{bubbles:!0}))}addTermToDisplay(e,t,i,s){const r=this.fields.get(e);if(!r||r.selectedContainer.querySelector(`[data-id="${t}"]`))return;const a=window.getTemplate("selectedTerm").cloneNode(!0);a.dataset.id=t,a.dataset.path=s,a.dataset.name=i,a.dataset.taxonomy=r.taxonomy,a.querySelector("span").textContent=s,a.querySelector("button").title=`Remove ${i}`,r.selectedContainer.appendChild(a)}updateFieldFromInput(e){const t=this.fields.get(e);if(!t)return;const i=t.input.value.trim();if(t.selectedTerms.clear(),window.removeChildren(t.selectedContainer),""!==i){i.split(",").map((e=>parseInt(e.trim()))).filter((e=>!isNaN(e))).forEach((e=>t.selectedTerms.add(e))),this.initFieldDisplay(e)}}handleSearch(){const e=this.ui.searchInput.value.trim();e.length>=2||0===e.length?(this.activeStore.setFilter("page",1),this.activeStore.setFilter("search",e),window.removeChildren(this.ui.termsList),(e.length>=2||0===e.length)&&(this.showLoading(),this.fetchCurrentTerms())):(this.hideLoading(),this.showEmptyState("Enter at least 2 characters to search."))}navigateToParent(){this.activeStore.filters.parent;this.activeStore.setFilter("parent",0),this.activeStore.setFilter("page",1),window.removeChildren(this.ui.termsList),this.showLoading(),this.fetchCurrentTerms(),this.ui.breadcrumbs.back.hidden=!0}navigateToChild(e,t){this.activeStore.setFilter("parent",e),this.activeStore.setFilter("page",1),window.removeChildren(this.ui.termsList),this.showLoading(),this.fetchCurrentTerms(),this.updateBreadcrumbs(e,t),this.ui.breadcrumbs.back.hidden=!1}navigateToPath(e){const t=parseInt(e.dataset.id)||0;this.activeStore.setFilter("parent",t),this.activeStore.setFilter("page",1),window.removeChildren(this.ui.termsList),this.showLoading(),this.fetchCurrentTerms(),this.ui.breadcrumbs.back.hidden=0===t}fetchCurrentTerms(){this.activeStore&&(this.showLoading(),this.activeStore.fetch())}loadMoreTerms(){if(!this.activeStore)return;const e=this.activeStore.filters.page||1;this.activeStore.setFilter("page",e+1)}renderTerms(e,t=!1,i=!1){if(t||window.removeChildren(this.ui.termsList),0===e.length)return void(t||this.showEmptyState());const s=this.activeStore.filters.parent||0;this.ui.breadcrumbs.back.hidden=0===s,e.forEach((e=>{const t=this.activeStore.getDOMElement(e.id,"list-item");if(t){const i=t.querySelector('input[type="checkbox"]');i&&(i.checked=this.selectedTerms.has(e.id),i.disabled=!i.checked&&this.disabled),this.ui.termsList.appendChild(t)}else{const t=this.createTermElement({id:parseInt(e.id),name:e.name,hasChildren:e.hasChildren,path:e.path||null,show:i});t&&(this.activeStore.storeDOMElement(e.id,"list-item",t),this.ui.termsList.appendChild(t))}}))}createTermElement(e){if(!e||!e.name)return null;const t=window.getTemplate("termListItem").cloneNode(!0);t.dataset.id=e.id;const i=this.selectedTerms.has(e.id),s=t.querySelector("input"),r=t.querySelector("label"),a=t.querySelector("span, .term-name");if(s&&r&&a&&(s.id=`${this.currentConfig.container.id}${e.id}`,s.name=`${this.currentConfig.container.id}${this.currentConfig.taxonomy}-select`,s.value=e.id,s.disabled=!i&&this.disabled,s.checked=i,r.htmlFor=s.id,r.title=e.path||e.name,r.dataset.path=e.path,a.textContent=e.show?e.path:e.name),e.hasChildren){const i=window.getTemplate?window.getTemplate("termChildrenToggle"):this.createChildrenToggle();i&&(i.ariaLabel=`View sub-terms of ${e.name}`,t.appendChild(i))}return t}createChildrenToggle(){const e=document.createElement("button");return e.type="button",e.className="toggle-children",e.innerHTML="→",e}updateBreadcrumbs(e,t){const i=window.getTemplate("termBreadcrumb").cloneNode(!0);i.dataset.id=e,i.textContent=t,i.title=t;const s=this.ui.breadcrumbs.nav.querySelector(`[data-id="${e}"]`);if(s)for(;s.nextElementSibling;)s.nextElementSibling.remove();else this.ui.breadcrumbs.nav.appendChild(i)}showLoading(){this.ui.loading.loading.hidden=!1,this.modal.classList.add("loading");const e=this.activeStore?.filters?.search||"",t=this.activeStore?.filters?.parent||0;let i=""!==e?`searching for "${e}" items`:0===t?"loading items":"loading child items";window.typeLoop?this.stopTyping=window.typeLoop(this.ui.loading.text,i):this.ui.loading.text.textContent=i}hideLoading(){this.ui.loading.loading.hidden=!0,this.modal.classList.remove("loading"),this.stopTyping&&this.stopTyping()}showEmptyState(e="No items found."){const t=window.getTemplate("noResults").cloneNode(!0);e&&t.querySelector("span")&&(t.querySelector("span").textContent=e),this.ui.termsList.appendChild(t)}getFieldId(e){if(e.dataset.fieldId)return e.dataset.fieldId;const t=e.closest("[data-field-id]");return t?t.dataset.fieldId:null}destroy(){document.removeEventListener("click",this.handleClick),document.removeEventListener("change",this.handleChange),this.observer?.disconnect(),this.store.destroy(),this.fields.clear(),this.selectedTerms.clear()}}document.addEventListener("DOMContentLoaded",(function(){window.jvbSelector||(window.jvbSelector=new e)}))})();
\ No newline at end of file
+(()=>{class e{constructor(){this.a11y=window.jvbA11y,this.error=window.jvbError,this.index=-1,this.hasAutocomplete=!1,this.isInitializing=!0,this.taxonomiesToFetch=new Set,this.store=new window.jvbStore({name:"taxonomies",storeName:"terms",keyPath:"id",showLoading:!1,indexes:[{name:"taxonomy",keyPath:"taxonomy"},{name:"parent",keyPath:"parent"},{name:"slug",keyPath:"slug",unique:!0},{name:"count",keyPath:"count"}],endpoint:"terms",TTL:72e5,filters:{taxonomy:"",page:1,search:"",parent:0},required:"taxonomy"}),this.fields=new Map,this.selectedTerms=new Map,this.activeField=null,this.currentConfig=null,this.currentSingular=null,this.currentPlural=null,this.activeStore=null,this.disabled=!1,this.searchHandler=null,this.autocompleteHandler=null,this.isAutocompleteActive=!1,this.init()}init(){this.initModal(),this.scanExistingFields(),this.initGlobalListeners(),this.store.subscribe(this.handleStoreEvent.bind(this)),this.isInitializing=!1,this.batchFetchTaxonomies()}handleStoreEvent(e,t){switch(e){case"data-loaded":if(this.modal?.open&&this.handleTermsLoaded(t),this.isAutocompleteActive&&this.activeField){const e=this.fields.get(this.activeField),i=t.data?.items||[],s=t.filters?.search||"";this.showAutocompleteResults(e,i,s),this.isAutocompleteActive=!1}break;case"filters-changed":this.modal?.open&&this.showLoading();break;case"fetch-error":this.isAutocompleteActive&&this.activeField&&(this.showAutocompleteError(this.activeField),this.isAutocompleteActive=!1),this.handleFetchError(t.error)}}handleTermsLoaded(e){this.hideLoading();const t=e.data?.items||[],i=e.data?.pagination||{},s=e.filters?.search&&e.filters.search.length>0,o=e.filters?.page>1;0===t.length?(o||this.showEmptyState(s?"No results found.":"No items available."),this.observer.unobserve(this.ui.sentinel)):(this.renderTerms(t,o,s),this.currentTerms=t,i.has_more?this.observer.observe(this.ui.sentinel):this.observer.unobserve(this.ui.sentinel)),this.a11y?.announce(t.length,o)}handleFetchError(e){console.error("Taxonomy fetch error:",e),this.hideLoading(),this.error?.log?this.error.log(e,{component:"TaxonomySelector",action:"fetchTerms"},(()=>this.fetchCurrentTerms())):this.showEmptyState("Error loading terms. Please try again.")}updateFieldsForTaxonomy(e,t){this.fields.forEach((i=>{i.taxonomy===e&&i.selectedTerms.size>0&&i.selectedTerms.forEach((e=>{const s=t.find((t=>t.id===e));if(s){const t=i.selectedContainer.querySelector(`[data-id="${e}"]`);t&&(t.dataset.path=s.path,t.querySelector("span").textContent=s.path)}}))}))}scanExistingFields(e=null){e||(e=document.body);e.querySelectorAll(".field.taxonomy, .field.post").forEach((e=>{try{this.registerField(e)}catch(t){this.error.log(t,{component:"TaxonomySelector",action:"scanExistingFields",container:e.dataset.name})}}))}registerField(e,t={}){let i=e.querySelector("input[type=hidden]");if(!i)return;"fieldId"in e.dataset||(e.dataset.fieldId=this.createFieldId(e));let s=e.dataset.fieldId,o=e.querySelector("button.taxonomy-toggle"),n={id:s,input:i,container:e,taxonomy:o.dataset.taxonomy,name:e.dataset.field,maxSelection:parseInt(o.dataset.max)||0,canSearch:"search"in o.dataset,hasAutocomplete:"autocomplete"in o.dataset,autocompleteDropdown:e.querySelector(".autocomplete-dropdown")??!1,canCreate:"creatable"in o.dataset,isRequired:"required"in o.dataset,selectedTerms:new Set,toggle:o,selectedContainer:e.querySelector(".selected-items"),...t};!this.hasAutocomplete&&n.hasAutocomplete&&(this.hasAutocomplete=!0,this.initAutocomplete());const a=i.value.trim();if(""!==a){a.split(",").map((e=>parseInt(e.trim()))).filter((e=>!isNaN(e))).forEach((e=>n.selectedTerms.add(e)))}return this.fields.set(s,n),this.isInitializing?this.taxonomiesToFetch.add(n.taxonomy):this.store.setFilter("taxonomy",n.taxonomy),n.selectedTerms.size>0&&this.initFieldDisplay(s),s}async batchFetchTaxonomies(){if(0===this.taxonomiesToFetch.size)return;const e=Array.from(this.taxonomiesToFetch);this.taxonomiesToFetch.clear(),console.log(`Batch fetching ${e.length} unique taxonomies:`,e);for(const t of e)await this.store.setFilters({taxonomy:t,page:1,search:"",parent:0});this.fields.forEach(((e,t)=>{e.selectedTerms.size>0&&this.initFieldDisplay(t)}))}createFieldId(e){return this.index++,"selector-"+this.index}async initFieldDisplay(e){const t=this.fields.get(e);if(!t||0===t.selectedTerms.size)return;const i=Array.from(t.selectedTerms),s=[];i.forEach((e=>{const t=this.store.data.get(e);t&&s.push(t)})),s.forEach((t=>{this.addTermToDisplay(e,t.id,t.name,t.path)}))}initModal(){this.modalID="dialog#jvb-selector",this.modal=document.querySelector(this.modalID),this.modal?(this.initModalElements(),this.modalInstance=new window.jvbModal(this.modal,{handleForm:!1,save:null,open:null}),this.modalInstance.subscribe(((e,t)=>{switch(e){case"modal-open":console.log(t),this.openModal(t);break;case"modal-close":this.closeModal(t)}}))):console.warn("Taxonomy selector modal not found")}initModalElements(){this.selectors={search:{input:"[type=search]",clear:".clear-search",container:".search-wrapper"},termsList:".items-container",termsWrap:".items-wrap",breadcrumbs:{nav:"nav.term-navigation",back:".back-to-parent"},loading:{loading:".loading",text:".loading span"},selectedTerms:".selected-items",sentinel:".scroll-sentinel",modal:{title:"#modal-title",content:".modal-content"},create:{details:".create-new-term",parent:"#select_parent",summary:".create-new-term summary",name:"#term_name",button:".submit-term",label:{name:"[for=term_name]",parent:"[for=select_parent]"}},favouriteTerms:".favourite-terms"},this.ui=window.uiFromSelectors(this.selectors),this.observer=new IntersectionObserver((e=>{e.forEach((e=>{e.isIntersecting&&this.activeStore&&this.loadMoreTerms()}))}),{root:this.ui.termsWrap,threshold:.5})}initGlobalListeners(){document.addEventListener("click",this.handleClick.bind(this)),document.addEventListener("change",this.handleChange.bind(this)),this.hasAutocomplete&&this.initAutocomplete()}initAutocomplete(){console.log("Autocomplete init"),this.autocompleteHandler=window.debounce((e=>this.handleAutocomplete(e)),300),document.addEventListener("input",this.autocompleteHandler),document.addEventListener("blur",this.cleanupAutocomplete.bind(this))}handleClick(e){const t=window.targetCheck(e,".taxonomy-toggle");if(t)return e.preventDefault(),void this.handleToggleClick(t);const i=window.targetCheck(e,"button.remove-item");if(i&&e.target.closest(".jvb-selector")){const e=this.getFieldId(i),t=i.closest(".selected-item").dataset.id;this.removeSelectedTerm(e,t)}else e.target.matches(".modal-close")?this.modalInstance&&this.modalInstance.handleClose():this.modal&&this.modal.contains(e.target)&&this.handleModalClick(e)}handleChange(e){if(window.targetCheck(e,".taxonomy.field, .post.field")&&"hidden"===e.target.type){const t=this.getFieldId(e.target);this.updateFieldFromInput(t)}else this.modal&&this.modal.contains(e.target)&&this.handleModalChange(e)}handleToggleClick(e){try{const t=this.getFieldId(e);if(!this.fields.get(t))return void console.error("Field not found for toggle:",t);this.setActiveField(t),this.modalInstance.handleOpen()}catch(e){console.error("Error handling toggle click:",e),this.error?.handleError(e,{component:"TaxonomySelector",action:"handleToggleClick"})}}setActiveField(e){if(this.activeField=e,this.currentConfig=this.fields.get(e),this.currentSingular=jvbSettings.labels[this.currentConfig.taxonomy].single,this.currentPlural=jvbSettings.labels[this.currentConfig.taxonomy].plural,this.store.setFilter("taxonomy",this.currentConfig.taxonomy),this.selectedTerms.clear(),this.currentConfig.selectedTerms){let e=[];if(this.currentConfig.selectedTerms.forEach((t=>{const i=this.store.getItem(t);i?this.selectedTerms.set(t,{id:t,name:i.name,path:i.path}):e.push(t)})),e.length>0){this.fetchSpecificTerms(e).forEach((e=>{this.selectedTerms.set(e.id,{id:e.id,name:e.name,path:e.path})}))}}}fetchSpecificTerms(e){return[]}handleModalClick(e){if(window.targetCheck(e,".remove-item")){let t=window.targetCheck(e,".selected-item");t&&this.removeSelectedTermFromModal(t.dataset.id)}else if(window.targetCheck(e,".back-to-parent"))this.navigateToParent();else if(window.targetCheck(e,".toggle-children")){let t=e.target.closest("li");this.navigateToChild(parseInt(t.dataset.id),t.querySelector(".term-name").textContent)}else if(window.targetCheck(e,".path-level")){let t=window.targetCheck(e,".path-level");this.navigateToPath(t)}}handleModalChange(e){if(window.targetCheck(e,this.modalID)&&"checkbox"===e.target.type){e.preventDefault(),e.stopPropagation();const t=parseInt(e.target.closest("li").dataset.id),i=e.target.closest("li").querySelector("label");e.target.checked?this.addSelectedTermToModal(t,i.title,i.dataset.path):this.removeSelectedTermFromModal(t)}}openForFilter(e,t,i=[]){const s=`filter-${e}-${Date.now()}`;this.fields.set(s,{id:s,input:null,container:null,taxonomy:e,name:`filter_${e}`,maxSelection:0,canSearch:!0,hasAutocomplete:!1,autocompleteDropdown:document.querySelector(".autocomplete-dropdown")??!1,canCreate:!1,isRequired:!1,selectedTerms:new Set(i),toggle:null,selectedContainer:null,isFilterMode:!0,filterCallback:t}),this.setActiveField(s),this.modalInstance.handleOpen()}openModal(e){this.activeField=e.fieldId,this.currentConfig=e,e.canCreate&&"jvbTaxCreator"in window?this.creator=new window.jvbTaxCreator(this):this.creator&&delete this.creator,this.selectedTerms=new Set(e.selectedTerms);this.store.filters.taxonomy!==e.taxonomy&&this.store.setFilters({taxonomy:e.taxonomy,page:1,search:"",parent:0}),window.removeChildren(this.ui.termsList),this.ui.search.value="",this.updateSelectionCount(),this.modalInstance.open()}closeModal(){if(this.observer.unobserve(this.ui.sentinel),window.removeChildren(this.ui.termsList),this.currentConfig?.isFilterMode){if(this.currentConfig.filterCallback){const e=Array.from(this.selectedTerms.keys());this.currentConfig.filterCallback(e,this.currentConfig.taxonomy)}this.fields.delete(this.activeField)}else this.activeField&&this.saveSelectionsToField(this.activeField);this.currentConfig?.canSearch&&this.searchHandler&&this.ui.search.input.removeEventListener("input",this.searchHandler),this.creator&&delete this.creator,this.activeField=null,this.currentConfig=null}resetModalState(){this.disabled=!1,window.removeChildren(this.ui.termsList),window.removeChildren(this.ui.selectedTerms),this.ui.search.input.value="",window.removeChildren(this.ui.breadcrumbs.nav),this.ui.breadcrumbs.nav.appendChild(this.ui.breadcrumbs.back),this.ui.breadcrumbs.back.hidden=!0}updateModalForTaxonomy(){if(!this.currentConfig)return;this.ui.modal.title.textContent=`Select ${this.currentPlural}`,this.ui.search.container&&(this.ui.search.container.style.display=this.currentConfig.canSearch?"block":"none"),this.ui.create.details&&(this.ui.create.details.style.display=this.currentConfig.canCreate?"block":"none",this.ui.create.details.hidden=!this.currentConfig.canCreate,this.ui.create.summary&&(this.ui.create.summary.textContent=`Add new ${this.currentSingular}`),this.ui.create.label.name&&(this.ui.create.label.name.textContent=`Name this ${this.currentSingular}`),this.ui.create.label.parent&&(this.ui.create.label.parent.textContent="Nest it under"),this.ui.create.parent);const e=`Opened ${this.currentSingular} selection. Choose from checkboxes or search to filter results.`;this.a11y?.announce(e)}updateModalSelections(){window.removeChildren(this.ui.selectedTerms),this.selectedTerms.forEach(((e,t)=>{this.addTermToModalDisplay(t,e.name,e.path)})),this.checkSelectionLimits()}addSelectedTermToModal(e,t,i){this.selectedTerms.set(e,{id:e,name:t,path:i}),this.addTermToModalDisplay(e,t,i),this.checkSelectionLimits();const s=this.ui.termsList.querySelector(`input[value="${e}"]`);s&&(s.checked=!0)}removeSelectedTermFromModal(e){this.selectedTerms.delete(parseInt(e));const t=this.ui.selectedTerms.querySelector(`[data-id="${e}"]`);t&&t.remove();const i=this.ui.termsList.querySelector(`input[value="${e}"]`);i&&(i.checked=!1),this.checkSelectionLimits()}addTermToModalDisplay(e,t,i){const s=window.getTemplate("selectedTerm").cloneNode(!0);s.dataset.id=e,s.dataset.path=i,s.dataset.name=t,s.dataset.taxonomy=this.currentConfig.taxonomy,s.querySelector("span").textContent=i,s.querySelector("button").title=`Remove ${t}`,this.ui.selectedTerms.appendChild(s)}checkSelectionLimits(){this.currentConfig&&0!==this.currentConfig.maxSelection&&(this.disabled=this.selectedTerms.size>=this.currentConfig.maxSelection,this.setCheckboxes(this.disabled))}setCheckboxes(e){this.ui.termsList.querySelectorAll('input[type="checkbox"]').forEach((t=>{t.checked||(t.disabled=e)}))}saveSelectionsToField(e){const t=this.fields.get(e);if(!t)return;t.selectedTerms.clear(),window.removeChildren(t.selectedContainer),this.selectedTerms.forEach(((i,s)=>{t.selectedTerms.add(s),this.addTermToDisplay(e,s,i.name,i.path)}));const i=Array.from(t.selectedTerms);t.input.value=i.join(","),t.input.dispatchEvent(new Event("change",{bubbles:!0}))}removeSelectedTerm(e,t){const i=this.fields.get(e);if(!i)return;const s=parseInt(t);i.selectedTerms.delete(s);const o=i.selectedContainer.querySelector(`[data-id="${s}"]`);o&&o.remove();const n=Array.from(i.selectedTerms);i.input.value=n.join(","),i.input.dispatchEvent(new Event("change",{bubbles:!0}))}addTermToDisplay(e,t,i,s){const o=this.fields.get(e);if(!o||o.selectedContainer.querySelector(`[data-id="${t}"]`))return;const n=window.getTemplate("selectedTerm").cloneNode(!0);n.dataset.id=t,n.dataset.path=s,n.dataset.name=i,n.dataset.taxonomy=o.taxonomy,n.querySelector("span").textContent=s,n.querySelector("button").title=`Remove ${i}`,o.selectedContainer.appendChild(n)}updateFieldFromInput(e){const t=this.fields.get(e);if(!t)return;const i=t.input.value.trim();if(t.selectedTerms.clear(),window.removeChildren(t.selectedContainer),""!==i){i.split(",").map((e=>parseInt(e.trim()))).filter((e=>!isNaN(e))).forEach((e=>t.selectedTerms.add(e))),this.initFieldDisplay(e)}}handleSearch(e){const t=e.target.value.trim();this.searchHandler&&clearTimeout(this.searchHandler),this.searchHandler=setTimeout((()=>{this.store.setFilters({search:t,page:1,parent:t?0:this.store.filters.parent||0}),window.removeChildren(this.ui.termsList)}),300)}async handleAutocomplete(e){if(!("autocomplete"in e.target.dataset))return;const t=this.getFieldId(e.target),i=this.fields.get(t);if(!i)return;const s=e.target.value.trim();if(s.length<2)return i.autocompleteDropdown&&(i.autocompleteDropdown.hidden=!0),void(this.isAutocompleteActive=!1);this.activeField=t,this.currentConfig=i,i.canCreate&&!this.creator&&(this.creator=new window.jvbTaxCreator(this)),this.isAutocompleteActive=!0,i.autocompleteDropdown&&(i.autocompleteDropdown.hidden=!1),this.store.setFilters({taxonomy:i.taxonomy,search:s,page:1})}cleanupAutocomplete(e){if(!("autocomplete"in e.target.dataset))return;const t=this.getFieldId(e.target);this.fields.get(t)&&this.creator&&delete this.creator}showAutocompleteError(e){const t=this.fields.get(e);if(!t)return;t.config.autocompleteDropdown||(t.config.autocompleteDropdown=t.element.querySelector(".autocomplete-dropdown"));const i=t.config.autocompleteDropdown;i&&(window.removeChildren(i),this.showEmptyState("Hmmm... something went wrong",i))}showAutocompleteResults(e,t,i){if(!e||!e.autocompleteDropdown)return;const s=e.autocompleteDropdown;if(window.removeChildren(s),0===t.length?this.showEmptyState("No items found.",s):t.forEach((t=>{const i=this.createAutocompleteTermElement(e,t);i&&s.appendChild(i)})),this.creator){const t=this.creator.createAutocompleteOption(i,e);s.appendChild(t)}s.hidden=!1}createAutocompleteTermElement(e,t){const i=document.createElement("button");return i.type="button",i.className="autocomplete-item",i.dataset.id=t.id,i.dataset.name=t.name,i.dataset.path=t.path||t.name,i.textContent=t.path||t.name,i.addEventListener("click",(()=>{e.selectedTerms.add(parseInt(t.id)),this.addTermToDisplay(e.id,t.id,t.name,t.path),e.input.value=Array.from(e.selectedTerms).join(","),e.input.dispatchEvent(new Event("change",{bubbles:!0})),e.autocompleteDropdown.hidden=!0;const i=e.container.querySelector("input[data-autocomplete]");i&&(i.value="")})),i}navigateToParent(){this.store.setFilters({parent:0,page:1}),window.removeChildren(this.ui.termsList),this.ui.breadcrumbs.back.hidden=!0}navigateToChild(e,t){this.store.setFilters({parent:e,page:1}),window.removeChildren(this.ui.termsList),this.updateBreadcrumbs(e,t),this.ui.breadcrumbs.back.hidden=!1}navigateToPath(e){const t=parseInt(e.dataset.id)||0;this.store.setFilters({parent:t,page:1}),window.removeChildren(this.ui.termsList),this.ui.breadcrumbs.back.hidden=0===t}loadMoreTerms(){if(!this.activeStore)return;const e=this.activeStore.filters.page||1;this.store.setFilter("page",e+1)}renderTerms(e,t=!1,i=!1){if(t||window.removeChildren(this.ui.termsList),0===e.length)return void(t||this.showEmptyState());const s=this.store.filters.parent||0;this.ui.breadcrumbs.back.hidden=0===s,e.forEach((e=>{const t=this.createTermElement({id:parseInt(e.id),name:e.name,hasChildren:e.hasChildren,path:e.path||null,show:i});t&&this.ui.termsList.appendChild(t)}))}createTermElement(e){if(!e||!e.name)return null;const t=window.getTemplate("termListItem").cloneNode(!0);t.dataset.id=e.id;const i=this.selectedTerms.has(e.id),s=t.querySelector("input"),o=t.querySelector("label"),n=t.querySelector("span, .term-name");if(s&&o&&n&&(s.id=`${this.currentConfig.container.id}${e.id}`,s.name=`${this.currentConfig.container.id}${this.currentConfig.taxonomy}-select`,s.value=e.id,s.disabled=!i&&this.disabled,s.checked=i,o.htmlFor=s.id,o.title=e.path||e.name,o.dataset.path=e.path,n.textContent=e.show?e.path:e.name),e.hasChildren){const i=window.getTemplate?window.getTemplate("termChildrenToggle"):this.createChildrenToggle();i&&(i.ariaLabel=`View sub-terms of ${e.name}`,t.appendChild(i))}return t}createChildrenToggle(){const e=document.createElement("button");return e.type="button",e.className="toggle-children",e.innerHTML="→",e}updateBreadcrumbs(e,t){const i=window.getTemplate("termBreadcrumb").cloneNode(!0);i.dataset.id=e,i.textContent=t,i.title=t;const s=this.ui.breadcrumbs.nav.querySelector(`[data-id="${e}"]`);if(s)for(;s.nextElementSibling;)s.nextElementSibling.remove();else this.ui.breadcrumbs.nav.appendChild(i)}showLoading(){this.ui.loading.loading.hidden=!1,this.modal.classList.add("loading");const e=this.store?.filters?.search||"",t=this.store?.filters?.parent||0;let i=""!==e?`searching for "${e}" items`:0===t?"loading items":"loading child items";window.typeLoop?this.stopTyping=window.typeLoop(this.ui.loading.text,i):this.ui.loading.text.textContent=i}hideLoading(){this.ui.loading.loading.hidden=!0,this.modal.classList.remove("loading"),this.stopTyping&&this.stopTyping()}showEmptyState(e="No items found.",t=null){t||(t=this.ui.termsList);const i=window.getTemplate("noResults").cloneNode(!0);e&&i.querySelector("span")&&(i.querySelector("span").textContent=e),t.appendChild(i)}getFieldId(e){if(e.dataset.fieldId)return e.dataset.fieldId;const t=e.closest("[data-field-id]");return t?t.dataset.fieldId:null}destroy(){document.removeEventListener("click",this.handleClick),document.removeEventListener("change",this.handleChange),this.observer?.disconnect(),this.store.destroy(),this.fields.clear(),this.selectedTerms.clear()}}document.addEventListener("DOMContentLoaded",(function(){window.jvbSelector=new e}))})();
\ No newline at end of file
diff --git a/assets/js/min/settings.min.js b/assets/js/min/settings.min.js
new file mode 100644
index 0000000..8d72a78
--- /dev/null
+++ b/assets/js/min/settings.min.js
@@ -0,0 +1 @@
+(()=>{class e{constructor(){this.cache=new window.jvbCache("settings"),this.cache.loadFromCache(),this.findSettings(),this.debouncer=window.debouncer,this.isLoggedIn=null!==jvbSettings.currentUser,this.initListeners(),this.loadSettings(),this.subscribers=new Set}findSettings(){this.settings=document.querySelectorAll("[data-setting]")??[]}addSetting(e,t="",s=null){t=""===t?e.name:t,e.dataset.setting=t;let n=this.cache.get(t);n&&("INPUT"===e.tagName&&["checkbox","radio"].includes(e.type)?e.checked=n===e.value:"DETAILS"===e.tagName&&(e.open="on"===n)),this.debouncer.schedule("add-setting",(()=>{this.findSettings.bind(this)}),300)}loadSettings(){for(const e of this.settings){let t=e.name;if(Object.hasOwn(e.dataset,"theme"))this.checkTheme(e);else{let s=this.cache.get(t);s&&("on"===e.value?e.checked="on"===s:["checkbox","radio"].includes(e.tagName)?e.checked=e.value===s:e.value=s)}}}checkTheme(e){const t=window.matchMedia("(prefers-color-scheme: dark)");let s=this.cache.get("dark-mode");!t||s&&"off"===s?"on"===s&&(e.checked=!0):e.checked=!0}initListeners(){this.changeHandler=this.handleChange.bind(this),document.addEventListener("change",this.changeHandler)}handleChange(e){if(!Object.hasOwn(e.target.dataset,"setting"))return;let t=e.target.value;"on"===e.target.value&&(t=e.target.checked?"on":"off"),this.saveSetting(e.target.name,t)}saveSetting(e,t){let s;this.isLoggedIn&&(s=this.cache.get(e)),this.cache.set(e,t),this.isLoggedIn&&s&&s!==t&&this.saveToServer(e,t)}async saveToServer(e,t){if(!this.isLoggedIn||!["dark-mode"].includes(e))return;const s={"X-WP-Nonce":jvbSettings?.nonce,"Content-Type":"application/json"},n={user:jvbSettings.currentUser,setting:e,value:t},i=await fetch(`${jvbSettings.api}settings`,{method:"POST",headers:s,body:JSON.stringify(n)});await i.json()}loadSetting(e){return this.cache.get(e)}loadUserSetting(e){}subscribe(e){return this.subscribers.add(e),()=>this.subscribers.delete(e)}notify(e,t){this.subscribers.forEach((s=>s(e,t)))}destroy(){document.removeEventListener("change",this.changeHandler),this.subscribers.clear()}}document.addEventListener("DOMContentLoaded",(function(){window.jvbUserSettings=new e}))})();
\ No newline at end of file
diff --git a/assets/js/min/square.min.js b/assets/js/min/square.min.js
index 98ae4bc..3c662a4 100644
--- a/assets/js/min/square.min.js
+++ b/assets/js/min/square.min.js
@@ -1 +1 @@
-(()=>{class e{constructor(e={}){this.config={...squareConfig,...e},this.payments=null,this.card=null,this.isInitialized=!1,this.cartItems=new Map,this.checkout=document.querySelector("aside#cart"),this.isOpen="1"!==this.config.isOpen||!1,this.isOpen=!0,this.isLoggedIn=this.config.is_logged_in||!1,this.userEmail=this.config.user_email||"",this.savedCards=[],this.selectedCardId=null,this.cartId=null,this.cache=new window.jvbCache("cart",{TTL:864e5}),this.a11y=window.jvbA11y,this.initCart(),this.checkout&&(this.initElements(),this.init(),this.initListeners(),this.isLoggedIn&&this.loadSavedCards()),this.stepMultiplier=1,this.popup=new window.jvbPopup({popup:this.checkout,toggle:this.toggle,name:"Cart",onOpen:this.maybeAddEmptyState.bind(this)}),console.log(this.popup)}async initCart(){this.cartItems=await this.cache.get("cart")??new Map,console.log("cart",this.cartItems),this.cartItems.size>0&&this.notifyRestoredCart()}handleClick(e){if(window.targetCheck(e,"button")&&window.targetCheck(e,"div.quantity")){let t=window.targetCheck(e,"div.quantity");this.handleNumberClick(e,t)}else if(window.targetCheck(e,"[data-add-to-cart]")){let t=window.targetCheck(e,"[data-add-to-cart]");this.handleAddToCart(t)}else if(window.targetCheck(e,"[data-remove-from-cart]")){let t=window.targetCheck(e,"[data-remove-from-cart]");this.handleRemoveFromCart(t)}else window.targetCheck(e,"[data-clear-cart]")&&this.clearCart()}handleChange(e,t){console.log("Checkout change");let a=window.targetCheck(e,".quantity-input");if(a){let t=e.target.closest(".quantity"),i=a.value;if(window.targetCheck(e,".cart-items")){let e=document.querySelector(`.menu-section [data-id="${t.dataset.id}"] input`);e&&(e.value=a.value)}i>0?this.handleAddToCart(t):this.handleRemoveFromCart(t)}}handleNumberClick(e,t){console.log(t),e.preventDefault();let a=0;if(e.target.closest(".increase")?a+=1:e.target.closest(".decrease")&&(a-=1),0!==a){let[e,i]=[parseInt(t.dataset.step),t.querySelector("input")],s=""===i.value?0:parseInt(i.value);i.value=s+e*a*this.stepMultiplier,i.dispatchEvent(new Event("change",{bubbles:!0})),this.handleNumberLimits(t)}}handleNumberLimits(e){let[t,a,i,s,r]=[e.dataset.min,e.dataset.max,e.querySelector("input"),e.querySelector(".increase"),e.querySelector(".decrease")],n=parseInt(i.value);n<t?(i.value=t,r.disabled=!0):n>a?(i.value=a,s.disabled=!1):s.disabled?s.disabled=!1:r.disabled&&(r.disabled=!1)}maybeAddEmptyState(){let e=this.itemsList.querySelector(".empty");if(e&&e.remove(),0===this.cartItems.size){this.checkoutPanel.disabled=!0,this.checkoutPanel.title="Add some things to your cart first!";let e=window.getTemplate("emptyCart");this.itemsList.append(e),this.table.closest("table").hidden=!0,this.total.hidden=!0,this.a11y.announce("Nothing in Cart")}else this.checkoutPanel.disabled=!1,this.table.closest("table").hidden=!1,this.total.hidden=!1,this.checkoutPanel.title="Checkout"}handleEscape(e){"Escape"===e.key?this.stepMultiplier=1:e.ctrlKey&&e.shiftKey?this.stepMultiplier=Math.max(100*parseInt(this.stepMultiplier),1e3):e.shiftKey&&(this.stepMultiplier=Math.max(10*parseInt(this.stepMultiplier),1e3))}handleAddToCart(e){let t=e.dataset.id;this.createItemElement(e);let a=parseFloat(e.dataset.price),i=parseInt(e.querySelector(".quantity-input")?.value)??1,s=parseFloat(a*i);this.cartItems.set(t,{post_id:t,name:e.dataset.name,price:a,quantity:i,total:s,square_catalog_id:e.dataset.squareCatalogId}),this.saveCart()}notifyRestoredCart(){let e=window.getTemplate("restoredCart");this.checkout.querySelector(".tab-content[data-tab=cartItems]").insertBefore(e,this.itemsList),this.cartItems.forEach((e=>{console.log(e);let t=window.getTemplate("cartItem"),a=t.querySelector(".quantity"),i=e.price,s=e.quantity;[a.dataset.id,t.querySelector("label").textContent,t.querySelector(".price").textContent,a.dataset.price,a.dataset.squareCatalogId,t.querySelector('[name="quantity"]').value,t.querySelector(".total").textContent]=[e.post_id,e.name,window.formatPrice(i),i,e.square_catalog_id,s,window.formatPrice(s*i)],this.table.append(t)})),this.updateTotal()}handleRemoveFromCart(e){if(confirm("This will remove this item from the cart. Continue?")){e.querySelector("[data-id]")||(e=e.closest(".item")?.querySelector(".quantity.field"));let t=e.dataset.id;this.cartItems.delete(t),this.table.querySelector(`[data-id="${t}"]`)?.closest("tr").remove();let a=document.querySelector(`[data-id="${t}"] input`);a&&(a.value=0),this.maybeAddEmptyState(),this.saveCart()}}clearCart(){this.cartItems.clear(),window.removeChildren(this.table),this.saveCart()}saveCart(){this.updateTotal(),this.cache.set("cart",this.cartItems)}updateTotal(){let e=0;this.cartItems.forEach((t=>{console.log(t),e+=t.total}));let t=.05*e;e=window.formatPrice(e+t),t=window.formatPrice(t),window.eraseText(this.totalTax),window.eraseText(this.grandTotal),window.typeText(this.totalTax,t),window.typeText(this.grandTotal,e),this.totalTax.classList.remove("typeText")}createItemElement(e){let t=this.itemsList.querySelector(`[data-id="${e.dataset.id}"]`),a=!1,i=e.dataset.price,s=e.querySelector('[name="quantity"]')?.value??1;if(t)t=t.closest("tr");else{a=!0,t=window.getTemplate("cartItem");let s=t.querySelector(".quantity");[s.dataset.id,t.querySelector("label").textContent,t.querySelector(".price").textContent,s.dataset.price,s.dataset.squareCatalogId]=[e.dataset.id,e.dataset.name,window.formatPrice(i),i,e.dataset.squareCatalogId]}[t.querySelector('[name="quantity"]').value,t.querySelector(".total").textContent]=[s,window.formatPrice(s*i)],a&&(t.classList.add("adding"),this.table.append(t),setTimeout((()=>{t.classList.remove("adding")}),500))}async init(){if(window.Square)try{this.payments=window.Square.payments(this.config.application_id,this.config.location_id),await this.initializePaymentMethods(),this.isInitialized=!0,document.dispatchEvent(new CustomEvent("squareCheckoutReady",{detail:{checkout:this}}))}catch(e){console.error("Failed to initialize Square payments:",e),this.handleError(e)}else console.error("Square Web Payments SDK not loaded")}initElements(){this.toggle=document.querySelector(".toggle-cart"),this.isOpen||(this.toggle.disabled=!0,this.toggle.title="Currently closed for online ordering"),this.checkoutPanel=this.checkout.querySelector('button[data-tab="checkout"]'),this.itemsList=this.checkout.querySelector(".cart-items"),this.table=this.checkout.querySelector(".cart-items tbody"),this.total=this.checkout.querySelector(".cart-total"),this.totalTax=this.total.querySelector(".tax span"),this.grandTotal=this.total.querySelector(".total span"),this.checkoutForm=this.checkout.querySelector("form"),this.tabs=new window.jvbTabs(this.checkoutForm,{updateURL:!1}),console.log("Initialized Checkout")}initListeners(){this.clickHandler=this.handleClick.bind(this),this.keyHandler=this.handleEscape.bind(this),this.changeHandler=this.handleChange.bind(this),this.checkoutForm.addEventListener("submit",(e=>this.handleFormSubmit(e))),document.addEventListener("click",this.clickHandler),document.addEventListener("change",this.changeHandler)}async initializePaymentMethods(){if(document.getElementById("square-card-container"))try{this.card=await this.payments.card({style:this.getCardStyle()}),await this.card.attach("#square-card-container"),this.card.addEventListener("cardBrandChanged",(e=>{console.log("Card brand:",e.detail.cardBrand)}))}catch(e){throw console.error("Failed to initialize card:",e),e}}getCardStyle(){return{input:{fontSize:"16px",fontFamily:"inherit",color:"#333",backgroundColor:"#fff"},".input-container":{borderColor:"#ccc",borderRadius:"4px"},".input-container.is-focus":{borderColor:"#006AFF",borderWidth:"2px",outline:"2px solid #006AFF",outlineOffset:"2px"},".input-container.is-error":{borderColor:"#d63638"}}}async handleFormSubmit(e){if(!this.isOpen)return;if(e.preventDefault(),!this.isInitialized)return void this.handleError("Checkout not initialized");const t=e.target,a=this.extractOrderData(t);try{window.jvbLoading.showLoading("Processing payment...");const e=await this.processPayment(a);this.handleSuccess(e,t)}catch(e){this.handleError(e)}finally{window.jvbLoading.hideLoading()}}extractOrderData(e){const t=Array.from(this.cartItems.values()).map((e=>({catalog_object_id:e.square_catalog_id,quantity:String(e.quantity),price:e.price,note:e.note||""}))),a=t.reduce(((e,t)=>e+t.price*t.quantity),0);return{total:Math.round(100*a),items:t,customer:{email:this.isLoggedIn?this.userEmail:e.querySelector('[name="email"]')?.value||"",name:e.querySelector('[name="name"]')?.value||"",phone:e.querySelector('[name="phone"]')?.value||""},note:e.querySelector('[name="special_instructions"]')?.value||"",pickup_time:e.querySelector('[name="pickup_time"]')?.value||""}}async processPayment(e){try{let t=null;if(this.selectedCardId)t=this.selectedCardId;else{const a=await this.card.tokenize({verificationDetails:{amount:String(e.total),currencyCode:this.config.currency||"CAD",intent:"CHARGE",customerInitiated:!0,billingContact:{givenName:e.customer.name.split(" ")[0],familyName:e.customer.name.split(" ").slice(1).join(" "),email:e.customer.email,phone:e.customer.phone,addressLines:[form.querySelector('[name="address"]')?.value||""],city:form.querySelector('[name="city"]')?.value||"",state:form.querySelector('[name="state"]')?.value||"",postalCode:form.querySelector('[name="postal_code"]')?.value||"",countryCode:"CA"}}});if("OK"!==a.status){const e=a.errors?.map((e=>e.message)).join(", ")||"Unknown error";throw new Error(`Card tokenization failed: ${e}`)}t=a.token,a.details?.userChallenged&&console.log("3D Secure verification completed")}return await this.submitToServer(t,e,!!this.selectedCardId)}catch(e){throw console.error("Payment processing failed:",e),e}}async submitToServer(e,t,a=!1){if(!this.isOpen)throw new Error("Store is currently closed");const i=await fetch(this.config.api_url+"process-payment",{method:"POST",headers:{"Content-Type":"application/json","X-WP-Nonce":this.config.nonce},body:JSON.stringify({source_id:e,is_saved_card:a,cart_id:this.getCartId(),amount:t.total,items:t.items,customer:{email:this.isLoggedIn?this.userEmail:t.customer.email,name:t.customer.name,phone:t.customer.phone},note:t.note,pickup_time:t.pickup_time})}),s=await i.json();if(!i.ok)throw new Error(s.message||"Payment processing failed");return this.clearCart(),s}getCartId(){return this.cartId||(this.cartId=crypto.randomUUID(),this.cache.set("cart_id",this.cartId)),this.cartId}trackOrder(e){this.orderId=e,this.scheduleOrderCheck(),this.checkout.querySelector("button[data-tab=order]").hidden=!1}scheduleOrderCheck(){window.debouncer.schedule("order",(()=>{this.checkOrderStatus()}),3e4)}async checkOrderStatus(){const e=await fetch(`/wp-json/jvb/v1/square/order-status/${this.orderId}`),t=await e.json();"ready"!==t.status&&this.scheduleOrderCheck(),this.updateOrderStatus(t)}updateOrderStatus(e){this.checkout.querySelectorAll(".status-item").forEach((t=>{t.dataset.status===e.status&&t.classList.add("active")})),this.checkout.querySelector("#eta").textContent=e.eta||"In progress"}async loadSavedCards(){try{const e=await fetch(this.config.api_url+"saved-cards",{method:"GET",headers:{"X-WP-Nonce":this.config.nonce}}),t=await e.json();t.success&&t.cards&&(this.savedCards=t.cards,this.renderSavedCards())}catch(e){console.error("Failed to load saved cards:",e)}}renderSavedCards(){const e=document.getElementById("saved-cards");if(!e||0===this.savedCards.length)return;const t=`\n <div class="saved-cards-section">\n <h4>Saved Payment Methods</h4>\n ${this.savedCards.map((e=>`\n <label class="saved-card">\n <input type="radio" name="payment-method" value="saved" data-card-id="${e.id}">\n <span class="card-info">\n <strong>${e.card_brand}</strong> ending in ${e.last_4}\n <small>Exp: ${e.exp_month}/${e.exp_year}</small>\n </span>\n </label>\n `)).join("")}\n <label class="saved-card">\n <input type="radio" name="payment-method" value="new" checked>\n <span>Use a new card</span>\n </label>\n </div>\n `;e.innerHTML=t,e.querySelectorAll('input[name="payment-method"]').forEach((e=>{e.addEventListener("change",(e=>{const t="new"===e.target.value,a=document.getElementById("square-card-container");a&&(a.style.display=t?"block":"none"),this.selectedCardId=t?null:e.target.dataset.cardId}))}))}handleSuccess(e,t){document.dispatchEvent(new CustomEvent("squareCheckoutSuccess",{detail:{result:e,form:t}}));const a=t.dataset.successUrl||`/order-confirmation/?order=${e.wp_order_id}`;window.location.href=a}handleError(e){console.error("Square checkout error:",e),document.dispatchEvent(new CustomEvent("squareCheckoutError",{detail:{error:e}})),window.jvbNotifications?.show?.(e.message||"Payment failed","error")}}document.addEventListener("DOMContentLoaded",(()=>{window.squareCheckout=new e}))})();
\ No newline at end of file
+(()=>{class e{constructor(e={}){this.config={...squareConfig,...e},this.payments=null,this.card=null,this.isInitialized=!1,this.cartItems=new Map,this.checkout=document.querySelector("aside#cart"),this.isOpen="1"!==this.config.isOpen||!1,this.isLoggedIn=this.config.is_logged_in||!1,this.userEmail=this.config.user_email||"",this.savedCards=[],this.selectedCardId=null,this.cartId=null,this.cache=new window.jvbCache("cart",{TTL:864e5}),this.a11y=window.jvbA11y,this.initCart(),this.checkout&&(this.initElements(),this.init(),this.initListeners(),this.isLoggedIn&&this.loadSavedCards()),this.stepMultiplier=1,this.popup=new window.jvbPopup({popup:this.checkout,toggle:this.toggle,name:"Cart",onOpen:this.maybeAddEmptyState.bind(this)}),console.log(this.popup)}async initCart(){this.cartItems=await this.cache.get("cart")??new Map,console.log("cart",this.cartItems),this.cartItems.size>0&&this.notifyRestoredCart()}handleClick(e){if(window.targetCheck(e,"button")&&window.targetCheck(e,"div.quantity")){let t=window.targetCheck(e,"div.quantity");this.handleNumberClick(e,t)}else if(window.targetCheck(e,"[data-add-to-cart]")){let t=window.targetCheck(e,"[data-add-to-cart]");this.handleAddToCart(t)}else if(window.targetCheck(e,"[data-remove-from-cart]")){let t=window.targetCheck(e,"[data-remove-from-cart]");this.handleRemoveFromCart(t)}else window.targetCheck(e,"[data-clear-cart]")&&this.clearCart()}handleChange(e,t){console.log("Checkout change");let a=window.targetCheck(e,".quantity-input");if(a){let t=e.target.closest(".quantity"),i=a.value;if(window.targetCheck(e,".cart-items")){let e=document.querySelector(`.menu-section [data-id="${t.dataset.id}"] input`);e&&(e.value=a.value)}i>0?this.handleAddToCart(t):this.handleRemoveFromCart(t)}}handleNumberClick(e,t){console.log(t),e.preventDefault();let a=0;if(e.target.closest(".increase")?a+=1:e.target.closest(".decrease")&&(a-=1),0!==a){let[e,i]=[parseInt(t.dataset.step),t.querySelector("input")],s=""===i.value?0:parseInt(i.value);i.value=s+e*a*this.stepMultiplier,i.dispatchEvent(new Event("change",{bubbles:!0})),this.handleNumberLimits(t)}}handleNumberLimits(e){let[t,a,i,s,r]=[e.dataset.min,e.dataset.max,e.querySelector("input"),e.querySelector(".increase"),e.querySelector(".decrease")],o=parseInt(i.value);o<t?(i.value=t,r.disabled=!0):o>a?(i.value=a,s.disabled=!1):s.disabled?s.disabled=!1:r.disabled&&(r.disabled=!1)}maybeAddEmptyState(){let e=this.itemsList.querySelector(".empty");if(e&&e.remove(),0===this.cartItems.size){this.checkoutPanel.disabled=!0,this.checkoutPanel.title="Add some things to your cart first!";let e=window.getTemplate("emptyCart");this.itemsList.append(e),this.table.closest("table").hidden=!0,this.total.hidden=!0,this.a11y.announce("Nothing in Cart")}else this.checkoutPanel.disabled=!1,this.table.closest("table").hidden=!1,this.total.hidden=!1,this.checkoutPanel.title="Checkout"}handleEscape(e){"Escape"===e.key?this.stepMultiplier=1:e.ctrlKey&&e.shiftKey?this.stepMultiplier=Math.max(100*parseInt(this.stepMultiplier),1e3):e.shiftKey&&(this.stepMultiplier=Math.max(10*parseInt(this.stepMultiplier),1e3))}handleAddToCart(e){let t=e.dataset.id;this.createItemElement(e);let a=parseFloat(e.dataset.price),i=parseInt(e.querySelector(".quantity-input")?.value)??1,s=parseFloat(a*i);this.cartItems.set(t,{post_id:t,name:e.dataset.name,price:a,quantity:i,total:s,square_catalog_id:e.dataset.squareCatalogId}),this.saveCart()}notifyRestoredCart(){let e=window.getTemplate("restoredCart");this.checkout.querySelector(".tab-content[data-tab=cartItems]").insertBefore(e,this.itemsList),this.cartItems.forEach((e=>{console.log(e);let t=window.getTemplate("cartItem"),a=t.querySelector(".quantity"),i=e.price,s=e.quantity;[a.dataset.id,t.querySelector("label").textContent,t.querySelector(".price").textContent,a.dataset.price,a.dataset.squareCatalogId,t.querySelector('[name="quantity"]').value,t.querySelector(".total").textContent]=[e.post_id,e.name,window.formatPrice(i),i,e.square_catalog_id,s,window.formatPrice(s*i)],this.table.append(t)})),this.updateTotal()}handleRemoveFromCart(e){if(confirm("This will remove this item from the cart. Continue?")){e.querySelector("[data-id]")||(e=e.closest(".item")?.querySelector(".quantity.field"));let t=e.dataset.id;this.cartItems.delete(t),this.table.querySelector(`[data-id="${t}"]`)?.closest("tr").remove();let a=document.querySelector(`[data-id="${t}"] input`);a&&(a.value=0),this.maybeAddEmptyState(),this.saveCart()}}clearCart(){this.cartItems.clear(),window.removeChildren(this.table),this.saveCart()}saveCart(){this.updateTotal(),this.cache.set("cart",this.cartItems)}updateTotal(){let e=0;this.cartItems.forEach((t=>{console.log(t),e+=t.total}));let t=.05*e;e=window.formatPrice(e+t),t=window.formatPrice(t),window.eraseText(this.totalTax),window.eraseText(this.grandTotal),window.typeText(this.totalTax,t),window.typeText(this.grandTotal,e),this.totalTax.classList.remove("typeText")}createItemElement(e){let t=this.itemsList.querySelector(`[data-id="${e.dataset.id}"]`),a=!1,i=e.dataset.price,s=e.querySelector('[name="quantity"]')?.value??1;if(t)t=t.closest("tr");else{a=!0,t=window.getTemplate("cartItem");let s=t.querySelector(".quantity");[s.dataset.id,t.querySelector("label").textContent,t.querySelector(".price").textContent,s.dataset.price,s.dataset.squareCatalogId]=[e.dataset.id,e.dataset.name,window.formatPrice(i),i,e.dataset.squareCatalogId]}[t.querySelector('[name="quantity"]').value,t.querySelector(".total").textContent]=[s,window.formatPrice(s*i)],a&&(t.classList.add("adding"),this.table.append(t),setTimeout((()=>{t.classList.remove("adding")}),500))}async init(){if(window.Square)try{this.payments=window.Square.payments(this.config.application_id,this.config.location_id),await this.initializePaymentMethods(),this.isInitialized=!0,document.dispatchEvent(new CustomEvent("squareCheckoutReady",{detail:{checkout:this}}))}catch(e){console.error("Failed to initialize Square payments:",e),this.handleError(e)}else console.error("Square Web Payments SDK not loaded")}initElements(){this.toggle=document.querySelector(".toggle-cart"),this.isOpen||(this.toggle.disabled=!0,this.toggle.title="Currently closed for online ordering"),this.checkoutPanel=this.checkout.querySelector('button[data-tab="checkout"]'),this.itemsList=this.checkout.querySelector(".cart-items"),this.table=this.checkout.querySelector(".cart-items tbody"),this.total=this.checkout.querySelector(".cart-total"),this.totalTax=this.total.querySelector(".tax span"),this.grandTotal=this.total.querySelector(".total span"),this.checkoutForm=this.checkout.querySelector("form"),this.tabs=new window.jvbTabs(this.checkoutForm,{updateURL:!1}),console.log("Initialized Checkout")}initListeners(){this.clickHandler=this.handleClick.bind(this),this.keyHandler=this.handleEscape.bind(this),this.changeHandler=this.handleChange.bind(this),this.checkoutForm.addEventListener("submit",(e=>this.handleFormSubmit(e))),document.addEventListener("click",this.clickHandler),document.addEventListener("change",this.changeHandler)}async initializePaymentMethods(){if(document.getElementById("square-card-container"))try{this.card=await this.payments.card({style:this.getCardStyle()}),await this.card.attach("#square-card-container"),this.card.addEventListener("cardBrandChanged",(e=>{console.log("Card brand:",e.detail.cardBrand)}))}catch(e){throw console.error("Failed to initialize card:",e),e}}getCardStyle(){return{input:{fontSize:"16px",fontFamily:"inherit",color:"#333",backgroundColor:"#fff"},".input-container":{borderColor:"#ccc",borderRadius:"4px"},".input-container.is-focus":{borderColor:"#006AFF",borderWidth:"2px",outline:"2px solid #006AFF",outlineOffset:"2px"},".input-container.is-error":{borderColor:"#d63638"}}}async handleFormSubmit(e){if(!this.isOpen)return;if(e.preventDefault(),!this.isInitialized)return void this.handleError("Checkout not initialized");const t=e.target,a=this.extractOrderData(t);try{window.jvbLoading.showLoading("Processing payment...");const e=await this.processPayment(a);this.handleSuccess(e,t)}catch(e){this.handleError(e)}finally{window.jvbLoading.hideLoading()}}extractOrderData(e){const t=Array.from(this.cartItems.values()).map((e=>({catalog_object_id:e.square_catalog_id,quantity:String(e.quantity),price:e.price,note:e.note||""}))),a=t.reduce(((e,t)=>e+t.price*t.quantity),0);return{total:Math.round(100*a),items:t,customer:{email:this.isLoggedIn?this.userEmail:e.querySelector('[name="email"]')?.value||"",name:e.querySelector('[name="name"]')?.value||"",phone:e.querySelector('[name="phone"]')?.value||""},note:e.querySelector('[name="special_instructions"]')?.value||"",pickup_time:e.querySelector('[name="pickup_time"]')?.value||""}}async processPayment(e){try{let t=null;if(this.selectedCardId)t=this.selectedCardId;else{const a=await this.card.tokenize({verificationDetails:{amount:String(e.total),currencyCode:this.config.currency||"CAD",intent:"CHARGE",customerInitiated:!0,billingContact:{givenName:e.customer.name.split(" ")[0],familyName:e.customer.name.split(" ").slice(1).join(" "),email:e.customer.email,phone:e.customer.phone,addressLines:[form.querySelector('[name="address"]')?.value||""],city:form.querySelector('[name="city"]')?.value||"",state:form.querySelector('[name="state"]')?.value||"",postalCode:form.querySelector('[name="postal_code"]')?.value||"",countryCode:"CA"}}});if("OK"!==a.status){const e=a.errors?.map((e=>e.message)).join(", ")||"Unknown error";throw new Error(`Card tokenization failed: ${e}`)}t=a.token,a.details?.userChallenged&&console.log("3D Secure verification completed")}return await this.submitToServer(t,e,!!this.selectedCardId)}catch(e){throw console.error("Payment processing failed:",e),e}}async submitToServer(e,t,a=!1){if(!this.isOpen)throw new Error("Store is currently closed");const i=await fetch(this.config.api_url+"process-payment",{method:"POST",headers:{"Content-Type":"application/json","X-WP-Nonce":this.config.nonce},body:JSON.stringify({source_id:e,is_saved_card:a,cart_id:this.getCartId(),amount:t.total,items:t.items,customer:{email:this.isLoggedIn?this.userEmail:t.customer.email,name:t.customer.name,phone:t.customer.phone},note:t.note,pickup_time:t.pickup_time})}),s=await i.json();if(!i.ok)throw new Error(s.message||"Payment processing failed");return this.clearCart(),s}getCartId(){return this.cartId||(this.cartId=crypto.randomUUID(),this.cache.set("cart_id",this.cartId)),this.cartId}trackOrder(e){this.orderId=e,this.scheduleOrderCheck(),this.checkout.querySelector("button[data-tab=order]").hidden=!1}scheduleOrderCheck(){window.debouncer.schedule("order",(()=>{this.checkOrderStatus()}),3e4)}async checkOrderStatus(){const e=await fetch(`/wp-json/jvb/v1/square/order-status/${this.orderId}`),t=await e.json();"ready"!==t.status&&this.scheduleOrderCheck(),this.updateOrderStatus(t)}updateOrderStatus(e){this.checkout.querySelectorAll(".status-item").forEach((t=>{t.dataset.status===e.status&&t.classList.add("active")})),this.checkout.querySelector("#eta").textContent=e.eta||"In progress"}async loadSavedCards(){try{const e=await fetch(this.config.api_url+"saved-cards",{method:"GET",headers:{"X-WP-Nonce":this.config.nonce}}),t=await e.json();t.success&&t.cards&&(this.savedCards=t.cards,this.renderSavedCards())}catch(e){console.error("Failed to load saved cards:",e)}}renderSavedCards(){const e=document.getElementById("saved-cards");if(!e||0===this.savedCards.length)return;const t=`\n <div class="saved-cards-section">\n <h4>Saved Payment Methods</h4>\n ${this.savedCards.map((e=>`\n <label class="saved-card">\n <input type="radio" name="payment-method" value="saved" data-card-id="${e.id}">\n <span class="card-info">\n <strong>${e.card_brand}</strong> ending in ${e.last_4}\n <small>Exp: ${e.exp_month}/${e.exp_year}</small>\n </span>\n </label>\n `)).join("")}\n <label class="saved-card">\n <input type="radio" name="payment-method" value="new" checked>\n <span>Use a new card</span>\n </label>\n </div>\n `;e.innerHTML=t,e.querySelectorAll('input[name="payment-method"]').forEach((e=>{e.addEventListener("change",(e=>{const t="new"===e.target.value,a=document.getElementById("square-card-container");a&&(a.style.display=t?"block":"none"),this.selectedCardId=t?null:e.target.dataset.cardId}))}))}handleSuccess(e,t){document.dispatchEvent(new CustomEvent("squareCheckoutSuccess",{detail:{result:e,form:t}}));const a=t.dataset.successUrl||`/order-confirmation/?order=${e.wp_order_id}`;window.location.href=a}handleError(e){console.error("Square checkout error:",e),document.dispatchEvent(new CustomEvent("squareCheckoutError",{detail:{error:e}})),window.jvbNotifications?.show?.(e.message||"Payment failed","error")}}document.addEventListener("DOMContentLoaded",(()=>{window.squareCheckout=new e}))})();
\ No newline at end of file
diff --git a/assets/js/min/uploader.min.js b/assets/js/min/uploader.min.js
index 2f76fac..7c51b23 100644
--- a/assets/js/min/uploader.min.js
+++ b/assets/js/min/uploader.min.js
@@ -1 +1 @@
-(()=>{class e{constructor(){this.queue=window.jvbQueue,this.a11y=window.jvbA11y,this.error=window.jvbError,this.fieldStore=new window.jvbStore({name:"upload_fields",storeName:"fieldStates",keyPath:"id",version:2,indexes:[{name:"fieldId",keyPath:"fieldId"},{name:"timestamp",keyPath:"timestamp"},{name:"content",keyPath:"content"},{name:"itemId",keyPath:"itemId"},{name:"status",keyPath:"status"}],stripDOMReferences:!0,TTL:6048e5}),this.uploadStore=new window.jvbStore({name:"uploads",storeName:"uploads",keyPath:"id",storeBlobs:!0,indexes:[{name:"fieldId",keyPath:"fieldId"},{name:"status",keyPath:"status"},{name:"groupId",keyPath:"groupId"},{name:"attachmentId",keyPath:"attachmentId"}]}),this.fieldStore.subscribe(this.handleFieldStoreEvent.bind(this)),this.uploadStore.subscribe(this.handleUploadStoreEvent.bind(this)),this.initWorker(),this.fields=new Map,this.uploads=new Map,this.uploadBlobs=new Map,this.groups=new Map,this.selected=new Map,this.selectionHandlers=new Map,this.previewUrls=new Set,this.subscribers=new Set,this.dragController=null,this.selectors={field:{field:"[data-upload-field]",input:'input[type="file"]',hiddenValue:'input[type="hidden"]',dropZone:".file-upload-container",preview:".item-grid.preview",progress:".image-progress"},groups:{container:".upload-group",grid:".item-grid.group",header:".group-header",selectAll:'[name="select-all-group"]',actions:".group-actions",count:".selection-controls .info"},items:{item:"[data-upload-id]",checkbox:'[name*="select-item"]',featured:'[name="featured"]',details:"details"}},this.statusMapping={received:"Image Received",local_processing:"Processing Image...",queued:"Waiting to upload...",uploading:"Uploading to Server",pending:"Successfully sent to server. In line for further processing.",processing:"Processing on server...",completed:"Upload complete!",failed:"Upload failed (will retry)",failed_permanent:"Upload failed permanently"},this.init()}async init(){await this.loadFields(),await this.loadUploads(),this.initializeFields(),this.initListeners(),this.queue.subscribe(((e,t)=>{if("uploads"!==t.endpoint&&"uploads/meta"!==t.endpoint)return;const s=t.data instanceof FormData?t.data.get("fieldId"):t.data.fieldId;switch(e){case"cancel-operation":s&&this.clearField(s);break;case"operation-status":s&&this.updateFieldStatus(s,t.status);break;case"operation-complete":(t.result?.data||[]).forEach((e=>{const t=this.uploads.get(e.upload_id);t&&(t.attachmentId=e.attachment_id,t.status="completed",this.uploads.set(t.id,t))})),s&&this.cleanField(s)}})),window.addEventListener("beforeunload",(()=>{this.cleanupAllPreviewUrls()}))}initWorker(){this.worker={worker:null,timeout:null,tasks:new Map,restart:{count:0,max:3},settings:{timeout:1e4,batchSize:1,maxConcurrent:3,restartAfterTimeout:!0}}}initializeFields(){document.querySelectorAll(this.selectors.field.field).forEach((e=>{this.registerUploader(e)}))}scanFields(e){e.querySelectorAll(this.selectors.field.field).forEach((e=>{this.registerUploader(e)}))}registerUploader(e){const t=this.determineFieldId(e),s=this.extractFieldConfig(e),r={id:t,config:s,element:e,ui:this.buildFieldUI(e),uploads:new Set,groups:new Set,state:"ready"};return this.fields.set(t,r),e.dataset.uploader=t,this.addFieldSelectionHandler(t),"post_group"!==s.destination||this.dragController||this.initGroupFeatures(),t}extractFieldConfig(e){return{destination:e.dataset.destination||"meta",content:e.dataset.content||null,mode:e.dataset.mode||"direct",type:e.dataset.type||"single",name:e.dataset.field,itemID:e.dataset.itemId||0,maxFiles:parseInt(e.dataset.maxFiles)||999,subtype:e.dataset.subtype||"image"}}buildFieldUI(e){let t={field:e,input:e.querySelector(this.selectors.field.input),dropZone:e.querySelector(this.selectors.field.dropZone),preview:e.querySelector(this.selectors.field.preview),progress:{progress:e.querySelector(this.selectors.field.progress),bar:e.querySelector(".bar"),fill:e.querySelector(".fill"),details:e.querySelector(".details"),text:e.querySelector(".details .text"),count:e.querySelector(".details .count")}},s=e.querySelector(".group-display");return s&&(t.groups={display:s,container:e.querySelector(".item-grid.groups"),empty:e.querySelector(".empty-group"),groups:new Map}),t}initListeners(){this.clickHandler=this.handleClick.bind(this),this.changeHandler=this.handleChange.bind(this),document.addEventListener("click",this.clickHandler),document.addEventListener("change",this.changeHandler),this.dragEnterHandler=this.handleExternalDragEnter.bind(this),this.dragLeaveHandler=this.handleExternalDragLeave.bind(this),this.dragOverHandler=this.handleExternalDragOver.bind(this),this.dropHandler=this.handleExternalDrop.bind(this),document.addEventListener("dragenter",this.dragEnterHandler),document.addEventListener("dragleave",this.dragLeaveHandler),document.addEventListener("dragover",this.dragOverHandler),document.addEventListener("drop",this.dropHandler)}initGroupFeatures(){this.dragController=new window.jvbDragHandler({draggableSelector:this.selectors.items.item,dropTargetSelector:`${this.selectors.field.preview}, ${this.selectors.groups.grid}, .empty-group`,ignoreSelector:"input:not(.upload-select), button, select, textarea, details, summary, a",previewElement:"img, video, .icon",getItemId:e=>e.dataset.uploadId,getSelectedItems:e=>{const t=this.getFieldIdFromElement(e),s=e.dataset.uploadId,r=this.getCurrentSelection(t);return r&&r.includes(s)?r:[s]},validateDrop:(e,t)=>{const s=this.getFieldIdFromElement(t),r=document.querySelector(`[data-upload-id="${e[0]}"]`);return s===this.getFieldIdFromElement(r)},onDrop:(e,t)=>{this.handleItemDrop(e,t),t.scrollIntoView({behavior:"smooth",block:"center"})},onDragStart:e=>{},onDragEnd:(e,t)=>{if(t){const t=document.querySelector(`[data-upload-id="${e[0]}"]`),s=this.getFieldIdFromElement(t),r=this.selectionHandlers.get(s);r?.clearSelection()}},previewOptions:{multiOffset:{x:-60,y:-80},singleOffset:{x:-50,y:-60},showCount:!0}})}handleExternalDragLeave(e){const t=e.target.closest(this.selectors.field.dropZone);t&&!t.contains(e.relatedTarget)&&t.classList.remove("dragover")}handleExternalDragEnter(e){if(!e.dataTransfer.types.includes("Files"))return;const t=e.target.closest(this.selectors.field.dropZone);t&&(e.preventDefault(),t.classList.add("dragover"))}handleExternalDragOver(e){if(!e.dataTransfer.types.includes("Files"))return;e.target.closest(this.selectors.field.dropZone)&&(e.preventDefault(),e.dataTransfer.dropEffect="copy")}handleExternalDrop(e){const t=e.target.closest(this.selectors.field.dropZone);if(!t)return;e.preventDefault(),t.classList.remove("dragover");const s=Array.from(e.dataTransfer.files);if(0===s.length)return;const r=this.getFieldIdFromElement(t);r?(this.processFiles(r,s),this.a11y.announce(`${s.length} file(s) dropped for upload`)):console.error("No field ID found for drop zone")}handleItemDrop(e,t){const s=t.classList.contains("preview");let r=t;if(t.classList.contains("empty-group")){const e=this.getFieldIdFromElement(t),s=this.createGroup(e);if(!s)return void console.error("Failed to create group");r=s.grid}e.forEach((e=>{s?this.removeFromGroup(e):this.addToGroup(e,r)}));const o=this.getFieldIdFromElement(t);this.schedulePersistance(o);const i=e.length>1?`Moved ${e.length} items`:"Moved item";this.a11y.announce(i)}handleClick(e){if(e.target.matches(this.selectors.field.dropZone)||e.target.closest(this.selectors.field.dropZone)){const t=e.target.closest(this.selectors.field.dropZone);if(t&&!e.target.matches("input, button, a")){const e=t.querySelector(this.selectors.field.input);e?.click()}}const t=e.target.closest("[data-action]");t&&this.handleAction(t)}handleChange(e){const t=this.getFieldIdFromElement(e.target);if(e.target.matches(this.selectors.field.input)){const t=this.getFieldIdFromElement(e.target),s=Array.from(e.target.files);s.length>0&&t&&this.processFiles(t,s)}t&&("post_group"===this.fields.get(t).config.destination?this.handleGroupMetaChange(e.target):this.queueUploadMeta(e))}getCurrentSelection(e){let t=[];for(let[s,r]of this.selectionHandlers)(e===s||s.includes(e))&&r.selectedItems.size>0&&(t=t.concat([...r.selectedItems]));return t}getSubtypeFromMime(e){return e.startsWith("image/")?"image":e.startsWith("video/")?"video":"document"}getStatusText(e){return this.statusMapping[e]||e}getStatusIcon(e){return window.getIcon(this.queue.icons[e])}getStatusProgress(e){switch(e){case"local_processing":return 28;case"queued":return 50;case"uploading":return 66;case"pending":return 75;case"processing":return 89;case"completed":return 100;default:return 0}}getModalType(e){if(void 0!==e._cachedModalType)return e._cachedModalType;if(!e||!e.element)return e._cachedModalType=null,null;const t=e.element.closest("dialog");if(!t)return e._cachedModalType=null,null;let s=null;return s=t.classList.contains("edit")?"edit":t.classList.contains("create")?"create":t.classList.contains("bulkEdit")?"bulkEdit":t.className,e._cachedModalType=s,s}handleAction(e){const t=e.dataset.action,s=this.getFieldIdFromElement(e);switch(t){case"add-to-group":this.handleAddToGroup(e);break;case"delete-group":this.handleDeleteGroup(e);break;case"delete-upload":case"remove-from-group":this.handleRemoveItem(e);break;case"upload":this.fields.get(s).element.closest("details").open=!1,document.body.classList.add("uploading"),this.submitUploads(s);break;case"restore":this.handleRestoreUploads().then((()=>{}));break;case"clear-cache":confirm("Save these uploads for later?")||this.cleanupStoredUploads(),this.cleanupRestore()}}handleAddToGroup(e){const t=e.closest(this.selectors.field.field),s=t?.dataset.uploader;if(!s)return;const r=this.selected.get(s);if(r&&0!==r.size){const e=this.createGroup(s);if(!e)return;r.forEach((t=>{this.addToGroup(t,e.grid)}));const t=this.selectionHandlers.get(s);t?.clearSelection(),this.a11y.announce(`Created group with ${r.size} items`)}else this.createGroup(s);this.schedulePersistance(s)}handleDeleteGroup(e){const t=e.closest(this.selectors.groups.container);if(!t)return;const s=t.dataset.groupId,r=this.getFieldIdFromElement(t);if(!confirm("Delete this group? Items will be moved back to the upload area."))return;t.querySelectorAll(this.selectors.items.item).forEach((e=>{const t=e.dataset.uploadId;this.removeFromGroup(t)})),this.deleteGroup(s),this.a11y.announce("Group deleted, items returned to upload area"),this.schedulePersistance(r)}handleRemoveItem(e){const t=e.closest(this.selectors.items.item);if(!t)return;const s=t.dataset.uploadId,r=this.getFieldIdFromElement(t);confirm("Remove this item?")&&(this.removeUpload(r,s),this.a11y.announce("Item removed"),this.schedulePersistance(r))}addFieldSelectionHandler(e){if(this.selectionHandlers.has(e))return this.selectionHandlers.get(e);const t=this.fields.get(e);if(!t)return;const s=t.ui.field;if(!s)return;const r=new window.jvbHandleSelection({container:s,ui:{selectAll:s.querySelector('[name="select-all-uploads"]'),bulkControls:s.querySelector(".selection-actions"),count:s.querySelector(".selection-count")},itemSelector:"[data-upload-id]",checkboxSelector:'[name*="select-item"]'});return r.subscribe(((t,s)=>{switch(t){case"item-selected":case"item-deselected":case"range-selected":this.selected.set(e,s.selectedItems);break;case"select-all":this.handleSelectAll(s.container,s.selected)}})),this.selectionHandlers.set(e,r),r}addGroupSelectionHandler(e,t){const s=`${e}_${t}`;if(this.selectionHandlers.has(s))return this.selectionHandlers.get(s);const r=this.groups.get(t);if(!r)return;const o=new window.jvbHandleSelection({container:r.element,ui:{selectAll:r.element.querySelector(this.selectors.groups.selectAll),bulkControls:r.element.querySelector(this.selectors.groups.actions),count:r.element.querySelector(this.selectors.groups.count)},itemSelector:"[data-upload-id]",checkboxSelector:'[name*="select-item"]'});return o.subscribe(((t,s)=>{switch(t){case"item-selected":case"item-deselected":case"range-selected":this.selected.set(e,s.selectedItems);break;case"select-all":this.handleSelectAll(s.container,s.selected)}})),this.selectionHandlers.set(s,o),o}handleSelectAll(e,t){}determineFieldId(e){return`${e.dataset.content||e.closest("dialog")?.dataset.content||e.closest("form")?.dataset.save||""}_${e.dataset.itemId||e.closest("dialog")?.dataset.itemId||""}_${e.dataset.field||""}`}getFromElement(e,t){const s={field:{selector:this.selectors.field.field,key:"uploader",store:this.fields},upload:{selector:this.selectors.items.item,key:"uploadId",store:this.uploads},group:{selector:this.selectors.groups.container,key:"groupId",store:this.groups}}[t];if(!s)return null;const r=e.closest(s.selector);if(!r)return null;const o=r.dataset[s.key];return s.store.get(o)}getFieldFromElement(e){return this.getFromElement(e,"field")}getUploadFromElement(e){return this.getFromElement(e,"upload")}getGroupFromElement(e){return this.getFromElement(e,"group")}getFieldIdFromElement(e){return this.getFromElement(e,"field")?.id??null}getUploadIdFromElement(e){return this.getFromElement(e,"upload")?.id??null}getGroupIdFromElement(e){return this.getFromElement(e,"group")?.id??null}async processFiles(e,t){const s=this.fields.get(e);if(!s)return;s.ui.dropZone&&(s.ui.dropZone.hidden=!0),s.ui.groups.display&&(s.ui.groups.display.hidden=!1);const r=t.length;let o=0;this.updateUploadProgress(e,0,r,"Processing files..."),s.uploads||(s.uploads=new Set);const i=Array.from(t).map((async(t,i)=>{try{const i=`upload_${Date.now()}_${Math.random().toString(36).substr(2,9)}`,a={id:i,attachment_id:null,fieldId:e,originalFile:t,processedFile:null,preview:null,status:"local_processing",element:null,location:null,meta:{originalName:t.name,size:t.size,type:t.type}};a.preview=this.createPreviewUrl(t),t.type.startsWith("image/")?a.processedFile=await this.processImage(t,s.subtype):a.processedFile=t,await this.uploadStore.saveBlob(i,a.processedFile||t);const l=this.getSubtypeFromMime(t.type);return a.element=this.createUploadElement({...a,subtype:l},"post_group"===s.config.destination),this.showUploadProgress(i,!0),this.updateUploadItemProgress(i,50,"local_processing"),s.ui.preview&&(s.ui.preview.appendChild(a.element),a.location=s.ui.preview),this.uploads.set(i,a),s.uploads.add(i),o++,this.updateUploadProgress(e,o,r,"Processing files..."),this.updateUploadItemProgress(i,100,"processed"),a.status="processed",setTimeout((()=>{this.showUploadProgress(i,!1)}),1e3),i}catch(s){return console.error("Error processing file:",t.name,s),o++,this.updateUploadProgress(e,o,r,"Processing files..."),null}}));await Promise.all(i),this.updateFieldState(e),await this.schedulePersistance(e),"post_group"!==s.config.destination&&(await this.queueUpload(e),this.maybeLockUploads(e))}updateFieldState(e){const t=this.fields.get(e);if(!t||!t.ui.field)return;const s=t.ui.field,r=t.uploads?.size||0,o=t.ui.groups?.container?.querySelectorAll(".upload-group").length>0;s.dataset.hasUploads=r>0?"true":"false",s.dataset.uploadCount=r.toString(),s.dataset.hasGroups=o?"true":"false",t.ui.preview&&t.ui.preview.setAttribute("aria-label",`Upload preview area with ${r} item${1!==r?"s":""}`)}updateUploadProgress(e,t,s,r){const o=this.fields.get(e);if(!o?.ui?.progress?.progress)return;const i=o.ui.progress,a=s>0?t/s*100:0;i.fill&&(i.fill.style.width=`${a}%`),i.text&&(i.text.textContent=r),i.count&&(i.count.textContent=`${t}/${s}`),i.progress.hidden=t===s}updateFieldStatus(e,t){const s=this.fields.get(e);s&&(s.state=t)}updateUploadStatus(e,t){const s=this.uploads.get(e);s&&(s.status=t,this.updateUploadUI(e))}updateUploadUI(e){const t=this.uploads.get(e);if(!t?.element)return;t.element.className=t.element.className.replace(/status-[\w-]+/g,""),t.element.classList.add(`status-${t.status}`);t.element.querySelector(".progress")&&this.updateUploadItemProgress(e,this.getStatusProgress(t.status),t.status)}showUploadProgress(e,t=!0){const s=this.uploads.get(e);if(!s||!s.element)return;const r=s.element.querySelector(".progress");r&&(t?(r.style.removeProperty("animation"),r.hidden=!1):(r.style.animation="fadeOut var(--transition-base)",setTimeout((()=>{r.hidden=!0}),300)))}updateUploadItemProgress(e,t,s=null){const r=this.uploads.get(e);if(!r||!r.element)return;const o=r.element.querySelector(".progress");if(!o)return;const i=o.querySelector(".fill"),a=o.querySelector(".details"),l=o.querySelector(".icon");i&&(i.style.width=`${t}%`),s&&a&&(a.textContent=this.getStatusText(s)),s&&l&&(l.innerHTML=this.getStatusIcon(s).outerHTML)}checkFieldLimits(e,t){const s=this.fields.get(e);if(!s)return!1;return(s.uploads?.size||0)+t<=s.maxFiles}validateFile(e,t){return this.settings.allowedTypes.includes(e.type)?!(e.size>this.settings.maxFileSize)||(this.notify(`File too large: ${this.formatBytes(e.size)}`,"error"),!1):(this.notify(`Invalid file type: ${e.type}`,"error"),!1)}formatBytes(e,t=2){if(0===e)return"0 Bytes";const s=t<0?0:t,r=Math.floor(Math.log(e)/Math.log(1024));return parseFloat((e/Math.pow(1024,r)).toFixed(s))+" "+["Bytes","KB","MB","GB"][r]}shouldProcessClientSide(e,t){return!("image"!==t||!e.type.startsWith("image/"))}async processImage(e,t){const s=this.worker.settings.timeout;return new Promise(((r,o)=>{let i,a=!1;i=setTimeout((()=>{a||(a=!0,this.worker.tasks.delete(t),this.worker.settings.restartAfterTimeout&&this.restartCompressionWorker(),o(new Error(`Processing timeout for ${e.name}`)))}),s),this.worker.tasks.set(t,{file:e,timeoutId:i}),this.handleProcess(e,t).then((e=>{a||(a=!0,clearTimeout(i),this.worker.tasks.delete(t),r(e))})).catch((e=>{a||(a=!0,clearTimeout(i),this.worker.tasks.delete(t),o(e))}))}))}async handleProcess(e,t){if(!e.type.startsWith("image/"))return e;const s=this.getMaxDimension();if(this.shouldUseWorker(e))try{if(this.worker.worker||this.initCompressionWorker(),this.worker.worker)return await this.processWithWorker(e,t,s,.85)}catch(e){console.warn("Worker processing failed, falling back to main thread:",e)}return await this.processOnMainThread(e,s,.85)}async processOnMainThread(e,t,s){return new Promise(((r,o)=>{const i=new Image,a=document.createElement("canvas"),l=a.getContext("2d");let n=null;const d=()=>{i.onload=null,i.onerror=null,n&&(URL.revokeObjectURL(n),n=null),a.width=1,a.height=1,l.clearRect(0,0,1,1)};i.onload=()=>{try{const{width:n,height:c}=this.calculateOptimalDimensions(i,t);a.width=n,a.height=c,l.imageSmoothingEnabled=!0,l.imageSmoothingQuality="high",l.drawImage(i,0,0,n,c);const u=this.getOptimalFormat(e),p=this.getOptimalQuality(e,s);a.toBlob((t=>{if(d(),t){const s=new File([t],this.getProcessedFileName(e,u),{type:u,lastModified:Date.now()});r(s)}else o(new Error("Canvas toBlob failed"))}),u,p)}catch(e){d(),o(new Error(`Canvas processing failed: ${e.message}`))}},i.onerror=()=>{d(),o(new Error(`Failed to load image: ${e.name}`))};try{n=this.createPreviewUrl(e),i.src=n}catch(e){d(),o(new Error(`Failed to create object URL: ${e.message}`))}}))}getOptimalFormat(e){return"image/gif"===e.type||"image/svg+xml"===e.type?e.type:this.supportsWebP()?"image/webp":"image/jpeg"}getOptimalQuality(e,t){return e.size<512e3?Math.max(t,.9):e.size<2097152?t:Math.min(t,.8)}getProcessedFileName(e,t){return e.name.replace(/\.[^/.]+$/,"")+({"image/webp":".webp","image/jpeg":".jpg","image/png":".png","image/gif":".gif"}[t]||".jpg")}getMaxDimension(){const e=window.screen.width,t=window.devicePixelRatio||1;return e*t>2560?2400:e*t>1920?1920:1200}shouldUseWorker(e){return this.worker.worker&&e.size>1048576&&"undefined"!=typeof OffscreenCanvas}async processWithWorker(e,t,s,r){return new Promise(((o,i)=>{if(!this.worker.worker)return void i(new Error("Worker not available"));const a=`${t}_${Date.now()}`,l=t=>{if(t.data.messageId===a)if(this.worker.worker.removeEventListener("message",l),this.worker.worker.removeEventListener("error",n),t.data.success){const s=new File([t.data.blob],this.getProcessedFileName(e,t.data.format||"image/webp"),{type:t.data.format||"image/webp",lastModified:Date.now()});o(s)}else i(new Error(t.data.error||"Worker processing failed"))},n=e=>{this.worker.worker.removeEventListener("message",l),this.worker.worker.removeEventListener("error",n),i(new Error(`Worker error: ${e.message}`))};this.worker.worker.addEventListener("message",l),this.worker.worker.addEventListener("error",n),this.worker.worker.postMessage({messageId:a,file:e,maxDimension:s,quality:r,outputFormat:this.getOptimalFormat(e)})}))}restartCompressionWorker(){this.worker.worker&&(this.worker.worker.terminate(),this.worker.worker=null),this.worker.tasks.clear(),this.worker.restart.count>=this.worker.restart.max?console.error("Max worker restarts reached, disabling worker"):(this.worker.restart.count++,this.initCompressionWorker())}initCompressionWorker(){if(!this.worker.worker&&"undefined"!=typeof Worker)try{const e=new Blob(["\n self.onmessage = async function(e) {\n const { messageId, file, maxDimension, quality, outputFormat } = e.data;\n\n try {\n // Create ImageBitmap from file\n const bitmap = await createImageBitmap(file);\n\n // Calculate dimensions\n const scale = Math.min(maxDimension / bitmap.width, maxDimension / bitmap.height, 1);\n const width = Math.round(bitmap.width * scale);\n const height = Math.round(bitmap.height * scale);\n\n // Create OffscreenCanvas\n const canvas = new OffscreenCanvas(width, height);\n const ctx = canvas.getContext('2d');\n\n // Draw and resize\n ctx.imageSmoothingEnabled = true;\n ctx.imageSmoothingQuality = 'high';\n ctx.drawImage(bitmap, 0, 0, width, height);\n\n // Clean up bitmap\n bitmap.close();\n\n // Convert to blob\n const blob = await canvas.convertToBlob({\n type: outputFormat,\n quality: quality\n });\n\n self.postMessage({\n messageId,\n success: true,\n blob: blob,\n format: outputFormat\n });\n\n } catch (error) {\n self.postMessage({\n messageId,\n success: false,\n error: error.message\n });\n }\n };\n "],{type:"application/javascript"});this.worker.worker=new Worker(this.createPreviewUrl(e))}catch(e){console.warn("Failed to initialize compression worker:",e),this.worker.worker=null}}calculateOptimalDimensions(e,t){let{width:s,height:r}=e;if(s<=t&&r<=t)return{width:s,height:r};const o=Math.min(t/s,t/r);return{width:Math.round(s*o),height:Math.round(r*o)}}supportsWebP(){return 0===document.createElement("canvas").toDataURL("image/webp").indexOf("data:image/webp")}createPreviewUrl(e){const t=URL.createObjectURL(e);return this.previewUrls||(this.previewUrls=new Set),this.previewUrls.add(t),t}revokePreviewUrl(e){e?.startsWith("blob:")&&(URL.revokeObjectURL(e),this.previewUrls?.delete(e))}maybeLockUploads(e){const t=this.fields.get(e);if(!t?.ui?.dropZone)return;if("post_group"===t.config.destination)return;const s=t.uploads?.size||0,r=t.config?.maxFiles||999;t.ui.dropZone.hidden=s>=r,t.element.classList.toggle("at-max-uploads",s>=r)}createUploadElement(e,t=!1){let s=window.getTemplate("uploadItem");if(!s)return void console.error("Image template not found");s.dataset.uploadId=e.id,e.originalFile&&(s.dataset.subtype=this.getSubtypeFromMime(e.originalFile.type)),s.querySelector('[name="featured"]').value=e.id;let[r,o,i,a,l]=[s.querySelector('[name="featured"]'),s.querySelector("img"),s.querySelector("video"),s.querySelector("label > span"),s.querySelector("details")];switch([r.value,o.src,o.alt]=[e.id,e.preview,e.originalFile?.name??e.meta?.originalName??""],s.dataset.subtype){case"image":[o.src,o.alt]=[e.preview,e.originalFile?.name??e.meta?.originalName??""],i.remove(),a.remove();break;case"video":i.src=e.preview,o.remove(),a.remove();break;case"document":const t=e.originalFile?.name??e.meta?.originalName??"",s=t.split(".").pop()?.toLowerCase()??"",r={pdf:"file-pdf",csv:"file-csv",doc:"file-doc",docx:"file-doc",txt:"file-txt",xls:"file-xls",xlsx:"file-xls"},l=window.getIcon(r[s]||"file");a.innerText=e.originalFile.name,a.prepend(l),o.remove(),i.remove()}if(l){let e=window.getTemplate("uploadMeta");e&&l.append(e)}return s.draggable=t,s.querySelectorAll("input").forEach((t=>{let s=t.id;if(s){let r=s+e.id,o=t.parentNode.querySelector(`label[for="${s}"]`);t.id=r,o&&(o.htmlFor=r)}})),s}async submitUploads(e){const t=this.fields.get(e);if(!t?.uploads||0===t.uploads.size)return;let s=Array.from(t.uploads);if(0===s.length)return void this.error.log("No uploads to upload",{component:"UploadManager",action:"submitGroupedUploads",fieldId:e});const r=this.getFieldGroups(e);if(0===r.length)return void this.error.log("No groups created for post_group upload",{component:"UploadManager",action:"submitGroupedUploads",fieldId:e});const o=[],i=new FormData;let a=[];s=s.map((e=>this.uploads.get(e))),r.forEach(((e,t)=>{const r={images:[],fields:{}};for(let[t,s]of Object.entries(e.changes))r.fields[t]=s;s.filter((t=>t.groupId===e.id)).forEach((e=>{if(e){const t=e.processedFile||e.originalFile;if(t){i.append("files[]",t);const s={upload_id:e.id,index:a.length};r.images.push(s),a.push(e.id)}}})),o.push(r)})),s.filter((e=>!Object.hasOwn(e,"groupId"))).forEach((e=>{if(e){const t={images:[],fields:{}},s=e.processedFile||e.originalFile;if(s){i.append("files[]",s);const r={upload_id:e.id,index:a.length};t.images.push(r),a.push(e.id)}o.push(t)}})),i.append("content",t.config.content),i.append("user",t.config.itemID),i.append("posts",JSON.stringify(o)),i.append("upload_ids",JSON.stringify(a));const l={endpoint:"uploads/groups",method:"POST",data:i,title:`Creating ${o.length} ${t.config.content}${o.length>1?"s":""} from uploads...`,popup:`Creating ${o.length} post${o.length>1?"s":""}...`,canMerge:!1,headers:{action_nonce:jvbSettings.dash},append:"_upload"};try{const e=await this.queue.addToQueue(l);return s.forEach((t=>{let s=this.uploads.get(t);s&&(s.operationId=e,this.updateUploadStatus(t,"queued"))})),t.operationId=e,this.a11y.announce(`Creating ${o.length} post${o.length>1?"s":""} from your uploads`),e}catch(t){throw this.error.log(t,{component:"UploadManager",action:"submitGroupedUploads",fieldId:e}),t}finally{this.schedulePersistance(t.id)}}async queueUpload(e){const t=this.fields.get(e);if(!t?.uploads)return;const s=Array.from(t.uploads);if(0===s.length)return;const r=this.prepareUploadData(t,s);this.a11y.announce("Queuing for upload");let o=1===s.length?"file":"files";const i={endpoint:"uploads",method:"POST",data:r,title:`Uploading ${s.length} ${o} to server...`,popup:`Uploading ${s.length} ${o}...`,canMerge:!1,headers:{action_nonce:jvbSettings.dash},append:"_upload"};try{const e=await this.queue.addToQueue(i);return s.forEach((t=>{let s=this.uploads.get(t);s&&(s.operationId=e,this.updateUploadStatus(t,"queued"))})),t.operationId=e,e}catch(e){throw e}finally{this.schedulePersistance(t.id)}}prepareUploadData(e,t){const s=new FormData;s.append("content",e.config.content),s.append("mode",e.config.mode),s.append("field_name",e.config.name),s.append("fieldId",e.id),s.append("field_type",e.config.type),s.append("subtype",e.config.subtype),s.append("item_id",e.config.itemID),s.append("destination",e.config.destination||"meta");let r=[];const o=this.getFieldGroups(e.id);if("post_group"===e.config.destination&&o.length>0){let e=[],t=[],i=[];o.forEach((o=>{let a=[],l=null;o.uploads.forEach((e=>{let t=this.uploads.get(e);if(t){const e=t.processedFile||t.originalFile;if(e){s.append("files[]",e);r.length;r.push(t.id),a.push(t.id);const o=t.element?.querySelector('[name="featured"]');o?.checked&&(l=t.id)}}})),e.push(a),t.push(o.title||""),i.push(l)})),s.append("groups",JSON.stringify(e)),s.append("group_titles",JSON.stringify(t)),s.append("featured_images",JSON.stringify(i))}else t.forEach((e=>{let t=this.uploads.get(e);if(t){const e=t.processedFile||t.originalFile;e&&(s.append("files[]",e),r.push(t.id))}}));return s.append("upload_ids",JSON.stringify(r)),s}getFieldGroups(e){const t=[];return this.groups.forEach(((s,r)=>{if(s.fieldId===e){const o=this.fields.get(e),i=o?.ui?.groups?.groups?.get(r);t.push({id:r,uploads:Array.from(s.uploads||new Set),changes:s.changes||{},element:i||null})}})),t}async queueUploadMeta(e){const t=this.getUploadFromElement(e.target);if(!t)return;if(!this.fields.get(t.fieldId))return;if(!e.target.closest(".upload-meta"))return;let s={};s[e.target.name]=e.target.value,t.meta={...t.meta,...s};let r={};r[t.attachmentId??t.id]=t.meta;const o={endpoint:"uploads/meta",method:"POST",data:r,title:"Updating meta",canMerge:!0,headers:{action_nonce:jvbSettings.dash}};try{await this.queue.addToQueue(o)}catch(e){this.error.log(e,{component:"UploadManager",action:"sendMetaUpdate",uploadId:t.id})}}createGroup(e,t=null){const s=this.fields.get(e);if(!s)return console.error("Field not found:",e),null;t||(t=`group_${Date.now()}_${Math.random().toString(36).substr(2,9)}`);const r=this.createGroupElement(t,e);if(!r)return console.error("Failed to create group element"),null;s.ui.groups||(s.ui.groups={groups:new Map,container:null,empty:null,display:null}),s.ui.groups.groups.set(t,r),s.ui.groups.container&&s.ui.groups.empty?s.ui.groups.container.insertBefore(r,s.ui.groups.empty):s.ui.groups.container&&s.ui.groups.container.appendChild(r);const o={id:t,fieldId:e,element:r,grid:r.querySelector(".item-grid.group"),uploads:new Set,changes:{}};return this.groups.set(t,o),this.addGroupSelectionHandler(e,t),this.schedulePersistance(e),o}createGroupElement(e,t){let s=window.getTemplate("imageGroup");if(!s)return;s.dataset.groupId=e,s.dataset.fieldId=t;let r=window.getTemplate("groupMetadata");const o=s.querySelector(".fields");if(o&&r){o.append(r);const i=o.querySelector('[name="post_title"]'),a=o.querySelector('[name="post_excerpt"]');i&&(i.id=`${e}_title`,i.name=`${e}[post_title]`),a&&(a.id=`${e}_excerpt`,a.name=`${e}[post_excerpt]`);let l=this.fields.get(t);if(""!==l.config.content){s.querySelector("summary").textContent=l.config.content+" Fields"}}else s.querySelector("details").remove();const i=s.querySelector(".item-grid.group");return i&&(i.dataset.groupId=e),s}deleteGroup(e,t=!0){let s=this.groups.get(e);if(!s)return;let r=!0;t&&s.uploads&&s.uploads.size>0&&(r=!window.confirm("Delete uploads in group?")),t&&r&&s.uploads&&s.uploads.size>0&&Array.from(s.uploads).forEach((e=>{this.addImageToGroup(e,null,!1)})),this.groups.delete(e);let o=s.element;o&&(o.remove(),this.a11y.announce("Group removed")),this.schedulePersistance(s.fieldId)}addToGroup(e,t=null,s=!0){let r=this.uploads.get(e);if(!r)return;let o=this.fields.get(r.fieldId);if(!o)return;if(!t&&r.location===o.ui.preview||t===r.location)return;if(r.location){let t=r.location.dataset.groupId;if(t){let s=this.groups.get(t);s&&s.uploads&&(s.uploads.delete(e),0===s.uploads.size&&this.deleteGroup(t))}}const i=r.element.querySelector('[name*="select-item"]');i&&(i.checked=!1);let a=r.element.querySelector('[name="featured"]');if(a.hidden=!t,t){if(!t.classList.contains("item-grid")||!t.classList.contains("preview")){let s=t.dataset.groupId;a.name=s+"_"+a.name;let o=this.groups.get(s);o||(o=this.createGroup(r.fieldId),t=o.grid,s=o.id),o&&(o.uploads.add(e),r.groupId=s)}}else t=o.ui.preview,r.groupId=null;r.location=t,t.append(r.element),s&&this.schedulePersistance(o.id)}removeFromGroup(e){const t=this.uploads.get(e);if(!t)return;const s=this.fields.get(t.fieldId);if(!s)return;if(t.groupId){const s=this.groups.get(t.groupId);s?.uploads&&(s.uploads.delete(e),0===s.uploads.size&&this.deleteGroup(t.groupId,!1)),t.groupId=null}s.ui?.preview&&(s.ui.preview.appendChild(t.element),t.location=s.ui.preview);const r=t.element.querySelector('[name="featured"]');r&&(r.hidden=!0,r.checked=!1)}removeUpload(e,t){const s=this.fields.get(e),r=this.uploads.get(t);if(!s||!r)return;if(s.uploads?.delete(t),r.groupId){const e=this.groups.get(r.groupId);e&&e.uploads&&(e.uploads.delete(t),0===e.uploads.size&&this.removeGroup(r.groupId))}r.element?.remove(),this.clearUpload(t),this.updateFieldState(e),this.maybeLockUploads(e);const o=this.selectionHandlers.get(s.id);o&&o.deselect(t),this.a11y.announce("Upload removed")}schedulePersistance(e){const t=`persist_${e}`;window.debouncer.schedule(t,(()=>this.persistFieldState(e)),1e3)}async persistFieldState(e){const t=this.fields.get(e);if(!t)return;const s={...t,id:e,fieldId:e,uploads:Array.from(t.uploads||[]).map((e=>this.uploads.get(e))),groups:Array.from(this.groups.entries()).filter((([t,s])=>s.fieldId===e&&s.uploads&&s.uploads.size>0)).map((([e,t])=>({id:t.id,uploads:Array.from(t.uploads),changes:t.changes||{}}))),context:{url:this.normalizeUrl(window.location.href),fullUrl:window.location.href,modalType:this.getModalType(t),formId:t.formId,fieldSelector:`.field.upload[data-field="${t.config.name}"]`},timestamp:Date.now()};await this.fieldStore.save(s)}normalizeUrl(e){try{const t=new URL(e);return t.origin+t.pathname}catch(t){return e}}getFieldUploads(e,t=!1){const s=this.fields.get(e);return s&&s.uploads?Array.from(s.uploads).map((e=>{const s=this.uploads.get(e);return s?t?{id:s.id,fieldId:s.fieldId,status:s.status,attachmentId:s.attachmentId,operationId:s.operationId,groupId:s.groupId||null,changes:s.changes||{},meta:{originalName:s.meta?.originalName||s.originalFile?.name,size:s.meta?.size||s.originalFile?.size,type:s.meta?.type||s.originalFile?.type,title:s.meta?.title,alt:s.meta?.alt,caption:s.meta?.caption}}:s:null})).filter(Boolean):[]}async checkForStoredUploads(){if(!this.db)return;const e=this.db.transaction(["fieldStates"],"readonly").objectStore("fieldStates"),t=(await new Promise((t=>{const s=e.getAll();s.onsuccess=()=>t(s.result)}))).filter((e=>e.uploads.some((e=>!e.operationId&&("completed"===e.status||"processed"===e.status||"local_processing"===e.status||"processed-original"===e.status)))));0!==t.length&&this.showRecoveryNotification(t)}async handleRestoreUploads(){let e=document.querySelector("dialog.restore-uploads");if(!e)return;const t=this.getSelectedRestorationUploads(e);0!==t.length&&(await this.restoreSelectedUploads(t),this.cleanupRestore())}getSelectedRestorationUploads(e){let t=[];return e.querySelectorAll("[type=checkbox]:checked").forEach((e=>{const s=e.closest(".item");s&&t.push({uploadId:s.dataset.uploadId,fieldId:s.dataset.fieldId})})),t}handleGroupMetaChange(e){let t=this.getGroupFromElement(e);if(!t)return;Object.hasOwn(t,"changes")||(t.changes={});let s=e.name;if(s.includes("group")){let e=t.id+"_",r=t.id+"[";s=s.replace(e,"").replace(r,"").replace("]","")}t.changes[`${s}`]=e.value,this.groups.set(t.id,t),this.schedulePersistance(t.fieldId)}async showRecoveryNotification(e){const t=e.reduce(((e,t)=>e+t.uploads.length),0),s=e.reduce(((e,t)=>e+(t.groups?.length||0)),0);let r,o=window.getTemplate("restoreNotification");if(!o)return void console.error("Restore notification template not found");if(s>0){r=`${s} ${s>1?"groups":"group"} with ${t} ${t>1?"uploads":"upload"} can be restored.`}else r=`${t} upload(s) from ${e.length} field(s) can be recovered.`;const i=o.querySelector(".restore-details");i&&(i.textContent=r);for(const t of e){let e=window.getTemplate("restoreField");if(!e)continue;const s=e.querySelector("h3");s&&(s.textContent=t.config.name||"Unnamed Field");const r=e.querySelector(".item-grid.restore");for(const e of t.uploads){let s=window.getTemplate("uploadItem");if(!s)continue;const o=await this.uploadStore.getBlob(e.id);if(o)try{const r=new Blob([o.data],{type:o.type}),i=this.createPreviewUrl(r);let[a,l,n,d,c]=[s.querySelector('[name="featured"]'),s.querySelector("img"),s.querySelector("video"),s.querySelector("label > span"),s.querySelector("details")];s.dataset.uploadId=e.id,s.dataset.fieldId=t.id;let u=this.getSubtypeFromMime(o.type);switch(s.dataset.subtype=u,u){case"image":[l.src,l.alt]=[i,e.originalFile?.name??e.meta?.originalName??""],n.remove(),d.remove();break;case"video":n.src=i,l.remove(),d.remove();break;case"document":let t;switch(""){case"pdf":t=window.getIcon("file-pdf");break;case"csv":t=window.getIcon("file-csv");break;case"doc":t=window.getIcon("file-doc");break;case"txt":t=window.getIcon("file-txt");break;case"xls":t=window.getIcon("file-xls");break;default:t=window.getIcon("file")}d.innerText=e.originalFile.name,d.prepend(t),l.remove(),n.remove()}s.dataset.previewUrl=i}catch(t){console.warn("Failed to create preview for upload:",e.id,t)}const i=s.querySelector("summary span");i&&(i.textContent=e.meta?.originalName||"Unknown file");const a=s.querySelector("details");a&&e.meta&&(a.textContent=`${this.formatBytes(e.meta.size)} • ${e.meta.type}`),s.querySelectorAll("input").forEach((t=>{let s=t.id;if(s){let r=s+e.id,o=t.parentNode.querySelector(`label[for="${s}"]`);t.id=r,o&&(o.htmlFor=r)}})),r&&r.appendChild(s)}o.querySelector(".wrap").appendChild(r)}document.querySelector(".field.upload").appendChild(o),o=document.querySelector("dialog.restore-uploads"),this.restoreModal=new window.jvbModal(o),this.restoreSelection=new window.jvbHandleSelection({container:o,ui:{selectAll:o.querySelector("#select-all-restore"),count:o.querySelector(".selection-count")}}),this.restoreModal.handleOpen()}async restoreSelectedUploads(e){const t=new Map;if(e.forEach((e=>{t.has(e.fieldId)||t.set(e.fieldId,[]),t.get(e.fieldId).push(e.uploadId)})),!this.db)return;const s=this.db.transaction(["fieldStates"],"readonly").objectStore("fieldStates");for(const[e,r]of t.entries()){const t=s.get(e),o=await new Promise((e=>{t.onsuccess=()=>e(t.result),t.onerror=()=>e(null)}));o&&(o.uploads=o.uploads.filter((e=>r.includes(e.id))),await this.restoreField(o))}}async restoreField(e){const{config:t,context:s,uploads:r,groups:o,id:i}=e;s.modalType&&await this.openModalForRestore(s);let a=document.querySelector(`.field.upload[data-field="${t.name}"]`);if(!a){const e=`${t.content}_${t.itemID}_${t.name}`;a=document.querySelector(`.field.upload[data-uploader="${e}"]`)}if(!a)return void console.warn(`Field ${t.name} not found for restoration`,t);let l=a.dataset.uploader;l&&this.fields.has(l)||(l=this.registerUploader(a,t));const n=this.fields.get(l);if(n){n.state=e.state||"ready",n.ui=this.buildFieldUI(a),n.ui.groups?.display&&(n.ui.groups.display.hidden=!1),o&&o.length>0&&await this.restoreGroups(l,o);for(const e of r)await this.restoreUpload(n,e);this.updateFieldState(l),this.maybeLockUploads(l),"direct"===t.mode&&"post_group"!==t.destination&&await this.queueUpload(l)}else console.error("Failed to register field for restoration")}async restoreUpload(e,t){const s=await this.uploadStore.getBlob(t.id);if(!s)return void console.warn("Blob data not found for upload:",t.id);{const e=s.data instanceof File?s.data:new File([s.data],s.name,{type:s.type,lastModified:s.lastModified});t.originalFile=e,t.processedFile=e,t.preview=this.createPreviewUrl(e)}e.uploads||(e.uploads=new Set),e.uploads.add(t.id);const r=this.getSubtypeFromMime(t.originalFile.type);let o;if(t.element=this.createUploadElement({...t,subtype:r},"post_group"===e.config.destination),o=t.groupId&&e.ui.groups.groups.has(t.groupId)?e.ui.groups.groups.get(t.groupId).querySelector(".item-grid"):e.ui.preview,o&&(o.appendChild(t.element),t.location=o),this.uploads.set(t.id,t),t.groupId){const e=this.groups.get(t.groupId);e&&e.uploads&&e.uploads.add(t.id)}}async restoreGroups(e,t){for(const s of t){const t=this.createGroup(e,s.id);if(t&&(s.meta&&(t.meta={...s.meta}),s.changes&&(t.changes={...s.changes}),s.title)){const e=t.element.querySelector('[name*="post_title"]');e&&(e.value=s.title)}}}async openModalForRestore(e){const{modalType:t,formId:s}=e;let r=null;switch(t){case"create":r=document.querySelector('[data-action="create"]');break;case"edit":r=document.querySelector(`[data-action="edit"][data-id="${e.itemId}"]`);break;case"bulkEdit":r=document.querySelector('[data-action="bulk-edit"]')}r&&(r.click(),await new Promise((e=>setTimeout(e,300))))}handleFieldStoreEvent(e,t){switch(e){case"data-loaded":break;case"item-saved":console.log(`Field state saved: ${t.key}`)}}handleUploadStoreEvent(e,t){switch(e){case"data-loaded":this.checkForStoredUploads();break;case"item-saved":this.showSaveIndicator(t.key)}}async saveUpload(e){if(e.file instanceof File||e.file instanceof Blob){await this.uploadStore.saveBlob(e.id,e.file);const{file:t,originalFile:s,...r}=e;await this.uploadStore.save(r)}else await this.uploadStore.save(e)}async loadFields(){(await this.fieldStore.getAll()).forEach((e=>{e.uploads&&Array.isArray(e.uploads)&&(e.uploads=new Set(e.uploads.map((e=>e.id)))),this.fields.set(e.fieldId,e)}))}async loadUploads(){(await this.uploadStore.getAll()).forEach((e=>{this.uploads.set(e.id,e)}))}subscribe(e){return this.subscribers.add(e),()=>this.subscribers.delete(e)}notify(e,t){this.subscribers.forEach((s=>s(e,t)))}destroy(){document.removeEventListener("click",this.clickHandler),document.removeEventListener("change",this.changeHandler),document.removeEventListener("dragenter",this.dragEnterHandler),document.removeEventListener("dragleave",this.dragLeaveHandler),document.removeEventListener("dragover",this.dragOverHandler),document.removeEventListener("drop",this.dropHandler),this.dragController&&this.dragController.destroy(),this.selectionHandlers.forEach((e=>e.destroy())),this.selectionHandlers.clear(),this.cleanupAllPreviewUrls(),this.fields.clear(),this.uploads.clear(),this.groups.clear(),this.selected.clear(),this.subscribers.clear()}cleanupRestore(){this.restoreModal.handleClose(),this.restoreSelection.destroy(),this.restoreSelection=null,this.restoreModal.destroy(),this.restoreModal.modal.remove(),this.restoreModal=null}async cleanupStoredUploads(){this.fieldStore.clear(),this.uploadStore.clear()}async clearField(e){await this.fieldStore.delete(e);const t=this.fields.get(e);if(t?.uploads)for(const e of t.uploads)await this.uploadStore.delete(e);this.fields.delete(e)}async clearUpload(e,t=!0){const s=this.uploads.get(e);if(s){if(this.revokePreviewUrl(s.preview),s.element){const e=s.element.dataset.previewUrl;this.revokePreviewUrl(e),delete s.element.dataset.previewUrl}t&&await this.schedulePersistance(s.fieldId),this.uploads.delete(e),this.uploadStore.delete(e),this.uploadStore.delete(e,"blobs")}}cleanupAllPreviewUrls(){this.previewUrls&&(this.previewUrls.forEach((e=>{try{URL.revokeObjectURL(e)}catch(e){}})),this.previewUrls.clear())}}document.addEventListener("DOMContentLoaded",(()=>{window.jvbUploads=new e}))})();
\ No newline at end of file
+(()=>{class e{constructor(){this.queue=window.jvbQueue,this.a11y=window.jvbA11y,this.error=window.jvbError,this.fieldStore=new window.jvbStore({name:"upload_fields",storeName:"fieldStates",keyPath:"id",version:2,indexes:[{name:"fieldId",keyPath:"fieldId"},{name:"timestamp",keyPath:"timestamp"},{name:"content",keyPath:"content"},{name:"itemId",keyPath:"itemId"},{name:"status",keyPath:"status"}],stripDOMReferences:!0,TTL:6048e5}),this.uploadStore=new window.jvbStore({name:"uploads",storeName:"uploads",keyPath:"id",storeBlobs:!0,indexes:[{name:"fieldId",keyPath:"fieldId"},{name:"status",keyPath:"status"},{name:"groupId",keyPath:"groupId"},{name:"attachmentId",keyPath:"attachmentId"}]}),window.jvbUploadBlobs=this.uploadStore,this.fieldStore.subscribe(this.handleFieldStoreEvent.bind(this)),this.uploadStore.subscribe(this.handleUploadStoreEvent.bind(this)),this.initWorker(),this.fields=new Map,this.uploads=new Map,this.groups=new Map,this.selected=new Map,this.selectionHandlers=new Map,this.previewUrls=new Set,this.subscribers=new Set,this.dragController=null,this.selectors={field:{field:"[data-upload-field]",input:'input[type="file"]',hiddenValue:'input[type="hidden"]',dropZone:".file-upload-container",preview:".item-grid.preview",progress:".image-progress"},groups:{container:".upload-group",grid:".item-grid.group",header:".group-header",selectAll:'[name="select-all-group"]',actions:".group-actions",count:".selection-controls .info"},items:{item:"[data-upload-id]",checkbox:'[name*="select-item"]',featured:'[name="featured"]',details:"details"}},this.statusMapping={received:"Image Received",local_processing:"Processing Image...",queued:"Waiting to upload...",uploading:"Uploading to Server",pending:"Successfully sent to server. In line for further processing.",processing:"Processing on server...",completed:"Upload complete!",failed:"Upload failed (will retry)",failed_permanent:"Upload failed permanently"},this.init()}async init(){await this.loadFields(),await this.loadUploads(),this.initializeFields(),this.initListeners(),this.queue.subscribe(((e,t)=>{if("uploads"!==t.endpoint&&"uploads/meta"!==t.endpoint)return;const s=t.data instanceof FormData?t.data.get("fieldId"):t.data.fieldId;switch(e){case"cancel-operation":s&&this.clearField(s);break;case"operation-status":s&&this.updateFieldStatus(s,t.status);break;case"operation-complete":(t.result?.data||[]).forEach((e=>{const t=this.uploads.get(e.upload_id);t&&(t.attachmentId=e.attachment_id,t.status="completed",this.uploads.set(t.id,t))})),s&&this.cleanField(s)}})),window.addEventListener("beforeunload",(()=>{this.cleanupAllPreviewUrls()}))}initWorker(){this.worker={worker:null,timeout:null,tasks:new Map,restart:{count:0,max:3},settings:{timeout:1e4,batchSize:1,maxConcurrent:3,restartAfterTimeout:!0}}}initializeFields(){document.querySelectorAll(this.selectors.field.field).forEach((e=>{this.registerUploader(e)}))}scanFields(e){e.querySelectorAll(this.selectors.field.field).forEach((e=>{this.registerUploader(e)}))}registerUploader(e){const t=this.determineFieldId(e),s=this.extractFieldConfig(e),r={id:t,config:s,element:e,ui:this.buildFieldUI(e),uploads:new Set,groups:new Set,state:"ready"};return this.fields.set(t,r),e.dataset.uploader=t,this.addFieldSelectionHandler(t),"post_group"!==s.destination||this.dragController||this.initGroupFeatures(),t}extractFieldConfig(e){return{destination:e.dataset.destination||"meta",content:e.dataset.content||null,mode:e.dataset.mode||"direct",type:e.dataset.type||"single",name:e.dataset.field,itemID:e.dataset.itemId||0,maxFiles:parseInt(e.dataset.maxFiles)||999,subtype:e.dataset.subtype||"image"}}buildFieldUI(e){let t={field:e,input:e.querySelector(this.selectors.field.input),dropZone:e.querySelector(this.selectors.field.dropZone),preview:e.querySelector(this.selectors.field.preview),progress:{progress:e.querySelector(this.selectors.field.progress),bar:e.querySelector(".bar"),fill:e.querySelector(".fill"),details:e.querySelector(".details"),text:e.querySelector(".details .text"),count:e.querySelector(".details .count")}},s=e.querySelector(".group-display");return s&&(t.groups={display:s,container:e.querySelector(".item-grid.groups"),empty:e.querySelector(".empty-group"),groups:new Map}),t}initListeners(){this.clickHandler=this.handleClick.bind(this),this.changeHandler=this.handleChange.bind(this),document.addEventListener("click",this.clickHandler),document.addEventListener("change",this.changeHandler),this.dragEnterHandler=this.handleExternalDragEnter.bind(this),this.dragLeaveHandler=this.handleExternalDragLeave.bind(this),this.dragOverHandler=this.handleExternalDragOver.bind(this),this.dropHandler=this.handleExternalDrop.bind(this),document.addEventListener("dragenter",this.dragEnterHandler),document.addEventListener("dragleave",this.dragLeaveHandler),document.addEventListener("dragover",this.dragOverHandler),document.addEventListener("drop",this.dropHandler)}initGroupFeatures(){this.dragController=new window.jvbDragHandler({draggableSelector:this.selectors.items.item,dropTargetSelector:`${this.selectors.field.preview}, ${this.selectors.groups.grid}, .empty-group`,ignoreSelector:"input:not(.upload-select), button, select, textarea, details, summary, a",previewElement:"img, video, .icon",getItemId:e=>e.dataset.uploadId,getSelectedItems:e=>{const t=this.getFieldIdFromElement(e),s=e.dataset.uploadId,r=this.getCurrentSelection(t);return r&&r.includes(s)?r:[s]},validateDrop:(e,t)=>{const s=this.getFieldIdFromElement(t),r=document.querySelector(`[data-upload-id="${e[0]}"]`);return s===this.getFieldIdFromElement(r)},onDrop:(e,t)=>{this.handleItemDrop(e,t),t.scrollIntoView({behavior:"smooth",block:"center"})},onDragStart:e=>{},onDragEnd:(e,t)=>{if(t){const t=document.querySelector(`[data-upload-id="${e[0]}"]`),s=this.getFieldIdFromElement(t),r=this.selectionHandlers.get(s);r?.clearSelection()}},previewOptions:{multiOffset:{x:-60,y:-80},singleOffset:{x:-50,y:-60},showCount:!0}})}handleExternalDragLeave(e){const t=e.target.closest(this.selectors.field.dropZone);t&&!t.contains(e.relatedTarget)&&t.classList.remove("dragover")}handleExternalDragEnter(e){if(!e.dataTransfer.types.includes("Files"))return;const t=e.target.closest(this.selectors.field.dropZone);t&&(e.preventDefault(),t.classList.add("dragover"))}handleExternalDragOver(e){if(!e.dataTransfer.types.includes("Files"))return;e.target.closest(this.selectors.field.dropZone)&&(e.preventDefault(),e.dataTransfer.dropEffect="copy")}handleExternalDrop(e){const t=e.target.closest(this.selectors.field.dropZone);if(!t)return;e.preventDefault(),t.classList.remove("dragover");const s=Array.from(e.dataTransfer.files);if(0===s.length)return;const r=this.getFieldIdFromElement(t);r?(this.processFiles(r,s),this.a11y.announce(`${s.length} file(s) dropped for upload`)):console.error("No field ID found for drop zone")}handleItemDrop(e,t){const s=t.classList.contains("preview");let r=t;if(t.classList.contains("empty-group")){const e=this.getFieldIdFromElement(t),s=this.createGroup(e);if(!s)return void console.error("Failed to create group");r=s.grid}e.forEach((e=>{s?this.removeFromGroup(e):this.addToGroup(e,r)}));const o=this.getFieldIdFromElement(t);this.schedulePersistance(o);const i=e.length>1?`Moved ${e.length} items`:"Moved item";this.a11y.announce(i)}handleClick(e){if(e.target.matches(this.selectors.field.dropZone)||e.target.closest(this.selectors.field.dropZone)){const t=e.target.closest(this.selectors.field.dropZone);if(t&&!e.target.matches("input, button, a")){const e=t.querySelector(this.selectors.field.input);e?.click()}}const t=e.target.closest("[data-action]");t&&this.handleAction(t)}handleChange(e){const t=this.getFieldIdFromElement(e.target);if(e.target.matches(this.selectors.field.input)){const t=this.getFieldIdFromElement(e.target),s=Array.from(e.target.files);s.length>0&&t&&this.processFiles(t,s)}t&&("post_group"===this.fields.get(t).config.destination?this.handleGroupMetaChange(e.target):this.queueUploadMeta(e))}getCurrentSelection(e){let t=[];for(let[s,r]of this.selectionHandlers)(e===s||s.includes(e))&&r.selectedItems.size>0&&(t=t.concat([...r.selectedItems]));return t}getSubtypeFromMime(e){return e.startsWith("image/")?"image":e.startsWith("video/")?"video":"document"}getStatusText(e){return this.statusMapping[e]||e}getStatusIcon(e){return window.getIcon(this.queue.icons[e])}getStatusProgress(e){switch(e){case"local_processing":return 28;case"queued":return 50;case"uploading":return 66;case"pending":return 75;case"processing":return 89;case"completed":return 100;default:return 0}}getModalType(e){if(void 0!==e._cachedModalType)return e._cachedModalType;if(!e||!e.element)return e._cachedModalType=null,null;const t=e.element.closest("dialog");if(!t)return e._cachedModalType=null,null;let s=null;return s=t.classList.contains("edit")?"edit":t.classList.contains("create")?"create":t.classList.contains("bulkEdit")?"bulkEdit":t.className,e._cachedModalType=s,s}handleAction(e){const t=e.dataset.action,s=this.getFieldIdFromElement(e);switch(t){case"add-to-group":this.handleAddToGroup(e);break;case"delete-group":this.handleDeleteGroup(e);break;case"delete-upload":case"remove-from-group":this.handleRemoveItem(e);break;case"upload":this.fields.get(s).element.closest("details").open=!1,document.body.classList.add("uploading"),this.submitUploads(s);break;case"restore":this.handleRestoreUploads().then((()=>{}));break;case"clear-cache":confirm("Save these uploads for later?")||this.cleanupStoredUploads(),this.cleanupRestore()}}handleAddToGroup(e){const t=e.closest(this.selectors.field.field),s=t?.dataset.uploader;if(!s)return;const r=this.selected.get(s);if(r&&0!==r.size){const e=this.createGroup(s);if(!e)return;r.forEach((t=>{this.addToGroup(t,e.grid)}));const t=this.selectionHandlers.get(s);t?.clearSelection(),this.a11y.announce(`Created group with ${r.size} items`)}else this.createGroup(s);this.schedulePersistance(s)}handleDeleteGroup(e){const t=e.closest(this.selectors.groups.container);if(!t)return;const s=t.dataset.groupId,r=this.getFieldIdFromElement(t);if(!confirm("Delete this group? Items will be moved back to the upload area."))return;t.querySelectorAll(this.selectors.items.item).forEach((e=>{const t=e.dataset.uploadId;this.removeFromGroup(t)})),this.deleteGroup(s),this.a11y.announce("Group deleted, items returned to upload area"),this.schedulePersistance(r)}handleRemoveItem(e){const t=e.closest(this.selectors.items.item);if(!t)return;const s=t.dataset.uploadId,r=this.getFieldIdFromElement(t);confirm("Remove this item?")&&(this.removeUpload(r,s),this.a11y.announce("Item removed"),this.schedulePersistance(r))}addFieldSelectionHandler(e){if(this.selectionHandlers.has(e))return this.selectionHandlers.get(e);const t=this.fields.get(e);if(!t)return;const s=t.ui.field;if(!s)return;const r=new window.jvbHandleSelection({container:s,ui:{selectAll:s.querySelector('[name="select-all-uploads"]'),bulkControls:s.querySelector(".selection-actions"),count:s.querySelector(".selection-count")},itemSelector:"[data-upload-id]",checkboxSelector:'[name*="select-item"]'});return r.subscribe(((t,s)=>{switch(t){case"item-selected":case"item-deselected":case"range-selected":this.selected.set(e,s.selectedItems);break;case"select-all":this.handleSelectAll(s.container,s.selected)}})),this.selectionHandlers.set(e,r),r}addGroupSelectionHandler(e,t){const s=`${e}_${t}`;if(this.selectionHandlers.has(s))return this.selectionHandlers.get(s);const r=this.groups.get(t);if(!r)return;const o=new window.jvbHandleSelection({container:r.element,ui:{selectAll:r.element.querySelector(this.selectors.groups.selectAll),bulkControls:r.element.querySelector(this.selectors.groups.actions),count:r.element.querySelector(this.selectors.groups.count)},itemSelector:"[data-upload-id]",checkboxSelector:'[name*="select-item"]'});return o.subscribe(((t,s)=>{switch(t){case"item-selected":case"item-deselected":case"range-selected":this.selected.set(e,s.selectedItems);break;case"select-all":this.handleSelectAll(s.container,s.selected)}})),this.selectionHandlers.set(s,o),o}handleSelectAll(e,t){}determineFieldId(e){return`${e.dataset.content||e.closest("dialog")?.dataset.content||e.closest("form")?.dataset.save||""}_${e.dataset.itemId||e.closest("dialog")?.dataset.itemId||""}_${e.dataset.field||""}`}getFromElement(e,t){const s={field:{selector:this.selectors.field.field,key:"uploader",store:this.fields},upload:{selector:this.selectors.items.item,key:"uploadId",store:this.uploads},group:{selector:this.selectors.groups.container,key:"groupId",store:this.groups}}[t];if(!s)return null;const r=e.closest(s.selector);if(!r)return null;const o=r.dataset[s.key];return s.store.get(o)}getFieldFromElement(e){return this.getFromElement(e,"field")}getUploadFromElement(e){return this.getFromElement(e,"upload")}getGroupFromElement(e){return this.getFromElement(e,"group")}getFieldIdFromElement(e){return this.getFromElement(e,"field")?.id??null}getUploadIdFromElement(e){return this.getFromElement(e,"upload")?.id??null}getGroupIdFromElement(e){return this.getFromElement(e,"group")?.id??null}async processFiles(e,t){const s=this.fields.get(e);if(!s)return;s.ui.dropZone&&(s.ui.dropZone.hidden=!0),s.ui.groups.display&&(s.ui.groups.display.hidden=!1);const r=t.length;let o=0;this.updateUploadProgress(e,0,r,"Processing files..."),s.uploads||(s.uploads=new Set);const i=Array.from(t).map((async(t,i)=>{try{const i=`upload_${Date.now()}_${Math.random().toString(36).substr(2,9)}`,a={id:i,attachment_id:null,fieldId:e,originalFile:t,processedFile:null,preview:null,status:"local_processing",element:null,location:null,meta:{originalName:t.name,size:t.size,type:t.type}};a.preview=this.createPreviewUrl(t),t.type.startsWith("image/")?a.processedFile=await this.processImage(t,s.subtype):a.processedFile=t,await this.uploadStore.saveBlob(i,a.processedFile||t);const l=this.getSubtypeFromMime(t.type);return a.element=this.createUploadElement({...a,subtype:l},"post_group"===s.config.destination),this.showUploadProgress(i,!0),this.updateUploadItemProgress(i,50,"local_processing"),s.ui.preview&&(s.ui.preview.appendChild(a.element),a.location=s.ui.preview),this.uploads.set(i,a),s.uploads.add(i),o++,this.updateUploadProgress(e,o,r,"Processing files..."),this.updateUploadItemProgress(i,100,"processed"),a.status="processed",setTimeout((()=>{this.showUploadProgress(i,!1)}),1e3),i}catch(s){return console.error("Error processing file:",t.name,s),o++,this.updateUploadProgress(e,o,r,"Processing files..."),null}}));await Promise.all(i),this.updateFieldState(e),await this.schedulePersistance(e),"post_group"!==s.config.destination&&(await this.queueUpload(e),this.maybeLockUploads(e))}updateFieldState(e){const t=this.fields.get(e);if(!t||!t.ui.field)return;const s=t.ui.field,r=t.uploads?.size||0,o=t.ui.groups?.container?.querySelectorAll(".upload-group").length>0;s.dataset.hasUploads=r>0?"true":"false",s.dataset.uploadCount=r.toString(),s.dataset.hasGroups=o?"true":"false",t.ui.preview&&t.ui.preview.setAttribute("aria-label",`Upload preview area with ${r} item${1!==r?"s":""}`)}updateUploadProgress(e,t,s,r){const o=this.fields.get(e);if(!o?.ui?.progress?.progress)return;const i=o.ui.progress,a=s>0?t/s*100:0;i.fill&&(i.fill.style.width=`${a}%`),i.text&&(i.text.textContent=r),i.count&&(i.count.textContent=`${t}/${s}`),i.progress.hidden=t===s}updateFieldStatus(e,t){const s=this.fields.get(e);s&&(s.state=t)}updateUploadStatus(e,t){const s=this.uploads.get(e);s&&(s.status=t,this.updateUploadUI(e))}updateUploadUI(e){const t=this.uploads.get(e);if(!t?.element)return;t.element.className=t.element.className.replace(/status-[\w-]+/g,""),t.element.classList.add(`status-${t.status}`);t.element.querySelector(".progress")&&this.updateUploadItemProgress(e,this.getStatusProgress(t.status),t.status)}showUploadProgress(e,t=!0){const s=this.uploads.get(e);if(!s||!s.element)return;const r=s.element.querySelector(".progress");r&&(t?(r.style.removeProperty("animation"),r.hidden=!1):(r.style.animation="fadeOut var(--transition-base)",setTimeout((()=>{r.hidden=!0}),300)))}updateUploadItemProgress(e,t,s=null){const r=this.uploads.get(e);if(!r||!r.element)return;const o=r.element.querySelector(".progress");if(!o)return;const i=o.querySelector(".fill"),a=o.querySelector(".details"),l=o.querySelector(".icon");i&&(i.style.width=`${t}%`),s&&a&&(a.textContent=this.getStatusText(s)),s&&l&&(l.innerHTML=this.getStatusIcon(s).outerHTML)}checkFieldLimits(e,t){const s=this.fields.get(e);if(!s)return!1;return(s.uploads?.size||0)+t<=s.maxFiles}validateFile(e,t){return this.settings.allowedTypes.includes(e.type)?!(e.size>this.settings.maxFileSize)||(this.notify(`File too large: ${this.formatBytes(e.size)}`,"error"),!1):(this.notify(`Invalid file type: ${e.type}`,"error"),!1)}formatBytes(e,t=2){if(0===e)return"0 Bytes";const s=t<0?0:t,r=Math.floor(Math.log(e)/Math.log(1024));return parseFloat((e/Math.pow(1024,r)).toFixed(s))+" "+["Bytes","KB","MB","GB"][r]}shouldProcessClientSide(e,t){return!("image"!==t||!e.type.startsWith("image/"))}async processImage(e,t){const s=this.worker.settings.timeout;return new Promise(((r,o)=>{let i,a=!1;i=setTimeout((()=>{a||(a=!0,this.worker.tasks.delete(t),this.worker.settings.restartAfterTimeout&&this.restartCompressionWorker(),o(new Error(`Processing timeout for ${e.name}`)))}),s),this.worker.tasks.set(t,{file:e,timeoutId:i}),this.handleProcess(e,t).then((e=>{a||(a=!0,clearTimeout(i),this.worker.tasks.delete(t),r(e))})).catch((e=>{a||(a=!0,clearTimeout(i),this.worker.tasks.delete(t),o(e))}))}))}async handleProcess(e,t){if(!e.type.startsWith("image/"))return e;const s=this.getMaxDimension();if(this.shouldUseWorker(e))try{if(this.worker.worker||this.initCompressionWorker(),this.worker.worker)return await this.processWithWorker(e,t,s,.85)}catch(e){console.warn("Worker processing failed, falling back to main thread:",e)}return await this.processOnMainThread(e,s,.85)}async processOnMainThread(e,t,s){return new Promise(((r,o)=>{const i=new Image,a=document.createElement("canvas"),l=a.getContext("2d");let n=null;const d=()=>{i.onload=null,i.onerror=null,n&&(URL.revokeObjectURL(n),n=null),a.width=1,a.height=1,l.clearRect(0,0,1,1)};i.onload=()=>{try{const{width:n,height:c}=this.calculateOptimalDimensions(i,t);a.width=n,a.height=c,l.imageSmoothingEnabled=!0,l.imageSmoothingQuality="high",l.drawImage(i,0,0,n,c);const u=this.getOptimalFormat(e),p=this.getOptimalQuality(e,s);a.toBlob((t=>{if(d(),t){const s=new File([t],this.getProcessedFileName(e,u),{type:u,lastModified:Date.now()});r(s)}else o(new Error("Canvas toBlob failed"))}),u,p)}catch(e){d(),o(new Error(`Canvas processing failed: ${e.message}`))}},i.onerror=()=>{d(),o(new Error(`Failed to load image: ${e.name}`))};try{n=this.createPreviewUrl(e),i.src=n}catch(e){d(),o(new Error(`Failed to create object URL: ${e.message}`))}}))}getOptimalFormat(e){return"image/gif"===e.type||"image/svg+xml"===e.type?e.type:this.supportsWebP()?"image/webp":"image/jpeg"}getOptimalQuality(e,t){return e.size<512e3?Math.max(t,.9):e.size<2097152?t:Math.min(t,.8)}getProcessedFileName(e,t){return e.name.replace(/\.[^/.]+$/,"")+({"image/webp":".webp","image/jpeg":".jpg","image/png":".png","image/gif":".gif"}[t]||".jpg")}getMaxDimension(){const e=window.screen.width,t=window.devicePixelRatio||1;return e*t>2560?2400:e*t>1920?1920:1200}shouldUseWorker(e){return this.worker.worker&&e.size>1048576&&"undefined"!=typeof OffscreenCanvas}async processWithWorker(e,t,s,r){return new Promise(((o,i)=>{if(!this.worker.worker)return void i(new Error("Worker not available"));const a=`${t}_${Date.now()}`,l=t=>{if(t.data.messageId===a)if(this.worker.worker.removeEventListener("message",l),this.worker.worker.removeEventListener("error",n),t.data.success){const s=new File([t.data.blob],this.getProcessedFileName(e,t.data.format||"image/webp"),{type:t.data.format||"image/webp",lastModified:Date.now()});o(s)}else i(new Error(t.data.error||"Worker processing failed"))},n=e=>{this.worker.worker.removeEventListener("message",l),this.worker.worker.removeEventListener("error",n),i(new Error(`Worker error: ${e.message}`))};this.worker.worker.addEventListener("message",l),this.worker.worker.addEventListener("error",n),this.worker.worker.postMessage({messageId:a,file:e,maxDimension:s,quality:r,outputFormat:this.getOptimalFormat(e)})}))}restartCompressionWorker(){this.worker.worker&&(this.worker.worker.terminate(),this.worker.worker=null),this.worker.tasks.clear(),this.worker.restart.count>=this.worker.restart.max?console.error("Max worker restarts reached, disabling worker"):(this.worker.restart.count++,this.initCompressionWorker())}initCompressionWorker(){if(!this.worker.worker&&"undefined"!=typeof Worker)try{const e=new Blob(["\n self.onmessage = async function(e) {\n const { messageId, file, maxDimension, quality, outputFormat } = e.data;\n\n try {\n // Create ImageBitmap from file\n const bitmap = await createImageBitmap(file);\n\n // Calculate dimensions\n const scale = Math.min(maxDimension / bitmap.width, maxDimension / bitmap.height, 1);\n const width = Math.round(bitmap.width * scale);\n const height = Math.round(bitmap.height * scale);\n\n // Create OffscreenCanvas\n const canvas = new OffscreenCanvas(width, height);\n const ctx = canvas.getContext('2d');\n\n // Draw and resize\n ctx.imageSmoothingEnabled = true;\n ctx.imageSmoothingQuality = 'high';\n ctx.drawImage(bitmap, 0, 0, width, height);\n\n // Clean up bitmap\n bitmap.close();\n\n // Convert to blob\n const blob = await canvas.convertToBlob({\n type: outputFormat,\n quality: quality\n });\n\n self.postMessage({\n messageId,\n success: true,\n blob: blob,\n format: outputFormat\n });\n\n } catch (error) {\n self.postMessage({\n messageId,\n success: false,\n error: error.message\n });\n }\n };\n "],{type:"application/javascript"});this.worker.worker=new Worker(this.createPreviewUrl(e))}catch(e){console.warn("Failed to initialize compression worker:",e),this.worker.worker=null}}calculateOptimalDimensions(e,t){let{width:s,height:r}=e;if(s<=t&&r<=t)return{width:s,height:r};const o=Math.min(t/s,t/r);return{width:Math.round(s*o),height:Math.round(r*o)}}supportsWebP(){return 0===document.createElement("canvas").toDataURL("image/webp").indexOf("data:image/webp")}createPreviewUrl(e){const t=URL.createObjectURL(e);return this.previewUrls||(this.previewUrls=new Set),this.previewUrls.add(t),t}revokePreviewUrl(e){e?.startsWith("blob:")&&(URL.revokeObjectURL(e),this.previewUrls?.delete(e))}maybeLockUploads(e){const t=this.fields.get(e);if(!t?.ui?.dropZone)return;if("post_group"===t.config.destination)return;const s=t.uploads?.size||0,r=t.config?.maxFiles||999;t.ui.dropZone.hidden=s>=r,t.element.classList.toggle("at-max-uploads",s>=r)}createUploadElement(e,t=!1){let s=window.getTemplate("uploadItem");if(!s)return void console.error("Image template not found");s.dataset.uploadId=e.id,e.originalFile&&(s.dataset.subtype=this.getSubtypeFromMime(e.originalFile.type)),s.querySelector('[name="featured"]').value=e.id;let[r,o,i,a,l]=[s.querySelector('[name="featured"]'),s.querySelector("img"),s.querySelector("video"),s.querySelector("label > span"),s.querySelector("details")];switch([r.value,o.src,o.alt]=[e.id,e.preview,e.originalFile?.name??e.meta?.originalName??""],s.dataset.subtype){case"image":[o.src,o.alt]=[e.preview,e.originalFile?.name??e.meta?.originalName??""],i.remove(),a.remove();break;case"video":i.src=e.preview,o.remove(),a.remove();break;case"document":const t=e.originalFile?.name??e.meta?.originalName??"",s=t.split(".").pop()?.toLowerCase()??"",r={pdf:"file-pdf",csv:"file-csv",doc:"file-doc",docx:"file-doc",txt:"file-txt",xls:"file-xls",xlsx:"file-xls"},l=window.getIcon(r[s]||"file");a.innerText=e.originalFile.name,a.prepend(l),o.remove(),i.remove()}if(l){let e=window.getTemplate("uploadMeta");e&&l.append(e)}return s.draggable=t,s.querySelectorAll("input").forEach((t=>{let s=t.id;if(s){let r=s+e.id,o=t.parentNode.querySelector(`label[for="${s}"]`);t.id=r,o&&(o.htmlFor=r)}})),s}async submitUploads(e){const t=this.fields.get(e);if(!t?.uploads||0===t.uploads.size)return;let s=Array.from(t.uploads);if(0===s.length)return void this.error.log("No uploads to upload",{component:"UploadManager",action:"submitGroupedUploads",fieldId:e});const r=this.getFieldGroups(e);if(0===r.length)return void this.error.log("No groups created for post_group upload",{component:"UploadManager",action:"submitGroupedUploads",fieldId:e});const o=[],i=new FormData;let a=[];s=s.map((e=>this.uploads.get(e))),r.forEach(((e,t)=>{const r={images:[],fields:{}};for(let[t,s]of Object.entries(e.changes))r.fields[t]=s;s.filter((t=>t.groupId===e.id)).forEach((e=>{if(e){const t=e.processedFile||e.originalFile;if(t){i.append("files[]",t);const s={upload_id:e.id,index:a.length};r.images.push(s),a.push(e.id)}}})),o.push(r)})),s.filter((e=>!Object.hasOwn(e,"groupId"))).forEach((e=>{if(e){const t={images:[],fields:{}},s=e.processedFile||e.originalFile;if(s){i.append("files[]",s);const r={upload_id:e.id,index:a.length};t.images.push(r),a.push(e.id)}o.push(t)}})),i.append("content",t.config.content),i.append("user",t.config.itemID),i.append("posts",JSON.stringify(o)),i.append("upload_ids",JSON.stringify(a));for(const[e,t]of i.entries())console.log(e,t);const l={endpoint:"uploads/groups",method:"POST",data:i,title:`Creating ${o.length} ${t.config.content}${o.length>1?"s":""} from uploads...`,popup:`Creating ${o.length} post${o.length>1?"s":""}...`,canMerge:!1,headers:{action_nonce:jvbSettings.dash},append:"_upload"};try{const e=await this.queue.addToQueue(l);return s.forEach((t=>{let s=this.uploads.get(t);s&&(s.operationId=e,this.updateUploadStatus(t,"queued"))})),t.operationId=e,this.a11y.announce(`Creating ${o.length} post${o.length>1?"s":""} from your uploads`),e}catch(t){throw this.error.log(t,{component:"UploadManager",action:"submitGroupedUploads",fieldId:e}),t}finally{this.schedulePersistance(t.id)}}async queueUpload(e){const t=this.fields.get(e);if(!t?.uploads)return;const s=Array.from(t.uploads);if(0===s.length)return;const r=this.prepareUploadData(t,s);this.a11y.announce("Queuing for upload");let o=1===s.length?"file":"files";const i={endpoint:"uploads",method:"POST",data:r,title:`Uploading ${s.length} ${o} to server...`,popup:`Uploading ${s.length} ${o}...`,canMerge:!1,headers:{action_nonce:jvbSettings.dash},append:"_upload"};try{const e=await this.queue.addToQueue(i);return s.forEach((t=>{let s=this.uploads.get(t);s&&(s.operationId=e,this.updateUploadStatus(t,"queued"))})),t.operationId=e,e}catch(e){throw e}finally{this.schedulePersistance(t.id)}}prepareUploadData(e,t){const s=new FormData;s.append("content",e.config.content),s.append("mode",e.config.mode),s.append("field_name",e.config.name),s.append("fieldId",e.id),s.append("field_type",e.config.type),s.append("subtype",e.config.subtype),s.append("item_id",e.config.itemID),s.append("destination",e.config.destination||"meta");let r=[];const o=this.getFieldGroups(e.id);if("post_group"===e.config.destination&&o.length>0){let e=[],t=[],i=[];o.forEach((o=>{let a=[],l=null;o.uploads.forEach((e=>{let t=this.uploads.get(e);if(t){const e=t.processedFile||t.originalFile;if(e){s.append("files[]",e);r.length;r.push(t.id),a.push(t.id);const o=t.element?.querySelector('[name="featured"]');o?.checked&&(l=t.id)}}})),e.push(a),t.push(o.title||""),i.push(l)})),s.append("groups",JSON.stringify(e)),s.append("group_titles",JSON.stringify(t)),s.append("featured_images",JSON.stringify(i))}else t.forEach((e=>{let t=this.uploads.get(e);if(t){const e=t.processedFile||t.originalFile;e&&(s.append("files[]",e),r.push(t.id))}}));return s.append("upload_ids",JSON.stringify(r)),s}getFieldGroups(e){const t=[];return this.groups.forEach(((s,r)=>{if(s.fieldId===e){const o=this.fields.get(e),i=o?.ui?.groups?.groups?.get(r);t.push({id:r,uploads:Array.from(s.uploads||new Set),changes:s.changes||{},element:i||null})}})),t}async queueUploadMeta(e){const t=this.getUploadFromElement(e.target);if(!t)return;if(!this.fields.get(t.fieldId))return;if(!e.target.closest(".upload-meta"))return;let s={};s[e.target.name]=e.target.value,t.meta={...t.meta,...s};let r={};r[t.attachmentId??t.id]=t.meta;const o={endpoint:"uploads/meta",method:"POST",data:r,title:"Updating meta",canMerge:!0,headers:{action_nonce:jvbSettings.dash}};try{await this.queue.addToQueue(o)}catch(e){this.error.log(e,{component:"UploadManager",action:"sendMetaUpdate",uploadId:t.id})}}createGroup(e,t=null){const s=this.fields.get(e);if(!s)return console.error("Field not found:",e),null;t||(t=`group_${Date.now()}_${Math.random().toString(36).substr(2,9)}`);const r=this.createGroupElement(t,e);if(!r)return console.error("Failed to create group element"),null;s.ui.groups||(s.ui.groups={groups:new Map,container:null,empty:null,display:null}),s.ui.groups.groups.set(t,r),s.ui.groups.container&&s.ui.groups.empty?s.ui.groups.container.insertBefore(r,s.ui.groups.empty):s.ui.groups.container&&s.ui.groups.container.appendChild(r);const o={id:t,fieldId:e,element:r,grid:r.querySelector(".item-grid.group"),uploads:new Set,changes:{}};return this.groups.set(t,o),this.addGroupSelectionHandler(e,t),this.schedulePersistance(e),o}createGroupElement(e,t){let s=window.getTemplate("imageGroup");if(!s)return;s.dataset.groupId=e,s.dataset.fieldId=t;let r=window.getTemplate("groupMetadata");const o=s.querySelector(".fields");if(o&&r){o.append(r);const i=o.querySelector('[name="post_title"]'),a=o.querySelector('[name="post_excerpt"]');i&&(i.id=`${e}_title`,i.name=`${e}[post_title]`),a&&(a.id=`${e}_excerpt`,a.name=`${e}[post_excerpt]`);let l=this.fields.get(t);if(""!==l.config.content){s.querySelector("summary").textContent=l.config.content+" Fields"}}else s.querySelector("details").remove();const i=s.querySelector(".item-grid.group");return i&&(i.dataset.groupId=e),s}deleteGroup(e,t=!0){let s=this.groups.get(e);if(!s)return;let r=!0;t&&s.uploads&&s.uploads.size>0&&(r=!window.confirm("Delete uploads in group?")),t&&r&&s.uploads&&s.uploads.size>0&&Array.from(s.uploads).forEach((e=>{this.addImageToGroup(e,null,!1)})),this.groups.delete(e);let o=s.element;o&&(o.remove(),this.a11y.announce("Group removed")),this.schedulePersistance(s.fieldId)}addToGroup(e,t=null,s=!0){let r=this.uploads.get(e);if(!r)return;let o=this.fields.get(r.fieldId);if(!o)return;if(!t&&r.location===o.ui.preview||t===r.location)return;if(r.location){let t=r.location.dataset.groupId;if(t){let s=this.groups.get(t);s&&s.uploads&&(s.uploads.delete(e),0===s.uploads.size&&this.deleteGroup(t))}}const i=r.element.querySelector('[name*="select-item"]');i&&(i.checked=!1);let a=r.element.querySelector('[name="featured"]');if(a.hidden=!t,t){if(!t.classList.contains("item-grid")||!t.classList.contains("preview")){let s=t.dataset.groupId;a.name=s+"_"+a.name;let o=this.groups.get(s);o||(o=this.createGroup(r.fieldId),t=o.grid,s=o.id),o&&(o.uploads.add(e),r.groupId=s)}}else t=o.ui.preview,r.groupId=null;r.location=t,t.append(r.element),s&&this.schedulePersistance(o.id)}removeFromGroup(e){const t=this.uploads.get(e);if(!t)return;const s=this.fields.get(t.fieldId);if(!s)return;if(t.groupId){const s=this.groups.get(t.groupId);s?.uploads&&(s.uploads.delete(e),0===s.uploads.size&&this.deleteGroup(t.groupId,!1)),t.groupId=null}s.ui?.preview&&(s.ui.preview.appendChild(t.element),t.location=s.ui.preview);const r=t.element.querySelector('[name="featured"]');r&&(r.hidden=!0,r.checked=!1)}removeUpload(e,t){const s=this.fields.get(e),r=this.uploads.get(t);if(!s||!r)return;if(s.uploads?.delete(t),r.groupId){const e=this.groups.get(r.groupId);e&&e.uploads&&(e.uploads.delete(t),0===e.uploads.size&&this.removeGroup(r.groupId))}r.element?.remove(),this.clearUpload(t),this.updateFieldState(e),this.maybeLockUploads(e);const o=this.selectionHandlers.get(s.id);o&&o.deselect(t),this.a11y.announce("Upload removed")}schedulePersistance(e){const t=`persist_${e}`;window.debouncer.schedule(t,(()=>this.persistFieldState(e)),1e3)}async persistFieldState(e){const t=this.fields.get(e);if(!t)return;const s={...t,id:e,fieldId:e,uploads:Array.from(t.uploads||[]).map((e=>this.uploads.get(e))),groups:Array.from(this.groups.entries()).filter((([t,s])=>s.fieldId===e&&s.uploads&&s.uploads.size>0)).map((([e,t])=>({id:t.id,uploads:Array.from(t.uploads),changes:t.changes||{}}))),context:{url:this.normalizeUrl(window.location.href),fullUrl:window.location.href,modalType:this.getModalType(t),formId:t.formId,fieldSelector:`.field.upload[data-field="${t.config.name}"]`},timestamp:Date.now()};await this.fieldStore.save(s)}normalizeUrl(e){try{const t=new URL(e);return t.origin+t.pathname}catch(t){return e}}getFieldUploads(e,t=!1){const s=this.fields.get(e);return s&&s.uploads?Array.from(s.uploads).map((e=>{const s=this.uploads.get(e);return s?t?{id:s.id,fieldId:s.fieldId,status:s.status,attachmentId:s.attachmentId,operationId:s.operationId,groupId:s.groupId||null,changes:s.changes||{},meta:{originalName:s.meta?.originalName||s.originalFile?.name,size:s.meta?.size||s.originalFile?.size,type:s.meta?.type||s.originalFile?.type,title:s.meta?.title,alt:s.meta?.alt,caption:s.meta?.caption}}:s:null})).filter(Boolean):[]}async checkForStoredUploads(){if(!this.db)return;const e=this.db.transaction(["fieldStates"],"readonly").objectStore("fieldStates"),t=(await new Promise((t=>{const s=e.getAll();s.onsuccess=()=>t(s.result)}))).filter((e=>e.uploads.some((e=>!e.operationId&&("completed"===e.status||"processed"===e.status||"local_processing"===e.status||"processed-original"===e.status)))));0!==t.length&&this.showRecoveryNotification(t)}async handleRestoreUploads(){let e=document.querySelector("dialog.restore-uploads");if(!e)return;const t=this.getSelectedRestorationUploads(e);0!==t.length&&(await this.restoreSelectedUploads(t),this.cleanupRestore())}getSelectedRestorationUploads(e){let t=[];return e.querySelectorAll("[type=checkbox]:checked").forEach((e=>{const s=e.closest(".item");s&&t.push({uploadId:s.dataset.uploadId,fieldId:s.dataset.fieldId})})),t}handleGroupMetaChange(e){let t=this.getGroupFromElement(e);if(!t)return;Object.hasOwn(t,"changes")||(t.changes={});let s=e.name;if(s.includes("group")){let e=t.id+"_",r=t.id+"[";s=s.replace(e,"").replace(r,"").replace("]","")}t.changes[`${s}`]=e.value,this.groups.set(t.id,t),this.schedulePersistance(t.fieldId)}async showRecoveryNotification(e){const t=e.reduce(((e,t)=>e+t.uploads.length),0),s=e.reduce(((e,t)=>e+(t.groups?.length||0)),0);let r,o=window.getTemplate("restoreNotification");if(!o)return void console.error("Restore notification template not found");if(s>0){r=`${s} ${s>1?"groups":"group"} with ${t} ${t>1?"uploads":"upload"} can be restored.`}else r=`${t} upload(s) from ${e.length} field(s) can be recovered.`;const i=o.querySelector(".restore-details");i&&(i.textContent=r);for(const t of e){let e=window.getTemplate("restoreField");if(!e)continue;const s=e.querySelector("h3");s&&(s.textContent=t.config.name||"Unnamed Field");const r=e.querySelector(".item-grid.restore");for(const e of t.uploads){let s=window.getTemplate("uploadItem");if(!s)continue;const o=await this.uploadStore.getBlob(e.id);if(o)try{const r=new Blob([o.data],{type:o.type}),i=this.createPreviewUrl(r);let[a,l,n,d,c]=[s.querySelector('[name="featured"]'),s.querySelector("img"),s.querySelector("video"),s.querySelector("label > span"),s.querySelector("details")];s.dataset.uploadId=e.id,s.dataset.fieldId=t.id;let u=this.getSubtypeFromMime(o.type);switch(s.dataset.subtype=u,u){case"image":[l.src,l.alt]=[i,e.originalFile?.name??e.meta?.originalName??""],n.remove(),d.remove();break;case"video":n.src=i,l.remove(),d.remove();break;case"document":let t;switch(""){case"pdf":t=window.getIcon("file-pdf");break;case"csv":t=window.getIcon("file-csv");break;case"doc":t=window.getIcon("file-doc");break;case"txt":t=window.getIcon("file-txt");break;case"xls":t=window.getIcon("file-xls");break;default:t=window.getIcon("file")}d.innerText=e.originalFile.name,d.prepend(t),l.remove(),n.remove()}s.dataset.previewUrl=i}catch(t){console.warn("Failed to create preview for upload:",e.id,t)}const i=s.querySelector("summary span");i&&(i.textContent=e.meta?.originalName||"Unknown file");const a=s.querySelector("details");a&&e.meta&&(a.textContent=`${this.formatBytes(e.meta.size)} • ${e.meta.type}`),s.querySelectorAll("input").forEach((t=>{let s=t.id;if(s){let r=s+e.id,o=t.parentNode.querySelector(`label[for="${s}"]`);t.id=r,o&&(o.htmlFor=r)}})),r&&r.appendChild(s)}o.querySelector(".wrap").appendChild(r)}document.querySelector(".field.upload").appendChild(o),o=document.querySelector("dialog.restore-uploads"),this.restoreModal=new window.jvbModal(o),this.restoreSelection=new window.jvbHandleSelection({container:o,ui:{selectAll:o.querySelector("#select-all-restore"),count:o.querySelector(".selection-count")}}),this.restoreModal.handleOpen()}async restoreSelectedUploads(e){const t=new Map;if(e.forEach((e=>{t.has(e.fieldId)||t.set(e.fieldId,[]),t.get(e.fieldId).push(e.uploadId)})),!this.db)return;const s=this.db.transaction(["fieldStates"],"readonly").objectStore("fieldStates");for(const[e,r]of t.entries()){const t=s.get(e),o=await new Promise((e=>{t.onsuccess=()=>e(t.result),t.onerror=()=>e(null)}));o&&(o.uploads=o.uploads.filter((e=>r.includes(e.id))),await this.restoreField(o))}}async restoreField(e){const{config:t,context:s,uploads:r,groups:o,id:i}=e;s.modalType&&await this.openModalForRestore(s);let a=document.querySelector(`.field.upload[data-field="${t.name}"]`);if(!a){const e=`${t.content}_${t.itemID}_${t.name}`;a=document.querySelector(`.field.upload[data-uploader="${e}"]`)}if(!a)return void console.warn(`Field ${t.name} not found for restoration`,t);let l=a.dataset.uploader;l&&this.fields.has(l)||(l=this.registerUploader(a,t));const n=this.fields.get(l);if(n){n.state=e.state||"ready",n.ui=this.buildFieldUI(a),n.ui.groups?.display&&(n.ui.groups.display.hidden=!1),o&&o.length>0&&await this.restoreGroups(l,o);for(const e of r)await this.restoreUpload(n,e);this.updateFieldState(l),this.maybeLockUploads(l),"direct"===t.mode&&"post_group"!==t.destination&&await this.queueUpload(l)}else console.error("Failed to register field for restoration")}async restoreUpload(e,t){const s=await this.uploadStore.getBlob(t.id);if(!s)return void console.warn("Blob data not found for upload:",t.id);{const e=s.data instanceof File?s.data:new File([s.data],s.name,{type:s.type,lastModified:s.lastModified});t.originalFile=e,t.processedFile=e,t.preview=this.createPreviewUrl(e)}e.uploads||(e.uploads=new Set),e.uploads.add(t.id);const r=this.getSubtypeFromMime(t.originalFile.type);let o;if(t.element=this.createUploadElement({...t,subtype:r},"post_group"===e.config.destination),o=t.groupId&&e.ui.groups.groups.has(t.groupId)?e.ui.groups.groups.get(t.groupId).querySelector(".item-grid"):e.ui.preview,o&&(o.appendChild(t.element),t.location=o),this.uploads.set(t.id,t),t.groupId){const e=this.groups.get(t.groupId);e&&e.uploads&&e.uploads.add(t.id)}}async restoreGroups(e,t){for(const s of t){const t=this.createGroup(e,s.id);if(t&&(s.meta&&(t.meta={...s.meta}),s.changes&&(t.changes={...s.changes}),s.title)){const e=t.element.querySelector('[name*="post_title"]');e&&(e.value=s.title)}}}async openModalForRestore(e){const{modalType:t,formId:s}=e;let r=null;switch(t){case"create":r=document.querySelector('[data-action="create"]');break;case"edit":r=document.querySelector(`[data-action="edit"][data-id="${e.itemId}"]`);break;case"bulkEdit":r=document.querySelector('[data-action="bulk-edit"]')}r&&(r.click(),await new Promise((e=>setTimeout(e,300))))}handleFieldStoreEvent(e,t){switch(e){case"data-loaded":break;case"item-saved":console.log(`Field state saved: ${t.key}`)}}handleUploadStoreEvent(e,t){switch(e){case"data-loaded":this.checkForStoredUploads();break;case"item-saved":this.showSaveIndicator(t.key)}}async saveUpload(e){const t=e.processedFile||e.originalFile||e.file;if(t instanceof File||t instanceof Blob){await this.uploadStore.saveBlob(e.id,t);const{file:s,originalFile:r,processedFile:o,...i}=e;await this.uploadStore.save(i)}else await this.uploadStore.save(e)}async loadFields(){(await this.fieldStore.getAll()).forEach((e=>{e.uploads&&Array.isArray(e.uploads)&&(e.uploads=new Set(e.uploads.map((e=>e.id)))),this.fields.set(e.fieldId,e)}))}async loadUploads(){(await this.uploadStore.getAll()).forEach((e=>{this.uploads.set(e.id,e)}))}subscribe(e){return this.subscribers.add(e),()=>this.subscribers.delete(e)}notify(e,t){this.subscribers.forEach((s=>s(e,t)))}destroy(){document.removeEventListener("click",this.clickHandler),document.removeEventListener("change",this.changeHandler),document.removeEventListener("dragenter",this.dragEnterHandler),document.removeEventListener("dragleave",this.dragLeaveHandler),document.removeEventListener("dragover",this.dragOverHandler),document.removeEventListener("drop",this.dropHandler),this.dragController&&this.dragController.destroy(),this.selectionHandlers.forEach((e=>e.destroy())),this.selectionHandlers.clear(),this.cleanupAllPreviewUrls(),this.fields.clear(),this.uploads.clear(),this.groups.clear(),this.selected.clear(),this.subscribers.clear()}cleanupRestore(){this.restoreModal.handleClose(),this.restoreSelection.destroy(),this.restoreSelection=null,this.restoreModal.destroy(),this.restoreModal.modal.remove(),this.restoreModal=null}async cleanupStoredUploads(){this.fieldStore.clear(),this.uploadStore.clear()}async clearField(e){await this.fieldStore.delete(e);const t=this.fields.get(e);if(t?.uploads)for(const e of t.uploads)await this.uploadStore.delete(e);this.fields.delete(e)}async clearUpload(e,t=!0){const s=this.uploads.get(e);if(s){if(this.revokePreviewUrl(s.preview),s.element){const e=s.element.dataset.previewUrl;this.revokePreviewUrl(e),delete s.element.dataset.previewUrl}t&&await this.schedulePersistance(s.fieldId),this.uploads.delete(e),this.uploadStore.delete(e),this.uploadStore.delete(e,"blobs")}}cleanupAllPreviewUrls(){this.previewUrls&&(this.previewUrls.forEach((e=>{try{URL.revokeObjectURL(e)}catch(e){}})),this.previewUrls.clear())}}document.addEventListener("DOMContentLoaded",(()=>{window.jvbUploads=new e}))})();
\ No newline at end of file
diff --git a/assets/js/min/utility.min.js b/assets/js/min/utility.min.js
index 8bdeaee..d1c6ce2 100644
--- a/assets/js/min/utility.min.js
+++ b/assets/js/min/utility.min.js
@@ -1 +1 @@
-(()=>{window.fade=function(t,e=!0){e?t.style.animation="fadeIn var(--transition-base)":(t.style.animation="fadeOut var(--transition-base)",window.debouncer.schedule(`remove-${t.dataset.id??t.id??t.className.replace(" ","-")}`,(()=>{t.remove()}),500))},window.formatTimeAgo=function(t){const e=t instanceof Date?t:new Date(t),n=new Date,i=Math.floor((n-e)/1e3),o=Math.floor(i/60),r=Math.floor(o/60),a=Math.floor(r/24);return r<24?0===r?0===o?"Just now":`${o} ${1===o?"minute":"minutes"} ago`:`${r} ${1===r?"hour":"hours"} ago`:a<7?`${a} ${1===a?"day":"days"} ago`:e.toLocaleDateString()},window.formatTimeSoon=function(t){const e=t instanceof Date?t:new Date(t),n=new Date;if(e<=n)return"Just now";const i=Math.floor((e-n)/1e3),o=Math.floor(i/60);return i<60?"In a moment":o<5?"In a few minutes":o<20?"Coming up soon":o<60?"In about half an hour":"Later today"},window.uppercaseFirst=function(t){return t.charAt(0).toUpperCase()+t.slice(1)},window.templates=new Map,document.addEventListener("DOMContentLoaded",(()=>{window.loadTemplates()})),window.loadTemplates=function(){document.querySelectorAll("template").forEach((t=>{const e=Array.from(t.classList);if(e.length>0){const n=t.content.cloneNode(!0).firstElementChild;e.forEach((t=>{window.templates.has(t)||window.templates.set(t,n)}))}}))},window.getTemplate=function(t){return 0===window.templates.size&&loadTemplates(),!!window.templates.has(t)&&window.templates.get(t).cloneNode(!0)},window.formatVote=function(t,e){let n=window.getTemplate("voteButton");n.dataset.itemId=t.id,n.dataset.content=t.content;let i=n.querySelector("button.up"),o=n.querySelector("button.down");return"up"===e&&i.classList.add("voted"),"down"===e&&o.classList.add("voted"),t.upvotes>0&&(i.querySelector(".count").textContent=t.upvotes),t.downvotes>0&&(o.querySelector(".count").textContent="-"+t.downvotes),n},window.checkVoteStatus=function(t,e){if(!jvbSettings.currentUser)return"";let n="";return window.userVotes&&window.userVotes[t]?.has(e)&&(n=window.userVotes[t].get(e)),n},window.getIcon=function(t){if(void 0===t)return"";if(window.jvbIcons||(window.jvbIcons=new Map),!window.jvbIcons.has(t)&&jvbSettings.icons[t]){let e=document.createElement("div");e.innerHTML=jvbSettings.icons[t],window.jvbIcons.set(t,e.firstElementChild.cloneNode(!0)),e.remove()}return window.jvbIcons.get(t)?.cloneNode(!0)},window.isEmptyObject=function(t){return 0===Object.keys(t).length},window.formatNumber=function(t){return t.toString().replace(/\B(?=(\d{3})+(?!\d))/g,",")},window.formatPrice=function(t,e="CAD"){return new Intl.NumberFormat("en-CA",{style:"currency",currency:e}).format(t)},window.escapeHtml=function(t){return t?("string"==typeof t||t instanceof String||(t=String(t)),t.replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,""").replace(/'/g,"'")):""},window.truncateText=function(t,e=100){return!t||t.length<=e?t:t.substring(0,e)+"..."},window.removeChildren=function(t){if(0!==t.children.length)for(;t.firstChild;)t.removeChild(t.firstChild)},window.formatDateRange=function(t,e){const n=new Date(t),i=new Date(e);return n.toDateString()===i.toDateString()?n.toLocaleDateString("en-US",{year:"numeric",month:"short",day:"numeric"}):n.getMonth()===i.getMonth()&&n.getFullYear()===i.getFullYear()?`${n.toLocaleDateString("en-US",{month:"short",day:"numeric"})} - ${i.getDate()}, ${i.getFullYear()}`:n.getFullYear()===i.getFullYear()?`${n.toLocaleDateString("en-US",{month:"short",day:"numeric"})} - ${i.toLocaleDateString("en-US",{month:"short",day:"numeric"})}, ${i.getFullYear()}`:`${n.toLocaleDateString("en-US",{month:"short",day:"numeric",year:"numeric"})} - ${i.toLocaleDateString("en-US",{month:"short",day:"numeric",year:"numeric"})}`},window.debounce=function(t,e=300){let n;return function(...i){clearTimeout(n),n=setTimeout((()=>t.apply(this,i)),e)}},window.throttle=function(t,e){let n;return function(){const i=arguments,o=this;n||(t.apply(o,i),n=!0,setTimeout((()=>n=!1),e))}},window.throttle=function(t,e=300){let n;return function(...i){n||(t.apply(this,i),n=!0,setTimeout((()=>n=!1),e))}},window.uppercaseFirst=function(t){return t.charAt(0).toUpperCase()+t.slice(1)},window.sanitizeHtml=function(t){const e=document.createElement("div");return e.textContent=t,e.innerHTML},window.formatDate=function(t){if(!t)return"";const e=new Date(t),n=new Date,i=Math.floor((n-e)/864e5);return i<1?"Today":i<2?"Yesterday":i<7?`${i} days ago`:e.toLocaleDateString()},window.getPluralContent=function(t){return"artwork"===t?"artwork":t+"s"},window.showToast=function(t,e="success",n={}){window.jvbNotifications.showToast(t,e,n)},window.dateFormatter=new Intl.DateTimeFormat("en-CA",{year:"numeric",month:"long",day:"numeric",hour:"2-digit",minute:"2-digit",second:"2-digit",timeZoneName:"short"}),window.formatDate=function(t){return t instanceof Date&&!isNaN(t)||(t=new Date(t)),window.dateFormatter.format(t)},window.typeText=function(t,e,n=50){return t.classList.add("typeText"),new Promise((i=>{let o=0;t.textContent="";const r=setInterval((()=>{o<e.length?(t.textContent+=e.charAt(o),o++):(clearInterval(r),i())}),n)}))},window.eraseText=function(t,e=10){return new Promise((n=>{let i=t.textContent,o=i.length;const r=setInterval((()=>{o>0?(o--,t.textContent=i.substring(0,o)):(clearInterval(r),n())}),e)}))},window.typeLoop=function(t,e,n=50,i=10,o=1e3,r=250){let a=!0;return async function(){for(;a;)await window.typeText(t,e,n),await new Promise((t=>setTimeout(t,o))),await window.eraseText(t,i),await new Promise((t=>setTimeout(t,r)))}(),function(){a=!1}},window.toCamelCase=function(t){return t.replace(/-([a-z])/g,(function(t){return t[1].toUpperCase()}))},window.targetCheck=function(t,e){return"string"==typeof e&&(t.target.closest(e)??!1)},window.getDifferences={VALUE_CREATED:"created",VALUE_UPDATED:"updated",VALUE_DELETED:"deleted",VALUE_UNCHANGED:"unchanged",map:function(t,e){if(this.isFunction(t)||this.isFunction(e))throw"Invalid argument. Function given, object expected.";if(this.isFile(t)||this.isFile(e)){const n=this.compareFiles(t,e);return n===this.VALUE_UNCHANGED?null:{type:n,data:void 0===t?e:t}}if(this.isValue(t)||this.isValue(e)){const n=this.compareValues(t,e);if(n===this.VALUE_UNCHANGED)return null;let i;switch(n){case this.VALUE_CREATED:i=e;break;case this.VALUE_DELETED:i=this.getEmptyValue(t);break;case this.VALUE_UPDATED:default:i=e}return{type:n,data:i}}let n={},i=!1;for(let o in t)if(!this.isFunction(t[o])){let r;e&&void 0!==e[o]&&(r=e[o]);const a=this.map(t[o],r);null!==a&&(a.hasOwnProperty("type")&&a.hasOwnProperty("data")?n[o]=a.data:n[o]=a,i=!0)}if(e)for(let o in e)if(!this.isFunction(e[o])&&(void 0===t||void 0===t[o])){const t=this.map(void 0,e[o]);null!==t&&(t.hasOwnProperty("type")&&t.hasOwnProperty("data")?n[o]=t.data:n[o]=t,i=!0)}return i?n:null},getEmptyValue:function(t){return this.isArray(t)?[]:this.isObject(t)?{}:"number"==typeof t?0:"boolean"!=typeof t&&""},compareValues:function(t,e){return t===e||this.isDate(t)&&this.isDate(e)&&t.getTime()===e.getTime()?this.VALUE_UNCHANGED:void 0===t?this.VALUE_CREATED:void 0===e?this.VALUE_DELETED:this.VALUE_UPDATED},isFunction:function(t){return"[object Function]"===Object.prototype.toString.call(t)},isArray:function(t){return"[object Array]"===Object.prototype.toString.call(t)},isDate:function(t){return"[object Date]"===Object.prototype.toString.call(t)},isObject:function(t){return"[object Object]"===Object.prototype.toString.call(t)},isFile:function(t){return t instanceof File},isValue:function(t){return!this.isObject(t)&&!this.isArray(t)},compareFiles:function(t,e){return!this.isFile(t)&&this.isFile(e)?this.VALUE_CREATED:this.isFile(t)&&!this.isFile(e)?this.VALUE_DELETED:this.isFile(t)&&this.isFile(e)?t.name===e.name&&t.size===e.size&&t.type===e.type&&t.lastModified===e.lastModified?this.VALUE_UNCHANGED:this.VALUE_UPDATED:this.VALUE_UNCHANGED},merge:function(t,e){if(null==t)return e;if(null==e)return t;if(this.isFunction(t)||this.isFunction(e))return e;if(this.isFile(t)||this.isFile(e))return e;if(this.isValue(t)||this.isValue(e)||this.isArray(t)||this.isArray(e))return e;if(this.isObject(t)&&this.isObject(e)){let n={};for(let e in t)this.isFunction(t[e])||(n[e]=t[e]);for(let i in e)this.isFunction(e[i])||(void 0!==t[i]?n[i]=this.merge(t[i],e[i]):n[i]=e[i]);return n}return e}},window.deepMerge=function(t,e){return window.getDifferences.merge(t,e)},window.isInt=function(t){return!isNaN(parseInt(t))&&isFinite(t)},window.isNumeric=function(t){return!isNaN(parseFloat(t))&&isFinite(t)},window.handleListField=function(t,e){if(!Array.isArray(e))return void t.remove();let n=t.querySelector("li");e.forEach((e=>{let i=n.cloneNode(!0);i.textContent=e,t.append(i)})),n.remove()},window.handleTextField=function(t,e){"string"==typeof e?t.textContent=e:t.remove()},window.handleImageField=function(t,e){if(!Array.isArray(e)||0===e)return void t.remove();let n="IMG"===t.tagName?t:t.querySelector("img");n?(n.alt=e.alt,n.src=e.thumbnail,n.dataset.small=e.small,n.dataset.medium=e.medium,n.dataset.large=e.full):t.remove()},window.handleGalleryField=function(t,e){if(!Array.isArray(e))return void t.remove();let n=t.querySelector("img");e.forEach((e=>{let i=n.cloneNode(!0);window.handleImageField(i,e),t.append(i)})),n.remove()},window.uiFromSelectors=function(t,e=null){let n={};for(let[i,o]of Object.entries(t))n[i]="object"==typeof o?window.uiFromSelectors(o,e):e?e.querySelector(o):document.querySelector(o);return n};window.debouncer=new class{constructor(){this.timeouts=new Map,window.addEventListener("beforeunload",(()=>this.cleanup()))}schedule(t,e,n=1e3){this.cancel(t),console.log("Scheduling action: ",t),console.log("With callback",e),this.timeouts.set(t,setTimeout((()=>{e(),this.timeouts.delete(t)}),n))}cancel(t){this.timeouts.has(t)&&(console.log("Cancelling ",t),clearTimeout(this.timeouts.get(t)),this.timeouts.delete(t))}cleanup(){for(let t of this.timeouts.values())console.log("clearing timeout: ",t),clearTimeout(t);this.timeouts.clear()}}})();
\ No newline at end of file
+(()=>{window.fade=function(t,e=!0){e?t.style.animation="fadeIn var(--transition-base)":(t.style.animation="fadeOut var(--transition-base)",window.debouncer.schedule(`remove-${t.dataset.id??t.id??t.className.replace(" ","-")}`,(()=>{t.remove()}),500))},window.formatTimeAgo=function(t){const e=t instanceof Date?t:new Date(t),n=new Date,i=Math.floor((n-e)/1e3),o=Math.floor(i/60),r=Math.floor(o/60),a=Math.floor(r/24);return r<24?0===r?0===o?"Just now":`${o} ${1===o?"minute":"minutes"} ago`:`${r} ${1===r?"hour":"hours"} ago`:a<7?`${a} ${1===a?"day":"days"} ago`:e.toLocaleDateString()},window.formatTimeSoon=function(t){const e=t instanceof Date?t:new Date(t),n=new Date;if(e<=n)return"Just now";const i=Math.floor((e-n)/1e3),o=Math.floor(i/60);return i<60?"In a moment":o<5?"In a few minutes":o<20?"Coming up soon":o<60?"In about half an hour":"Later today"},window.uppercaseFirst=function(t){return t.charAt(0).toUpperCase()+t.slice(1)},window.templates=new Map,document.addEventListener("DOMContentLoaded",(()=>{window.loadTemplates()})),window.loadTemplates=function(){document.querySelectorAll("template").forEach((t=>{const e=Array.from(t.classList);if(e.length>0){const n=t.content.cloneNode(!0).firstElementChild;e.forEach((t=>{window.templates.has(t)||window.templates.set(t,n)}))}}))},window.getTemplate=function(t){return 0===window.templates.size&&loadTemplates(),!!window.templates.has(t)&&window.templates.get(t).cloneNode(!0)},window.formatVote=function(t,e){let n=window.getTemplate("voteButton");n.dataset.itemId=t.id,n.dataset.content=t.content;let i=n.querySelector("button.up"),o=n.querySelector("button.down");return"up"===e&&i.classList.add("voted"),"down"===e&&o.classList.add("voted"),t.upvotes>0&&(i.querySelector(".count").textContent=t.upvotes),t.downvotes>0&&(o.querySelector(".count").textContent="-"+t.downvotes),n},window.checkVoteStatus=function(t,e){if(!jvbSettings.currentUser)return"";let n="";return window.userVotes&&window.userVotes[t]?.has(e)&&(n=window.userVotes[t].get(e)),n},window.getIcon=function(t){if(void 0===t)return"";if(window.jvbIcons||(window.jvbIcons=new Map),!window.jvbIcons.has(t)&&jvbSettings.icons[t]){let e=document.createElement("div");e.innerHTML=jvbSettings.icons[t],window.jvbIcons.set(t,e.firstElementChild.cloneNode(!0)),e.remove()}return window.jvbIcons.get(t)?.cloneNode(!0)},window.isEmptyObject=function(t){return 0===Object.keys(t).length},window.formatNumber=function(t){return t.toString().replace(/\B(?=(\d{3})+(?!\d))/g,",")},window.formatPrice=function(t,e="CAD"){return new Intl.NumberFormat("en-CA",{style:"currency",currency:e}).format(t)},window.escapeHtml=function(t){return t?("string"==typeof t||t instanceof String||(t=String(t)),t.replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,""").replace(/'/g,"'")):""},window.truncateText=function(t,e=100){return!t||t.length<=e?t:t.substring(0,e)+"..."},window.removeChildren=function(t){if(0!==t.children.length)for(;t.firstChild;)t.removeChild(t.firstChild)},window.formatDateRange=function(t,e){const n=new Date(t),i=new Date(e);return n.toDateString()===i.toDateString()?n.toLocaleDateString("en-US",{year:"numeric",month:"short",day:"numeric"}):n.getMonth()===i.getMonth()&&n.getFullYear()===i.getFullYear()?`${n.toLocaleDateString("en-US",{month:"short",day:"numeric"})} - ${i.getDate()}, ${i.getFullYear()}`:n.getFullYear()===i.getFullYear()?`${n.toLocaleDateString("en-US",{month:"short",day:"numeric"})} - ${i.toLocaleDateString("en-US",{month:"short",day:"numeric"})}, ${i.getFullYear()}`:`${n.toLocaleDateString("en-US",{month:"short",day:"numeric",year:"numeric"})} - ${i.toLocaleDateString("en-US",{month:"short",day:"numeric",year:"numeric"})}`},window.debounce=function(t,e=300){let n;return function(...i){clearTimeout(n),n=setTimeout((()=>t.apply(this,i)),e)}},window.throttle=function(t,e){let n;return function(){const i=arguments,o=this;n||(t.apply(o,i),n=!0,setTimeout((()=>n=!1),e))}},window.throttle=function(t,e=300){let n;return function(...i){n||(t.apply(this,i),n=!0,setTimeout((()=>n=!1),e))}},window.uppercaseFirst=function(t){return t.charAt(0).toUpperCase()+t.slice(1)},window.sanitizeHtml=function(t){const e=document.createElement("div");return e.textContent=t,e.innerHTML},window.formatDate=function(t){if(!t)return"";const e=new Date(t),n=new Date,i=Math.floor((n-e)/864e5);return i<1?"Today":i<2?"Yesterday":i<7?`${i} days ago`:e.toLocaleDateString()},window.getPluralContent=function(t){return"artwork"===t?"artwork":t+"s"},window.showToast=function(t,e="success",n={}){window.jvbNotifications.showToast(t,e,n)},window.dateFormatter=new Intl.DateTimeFormat("en-CA",{year:"numeric",month:"long",day:"numeric",hour:"2-digit",minute:"2-digit",second:"2-digit",timeZoneName:"short"}),window.formatDate=function(t){return t instanceof Date&&!isNaN(t)||(t=new Date(t)),window.dateFormatter.format(t)},window.typeText=function(t,e,n=50){return t.classList.add("typeText"),new Promise((i=>{let o=0;t.textContent="";const r=setInterval((()=>{o<e.length?(t.textContent+=e.charAt(o),o++):(clearInterval(r),i())}),n)}))},window.eraseText=function(t,e=10){return new Promise((n=>{let i=t.textContent,o=i.length;const r=setInterval((()=>{o>0?(o--,t.textContent=i.substring(0,o)):(clearInterval(r),n())}),e)}))},window.typeLoop=function(t,e,n=50,i=10,o=1e3,r=250){let a=!0;return async function(){for(;a;)await window.typeText(t,e,n),await new Promise((t=>setTimeout(t,o))),await window.eraseText(t,i),await new Promise((t=>setTimeout(t,r)))}(),function(){a=!1}},window.toCamelCase=function(t){return t.replace(/-([a-z])/g,(function(t){return t[1].toUpperCase()}))},window.targetCheck=function(t,e){return"string"==typeof e&&(t.target.closest(e)??!1)},window.getDifferences={VALUE_CREATED:"created",VALUE_UPDATED:"updated",VALUE_DELETED:"deleted",VALUE_UNCHANGED:"unchanged",map:function(t,e){if(this.isFunction(t)||this.isFunction(e))throw"Invalid argument. Function given, object expected.";if(this.isFile(t)||this.isFile(e)){const n=this.compareFiles(t,e);return n===this.VALUE_UNCHANGED?null:{type:n,data:void 0===t?e:t}}if(this.isValue(t)||this.isValue(e)){const n=this.compareValues(t,e);if(n===this.VALUE_UNCHANGED)return null;let i;switch(n){case this.VALUE_CREATED:i=e;break;case this.VALUE_DELETED:i=this.getEmptyValue(t);break;case this.VALUE_UPDATED:default:i=e}return{type:n,data:i}}let n={},i=!1;for(let o in t)if(!this.isFunction(t[o])){let r;e&&void 0!==e[o]&&(r=e[o]);const a=this.map(t[o],r);null!==a&&(a.hasOwnProperty("type")&&a.hasOwnProperty("data")?n[o]=a.data:n[o]=a,i=!0)}if(e)for(let o in e)if(!this.isFunction(e[o])&&(void 0===t||void 0===t[o])){const t=this.map(void 0,e[o]);null!==t&&(t.hasOwnProperty("type")&&t.hasOwnProperty("data")?n[o]=t.data:n[o]=t,i=!0)}return i?n:null},getEmptyValue:function(t){return this.isArray(t)?[]:this.isObject(t)?{}:"number"==typeof t?0:"boolean"!=typeof t&&""},compareValues:function(t,e){return t===e||this.isDate(t)&&this.isDate(e)&&t.getTime()===e.getTime()?this.VALUE_UNCHANGED:void 0===t?this.VALUE_CREATED:void 0===e?this.VALUE_DELETED:this.VALUE_UPDATED},isFunction:function(t){return"[object Function]"===Object.prototype.toString.call(t)},isArray:function(t){return"[object Array]"===Object.prototype.toString.call(t)},isDate:function(t){return"[object Date]"===Object.prototype.toString.call(t)},isObject:function(t){return"[object Object]"===Object.prototype.toString.call(t)},isFile:function(t){return t instanceof File},isValue:function(t){return!this.isObject(t)&&!this.isArray(t)},compareFiles:function(t,e){return!this.isFile(t)&&this.isFile(e)?this.VALUE_CREATED:this.isFile(t)&&!this.isFile(e)?this.VALUE_DELETED:this.isFile(t)&&this.isFile(e)?t.name===e.name&&t.size===e.size&&t.type===e.type&&t.lastModified===e.lastModified?this.VALUE_UNCHANGED:this.VALUE_UPDATED:this.VALUE_UNCHANGED},merge:function(t,e){if(null==t)return e;if(null==e)return t;if(this.isFunction(t)||this.isFunction(e))return e;if(this.isFile(t)||this.isFile(e))return e;if(this.isValue(t)||this.isValue(e)||this.isArray(t)||this.isArray(e))return e;if(this.isObject(t)&&this.isObject(e)){let n={};for(let e in t)this.isFunction(t[e])||(n[e]=t[e]);for(let i in e)this.isFunction(e[i])||(void 0!==t[i]?n[i]=this.merge(t[i],e[i]):n[i]=e[i]);return n}return e}},window.deepMerge=function(t,e){return window.getDifferences.merge(t,e)},window.isInt=function(t){return!isNaN(parseInt(t))&&isFinite(t)},window.isNumeric=function(t){return!isNaN(parseFloat(t))&&isFinite(t)},window.handleListField=function(t,e){if(!Array.isArray(e))return void t.remove();let n=t.querySelector("li");e.forEach((e=>{let i=n.cloneNode(!0);i.textContent=e,t.append(i)})),n.remove()},window.handleTextField=function(t,e){"string"==typeof e?t.textContent=e:t.remove()},window.handleImageField=function(t,e){if(!Array.isArray(e)||0===e)return void t.remove();let n="IMG"===t.tagName?t:t.querySelector("img");n?(n.alt=e.alt,n.src=e.thumbnail,n.dataset.small=e.small,n.dataset.medium=e.medium,n.dataset.large=e.full):t.remove()},window.handleGalleryField=function(t,e){if(!Array.isArray(e))return void t.remove();let n=t.querySelector("img");e.forEach((e=>{let i=n.cloneNode(!0);window.handleImageField(i,e),t.append(i)})),n.remove()},window.uiFromSelectors=function(t,e=null){let n={};for(let[i,o]of Object.entries(t))n[i]="object"==typeof o?window.uiFromSelectors(o,e):e?e.querySelector(o):document.querySelector(o);return n};window.debouncer=new class{constructor(){this.timeouts=new Map,window.addEventListener("beforeunload",(()=>this.cleanup()))}schedule(t,e,n=1e3){this.cancel(t),this.timeouts.set(t,setTimeout((()=>{e(),this.timeouts.delete(t)}),n))}cancel(t){this.timeouts.has(t)&&(clearTimeout(this.timeouts.get(t)),this.timeouts.delete(t))}cleanup(){for(let t of this.timeouts.values())clearTimeout(t);this.timeouts.clear()}}})();
\ No newline at end of file
diff --git a/assets/js/min/view.min.js b/assets/js/min/view.min.js
index afc634f..63ff69b 100644
--- a/assets/js/min/view.min.js
+++ b/assets/js/min/view.min.js
@@ -1 +1 @@
-window.jvbViews=class{constructor(e,t){this.a11y=window.jvbA11y,this.error=window.jvbError,this.container=e,this.initElements(),this.store=t,this.items={list:new Map,grid:new Map,table:new Map},this.currentView="grid",this.selectedItems=new Set,this.init()}initElements(){this.selectors={grid:".item-grid",table:{table:"table",body:"table body",selectedColumns:".all-filters .multi-select",columns:"thead th"},bulk:{count:".bulk-controls .selected-count",control:".bulk-controls .bulk-actions",select:".bulk-controls select",selectAll:".select-all"}},this.ui=window.uiFromSelectors(this.selectors,this.container)}init(){this.store.subscribe(((e,t)=>{switch(e){case"data-loaded":case"items-saved":this.handleDataUpdate(t);break;case"items-updated":this.handleItemsUpdate(t.items)}})),this.setupViewSwitcher(),this.changeHandler=this.handleChange.bind(this),this.clickHandler=this.handleClick.bind(this),this.lastSelected=null,document.addEventListener("change",this.changeHandler),document.addEventListener("click",this.clickHandler)}handleClick(e){e.target.closest(".select-item-label")&&(e.shiftKey?(e.preventDefault(),this.handleRangeSelection(e.target)):this.lastSelected=e.target.closest(".item"))}handleRangeSelection(e){if(!this.lastSelected)return void(this.lastSelected=e.closest(".item"));const t=e.closest(".item"),i=Array.from(this.container.querySelectorAll(".item")),s=i.indexOf(this.lastSelected),l=i.indexOf(t);if(-1===s||-1===l)return void(this.lastSelected=t);const a=Math.min(s,l),r=Math.max(s,l);let d=0;for(let e=a;e<=r;e++){let t=i[e];this.selectedItems.add(t.dataset.id);let s=t.querySelector(".select-item");s&&!s.checked&&(s.checked=!0,d++)}this.updateSelectionUI(),window.jvbA11y.announce(`Selected ${d} items in range.`)}handleChange(e){e.target.closest(".select-all")?this.selectAll(e.target.checked):e.target.closest(".select-item")?this.toggleSelection(e.target.closest(".item").dataset.id):e.target.closest("details.multi-select")&&this.toggleColumns(e.target.id,e.target.checked)}toggleColumns(e,t){this.ui.table.columns.filter((t=>t.className===e))}setupViewSwitcher(){document.querySelectorAll("[data-view]").forEach((e=>{e.addEventListener("click",(()=>{this.currentView=e.dataset.view,this.render()}))}))}handleDataUpdate(e){e.data&&e.data.items&&this.render(e.data.items)}handleItemsUpdate(e){this.render(e)}render(e=null){if(this.store){if(!e){const t=this.store.getCurrentRequest();if(!(t&&t.data&&t.data.items))return;e=t.data.items}switch(this.currentView){case"grid":this.renderGrid(e);break;case"table":this.renderTable(e);break;case"list":this.renderList(e)}this.updateSelectionUI()}else console.error("No store connected to renderer")}renderGrid(e){this.toggleGrid(),this.toggleTable(!1),this.ui.grid.classList.remove("list-view"),this.ui.grid.classList.add("grid-view");const t=document.createDocumentFragment();e.forEach((e=>{let i;this.store.renderOrRetrieve?i=this.store.renderOrRetrieve(e,"grid",this.renderGridItem.bind(this)):this.items.grid.has(e.id)?i=this.items.grid.get(e.id):(i=this.renderGridItem(e),this.items.grid.set(e.id,i)),t.appendChild(i)})),this.ui.grid.appendChild(t)}renderGridItem(e){const t=window.getTemplate("gridView");t.dataset.id=e.id,e._pending&&t.classList.add("pending");let[i,s,l,a,r]=[t.querySelector("input"),t.querySelector("label"),t.querySelector("img"),t.querySelector('[data-action="edit"]'),t.querySelector('[data-action="trash"]')];return[i.value,i.id,i.checked,s.htmlFor,l.src,l.alt,a.dataset.id,r.dataset.id]=[e.id,`select-${e.id}`,this.selectedItems.has(`${e.id}`),`select-${e.id}`,e.images[e.fields.post_thumbnail]?.medium??"",e.images[e.fields.post_thumbnail]?.alt??"",e.id,e.id],t}toggleTable(e){if(this.ui.table.selectedColumns.hidden=!e,e&&!this.ui.table.table){let e=window.getTemplate("contentTable");this.container.append(e),this.ui.table.table=this.container.querySelector("form.table"),this.ui.table.body=this.ui.table.table.querySelector("tbody"),this.ui.table.columns=this.container.querySelectorAll(this.selectors.table.columns)}this.ui.table.table&&(this.ui.table.table.hidden=!e,window.removeChildren(this.ui.table.body)),this.ui.table.selectedColumns.hidden=!e}toggleGrid(){window.removeChildren(this.ui.grid)}renderTable(e){this.toggleTable(!0),this.toggleGrid(),e.forEach((e=>{let t;this.items.table.has(e.id)?t=this.items.table.get(e.id):(t=this.store.renderOrRetrieve(e,"table",this.renderTableItem.bind(this)),this.items.table.set(e.id,t)),this.ui.table.body.append(t)})),window.jvbSelector.scanExistingFields()}renderTableItem(e){let t=["",0];const i=window.getTemplate("tableView");return i.dataset.id=e.id,[i.querySelector(".select-item").id,i.querySelector(".select-item").value,i.querySelector(".select-item").checked,i.querySelector(".select-item + label").htmlFor,i.querySelector(`input[name="post_status"][value="${e.status}"]`).checked]=[e.id,e.id,this.selectedItems.has(`${e.id}`),e.id,e.status],i.querySelectorAll("td[data-field]").forEach((i=>{let s,l=e.fields[i.dataset.field],a=i.querySelector("label"),r=t.includes(l);switch(i.dataset.fieldType){case"text":case"number":case"url":case"tel":case"email":r||(i.querySelector("input").value=l),a.remove();break;case"textarea":r||(i.querySelector("textarea").value=l),a.remove();break;case"taxonomy":a.remove(),r||(s=i.querySelector("input[type=hidden]"),s.value=l);break;case"image":if(!r){let t=window.getTemplate("uploadItem"),s=t.querySelector("img");[s.src,s.alt]=[e.images[l].medium??"",e.images[l].alt??""],i.querySelector(".item-grid").append(t),i.querySelector("input[type=hidden]").value=l}i.querySelectorAll(".progress,label,.upload-select,.status,details").forEach((e=>{e.remove()}));break;case"true_false":r||(i.querySelector("input").checked=1===parseInt(l)),i.querySelector(".toggle-label")?.remove();break;case"select":a.remove();case"radio":case"checkbox":i.querySelector(".label")?.remove(),r||(l=l.split(","),l.forEach((e=>{s=i.querySelector(`[value="${e}"]`),s&&(s.checked=!0)})));break;default:r||console.log(l)}})),i}renderList(e){this.toggleGrid(),this.toggleTable(!1),this.ui.grid.classList.remove("grid-view"),this.ui.grid.classList.add("list-view"),e.forEach((e=>{let t;this.items.list.has(e.id)?t=this.items.list.get(e.id):(t=this.store.renderOrRetrieve(e,"list",this.renderListItem.bind(this)),this.items.list.set(e.id,t)),this.ui.grid.appendChild(t)}))}renderListItem(e){const t=window.getTemplate("listView");t.dataset.id=e.id,e._pending&&t.classList.add("pending");let i=t.querySelector(".select-item"),s=t.querySelector(".select-item + label");[i.id,i.value,i.checked,s.htmlFor]=[e.id,e.id,this.selectedItems.has(`${e.id}`),e.id],t.querySelectorAll("[data-attr]").forEach((t=>{""!==e[t.dataset.attr]?t.textContent=e[t.dataset.attr]:t.remove()})),t.querySelectorAll("[data-field]").forEach((t=>{let i=e.fields[t.dataset.field];""!==i?"DIV"===t.tagName?t.innerHTML=i:t.textContent=i:t.remove()}));let l=t.querySelector("img");return l&&([l.src,l.alt]=[e.images[e.fields.post_thumbnail]?.medium??"",e.images[e.fields.post_thumbnail]?.alt??""]),t}toggleSelection(e){this.selectedItems.has(e)?this.selectedItems.delete(e):this.selectedItems.add(e),this.updateSelectionUI()}selectAll(e){const t=this.container.querySelectorAll(".item");e||(this.selectedItems.clear(),this.ui.bulk.selectAll.checked=!1,this.ui.bulk.select.value=""),t.forEach((t=>{e&&this.selectedItems.add(t.dataset.id),t.querySelector(".select-item").checked=e})),this.updateSelectionUI()}clearSelection(){this.selectAll(!1),this.ui.bulk.select.value=""}updateSelectionUI(){const e=this.selectedItems.size;if(this.ui.bulk.control&&(this.ui.bulk.control.hidden=0===e),this.ui.bulk.count){let t=1===e?"item":"items";this.ui.bulk.count.hidden=0===e,this.ui.bulk.count.textContent=0===e?"":`${e} ${t} selected`}}};
\ No newline at end of file
+window.jvbViews=class{constructor(e,t){this.a11y=window.jvbA11y,this.error=window.jvbError,this.container=e,this.initElements(),this.settings=window.jvbUserSettings,this.store=t,this.items={list:new Map,grid:new Map,table:new Map},this.currentView="grid",this.selectedItems=new Set,this.init()}initElements(){this.selectors={grid:".item-grid",table:{table:"table",body:"table body",selectedColumns:".all-filters .multi-select",columns:"thead th"},bulk:{count:".bulk-controls .selected-count",control:".bulk-controls .bulk-actions",select:".bulk-controls select",selectAll:".select-all"}},this.ui=window.uiFromSelectors(this.selectors,this.container)}init(){this.store.subscribe(((e,t)=>{switch(e){case"items-saved":case"item-saved":case"item-deleted":break;case"data-loaded":this.handleItemsUpdate()}})),this.setupViewSwitcher(),this.changeHandler=this.handleChange.bind(this),this.clickHandler=this.handleClick.bind(this),this.lastSelected=null,document.addEventListener("change",this.changeHandler),document.addEventListener("click",this.clickHandler)}handleClick(e){e.target.closest(".select-item-label")&&(e.shiftKey?(e.preventDefault(),this.handleRangeSelection(e.target)):this.lastSelected=e.target.closest(".item"))}handleRangeSelection(e){if(!this.lastSelected)return void(this.lastSelected=e.closest(".item"));const t=e.closest(".item"),i=Array.from(this.container.querySelectorAll(".item")),s=i.indexOf(this.lastSelected),l=i.indexOf(t);if(-1===s||-1===l)return void(this.lastSelected=t);const a=Math.min(s,l),r=Math.max(s,l);let d=0;for(let e=a;e<=r;e++){let t=i[e];this.selectedItems.add(t.dataset.id);let s=t.querySelector(".select-item");s&&!s.checked&&(s.checked=!0,d++)}this.updateSelectionUI(),window.jvbA11y.announce(`Selected ${d} items in range.`)}handleChange(e){e.target.closest(".select-all")?this.selectAll(e.target.checked):e.target.closest(".select-item")?this.toggleSelection(e.target.closest(".item").dataset.id):e.target.closest("details.multi-select")&&this.toggleColumns(e.target.id,e.target.checked)}toggleColumns(e,t){this.ui.table.columns.filter((t=>t.className===e))}setupViewSwitcher(){document.querySelectorAll("[data-view]").forEach((e=>{this.settings.addSetting(e),e.addEventListener("click",(()=>{this.currentView=e.dataset.view,this.render()}))}))}handleDataUpdate(e){console.log(e);const t=e.data?.items||e.items||[];this.render(t)}handleItemsUpdate(){console.log(this.store.data),this.render(this.store.data)}render(e=[]){if(this.store)if(0!==e.length){switch(this.currentView){case"grid":this.renderGrid(e);break;case"table":this.renderTable(e);break;case"list":this.renderList(e)}this.updateSelectionUI()}else this.renderEmpty();else console.error("No store connected to renderer")}renderEmpty(){this.toggleTable(!1),window.removeChildren(this.ui.grid);const e=window.getTemplate("emptyState");e&&(this.ui.grid.appendChild(e),this.a11y?.announce("No items found"))}renderGrid(e){this.toggleGrid(),this.toggleTable(!1),this.ui.grid.classList.remove("list-view"),this.ui.grid.classList.add("grid-view");const t=document.createDocumentFragment();e.forEach((e=>{let i;this.store.renderOrRetrieve?i=this.store.renderOrRetrieve(e,"grid",this.renderGridItem.bind(this)):this.items.grid.has(e.id)?i=this.items.grid.get(e.id):(i=this.renderGridItem(e),this.items.grid.set(e.id,i)),t.appendChild(i)})),this.ui.grid.appendChild(t)}renderGridItem(e){const t=window.getTemplate("gridView");t.dataset.id=e.id,e._pending&&t.classList.add("pending");let[i,s,l,a,r]=[t.querySelector("input"),t.querySelector("label"),t.querySelector("img"),t.querySelector('[data-action="edit"]'),t.querySelector('[data-action="trash"]')];return[i.value,i.id,i.checked,s.htmlFor,a.dataset.id,r.dataset.id]=[e.id,`select-${e.id}`,this.selectedItems.has(`${e.id}`),`select-${e.id}`,e.id,e.id],"progress"===this.store.config.storeName?[l.src,l.alt]=[e.images[e.fields.timeline[0].post_thumbnail]?.medium??"",e.images[e.fields.timeline[0].post_thumbnail]?.alt??""]:[l.src,l.alt]=[e.images[e.fields.post_thumbnail]?.medium??"",e.images[e.fields.post_thumbnail]?.alt??""],t}toggleTable(e){if(this.ui.table.selectedColumns.hidden=!e,e&&!this.ui.table.table){let e=window.getTemplate("contentTable");this.container.append(e),this.ui.table.table=this.container.querySelector("form.table"),this.ui.table.body=this.ui.table.table.querySelector("tbody"),this.ui.table.columns=this.container.querySelectorAll(this.selectors.table.columns)}this.ui.table.table&&(this.ui.table.table.hidden=!e,window.removeChildren(this.ui.table.body)),this.ui.table.selectedColumns.hidden=!e}toggleGrid(){window.removeChildren(this.ui.grid)}renderTable(e){this.toggleTable(!0),this.toggleGrid(),e.forEach((e=>{let t;this.items.table.has(e.id)?t=this.items.table.get(e.id):(t=this.store.renderOrRetrieve(e,"table",this.renderTableItem.bind(this)),this.items.table.set(e.id,t)),this.ui.table.body.append(t)})),window.jvbSelector.scanExistingFields()}renderTableItem(e){let t=["",0];const i=window.getTemplate("tableView");return i.dataset.id=e.id,[i.querySelector(".select-item").id,i.querySelector(".select-item").value,i.querySelector(".select-item").checked,i.querySelector(".select-item + label").htmlFor,i.querySelector(`input[name="post_status"][value="${e.status}"]`).checked]=[e.id,e.id,this.selectedItems.has(`${e.id}`),e.id,e.status],i.querySelectorAll("td[data-field]").forEach((i=>{let s,l=e.fields[i.dataset.field],a=i.querySelector("label"),r=t.includes(l);switch(i.dataset.fieldType){case"text":case"number":case"url":case"tel":case"email":r||(i.querySelector("input").value=l),a.remove();break;case"textarea":r||(i.querySelector("textarea").value=l),a.remove();break;case"taxonomy":a.remove(),r||(s=i.querySelector("input[type=hidden]"),s.value=l);break;case"image":if(!r){let t=window.getTemplate("uploadItem"),s=t.querySelector("img");[s.src,s.alt]=[e.images[l].medium??"",e.images[l].alt??""],i.querySelector(".item-grid").append(t),i.querySelector("input[type=hidden]").value=l}i.querySelectorAll(".progress,label,.upload-select,.status,details").forEach((e=>{e.remove()}));break;case"true_false":r||(i.querySelector("input").checked=1===parseInt(l)),i.querySelector(".toggle-label")?.remove();break;case"select":a.remove();case"radio":case"checkbox":i.querySelector(".label")?.remove(),r||(l=l.split(","),l.forEach((e=>{s=i.querySelector(`[value="${e}"]`),s&&(s.checked=!0)})));break;default:r||console.log(l)}})),i}renderList(e){this.toggleGrid(),this.toggleTable(!1),this.ui.grid.classList.remove("grid-view"),this.ui.grid.classList.add("list-view"),e.forEach((e=>{let t;this.items.list.has(e.id)?t=this.items.list.get(e.id):(t=this.store.renderOrRetrieve(e,"list",this.renderListItem.bind(this)),this.items.list.set(e.id,t)),this.ui.grid.appendChild(t)}))}renderListItem(e){const t=window.getTemplate("listView");t.dataset.id=e.id,e._pending&&t.classList.add("pending");let i=t.querySelector(".select-item"),s=t.querySelector(".select-item + label");[i.id,i.value,i.checked,s.htmlFor]=[e.id,e.id,this.selectedItems.has(`${e.id}`),e.id],t.querySelectorAll("[data-attr]").forEach((t=>{""!==e[t.dataset.attr]?t.textContent=e[t.dataset.attr]:t.remove()})),t.querySelectorAll("[data-field]").forEach((t=>{let i=e.fields[t.dataset.field];""!==i?"DIV"===t.tagName?t.innerHTML=i:t.textContent=i:t.remove()}));let l=t.querySelector("img");return l&&([l.src,l.alt]=[e.images[e.fields.post_thumbnail]?.medium??"",e.images[e.fields.post_thumbnail]?.alt??""]),t}toggleSelection(e){this.selectedItems.has(e)?this.selectedItems.delete(e):this.selectedItems.add(e),this.updateSelectionUI()}selectAll(e){const t=this.container.querySelectorAll(".item");e||(this.selectedItems.clear(),this.ui.bulk.selectAll.checked=!1,this.ui.bulk.select.value=""),t.forEach((t=>{e&&this.selectedItems.add(t.dataset.id),t.querySelector(".select-item").checked=e})),this.updateSelectionUI()}clearSelection(){this.selectAll(!1),this.ui.bulk.select.value=""}updateSelectionUI(){const e=this.selectedItems.size;if(this.ui.bulk.control&&(this.ui.bulk.control.hidden=0===e),this.ui.bulk.count){let t=1===e?"item":"items";this.ui.bulk.count.hidden=0===e,this.ui.bulk.count.textContent=0===e?"":`${e} ${t} selected`}}};
\ No newline at end of file
diff --git a/build/faq/block.json b/build/faq/block.json
new file mode 100644
index 0000000..543d42e
--- /dev/null
+++ b/build/faq/block.json
@@ -0,0 +1,41 @@
+{
+ "$schema": "https://schemas.wp.org/trunk/block.json",
+ "apiVersion": 3,
+ "name": "jvb/faq",
+ "title": "FAQ Block",
+ "category": "jvb",
+ "icon": "info",
+ "description": "Display FAQs organized by sections with customizable ordering",
+ "keywords": [
+ "faq",
+ "questions",
+ "help"
+ ],
+ "version": "1.0.0",
+ "textdomain": "jvb",
+ "attributes": {
+ "sectionOrder": {
+ "type": "array",
+ "default": []
+ },
+ "showSectionTitles": {
+ "type": "boolean",
+ "default": true
+ },
+ "collapseByDefault": {
+ "type": "boolean",
+ "default": false
+ }
+ },
+ "supports": {
+ "align": [
+ "wide",
+ "full"
+ ],
+ "html": false
+ },
+ "editorScript": "file:./index.js",
+ "editorStyle": "file:./index.css",
+ "style": "file:./style-index.css",
+ "viewScript": "file:./view.js"
+}
\ No newline at end of file
diff --git a/build/faq/index-rtl.css b/build/faq/index-rtl.css
new file mode 100644
index 0000000..2b0a857
--- /dev/null
+++ b/build/faq/index-rtl.css
@@ -0,0 +1 @@
+.faq-block-editor{background:#f9f9f9;border:2px dashed #ccc;border-radius:8px;padding:2rem}.faq-block-editor .faq-block-preview{text-align:center}.faq-block-editor .faq-block-preview h3{font-size:1.25rem;font-weight:600;margin:0 0 .5rem}.faq-block-editor .faq-block-preview>p{color:#666;margin:0 0 1.5rem}.faq-block-editor .faq-block-preview .faq-sections-preview{background:#fff;border-radius:4px;margin-top:1.5rem;padding:1rem;text-align:right}.faq-block-editor .faq-block-preview .faq-sections-preview strong{display:block;margin-bottom:.5rem}.faq-block-editor .faq-block-preview .faq-sections-preview ol{margin:0;padding-right:1.5rem}.faq-block-editor .faq-block-preview .faq-sections-preview ol li{margin:.25rem 0;padding:.25rem 0}.faq-section-list{display:flex;flex-direction:column;gap:.5rem;margin-top:.5rem}.faq-section-item{align-items:center;background:#fff;border:1px solid #ddd;border-radius:4px;display:flex;gap:.5rem;padding:.5rem .75rem;transition:background .15s ease}.faq-section-item:hover{background:#f9f9f9}.faq-section-controls{display:flex;flex-shrink:0;gap:.25rem}.faq-section-button{height:30px!important;min-width:30px!important;padding:4px!important}.faq-section-button:disabled{cursor:not-allowed;opacity:.3}.faq-section-name{flex:1;font-weight:500;padding-right:.5rem}.components-panel__body .components-notice{margin:1rem 0}
diff --git a/build/faq/index.asset.php b/build/faq/index.asset.php
new file mode 100644
index 0000000..cc8cf89
--- /dev/null
+++ b/build/faq/index.asset.php
@@ -0,0 +1 @@
+<?php return array('dependencies' => array('react-jsx-runtime', 'wp-block-editor', 'wp-blocks', 'wp-components', 'wp-element', 'wp-i18n'), 'version' => 'a548f44eb677edc4a936');
diff --git a/build/faq/index.css b/build/faq/index.css
new file mode 100644
index 0000000..61a5670
--- /dev/null
+++ b/build/faq/index.css
@@ -0,0 +1 @@
+.faq-block-editor{background:#f9f9f9;border:2px dashed #ccc;border-radius:8px;padding:2rem}.faq-block-editor .faq-block-preview{text-align:center}.faq-block-editor .faq-block-preview h3{font-size:1.25rem;font-weight:600;margin:0 0 .5rem}.faq-block-editor .faq-block-preview>p{color:#666;margin:0 0 1.5rem}.faq-block-editor .faq-block-preview .faq-sections-preview{background:#fff;border-radius:4px;margin-top:1.5rem;padding:1rem;text-align:left}.faq-block-editor .faq-block-preview .faq-sections-preview strong{display:block;margin-bottom:.5rem}.faq-block-editor .faq-block-preview .faq-sections-preview ol{margin:0;padding-left:1.5rem}.faq-block-editor .faq-block-preview .faq-sections-preview ol li{margin:.25rem 0;padding:.25rem 0}.faq-section-list{display:flex;flex-direction:column;gap:.5rem;margin-top:.5rem}.faq-section-item{align-items:center;background:#fff;border:1px solid #ddd;border-radius:4px;display:flex;gap:.5rem;padding:.5rem .75rem;transition:background .15s ease}.faq-section-item:hover{background:#f9f9f9}.faq-section-controls{display:flex;flex-shrink:0;gap:.25rem}.faq-section-button{height:30px!important;min-width:30px!important;padding:4px!important}.faq-section-button:disabled{cursor:not-allowed;opacity:.3}.faq-section-name{flex:1;font-weight:500;padding-left:.5rem}.components-panel__body .components-notice{margin:1rem 0}
diff --git a/build/faq/index.js b/build/faq/index.js
new file mode 100644
index 0000000..f1bac65
--- /dev/null
+++ b/build/faq/index.js
@@ -0,0 +1 @@
+(()=>{"use strict";var e,s={604:()=>{const e=window.wp.blocks,s=window.wp.blockEditor,n=window.wp.components,i=window.wp.i18n,o=window.wp.element,t=window.ReactJSXRuntime;(0,e.registerBlockType)("jvb/faq",{edit:function({attributes:e,setAttributes:l}){const{sectionOrder:a,showSectionTitles:c,collapseByDefault:r}=e,[d,h]=(0,o.useState)([]),p=window.jvbFaq?.sections||[];(0,o.useEffect)((()=>{if(p.length)if(0===a.length){const e=p.map((e=>({id:e.id,name:e.name})));h(e),l({sectionOrder:e.map((e=>e.id))})}else{const e=[],s=new Set(a);a.forEach((s=>{const n=p.find((e=>e.id===s));n&&e.push({id:n.id,name:n.name})})),p.forEach((n=>{s.has(n.id)||e.push({id:n.id,name:n.name})})),h(e)}}),[p,a]);const b=(e,s)=>{const n=[...d],i="up"===s?e-1:e+1;i<0||i>=n.length||([n[e],n[i]]=[n[i],n[e]],h(n),l({sectionOrder:n.map((e=>e.id))}))},v=(0,s.useBlockProps)({className:"faq-block-editor"});return(0,t.jsxs)(t.Fragment,{children:[(0,t.jsxs)(s.InspectorControls,{children:[(0,t.jsxs)(n.PanelBody,{title:(0,i.__)("FAQ Settings","jvb"),initialOpen:!0,children:[(0,t.jsx)(n.ToggleControl,{label:(0,i.__)("Show Section Titles","jvb"),checked:c,onChange:e=>l({showSectionTitles:e}),help:(0,i.__)("Display section names as headings","jvb")}),(0,t.jsx)(n.ToggleControl,{label:(0,i.__)("Collapse by Default","jvb"),checked:r,onChange:e=>l({collapseByDefault:e}),help:(0,i.__)("Questions start collapsed and expand on click","jvb")})]}),(0,t.jsxs)(n.PanelBody,{title:(0,i.__)("Section Order","jvb"),initialOpen:!1,children:[(0,t.jsx)("p",{className:"components-base-control__help",children:(0,i.__)("Use the arrow buttons to reorder sections","jvb")}),d.length>0?(0,t.jsx)("div",{className:"faq-section-list",children:d.map(((e,s)=>(0,t.jsxs)("div",{className:"faq-section-item",children:[(0,t.jsxs)("div",{className:"faq-section-controls",children:[(0,t.jsx)(n.Button,{icon:"arrow-up-alt2",label:(0,i.__)("Move up","jvb"),disabled:0===s,onClick:()=>b(s,"up"),className:"faq-section-button"}),(0,t.jsx)(n.Button,{icon:"arrow-down-alt2",label:(0,i.__)("Move down","jvb"),disabled:s===d.length-1,onClick:()=>b(s,"down"),className:"faq-section-button"})]}),(0,t.jsx)("span",{className:"faq-section-name",children:e.name})]},e.id)))}):(0,t.jsx)(n.Notice,{status:"info",isDismissible:!1,children:(0,i.__)("No sections found. Create sections in the FAQ taxonomy.","jvb")})]})]}),(0,t.jsx)("div",{...v,children:(0,t.jsxs)("div",{className:"faq-block-preview",children:[(0,t.jsx)("h3",{children:(0,i.__)("FAQ Block","jvb")}),(0,t.jsx)("p",{children:(0,i.__)("This block will display FAQs organized by sections.","jvb")}),d.length>0?(0,t.jsxs)("div",{className:"faq-sections-preview",children:[(0,t.jsx)("strong",{children:(0,i.__)("Section Order:","jvb")}),(0,t.jsx)("ol",{children:d.map((e=>(0,t.jsx)("li",{children:e.name},e.id)))})]}):(0,t.jsx)(n.Notice,{status:"warning",isDismissible:!1,children:(0,i.__)("No sections available. Create sections in the FAQ taxonomy.","jvb")})]})})]})},save:()=>null})}},n={};function i(e){var o=n[e];if(void 0!==o)return o.exports;var t=n[e]={exports:{}};return s[e](t,t.exports,i),t.exports}i.m=s,e=[],i.O=(s,n,o,t)=>{if(!n){var l=1/0;for(d=0;d<e.length;d++){for(var[n,o,t]=e[d],a=!0,c=0;c<n.length;c++)(!1&t||l>=t)&&Object.keys(i.O).every((e=>i.O[e](n[c])))?n.splice(c--,1):(a=!1,t<l&&(l=t));if(a){e.splice(d--,1);var r=o();void 0!==r&&(s=r)}}return s}t=t||0;for(var d=e.length;d>0&&e[d-1][2]>t;d--)e[d]=e[d-1];e[d]=[n,o,t]},i.o=(e,s)=>Object.prototype.hasOwnProperty.call(e,s),(()=>{var e={456:0,656:0};i.O.j=s=>0===e[s];var s=(s,n)=>{var o,t,[l,a,c]=n,r=0;if(l.some((s=>0!==e[s]))){for(o in a)i.o(a,o)&&(i.m[o]=a[o]);if(c)var d=c(i)}for(s&&s(n);r<l.length;r++)t=l[r],i.o(e,t)&&e[t]&&e[t][0](),e[t]=0;return i.O(d)},n=globalThis.webpackChunkjvb=globalThis.webpackChunkjvb||[];n.forEach(s.bind(null,0)),n.push=s.bind(null,n.push.bind(n))})();var o=i.O(void 0,[656],(()=>i(604)));o=i.O(o)})();
\ No newline at end of file
diff --git a/build/faq/style-index-rtl.css b/build/faq/style-index-rtl.css
new file mode 100644
index 0000000..619f4e6
--- /dev/null
+++ b/build/faq/style-index-rtl.css
@@ -0,0 +1 @@
+nav#faq{--height:fit-content;background-color:var(--base-100);border-radius:var(--outerRadius);display:block;padding:1.5rem}nav#faq ol{counter-reset:faq;display:block;height:-moz-fit-content;height:fit-content;list-style:decimal-leading-zero}nav#faq ol li{counter-increment:faq}nav#faq ol li:before{content:counter(faq);display:block;font-family:var(--heading);font-weight:var(--hBold)}nav#faq h2{font-size:var(--large);right:0;margin:.5rem 0}nav#faq a{padding:.5rem}.faq-block{max-width:none;padding-bottom:3rem;width:100%}.faq-block>*{margin:1rem auto;max-width:var(--alignWide)}.faq-block h2{margin:5rem 0 1.5rem}.faq-block h3{margin:0;text-transform:none}.faq-block :target{background-color:var(--base);outline:none}.faq-block :target h2{background-color:var(--base);border-radius:var(--outerRadius);padding:1rem 1.5rem}.faq-block details{margin:1rem auto;max-width:var(--maxWidth);padding:.75rem}.faq-block details+details{margin-top:3rem}.faq-block details .button{display:block;height:-moz-fit-content;height:fit-content;margin-right:auto}
diff --git a/build/faq/style-index.css b/build/faq/style-index.css
new file mode 100644
index 0000000..048e200
--- /dev/null
+++ b/build/faq/style-index.css
@@ -0,0 +1 @@
+nav#faq{--height:fit-content;background-color:var(--base-100);border-radius:var(--outerRadius);display:block;padding:1.5rem}nav#faq ol{counter-reset:faq;display:block;height:-moz-fit-content;height:fit-content;list-style:decimal-leading-zero}nav#faq ol li{counter-increment:faq}nav#faq ol li:before{content:counter(faq);display:block;font-family:var(--heading);font-weight:var(--hBold)}nav#faq h2{font-size:var(--large);left:0;margin:.5rem 0}nav#faq a{padding:.5rem}.faq-block{max-width:none;padding-bottom:3rem;width:100%}.faq-block>*{margin:1rem auto;max-width:var(--alignWide)}.faq-block h2{margin:5rem 0 1.5rem}.faq-block h3{margin:0;text-transform:none}.faq-block :target{background-color:var(--base);outline:none}.faq-block :target h2{background-color:var(--base);border-radius:var(--outerRadius);padding:1rem 1.5rem}.faq-block details{margin:1rem auto;max-width:var(--maxWidth);padding:.75rem}.faq-block details+details{margin-top:3rem}.faq-block details .button{display:block;height:-moz-fit-content;height:fit-content;margin-left:auto}
diff --git a/build/faq/view.asset.php b/build/faq/view.asset.php
new file mode 100644
index 0000000..bc9c643
--- /dev/null
+++ b/build/faq/view.asset.php
@@ -0,0 +1 @@
+<?php return array('dependencies' => array(), 'version' => '5f3b6f1df013ae48fe01');
diff --git a/build/faq/view.js b/build/faq/view.js
new file mode 100644
index 0000000..6fa0efb
--- /dev/null
+++ b/build/faq/view.js
@@ -0,0 +1 @@
+document.addEventListener("DOMContentLoaded",(()=>{if(document.querySelectorAll(".faq-block").forEach((e=>{e.querySelectorAll(".faq-item").forEach((e=>{const t=e.querySelector(".faq-item__question"),i=e.querySelector(".faq-item__answer");t&&i&&(t.addEventListener("click",(()=>{const o="true"===t.getAttribute("aria-expanded");t.setAttribute("aria-expanded",!o),o?(i.style.height=i.scrollHeight+"px",i.offsetHeight,i.style.height="0",setTimeout((()=>{i.style.display="none",i.style.height=""}),300),e.classList.remove("faq-item--expanded")):(i.style.display="block",i.style.height="0",i.offsetHeight,i.style.height=i.scrollHeight+"px",setTimeout((()=>{i.style.height="auto"}),300),e.classList.add("faq-item--expanded"))})),t.addEventListener("keydown",(e=>{" "!==e.key&&"Enter"!==e.key||(e.preventDefault(),t.click())})))}))})),window.location.hash){const e=window.location.hash.substring(1),t=document.querySelector(`[data-faq-id="${e}"]`);if(t){const e=t.querySelector(".faq-item__question");"true"===e.getAttribute("aria-expanded")||e.click(),setTimeout((()=>{t.scrollIntoView({behavior:"smooth",block:"center"})}),350)}}}));
\ No newline at end of file
diff --git a/build/glossary/block.json b/build/glossary/block.json
new file mode 100644
index 0000000..9c7a852
--- /dev/null
+++ b/build/glossary/block.json
@@ -0,0 +1,27 @@
+{
+ "$schema": "https://schemas.wp.org/trunk/block.json",
+ "apiVersion": 3,
+ "name": "jvb/glossary",
+ "version": "0.1.0",
+ "title": "Glossary of Terms",
+ "category": "jvb",
+ "icon": "excerpt-view",
+ "description": "Outputs the terms",
+ "example": {},
+ "supports": {
+ "html": false,
+ "align": [
+ "wide",
+ "full"
+ ]
+ },
+ "textdomain": "jvb",
+ "selectors": {
+ "root": ".glossary"
+ },
+ "editorScript": "file:./index.js",
+ "editorStyle": "file:./index.css",
+ "style": "file:./style-index.css",
+ "render": "file:./render.php",
+ "viewScript": "file:./view.js"
+}
\ No newline at end of file
diff --git a/build/glossary/index-rtl.css b/build/glossary/index-rtl.css
new file mode 100644
index 0000000..8b13789
--- /dev/null
+++ b/build/glossary/index-rtl.css
@@ -0,0 +1 @@
+
diff --git a/build/glossary/index.asset.php b/build/glossary/index.asset.php
new file mode 100644
index 0000000..82832b6
--- /dev/null
+++ b/build/glossary/index.asset.php
@@ -0,0 +1 @@
+<?php return array('dependencies' => array('react-jsx-runtime', 'wp-block-editor', 'wp-blocks', 'wp-i18n'), 'version' => '39ddb4f9b53613e58ee5');
diff --git a/build/glossary/index.css b/build/glossary/index.css
new file mode 100644
index 0000000..8b13789
--- /dev/null
+++ b/build/glossary/index.css
@@ -0,0 +1 @@
+
diff --git a/build/glossary/index.js b/build/glossary/index.js
new file mode 100644
index 0000000..cbae955
--- /dev/null
+++ b/build/glossary/index.js
@@ -0,0 +1 @@
+(()=>{"use strict";var r,o={359:()=>{const r=window.wp.blocks,o=window.wp.i18n,e=window.wp.blockEditor,t=window.ReactJSXRuntime,i=JSON.parse('{"UU":"jvb/glossary"}');(0,r.registerBlockType)(i.UU,{edit:function(){return(0,t.jsx)("p",{...(0,e.useBlockProps)(),children:(0,o.__)("Will output the glossary","jvb")})}})}},e={};function t(r){var i=e[r];if(void 0!==i)return i.exports;var n=e[r]={exports:{}};return o[r](n,n.exports,t),n.exports}t.m=o,r=[],t.O=(o,e,i,n)=>{if(!e){var s=1/0;for(v=0;v<r.length;v++){for(var[e,i,n]=r[v],l=!0,a=0;a<e.length;a++)(!1&n||s>=n)&&Object.keys(t.O).every((r=>t.O[r](e[a])))?e.splice(a--,1):(l=!1,n<s&&(s=n));if(l){r.splice(v--,1);var p=i();void 0!==p&&(o=p)}}return o}n=n||0;for(var v=r.length;v>0&&r[v-1][2]>n;v--)r[v]=r[v-1];r[v]=[e,i,n]},t.o=(r,o)=>Object.prototype.hasOwnProperty.call(r,o),(()=>{var r={342:0,642:0};t.O.j=o=>0===r[o];var o=(o,e)=>{var i,n,[s,l,a]=e,p=0;if(s.some((o=>0!==r[o]))){for(i in l)t.o(l,i)&&(t.m[i]=l[i]);if(a)var v=a(t)}for(o&&o(e);p<s.length;p++)n=s[p],t.o(r,n)&&r[n]&&r[n][0](),r[n]=0;return t.O(v)},e=globalThis.webpackChunkjvb=globalThis.webpackChunkjvb||[];e.forEach(o.bind(null,0)),e.push=o.bind(null,e.push.bind(e))})();var i=t.O(void 0,[642],(()=>t(359)));i=t.O(i)})();
\ No newline at end of file
diff --git a/build/glossary/render.php b/build/glossary/render.php
new file mode 100644
index 0000000..52aded4
--- /dev/null
+++ b/build/glossary/render.php
@@ -0,0 +1,8 @@
+<?php
+/**
+ * @see https://github.com/WordPress/gutenberg/blob/trunk/docs/reference-guides/block-api/block-metadata.md#render
+ */
+?>
+<p <?php echo get_block_wrapper_attributes(); ?>>
+ <?php esc_html_e( 'Menu – hello from a dynamic block!', 'menu' ); ?>
+</p>
diff --git a/build/glossary/style-index-rtl.css b/build/glossary/style-index-rtl.css
new file mode 100644
index 0000000..1710c41
--- /dev/null
+++ b/build/glossary/style-index-rtl.css
@@ -0,0 +1 @@
+:root{--navWidth:40vw}@media(min-width:768px){:root{--navWidth:22vw}}nav.glossary-index{height:60vh;position:fixed;left:0;top:50%;transform:translateY(-50%);width:var(--navWidth);z-index:var(--z-3)}nav.glossary-index>ul{--dir:column;--align:flex-start;height:100%;overflow:hidden auto;scroll-behavior:smooth;touch-action:pan-y;width:100%}nav.glossary-index a,nav.glossary-index li{width:100%}nav.glossary-index a{--justify:center;background-color:var(--overlay-heavy);word-wrap:anywhere;transition:background-color .2s ease;white-space:wrap}nav.glossary-index a.active,nav.glossary-index a:focus,nav.glossary-index a:hover{background-color:rgba(var(--action-rgb),var(--rgb-heavy));color:var(--action-contrast)}.glossary dd{margin-right:.5rem;width:calc(100% + .75rem)}.glossary dd,.glossary dt{right:0;position:relative;transition:margin var(--transition-base),right var(--transition-base),color var(--transition-base),width var(--transition-base)}.glossary dt.active,.glossary dt:target{color:var(--action-0);right:-1.5rem;outline:none;padding:0}.glossary dt.active+dd,.glossary dt:target+dd{right:-1.5rem}dl.glossary,main header{margin-right:0;margin-left:0;max-width:100vw;padding:0 2rem 0 var(--navWidth)}@media(min-width:768px){dl.glossary,main header{margin-right:auto;margin-left:var(--navWidth);max-width:var(--maxWidth);padding-left:var(--height)}}@media(max-width:768px){.glossary h2{font-size:var(--medium)}.glossary p{font-size:var(--small)}.glossary-index a,.glossary-index li{height:-moz-fit-content;height:fit-content}.glossary-index a{font-size:var(--small);min-height:2em;padding:.25rem}body:has(.glossary) h1{font-size:var(--xxlarge)}}
diff --git a/build/glossary/style-index.css b/build/glossary/style-index.css
new file mode 100644
index 0000000..932eb11
--- /dev/null
+++ b/build/glossary/style-index.css
@@ -0,0 +1 @@
+:root{--navWidth:40vw}@media(min-width:768px){:root{--navWidth:22vw}}nav.glossary-index{height:60vh;position:fixed;right:0;top:50%;transform:translateY(-50%);width:var(--navWidth);z-index:var(--z-3)}nav.glossary-index>ul{--dir:column;--align:flex-start;height:100%;overflow:hidden auto;scroll-behavior:smooth;touch-action:pan-y;width:100%}nav.glossary-index a,nav.glossary-index li{width:100%}nav.glossary-index a{--justify:center;background-color:var(--overlay-heavy);word-wrap:anywhere;transition:background-color .2s ease;white-space:wrap}nav.glossary-index a.active,nav.glossary-index a:focus,nav.glossary-index a:hover{background-color:rgba(var(--action-rgb),var(--rgb-heavy));color:var(--action-contrast)}.glossary dd{margin-left:.5rem;width:calc(100% + .75rem)}.glossary dd,.glossary dt{left:0;position:relative;transition:margin var(--transition-base),left var(--transition-base),color var(--transition-base),width var(--transition-base)}.glossary dt.active,.glossary dt:target{color:var(--action-0);left:-1.5rem;outline:none;padding:0}.glossary dt.active+dd,.glossary dt:target+dd{left:-1.5rem}dl.glossary,main header{margin-left:0;margin-right:0;max-width:100vw;padding:0 var(--navWidth) 0 2rem}@media(min-width:768px){dl.glossary,main header{margin-left:auto;margin-right:var(--navWidth);max-width:var(--maxWidth);padding-right:var(--height)}}@media(max-width:768px){.glossary h2{font-size:var(--medium)}.glossary p{font-size:var(--small)}.glossary-index a,.glossary-index li{height:-moz-fit-content;height:fit-content}.glossary-index a{font-size:var(--small);min-height:2em;padding:.25rem}body:has(.glossary) h1{font-size:var(--xxlarge)}}
diff --git a/build/glossary/view.asset.php b/build/glossary/view.asset.php
new file mode 100644
index 0000000..58bb33c
--- /dev/null
+++ b/build/glossary/view.asset.php
@@ -0,0 +1 @@
+<?php return array('dependencies' => array(), 'version' => '90747b206e7cdf47035c');
diff --git a/build/glossary/view.js b/build/glossary/view.js
new file mode 100644
index 0000000..1b7171f
--- /dev/null
+++ b/build/glossary/view.js
@@ -0,0 +1 @@
+(()=>{class t{constructor(t="dl.glossary",e="nav.glossary-index"){this.glossary=document.querySelector(t),this.nav=document.querySelector(e),this.glossary&&this.nav&&(this.terms=this.glossary.querySelectorAll("dt[id]"),this.navList=this.nav.querySelector("ul"),this.activeClass="active",this.currentActive=null,this.breakpoint=768,this.init(),this.setupResizeHandler())}init(){const t={root:null,rootMargin:this.getRootMargin(),threshold:0};this.observer=new IntersectionObserver((t=>this.handleIntersection(t)),t),this.terms.forEach((t=>this.observer.observe(t))),this.handleScroll=this.debounce((()=>this.checkActiveTerm()),100),window.addEventListener("scroll",this.handleScroll,{passive:!0})}getRootMargin(){if(window.innerWidth<this.breakpoint){const t=parseFloat(getComputedStyle(document.documentElement).fontSize),e=Math.round(5*t);return`-${e}px 0px -${e}px 0px`}return"-50% 0px -50% 0px"}setupResizeHandler(){let t;window.addEventListener("resize",(()=>{clearTimeout(t),t=setTimeout((()=>{this.reinitialize()}),250)}))}reinitialize(){this.observer&&this.observer.disconnect(),this.init()}handleIntersection(t){const e=t.find((t=>t.isIntersecting));e&&this.setActive(e.target)}checkActiveTerm(){const t=4*parseFloat(getComputedStyle(document.documentElement).fontSize);let e=null,i=1/0;this.terms.forEach((s=>{const n=s.getBoundingClientRect();if(window.innerWidth<this.breakpoint?n.top>=t&&n.top<=window.innerHeight-t:n.top+n.height/2>=0&&n.top+n.height/2<=window.innerHeight){const o=window.innerWidth<this.breakpoint?t:window.innerHeight/2,r=Math.abs(n.top-o);r<i&&(i=r,e=s)}})),e&&this.setActive(e)}setActive(t){this.currentActive!==t&&(this.currentActive&&this.currentActive.classList.remove(this.activeClass),t.classList.add(this.activeClass),this.currentActive=t,this.updateNavigation(t.id))}updateNavigation(t){this.nav.querySelectorAll("a").forEach((t=>t.classList.remove(this.activeClass)));const e=this.nav.querySelector(`a[href="#${t}"]`);e&&(e.classList.add(this.activeClass),this.centerNavItem(e))}centerNavItem(t){const e=this.navList.getBoundingClientRect(),i=t.getBoundingClientRect(),s=this.navList.scrollTop,n=i.top-e.top,o=e.height/2-i.height/2;this.navList.scrollTo({top:s+n-o,behavior:"smooth"})}debounce(t,e){let i;return function(...s){clearTimeout(i),i=setTimeout((()=>{clearTimeout(i),t(...s)}),e)}}destroy(){this.observer&&this.observer.disconnect(),window.removeEventListener("scroll",this.handleScroll)}}"loading"===document.readyState?document.addEventListener("DOMContentLoaded",(()=>{new t})):new t})();
\ No newline at end of file
diff --git a/build/gmbreviews/block.json b/build/gmbreviews/block.json
new file mode 100644
index 0000000..93206b0
--- /dev/null
+++ b/build/gmbreviews/block.json
@@ -0,0 +1,74 @@
+{
+ "$schema": "https://schemas.wp.org/trunk/block.json",
+ "apiVersion": 3,
+ "name": "jvb/gmbreviews",
+ "title": "GMB Reviews",
+ "category": "jvb",
+ "description": "Display top-rated Google My Business reviews with statistics and action buttons",
+ "keywords": [
+ "reviews",
+ "google",
+ "testimonials",
+ "gmb",
+ "ratings"
+ ],
+ "textdomain": "jvb",
+ "attributes": {
+ "inheritUser": {
+ "type": "boolean",
+ "default": false
+ },
+ "count": {
+ "type": "number",
+ "default": 5
+ },
+ "showRating": {
+ "type": "boolean",
+ "default": true
+ },
+ "showDate": {
+ "type": "boolean",
+ "default": true
+ },
+ "showReviewLink": {
+ "type": "boolean",
+ "default": true
+ },
+ "showViewAllLink": {
+ "type": "boolean",
+ "default": true
+ },
+ "showStats": {
+ "type": "boolean",
+ "default": true
+ },
+ "minStars": {
+ "type": "number",
+ "default": 4,
+ "minimum": 1,
+ "maximum": 5
+ }
+ },
+ "supports": {
+ "html": false,
+ "align": true,
+ "color": {
+ "text": true,
+ "background": true,
+ "link": true
+ },
+ "spacing": {
+ "margin": true,
+ "padding": true
+ },
+ "typography": {
+ "fontSize": true,
+ "lineHeight": true
+ }
+ },
+ "render": "file:./render.php",
+ "editorScript": "file:./index.js",
+ "editorStyle": "file:./index.css",
+ "style": "file:./style-index.css",
+ "viewScript": "file:./view.js"
+}
\ No newline at end of file
diff --git a/build/gmbreviews/index-rtl.css b/build/gmbreviews/index-rtl.css
new file mode 100644
index 0000000..8b13789
--- /dev/null
+++ b/build/gmbreviews/index-rtl.css
@@ -0,0 +1 @@
+
diff --git a/build/gmbreviews/index.asset.php b/build/gmbreviews/index.asset.php
new file mode 100644
index 0000000..f03bf4f
--- /dev/null
+++ b/build/gmbreviews/index.asset.php
@@ -0,0 +1 @@
+<?php return array('dependencies' => array('react-jsx-runtime', 'wp-block-editor', 'wp-blocks', 'wp-components', 'wp-i18n', 'wp-server-side-render'), 'version' => 'eb5533beee4ec68e1d0a');
diff --git a/build/gmbreviews/index.css b/build/gmbreviews/index.css
new file mode 100644
index 0000000..8b13789
--- /dev/null
+++ b/build/gmbreviews/index.css
@@ -0,0 +1 @@
+
diff --git a/build/gmbreviews/index.js b/build/gmbreviews/index.js
new file mode 100644
index 0000000..9a661c1
--- /dev/null
+++ b/build/gmbreviews/index.js
@@ -0,0 +1 @@
+(()=>{"use strict";var e,o={819:(e,o,n)=>{const r=window.wp.blocks,t=window.wp.blockEditor,i=window.wp.components,l=window.wp.i18n,s=window.wp.serverSideRender;var a=n.n(s);const h=window.ReactJSXRuntime,v=JSON.parse('{"UU":"jvb/gmbreviews"}');(0,r.registerBlockType)(v.UU,{edit:function({attributes:e,setAttributes:o}){const n=(0,t.useBlockProps)(),{count:r,inheritUser:s,showStats:v,minStars:w,showViewAllLink:b,showRating:c,showDate:g,showReviewLink:d}=e;return(0,h.jsxs)(h.Fragment,{children:[(0,h.jsx)(t.InspectorControls,{children:(0,h.jsxs)(i.PanelBody,{title:(0,l.__)("Review Settings","jvb"),children:[(0,h.jsx)(i.ToggleControl,{label:(0,l.__)("Inherit User","jvb"),checked:s,onChange:e=>o({inheritUser:e})}),(0,h.jsx)(i.RangeControl,{label:(0,l.__)("Number of Reviews","jvb"),value:r,onChange:e=>o({count:e}),min:1,max:20}),(0,h.jsx)(i.ToggleControl,{label:(0,l.__)("Show Rating","jvb"),checked:c,onChange:e=>o({showRating:e})}),(0,h.jsx)(i.ToggleControl,{label:(0,l.__)("Show Date","jvb"),checked:g,onChange:e=>o({showDate:e})}),(0,h.jsx)(i.ToggleControl,{label:(0,l.__)("Show Review Link","jvb"),checked:d,onChange:e=>o({showReviewLink:e})}),(0,h.jsx)(i.ToggleControl,{label:(0,l.__)("Show Stats","jvb"),checked:v,onChange:e=>o({showStats:e})}),(0,h.jsx)(i.ToggleControl,{label:(0,l.__)("Show All Reviews Link","jvb"),checked:b,onChange:e=>o({showViewAllLink:e})}),(0,h.jsx)(i.RangeControl,{label:(0,l.__)("Minimum Rating","jvb"),value:w,onChange:e=>o({minStars:e}),min:1,max:5})]})}),(0,h.jsx)("div",{...n,children:(0,h.jsx)(a(),{block:"jvb/gmbreviews",attributes:e})})]})},save:()=>null})}},n={};function r(e){var t=n[e];if(void 0!==t)return t.exports;var i=n[e]={exports:{}};return o[e](i,i.exports,r),i.exports}r.m=o,e=[],r.O=(o,n,t,i)=>{if(!n){var l=1/0;for(v=0;v<e.length;v++){for(var[n,t,i]=e[v],s=!0,a=0;a<n.length;a++)(!1&i||l>=i)&&Object.keys(r.O).every((e=>r.O[e](n[a])))?n.splice(a--,1):(s=!1,i<l&&(l=i));if(s){e.splice(v--,1);var h=t();void 0!==h&&(o=h)}}return o}i=i||0;for(var v=e.length;v>0&&e[v-1][2]>i;v--)e[v]=e[v-1];e[v]=[n,t,i]},r.n=e=>{var o=e&&e.__esModule?()=>e.default:()=>e;return r.d(o,{a:o}),o},r.d=(e,o)=>{for(var n in o)r.o(o,n)&&!r.o(e,n)&&Object.defineProperty(e,n,{enumerable:!0,get:o[n]})},r.o=(e,o)=>Object.prototype.hasOwnProperty.call(e,o),(()=>{var e={807:0,423:0};r.O.j=o=>0===e[o];var o=(o,n)=>{var t,i,[l,s,a]=n,h=0;if(l.some((o=>0!==e[o]))){for(t in s)r.o(s,t)&&(r.m[t]=s[t]);if(a)var v=a(r)}for(o&&o(n);h<l.length;h++)i=l[h],r.o(e,i)&&e[i]&&e[i][0](),e[i]=0;return r.O(v)},n=globalThis.webpackChunkjvb=globalThis.webpackChunkjvb||[];n.forEach(o.bind(null,0)),n.push=o.bind(null,n.push.bind(n))})();var t=r.O(void 0,[423],(()=>r(819)));t=r.O(t)})();
\ No newline at end of file
diff --git a/build/gmbreviews/render.php b/build/gmbreviews/render.php
new file mode 100644
index 0000000..47ad2b5
--- /dev/null
+++ b/build/gmbreviews/render.php
@@ -0,0 +1,203 @@
+<?php
+/**
+ * GMB Reviews Block - Render Template
+ *
+ * Displays recent Google My Business reviews with a link to leave a review
+ */
+function jvbRenderGMBReviewsBlock(array $attributes): string
+{
+ $count = $attributes['count'] ?? 5;
+ $showRating = $attributes['showRating'] ?? true;
+ $showDate = $attributes['showDate'] ?? true;
+ $showReviewLink = $attributes['showReviewLink'] ?? true;
+ $showViewAllLink = $attributes['showViewAllLink'] ?? true;
+ $showStats = $attributes['showStats'] ?? true;
+ $minStars = $attributes['minStars'] ?? 4; // Only show 4+ star reviews
+ $inheritUser = $attributes['inheritUser']??null;
+ if ($inheritUser) {
+ global $post;
+ $inheritUser = $post->post_author;
+ }
+ try {
+ $gmb = JVB()->connect('gmb', $inheritUser);
+ if (!$gmb->isSetUp()) {
+ error_log('GMB Not set up for: '.(int)$inheritUser);
+ return '';
+ }
+ $gotReviews = $gmb->getReviews();
+ // Get all data
+ $allReviews = $gotReviews['reviews']??[];
+ $reviewUrl = $gmb->getReviewUrl();
+ $viewAllUrl = $gmb->getReviewsViewUrl();
+
+ $average = $gotReviews['averageRating']??null;
+ $total = $gotReviews['totalReviewCount']??null;
+
+ // Filter reviews by minimum stars
+ $reviews = [];
+ if (!empty($allReviews)) {
+ foreach ($allReviews as $review) {
+ $rating = $review['starRating'] ?? 0;
+ if ($rating >= $minStars) {
+ $reviews[] = $review;
+ if (count($reviews) >= $count) {
+ break; // Got enough reviews
+ }
+ }
+ }
+ }
+
+ if (empty($reviews) && empty($reviewUrl) && empty($stats)) {
+ error_log('No reviews to display...');
+ return '';
+ }
+
+ ob_start();
+ ?>
+ <div class="gmb-reviews">
+ <div class="row btw">
+ <?php
+ if ($showStats && !empty($average) && !empty($total)) {
+ ?>
+ <p>
+ <span class="stars" aria-label="<?= $average ?> out of 5 stars">
+ <?php
+ $fullStars = floor($average);
+ $hasHalfStar = ($average - $fullStars) >= 0.5;
+
+ for ($i = 1; $i <= 5; $i++) {
+ if ($i <= $fullStars) {
+ echo jvbIcon('star', ['style' => 'fill']);
+ } elseif ($i == $fullStars + 1 && $hasHalfStar) {
+ echo jvbIcon('star-half', ['style'=> 'fill']);
+ } else {
+ echo jvbIcon('star', ['style' => 'light']);
+ }
+ }
+ ?>
+ </span>
+ <i>Average</i>
+ </p>
+ <?php
+ if ($total > 0) {
+ ?>
+ <p><i>{ <?= number_format($total ) . ' ' . _n('Review', 'Reviews', $total, 'jvb')?> Total }</i></p>
+ <?php
+ }
+ ?>
+ <?php
+ }
+ ?>
+
+ <?php
+ if ($showReviewLink && !empty($reviewUrl)) {
+ ?>
+ <a href="<?=esc_url($reviewUrl)?>"
+ class="button"
+ target="_blank"
+ rel="noopener noreferrer">
+ <?= jvbIcon('star', ['style' => 'fill']) ?>
+ Leave Your Review
+ </a>
+ <?php
+ }
+ ?>
+ </div>
+
+ <ul>
+ <?php
+ foreach ($reviews as $review) {
+ $reviewer = $review['reviewer']['displayName'] ?? 'Anonymous';
+ $profilePhoto = $review['reviewer']['profilePhotoUrl'] ?? '';
+ $rating = $review['starRating'] ?? 0;
+ $rating = match($rating) {
+ 'FIVE' => 5,
+ 'FOUR' => 4,
+ 'THREE' => 3,
+ 'TWO' => 2,
+ 'ONE' => 1,
+ default => $rating
+ };
+ $comment = $review['comment'] ?? '';
+ $date = $review['updateTime'] ?? '';
+ ?>
+ <li>
+ <article class="review">
+ <header class="row btw">
+ <?php if (!empty($profilePhoto)) { ?>
+ <img src="<?=esc_url($profilePhoto)?>"
+ alt="<?=esc_attr($reviewer)?>"
+ 'loading="lazy">
+ <?php } else { ?>
+ <div class="avatar">
+ <?= jvbIcon('user-circle')?>
+ </div>
+ <?php } ?>
+
+ <div class="col end">
+ <h4><?= esc_html($reviewer)?></h4>
+ <?php
+ // Date
+ if ($showDate && !empty($date)) {
+ $formatted_date = human_time_diff(strtotime($date), current_time('timestamp')) . ' ago';
+ ?>
+ <time datetime="<?=esc_attr($date)?>">
+ <?= esc_html($formatted_date) ?>
+ </time>
+ <?php } ?>
+ <?php if ($showRating && $rating > 0) { ?>
+ <div class="stars" aria-label="<?= $rating ?> out of 5 stars">
+ <?php
+ for ($i = 1; $i <= 5; $i++) {
+ echo ($i <= $rating) ? jvbIcon('star', ['style' => 'fill']) : jvbIcon('star', ['style' => 'light']);
+ } ?>
+ </div>
+ <?php } ?>
+ </div>
+
+ </header>
+ <?php
+ // Review text
+ if (!empty($comment)) { ?>
+ <div class="review">
+ <?= apply_filters('the_content', $comment) ?>
+ </div>
+ <?php } ?>
+ </article>
+ </li>
+ <?php
+ }
+ ?>
+ </ul>
+ <?php
+ // Footer with "See All Reviews" button
+ if ($showViewAllLink && !empty($viewAllUrl)) {
+ ?>
+ <div class="footer">
+ <a href=" <?= esc_url($viewAllUrl) ?>"
+ class="button"
+ target="_blank"
+ rel="noopener noreferrer">
+
+ <?php
+ if ($showStats ) {
+ echo 'See All ' . number_format($total) . ' Reviews';
+ } else {
+ echo ' See All Reviews';
+ }
+ ?>
+ <?= jvbIcon('arrow-square-out') ?>
+ </a>
+ </div>
+ <?php
+ }
+ ?>
+ </div>
+ <?php
+ return ob_get_clean();
+
+ } catch (\Exception $e) {
+ error_log('[GMB Reviews Block] Error: ' . $e->getMessage());
+ return '';
+ }
+}
diff --git a/build/gmbreviews/style-index-rtl.css b/build/gmbreviews/style-index-rtl.css
new file mode 100644
index 0000000..a413f26
--- /dev/null
+++ b/build/gmbreviews/style-index-rtl.css
@@ -0,0 +1 @@
+.gmb-reviews>.row.btw .button{height:-moz-max-content;height:max-content;width:100%}.gmb-reviews>.row.btw p{width:-moz-fit-content;width:fit-content}.gmb-reviews .stars{display:inline-block;vertical-align:middle}.gmb-reviews ul{list-style:none;margin:0;padding:0}.gmb-reviews ul li{margin:2rem 0;position:relative}.gmb-reviews ul li:nth-of-type(odd){right:-2rem}.gmb-reviews ul li:nth-of-type(2n){left:-2rem}.gmb-reviews article{background-color:var(--base);border-radius:var(--outerRadius);padding:1rem}.gmb-reviews article header{--align:center}.gmb-reviews article header>img{right:0;position:relative}.gmb-reviews article time{font-style:italic}.gmb-reviews article .review{padding:1.5rem}.gmb-reviews article h4{width:-moz-max-content;width:max-content}.gmb-reviews article .icon{color:var(--action-0)}.gmb-reviews .footer .button{width:100%}
diff --git a/build/gmbreviews/style-index.css b/build/gmbreviews/style-index.css
new file mode 100644
index 0000000..963482d
--- /dev/null
+++ b/build/gmbreviews/style-index.css
@@ -0,0 +1 @@
+.gmb-reviews>.row.btw .button{height:-moz-max-content;height:max-content;width:100%}.gmb-reviews>.row.btw p{width:-moz-fit-content;width:fit-content}.gmb-reviews .stars{display:inline-block;vertical-align:middle}.gmb-reviews ul{list-style:none;margin:0;padding:0}.gmb-reviews ul li{margin:2rem 0;position:relative}.gmb-reviews ul li:nth-of-type(odd){left:-2rem}.gmb-reviews ul li:nth-of-type(2n){right:-2rem}.gmb-reviews article{background-color:var(--base);border-radius:var(--outerRadius);padding:1rem}.gmb-reviews article header{--align:center}.gmb-reviews article header>img{left:0;position:relative}.gmb-reviews article time{font-style:italic}.gmb-reviews article .review{padding:1.5rem}.gmb-reviews article h4{width:-moz-max-content;width:max-content}.gmb-reviews article .icon{color:var(--action-0)}.gmb-reviews .footer .button{width:100%}
diff --git a/build/gmbreviews/view.asset.php b/build/gmbreviews/view.asset.php
new file mode 100644
index 0000000..f534533
--- /dev/null
+++ b/build/gmbreviews/view.asset.php
@@ -0,0 +1 @@
+<?php return array('dependencies' => array(), 'version' => '31d6cfe0d16ae931b73c');
diff --git a/build/gmbreviews/view.js b/build/gmbreviews/view.js
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/build/gmbreviews/view.js
diff --git a/build/summary/index.asset.php b/build/summary/index.asset.php
index 6318dd7..8ddd4ca 100644
--- a/build/summary/index.asset.php
+++ b/build/summary/index.asset.php
@@ -1 +1 @@
-<?php return array('dependencies' => array('react-jsx-runtime', 'wp-block-editor', 'wp-blocks', 'wp-components', 'wp-i18n'), 'version' => '394359c6b731b96ae4a1');
+<?php return array('dependencies' => array('react-jsx-runtime', 'wp-block-editor', 'wp-blocks', 'wp-components', 'wp-i18n'), 'version' => '42cbcc5837f1be561301');
diff --git a/build/summary/index.js b/build/summary/index.js
index 74e2909..9ff7fa2 100644
--- a/build/summary/index.js
+++ b/build/summary/index.js
@@ -1 +1 @@
-(()=>{"use strict";var r,e={955:()=>{const r=window.wp.blocks,e=window.wp.i18n,t=window.wp.blockEditor,n=(window.wp.components,window.ReactJSXRuntime);(0,r.registerBlockType)("jvb/summary",{edit:function({attributes:r,setAttributes:o}){const i=(0,t.useBlockProps)();return(0,n.jsx)("div",{...i,children:(0,n.jsxs)("div",{className:"jvb-summary-preview",children:[(0,n.jsx)("h3",{children:(0,e.__)("Summary","jvb")}),(0,n.jsx)("p",{className:"jvb-list-preview-note",children:(0,e.__)("This will inherit the current query to build the information from our custom meta on the front end.","jvb")})]})})},save:function(){return null}})}},t={};function n(r){var o=t[r];if(void 0!==o)return o.exports;var i=t[r]={exports:{}};return e[r](i,i.exports,n),i.exports}n.m=e,r=[],n.O=(e,t,o,i)=>{if(!t){var s=1/0;for(c=0;c<r.length;c++){for(var[t,o,i]=r[c],l=!0,a=0;a<t.length;a++)(!1&i||s>=i)&&Object.keys(n.O).every((r=>n.O[r](t[a])))?t.splice(a--,1):(l=!1,i<s&&(s=i));if(l){r.splice(c--,1);var u=o();void 0!==u&&(e=u)}}return e}i=i||0;for(var c=r.length;c>0&&r[c-1][2]>i;c--)r[c]=r[c-1];r[c]=[t,o,i]},n.o=(r,e)=>Object.prototype.hasOwnProperty.call(r,e),(()=>{var r={592:0,456:0};n.O.j=e=>0===r[e];var e=(e,t)=>{var o,i,[s,l,a]=t,u=0;if(s.some((e=>0!==r[e]))){for(o in l)n.o(l,o)&&(n.m[o]=l[o]);if(a)var c=a(n)}for(e&&e(t);u<s.length;u++)i=s[u],n.o(r,i)&&r[i]&&r[i][0](),r[i]=0;return n.O(c)},t=globalThis.webpackChunkjvb=globalThis.webpackChunkjvb||[];t.forEach(e.bind(null,0)),t.push=e.bind(null,t.push.bind(t))})();var o=n.O(void 0,[456],(()=>n(955)));o=n.O(o)})();
\ No newline at end of file
+(()=>{"use strict";var r,e={955:()=>{const r=window.wp.blocks,e=window.wp.i18n,t=window.wp.blockEditor,n=(window.wp.components,window.ReactJSXRuntime);(0,r.registerBlockType)("jvb/summary",{edit:function({attributes:r,setAttributes:o}){const i=(0,t.useBlockProps)();return(0,n.jsx)("div",{...i,children:(0,n.jsxs)("div",{className:"jvb-summary-preview",children:[(0,n.jsx)("h3",{children:(0,e.__)("Summary","jvb")}),(0,n.jsx)("p",{className:"jvb-list-preview-note",children:(0,e.__)("This will inherit the current query to build the information from our custom meta on the front end.","jvb")})]})})},save:function(){return null}})}},t={};function n(r){var o=t[r];if(void 0!==o)return o.exports;var i=t[r]={exports:{}};return e[r](i,i.exports,n),i.exports}n.m=e,r=[],n.O=(e,t,o,i)=>{if(!t){var s=1/0;for(c=0;c<r.length;c++){for(var[t,o,i]=r[c],l=!0,a=0;a<t.length;a++)(!1&i||s>=i)&&Object.keys(n.O).every((r=>n.O[r](t[a])))?t.splice(a--,1):(l=!1,i<s&&(s=i));if(l){r.splice(c--,1);var u=o();void 0!==u&&(e=u)}}return e}i=i||0;for(var c=r.length;c>0&&r[c-1][2]>i;c--)r[c]=r[c-1];r[c]=[t,o,i]},n.o=(r,e)=>Object.prototype.hasOwnProperty.call(r,e),(()=>{var r={592:0,75:0};n.O.j=e=>0===r[e];var e=(e,t)=>{var o,i,[s,l,a]=t,u=0;if(s.some((e=>0!==r[e]))){for(o in l)n.o(l,o)&&(n.m[o]=l[o]);if(a)var c=a(n)}for(e&&e(t);u<s.length;u++)i=s[u],n.o(r,i)&&r[i]&&r[i][0](),r[i]=0;return n.O(c)},t=globalThis.webpackChunkjvb=globalThis.webpackChunkjvb||[];t.forEach(e.bind(null,0)),t.push=e.bind(null,t.push.bind(t))})();var o=n.O(void 0,[75],(()=>n(955)));o=n.O(o)})();
\ No newline at end of file
diff --git a/build/video/index.asset.php b/build/video/index.asset.php
index 1eb3395..038e942 100644
--- a/build/video/index.asset.php
+++ b/build/video/index.asset.php
@@ -1 +1 @@
-<?php return array('dependencies' => array('react-jsx-runtime', 'wp-block-editor', 'wp-blocks', 'wp-components', 'wp-i18n'), 'version' => '0bc754bf0d806d2540bd');
+<?php return array('dependencies' => array('react-jsx-runtime', 'wp-block-editor', 'wp-blocks', 'wp-components', 'wp-i18n'), 'version' => '81699d43607c083e50c8');
diff --git a/build/video/index.js b/build/video/index.js
index 5ccd292..c62b234 100644
--- a/build/video/index.js
+++ b/build/video/index.js
@@ -1 +1 @@
-(()=>{"use strict";var e,o={128:()=>{const e=window.wp.blocks,o=window.wp.i18n,l=window.wp.blockEditor,t=window.wp.components,i=window.ReactJSXRuntime,r=["video/mp4","video/webm","video/ogg","video/ogv"],n=[["core/heading",{level:1,placeholder:"Add heading...",textAlign:"center"}],["core/paragraph",{placeholder:"Add description...",align:"center"}],["core/buttons",{layout:{type:"flex",justifyContent:"center"}}]];(0,e.registerBlockType)("jvb/video",{edit:function({attributes:e,setAttributes:s}){const{posterId:a,posterUrl:d,videoSources:c,mobileSources:v,fadeEffect:p,overlayOpacity:b,contentAlignment:m,minHeight:h}=e,j=(0,l.useBlockProps)({className:"video-cover-editor",style:{minHeight:h?`${h}px`:void 0}}),_=(0,l.useInnerBlocksProps)({className:"video-cover-content"},{template:n,templateLock:!1}),u=(e,o=!1)=>{const l={id:e.id,url:e.url,mime:e.mime};o?v.some((o=>o.mime===e.mime))||s({mobileSources:[...v,l]}):c.some((o=>o.mime===e.mime))||s({videoSources:[...c,l]})},g=(e,l=!1)=>0===e.length?null:(0,i.jsx)("ul",{className:"video-source-list",children:e.map(((e,r)=>(0,i.jsxs)("li",{className:"video-source-item",children:[(0,i.jsx)("span",{className:"video-source-mime",children:e.mime}),(0,i.jsx)(t.Button,{isDestructive:!0,isSmall:!0,onClick:()=>((e,o=!1)=>{if(o){const o=[...v];o.splice(e,1),s({mobileSources:o})}else{const o=[...c];o.splice(e,1),s({videoSources:o})}})(r,l),children:(0,o.__)("Remove","jvb")})]},r)))});return(0,i.jsxs)(i.Fragment,{children:[(0,i.jsxs)(l.InspectorControls,{children:[(0,i.jsxs)(t.PanelBody,{title:(0,o.__)("Video Settings","jvb"),initialOpen:!0,children:[(0,i.jsx)(t.BaseControl,{label:(0,o.__)("Poster Image","jvb"),help:(0,o.__)("Image shown while video loads","jvb"),children:(0,i.jsx)(l.MediaUploadCheck,{children:(0,i.jsx)(l.MediaUpload,{onSelect:e=>{s({posterId:e.id,posterUrl:e.url})},allowedTypes:["image"],value:a,render:({open:e})=>(0,i.jsxs)(i.Fragment,{children:[d&&(0,i.jsx)("img",{src:d,alt:(0,o.__)("Poster preview","jvb"),style:{maxWidth:"100%",marginBottom:"10px"}}),(0,i.jsx)(t.Button,{onClick:e,variant:d?"secondary":"primary",children:d?(0,o.__)("Change Poster","jvb"):(0,o.__)("Select Poster","jvb")}),d&&(0,i.jsx)(t.Button,{isDestructive:!0,onClick:()=>s({posterId:0,posterUrl:""}),style:{marginLeft:"10px"},children:(0,o.__)("Remove","jvb")})]})})})}),(0,i.jsxs)(t.BaseControl,{label:(0,o.__)("Desktop Video Sources","jvb"),help:(0,o.__)("Add multiple formats for better browser support","jvb"),children:[g(c,!1),(0,i.jsx)(l.MediaUploadCheck,{children:(0,i.jsx)(l.MediaUpload,{onSelect:e=>u(e,!1),allowedTypes:r,render:({open:e})=>(0,i.jsx)(t.Button,{onClick:e,variant:"secondary",children:(0,o.__)("Add Desktop Video","jvb")})})})]}),(0,i.jsxs)(t.BaseControl,{label:(0,o.__)("Mobile Video Sources (Optional)","jvb"),help:(0,o.__)("Smaller videos for mobile devices","jvb"),children:[g(v,!0),(0,i.jsx)(l.MediaUploadCheck,{children:(0,i.jsx)(l.MediaUpload,{onSelect:e=>u(e,!0),allowedTypes:r,render:({open:e})=>(0,i.jsx)(t.Button,{onClick:e,variant:"secondary",children:(0,o.__)("Add Mobile Video","jvb")})})})]}),(0,i.jsx)(t.ToggleControl,{label:(0,o.__)("Fade Effect","jvb"),help:(0,o.__)("Add fade class to video element","jvb"),checked:p,onChange:e=>s({fadeEffect:e})})]}),(0,i.jsxs)(t.PanelBody,{title:(0,o.__)("Overlay Settings","jvb"),initialOpen:!0,children:[(0,i.jsx)(t.RangeControl,{label:(0,o.__)("Overlay Opacity","jvb"),help:(0,o.__)("Darken video for better text readability","jvb"),value:b,onChange:e=>s({overlayOpacity:e}),min:0,max:100,step:5}),(0,i.jsx)(t.SelectControl,{label:(0,o.__)("Content Alignment","jvb"),value:m,options:[{label:(0,o.__)("Top Left","jvb"),value:"top-left"},{label:(0,o.__)("Top Center","jvb"),value:"top-center"},{label:(0,o.__)("Top Right","jvb"),value:"top-right"},{label:(0,o.__)("Center Left","jvb"),value:"center-left"},{label:(0,o.__)("Center","jvb"),value:"center"},{label:(0,o.__)("Center Right","jvb"),value:"center-right"},{label:(0,o.__)("Bottom Left","jvb"),value:"bottom-left"},{label:(0,o.__)("Bottom Center","jvb"),value:"bottom-center"},{label:(0,o.__)("Bottom Right","jvb"),value:"bottom-right"}],onChange:e=>s({contentAlignment:e})}),(0,i.jsx)(t.RangeControl,{label:(0,o.__)("Minimum Height","jvb"),help:(0,o.__)("Minimum height in pixels (leave 0 for auto)","jvb"),value:h,onChange:e=>s({minHeight:e}),min:0,max:1e3,step:50})]})]}),(0,i.jsx)("div",{...j,children:d||c.length>0?(0,i.jsxs)("div",{className:"video-cover-preview",children:[d&&(0,i.jsxs)(i.Fragment,{children:[(0,i.jsx)("img",{src:d,alt:(0,o.__)("Video poster","jvb")}),b>0&&(0,i.jsx)("div",{className:"video-overlay-preview",style:{opacity:b/100}})]}),(0,i.jsx)("div",{className:`video-cover-content-preview align-${m}`,children:(0,i.jsx)("div",{..._})}),(0,i.jsx)("div",{className:"video-info",children:(0,i.jsxs)("p",{children:[c.length," ",(0,o.__)("desktop source(s)","jvb"),v.length>0&&`, ${v.length} ${(0,o.__)("mobile source(s)","jvb")}`]})})]}):(0,i.jsx)("div",{className:"video-cover-placeholder",children:(0,i.jsx)("p",{children:(0,o.__)("Configure video sources in the sidebar →","jvb")})})})]})},save:()=>null})}},l={};function t(e){var i=l[e];if(void 0!==i)return i.exports;var r=l[e]={exports:{}};return o[e](r,r.exports,t),r.exports}t.m=o,e=[],t.O=(o,l,i,r)=>{if(!l){var n=1/0;for(c=0;c<e.length;c++){for(var[l,i,r]=e[c],s=!0,a=0;a<l.length;a++)(!1&r||n>=r)&&Object.keys(t.O).every((e=>t.O[e](l[a])))?l.splice(a--,1):(s=!1,r<n&&(n=r));if(s){e.splice(c--,1);var d=i();void 0!==d&&(o=d)}}return o}r=r||0;for(var c=e.length;c>0&&e[c-1][2]>r;c--)e[c]=e[c-1];e[c]=[l,i,r]},t.o=(e,o)=>Object.prototype.hasOwnProperty.call(e,o),(()=>{var e={205:0,601:0};t.O.j=o=>0===e[o];var o=(o,l)=>{var i,r,[n,s,a]=l,d=0;if(n.some((o=>0!==e[o]))){for(i in s)t.o(s,i)&&(t.m[i]=s[i]);if(a)var c=a(t)}for(o&&o(l);d<n.length;d++)r=n[d],t.o(e,r)&&e[r]&&e[r][0](),e[r]=0;return t.O(c)},l=globalThis.webpackChunkjvb=globalThis.webpackChunkjvb||[];l.forEach(o.bind(null,0)),l.push=o.bind(null,l.push.bind(l))})();var i=t.O(void 0,[601],(()=>t(128)));i=t.O(i)})();
\ No newline at end of file
+(()=>{"use strict";var e,o={747:()=>{const e=window.wp.blocks,o=window.wp.blockEditor,l=window.wp.i18n,t=window.wp.components,i=window.ReactJSXRuntime,r=["video/mp4","video/webm","video/ogg","video/ogv"],n=[["core/heading",{level:1,placeholder:"Add heading...",textAlign:"center"}],["core/paragraph",{placeholder:"Add description...",align:"center"}],["core/buttons",{layout:{type:"flex",justifyContent:"center"}}]];(0,e.registerBlockType)("jvb/video",{edit:function({attributes:e,setAttributes:s}){const{posterId:a,posterUrl:c,videoSources:d,mobileSources:v,fadeEffect:p,overlayOpacity:m,contentAlignment:b,minHeight:h}=e,j=(0,o.useBlockProps)({className:"video-cover-editor",style:{minHeight:h?`${h}px`:void 0}}),u=(0,o.useInnerBlocksProps)({className:"video-cover-content"},{template:n,templateLock:!1}),_=(e,o=!1)=>{const l={id:e.id,url:e.url,mime:e.mime};o?v.some((o=>o.mime===e.mime))||s({mobileSources:[...v,l]}):d.some((o=>o.mime===e.mime))||s({videoSources:[...d,l]})},g=(e,o=!1)=>0===e.length?null:(0,i.jsx)("ul",{className:"video-source-list",children:e.map(((e,r)=>(0,i.jsxs)("li",{className:"video-source-item",children:[(0,i.jsx)("span",{className:"video-source-mime",children:e.mime}),(0,i.jsx)(t.Button,{isDestructive:!0,isSmall:!0,onClick:()=>((e,o=!1)=>{if(o){const o=[...v];o.splice(e,1),s({mobileSources:o})}else{const o=[...d];o.splice(e,1),s({videoSources:o})}})(r,o),children:(0,l.__)("Remove","jvb")})]},r)))});return(0,i.jsxs)(i.Fragment,{children:[(0,i.jsxs)(o.InspectorControls,{children:[(0,i.jsxs)(t.PanelBody,{title:(0,l.__)("Video Settings","jvb"),initialOpen:!0,children:[(0,i.jsx)(t.BaseControl,{label:(0,l.__)("Poster Image","jvb"),help:(0,l.__)("Image shown while video loads","jvb"),children:(0,i.jsx)(o.MediaUploadCheck,{children:(0,i.jsx)(o.MediaUpload,{onSelect:e=>{s({posterId:e.id,posterUrl:e.url})},allowedTypes:["image"],value:a,render:({open:e})=>(0,i.jsxs)(i.Fragment,{children:[c&&(0,i.jsx)("img",{src:c,alt:(0,l.__)("Poster preview","jvb"),style:{maxWidth:"100%",marginBottom:"10px"}}),(0,i.jsx)(t.Button,{onClick:e,variant:c?"secondary":"primary",children:c?(0,l.__)("Change Poster","jvb"):(0,l.__)("Select Poster","jvb")}),c&&(0,i.jsx)(t.Button,{isDestructive:!0,onClick:()=>s({posterId:0,posterUrl:""}),style:{marginLeft:"10px"},children:(0,l.__)("Remove","jvb")})]})})})}),(0,i.jsxs)(t.BaseControl,{label:(0,l.__)("Desktop Video Sources","jvb"),help:(0,l.__)("Add multiple formats for better browser support","jvb"),children:[g(d,!1),(0,i.jsx)(o.MediaUploadCheck,{children:(0,i.jsx)(o.MediaUpload,{multiple:!0,onSelect:e=>_(e,!1),allowedTypes:r,render:({open:e})=>(0,i.jsx)(t.Button,{onClick:e,variant:"secondary",children:(0,l.__)("Add Desktop Video","jvb")})})})]}),(0,i.jsxs)(t.BaseControl,{label:(0,l.__)("Mobile Video Sources (Optional)","jvb"),help:(0,l.__)("Smaller videos for mobile devices","jvb"),children:[g(v,!0),(0,i.jsx)(o.MediaUploadCheck,{children:(0,i.jsx)(o.MediaUpload,{multiple:!0,onSelect:e=>_(e,!0),allowedTypes:r,render:({open:e})=>(0,i.jsx)(t.Button,{onClick:e,variant:"secondary",children:(0,l.__)("Add Mobile Video","jvb")})})})]}),(0,i.jsx)(t.ToggleControl,{label:(0,l.__)("Fade Effect","jvb"),help:(0,l.__)("Add fade class to video element","jvb"),checked:p,onChange:e=>s({fadeEffect:e})})]}),(0,i.jsxs)(t.PanelBody,{title:(0,l.__)("Overlay Settings","jvb"),initialOpen:!0,children:[(0,i.jsx)(t.RangeControl,{label:(0,l.__)("Overlay Opacity","jvb"),help:(0,l.__)("Darken video for better text readability","jvb"),value:m,onChange:e=>s({overlayOpacity:e}),min:0,max:100,step:5}),(0,i.jsx)(t.SelectControl,{label:(0,l.__)("Content Alignment","jvb"),value:b,options:[{label:(0,l.__)("Top Left","jvb"),value:"top-left"},{label:(0,l.__)("Top Center","jvb"),value:"top-center"},{label:(0,l.__)("Top Right","jvb"),value:"top-right"},{label:(0,l.__)("Center Left","jvb"),value:"center-left"},{label:(0,l.__)("Center","jvb"),value:"center"},{label:(0,l.__)("Center Right","jvb"),value:"center-right"},{label:(0,l.__)("Bottom Left","jvb"),value:"bottom-left"},{label:(0,l.__)("Bottom Center","jvb"),value:"bottom-center"},{label:(0,l.__)("Bottom Right","jvb"),value:"bottom-right"}],onChange:e=>s({contentAlignment:e})}),(0,i.jsx)(t.RangeControl,{label:(0,l.__)("Minimum Height","jvb"),help:(0,l.__)("Minimum height in pixels (leave 0 for auto)","jvb"),value:h,onChange:e=>s({minHeight:e}),min:0,max:1e3,step:50})]})]}),(0,i.jsx)("div",{...j,children:c||d.length>0?(0,i.jsxs)("div",{className:"video-cover-preview",children:[c&&(0,i.jsxs)(i.Fragment,{children:[(0,i.jsx)("img",{src:c,alt:(0,l.__)("Video poster","jvb")}),m>0&&(0,i.jsx)("div",{className:"video-overlay-preview",style:{opacity:m/100}})]}),(0,i.jsx)("div",{className:`video-cover-content-preview align-${b}`,children:(0,i.jsx)("div",{...u})}),(0,i.jsx)("div",{className:"video-info",children:(0,i.jsxs)("p",{children:[d.length," ",(0,l.__)("desktop source(s)","jvb"),v.length>0&&`, ${v.length} ${(0,l.__)("mobile source(s)","jvb")}`]})})]}):(0,i.jsx)("div",{className:"video-cover-placeholder",children:(0,i.jsx)("p",{children:(0,l.__)("Configure video sources in the sidebar →","jvb")})})})]})},save:({attributes:e})=>{const l=o.useBlockProps.save({className:"video-cover-wrapper-placeholder"});return(0,i.jsx)("div",{...l,children:(0,i.jsx)(o.InnerBlocks.Content,{})})}})}},l={};function t(e){var i=l[e];if(void 0!==i)return i.exports;var r=l[e]={exports:{}};return o[e](r,r.exports,t),r.exports}t.m=o,e=[],t.O=(o,l,i,r)=>{if(!l){var n=1/0;for(d=0;d<e.length;d++){for(var[l,i,r]=e[d],s=!0,a=0;a<l.length;a++)(!1&r||n>=r)&&Object.keys(t.O).every((e=>t.O[e](l[a])))?l.splice(a--,1):(s=!1,r<n&&(n=r));if(s){e.splice(d--,1);var c=i();void 0!==c&&(o=c)}}return o}r=r||0;for(var d=e.length;d>0&&e[d-1][2]>r;d--)e[d]=e[d-1];e[d]=[l,i,r]},t.o=(e,o)=>Object.prototype.hasOwnProperty.call(e,o),(()=>{var e={205:0,601:0};t.O.j=o=>0===e[o];var o=(o,l)=>{var i,r,[n,s,a]=l,c=0;if(n.some((o=>0!==e[o]))){for(i in s)t.o(s,i)&&(t.m[i]=s[i]);if(a)var d=a(t)}for(o&&o(l);c<n.length;c++)r=n[c],t.o(e,r)&&e[r]&&e[r][0](),e[r]=0;return t.O(d)},l=globalThis.webpackChunkjvb=globalThis.webpackChunkjvb||[];l.forEach(o.bind(null,0)),l.push=o.bind(null,l.push.bind(l))})();var i=t.O(void 0,[601],(()=>t(747)));i=t.O(i)})();
\ No newline at end of file
diff --git a/build/video/style-index-rtl.css b/build/video/style-index-rtl.css
index 1508f45..97527aa 100644
--- a/build/video/style-index-rtl.css
+++ b/build/video/style-index-rtl.css
@@ -1 +1 @@
-.video-cover-wrapper{display:flex;min-height:400px;overflow:hidden;position:relative;width:100%}.video-cover-wrapper .video-cover-bg{height:auto;right:50%;min-height:100%;min-width:100%;-o-object-fit:cover;object-fit:cover;position:absolute;top:50%;transform:translate(50%,-50%);width:auto;z-index:0}.video-cover-wrapper .video-cover-bg.fade{animation:fadeIn 1s ease-in}.video-cover-wrapper .video-cover-overlay{background:#000;bottom:0;right:0;position:absolute;left:0;top:0;z-index:1}.video-cover-wrapper .video-cover-content{color:#fff;padding:2rem;position:relative;width:100%;z-index:2}.video-cover-wrapper .video-cover-content h1,.video-cover-wrapper .video-cover-content h2,.video-cover-wrapper .video-cover-content h3,.video-cover-wrapper .video-cover-content h4,.video-cover-wrapper .video-cover-content h5,.video-cover-wrapper .video-cover-content h6{color:#fff;text-shadow:0 2px 4px rgba(0,0,0,.5)}.video-cover-wrapper .video-cover-content p{color:#fff;text-shadow:0 1px 2px rgba(0,0,0,.5)}.video-cover-wrapper .video-cover-content .wp-block-button__link{text-shadow:none}.video-cover-wrapper.align-top-left{align-items:flex-start;justify-content:flex-start}.video-cover-wrapper.align-top-center{align-items:flex-start;justify-content:center}.video-cover-wrapper.align-top-right{align-items:flex-start;justify-content:flex-end}.video-cover-wrapper.align-center-left{align-items:center;justify-content:flex-start}.video-cover-wrapper.align-center{align-items:center;justify-content:center}.video-cover-wrapper.align-center-right{align-items:center;justify-content:flex-end}.video-cover-wrapper.align-bottom-left{align-items:flex-end;justify-content:flex-start}.video-cover-wrapper.align-bottom-center{align-items:flex-end;justify-content:center}.video-cover-wrapper.align-bottom-right{align-items:flex-end;justify-content:flex-end}.video-cover-wrapper.alignfull{margin-right:calc(50% - 50vw);margin-left:calc(50% - 50vw);max-width:none;width:100vw}.video-cover-wrapper.alignwide{max-width:1200px}@keyframes fadeIn{0%{opacity:0}to{opacity:1}}@media(max-width:768px){.video-cover-wrapper{min-height:300px}.video-cover-wrapper .video-cover-content{padding:1.5rem}}@media(max-width:480px){.video-cover-wrapper{min-height:250px}.video-cover-wrapper .video-cover-content{padding:1rem}}
+.video-cover{display:flex;min-height:75vh;overflow:hidden;position:relative;width:100%}.video-cover .wrap{background-color:var(--contrast-200)}.video-cover .video-container{background-color:var(--action-50);bottom:0;display:flex;right:0;min-height:100%;min-width:100%;position:absolute;left:0;top:0;z-index:0}.video-cover .video-container.fade{animation:fadeIn 1s ease-in}.video-cover .video-container video{filter:grayscale(100%) contrast(1);flex:1 0 100%;mix-blend-mode:multiply;-o-object-fit:cover;object-fit:cover;opacity:.85;pointer-events:none}.video-cover .inner-wrap{color:var(--action-contrast);padding:2rem;position:relative;width:100%;z-index:2}.video-cover .inner-wrap h1,.video-cover .inner-wrap h2,.video-cover .inner-wrap h3,.video-cover .inner-wrap h4,.video-cover .inner-wrap h5,.video-cover .inner-wrap h6{color:var(--action-contrast);margin:2rem 0 0;text-shadow:0 2px 4px rgba(0,0,0,.5);word-spacing:100vw}.video-cover .inner-wrap p{color:var(--action-contrast);letter-spacing:2px;margin:0;text-shadow:0 1px 2px rgba(0,0,0,.5);text-transform:uppercase}.video-cover .inner-wrap .media-text figure{max-width:50%}@media(min-width:768px){.video-cover .inner-wrap .media-text{--align:flex-start;gap:3rem;max-width:var(--maxWidth)}}.video-cover .inner-wrap .media-text>div{width:-moz-fit-content;width:fit-content}.video-cover .inner-wrap .buttons a{border-color:var(--action-contrast);color:var(--action-contrast);font-weight:500}.video-cover .inner-wrap .buttons a:visited{color:var(--action-0)}.video-cover .inner-wrap .buttons a:visited:hover{color:var(--action-contrast)}.video-cover .inner-wrap .buttons a:hover{background-color:var(--action-0);color:var(--action-contrast)}.video-cover .inner-wrap .outline a{background-color:rgba(var(--base-rgb),var(--overlay-light))}.video-cover .inner-wrap .buttons{margin:3rem 0}.video-cover .inner-wrap .wp-block-button__link{text-shadow:none}.video-cover.align-top-left{align-items:flex-start;justify-content:flex-start}.video-cover.align-top-center{align-items:flex-start;justify-content:center}.video-cover.align-top-right{align-items:flex-start;justify-content:flex-end}.video-cover.align-center-left{align-items:center;justify-content:flex-start}.video-cover.align-center{align-items:center;justify-content:center}.video-cover.align-center-right{align-items:center;justify-content:flex-end}.video-cover.align-bottom-left{align-items:flex-end;justify-content:flex-start}.video-cover.align-bottom-center{align-items:flex-end;justify-content:center}.video-cover.align-bottom-right{align-items:flex-end;justify-content:flex-end}.video-cover.alignfull{margin-right:calc(50% - 50vw);margin-left:calc(50% - 50vw);max-width:none;width:100vw}.video-cover.alignwide{max-width:1200px}@keyframes fadeIn{0%{opacity:0}to{opacity:1}}
diff --git a/build/video/style-index.css b/build/video/style-index.css
index 5a65ae4..f326678 100644
--- a/build/video/style-index.css
+++ b/build/video/style-index.css
@@ -1 +1 @@
-.video-cover-wrapper{display:flex;min-height:400px;overflow:hidden;position:relative;width:100%}.video-cover-wrapper .video-cover-bg{height:auto;left:50%;min-height:100%;min-width:100%;-o-object-fit:cover;object-fit:cover;position:absolute;top:50%;transform:translate(-50%,-50%);width:auto;z-index:0}.video-cover-wrapper .video-cover-bg.fade{animation:fadeIn 1s ease-in}.video-cover-wrapper .video-cover-overlay{background:#000;bottom:0;left:0;position:absolute;right:0;top:0;z-index:1}.video-cover-wrapper .video-cover-content{color:#fff;padding:2rem;position:relative;width:100%;z-index:2}.video-cover-wrapper .video-cover-content h1,.video-cover-wrapper .video-cover-content h2,.video-cover-wrapper .video-cover-content h3,.video-cover-wrapper .video-cover-content h4,.video-cover-wrapper .video-cover-content h5,.video-cover-wrapper .video-cover-content h6{color:#fff;text-shadow:0 2px 4px rgba(0,0,0,.5)}.video-cover-wrapper .video-cover-content p{color:#fff;text-shadow:0 1px 2px rgba(0,0,0,.5)}.video-cover-wrapper .video-cover-content .wp-block-button__link{text-shadow:none}.video-cover-wrapper.align-top-left{align-items:flex-start;justify-content:flex-start}.video-cover-wrapper.align-top-center{align-items:flex-start;justify-content:center}.video-cover-wrapper.align-top-right{align-items:flex-start;justify-content:flex-end}.video-cover-wrapper.align-center-left{align-items:center;justify-content:flex-start}.video-cover-wrapper.align-center{align-items:center;justify-content:center}.video-cover-wrapper.align-center-right{align-items:center;justify-content:flex-end}.video-cover-wrapper.align-bottom-left{align-items:flex-end;justify-content:flex-start}.video-cover-wrapper.align-bottom-center{align-items:flex-end;justify-content:center}.video-cover-wrapper.align-bottom-right{align-items:flex-end;justify-content:flex-end}.video-cover-wrapper.alignfull{margin-left:calc(50% - 50vw);margin-right:calc(50% - 50vw);max-width:none;width:100vw}.video-cover-wrapper.alignwide{max-width:1200px}@keyframes fadeIn{0%{opacity:0}to{opacity:1}}@media(max-width:768px){.video-cover-wrapper{min-height:300px}.video-cover-wrapper .video-cover-content{padding:1.5rem}}@media(max-width:480px){.video-cover-wrapper{min-height:250px}.video-cover-wrapper .video-cover-content{padding:1rem}}
+.video-cover{display:flex;min-height:75vh;overflow:hidden;position:relative;width:100%}.video-cover .wrap{background-color:var(--contrast-200)}.video-cover .video-container{background-color:var(--action-50);bottom:0;display:flex;left:0;min-height:100%;min-width:100%;position:absolute;right:0;top:0;z-index:0}.video-cover .video-container.fade{animation:fadeIn 1s ease-in}.video-cover .video-container video{filter:grayscale(100%) contrast(1);flex:1 0 100%;mix-blend-mode:multiply;-o-object-fit:cover;object-fit:cover;opacity:.85;pointer-events:none}.video-cover .inner-wrap{color:var(--action-contrast);padding:2rem;position:relative;width:100%;z-index:2}.video-cover .inner-wrap h1,.video-cover .inner-wrap h2,.video-cover .inner-wrap h3,.video-cover .inner-wrap h4,.video-cover .inner-wrap h5,.video-cover .inner-wrap h6{color:var(--action-contrast);margin:2rem 0 0;text-shadow:0 2px 4px rgba(0,0,0,.5);word-spacing:100vw}.video-cover .inner-wrap p{color:var(--action-contrast);letter-spacing:2px;margin:0;text-shadow:0 1px 2px rgba(0,0,0,.5);text-transform:uppercase}.video-cover .inner-wrap .media-text figure{max-width:50%}@media(min-width:768px){.video-cover .inner-wrap .media-text{--align:flex-start;gap:3rem;max-width:var(--maxWidth)}}.video-cover .inner-wrap .media-text>div{width:-moz-fit-content;width:fit-content}.video-cover .inner-wrap .buttons a{border-color:var(--action-contrast);color:var(--action-contrast);font-weight:500}.video-cover .inner-wrap .buttons a:visited{color:var(--action-0)}.video-cover .inner-wrap .buttons a:visited:hover{color:var(--action-contrast)}.video-cover .inner-wrap .buttons a:hover{background-color:var(--action-0);color:var(--action-contrast)}.video-cover .inner-wrap .outline a{background-color:rgba(var(--base-rgb),var(--overlay-light))}.video-cover .inner-wrap .buttons{margin:3rem 0}.video-cover .inner-wrap .wp-block-button__link{text-shadow:none}.video-cover.align-top-left{align-items:flex-start;justify-content:flex-start}.video-cover.align-top-center{align-items:flex-start;justify-content:center}.video-cover.align-top-right{align-items:flex-start;justify-content:flex-end}.video-cover.align-center-left{align-items:center;justify-content:flex-start}.video-cover.align-center{align-items:center;justify-content:center}.video-cover.align-center-right{align-items:center;justify-content:flex-end}.video-cover.align-bottom-left{align-items:flex-end;justify-content:flex-start}.video-cover.align-bottom-center{align-items:flex-end;justify-content:center}.video-cover.align-bottom-right{align-items:flex-end;justify-content:flex-end}.video-cover.alignfull{margin-left:calc(50% - 50vw);margin-right:calc(50% - 50vw);max-width:none;width:100vw}.video-cover.alignwide{max-width:1200px}@keyframes fadeIn{0%{opacity:0}to{opacity:1}}
diff --git a/globals.php b/globals.php
index 32762da..38b518e 100644
--- a/globals.php
+++ b/globals.php
@@ -311,7 +311,10 @@
function jvbExtractUserContent(array $content):array
{
- $out = [];
+ // Deprecated: Use Features::forUser($role)->getCreatableContent() instead
+ _deprecated_function(__FUNCTION__, '2.0.0', 'Features::forUser($role)->getCreatableContent()');
+
+ $out = [];
foreach ($content as $c) {
if (is_array($c)) {
foreach ($c as $type => $contents) {
diff --git a/icons.php b/icons.php
index 10df568..6685f4f 100644
--- a/icons.php
+++ b/icons.php
@@ -171,6 +171,7 @@
'project' => 'code',
'map' => 'map-trifold',
'offer' => 'gift',
+ 'referrals' => 'hand-heart'
];
@@ -374,7 +375,7 @@
public function __construct()
{
- $this->cache = new CacheManager('icons', 604800); //1 week in seconds
+ $this->cache = CacheManager::for('icons', WEEK_IN_SECONDS);
// $this->cache->invalidateGroup('icons');
$this->style = JVB_SITE['icons']??'regular';
@@ -546,13 +547,12 @@
'color' => 'currentColor'
], $options);
- $icon = $this->cache->remember(
+ return $this->cache->remember(
array_merge($options, ['name' => $name]),
function () use ($name, $options) {
return $this->buildIcon($name, $options);
}
);
- return $icon;
}
public function getIconsByGroup(string $group):array
diff --git a/inc/blocks/CustomBlocks.php b/inc/blocks/CustomBlocks.php
index 721c706..8e175b8 100644
--- a/inc/blocks/CustomBlocks.php
+++ b/inc/blocks/CustomBlocks.php
@@ -1,8 +1,11 @@
<?php
namespace JVBase\blocks;
+use DateTime;
+use DOMDocument;
use JVBase\managers\CacheManager;
use WP_Block;
+use WP_Query;
if (!defined('ABSPATH')) {
exit; // Exit if accessed directly
@@ -14,8 +17,8 @@
protected CacheManager $imgCache;
public function __construct()
{
- $this->cache = new CacheManager('blocks', DAY_IN_SECONDS);
- $this->imgCache = new CacheManager('images', DAY_IN_SECONDS);
+ $this->cache = CacheManager::for('blocks', WEEK_IN_SECONDS);
+ $this->imgCache = CacheManager::for('images', WEEK_IN_SECONDS);
add_action('render_block', [$this, 'render'], 10, 3);
add_action('init', [$this, 'registerBlockStyles']);
@@ -23,6 +26,7 @@
public function registerBlockStyles():void
{
+ do_action('jvbBlockStyles');
//Register extra block styles
register_block_style(
'core/navigation',
@@ -45,12 +49,36 @@
'label' => __('Fixed', 'jvb')
]
);
+ register_block_style(
+ 'core/group',
+ [
+ 'name' =>'callout',
+ 'label' => __('Callout', 'jvb')
+ ]
+ );
+ register_block_style(
+ 'core/group',
+ [
+ 'name' =>'callalt',
+ 'label' => __('Callout Alt', 'jvb')
+ ]
+ );
}
public function render(string $content, array $block, WP_Block $instance)
{
$method = 'render_'.$this->sanitizeBlockName($block);
- if (method_exists($this, $method)) {
+ $function = BASE.$method;
+ if (function_exists($function)) {
+// return $this->cache->remember(
+// $block,
+// function () use ($function, $block, $content) {
+// return $function($block, $content);
+// }
+// );
+ return $function($block, $content);
+ }
+ if (method_exists($this, $method)) {
return $this->$method($block, $content);
//TODO: Recache it
// return $this->cache->remember(
@@ -59,7 +87,18 @@
// return $this->$method($block, $content);
// }
// );
- }
+ } else if (!empty($block['blockName'])){
+ //TESTING
+ $ignore = [
+ 'core/null',
+ 'core/post-title',
+ 'core/list-item',
+ 'core/site-title',
+ ];
+ if (!in_array($block['blockName'], $ignore)) {
+ jvbDump('No method found for '.print_r($block['blockName'], true));
+ }
+ }
if ($block['blockName'] === 'jvb/feed') {
// Enqueue the feed block script (it will automatically load dependencies)
$this->localize_feedblock();
@@ -73,6 +112,7 @@
/**
* Common Blocks
*/
+ //For Reference:
//core_form
//core_form_input
//core_form_submission_notification
@@ -82,25 +122,46 @@
*/
- protected function render_core_button($block):string
+ protected function render_core_button(array $block):string
{
- $link = explode('href="', $block['innerHTML']);
- $url = explode('">', $link[1]);
- $label = explode('</a>', $url[1])[0];
- $url = $url[0];
+ preg_match('/href="([^"]*)"/', $block['innerHTML'], $url);
+ preg_match('/>([^<]*)<\/a>/', $block['innerHTML'], $label);
- return '<li'.$this->getClassesAndStyles($block['attrs'],['row']).'>
- <a href="'.$url.'">'.$label.'</a>
- </li>';
+ if (empty($url[1]) || empty($label[1])) {
+ return '';
+ }
+ $icon = '';
+ if (str_contains($url[1], 'google.com/maps')) {
+ $icon = 'google-logo';
+ }
+ if (str_contains($url[1], 'maps.apple.com')) {
+ $icon = 'apple-logo';
+ }
+ if ($icon !== '') {
+ return sprintf(
+ '<li%s><a href="%s" title="Find Us On %s">%s Maps</a></li>',
+ $this->getClassesAndStyles($block['attrs']),
+ esc_url($url[1]),
+ esc_html($label[1]),
+ jvbIcon($icon)
+ );
+ }
+
+ return sprintf(
+ '<li%s><a href="%s">%s</a></li>',
+ $this->getClassesAndStyles($block['attrs']),
+ esc_url($url[1]),
+ esc_html($label[1])
+ );
}
- protected function render_core_buttons($block):string
+ protected function render_core_buttons(array $block):string
{
- return '<ul'.$this->getClassesAndStyles($block['attrs'], ['buttons row']).'">'.
+ return '<ul'.$this->getClassesAndStyles($block['attrs'], ['buttons','row']).'>'.
$this->innerBlocks($block).'</ul>';
}
- protected function render_core_column($block):string
+ protected function render_core_column(array $block):string
{
$styles = (array_key_exists('attrs', $block) &&
array_key_exists('width', $block['attrs'])) ?
@@ -111,7 +172,7 @@
$this->innerBlocks($block).'</div>';
}
- protected function render_core_columns($block):string
+ protected function render_core_columns(array $block):string
{
return '<section'.
$this->getClassesAndStyles($block['attrs'], ['columns']).'>'.
@@ -119,24 +180,24 @@
}
//core_comment_template
- protected function render_core_group($block):string
+ protected function render_core_group(array $block):string
{
$tag = (array_key_exists('tagName', $block['attrs'])) ? $block['attrs']['tagName'] : 'div';
$classes = ($tag === 'main') ?
$this->getClassesAndStyles($block['attrs']) :
- $this->getClassesAndStyles($block['attrs'], ['group row']);
+ $this->getClassesAndStyles($block['attrs'], ['group']);
return '<'.$tag.$classes.'>'.$this->innerBlocks($block).'</'.$tag.'>';
}
//core_home_link
//core_more
//core_nextpage
- protected function render_core_separator($block):string
+ protected function render_core_separator(array $block):string
{
return '<hr'.$this->getClassesAndStyles($block['attrs']).'>';
}
- protected function render_core_spacer($block):string
+ protected function render_core_spacer(array $block):string
{
return '<div'.$this->getClassesAndStyles($block['attrs'], ['spacer'], ['height:2rem']).
' aria-hidden="true"></div>';
@@ -152,49 +213,52 @@
* Media Blocks
*/
//core_audio
- protected function render_core_cover($block):string
+ protected function render_core_cover(array $block):string
{
+
// Extract block attributes
$attrs = $block['attrs'] ?? [];
$innerContent = $this->innerBlocks($block);
- // Handle overlay opacity
- $dimRatio = $attrs['dimRatio'] ?? 50;
- $overlayClass = 'overlay-' . (ceil($dimRatio / 25) * 25);
-
- // Build classes and styles
- $classes = $this->getClassesAndStyles($attrs, ['cover', $overlayClass]);
+ if (array_key_exists('focalPoint', $attrs)) {
+ $x = (array_key_exists('x', $attrs['focalPoint'])) ? ($attrs['focalPoint']['x'] * 100).'%' : 'center';
+ $y = (array_key_exists('y', $attrs['focalPoint'])) ? ($attrs['focalPoint']['y'] * 100).'%' : 'center';
+ $position = 'object-position:'.$x.' '.$y.';';
+ unset($attrs['focalPoint']);
+ }
// Check for background type
$backgroundType = $attrs['backgroundType'] ?? 'image';
$background = '';
- if ($backgroundType === 'image' && isset($attrs['url'])) {
- // Image background
- $background = '<div class="cover-bg" aria-hidden="true"></div>';
+ if ($backgroundType === 'image' && isset($attrs['id'])) {
+ $background .= str_replace('<img', '<img style="'.$position.'"', $this->image($attrs['id']));
} elseif ($backgroundType === 'video' && isset($attrs['url'])) {
- // Video background
- $background = '<div class="cover-bg" aria-hidden="true"></div>';
- $background .= '<video autoplay muted loop playsinline src="' . esc_url($attrs['url']) . '"></video>';
+ $background .= '<video style="'.$position.'"autoplay muted loop playsinline src="' . esc_url($attrs['url']) . '"></video>';
}
- return '<div' . $classes . '>' .
+ // Build classes and styles
+ unset($attrs['url']);
+ $classes = $this->getClassesAndStyles($attrs, ['cover']);
+
+
+ return '<section' . $classes . '>' .
$background .
'<div class="content">' .
$innerContent .
- '</div></div>';
+ '</div></section>';
}
//core_file
- protected function render_core_gallery($block):string
+ protected function render_core_gallery(array $block):string
{
return '<ul'.$this->getClassesAndStyles($block['attrs'], ['gallery']).'>'.
$this->innerBlocks($block,'<li>', '</li>').
'</ul>';
}
- protected function render_core_image($block):string
+ protected function render_core_image(array $block):string
{
$ID = $this->imageID('', $block);
if (!$ID) {
@@ -215,15 +279,20 @@
$caption.'</figure>';
}
- protected function render_core_media_text($block):string
+ protected function render_core_media_text(array $block):string
{
+
$ID = $this->imageID('', $block);
- $img = ($ID) ? $this->image($ID, $block) : '';
$imgLink = ($ID) ? $this->imageLink(true, $ID) : '';
$inner = $this->innerBlocks($block);
- $content = '<div'.$this->getClassesAndStyles($block['attrs'], ['media-text']).'>';
+
+ $classes = ['media-text', 'row'];
+ if (array_key_exists('isStackedOnMobile', $block['attrs'])) {
+ $classes[] = 'nowrap';
+ }
+ $content = '<div'.$this->getClassesAndStyles($block['attrs'], $classes).'>';
$content .= (array_key_exists(
'mediaPosition',
$block['attrs']
@@ -251,22 +320,74 @@
protected function render_core_heading(array $block):string
{
$level = (array_key_exists('level', $block['attrs'])) ? $block['attrs']['level'] : '2';
- $id = sanitize_title(wp_strip_all_tags($block['innerHTML']));
+ $content = $this->inside($block);
+ $id = sanitize_title(wp_strip_all_tags($this->stripTagContents('small', $content)));
return '<h'.$level.' id="'.$id.'"'.$this->getClassesAndStyles($block['attrs']).'>'.
- $this->inside($block).
+ $content.
'</h'.$level.'>';
}
- //render_core_list
- //render_core_list_item
+
+ protected function render_core_list(array $block):string
+ {
+ $tag = (array_key_exists('ordered', $block['attrs'])) ? 'ol' : 'ul';
+ return '<'.$tag.$this->getClassesAndStyles($block['attrs']).'>'.$this->innerBlocks($block).'</'.$tag.'>';
+ }
+
+// protected function render_core_list_item(array $block):string
+// {
+// return '<li'.$this->getClassesAndStyles($block['attrs']).'>'.$this->inside($block).'</li>';
+// }
//render_core_missing
protected function render_core_paragraph(array $block):string
{
- return '<p'.$this->getClassesAndStyles($block['attrs'], ['paragraph']).'>'.
+ return '<p'.$this->getClassesAndStyles($block['attrs']).'>'.
$this->inside($block, 'p').
'</p>';
}
- //render_core_quote
+ protected function render_core_quote(array $block): string
+ {
+ $innerHTML = $block['innerHTML'];
+
+ // Extract cite content first
+ $cite = $this->extractElement($innerHTML, 'cite');
+ $citeHtml = ($cite === '') ? '' : '<cite>— '.$cite.'</cite>';
+
+ // Get the blockquote content
+ $content = $this->inside($block, 'blockquote');
+
+ // Remove the cite element from content if it exists
+ if ($cite !== '') {
+ $content = $this->stripTagContents('cite', $content);
+ }
+
+ return '<blockquote'.$this->getClassesAndStyles($block['attrs']).'>
+ <div class="content">'.$content.'</div>'.
+ $citeHtml.
+ '</blockquote>';
+ }
+ protected function render_core_pullquote(array $block): string
+ {
+ $innerHTML = $block['innerHTML'];
+
+ // Extract cite content first
+ $cite = $this->extractElement($innerHTML, 'cite');
+ $citeHtml = ($cite === '') ? '' : '<cite>— '.$cite.'</cite>';
+
+ // Get the blockquote content
+ $content = $this->extractElement($innerHTML, 'blockquote');
+
+ // Remove the cite element from content if it exists
+ if ($cite !== '') {
+ $content = $this->stripTagContents('cite', $content);
+ }
+ $content = apply_filters('the_content', $content);
+
+ return '<blockquote'.$this->getClassesAndStyles($block['attrs'], ['pull']).'>'.
+ $content.
+ $citeHtml.
+ '</blockquote>';
+ }
//render_core_table
//render_core_verse
@@ -280,12 +401,13 @@
protected function render_core_site_logo(array $block, string $content):string
{
$open = $close = '';
- if ($block['attrs']['isLink']) {
+
+ if (!is_home() && !is_front_page()) {
$open = '<a href="'.get_home_url().'" rel="home">';
$close = '</a>';
}
$img = get_theme_mod('custom_logo');
- $img = $this->image($img);
+ $img = $this->image($img, 'tiny', 'thumbnail');
$img = str_replace('<img', '<img'.$this->getClassesAndStyles($block['attrs']), $img);
return $open.$img.$close;
}
@@ -308,10 +430,7 @@
return '<'.$tag.$class.'>'.
$open.
- jvbIcon('logo-basic').
- '<span class="screen-reader-text">'.
get_bloginfo('name').
- '</span>'.
$close.
'</'.$tag.'>';
}
@@ -338,9 +457,9 @@
*/
protected function render_core_navigation(array $block, string $content):string
{
- $ID = $block['attrs']['ref'];
+ $ID = (array_key_exists('ref', $block['attrs'])) ? $block['attrs']['ref'] : false;
- if (empty($block['innerBlocks']) && get_post($ID)) {
+ if (empty($block['innerBlocks']) && $ID && get_post($ID)) {
$block['innerBlocks'] = parse_blocks(get_post($ID)->post_content);
}
@@ -364,17 +483,20 @@
//Allows to add custom items to a menu, based on the menu name
$helpmenu = apply_filters('jvbMenuExtraAfter', $helpmenu, get_the_title($ID));
+ $main = trim(apply_filters('jvbMenuExtra', $this->innerBlocks($block), get_the_title($ID), $block));
+
+ $main = str_starts_with($main, '<ul') ? $main : '<ul>'.$main.'</ul>';
+
return '<nav'.$class.' id="navigation-' . $ID . '"aria-label="Navigation">
<span class="screen-reader-text">
<a href="#content">Skip to Content</a>
</span>' .
$toggle .
- '<ul>'.
- apply_filters('jvbMenuExtra', $this->innerBlocks($block), get_the_title($ID)).
- '</ul></nav>'.$helpmenu;
+ $main.
+ '</nav>'.$helpmenu;
}
- protected function render_core_navigation_link($block):string
+ protected function render_core_navigation_link(array $block):string
{
global $wp;
$url = (str_starts_with($block['attrs']['url'],'/')) ?
@@ -431,7 +553,7 @@
home_url($attrs['url']) :
$attrs['url'];
- $type = $id = $label = $desc = $rel = $title = $kind = '';
+ $target = $type = $id = $label = $desc = $rel = $title = $kind = '';
foreach ($attrs as $k => $v) {
switch ($k) {
case 'description':
@@ -449,9 +571,12 @@
case 'type':
$type = $v;
break;
+ case 'opensInNewTab':
+ $target = ' target="'.$v.'"';
+ break;
}
}
- return '<a href="'.$url.'"'.$aria.$rel.$title.'>';
+ return '<a href="'.$url.'"'.$aria.$rel.$target.$title.'>';
}
/**
@@ -465,7 +590,7 @@
$tag = (array_key_exists('tagName', $block['attrs'])) ?
$block['attrs']['tagName'] :
- 'div';
+ 'main';
if ($content == '') {
return do_blocks(get_the_content(get_the_ID()));
@@ -474,6 +599,11 @@
}
}
//core_post_date
+ protected function render_core_post_date(array $block):string
+ {
+ $postDate = get_the_date('c');
+ return '<time datetime="'.$postDate.'" itemprop="datePublished"'.$this->getClassesAndStyles($block['attrs']).'>'.get_the_date().'</time>';
+ }
//core_post_excerpt
protected function render_core_post_featured_image(array $block):string
{
@@ -484,6 +614,25 @@
//core_post_navigation_link
//core_post_template
//core_post_terms
+ protected function render_core_post_terms(array $block):string
+ {
+ $terms = get_the_terms(get_the_ID(), $block['attrs']['term']);
+ $out = '';
+ if ($terms && !is_wp_error($terms)) {
+ $out = '<ul class="term-list">';
+ if (array_key_exists('prefix', $block['attrs'])) {
+ $out .= '<li>'.$block['attrs']['prefix'].'</li>';
+ }
+ foreach($terms as $term) {
+ $out .= '<li><a href="'.get_term_link($term).'" rel="tag">'.$term->name.'</a></li>';
+ }
+ if (array_key_exists('suffix', $block['attrs'])) {
+ $out .= '<li>'.$block['attrs']['suffix'].'</li>';
+ }
+ $out .= '</ul>';
+ }
+ return $out;
+ }
//core_post_time_to_read
protected function render_core_post_title(array $block):string
{
@@ -509,7 +658,103 @@
$open.get_the_title().$close.
'</h'.$level.'>';
}
- //core_query
+
+ protected function render_core_query(array $block, string $content):string
+ {
+// jvbDump($block);
+// $queryID = $block['attrs']['queryId'];
+// $args = [];
+// $inherit = $block['attrs']['inherit']??false;
+// if ($inherit) {
+// global $wp_query;
+// $loop = $wp_query;
+// } else {
+// foreach ($block['attrs']['query'] as $key => $value) {
+// if (empty($value)) {
+// continue;
+// }
+// switch ($key) {
+// case 'postType':
+// $args['post_type'] = $value;
+// break;
+// case 'perPage':
+// $args['posts_per_page'] = $value;
+// break;
+// case 'orderBy':
+// $args['orderby'] = $value;
+// break;
+// case 'taxQuery':
+// $taxQuery = [];
+// foreach ($value as $tax => $terms) {
+// $taxQuery[] = [
+// 'taxonomy' => $tax,
+// 'terms' => $terms
+// ];
+// }
+// if (!empty($taxQuery)) {
+// $args['tax_query'] = $taxQuery;
+// if (count($taxQuery) > 1) {
+// $args['tax_query']['relation'] = 'OR';
+// }
+// }
+// break;
+// case 'sticky':
+// if ($value === 'ignore') {
+// $args['ignore_sticky_posts'] = true;
+// } else if ($value === 'exclude'){
+// $args['post__not_in'] = get_option('sticky_posts');
+// } else if ($value === 'only') {
+// $args['include'] = get_option('sticky_posts');
+// }
+// break;
+// case 'search':
+// $args['s'] = $value;
+// break;
+// default:
+// $args[$key] = $value;
+// break;
+//
+// }
+// }
+// //Add in any args from the query string
+// $search = 'query-'.$queryID;
+// foreach ($_GET as $key => $value) {
+// if (str_contains($key, $search)) {
+// $key = str_replace($search, '', $key);
+// if ($key === 'page') {
+// $args['paged'] = (int)$value;
+// }
+// }
+// }
+// $loop = new WP_Query($args);
+// }
+
+// $inner = $this->innerBlocks($block);
+// foreach ($block['innerBlocks'] as $innerBlock) {
+// switch ($innerBlock['blockName']) {
+// case 'core/post-template':
+// $inner .= '<ul class="item-grid">';
+// if ($loop->have_posts()) {
+// while($loop->have_posts()) {
+// $loop->the_post();
+// $inner .= $this->doBlocks
+// }
+// }
+// $inner .= '</ul>';
+// break;
+// }
+// }
+
+
+
+ $tagName = (array_key_exists('tagName', $block['attrs'])) ? $block['attrs']['tagName'] : 'div';
+ $out = '<'.$tagName.' class="loop">'.$this->innerBlocks($block).'</'.$tagName.'>';
+// if ($inherit) {
+// wp_reset_postdata();
+// }
+ return $out;
+ }
+
//core_query_no_results
//core_query_pagination
//core_query_pagination_next
@@ -519,25 +764,48 @@
//core_read_more
protected function render_core_template_part(array $block, string $content):string
{
- if (array_key_exists('attrs', $block) && array_key_exists('slug', $block['attrs']) &&
- in_array($block['attrs']['slug'], array('header', 'footer'))) {
- $tag = (array_key_exists('slug', $block['attrs'])) ? $block['attrs']['slug'] : 'div';
- $breadcrumbs = $themeSwitch = $afterHeader = $footerText= '';
- if ($block['attrs']['slug'] == 'header') {
+ $check = ['header', 'footer'];
+ $isHeaderTemplate = (
+ (array_key_exists('slug', $block['attrs']) && str_contains($block['attrs']['slug'], 'header')) ||
+ (array_key_exists('tagName', $block['attrs']) && str_contains($block['attrs']['tagName'], 'header'))
+ ) ? 'header' : false;
+ $isFooterTemplate = (
+ (array_key_exists('slug', $block['attrs']) && str_contains($block['attrs']['slug'], 'footer')) ||
+ (array_key_exists('tagName', $block['attrs']) && str_contains($block['attrs']['tagName'], 'footer'))
+ ) ? 'footer' : false;
+
+
+ if ($isHeaderTemplate || $isFooterTemplate) {
+ $tag = $isHeaderTemplate ?: $isFooterTemplate ?: 'div';
+
+ $breadcrumbs = $themeSwitch = $afterHeader = $beforeHeader = $footerText= '';
+ if ($isHeaderTemplate) {
+
+ $beforeHeader = apply_filters('jvbAboveHeader', $beforeHeader);
+ if ($beforeHeader !== '') {
+ $beforeHeader = '<aside class="pre-header">'.$beforeHeader.'</aside>';
+ }
$checked = (is_user_logged_in() && current_user_can('prefers_dark_theme', true)) ? ' checked' : '';
$title = ($checked == '') ? 'Toggle Dark Mode' : 'Toggle Light Mode';
$themeSwitch = '<label title="'.$title.'" id="theme-switch" class="toggle-switch" for="theme-switcher">
- <input class="theme-switch row" id="theme-switcher" type="checkbox"'.$checked.' role="switch" name="dark-mode"><span class="slider">'.
+ <input class="theme-switch row" id="theme-switcher" type="checkbox"'.$checked.' data-setting="theme" data-theme role="switch" name="dark-mode"><span class="slider">'.
jvbIcon('light', ['title'=> 'Light Mode']).
jvbIcon('dark', ['title'=>'Dark Mode']).
'</span></label>';
$breadcrumbs = jvbBuildBreadcrumbs();
$afterHeader = apply_filters('jvbBelowHeader', $afterHeader);
- } elseif ($block['attrs']['slug'] == 'footer') {
- $footerText = jvbRandomFooterText();
+ if ($afterHeader !== '') {
+ $afterHeader = '<aside class="sub-header">'.$afterHeader.'</aside>';
+ }
+ } elseif ($isFooterTemplate) {
+ $beforeHeader = apply_filters('jvbBeforeFooter', '');
+ if ($beforeHeader !== '') {
+ $beforeHeader = '<section class="pre-footer">'.$beforeHeader.'</section>';
+ }
+ $footerText = jvbRandomFooterText();
}
- return '<'.$tag.$this->getClassesAndStyles($block['attrs']).'>'.
+ return $beforeHeader.'<'.$tag.$this->getClassesAndStyles($block['attrs']).'>'.
$themeSwitch .
$this->inside($block, $tag, $content).
$footerText.'</'.$tag.'>'.$afterHeader.$breadcrumbs;
@@ -560,10 +828,24 @@
//core_rss
//core_search
//core_shortcode
- //core_social_link
- //core_social_links
+ protected function render_core_social_link(array $block, string $content):string
+ {
+ $url = $block['attrs']['url'];
+ $service = $block['attrs']['service'];
+ $iconName = ($service === 'bluesky') ? 'butterfly' : $service.'-logo';
+ $icon = jvbIcon($iconName);
+ if (!$icon) {
+ $icon = jvbIcon('link');
+ }
+ return '<li><a href="'.$url.'" target="_blank" rel="nofollow" title="Find us on '.ucfirst($service).'">'.$icon.'<span class="screen-reader-text">Find us on '.ucfirst($service).'</span></a></li>';
+ }
+ protected function render_core_social_links(array $block, string $content):string
+ {
+ return '<ul class="socials">'.$this->innerBlocks($block).'</ul>';
+ }
//core_tag_cloud
+
/**
* Extra feed block localization
*/
@@ -585,6 +867,13 @@
/***********************************
* Helpers
**********************************/
+ public function stripTagContents(string $tag, string $content):string
+ {
+ $clean = preg_replace('/<'.$tag.'\b[^>]*>.*?<\/'.$tag.'>/is', '', $content);
+ $clean = preg_replace('/\s+/', ' ', $clean);
+ return trim($clean);
+ }
+
public function innerBlocks(array $block, string $before = '', string $after = ''):string
{
$content = '';
@@ -629,8 +918,35 @@
);
}
+ /**
+ * Extract content from a specific nested element
+ * @param string $html The HTML to parse
+ * @param string $tag The tag name to extract
+ * @return string The content of the first matching element, or empty string
+ */
+ protected function extractElement(string $html, string $tag): string
+ {
+ if (empty($html)) {
+ return '';
+ }
+
+ $dom = new DOMDocument();
+ // Suppress errors for malformed HTML
+ libxml_use_internal_errors(true);
+ $dom->loadHTML('<?xml encoding="utf-8" ?>' . $html, LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD);
+ libxml_clear_errors();
+
+ $elements = $dom->getElementsByTagName($tag);
+ if ($elements->length === 0) {
+ return '';
+ }
+
+ return trim($elements->item(0)->textContent);
+ }
+
public function imageID(int|string $ID, array $block = []):int|false
{
+
if ($ID === '' && !empty($block)) {
if (($block['blockName'] === 'core/post-featured-image' ||
(!array_key_exists('attrs', $block) && !array_key_exists('id', $block['attrs'])))) {
@@ -643,7 +959,7 @@
}
}
}
- if ($ID == '' || is_null(get_post($ID))) {
+ if (!is_int($ID)) {
return false;
}
return $ID;
@@ -674,7 +990,7 @@
}
return $img;
}
- public function image($ID = '', $start = 'tiny', $replace = 'large'):string
+ public function image(string $ID = '', string $start = 'tiny', string $replace = 'large'):string
{
if ($ID == '') {
$ID = $this->imageID($ID);
@@ -684,7 +1000,10 @@
if ($ID === 0 || $ID === false) {
return '';
}
- $img = wp_get_attachment_image_src($ID, $start)[0];
+
+ $img = wp_get_attachment_image_src($ID, $start);
+ if (!$img) return '';
+ $img = $img[0];
$data = $this->gallerySizes($ID, $replace);
@@ -815,13 +1134,13 @@
protected function getPresetSpacing(string $spacing):string
{
return match ($spacing) {
- 'var:preset|spacing|20' => '0.5rem', // 1
- 'var:preset|spacing|30' => '1rem', // 2
- 'var:preset|spacing|40' => '1.5rem', // 3
- 'var:preset|spacing|50' => '3rem', // 4
- 'var:preset|spacing|60' => '4rem', // 5
- 'var:preset|spacing|70' => '5rem', // 6
- 'var:preset|spacing|80' => '6rem', // 7
+ 'var:preset|spacing|20' => 1,
+ 'var:preset|spacing|30' => 2,
+ 'var:preset|spacing|40' => 3,
+ 'var:preset|spacing|50' => 4,
+ 'var:preset|spacing|60' => 5,
+ 'var:preset|spacing|70' => 6,
+ 'var:preset|spacing|80' => 7,
default => $spacing,
};
}
@@ -833,7 +1152,7 @@
}
$classes = [];
foreach ($attrs as $key => $value) {
- $class = $this->getClass($key, $value);
+ $class = $this->getClass($key, $value, $attrs);
if (is_array($class)) {
$classes = array_merge($classes, $class);
} else {
@@ -846,38 +1165,87 @@
return $classes;
}
- protected function getClass(string $key, string|bool|array|int $value):string|array
+ protected function getClass(string $key, string|bool|array|int $value, array $attrs):string|array
{
+
switch ($key) {
//Any additional classes the user adds
case 'className':
return match ($value) {
'is-style-floating' => 'always mobile fixed',
'is-style-fixed' => 'fixed bottom',
- default => $value,
+ default => str_replace('is-style-', '', $value),
};
+ case 'contentPosition':
+ $classes = [];
+ $pos = explode(' ', $value);
+ foreach($pos as $p) {
+ switch ($p) {
+ case 'top':
+ $classes[] = 'a-start';
+ break;
+ case 'right':
+ $classes[] = 'end';
+ break;
+ case 'bottom':
+ $classes[] = 'a-end';
+ break;
+ case 'left':
+ $classes[] = 'start';
+ break;
+ }
+ }
+ return implode(' ', $classes);
//Layout attributes
case 'layout':
$classes = [];
+ $type = 'row';
if (array_key_exists('type', $value)) {
+ $type = 'col';
if ($value['type'] === 'constrained') {
- $classes[] = 'container';
+ $classes[] = 'container col';
}
}
- if (array_key_exists('justifyContent', $value)) {
- if (in_array($value['justifyContent'], ['left', 'right','space-between'])) {
- $classes[] = 'j-'.$value['justifyContent'];
- }
- }
- if (array_key_exists('orientation', $value)) {
+ if (array_key_exists('orientation', $value)) {
+ $type = 'col';
if ($value['orientation'] === 'vertical') {
- $classes[] = 'col';
+ $classes[] = 'col';
if (in_array('row', $classes)) {
$index = array_search('row', $classes);
unset($classes[$index]);
}
- }
- }
+ }
+ }else if (array_key_exists('type', $value) && $value['type'] === 'flex') {
+ $classes[] = 'row';
+ if (in_array('col', $classes)) {
+ $index = array_search('col', $classes);
+ unset($classes[$index]);
+ }
+ }
+//jvbDump($type);
+//jvbDump($value);
+// $check = [$value, $attrs];
+// foreach ($check as $ch) {
+//
+// }
+ if (!array_key_exists('justifyContent', $value) && !array_key_exists('contentPosition', $attrs)) {
+ $classes[] = 'start';
+ }
+ if (array_key_exists('justifyContent', $value) && !array_key_exists('contentPosition', $attrs)) {
+ if (in_array($value['justifyContent'], ['left', 'right','space-between'])) {
+// jvbDump($type);
+ switch ($value['justifyContent']) {
+ case 'right':
+ $classes[] = 'end';
+ break;
+ case 'space-between':
+ $classes[] = 'btw';
+ break;
+ }
+ }
+ }
+
+
if (array_key_exists('flexWrap', $value)) {
if ($value['flexWrap'] === 'nowrap') {
$classes[] = 'nowrap';
@@ -898,11 +1266,11 @@
case 'dimRatio':
if (is_numeric($value)) {
$width = match (true) {
- $value < 25 => 'one-fourth',
- $value < 33 => 'one-third',
- $value < 50 => 'half',
- $value < 66 => 'two-third',
- $value < 75 => 'three-fourth',
+ $value < 25 => '25',
+ $value < 33 => '33',
+ $value <= 50 => '50',
+ $value < 66 => '66',
+ $value < 75 => '75',
default => 'full',
};
switch ($key) {
@@ -930,22 +1298,84 @@
case 'style':
$classes = [];
//Margin and Padding
- if (array_key_exists('spacing', $value)) {
- foreach (['margin' => 'm', 'padding'=>'p'] as $search => $c) {
- if (array_key_exists($search, $value['spacing'])) {
- foreach ($value['spacing'][$search] as $direction => $size) {
- $size = $this->getPresetSpacing($size);
- if ($size) {
- $classes[] = $c.'-'.$direction.'-'.$size;
- }
- }
- }
- }
- }
+ if (array_key_exists('spacing', $value)) {
+ foreach (['margin' => 'm', 'padding'=>'p'] as $search => $c) {
+ if (array_key_exists($search, $value['spacing'])) {
+ $directions = [];
+
+ // Collect ONLY preset spacing values for classes
+ foreach ($value['spacing'][$search] as $direction => $size) {
+ $presetSize = $this->getPresetSpacing($size);
+ if ($presetSize) {
+ $directions[$direction] = $presetSize;
+ }
+ // Non-preset values are skipped here and handled by inline styles below
+ }
+
+ if (empty($directions)) {
+ continue;
+ }
+
+ // Check what directions we have
+ $hasTop = isset($directions['top']);
+ $hasBottom = isset($directions['bottom']);
+ $hasLeft = isset($directions['left']);
+ $hasRight = isset($directions['right']);
+
+ // Check if axes match
+ $xMatch = $hasLeft && $hasRight && $directions['left'] === $directions['right'];
+ $yMatch = $hasTop && $hasBottom && $directions['top'] === $directions['bottom'];
+
+ // All 4 directions exist and match → p-3
+ if ($hasTop && $hasBottom && $hasLeft && $hasRight &&
+ count(array_unique($directions)) === 1) {
+ $classes[] = $c . '-' . reset($directions);
+ }
+ // Both axes match → px-3 py-2
+ elseif ($xMatch && $yMatch) {
+ $classes[] = $c . 'x-' . $directions['left'];
+ $classes[] = $c . 'y-' . $directions['top'];
+ }
+ // Only X axis matches → px-3 (+ individual for top/bottom)
+ elseif ($xMatch) {
+ $classes[] = $c . 'x-' . $directions['left'];
+ if ($hasTop) {
+ $classes[] = $c . 't-' . $directions['top'];
+ }
+ if ($hasBottom) {
+ $classes[] = $c . 'b-' . $directions['bottom'];
+ }
+ }
+ // Only Y axis matches → py-3 (+ individual for left/right)
+ elseif ($yMatch) {
+ $classes[] = $c . 'y-' . $directions['top'];
+ if ($hasLeft) {
+ $classes[] = $c . 'l-' . $directions['left'];
+ }
+ if ($hasRight) {
+ $classes[] = $c . 'r-' . $directions['right'];
+ }
+ }
+ // No matches - individual directions
+ else {
+ foreach ($directions as $direction => $size) {
+ $dir = match($direction) {
+ 'top' => 't',
+ 'bottom' => 'b',
+ 'left' => 'l',
+ 'right' => 'r',
+ default => $direction
+ };
+ $classes[] = $c . $dir . '-' . $size;
+ }
+ }
+ }
+ }
+ }
if (array_key_exists('fontSize', $value)) {
if (in_array($value['fontSize'], ['small', 'large', 'extra-large', 'huge'])) {
- $classes[] = 'text-'.$value['fontSize'];
+ $classes[] = 'font-'.$value['fontSize'];
}
if (in_array('fontWeight', $value)) {
$classes[] = 'text-'.$value['fontWeight'];
@@ -957,8 +1387,76 @@
}
}
return implode(' ', $classes);
-
+ case 'fontSize':
+ $classes[] = 'font-'.$value;
+ return implode(' ', $classes);
+ case 'isStackedOnMobile':
+ return ($value === true) ? 'stack-small' : '';
+ case 'width':
+ if (is_numeric($value)) {
+ $width = match (true) {
+ $value < 25 => '25',
+ $value < 33 => '33',
+ $value <= 50 => '50',
+ $value < 66 => '66',
+ $value < 75 => '75',
+ default => 'full',
+ };
+ switch ($key) {
+ case 'width':
+ return 'width-'.$width;
+ case 'dimRatio':
+ return 'overlay-'.$width;
+ }
+ }
+ return '';
default:
+ $ignore = [
+ 'opacity',
+ 'borderColor',
+ 'backgroundColor',
+ 'textColor',
+ 'minHeight',
+ 'minHeightUnit',
+ 'isDark',
+ 'sizeSlug',
+ 'isUserOverlayColor',
+ 'customOverlayColor',
+ 'dimRatio',
+ 'placeholder',
+ 'alt',
+ 'imageFill',
+ 'mediaSizeSlug',
+ 'isLink',
+ 'kind',
+ 'label',
+ 'type',
+ 'id',
+ 'url',
+ 'label',
+ 'shouldSyncIcon',
+ 'rel',
+ 'opensInNewTab',
+ 'title',
+ 'ref',
+ 'overlayMenu',
+ 'slug',
+ 'theme',
+ 'tagName',
+ 'level',
+ 'ordered',
+ 'area',
+ 'mediaId',
+ 'mediaLink',
+ 'mediaType',
+ 'height', //maybe still need?
+ ];
+ if (!is_admin() &&!in_array($key, $ignore)) {
+// TESTING
+ jvbDump($key, 'getClass');
+ jvbDump($attrs);
+ }
+
return '';
}
}
@@ -1015,9 +1513,10 @@
// Focal point for background images
case 'focalPoint':
- if (isset($value['x']) && isset($value['y'])) {
- $styles[] = 'background-position: '.($value['x'] * 100).'% '.($value['y'] * 100).'%';
- }
+ $x = (array_key_exists('x', $attrs['focalPoint'])) ? $attrs['focalPoint']['x'] * 100 : 'center';
+ $y = (array_key_exists('y', $attrs['focalPoint'])) ? $attrs['focalPoint']['y'] * 100 : 'center';
+ $styles[] = 'background-position:'.$x.' '.$y.';';
+
break;
// Complex style object
@@ -1127,6 +1626,25 @@
}
}
break;
+ case 'dimRatio':
+ $ratio = (ceil($value /25) *25);
+ $s = 'background-color: rgba(var(--base-rgb), ';
+ switch ($ratio) {
+ case 0:
+ $s .= 'var(--rgb-subtle-hover));';
+ break;
+ case 25:
+ $s .= 'var(--rgb-light));';
+ break;
+ case 50:
+ $s .= 'var(--rgb-medium));';
+ break;
+ default:
+ $s .= 'var(--rgb-heavy));';
+ break;
+ }
+ $styles[] = $s;
+ break;
// Custom styles (any other attributes that need inline styling)
case 'backgroundType':
@@ -1137,8 +1655,72 @@
}
break;
+ case 'backgroundColor':
+ case 'borderColor':
+ case 'textColor':
+ $type = ($key === 'backgroundColor') ? 'background-color:' : (($key === 'borderColor') ? 'border-color:' : 'color:');
+ $defaults = apply_filters('jvbColours', ['base', 'contrast', 'action', 'secondary']);
+ $continue = true;
+ foreach ($defaults as $default) {
+ if (str_starts_with($value, $default)) {
+ $continue = false;
+ $styles[] = $type.'var(--'.$value.')';
+ }
+ }
+ if ($continue) {
+ $styles[] = $type.$value;
+ }
+ break;
// Any other attributes that need direct styling
default:
+ $ignore = [
+ 'opacity',
+ 'textAlign',
+ 'minHeightUnit',
+ 'isDark',
+ 'isUserOverlayColor',
+ 'contentPosition',
+ 'sizeSlug',
+ 'customOverlayColor',
+ 'alt',
+ 'placeholder',
+ 'imageFill',
+ 'mediaSizeSlug',
+ 'isLink',
+ 'kind',
+ 'label',
+ 'type',
+ 'id',
+ 'url',
+ 'label',
+ 'shouldSyncIcon',
+ 'rel',
+ 'opensInNewTab',
+ 'title',
+ 'ref',
+ 'overlayMenu',
+ 'slug',
+ 'theme',
+ 'tagName',
+ 'level',
+ 'ordered',
+ 'area',
+ 'className',
+ 'fontSize',
+ 'layout',
+ 'align',
+ 'mediaId',
+ 'mediaLink',
+ 'mediaType',
+ 'isStackedOnMobile',
+ 'width',
+ 'height', // maybe still need?
+ ];
+ if (!is_admin() && !in_array($key, $ignore)) {
+ //TESTING
+ jvbDump($key, 'getStyle');
+ jvbDump($attrs);
+ }
// No default inline styles
break;
}
@@ -1202,6 +1784,7 @@
}
);
}
+
}
new CustomBlocks();
diff --git a/inc/blocks/FAQBlock.php b/inc/blocks/FAQBlock.php
new file mode 100644
index 0000000..b27af52
--- /dev/null
+++ b/inc/blocks/FAQBlock.php
@@ -0,0 +1,297 @@
+<?php
+namespace JVBase\blocks;
+
+use JVBase\managers\CacheManager;
+use JVBase\forms\TaxonomySelector;
+use JVBase\meta\MetaManager;
+use WP_Block;
+use WP_Query;
+
+class FAQBlock {
+ protected CacheManager $cache;
+ public function __construct()
+ {
+ $this->cache = CacheManager::for('faq_block', WEEK_IN_SECONDS);
+ add_action('init', [ $this, 'registerBlock' ]);
+ add_action('enqueue_block_editor_assets', [$this, 'localizeData']);
+ }
+
+ /**
+ * Register the FAQ block
+ */
+ public function registerBlock() {
+ // Register the block
+ register_block_type(
+ JVB_DIR . '/build/faq',
+ [
+ 'render_callback' => [$this, 'render'],
+ ]
+ );
+//
+// // Localize script data for the editor
+// add_action('enqueue_block_editor_assets', [$this, 'enqueue_editor_assets']);
+//
+// // Enqueue frontend scripts
+// add_action('wp_enqueue_scripts', [$this, 'enqueue_frontend_assets']);
+ }
+
+ /**
+ * Enqueue editor assets
+ */
+ public static function enqueue_editor_assets() {
+ // Pass section taxonomy name to JavaScript
+ wp_localize_script(
+ 'jvb-faq-editor-script',
+ 'jvbFaq',
+ [
+ 'sectionTaxonomy' => BASE . 'section',
+ 'faqPostType' => BASE . 'faq',
+ ]
+ );
+ }
+
+ /**
+ * Enqueue frontend assets
+ */
+ public static function enqueue_frontend_assets() {
+ // Check if block is being used on this page
+ if (has_block('jvb/faq')) {
+ wp_enqueue_script(
+ 'jvb-faq-view',
+ JVB_URL . '/build/faq/view.js',
+ [],
+ filemtime(JVB_DIR . '/build/faq/view.js'),
+ true
+ );
+ }
+ }
+
+ /**
+ * Localize forms data for block editor
+ */
+ public function localizeData(): void
+ {
+ // Get all sections
+ $section_taxonomy = BASE . 'section';
+ $sections_data = $this->cache->remember(
+ 'sections',
+ function() {
+ $sections = get_terms([
+ 'taxonomy' => BASE.'section',
+ 'hide_empty' => false,
+ 'orderby' => 'name',
+ 'order' => 'ASC',
+ ]);
+
+ // Format sections for JavaScript
+ $sections_data = [];
+ if (!is_wp_error($sections) && !empty($sections)) {
+ foreach ($sections as $term) {
+ $sections_data[] = [
+ 'id' => $term->term_id,
+ 'name' => $term->name,
+ 'slug' => $term->slug,
+ ];
+ }
+ }
+ return $sections_data;
+ }
+ );
+
+
+ // Pass section taxonomy name and sections to JavaScript
+ wp_localize_script(
+ 'jvb-faq-editor-script',
+ 'jvbFaq',
+ [
+ 'sectionTaxonomy' => $section_taxonomy,
+ 'faqPostType' => BASE . 'faq',
+ 'sections' => $sections_data,
+ ]
+ );
+ }
+
+ /**
+ * Render callback
+ *
+ * @param array $attributes Block attributes
+ * @param string $content Block content
+ * @param WP_Block $block Block instance
+ * @return string Rendered block HTML
+ */
+ public function render($attributes, $content, $block)
+ {
+ ob_start();
+ ?>
+ <?php
+ /**
+ * FAQ Block Template
+ *
+ * @param array $attributes Block attributes
+ * @param string $content Block content
+ * @param WP_Block $block Block instance
+ */
+// Get BASE constant
+ $base = defined('BASE') ? BASE : '';
+ $faq_post_type = $base . 'faq';
+ $section_taxonomy = $base . 'section';
+
+// Get block attributes
+ $section_order = $attributes['sectionOrder'] ?? [];
+ $show_section_titles = $attributes['showSectionTitles'] ?? true;
+ $collapse_by_default = $attributes['collapseByDefault'] ?? false;
+
+// Determine if we're on a taxonomy archive or main FAQ archive
+ $is_tax_archive = is_tax($section_taxonomy);
+ $current_term = null;
+
+ if ($is_tax_archive) {
+ $current_term = get_queried_object();
+ global $wp_query;
+ $faq_query = $wp_query;
+ } else {
+ // Build query args based on context
+ $query_args = [
+ 'post_type' => $faq_post_type,
+ 'posts_per_page' => -1,
+ 'post_status' => 'publish',
+ 'orderby' => 'menu_order title',
+ 'order' => 'ASC',
+ ];
+ // Get FAQs
+ $faq_query = new WP_Query($query_args);
+ }
+
+
+ if (!$faq_query->have_posts()) {
+ echo '<div class="faq-block faq-block--empty">';
+ echo '<p>' . esc_html__('No FAQs found.', 'jvb') . '</p>';
+ echo '</div>';
+ return;
+ }
+
+ // Organize FAQs by section
+ $faqs_by_section = [];
+
+ while ($faq_query->have_posts()) {
+ $faq_query->the_post();
+ $post_id = get_the_ID();
+
+ $terms = get_the_terms($post_id, $section_taxonomy);
+
+ if ($terms && !is_wp_error($terms)) {
+ foreach ($terms as $term) {
+ if (!isset($faqs_by_section[$term->term_id])) {
+ $faqs_by_section[$term->term_id] = [
+ 'term' => $term,
+ 'faqs' => [],
+ ];
+ }
+
+ $faqs_by_section[$term->term_id]['faqs'][] = [
+ 'id' => $post_id,
+ 'title' => get_the_title(),
+ 'content' => get_the_excerpt(),
+ 'url' => get_the_permalink(),
+ ];
+ }
+ } else {
+ // FAQ without section - add to "uncategorized"
+ if (!isset($faqs_by_section[0])) {
+ $faqs_by_section[0] = [
+ 'term' => (object) [
+ 'term_id' => 0,
+ 'name' => __('General', 'jvb'),
+ 'slug' => 'general',
+ ],
+ 'faqs' => [],
+ ];
+ }
+
+ $faqs_by_section[0]['faqs'][] = [
+ 'id' => $post_id,
+ 'title' => get_the_title(),
+ 'content' => get_the_excerpt(),
+ 'url' => get_the_permalink(),
+ ];
+ }
+ }
+
+ wp_reset_postdata();
+
+ // If on main FAQ archive and we have a custom order, apply it
+ if (!$is_tax_archive && !empty($section_order)) {
+ $ordered_sections = [];
+
+ // Add sections in custom order
+ foreach ($section_order as $term_id) {
+ if (isset($faqs_by_section[$term_id])) {
+ $ordered_sections[$term_id] = $faqs_by_section[$term_id];
+ }
+ }
+
+ // Add any sections not in the custom order at the end
+ foreach ($faqs_by_section as $term_id => $section_data) {
+ if (!isset($ordered_sections[$term_id])) {
+ $ordered_sections[$term_id] = $section_data;
+ }
+ }
+
+ $faqs_by_section = $ordered_sections;
+ }
+
+// Generate unique IDs for accordion functionality
+ $block_id = 'faq-block-' . wp_unique_id();
+
+// Render the block
+ $wrapper_attributes = get_block_wrapper_attributes([
+ 'class' => 'faq-block',
+ 'data-block-id' => $block_id,
+ ]);
+
+ $nav = '';
+ if (!empty($section_order)) {
+ $nav = '<nav id="faq"><h2>Sections:</h2><ol>';
+ foreach ($section_order as $term_id) {
+ $term = get_term($term_id, $section_taxonomy);
+ if ($term && !is_wp_error($term)) {
+ $url = (!$is_tax_archive) ? "#{$term->slug}" : get_term_link($term);
+ $nav .= '<li><a href="'.$url.'">'.$term->name.'</a></li>';
+ }
+ }
+ $seeAll = ($is_tax_archive) ? '<p><a href="'.get_post_type_archive_link(BASE.'faq').'">'.__('See All FAQs', 'jvb').'</a></p>' : '';
+ $nav .= '</ol>'.$seeAll.'</nav>';
+ }
+ ?>
+
+ <section <?php echo $wrapper_attributes; ?>>
+ <?= $nav ?>
+ <?php foreach ($faqs_by_section as $term_id => $section_data): ?>
+ <div id="<?= $section_data['term']->slug?>" class="faq-section" data-section-id="<?php echo esc_attr($term_id); ?>">
+ <?php if ($show_section_titles): ?>
+ <h2>
+ <?php echo esc_html($section_data['term']->name); ?>
+ </h2>
+ <?php endif; ?>
+
+ <div class="faq-list">
+ <?php foreach ($section_data['faqs'] as $index => $faq): ?>
+ <?php
+ $faq_id = $block_id . '-faq-' . $faq['id'];
+ $is_expanded = !$collapse_by_default;
+ ?>
+ <details class="faq"<?= !$collapse_by_default ? ' open' : '' ?>>
+ <summary><h3><b>Q</b> <?= esc_html($faq['title']) ?></h3></summary>
+ <?= apply_filters('the_content', $faq['content']) ?>
+ <a class="button" href="<?= $faq['url'] ?>" title="Learn More about <?=$faq['title']?>">Learn More</a>
+ </details>
+ <?php endforeach; ?>
+ </div>
+ </div>
+ <?php endforeach; ?>
+ </section>
+
+ <?php
+ return ob_get_clean();
+ }
+}
diff --git a/inc/blocks/FeedBlock.php b/inc/blocks/FeedBlock.php
index 6bbe0e4..afb159c 100644
--- a/inc/blocks/FeedBlock.php
+++ b/inc/blocks/FeedBlock.php
@@ -17,7 +17,7 @@
public function __construct()
{
- $this->cache = new CacheManager('feed', WEEK_IN_SECONDS);
+ $this->cache = CacheManager::for('feed',WEEK_IN_SECONDS);
add_action('init', [$this, 'registerBlock']);
}
diff --git a/inc/blocks/FormBlock.php b/inc/blocks/FormBlock.php
index 369ce77..640368a 100644
--- a/inc/blocks/FormBlock.php
+++ b/inc/blocks/FormBlock.php
@@ -35,7 +35,7 @@
public function __construct()
{
- $this->cache = new CacheManager('form_blocks', HOUR_IN_SECONDS);
+ $this->cache = CacheManager::for('form_blocks', WEEK_IN_SECONDS);
// Initialize forms from filter
$this->forms = $this->registerForms();
@@ -289,6 +289,8 @@
foreach ($form_config['fields'] as $field_name => $field_config) {
$meta->render('form', $field_name, $field_config);
}
+ $submit_text = $form_config['submit'] ?? 'Submit';
+ echo '<button type="submit" class="button primary">' . esc_html($submit_text) . '</button>';
}
}
@@ -435,11 +437,6 @@
];
}
- error_log('Form Localization: '.print_r([
- 'formTypes' => $form_types,
- 'availableForms' => $this->forms,
- 'nonce' => wp_create_nonce('jvbForm')
- ], true));
wp_localize_script('jvb-forms-editor-script', 'jvbFormsData', [
'formTypes' => $form_types,
'availableForms' => $this->forms,
diff --git a/inc/blocks/GlossaryBlock.php b/inc/blocks/GlossaryBlock.php
new file mode 100644
index 0000000..c0442bb
--- /dev/null
+++ b/inc/blocks/GlossaryBlock.php
@@ -0,0 +1,153 @@
+<?php
+namespace JVBase\blocks;
+
+use JVBase\managers\CacheManager;
+use JVBase\forms\TaxonomySelector;
+use JVBase\meta\MetaManager;
+use WP_Block;
+use WP_Query;
+
+if (!defined('ABSPATH')) {
+ exit; // Exit if accessed directly
+}
+
+class GlossaryBlock
+{
+ protected CacheManager $cache;
+ protected string $config;
+ protected string $type;
+ protected string $path = JVB_DIR . '/build/glossary';
+ protected string $image;
+ protected string $header;
+ protected array $details;
+ protected array|false $sections = false;
+
+ public function __construct()
+ {
+ $this->cache = CacheManager::for('glossary_terms', WEEK_IN_SECONDS);
+ add_action('init', [ $this, 'registerBlock' ]);
+ }
+
+ public function registerBlock()
+ {
+ register_block_type($this->path, [
+ 'render_callback' => [ $this, 'render' ]
+ ]);
+ }
+
+ public function render(array $attributes, string $content, WP_Block $block)
+ {
+ $cache = $this->cache->get('all');
+ $cache = false;
+ if ($cache) {
+ return $cache;
+ }
+
+ ob_start();
+ $this->renderBlock();
+ $content = ob_get_clean();
+ $this->cache->set('all', $content);
+ return $content;
+ }
+
+ protected function renderBlock():void
+ {
+ $this->renderHeader();
+ $this->renderGlossary();
+ $this->renderOnThisPage();
+ }
+
+ protected function renderHeader():void
+ {
+ ?>
+ <header id="top">
+ <h1>Glossary of Terms</h1>
+ <p class="font-large">Lost in laser lingo? We'll translate.</p>
+ <p>Understanding how tattoo removal works helps you make better decisions about your treatment. When we mention wavelengths targeting specific pigments, or explain why your lymphatic system matters, we're describing real biological processes - not just throwing around fancy words.</p>
+ <p>This glossary explains the terms you'll hear during your removal journey in plain language. We're not trying to impress you with jargon - we just want you to know what we're talking about when we say things like "photoacoustic energy" or "oxidation."</p>
+ <nav class="glossary-index"><ul>
+ <?php
+ $glossary = $this->getGlossary();
+ foreach ($glossary as $slug => $term) {
+ ?>
+ <li>
+ <a href="#<?=$slug?>" title="Jump to <?=$term['post_title']?>">
+ <?=$term['post_title']?>
+ </a>
+ </li>
+ <?php
+ }
+ ?>
+ </ul></nav>
+ </header>
+ <?php
+ }
+ protected function getGlossary():array
+ {
+ $posts = new WP_Query([
+ 'post_type' => BASE.'terms',
+ 'posts_per_page' => -1,
+ 'post_status' => 'publish',
+ 'orderby' => 'title',
+ 'order' => 'asc',
+// 'fields' => 'ids'
+ ]);
+ $glossary = [];
+ if ($posts->have_posts()) {
+ foreach($posts->posts as $post) {
+// $meta = new MetaManager($post, 'post');
+// $fields = $meta->getAll();
+// $glossary[$fields['post_slug']] = $fields;
+ $glossary[$post->post_name] = [
+ 'post_title' => $post->post_title,
+ 'post_content' => $post->post_content,
+ 'pronunciation' => '',
+ 'type' => '',
+ ];
+ }
+ }
+ return $glossary;
+ }
+
+ protected function renderGlossary():void
+ {
+ $glossary = $this->getGlossary();
+ ?>
+ <dl class="glossary">
+ <?php
+ foreach ($glossary as $slug => $term) {
+ ?>
+ <dt id="<?=$slug?>">
+ <h2><?=$term['post_title']?></h2>
+ <?php
+ $out = '';
+ if ($term['pronunciation'] !== '' && !empty($term['pronunciation'])) {
+ $out = '[ '.str_replace(' · ·', ' ',implode(' · ', $term['pronunciation'])).' ]';
+ }
+ if ($term['type'] !== '') {
+ $out .= ' <span>'.$term['type'].'</span>';
+ }
+ if ($out !== ''){
+ echo '<span>'.$out.'</span>';
+ }
+ ?>
+ </dt>
+ <dd>
+ <?= $term['post_content']?>
+ </dd>
+ <?php
+ }
+ ?>
+ </dl>
+ <?php
+ }
+
+
+ protected function renderOnThisPage():void
+ {
+ if (empty($this->details)) {
+ return;
+ }
+ echo jvbOnThisPage(array_keys($this->details));
+ }
+}
diff --git a/inc/blocks/MenuBlock.php b/inc/blocks/MenuBlock.php
index b79d9ff..e6ba1e2 100644
--- a/inc/blocks/MenuBlock.php
+++ b/inc/blocks/MenuBlock.php
@@ -27,7 +27,7 @@
public function __construct()
{
- $this->cache = new CacheManager('menu', WEEK_IN_SECONDS);
+ $this->cache = CacheManager::for('menu', WEEK_IN_SECONDS);
add_action('init', [ $this, 'registerBlock' ]);
}
diff --git a/inc/blocks/RegisterBlocks.php b/inc/blocks/RegisterBlocks.php
index 596de1b..b4cb788 100644
--- a/inc/blocks/RegisterBlocks.php
+++ b/inc/blocks/RegisterBlocks.php
@@ -13,9 +13,23 @@
require(JVB_DIR . '/build/summary/render.php');
require(JVB_DIR . '/build/forms/render.php');
require(JVB_DIR . '/build/menu/render.php');
+if (Features::anyContentHas('is_glossary')) {
+ error_log('Has Glossary Type');
+ require(JVB_DIR . '/build/glossary/render.php');
+}
+if (Features::forSite()->has('faq') || array_key_exists('faq', JVB_CONTENT)) {
+ require(JVB_DIR . '/build/faq/render.php');
+}
+if (Features::hasIntegration('gmb')) {
+ require(JVB_DIR . '/build/gmbreviews/render.php');
+}
+
function jvbRegisterBlocks():void
{
+ if (Features::hasIntegration('gmb')) {
+ register_block_type(JVB_DIR . '/build/gmb-reviews');
+ }
// if (jvbSiteUsesFeedBlock()) {
// register_block_type(
// JVB_DIR . '/build/feed',
diff --git a/inc/blocks/SummaryBlock.php b/inc/blocks/SummaryBlock.php
index 1d250b3..fd55561 100644
--- a/inc/blocks/SummaryBlock.php
+++ b/inc/blocks/SummaryBlock.php
@@ -23,7 +23,7 @@
public function __construct()
{
- $this->cache = new CacheManager('summary', WEEK_IN_SECONDS);
+ $this->cache = CacheManager::for('summary_block', WEEK_IN_SECONDS);
add_action('init', [ $this, 'registerBlock' ]);
}
@@ -105,8 +105,6 @@
$this->getType()
);
-
- $this->icons = new JVBICons();
ob_start();
$this->renderBlock();
$content = ob_get_clean();
diff --git a/inc/blocks/VideoCoverBlock.php b/inc/blocks/VideoCoverBlock.php
index c0b18a6..90bda6a 100644
--- a/inc/blocks/VideoCoverBlock.php
+++ b/inc/blocks/VideoCoverBlock.php
@@ -1,6 +1,7 @@
<?php
namespace JVBase\blocks;
+use JVBase\blocks\CustomBlocks;
if (!defined('ABSPATH')) {
exit;
}
@@ -41,6 +42,7 @@
*/
public function render($attributes, $content): string
{
+
// Extract attributes with defaults
$poster_id = $attributes['posterId'] ?? 0;
$video_sources = $attributes['videoSources'] ?? [];
@@ -81,7 +83,7 @@
"uploadDate": "'.$date.'"
}
</script>
- <div class="wrap">
+ <div class="wrap abs edges">
<div class="video-container">';
$html .= '<video';
$html .= ' muted loop playsinline autoplay';
@@ -120,8 +122,27 @@
}
$html .= '</video>';
- $html .= '</div></div><div class="inner-wrap"></div></section>';
+
+ $inner_content = $this->extractInnerContent($content);
+ $html .= '</div></div><div class="inner-wrap">'.$inner_content.'</div></section>';
return $html;
}
+
+ /**
+ * Extract inner content from the saved block content
+ * Removes the wrapper div and returns just the inner blocks HTML
+ */
+ protected function extractInnerContent(string $content): string
+ {
+ if (empty($content)) {
+ return '';
+ }
+
+ // Remove the placeholder wrapper div
+ $content = preg_replace('/<div[^>]*class="[^"]*video-cover-wrapper-placeholder[^"]*"[^>]*>/', '', $content, 1);
+ $content = preg_replace('/<\/div>\s*$/', '', $content, 1);
+
+ return trim($content);
+ }
}
diff --git a/inc/blocks/_setup.php b/inc/blocks/_setup.php
index 88cde26..78a4122 100644
--- a/inc/blocks/_setup.php
+++ b/inc/blocks/_setup.php
@@ -14,6 +14,16 @@
new JVBase\blocks\MenuBlock();
}
+if (Features::forSite()->has('faq') || array_key_exists('faq', JVB_CONTENT)) {
+ require(JVB_DIR . '/inc/blocks/FAQBlock.php');
+ new JVBase\blocks\FAQBlock();
+}
+
+
+if (Features::anyContentHas('is_gallery')) {
+ require(JVB_DIR . '/inc/blocks/GlossaryBlock.php');
+ new JVBase\blocks\GlossaryBlock();
+}
require(JVB_DIR . '/inc/blocks/SummaryBlock.php');
new JVBase\blocks\SummaryBlock();
@@ -35,3 +45,34 @@
]);
}
add_filter('block_categories_all', 'jvbRegisterBlockCategory');
+
+if (Features::hasIntegration('gmb')) {
+ require(JVB_DIR . '/build/gmbreviews/render.php');
+}
+function jvbRegisterBlocks():void
+{
+ if (Features::hasIntegration('gmb')) {
+ register_block_type(
+ JVB_DIR . '/build/gmbreviews',
+ [
+ 'render_callback' => 'jvbRenderGMBReviewsBlock'
+ ]);
+ }
+// if (jvbSiteUsesFeedBlock()) {
+// register_block_type(
+// JVB_DIR . '/build/feed',
+// [
+// 'render_callback' => 'jvbRenderFeedBlock'
+// ]
+// );
+// }
+ if (Features::anyContentHas('show_directory') || Features::anyTaxonomyHas('show_directory')) {
+ register_block_type(
+ JVB_DIR . '/build/list',
+ [
+ 'render_callback' => 'jvbRenderListBlock'
+ ]
+ );
+ }
+}
+add_action('init', 'jvbRegisterBlocks');
diff --git a/inc/forms/PostSelector.php b/inc/forms/PostSelector.php
index ee18b9a..0df418f 100644
--- a/inc/forms/PostSelector.php
+++ b/inc/forms/PostSelector.php
@@ -23,7 +23,7 @@
public function __construct(string $post_type, array $config = [])
{
$this->post_type = $post_type;
- $this->cache = new CacheManager('posts');
+ $this->cache = CacheManager::for(jvbNoBase($post_type), WEEK_IN_SECONDS);
$this->config = wp_parse_args($config, [
'multiple' => true,
diff --git a/inc/forms/TaxonomySelector.php b/inc/forms/TaxonomySelector.php
index 09ad27e..6489e0d 100644
--- a/inc/forms/TaxonomySelector.php
+++ b/inc/forms/TaxonomySelector.php
@@ -1,21 +1,14 @@
<?php
namespace JVBase\forms;
-use JVBase\managers\CacheManager;
-use WP_REST_Request;
-use WP_REST_Response;
use WP_Term;
-use WP_Query;
if (!defined('ABSPATH')) {
exit; // Exit if accessed directly
}
/**
- * Single Modal Taxonomy Selector
- *
- * Complete replacement for individual taxonomy selector modals.
- * Uses one shared modal instance with intelligent prefetching.
+ * Taxonomy Selector
*
* @package TaxonomySelector
* @version 2.0.0
@@ -25,7 +18,6 @@
/**
* Track if any taxonomy selectors are present on the page
*/
- private static $hasSelectors = false;
protected string $id;
protected string $name;
@@ -33,48 +25,44 @@
protected string $plural;
protected string $taxonomy;
protected string $base;
+ protected string $title;
protected array $config;
public function __construct(string $id, string $taxonomy, array $config = []) {
$this->id = sanitize_key($id);
$this->taxonomy = jvbCheckBase($taxonomy);
$this->name = jvbNoBase($taxonomy);
+ $this->title = JVB_TAXONOMY[$this->name]['plural'];
$this->base = $config['base']??'';
$this->config = wp_parse_args($config, [
- 'types' => false, //for feed block implementation
- 'max' => 0,
+ 'types' => false, // for feed block implementation
+ 'max' => 0, // 0 = unlimited
'search' => true,
+ 'label' => $this->name,
+ 'icon' => false,
+ 'autocomplete' => false,
'createNew' => false,
'required' => false,
'hidden' => false,
- 'update' => false,
+ 'base' => '',
+ 'name' => $this->taxonomy,
+ 'update' => true, // Whether to update on close
]);
$this->plural = JVB_TAXONOMY[$taxonomy]['plural'];
$this->singular = JVB_TAXONOMY[$taxonomy]['singular'];
-
-
}
- /**
- * Mark that selectors are present (called when rendering toggles)
- */
- public static function markSelectorsPresent(): void {
- self::$hasSelectors = true;
- }
/**
* Get the full path for a term (for hierarchical taxonomies)
*
* @param WP_Term $term The term object
- * @return string The full term path
+ * @param bool $returnArray if true, returns the array. If false, a string of terms separated by ' → '
+ * @return string|array An array of terms or the full term path
*/
- public static function getTermPath($term): string {
- if (!$term || is_wp_error($term)) {
- return '';
- }
-
+ public static function getTermPath(WP_Term $term, bool $returnArray = false): string|array {
if (!is_taxonomy_hierarchical($term->taxonomy)) {
return $term->name;
}
@@ -94,22 +82,14 @@
break;
}
}
-
- return implode(' → ', $path);
+ return ($returnArray) ? $path : implode(' → ', $path);
}
- /**
- * Output the single modal dialog in footer
- */
- public static function outputSelector(): void {
- echo self::getSingleModalHTML();
- remove_action('wp_footer', [self::class, 'outputSelector']);
- }
/**
* Get the single modal HTML structure
*/
- public static function getSingleModalHTML(): string {
+ public static function outputSelectorModal(): string {
ob_start();
?>
<dialog id="jvb-selector" aria-labelledby="modal-title" aria-modal="true">
@@ -223,53 +203,126 @@
return ob_get_clean();
}
- public function render(array $selected =[], string $extra = ''):string
- {
- // Mark that selectors are present for footer output
- self::markSelectorsPresent();
+ /**
+ * Render the taxonomy selector toggle and display
+ *
+ * @param array $selected Array of term IDs that are already selected
+ * @param string $extra Additional HTML to append (optional)
+ * @return string The rendered HTML
+ */
+ public function render(array $selected = [], string $extra = ''): string {
+ // Build data attributes
+ $dataAttrs = $this->buildDataAttributes($selected);
- $update = ($this->config['update']) ? '' : ' data-update="'.$this->config['update'].'"';
- $max = ($this->config['max'] === 0) ? '' : ' data-max="'.$this->config['max'];
- $search = ($this->config['search']) ? ' data-search' : '';
- $creatable = ($this->config['createNew']) ? ' data-creatable' : '';
- $required = ($this->config['required']) ? ' data-required' : '';
- $hidden = ($this->config['hidden']) ? ' hidden' : '';
- $for = ($this->config['types']) ? ' data-for="'.implode(',',$this->config['types']) : '';
- $dataSelected = ' data-selected="'.implode(',',$selected).'"';
+ $hasAutocomplete = ($this->config['autocomplete']) ? ' data-autocomplete' : '';
+
+ // Hidden attribute
+ $hidden = $this->config['hidden'] ? ' hidden' : '';
+
ob_start();
?>
-
- <div class="jvb-selector <?= $this->name ?>"
- id="<?= $this->id ?>"<?=$hidden?>>
- <button type="button"
+ <div class="jvb-selector <?= esc_attr($this->name) ?>"
+ id="<?= esc_attr($this->id) ?>"<?= $hidden ?>>
+ <div class="field-group-header row btw">
+ <label for="<?= $this->base ?><?= esc_attr($this->config['name']) ?>-autocomplete">
+ <?= ($this->config['icon']) ? jvbIcon($this->config['icon']) : '' ?>
+ <span><?= $this->config['label'] ?></span>
+ </label>
+ <button type="button"
class="filter-toggle row taxonomy-toggle"
- data-taxonomy="<?=$this->name?>"
- data-single="<?=$this->singular?>"
- data-plural="<?=$this->plural?>"
- <?= $max.$search.$creatable.$required.$for.$dataSelected?>
+ data-taxonomy="<?= esc_attr($this->name) ?>"
+ data-single="<?= esc_attr($this->singular) ?>"
+ data-plural="<?= esc_attr($this->plural) ?>"
+ <?= $dataAttrs ?>
+ <?= $hasAutocomplete ?>
+ title="Open <?= $this->singular ?> Selector"
aria-label="Select <?= esc_attr($this->plural) ?>">
- <span class="button-text">Select <?= esc_html($this->plural) ?></span>
- <?= jvbIcon('add') ?>
- </button>
- <div class="selected-items row" role="region" aria-label="Selected <?=$this->plural?>">
+ <?= jvbIcon('add', ['title' => 'Add ' . $this->title]) ?>
+ </button>
+ <input type="text" id="<?= $this->base ?><?= esc_attr($this->config['name']) ?>-autocomplete" autocomplete="off" data-ignore data-autocomplete>
+ <ul class="autocomplete-dropdown" hidden>
+
+ </ul>
+ </div>
+
+ <div class="selected-items row" role="region" aria-label="Selected <?= esc_attr($this->plural) ?>">
<?php if (!empty($selected)): ?>
- <?php foreach ($selected as $termId ):
- $term = get_term($termId, $this->taxonomy);
- $termData = [
- 'name' => $term->name,
- 'path' => $this->getTermPath($term)
- ]; ?>
- <div class="selected-item row" data-id="<?= esc_attr($termId) ?>">
- <span><?= esc_html(is_array($termData) ? ($termData['path'] ?? $termData['name']) : $termData) ?></span>
- <button type="button" class="remove-item row" aria-label="Remove term">×</button>
- </div>
+ <?php foreach ($selected as $termId): ?>
+ <?php $this->renderSelectedTerm($termId); ?>
<?php endforeach; ?>
<?php endif; ?>
</div>
+
<?= $extra ?>
</div>
-
<?php
return ob_get_clean();
}
+
+ /**
+ * Build data attributes string for the toggle button
+ */
+ private function buildDataAttributes(array $selected): string {
+ $attrs = [];
+
+ // Update behavior
+ if (!$this->config['update']) {
+ $attrs[] = 'data-update="false"';
+ }
+
+ // Max selection
+ if ($this->config['max'] > 0) {
+ $attrs[] = 'data-max="' . esc_attr($this->config['max']) . '"';
+ }
+
+ // Search capability
+ if ($this->config['search']) {
+ $attrs[] = 'data-search';
+ }
+
+ // Create new capability
+ if ($this->config['createNew']) {
+ $attrs[] = 'data-creatable';
+ }
+
+ // Required
+ if ($this->config['required']) {
+ $attrs[] = 'data-required';
+ }
+
+ // Post types filter (for feed blocks)
+ if ($this->config['types'] && is_array($this->config['types'])) {
+ $attrs[] = 'data-for="' . esc_attr(implode(',', $this->config['types'])) . '"';
+ }
+
+ // Selected items
+ if (!empty($selected)) {
+ $attrs[] = 'data-selected="' . esc_attr(implode(',', $selected)) . '"';
+ }
+
+ return implode(' ', $attrs);
+ }
+
+ /**
+ * Render a single selected term
+ */
+ private function renderSelectedTerm(int $termId): void {
+ $term = get_term($termId, $this->taxonomy);
+
+ if (!$term || is_wp_error($term)) {
+ return;
+ }
+
+ $termPath = self::getTermPath($term);
+ ?>
+ <div class="selected-item row" data-id="<?= esc_attr($termId) ?>">
+ <span><?= esc_html($termPath) ?></span>
+ <button type="button"
+ class="remove-item row"
+ aria-label="Remove <?= esc_attr($term->name) ?>">
+ <?= jvbIcon('close') ?>
+ </button>
+ </div>
+ <?php
+ }
}
diff --git a/inc/forms/TaxonomySelectorOld.php b/inc/forms/TaxonomySelectorOld.php
index 3215b5b..32ceef1 100644
--- a/inc/forms/TaxonomySelectorOld.php
+++ b/inc/forms/TaxonomySelectorOld.php
@@ -44,7 +44,7 @@
$this->taxonomy = jvbCheckBase($taxonomy);
$this->name = str_replace(BASE, '', $taxonomy);
$this->icon = new JVBIcons();
- $this->cache = new CacheManager('taxonomy');
+ $this->cache = CacheManager::for(jvbNoBase($taxonomy), WEEK_IN_SECONDS);
$this->base = $config['base'] ?? '';
diff --git a/inc/helpers/all.php b/inc/helpers/all.php
index f0ffaee..8229c30 100644
--- a/inc/helpers/all.php
+++ b/inc/helpers/all.php
@@ -5,7 +5,7 @@
}
require(JVB_DIR . '/inc/helpers/breadcrumbs.php');
-require(JVB_DIR . '/inc/helpers/cache.php');
+//require(JVB_DIR . '/inc/helpers/cache.php');
require(JVB_DIR . '/inc/helpers/commonPages.php');
require(JVB_DIR . '/inc/helpers/crud.php');
require(JVB_DIR . '/inc/helpers/dashboard.php');
diff --git a/inc/helpers/breadcrumbs.php b/inc/helpers/breadcrumbs.php
index 486b9d9..5085d50 100644
--- a/inc/helpers/breadcrumbs.php
+++ b/inc/helpers/breadcrumbs.php
@@ -1,5 +1,7 @@
<?php
+use JVBase\managers\CacheManager;
+
if (!defined('ABSPATH')) {
exit;
}
@@ -10,7 +12,7 @@
*/
function jvbGetCrumbs():array
{
- $cache = new JVBase\managers\CacheManager('breadcrumbs', MONTH_IN_SECONDS);
+ $cache = CacheManager::for('breadcrumbs', MONTH_IN_SECONDS);
$key = get_queried_object_id();
$crumbs = $cache->get($key);
$crumbs = false;
diff --git a/inc/helpers/cache.php b/inc/helpers/cache.php
deleted file mode 100644
index cb29f2b..0000000
--- a/inc/helpers/cache.php
+++ /dev/null
@@ -1,125 +0,0 @@
-<?php
-
-if (!defined('ABSPATH')) {
- exit;
-}
-
-/**
- * Cache Stuffs
- */
-/**
- * Update cache timestamps when content is modified
- * This helps us reduce database requests and only update a content cache when necessary
- */
-/**
- * @param string $type
- * @param object|null $item
- *
- * @return array
- */
-function jvbUpdateCacheTimestamp(string $type, object|null $item = null):array
-{
- $timestamps = jvbGetCache();
- $current_time = time();
-
- // Handle different types of updates
- switch ($type) {
- case 'term':
- // Get term and update its taxonomy timestamp
- $taxonomy = str_replace(BASE, '', $item->taxonomy);
- $timestamps[ $taxonomy ] = $current_time;
- JVBase\managers\CacheManager::invalidateGroup($taxonomy);
- JVBase\managers\CacheManager::invalidateGroup('terms');
- break;
-
- case 'post':
- // Update specific post type timestamp
- $post_type = str_replace(BASE, '', $item->postType);
- $timestamps[$post_type] = $current_time;
- JVBase\managers\CacheManager::invalidateGroup($post_type);
- break;
- default:
- $timestamps[$type] = $current_time;
- JVBase\managers\CacheManager::invalidateGroup($type);
- break;
- }
- update_option('jvb_cache', $timestamps);
- return $timestamps;
-}
-
-// Hook into WordPress content changes
-add_action('save_post', function ($post_id, $post, $update) {
- if (jvbNoSaveIt($post_id, $post)) {
- return;
- }
- if (!in_array($post->post_type, jvbBasedFeedContent())) {
- return;
- }
- jvbUpdateCacheTimestamp('post', $post);
-}, 10, 3);
-
-add_action('edited_term', function ($term_id, $tt_id, $taxonomy) {
- jvbUpdateCacheTimestamp('term', get_term($term_id, $taxonomy));
-}, 10, 3);
-
-add_action('created_term', function ($term_id, $tt_id, $taxonomy) {
- jvbUpdateCacheTimestamp('term', get_term($term_id, $taxonomy));
-}, 10, 3);
-
-add_action('rest_api_init', function () {
- register_rest_route('jvb/v1', '/cachedContent', [
- 'methods' => 'GET',
- 'callback' => 'jvbCacheCheck',
- 'permission_callback' => '__return_true' // Public endpoint
- ]);
-});
-
-function jvbCacheCheck(WP_REST_Request $request):WP_REST_Response
-{
- // Get the cache timestamps
- $timestamps = jvbGetCache();
-
- $data = $request->get_params();
- error_log('Cache request: '.print_r($data, true));
-
- // Set Last-Modified header for conditional requests
- $last_update = 0;
- foreach ($timestamps as $type) {
- if ($type > $last_update) {
- $last_update = $type;
- }
- }
- if ($last_update > 0) {
- header('Last-Modified: ' . gmdate('D, d M Y H:i:s', $last_update) . ' GMT');
- }
-
- // Check If-Modified-Since header
- $if_modified_since = $request->get_header('if-modified-since');
- if ($if_modified_since) {
- $if_modified_since = strtotime($if_modified_since);
- if ($last_update <= $if_modified_since) {
- // Return 304 Not Modified if nothing has changed
- return new WP_REST_Response(null, 304);
- }
- }
-
- // Add cache control headers
- header('Cache-Control: max-age=60, must-revalidate');
-
- return new WP_REST_Response($timestamps);
-}
-
-function jvbGetCache():array
-{
- $timestamps = get_option('jvb_cache');
- if (!$timestamps) {
- $timestamps = [];
- $keys = JVB()->registeredContent();
- $currentTime = time();
- foreach ($keys as $key) {
- $timestamps[$key] = $currentTime;
- }
- update_option('jvb_cache', $timestamps);
- }
- return $timestamps;
-}
diff --git a/inc/helpers/formatting.php b/inc/helpers/formatting.php
index 4ac5520..dae808e 100644
--- a/inc/helpers/formatting.php
+++ b/inc/helpers/formatting.php
@@ -1,5 +1,7 @@
<?php
+use JVBase\managers\CacheManager;
+
if (!defined('ABSPATH')) {
exit;
}
@@ -71,7 +73,7 @@
*/
function jvbFormatRating(int $ID, JVBase\meta\MetaManager|null $meta = null):string
{
- $cache = new JVBase\managers\CacheManager('bio-'.$ID, WEEK_IN_SECONDS);
+ $cache = CacheManager::for('bio-'.$ID, WEEK_IN_SECONDS);
$key = 'rating';
$cached = $cache->get($key);
$cached = false;
@@ -135,7 +137,7 @@
*/
function jvbImageData(int $imgID):array
{
- $cache = new JVBase\managers\CacheManager('image_data', WEEK_IN_SECONDS);
+ $cache = CacheManager::for('imageData', WEEK_IN_SECONDS);
$cached = $cache->get($imgID);
if ($cached) {
return $cached;
@@ -193,3 +195,41 @@
list($dollars, $cents) = explode('.', $number);
return '$'.$dollars.'.<span>'.$cents.'</span>';
}
+
+function jvbMailToLink(string $emailTo,
+ string $subject = 'Contact from Website',
+ string $message = '',
+ bool $icon = true,
+ ?string $linkText = null
+):string
+{
+ if (!is_email($emailTo)) {
+ return '';
+ }
+ $link = 'mailto:'.$emailTo.'?subject='.rawurlencode($subject);
+ if ($message !== '') {
+ $link .= '&body='.rawurlencode($message);
+ }
+ return $link;
+}
+function jvbTextLink(int $phoneNumber, string $message=''):string
+{
+ $length =strlen((string)$phoneNumber);
+ if ($length < 10 || $length > 10) {
+ return '';
+ }
+ $link = 'sms:+1'.$phoneNumber;
+ if ($message !== '') {
+ $link .= '?body='.rawurlencode($message);
+ }
+ return $link;
+}
+
+function jvbPhoneLink(int $phoneNumber):string
+{
+ $length =strlen((string)$phoneNumber);
+ if ($length < 10 || $length > 10) {
+ return '';
+ }
+ return 'tel:+1'.$phoneNumber;
+}
diff --git a/inc/helpers/legacy.php b/inc/helpers/legacy.php
index 5e97337..c5ff946 100644
--- a/inc/helpers/legacy.php
+++ b/inc/helpers/legacy.php
@@ -8,61 +8,61 @@
* Outputs a random link to Legacy Tattoo Removal
* @return string
*/
-if (!function_exists('jvbRandomFooterText')) {
- function jvbRandomFooterText():string
- {
- $aOpen = '<a href="https://legacytattooremoval.ca" title="Learn more about Legacy Tattoo Removal">';
- $options = array(
- [
- 'text' => 'Built with ♡ by '.$aOpen.'your friendly neighbourhood laser nerds</a>.',
- 'weight' => 25
- ],
- [
- 'text' => 'Built with ♡ by your friends at '.$aOpen.'Legacy Tattoo Removal</a>.',
- 'weight' => 15
- ],
- [
- 'text' => 'Your friendly '.$aOpen.'Edmonton tattoo removal</a> crew.',
- 'weight' => 15
- ],
- [
- 'text' => $aOpen.'tattoo removal with ♡</a>',
- 'weight' => 15
- ],
- [
- 'text' => 'From '.$aOpen.'cover ups to fresh starts</a> - we\'re the laser nerds with ♡.',
- 'weight' => 10
- ],
- [
- 'text' => $aOpen.'See the difference at Legacy Tattoo Removal</a>',
- 'weight' => 10
- ],
- [
- 'text' => 'Make space for your next tattoo at '.$aOpen.'Legacy Tattoo Removal</a>.',
- 'weight' => 10
- ],
- [
- 'text' => $aOpen.'We\'re your artist\'s secret weapon</a>.',
- 'weight' => 15
- ]
- );
- $totalWeight = 0;
- foreach ($options as $option) {
- $totalWeight += (int)$option['weight'];
- }
-
- $randomNumber = mt_rand(1, $totalWeight);
- $weightSum = 0;
-
- foreach ($options as $option) {
- $weightSum += (int)$option['weight'];
- if ($randomNumber <= $weightSum) {
- return '<p>'.$option['text'].'</p>';
- }
- }
-
- return '<p>'.$options[0]['text'].'</p>';
- }
+function jvbRandomFooterText():string
+{
+ return apply_filters('jvbRandomFooterText', '<p>©'.date('Y').' '.get_bloginfo('name').'</p><p>Built with ♡ by <a href="https://jakevan.ca">Jake Van</a>');
+//
+// $aOpen = '<a href="https://legacytattooremoval.ca" title="Learn more about Legacy Tattoo Removal">';
+// $options = array(
+// [
+// 'text' => 'Built with ♡ by '.$aOpen.'your friendly neighbourhood laser nerds</a>.',
+// 'weight' => 25
+// ],
+// [
+// 'text' => 'Built with ♡ by your friends at '.$aOpen.'Legacy Tattoo Removal</a>.',
+// 'weight' => 15
+// ],
+// [
+// 'text' => 'Your friendly '.$aOpen.'Edmonton tattoo removal</a> crew.',
+// 'weight' => 15
+// ],
+// [
+// 'text' => $aOpen.'tattoo removal with ♡</a>',
+// 'weight' => 15
+// ],
+// [
+// 'text' => 'From '.$aOpen.'cover ups to fresh starts</a> - we\'re the laser nerds with ♡.',
+// 'weight' => 10
+// ],
+// [
+// 'text' => $aOpen.'See the difference at Legacy Tattoo Removal</a>',
+// 'weight' => 10
+// ],
+// [
+// 'text' => 'Make space for your next tattoo at '.$aOpen.'Legacy Tattoo Removal</a>.',
+// 'weight' => 10
+// ],
+// [
+// 'text' => $aOpen.'We\'re your artist\'s secret weapon</a>.',
+// 'weight' => 15
+// ]
+// );
+//
+// $totalWeight = 0;
+// foreach ($options as $option) {
+// $totalWeight += (int)$option['weight'];
+// }
+//
+// $randomNumber = mt_rand(1, $totalWeight);
+// $weightSum = 0;
+//
+// foreach ($options as $option) {
+// $weightSum += (int)$option['weight'];
+// if ($randomNumber <= $weightSum) {
+// return '<p>'.$option['text'].'</p>';
+// }
+// }
+//
+// return '<p>'.$options[0]['text'].'</p>';
}
-
diff --git a/inc/helpers/media.php b/inc/helpers/media.php
index 99bda69..4346f65 100644
--- a/inc/helpers/media.php
+++ b/inc/helpers/media.php
@@ -32,31 +32,3 @@
</dialog>
<?php
}
-
-function jvbRenderImageForm(int $attachmentId) {
- if (!$attachmentId) return '';
-
- $url = wp_get_attachment_image_url($attachmentId, 'medium');
- $title = get_the_title($attachmentId);
- $alt = get_post_meta($attachmentId, '_wp_attachment_image_alt', true);
- $caption = wp_get_attachment_caption($attachmentId);
-
- ob_start();
- ?>
- <div class="upload-item existing" data-attachment-id="<?= $attachmentId ?>">
- <div class="preview">
- <img src="<?= esc_url($url) ?>" alt="<?= esc_attr($alt) ?>">
- <div class="overlay">
- <div class="actions">
- <button type="button" class="remove" title="Remove">
- <span class="screen-reader-text">Remove image</span>
- ×
- </button>
- </div>
- </div>
- </div>
- <?= jvbImageMeta() ?>
- </div>
- <?php
- return ob_get_clean();
-}
diff --git a/inc/helpers/members.php b/inc/helpers/members.php
index 4522199..007210e 100644
--- a/inc/helpers/members.php
+++ b/inc/helpers/members.php
@@ -1,5 +1,8 @@
<?php
+use JVBase\managers\CacheManager;
+use JVBase\meta\MetaManager;
+
if (!defined('ABSPATH')) {
exit;
}
@@ -13,7 +16,7 @@
*/
function jvbShareName(int $userID):string
{
- $cache = new JVBase\managers\CacheManager('usernames');
+ $cache = CacheManager::for('usernames');
$cached = $cache->get($userID);
if ($cached) {
return $cached;
@@ -32,10 +35,10 @@
*/
function jvbGetUserByFirstName(string $first_name):WP_User|false
{
- $cache = new JVBase\managers\CacheManager;
- $cached = $cache->get('user_first_names')??[];
- if (in_array($first_name, $cached)) {
- return get_userdata(array_search($first_name, $cached));
+ $cache = CacheManager::for('userFirstname');
+ $cached = $cache->get($first_name)??false;
+ if ($cached) {
+ return get_userdata($cached);
}
$args = [
'post_type' => BASE . 'artist',
@@ -58,8 +61,10 @@
$user = get_userdata($user_id)?:false;
$cached[$user_id] = $first_name;
$cache->set('user_first_names', $cached);
+ wp_reset_postdata();
return $user;
}
+ wp_reset_postdata();
return false;
}
@@ -71,11 +76,11 @@
*/
function jvbGetUserByDisplayName(string $display_name):WP_User|false
{
- $cache = new JVBase\managers\CacheManager('users');
- $cached = $cache->get('user_display_names')??[];
+ $cache = CacheManager::for('user_displaynames');
+ $cached = $cache->get($display_name)??false;
- if (in_array($display_name, $cached)) {
- return get_userdata(array_search($display_name, $cached));
+ if ($cached && is_int($cached)) {
+ return get_userdata($cached);
}
$args = [
@@ -92,8 +97,8 @@
$user_id = get_post_meta($post_id, BASE . 'link', true);
$user = get_userdata($user_id)?:false;
- $cached[$user_id] = $display_name;
- $cache->set('user_display_names', $cached);
+
+ $cache->set($display_name, ($user) ? $user->ID : false);
return $user;
}
@@ -110,28 +115,20 @@
function jvbGetUsername(int $user_id):string
{
$key = 'user_display_names';
- $cache = new JVBase\managers\CacheManager('users', WEEK_IN_SECONDS);
- $cached_names = $cache->get($key, 'user_data');
- $cached_names = $cached_names ?: [];
+ $cache = CacheManager::for('userNames', WEEK_IN_SECONDS);
+ $cached = $cache->get($user_id);
- if (array_key_exists($user_id, $cached_names)) {
- return $cached_names[$user_id];
+ if ($cached) {
+ return $cached;
}
$permission = get_user_meta($user_id, BASE.'notify', true);
- if ($permission === false) {
- $cached_names[$user_id] = 'Someone';
- $cache->set($key, $cached_names, 'user_data');
- return 'Someone';
- }
- $display_name = get_userdata($user_id)?->display_name;
- if ($display_name) {
- $cached_names[$user_id] = $display_name;
- $cache->set($key, $cached_names, 'user_data');
- return $display_name;
- }
- return false;
+ $display_name = (!$permission) ? 'Someone' : false;
+ $user = get_userdata($user_id);
+ $display_name = (!$display_name && $user) ? $user->display_name : 'Someone';
+ $cache->set($user_id, $display_name);
+ return $display_name;
}
/**
@@ -159,52 +156,46 @@
return false;
}
- $handler = new JVBase\managers\CacheManager('artist', 3600);
- $handler->invalidateGroup('artist');
- $key = $userID;
+ $cache = CacheManager::for('artist', 3600);
+ $cached = $cache->get($userID);
+ if ($cached) {
+ return match ($return) {
+ 'id' => $cache['id'],
+ 'first_name', 'name' => $cache['first_name'],
+ 'display_name' => $cache['display_name'],
+ 'url' => $cache['url'],
+ 'type' => $cache['type'],
+ 'shop' => $cache['shop'],
+ 'city' => $cache['city'],
+ default => $cache,
+ };
+ }
- $cache = $handler->get($key);
- $cache = false;
- if ($cache) {
- return match ($return) {
- 'id' => $cache['id'],
- 'name' => $cache['name'],
- 'display_name' => $cache['display_name'],
- 'url' => $cache['url'],
- 'type' => $cache['type'],
- 'shop' => $cache['shop'],
- 'city' => $cache['city'],
- default => $cache,
- };
- }
-
- if (!get_userdata($userID)) {
+ $user = get_userdata($userID);
+ if (!$user) {
return [];
}
$id = (int) get_user_meta($userID, BASE.'link', true);
- $artist = [
- 'id' => $id,
- 'name' => get_post_meta($id, BASE.'first_name', true),
- 'display_name' => get_userdata($userID)->display_name,
- 'url' => get_the_permalink($id),
- 'type' => jvbGetArtistTerm($id, 'type'),
- 'city' => jvbGetArtistTerm($id, 'city'),
- 'shop' => jvbGetArtistTerm($id, 'shop'),
- ];
+ $meta = new MetaManager($id,'post');
+ $artist = $meta->getAll(['first_name','type','city','shop']);
+ $artist['id'] = $id;
+ $artist['display_name'] = $user->display_name;
+ $artist['url'] = get_the_permalink($id);
- $handler->set($key, $artist);
- return match ($return) {
- 'id' => $artist['id'],
- 'name' => $artist['name'],
- 'display_name' => $artist['display_name'],
- 'url' => $artist['url'],
- 'type' => $artist['type'],
- 'shop' => $artist['shop'],
- 'city' => $artist['city'],
- default => $artist,
- };
+ $cache->set($userID, $artist);
+
+ return match ($return) {
+ 'id' => $cache['id'],
+ 'first_name', 'name' => $cache['first_name'],
+ 'display_name' => $cache['display_name'],
+ 'url' => $cache['url'],
+ 'type' => $cache['type'],
+ 'shop' => $cache['shop'],
+ 'city' => $cache['city'],
+ default => $cache,
+ };
}
function jvbUserRole(int $ID = 0):string
diff --git a/inc/helpers/renderFields.php b/inc/helpers/renderFields.php
index 2e31338..16bb348 100644
--- a/inc/helpers/renderFields.php
+++ b/inc/helpers/renderFields.php
@@ -4,6 +4,8 @@
exit;
}
+use JVBase\managers\CacheManager;
+use JVBase\meta\MetaForm;
use JVBase\meta\MetaManager;
/**
@@ -57,7 +59,7 @@
*/
function jvbRenderLinks(int $ID, MetaManager|null $meta = null):string
{
- $cache = new JVBase\managers\CacheManager('bio-'.$ID, WEEK_IN_SECONDS);
+ $cache = CacheManager::for('bio-'.$ID, WEEK_IN_SECONDS);
$key = 'links';
$cached = $cache->get($key);
if ($cached) {
@@ -134,12 +136,12 @@
*/
function jvbRenderContactInfo(int $ID, MetaManager|null $meta = null):string
{
- $cache = new JVBase\managers\CacheManager('bio-'.$ID, WEEK_IN_SECONDS);
+ $cache = CacheManager::for('bio-'.$ID, WEEK_IN_SECONDS);
$key = 'contact';
-// $cached = $cache->get($key);
-// if($cached){
-// return $cached;
-// }
+ $cached = $cache->get($key);
+ if($cached){
+ return $cached;
+ }
if (!$meta) {
$meta = jvbGetMeta($ID);
}
@@ -444,34 +446,10 @@
</div>
</template>
<template class="uploadItem">
- <div class="item upload">
- <div class="preview">
- <?php jvbRenderProgressBar('',true) ?>
- <input type="checkbox" class="upload-select" name="select-item" id="select-item">
- <label for="select-item" aria-label="Select image">
- <img>
- <video></video>
- <span></span>
- </label>
- <div class="item-actions row btw">
- <div class="radio-button">
- <input type="radio" class="featured btn" name="featured" id="featured" hidden>
- <label for="featured">
- <?=jvbIcon('star')?>
- <?=jvbIcon('star', ['style' => 'fill'])?>
- <span class="screen-reader-text">Set as featured image</span>
- </label>
- </div>
-
- <button type="button" data-action="delete-upload" title="Remove from Group">
- <?=jvbIcon('delete')?>
- </button>
- </div>
- </div>
- <details>
- <summary class="row btw"><?=jvbIcon('edit')?><span>Edit Image Info</span></summary>
- </details>
- </div>
+ <?php
+ $form = new MetaForm();
+ $form->renderImagePreview();
+ ?>
</template>
<template class="restoreNotification">
<dialog class="restore-uploads">
@@ -544,41 +522,58 @@
<?php
}
if (wp_script_is('jvb-selector')) {
- \JVBase\forms\TaxonomySelector::class::outputSelector();
+ \JVBase\forms\TaxonomySelector::class::outputSelectorModal();
}
}
-function jvbImageMeta(int|null $ID = null, string $title = '', string $alt = '', string $caption = ''):string
+function jvbImageMeta(int|null $ID = null, string $title = '', string $alt = '', string $caption = '', array $fields = []):string
{
+ $form = new MetaForm();
+ $dataID = ($ID) ? ['image-id' => $ID] : false;
+ $addID = ($ID) ? '-'.$ID : '';
- $dataID = ($ID) ? ' data-image-id="'.$ID.'"' : '';
- $ID = ($ID) ? '-'.$ID : '';
+ $fields = array_merge([
+ 'image_data' => [
+ 'type' => 'group',
+ 'wrap' => 'details',
+ 'label' => 'Image Info',
+ 'hint' => 'These will be automatically generated if left blank.',
+ 'fields' => [
+ 'image-title'.$addID => [
+ 'type' => 'text',
+ 'label' => 'Image Title',
+ 'value' => $title,
+ 'data' => $dataID
+ ],
+ 'image-alt-text'.$addID => [
+ 'type' => 'text',
+ 'label' => 'Alt Text',
+ 'value' => $alt,
+ 'hint' => 'Alt text helps the visually impaired, as well as some benefits for SEO.',
+ 'data' => $dataID
+ ],
+ 'image-caption'.$addID => [
+ 'type' => 'textarea',
+ 'value' => $caption,
+ 'label' => 'Image Caption',
+ 'data' => $dataID
+ ]
+ ]
+ ]
+ ], $fields);
- return '<div class="upload-meta"'.$dataID.'>
- <div class="field">
- <label for="image-title'.$ID.'">Image Title</label>
- <input type="text" id="image-title'.$ID.'" name="image-title'.$ID.'" value="'.$title.'">
- </div>
- <div class="field">
- <label for="image-alt-text'.$ID.'">Image Alt Text</label>
- <input type="text" id="image-alt-text'.$ID.'" name="image-alt-text'.$ID.'" value="'.$alt.'">
- <p class="hint">Alt text helps the visually impaired, as well as some benefits for SEO.</p>
- </div>
- <div class="field">
- <label for="image-caption'.$ID.'">Image Caption</label>
- <textarea id="image-caption'.$ID.'" name="image-caption'.$ID.'">'.$caption.'</textarea>
- </div>
- <p class="hint">These will be automatically generated if left blank.</p>
- </div>';
+
+ return $form->render('image_data',null, $fields,false, true);
}
+
function jvbLocationLinks(array $location): string {
if (empty($location['address'])) {
return '';
}
- $cache = new \JVBase\managers\CacheManager('location');
+ $cache = CacheManager::for('locations');
$key = $cache->generateKey($location);
$cached = false;
diff --git a/inc/helpers/saveFields.php b/inc/helpers/saveFields.php
index 6d52b7e..bf03102 100644
--- a/inc/helpers/saveFields.php
+++ b/inc/helpers/saveFields.php
@@ -6,6 +6,9 @@
function jvbNoSaveIt(int $postID, \WP_Post $post): bool {
+ if (!$postID || $postID === 0) {
+ return true;
+ }
$postType = jvbNoBase($post->post_type);
if (!array_key_exists($postType, JVB_CONTENT)){
return true;
diff --git a/inc/helpers/time.php b/inc/helpers/time.php
index 9547b8a..126f52a 100644
--- a/inc/helpers/time.php
+++ b/inc/helpers/time.php
@@ -1,5 +1,7 @@
<?php
+use JVBase\managers\CacheManager;
+
if (!defined('ABSPATH')) {
exit;
}
@@ -139,7 +141,7 @@
*/
function jvbRenderHours(int $ID, JVBase\Meta\MetaManager $meta):string
{
- $cache = new JVBase\managers\CacheManager('hours-'.$ID, WEEK_IN_SECONDS);
+ $cache = CacheManager::for('hours-'.$ID, WEEK_IN_SECONDS);
$key = 'hours_display';
$cached = $cache->get($key);
diff --git a/inc/helpers/ui.php b/inc/helpers/ui.php
index b9bdfd4..a9e310d 100644
--- a/inc/helpers/ui.php
+++ b/inc/helpers/ui.php
@@ -153,6 +153,7 @@
function jvbHelpMenu():string
{
$out = get_option(BASE.'help_menu');
+
if ($out === false) {
$open = '<li><a href="';
$mid = '">';
@@ -371,6 +372,10 @@
$i = 0;
foreach ($tabs as $slug => $config) {
+ if (!array_key_exists('content', $config) || empty($config['content'])) {
+ error_log('No content for tab: '.$slug);
+ continue;
+ }
//Header
$active = ($i === 0) ? ' active' : '';
$selected = ($i === 0) ? 'true' : 'false';
diff --git a/inc/importers/JaneAppClientImporter.php b/inc/importers/JaneAppClientImporter.php
new file mode 100644
index 0000000..7f04b5c
--- /dev/null
+++ b/inc/importers/JaneAppClientImporter.php
@@ -0,0 +1,386 @@
+<?php
+namespace JVBase\importers;
+
+use WP_Error;
+
+if (!defined('ABSPATH')) {
+ exit;
+}
+
+/**
+ * JaneApp Client List Importer
+ *
+ * Imports client data from JaneApp CSV exports and maps to WordPress users
+ */
+class JaneAppClientImporter
+{
+ protected $wpdb;
+ protected string $jane_clients_table;
+ protected array $import_stats = [];
+
+ // CSV column mapping
+ protected array $column_map = [
+ 'patient_guid' => 'patient_guid',
+ 'first_name' => 'First Name',
+ 'last_name' => 'Last Name',
+ 'email' => 'Email',
+ ];
+
+ public function __construct()
+ {
+ global $wpdb;
+ $this->wpdb = $wpdb;
+ $this->jane_clients_table = $wpdb->prefix . BASE . 'jane_clients';
+ }
+
+ /**
+ * Import client list from CSV file
+ *
+ * @param string $file_path Path to the CSV file
+ * @param array $options Import options (e.g., update_existing, send_welcome_email)
+ * @return array Import results with stats and errors
+ */
+ public function importFromCSV(string $file_path, array $options = []): array
+ {
+ // Initialize stats
+ $this->import_stats = [
+ 'total_rows' => 0,
+ 'processed' => 0,
+ 'created' => 0,
+ 'updated' => 0,
+ 'skipped' => 0,
+ 'errors' => [],
+ 'unmatched_emails' => []
+ ];
+
+ // Validate file exists
+ if (!file_exists($file_path)) {
+ return new WP_Error('file_not_found', 'CSV file not found');
+ }
+
+ // Parse options
+ $update_existing = $options['update_existing'] ?? true;
+ $send_welcome_email = $options['send_welcome_email'] ?? false;
+ $create_users = $options['create_users'] ?? true;
+
+ // Open and parse CSV
+ $handle = fopen($file_path, 'r');
+ if (!$handle) {
+ return new WP_Error('file_open_error', 'Could not open CSV file');
+ }
+
+ // Get header row
+ $headers = fgetcsv($handle);
+ if (!$headers) {
+ fclose($handle);
+ return new WP_Error('invalid_csv', 'CSV file is empty or invalid');
+ }
+
+ // Map column indices
+ $column_indices = $this->mapColumnIndices($headers);
+ if (is_wp_error($column_indices)) {
+ fclose($handle);
+ return $column_indices;
+ }
+
+ // Start transaction for data integrity
+ $this->wpdb->query('START TRANSACTION');
+
+ try {
+ // Process each row
+ while (($row = fgetcsv($handle)) !== false) {
+ $this->import_stats['total_rows']++;
+
+ $result = $this->processClientRow($row, $column_indices, [
+ 'update_existing' => $update_existing,
+ 'send_welcome_email' => $send_welcome_email,
+ 'create_users' => $create_users
+ ]);
+
+ if (is_wp_error($result)) {
+ $this->import_stats['errors'][] = [
+ 'row' => $this->import_stats['total_rows'],
+ 'error' => $result->get_error_message()
+ ];
+ $this->import_stats['skipped']++;
+ } else {
+ $this->import_stats['processed']++;
+ if ($result['action'] === 'created') {
+ $this->import_stats['created']++;
+ } elseif ($result['action'] === 'updated') {
+ $this->import_stats['updated']++;
+ }
+ }
+ }
+
+ // Commit transaction
+ $this->wpdb->query('COMMIT');
+
+ } catch (\Exception $e) {
+ // Rollback on error
+ $this->wpdb->query('ROLLBACK');
+ fclose($handle);
+ return new WP_Error('import_error', 'Import failed: ' . $e->getMessage());
+ }
+
+ fclose($handle);
+
+ return $this->import_stats;
+ }
+
+ /**
+ * Map CSV column headers to internal field names
+ *
+ * @param array $headers CSV header row
+ * @return array|WP_Error Column index mapping or error
+ */
+ protected function mapColumnIndices(array $headers): array|WP_Error
+ {
+ $indices = [];
+
+ foreach ($this->column_map as $field => $csv_column) {
+ $index = array_search($csv_column, $headers);
+ if ($index === false) {
+ return new WP_Error(
+ 'missing_column',
+ sprintf('Required column "%s" not found in CSV', $csv_column)
+ );
+ }
+ $indices[$field] = $index;
+ }
+
+ return $indices;
+ }
+
+ /**
+ * Process a single client row from CSV
+ *
+ * @param array $row CSV row data
+ * @param array $column_indices Column mapping
+ * @param array $options Processing options
+ * @return array|WP_Error Result of processing
+ */
+ protected function processClientRow(array $row, array $column_indices, array $options): array|WP_Error
+ {
+ // Extract data from row
+ $patient_guid = trim($row[$column_indices['patient_guid']] ?? '');
+ $first_name = trim($row[$column_indices['first_name']] ?? '');
+ $last_name = trim($row[$column_indices['last_name']] ?? '');
+ $email = trim($row[$column_indices['email']] ?? '');
+
+ // Validate required fields
+ if (empty($patient_guid) || empty($email)) {
+ return new WP_Error('invalid_data', 'Missing patient_guid or email');
+ }
+
+ // Sanitize email
+ $email = sanitize_email($email);
+ if (!is_email($email)) {
+ return new WP_Error('invalid_email', 'Invalid email address: ' . $email);
+ }
+
+ // Check if client already exists in mapping table
+ $existing_mapping = $this->getClientByGuid($patient_guid);
+
+ // Find or create WordPress user
+ $user = get_user_by('email', $email);
+
+ if (!$user && $options['create_users']) {
+ // Create new user
+ $user_id = $this->createWordPressUser($email, $first_name, $last_name, $options['send_welcome_email']);
+
+ if (is_wp_error($user_id)) {
+ return $user_id;
+ }
+
+ $user = get_user_by('ID', $user_id);
+ $action = 'created';
+
+ } elseif (!$user) {
+ // User doesn't exist and we're not creating users
+ $this->import_stats['unmatched_emails'][] = $email;
+ return new WP_Error('user_not_found', 'User not found and create_users is false');
+
+ } else {
+ $action = 'existing';
+ }
+
+ // Update or insert client mapping
+ if ($existing_mapping) {
+ if ($options['update_existing']) {
+ $this->updateClientMapping($existing_mapping->id, [
+ 'user_id' => $user->ID,
+ 'first_name' => $first_name,
+ 'last_name' => $last_name,
+ 'email' => $email
+ ]);
+ $action = 'updated';
+ }
+ } else {
+ $this->insertClientMapping([
+ 'patient_guid' => $patient_guid,
+ 'user_id' => $user->ID,
+ 'first_name' => $first_name,
+ 'last_name' => $last_name,
+ 'email' => $email
+ ]);
+ if ($action !== 'created') {
+ $action = 'mapped';
+ }
+ }
+
+ return [
+ 'action' => $action,
+ 'user_id' => $user->ID,
+ 'patient_guid' => $patient_guid
+ ];
+ }
+
+ /**
+ * Create a new WordPress user
+ *
+ * @param string $email User email
+ * @param string $first_name First name
+ * @param string $last_name Last name
+ * @param bool $send_welcome_email Whether to send welcome email
+ * @return int|WP_Error User ID or error
+ */
+ protected function createWordPressUser(string $email, string $first_name, string $last_name, bool $send_welcome_email = false): int|WP_Error
+ {
+ // Generate username from email
+ $username = $this->generateUsername($email);
+
+ // Generate random password
+ $password = wp_generate_password(12, true, true);
+
+ $userdata = [
+ 'user_login' => $username,
+ 'user_email' => $email,
+ 'user_pass' => $password,
+ 'first_name' => $first_name,
+ 'last_name' => $last_name,
+ 'display_name' => trim($first_name . ' ' . $last_name),
+ 'role' => apply_filters(BASE . 'jane_import_default_role', 'customer')
+ ];
+
+ $user_id = wp_insert_user($userdata);
+
+ if (is_wp_error($user_id)) {
+ return $user_id;
+ }
+
+ // Send welcome email if requested
+ if ($send_welcome_email) {
+ wp_send_new_user_notifications($user_id, 'both');
+ }
+
+ do_action(BASE . 'jane_client_created', $user_id, $userdata);
+
+ return $user_id;
+ }
+
+ /**
+ * Generate unique username from email
+ *
+ * @param string $email Email address
+ * @return string Unique username
+ */
+ protected function generateUsername(string $email): string
+ {
+ $base_username = sanitize_user(substr($email, 0, strpos($email, '@')));
+ $username = $base_username;
+ $counter = 1;
+
+ while (username_exists($username)) {
+ $username = $base_username . $counter;
+ $counter++;
+ }
+
+ return $username;
+ }
+
+ /**
+ * Get client by patient GUID
+ *
+ * @param string $patient_guid Patient GUID
+ * @return object|null Client data or null
+ */
+ protected function getClientByGuid(string $patient_guid): ?object
+ {
+ return $this->wpdb->get_row($this->wpdb->prepare(
+ "SELECT * FROM {$this->jane_clients_table} WHERE patient_guid = %s",
+ $patient_guid
+ ));
+ }
+
+ /**
+ * Get client by user ID
+ *
+ * @param int $user_id WordPress user ID
+ * @return object|null Client data or null
+ */
+ public function getClientByUserId(int $user_id): ?object
+ {
+ return $this->wpdb->get_row($this->wpdb->prepare(
+ "SELECT * FROM {$this->jane_clients_table} WHERE user_id = %d",
+ $user_id
+ ));
+ }
+
+ /**
+ * Insert new client mapping
+ *
+ * @param array $data Client data
+ * @return int|false Insert ID or false on failure
+ */
+ protected function insertClientMapping(array $data): int|false
+ {
+ $result = $this->wpdb->insert(
+ $this->jane_clients_table,
+ $data,
+ ['%s', '%d', '%s', '%s', '%s']
+ );
+
+ return $result ? $this->wpdb->insert_id : false;
+ }
+
+ /**
+ * Update existing client mapping
+ *
+ * @param int $id Mapping ID
+ * @param array $data Updated data
+ * @return bool Success
+ */
+ protected function updateClientMapping(int $id, array $data): bool
+ {
+ return (bool) $this->wpdb->update(
+ $this->jane_clients_table,
+ $data,
+ ['id' => $id],
+ ['%d', '%s', '%s', '%s'],
+ ['%d']
+ );
+ }
+
+ /**
+ * Get user ID by patient GUID
+ *
+ * @param string $patient_guid Patient GUID
+ * @return int|null User ID or null if not found
+ */
+ public function getUserIdByGuid(string $patient_guid): ?int
+ {
+ $client = $this->getClientByGuid($patient_guid);
+ return $client ? (int) $client->user_id : null;
+ }
+
+ /**
+ * Get import statistics
+ *
+ * @return array Import statistics
+ */
+ public function getImportStats(): array
+ {
+ return $this->import_stats;
+ }
+}
diff --git a/inc/importers/JaneAppSalesImporter.php b/inc/importers/JaneAppSalesImporter.php
new file mode 100644
index 0000000..16fc8e8
--- /dev/null
+++ b/inc/importers/JaneAppSalesImporter.php
@@ -0,0 +1,574 @@
+<?php
+
+namespace JVBase\managers;
+
+use WP_Error;
+
+if (!defined('ABSPATH')) {
+ exit;
+}
+
+/**
+ * JaneApp Sales Importer
+ *
+ * Imports sales/treatment data from JaneApp CSV exports and updates referral tracking
+ */
+class JaneSalesImporter
+{
+ protected $wpdb;
+ protected string $jane_clients_table;
+ protected string $referrals_table;
+ protected string $treatments_table;
+ protected string $rewards_table;
+ protected ReferralManager $referral_manager;
+ protected array $import_stats = [];
+
+ // CSV column mapping
+ protected array $column_map = [
+ 'patient_guid' => 'Patient Guid',
+ 'purchase_date' => 'Purchase Date',
+ 'item' => 'Item',
+ 'status' => 'Status',
+ 'invoice_number' => 'Invoice #',
+ 'total' => 'Total',
+ ];
+
+ // Treatment types that qualify for rewards
+ protected array $treatment_types = [
+ 'free_consult' => 'Free Consult',
+ 'tier_1' => 'Tier 1',
+ 'tier_2' => 'Tier 2',
+ 'tier_3' => 'Tier 3',
+ 'tier_4' => 'Tier 4',
+ 'tier_5' => 'Tier 5',
+ 'tier_6' => 'Tier 6',
+ 'brows' => 'Brows',
+ 'freckles' => 'Freckles',
+ 'test_snap' => 'Test Snap',
+ 'smp_half' => 'SMP - Half Head',
+ 'smp_full' => 'SMP - Full Head',
+ ];
+
+ public function __construct()
+ {
+ global $wpdb;
+ $this->wpdb = $wpdb;
+ $this->jane_clients_table = $wpdb->prefix . BASE . 'jane_clients';
+ $this->referrals_table = $wpdb->prefix . BASE . 'referrals';
+ $this->treatments_table = $wpdb->prefix . BASE . 'referral_treatments';
+ $this->rewards_table = $wpdb->prefix . BASE . 'referral_rewards';
+ $this->referral_manager = new ReferralManager();
+ }
+
+ /**
+ * Import sales from CSV file
+ *
+ * @param string $file_path Path to the CSV file
+ * @param array $options Import options
+ * @return array Import results with stats and errors
+ */
+ public function importFromCSV(string $file_path, array $options = []): array
+ {
+ // Initialize stats
+ $this->import_stats = [
+ 'total_rows' => 0,
+ 'processed' => 0,
+ 'consultations' => 0,
+ 'treatments' => 0,
+ 'skipped' => 0,
+ 'errors' => [],
+ 'unmatched_guids' => [],
+ 'no_referral' => []
+ ];
+
+ // Validate file exists
+ if (!file_exists($file_path)) {
+ return new WP_Error('file_not_found', 'CSV file not found');
+ }
+
+ // Parse options
+ $skip_existing = $options['skip_existing'] ?? true;
+
+ // Open and parse CSV
+ $handle = fopen($file_path, 'r');
+ if (!$handle) {
+ return new WP_Error('file_open_error', 'Could not open CSV file');
+ }
+
+ // Get header row
+ $headers = fgetcsv($handle);
+ if (!$headers) {
+ fclose($handle);
+ return new WP_Error('invalid_csv', 'CSV file is empty or invalid');
+ }
+
+ // Map column indices
+ $column_indices = $this->mapColumnIndices($headers);
+ if (is_wp_error($column_indices)) {
+ fclose($handle);
+ return $column_indices;
+ }
+
+ // Start transaction for data integrity
+ $this->wpdb->query('START TRANSACTION');
+
+ try {
+ // Process each row
+ while (($row = fgetcsv($handle)) !== false) {
+ $this->import_stats['total_rows']++;
+
+ $result = $this->processSalesRow($row, $column_indices, [
+ 'skip_existing' => $skip_existing
+ ]);
+
+ if (is_wp_error($result)) {
+ $this->import_stats['errors'][] = [
+ 'row' => $this->import_stats['total_rows'],
+ 'error' => $result->get_error_message()
+ ];
+ $this->import_stats['skipped']++;
+ } else {
+ $this->import_stats['processed']++;
+ if ($result['type'] === 'consultation') {
+ $this->import_stats['consultations']++;
+ } elseif ($result['type'] === 'treatment') {
+ $this->import_stats['treatments']++;
+ }
+ }
+ }
+
+ // Commit transaction
+ $this->wpdb->query('COMMIT');
+
+ } catch (\Exception $e) {
+ // Rollback on error
+ $this->wpdb->query('ROLLBACK');
+ fclose($handle);
+ return new WP_Error('import_error', 'Import failed: ' . $e->getMessage());
+ }
+
+ fclose($handle);
+
+ return $this->import_stats;
+ }
+
+ /**
+ * Map CSV column headers to internal field names
+ *
+ * @param array $headers CSV header row
+ * @return array|WP_Error Column index mapping or error
+ */
+ protected function mapColumnIndices(array $headers): array|WP_Error
+ {
+ $indices = [];
+
+ foreach ($this->column_map as $field => $csv_column) {
+ $index = array_search($csv_column, $headers);
+ if ($index === false) {
+ return new WP_Error(
+ 'missing_column',
+ sprintf('Required column "%s" not found in CSV', $csv_column)
+ );
+ }
+ $indices[$field] = $index;
+ }
+
+ return $indices;
+ }
+
+ /**
+ * Process a single sales row from CSV
+ *
+ * @param array $row CSV row data
+ * @param array $column_indices Column mapping
+ * @param array $options Processing options
+ * @return array|WP_Error Result of processing
+ */
+ protected function processSalesRow(array $row, array $column_indices, array $options): array|WP_Error
+ {
+ // Extract data from row
+ $patient_guid = trim($row[$column_indices['patient_guid']] ?? '');
+ $purchase_date = trim($row[$column_indices['purchase_date']] ?? '');
+ $item = trim($row[$column_indices['item']] ?? '');
+ $status = trim($row[$column_indices['status']] ?? '');
+ $invoice_number = trim($row[$column_indices['invoice_number']] ?? '');
+ $total = trim($row[$column_indices['total']] ?? 0);
+
+ // Validate required fields
+ if (empty($patient_guid) || empty($purchase_date) || empty($item)) {
+ return new WP_Error('invalid_data', 'Missing required fields');
+ }
+
+ // Skip no-shows and cancellations
+ if ($this->shouldSkipItem($item, $status)) {
+ return new WP_Error('skipped_status', 'No show or cancelled appointment');
+ }
+
+ // Get user ID from patient GUID
+ $user_id = $this->getUserIdByGuid($patient_guid);
+ if (!$user_id) {
+ $this->import_stats['unmatched_guids'][] = $patient_guid;
+ return new WP_Error('user_not_found', 'User not found for patient GUID: ' . $patient_guid);
+ }
+
+ // Get referral record for this user
+ $referral = $this->referral_manager->getReferralByReferee($user_id);
+ if (!$referral) {
+ $this->import_stats['no_referral'][] = [
+ 'patient_guid' => $patient_guid,
+ 'user_id' => $user_id
+ ];
+ return new WP_Error('no_referral', 'User has no referral record');
+ }
+
+ // Parse date
+ $treatment_date = date('Y-m-d H:i:s', strtotime($purchase_date));
+
+ // Determine if this is a consultation or treatment
+ $is_consultation = $this->isConsultation($item);
+
+ if ($is_consultation) {
+ return $this->processConsultation($referral, $user_id, $treatment_date, $invoice_number);
+ } else {
+ return $this->processTreatment($referral, $user_id, $item, $treatment_date, $invoice_number, $total, $options);
+ }
+ }
+
+ /**
+ * Check if item should be skipped based on status or suffix
+ *
+ * @param string $item Item name
+ * @param string $status Item status
+ * @return bool True if should skip
+ */
+ protected function shouldSkipItem(string $item, string $status): bool
+ {
+ // Check for no-show or cancelled suffix
+ if (stripos($item, ' - No Show') !== false || stripos($item, ' - Cancelled') !== false) {
+ return true;
+ }
+
+ // Check status if available
+ if (!empty($status) && in_array(strtolower($status), ['no_show', 'cancelled', 'no show'])) {
+ return true;
+ }
+
+ return false;
+ }
+
+ /**
+ * Check if item is a consultation
+ *
+ * @param string $item Item name
+ * @return bool True if consultation
+ */
+ protected function isConsultation(string $item): bool
+ {
+ return stripos($item, 'Free Consult') !== false || stripos($item, 'Consultation') !== false;
+ }
+
+ /**
+ * Process a consultation
+ *
+ * @param object $referral Referral record
+ * @param int $user_id User ID
+ * @param string $treatment_date Date of consultation
+ * @param string $invoice_number Invoice number
+ * @return array Result
+ */
+ protected function processConsultation(object $referral, int $user_id, string $treatment_date, string $invoice_number): array
+ {
+ // Update referral status to 'consulted' if still pending
+ if ($referral->status === 'pending') {
+ $this->wpdb->update(
+ $this->referrals_table,
+ [
+ 'status' => 'consulted',
+ 'consulted_at' => $treatment_date
+ ],
+ ['id' => $referral->id],
+ ['%s', '%s'],
+ ['%d']
+ );
+
+ // Create 50% off reward for referee
+ $this->createConsultationReward($referral->id, $user_id);
+ }
+
+ // Record consultation in treatments table
+ $this->insertTreatment([
+ 'referral_id' => $referral->id,
+ 'user_id' => $user_id,
+ 'treatment_type' => 'Free Consultation',
+ 'treatment_date' => $treatment_date,
+ 'invoice_number' => $invoice_number,
+ 'amount' => 0,
+ 'status' => 'completed'
+ ]);
+
+ return [
+ 'type' => 'consultation',
+ 'referral_id' => $referral->id,
+ 'user_id' => $user_id
+ ];
+ }
+
+ /**
+ * Process a treatment
+ *
+ * @param object $referral Referral record
+ * @param int $user_id User ID
+ * @param string $treatment_type Treatment type
+ * @param string $treatment_date Date of treatment
+ * @param string $invoice_number Invoice number
+ * @param float $total Treatment cost
+ * @param array $options Processing options
+ * @return array Result
+ */
+ protected function processTreatment(
+ object $referral,
+ int $user_id,
+ string $treatment_type,
+ string $treatment_date,
+ string $invoice_number,
+ float $total,
+ array $options
+ ): array
+ {
+ // Check if this treatment already exists (prevent duplicates)
+ if ($options['skip_existing'] && $this->treatmentExists($referral->id, $invoice_number)) {
+ return new WP_Error('duplicate_treatment', 'Treatment already imported');
+ }
+
+ // Insert treatment record
+ $treatment_id = $this->insertTreatment([
+ 'referral_id' => $referral->id,
+ 'user_id' => $user_id,
+ 'treatment_type' => $treatment_type,
+ 'treatment_date' => $treatment_date,
+ 'invoice_number' => $invoice_number,
+ 'amount' => $total,
+ 'status' => 'completed'
+ ]);
+
+ // Update referral counts and status
+ $treatment_count = (int)$referral->treatment_count + 1;
+ $update_data = [
+ 'treatment_count' => $treatment_count
+ ];
+
+ // If this is the first treatment, mark as treated and set treated_at
+ if ($referral->status !== 'treated') {
+ $update_data['status'] = 'treated';
+ $update_data['treated_at'] = $treatment_date;
+
+ // Create full rewards for both referrer and referee
+ $this->createTreatmentRewards($referral->id);
+ }
+
+ $this->wpdb->update(
+ $this->referrals_table,
+ $update_data,
+ ['id' => $referral->id],
+ array_fill(0, count($update_data), '%s'),
+ ['%d']
+ );
+
+ return [
+ 'type' => 'treatment',
+ 'referral_id' => $referral->id,
+ 'user_id' => $user_id,
+ 'treatment_id' => $treatment_id
+ ];
+ }
+
+ /**
+ * Create consultation reward (50% off)
+ *
+ * @param int $referral_id Referral ID
+ * @param int $user_id User ID
+ */
+ protected function createConsultationReward(int $referral_id, int $user_id): void
+ {
+ $this->wpdb->insert(
+ $this->rewards_table,
+ [
+ 'referral_id' => $referral_id,
+ 'user_id' => $user_id,
+ 'reward_type' => 'referee',
+ 'amount' => 50,
+ 'reward_calculation' => 'percentage',
+ 'status' => 'available',
+ 'created_at' => current_time('mysql'),
+ 'notes' => 'Consultation reward - 50% off first treatment'
+ ],
+ ['%d', '%d', '%s', '%f', '%s', '%s', '%s', '%s']
+ );
+ }
+
+ /**
+ * Create treatment rewards for both referrer and referee
+ *
+ * @param int $referral_id Referral ID
+ */
+ protected function createTreatmentRewards(int $referral_id): void
+ {
+ $referral = $this->wpdb->get_row($this->wpdb->prepare(
+ "SELECT * FROM {$this->referrals_table} WHERE id = %d",
+ $referral_id
+ ));
+
+ if (!$referral) {
+ return;
+ }
+
+ $settings = $this->referral_manager->getRewardSettings();
+
+ // Create referrer reward (fixed amount)
+ $this->wpdb->insert(
+ $this->rewards_table,
+ [
+ 'referral_id' => $referral_id,
+ 'user_id' => $referral->referrer_id,
+ 'reward_type' => 'referrer',
+ 'amount' => $settings['referrer_reward_amount'],
+ 'reward_calculation' => $settings['referrer_reward_type'],
+ 'status' => 'available',
+ 'created_at' => current_time('mysql'),
+ 'notes' => 'Referral reward for completed treatment'
+ ],
+ ['%d', '%d', '%s', '%f', '%s', '%s', '%s', '%s']
+ );
+
+ // Create referee reward (percentage or fixed)
+ $this->wpdb->insert(
+ $this->rewards_table,
+ [
+ 'referral_id' => $referral_id,
+ 'user_id' => $referral->referee_id,
+ 'reward_type' => 'referee',
+ 'amount' => $settings['referee_reward_amount'],
+ 'reward_calculation' => $settings['referee_reward_type'],
+ 'status' => 'available',
+ 'created_at' => current_time('mysql'),
+ 'notes' => 'Treatment completion reward'
+ ],
+ ['%d', '%d', '%s', '%f', '%s', '%s', '%s', '%s']
+ );
+ }
+
+ /**
+ * Check if treatment already exists
+ *
+ * @param int $referral_id Referral ID
+ * @param string $invoice_number Invoice number
+ * @return bool True if exists
+ */
+ protected function treatmentExists(int $referral_id, string $invoice_number): bool
+ {
+ $count = $this->wpdb->get_var($this->wpdb->prepare(
+ "SELECT COUNT(*) FROM {$this->treatments_table}
+ WHERE referral_id = %d AND invoice_number = %s",
+ $referral_id,
+ $invoice_number
+ ));
+
+ return (int)$count > 0;
+ }
+
+ /**
+ * Insert treatment record
+ *
+ * @param array $data Treatment data
+ * @return int|false Insert ID or false on failure
+ */
+ protected function insertTreatment(array $data): int|false
+ {
+ $result = $this->wpdb->insert(
+ $this->treatments_table,
+ $data,
+ ['%d', '%d', '%s', '%s', '%s', '%f', '%s']
+ );
+
+ return $result ? $this->wpdb->insert_id : false;
+ }
+
+ /**
+ * Get user ID by patient GUID
+ *
+ * @param string $patient_guid Patient GUID
+ * @return int|null User ID or null if not found
+ */
+ protected function getUserIdByGuid(string $patient_guid): ?int
+ {
+ $result = $this->wpdb->get_var($this->wpdb->prepare(
+ "SELECT user_id FROM {$this->jane_clients_table} WHERE patient_guid = %s",
+ $patient_guid
+ ));
+
+ return $result ? (int)$result : null;
+ }
+
+ /**
+ * Get import statistics
+ *
+ * @return array Import statistics
+ */
+ public function getImportStats(): array
+ {
+ return $this->import_stats;
+ }
+
+ /**
+ * Get treatment history for a referral
+ *
+ * @param int $referral_id Referral ID
+ * @return array Treatment records
+ */
+ public function getTreatmentHistory(int $referral_id): array
+ {
+ return $this->wpdb->get_results($this->wpdb->prepare(
+ "SELECT * FROM {$this->treatments_table}
+ WHERE referral_id = %d
+ ORDER BY treatment_date DESC",
+ $referral_id
+ ));
+ }
+
+ /**
+ * Get treatment statistics for a user
+ *
+ * @param int $user_id User ID
+ * @return array Statistics
+ */
+ public function getUserTreatmentStats(int $user_id): array
+ {
+ $referral = $this->referral_manager->getReferralByReferee($user_id);
+
+ if (!$referral) {
+ return [
+ 'total_treatments' => 0,
+ 'last_treatment' => null,
+ 'treatment_types' => []
+ ];
+ }
+
+ $treatments = $this->getTreatmentHistory($referral->id);
+
+ $stats = [
+ 'total_treatments' => count($treatments),
+ 'last_treatment' => $treatments[0]->treatment_date ?? null,
+ 'treatment_types' => []
+ ];
+
+ foreach ($treatments as $treatment) {
+ $type = $treatment->treatment_type;
+ if (!isset($stats['treatment_types'][$type])) {
+ $stats['treatment_types'][$type] = 0;
+ }
+ $stats['treatment_types'][$type]++;
+ }
+
+ return $stats;
+ }
+}
diff --git a/inc/importers/_setup.php b/inc/importers/_setup.php
new file mode 100644
index 0000000..8da1879
--- /dev/null
+++ b/inc/importers/_setup.php
@@ -0,0 +1,3 @@
+<?php
+require(JVB_DIR . '/inc/importers/JaneAppClientImporter.php');
+require(JVB_DIR . '/inc/importers/JaneAppSalesImporter.php');
diff --git a/inc/integrations/GoogleMyBusiness.php b/inc/integrations/GoogleMyBusiness.php
index 37a6821..f6541cc 100644
--- a/inc/integrations/GoogleMyBusiness.php
+++ b/inc/integrations/GoogleMyBusiness.php
@@ -35,6 +35,7 @@
];
$this->apiEndpoints = [
+ '/accounts/[^/]+/locations/[^/]+/reviews',
'/accounts/[^/]+/locations/[^/]+/foodMenus',
'/v4/accounts/[^/]+/locations/[^/]+/media',
'/v4/accounts/[^/]+/locations/[^/]+/localPosts',
@@ -124,6 +125,8 @@
'check_oauth_status' => 'Check OAuth Status'
]
);
+
+// $this->cache->clear();
}
protected function initialize(): void
@@ -157,6 +160,16 @@
}
}
+ /**
+ * Check if response contains an error - Google-specific
+ */
+ protected function isErrorResponse(array $response): bool
+ {
+ // Google APIs return errors in this format:
+ // {"error": {"code": 401, "message": "...", "status": "UNAUTHENTICATED"}}
+ return isset($response['error']) && isset($response['error']['code']);
+ }
+
protected function getRequestHeaders(): array
{
return [
@@ -1431,6 +1444,128 @@
}
/**
+ * Get reviews for the current location
+ * @param int $page_size Number of reviews to fetch (max 50)
+ * @return array|null
+ */
+ public function getReviews(int $page_size = 5): ?array
+ {
+ $this->ensureInitialized();
+ if (!$this->location) {
+ throw new \Exception('No location selected');
+ }
+ if (!$this->account_id) {
+ throw new \Exception('No account configured');
+ }
+
+ $location = $this->getSelectedLocationResourceName();
+ $account = $this->account_id;
+
+
+ // Check cache first (weekly refresh = 604800 seconds)
+ $cache_key = ['reviews', $location, $page_size];
+ $cached = $this->cache->get($cache_key);
+ if ($cached !== false) {
+ return $cached;
+ }
+
+ try {
+ // Reviews endpoint from My Business Account Management API
+ $response = $this->getRequest(
+ "/{$account}/{$location}/reviews",
+ [
+ 'orderBy' => 'updateTime desc'
+ ],
+ 'v4'
+ );
+
+ error_log('Review response: '.print_r($response, true));
+ $reviews = $response ?? [];
+
+ // Cache for 1 week (604800 seconds)
+ $this->cache->set($cache_key, $reviews, WEEK_IN_SECONDS);
+
+ return $reviews;
+
+ } catch (\Exception $e) {
+ $this->logError($e->getMessage(), [
+ 'method' => 'getReviews'
+ ]);
+ return null;
+ }
+ }
+
+ /**
+ * Get the URL to view all Google reviews for the current location
+ * @return string|null The reviews viewing URL or null if not available
+ */
+ public function getReviewsViewUrl(): ?string
+ {
+ $this->ensureInitialized();
+ try {
+ $location = $this->getLocation();
+
+ if (empty($location)) {
+ return null;
+ }
+
+ // Prefer maps URL as it shows all reviews directly
+ if (!empty($location['metadata']['mapsUrl'])) {
+ return $location['metadata']['mapsUrl'];
+ }
+
+ // Fallback: construct from Place ID
+ if (!empty($location['metadata']['placeId'])) {
+ return 'https://search.google.com/local/reviews?placeid=' .
+ urlencode($location['metadata']['placeId']);
+ }
+
+ return null;
+
+ } catch (\Exception $e) {
+ $this->logError('Failed to get reviews view URL: ' . $e->getMessage(), [
+ 'method' => 'getReviewsViewUrl'
+ ]);
+ return null;
+ }
+ }
+
+ /**
+ * Get the URL to leave a review for the current location
+ * @return string|null The review URL or null if not available
+ */
+ public function getReviewUrl(): ?string
+ {
+ $this->ensureInitialized();
+ try {
+ $location = $this->getLocation();
+
+ if (empty($location)) {
+ return null;
+ }
+
+ // Try to use Place ID for write review
+ if (!empty($location['metadata']['placeId'])) {
+ return 'https://search.google.com/local/writereview?placeid=' .
+ urlencode($location['metadata']['placeId']);
+ }
+
+ // Fallback to maps URL
+ if (!empty($location['metadata']['mapsUrl'])) {
+ return $location['metadata']['mapsUrl'] . '/reviews';
+ }
+
+ return null;
+
+ } catch (\Exception $e) {
+ $this->logError('Failed to get review URL: ' . $e->getMessage(), [
+ 'method' => 'getReviewUrl'
+ ]);
+ return null;
+ }
+ }
+
+ /**
* Get locations for an account (with persistent storage)
* Allowed Fields: https://developers.google.com/my-business/content/location-data#list_of_all_supported_filter_fields
*/
diff --git a/inc/integrations/Integrations.php b/inc/integrations/Integrations.php
index 5f47ff6..e3ef859 100644
--- a/inc/integrations/Integrations.php
+++ b/inc/integrations/Integrations.php
@@ -167,7 +167,7 @@
{
$this->cacheName = $this->cacheName ?: $this->service_name;
$this->userID = $userID;
- $this->cache = new CacheManager('integrations_' . $this->cacheName, $this->ttl);
+ $this->cache = CacheManager::for('integrations_' . $this->cacheName, $this->ttl);
// Load error stats from cache
$this->loadErrorStats();
@@ -846,14 +846,14 @@
$this->logDebug("$method request to: $url: ".print_r($args, true));
- // Standard WordPress HTTP API
- // Use appropriate WordPress HTTP function
+ // Make the request
$response = match($method) {
'GET' => wp_remote_get($url, $args),
'POST' => wp_remote_post($url, $args),
'PUT', 'PATCH', 'DELETE' => wp_remote_request($url, array_merge($args, ['method' => $method])),
default => null
};
+
if (!$response) {
$this->logError("Unsupported HTTP method $method for $this->service_name");
return null;
@@ -867,9 +867,42 @@
$response_code = wp_remote_retrieve_response_code($response);
$body = wp_remote_retrieve_body($response);
+ // Handle 401 - try to refresh token and retry once
+ if ($response_code === 401 && $this->isOAuthService && !empty($this->credentials['refresh_token'])) {
+ // Avoid infinite retry loop - only retry once
+ static $retry_count = 0;
+
+ if ($retry_count === 0) {
+ $retry_count++;
+
+ $this->logDebug('Got 401, attempting token refresh...');
+ if ($this->refreshOAuthToken()) {
+ $this->logDebug('Token refreshed successfully, retrying request...');
+
+ // Rebuild request args with new token
+ $args = $this->buildRequestArgs($method, $data, $options);
+
+ // Retry the request
+ $response = match($method) {
+ 'GET' => wp_remote_get($url, $args),
+ 'POST' => wp_remote_post($url, $args),
+ 'PUT', 'PATCH', 'DELETE' => wp_remote_request($url, array_merge($args, ['method' => $method])),
+ default => null
+ };
+
+ if ($response && !is_wp_error($response)) {
+ $response_code = wp_remote_retrieve_response_code($response);
+ $body = wp_remote_retrieve_body($response);
+ }
+ }
+ $retry_count = 0; // Reset for next request
+ }
+ }
+
if ($response_code >= 400) {
$this->handleApiError($response_code, $body, $endpoint);
}
+
$decoded = json_decode($body, true);
if (json_last_error() !== JSON_ERROR_NONE) {
return ['raw_response' => $body];
@@ -904,7 +937,8 @@
$result = $this->makeRequest('GET', $endpoint, $params, $baseKey);
- if ($result && $ttl > 0) {
+ // Only cache successful responses (not WP_Error and not error objects)
+ if ($result && !is_wp_error($result) && !$this->isErrorResponse($result) && $ttl > 0) {
$this->cache->set($cacheKey, $result, $ttl);
}
@@ -912,6 +946,18 @@
}
/**
+ * Check if response contains an error
+ * Override in child classes for service-specific error detection
+ */
+ protected function isErrorResponse(array $response): bool
+ {
+ // Common error patterns across APIs
+ return isset($response['error'])
+ || isset($response['errors'])
+ || isset($response['error_description']);
+ }
+
+ /**
* POST request
*/
protected function postRequest(string $endpoint, array $data = [], ?string $baseKey = null): ?array
@@ -1758,12 +1804,7 @@
'redirect_uri' => $this->getRedirectUri()
];
- // Use a custom endpoint key for OAuth (not part of regular API)
- // We need to handle this specially since OAuth endpoints are different
$oauth_endpoint = $this->oauth['token'];
-
- // Make the request using the centralized method
- // This automatically includes rate limiting and error handling
$response = $this->makeOAuthRequest('POST', $oauth_endpoint, $request_data);
if (is_wp_error($response)) {
@@ -1776,10 +1817,13 @@
// Parse response
if (isset($response['access_token'])) {
+ $expires_in = $response['expires_in'] ?? 2592000; // 30 days default
+
return [
'access_token' => $response['access_token'],
'refresh_token' => $response['refresh_token'] ?? '',
- 'expires_in' => $response['expires_in'] ?? 2592000, // 30 days default
+ 'expires_in' => $expires_in,
+ 'expires_at' => time() + $expires_in, // Calculate expiry timestamp
'token_type' => $response['token_type'] ?? 'Bearer',
'merchant_id' => $response['merchant_id'] ?? '',
'scope' => $response['scope'] ?? ''
@@ -3064,7 +3108,7 @@
}
$credentials = $this->getCredentials();
$hasCredentials = $this->hasOAuthCredentials();
- $returnURL = (is_admin()) ? :get_the_permalink();
+ $returnURL = is_admin() ? admin_url('admin.php?page=jvb-integrations') : (get_the_permalink() ?: home_url());
?>
<details <?= $hasCredentials?' open':''?>>
diff --git a/inc/managers/AdminPages.php b/inc/managers/AdminPages.php
index c9f9e25..a511b78 100644
--- a/inc/managers/AdminPages.php
+++ b/inc/managers/AdminPages.php
@@ -1,6 +1,8 @@
<?php
namespace JVBase\managers;
+use JVBase\utility\Features;
+
if (!defined('ABSPATH')) {
exit; // Exit if accessed directly
}
@@ -259,6 +261,7 @@
</div>
</div>
<?php
+
}
/**
diff --git a/inc/managers/AjaxRateLimiter.php b/inc/managers/AjaxRateLimiter.php
new file mode 100644
index 0000000..74272af
--- /dev/null
+++ b/inc/managers/AjaxRateLimiter.php
@@ -0,0 +1,325 @@
+<?php
+namespace JVBase\managers;
+
+if (!defined('ABSPATH')) {
+ exit;
+}
+
+/**
+ * Simple rate limiter for AJAX requests (non-REST)
+ * Includes both hourly limits AND burst protection
+ */
+class AjaxRateLimiter
+{
+ protected array $limits = [
+ 'login' => [
+ 'count' => 20, // Hourly limit
+ 'window' => 3600, // 1 hour
+ 'burst_count' => 5, // Burst limit
+ 'burst_window' => 60 // 1 minute
+ ],
+ 'register' => [
+ 'count' => 10,
+ 'window' => 3600,
+ 'burst_count' => 3,
+ 'burst_window' => 60
+ ],
+ 'lostpassword' => [
+ 'count' => 10,
+ 'window' => 3600,
+ 'burst_count' => 3,
+ 'burst_window' => 60
+ ],
+ 'resetpass' => [
+ 'count' => 10,
+ 'window' => 3600,
+ 'burst_count' => 3,
+ 'burst_window' => 60
+ ],
+ ];
+
+ /**
+ * Check if action is within rate limits (both hourly and burst)
+ *
+ * @param string $action The action being performed (login, register, etc.)
+ * @return bool True if within limits, false if exceeded
+ */
+ public function checkLimit(string $action): bool
+ {
+ // Check burst protection first (stricter, prevents rapid-fire)
+ if (!$this->checkBurstLimit($action)) {
+ return false;
+ }
+
+ // Then check hourly limit
+ return $this->checkHourlyLimit($action);
+ }
+
+ /**
+ * Check burst protection (prevents rapid-fire attempts)
+ *
+ * Example: 5 login attempts in 10 seconds = blocked
+ *
+ * @param string $action The action being performed
+ * @return bool True if within burst limits, false if exceeded
+ */
+ protected function checkBurstLimit(string $action): bool
+ {
+ $limit = $this->getLimit($action);
+
+ // Skip if no burst protection configured
+ if (!isset($limit['burst_count'])) {
+ return true;
+ }
+
+ $key = $this->getCacheKey($action) . '_burst';
+ $data = get_transient($key);
+
+ if (!$data) {
+ $data = ['count' => 0, 'first_attempt' => time()];
+ }
+
+ // Check if burst window expired
+ $elapsed = time() - $data['first_attempt'];
+ if ($elapsed >= $limit['burst_window']) {
+ // Window expired, reset
+ $data = ['count' => 0, 'first_attempt' => time()];
+ }
+
+ // Check if burst limit exceeded
+ if ($data['count'] >= $limit['burst_count']) {
+ // Log for security monitoring
+ error_log(sprintf(
+ 'Burst rate limit exceeded for %s from %s: %d attempts in %d seconds',
+ $action,
+ $this->getClientIp(),
+ $data['count'],
+ $elapsed
+ ));
+ return false;
+ }
+
+ // Increment and save
+ $data['count']++;
+ set_transient($key, $data, $limit['burst_window']);
+
+ return true;
+ }
+
+ /**
+ * Check hourly rate limit
+ *
+ * @param string $action The action being performed
+ * @return bool True if within hourly limits, false if exceeded
+ */
+ protected function checkHourlyLimit(string $action): bool
+ {
+ $key = $this->getCacheKey($action);
+ $limit = $this->getLimit($action);
+
+ // Get current count
+ $data = get_transient($key);
+ if (!$data) {
+ $data = ['count' => 0, 'first_attempt' => time()];
+ }
+
+ // Check if window has expired
+ if (time() - $data['first_attempt'] >= $limit['window']) {
+ // Window expired, reset
+ $data = ['count' => 0, 'first_attempt' => time()];
+ }
+
+ // Check if limit exceeded
+ if ($data['count'] >= $limit['count']) {
+ // Log for security monitoring
+ error_log(sprintf(
+ 'Hourly rate limit exceeded for %s from %s: %d attempts',
+ $action,
+ $this->getClientIp(),
+ $data['count']
+ ));
+ return false;
+ }
+
+ // Increment and save
+ $data['count']++;
+ set_transient($key, $data, $limit['window']);
+
+ return true;
+ }
+
+ /**
+ * Get remaining attempts for an action
+ *
+ * @param string $action The action being performed
+ * @return array ['remaining' => int, 'reset_at' => int, 'burst_remaining' => int, 'burst_reset_at' => int]
+ */
+ public function getRemaining(string $action): array
+ {
+ $limit = $this->getLimit($action);
+
+ // Hourly remaining
+ $key = $this->getCacheKey($action);
+ $data = get_transient($key);
+
+ $hourly_remaining = $limit['count'];
+ $hourly_reset_at = time() + $limit['window'];
+
+ if ($data) {
+ $hourly_remaining = max(0, $limit['count'] - $data['count']);
+ $hourly_reset_at = $data['first_attempt'] + $limit['window'];
+ }
+
+ // Burst remaining (if configured)
+ $burst_remaining = $limit['burst_count'] ?? null;
+ $burst_reset_at = null;
+
+ if (isset($limit['burst_count'])) {
+ $burst_key = $key . '_burst';
+ $burst_data = get_transient($burst_key);
+
+ if ($burst_data) {
+ $burst_remaining = max(0, $limit['burst_count'] - $burst_data['count']);
+ $burst_reset_at = $burst_data['first_attempt'] + $limit['burst_window'];
+ } else {
+ $burst_reset_at = time() + $limit['burst_window'];
+ }
+ }
+
+ return [
+ 'remaining' => $hourly_remaining,
+ 'reset_at' => $hourly_reset_at,
+ 'burst_remaining' => $burst_remaining,
+ 'burst_reset_at' => $burst_reset_at
+ ];
+ }
+
+ /**
+ * Generate cache key based on IP and action
+ *
+ * @param string $action The action being performed
+ * @return string Cache key
+ */
+ protected function getCacheKey(string $action): string
+ {
+ $ip = $this->getClientIp();
+ $user_id = get_current_user_id(); // 0 if not logged in
+
+ return BASE . 'ajax_rate_limit_' . md5($ip . '_' . $user_id . '_' . $action);
+ }
+
+ /**
+ * Get client IP address (supports proxies)
+ *
+ * @return string IP address
+ */
+ protected function getClientIp(): string
+ {
+ // Check for proxy headers first
+ if (!empty($_SERVER['HTTP_X_FORWARDED_FOR'])) {
+ $ip = $_SERVER['HTTP_X_FORWARDED_FOR'];
+ // X-Forwarded-For can contain multiple IPs, get the first one
+ $ips = explode(',', $ip);
+ return trim($ips[0]);
+ }
+
+ if (!empty($_SERVER['HTTP_CLIENT_IP'])) {
+ return $_SERVER['HTTP_CLIENT_IP'];
+ }
+
+ return $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0';
+ }
+
+ /**
+ * Get limit configuration for an action
+ *
+ * @param string $action The action being performed
+ * @return array Limit configuration
+ */
+ protected function getLimit(string $action): array
+ {
+ return $this->limits[$action] ?? $this->limits['login'];
+ }
+
+ /**
+ * Clear rate limit for a specific action (useful for testing)
+ *
+ * @param string $action The action to clear
+ * @return bool True if cleared, false otherwise
+ */
+ public function clearLimit(string $action): bool
+ {
+ $key = $this->getCacheKey($action);
+ $burst_key = $key . '_burst';
+
+ $result1 = delete_transient($key);
+ $result2 = delete_transient($burst_key);
+
+ return $result1 || $result2;
+ }
+
+ /**
+ * Update limit configuration
+ *
+ * @param string $action The action to update
+ * @param int $count Max attempts per window
+ * @param int $window Time window in seconds
+ * @param int|null $burst_count Optional burst limit
+ * @param int|null $burst_window Optional burst window
+ */
+ public function setLimit(
+ string $action,
+ int $count,
+ int $window,
+ ?int $burst_count = null,
+ ?int $burst_window = null
+ ): void {
+ $this->limits[$action] = [
+ 'count' => $count,
+ 'window' => $window
+ ];
+
+ if ($burst_count !== null && $burst_window !== null) {
+ $this->limits[$action]['burst_count'] = $burst_count;
+ $this->limits[$action]['burst_window'] = $burst_window;
+ }
+ }
+
+ /**
+ * Check if IP is currently rate limited
+ *
+ * @param string $action The action to check
+ * @return bool True if rate limited, false otherwise
+ */
+ public function isRateLimited(string $action): bool
+ {
+ // Check both burst and hourly without incrementing
+ $limit = $this->getLimit($action);
+
+ // Check burst
+ if (isset($limit['burst_count'])) {
+ $burst_key = $this->getCacheKey($action) . '_burst';
+ $burst_data = get_transient($burst_key);
+
+ if ($burst_data) {
+ $elapsed = time() - $burst_data['first_attempt'];
+ if ($elapsed < $limit['burst_window'] && $burst_data['count'] >= $limit['burst_count']) {
+ return true;
+ }
+ }
+ }
+
+ // Check hourly
+ $key = $this->getCacheKey($action);
+ $data = get_transient($key);
+
+ if ($data) {
+ $elapsed = time() - $data['first_attempt'];
+ if ($elapsed < $limit['window'] && $data['count'] >= $limit['count']) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+}
diff --git a/inc/managers/CRUDManager.php b/inc/managers/CRUDManager.php
index 6deb11e..37b3a59 100644
--- a/inc/managers/CRUDManager.php
+++ b/inc/managers/CRUDManager.php
@@ -2,7 +2,9 @@
namespace JVBase\managers;
use JVBase\managers\UserTermsManager;
+use JVBase\meta\MetaForm;
use JVBase\meta\MetaManager;
+use JVBase\utility\Features;
use WP_User;
if (!defined('ABSPATH')) {
@@ -19,6 +21,7 @@
protected array $filters;
protected array $bulkActions;
protected MetaManager $meta;
+ protected MetaForm $form;
protected array $taxonomies;
protected array $statuses;
protected array $fields;
@@ -47,6 +50,7 @@
];
$this->init();
+ add_filter('jvbAdditionalActions', [$this, 'createItem']);
}
protected function init():void
@@ -56,7 +60,7 @@
$this->initTaxonomies();
$this->initFilters();
$this->meta = new MetaManager(null, 'post', $this->content);
-
+ $this->form = new MetaForm();
$plural = strtolower($this->config['plural']??$this->content.'s');
$this->userCanPublish = (jvbUserIsVerified()) ?
user_can($this->user_id, "publish_{$plural}") : false;
@@ -192,13 +196,13 @@
'multiple' => true,
'destination' => 'post'
];
- if (!jvbCheck('single_image', $this->config)) {
+ if (!array_key_exists('single_image', $this->config) || $this->config['single_image'] === false) {
$uploadConfig['destination'] = 'post_group';
}
$uploadConfig['destination'] = 'post_group';
if (!jvbCheck('single_image', $this->config)) {
- $uploadConfig['group_title'] = 'Create '.$this->config['plural'];
- $uploadConfig['group_description'] = '<p>Drag images into groups. Each group becomes its own '.$this->singular.'.</p>
+ $uploadConfig['label'] = 'Create '.$this->config['plural'];
+ $uploadConfig['upload_text'] = '<p>Drag images into groups. Each group becomes its own '.$this->singular.'.</p>
<p>You can also select multiple images and click the "Add to Group" button.</p>
<p>If a '.$this->singular.' has multiple images, you can select the '.jvbIcon('star').' to set an image as the main one.</p>
<p>Images left ungrouped will become individual '.$this->plural.'</p>
@@ -207,7 +211,6 @@
$uploadConfig['description'] = 'Each image will become its own '.$this->singular.'.';
}
?>
- <button type="button" class="create-item row" title="Create New <?= $this->singular?>"><?=jvbIcon('add') ?><span class="screen-reader-text">Create New <?= $this->singular?></span></button>
<details open class="uploader">
<summary class="row btw"><?= $this->config['upload_title'] ?? 'Bulk Upload '.$this->plural?></summary>
<?php
@@ -256,7 +259,7 @@
protected function renderFilters():void
{
?>
- <div class="all-filters col start">
+ <div class="all-filters col start" data-ignore>
<div class="search row start nowrap">
<span class="label">Search:</span>
<?= jvbSearch() ?>
@@ -585,7 +588,7 @@
protected function renderModals():void
{
- $this->renderCreateModal();
+// $this->renderCreateModal();
$this->renderEditModal();
$this->renderBulkEditModal();
}
@@ -603,6 +606,7 @@
ob_start();
?>
<form class="edit-form" data-save="content" data-form-id="edit-<?=$this->content?>">
+ <?= jvbFormStatus() ?>
<input type="hidden" name="form-id" value="<?=uniqid('new-')?>" />
<input type="hidden" name="content" value="<?=$this->content?>" />
<div class="fields">
@@ -632,20 +636,59 @@
} else {
$tabs = false;
}
+
+ $isTimeline = Features::forContent($this->content)->has('is_timeline');
+
+
$fields = $this->fields;
+ if (!$isTimeline) {
+ $first = ['post_thumbnail', 'post_title', 'price'];
- $first = ['post_thumbnail', 'post_title', 'price'];
- foreach ($first as $f) {
- if (array_key_exists($f, $fields)) {
- if ($tabs) {
- $tabs['basic']['content'] .= $this->meta->render('form', $f, $fields[$f], false, true);
- } else {
- $this->meta->render('form', $f, $fields[$f]);
+ foreach ($first as $f) {
+ if (array_key_exists($f, $fields)) {
+ if ($tabs) {
+ $tabs['basic']['content'] .= $this->meta->render('form', $f, $fields[$f], false, true);
+ } else {
+ $this->meta->render('form', $f, $fields[$f]);
+ }
+
+ unset($fields[$f]);
}
-
- unset($fields[$f]);
}
}
+
+ if ($isTimeline) {
+ $temp = array_filter($fields, function ($field) {
+ if (array_key_exists('for_all', $field) && $field['for_all'] === true) {
+ return true;
+ }
+ return false;
+ });
+ $config = [
+ 'type' => 'gallery',
+ 'subtype' => 'timeline',
+ 'data' => 'timeline',
+ 'label' => 'Progression',
+ 'fields' => $temp
+ ];
+ $content = '';
+ foreach ($fields as $slug=> $field) {
+ if (!array_key_exists('for_all', $field) || $field['for_all'] === false) {
+ $content .= $this->form->render($slug, null, $field, false, true);
+ }
+ }
+
+ $content .= $this->meta->render('form', 'timeline', $config, false,true);
+
+ $tabs['progression']['content'] = $content;
+ $this->fields = array_filter($fields, function ($field) {
+ if (array_key_exists('for_all', $field) && $field['for_all'] === true) {
+ return false;
+ }
+ return true;
+ });
+ $fields = $this->fields;
+ }
foreach ($fields as $n => $config) {
if ($tabs) {
$section = (array_key_exists('section', $config)) ? $config['section'] : 'basic';
@@ -653,10 +696,8 @@
} else {
$this->meta->render('form', $n, $config);
}
-
}
-
if ($tabs) {
jvbRenderTabs($tabs);
}
@@ -667,6 +708,58 @@
return ob_get_clean();
}
+
+ protected function renderTimelineFields():string
+ {
+ ob_start();
+
+
+ ?>
+ <div class="repeater-field timeline-repeater" data-timeline data-field="fields">
+ <div class="repeater-rows" data-repeater-container>
+ <!-- Parent row (non-draggable) -->
+ <div class="repeater-row parent-row" data-row-index="0" data-id="">
+ <div class="row-header">
+ <h4>Before (Starting Point)</h4>
+ </div>
+ <div class="row-fields">
+ <?php $this->renderRowFields(); ?>
+ </div>
+ </div>
+
+ <!-- Child rows will be added dynamically -->
+ </div>
+
+ <button type="button" class="add-repeater-row btn secondary">
+ <?= jvbIcon('add') ?>
+ <span>Add Progress Step</span>
+ </button>
+ </div>
+ <?php
+ return ob_get_clean();
+ }
+
+ protected function renderRowFields():void
+ {
+ $fields = $this->fields;
+
+ // Render priority fields first
+ $first = ['post_thumbnail', 'post_title', 'price'];
+ foreach ($first as $f) {
+ if (array_key_exists($f, $fields)) {
+ $this->meta->render('form', $f, $fields[$f]);
+ unset($fields[$f]);
+ }
+ }
+
+ // Render remaining fields
+ foreach ($fields as $name => $config) {
+ if (!array_key_exists('hidden', $config) || !$config['hidden']) {
+ $this->meta->render('form', $name, $config);
+ }
+ }
+ }
+
protected function getApplicableStatuses(string $prefix) {
foreach ($this->statuses as $status => $config) {
if ($status === 'all') {
@@ -713,6 +806,7 @@
ob_start();
?>
<form class="bulk-edit-form" data-save="content" data-form-id="bulk-edit-<?=$this->content?>">
+ <?= jvbFormStatus() ?>
<div class="selected"></div>
<p class="description">You can unselect items by clicking the image here.</p>
<p class="hint"><strong>IMPORTANT: </strong> Whatever changes you make here will be applied to all selected <?=$this->plural?>.</p>
@@ -776,6 +870,18 @@
$this->renderGridView();
$this->renderTableView();
$this->renderTableRow();
+ if (Features::forContent($this->content)->has('is_timeline')) {
+ $temp = array_filter($this->fields, function ($field) {
+ if (array_key_exists('for_all', $field) && $field['for_all'] === true) {
+ return true;
+ }
+ return false;
+ });
+ $form = new MetaForm();
+ echo '<template class="uploadTimeline">';
+ $form->renderImagePreview(null,$temp);
+ echo '</template>';
+ }
echo jvbGetEmptyStateTemplate();
echo jvbGetGalleryPreviewTemplate();
@@ -886,7 +992,7 @@
data-save="content"
data-content="<?= esc_attr($this->content) ?>"
data-form-id="content-table-<?= esc_attr($this->content) ?>">
-
+ <?= jvbFormStatus() ?>
<?= $this->renderTableActions() ?>
<table>
@@ -1024,4 +1130,17 @@
<?php
return ob_get_clean();
}
+
+ public function createItem(array $actions):array
+ {
+ ob_start();
+ $this->renderCreateModal();
+ $content = ob_get_clean();
+ $create = [
+ 'button' => '<button type="button" class="create-item row" title="Create New '.$this->singular.'">'.jvbIcon('add').'<span class="screen-reader-text">Create New '.$this->singular.'</span></button>',
+ 'content' => $content,
+ ];
+ $actions[] = $create;
+ return $actions;
+ }
}
diff --git a/inc/managers/CacheManager.php b/inc/managers/CacheManager.php
index e6530da..c8a38b9 100644
--- a/inc/managers/CacheManager.php
+++ b/inc/managers/CacheManager.php
@@ -2,33 +2,230 @@
namespace JVBase\managers;
if (!defined('ABSPATH')) {
- exit; // Exit if accessed directly
+ exit;
}
+/**
+ * Manages HTTP cache timestamps and relationship-based invalidation
+ *
+ * Data caching: Use wrapper methods or wp_cache_get/set directly
+ * HTTP caching: This class manages timestamps for ETag/Last-Modified headers
+ */
class CacheManager
{
- private string $prefix = 'jvb_';
+ private string $prefix = BASE;
private string $group;
private int $cache_ttl;
private static ?bool $use_object_cache = null;
+ private static array $instances = []; // Cache instances per type
+ private static array $http_timestamps = []; // Request-level memory cache
+ private static array $relationships = []; // Type => [related types]
+ private static bool $relationships_loaded = false;
/**
- * @param string|null $group The group name for this cache instance
- * @param int|null $ttl The default ttl for this instance
+ * Private constructor - use for() factory method instead
*/
- public function __construct(?string $group = null, ?int $ttl = null)
+ private function __construct(string $group, ?int $ttl = null)
{
- $this->group = $group ?: 'jvb_default';
+ $this->group = jvbNoBase($group);
$this->cache_ttl = $ttl ?: 3600;
- // Check if Redis/Memcached is available
if (is_null(static::$use_object_cache)) {
- static::$use_object_cache = !is_null(wp_using_ext_object_cache());
-// error_log((static::$use_object_cache) ? 'Using Object Cache' : 'Not using Object Cache');
+ static::$use_object_cache = wp_using_ext_object_cache();
}
}
/**
+ * Get or create a cache manager instance for a content type
+ *
+ * @param string $type Content type (tattoo, style, etc.)
+ * @param int|null $ttl Optional TTL override
+ * @return self Fluent interface
+ */
+ public static function for(string $type, ?int $ttl = null): self
+ {
+ $type = jvbNoBase($type);
+ $key = $type . ($ttl ? "_{$ttl}" : '');
+
+ if (!isset(self::$instances[$key])) {
+ self::$instances[$key] = new self($type, $ttl);
+ }
+
+ return self::$instances[$key];
+ }
+
+ /**
+ * Get cache manager for a specific user
+ * Each user gets their own cache group for complete isolation
+ *
+ * @param int $user_id User ID
+ * @param int|null $ttl Optional TTL
+ * @return self
+ */
+ public static function forUser(int $user_id, ?int $ttl = null): self
+ {
+ return self::for("user_{$user_id}", $ttl);
+ }
+
+ /**
+ * Get HTTP cache timestamp for content type(s)
+ * Used for ETag and Last-Modified header generation
+ *
+ * @param string|array $types Single type or array of types
+ * @return int Latest timestamp (Unix time)
+ */
+ public static function getTimestamp(string|array $types): int
+ {
+ // Multiple types - return latest
+ if (is_array($types)) {
+ $latest = 0;
+ foreach ($types as $type) {
+ $timestamp = self::getTimestamp($type);
+ if ($timestamp > $latest) {
+ $latest = $timestamp;
+ }
+ }
+ return $latest ?: time();
+ }
+
+ $type = jvbNoBase($types);
+
+ // Check request-level cache
+ if (isset(self::$http_timestamps[$type])) {
+ return self::$http_timestamps[$type];
+ }
+
+ // Load from cache (Redis or transient - wp_cache handles it)
+ $timestamp = (int)wp_cache_get("http_ts_{$type}", 'jvb_timestamps') ?: time();
+
+ // Cache in memory for this request
+ self::$http_timestamps[$type] = $timestamp;
+
+ return $timestamp;
+ }
+
+ /**
+ * Update HTTP cache timestamp (marks content as modified)
+ *
+ * @param string $type Content type
+ * @return int The new timestamp
+ */
+ public static function updateTimestamp(string $type): int
+ {
+ $type = jvbNoBase($type);
+ $timestamp = time();
+
+ // Store (Redis or transient - wp_cache handles it)
+ wp_cache_set("http_ts_{$type}", $timestamp, 'jvb_timestamps', WEEK_IN_SECONDS);
+
+ // Update request cache
+ self::$http_timestamps[$type] = $timestamp;
+
+ do_action('jvb_http_timestamp_updated', $type, $timestamp);
+
+ return $timestamp;
+ }
+
+ /**
+ * Invalidate cache for a content type with automatic cascade
+ *
+ * @param string $type Content type to invalidate
+ * @param mixed $context Post/Term object or array with relationship data (for cascade)
+ * @param string|array|null $specific_keys Optional specific key(s) to delete without flushing group
+ * @return void
+ */
+ public static function invalidateAll(string $type, $context = null, $specific_keys = null): void
+ {
+ $type = jvbNoBase($type);
+
+ // Update HTTP timestamp
+ self::updateTimestamp($type);
+
+ // If specific keys provided, only delete those (don't flush whole group)
+ if ($specific_keys !== null) {
+ $instance = self::for($type);
+ if (is_array($specific_keys)) {
+ foreach ($specific_keys as $key) {
+ $instance->delete($key);
+ }
+ } else {
+ $instance->delete($specific_keys);
+ }
+ } else {
+ // No specific keys - flush the entire group
+ if (function_exists('wp_cache_flush_group')) {
+ wp_cache_flush_group($type);
+ } else {
+ // Fallback for older WP
+ wp_cache_flush();
+ }
+ }
+
+ // Cascade to related types if context provided
+ if ($context !== null) {
+ self::cascadeInvalidation($type, $context);
+ }
+
+ do_action('jvb_cache_invalidated', $type, $context);
+ }
+
+ /**
+ * Invalidate only specific keys for a type (doesn't flush group or update timestamp)
+ * Use this when you want surgical cache invalidation
+ *
+ * @param string $type Content type
+ * @param string|array $keys Key(s) to delete
+ * @return void
+ */
+ public static function invalidateKeys(string $type, string|array $keys): void
+ {
+ $instance = self::for($type);
+
+ if (is_array($keys)) {
+ foreach ($keys as $key) {
+ $instance->delete($key);
+ }
+ } else {
+ $instance->delete($keys);
+ }
+ }
+
+ /**
+ * Fluent instance method to invalidate this cache type
+ * Allows chaining: CacheManager::for('tattoo')->invalidate()->clear()
+ *
+ * @param mixed $context Optional context for cascade
+ * @param string|array|null $specific_keys Optional specific key(s)
+ * @return self For chaining
+ */
+ public function invalidate($context = null, $specific_keys = null): self
+ {
+ self::invalidateAll($this->group, $context, $specific_keys);
+ return $this;
+ }
+
+ /**
+ * Get the HTTP timestamp for this instance's type
+ *
+ * @return int
+ */
+ public function timestamp(): int
+ {
+ return self::getTimestamp($this->group);
+ }
+
+ /**
+ * Update the HTTP timestamp for this instance's type
+ *
+ * @return self For chaining
+ */
+ public function touch(): self
+ {
+ self::updateTimestamp($this->group);
+ return $this;
+ }
+
+ /**
* Get a value from the cache
* @param string|array $key The key to look up (auto-generates key from array of key=>values)
* @param string|null $group The group to get from. Defaults to current group
@@ -37,39 +234,10 @@
public function get(string|array $key, ?string $group = null): mixed
{
$group = $group ?: $this->group;
-
$key = $this->normalizeKey($key);
-
$cache_key = $this->buildKey($key);
- // Use appropriate cache method
- if (static::$use_object_cache) {
- $value = wp_cache_get($cache_key, $group);
- } else {
- // Fallback to transients for local development
- $value = get_transient($this->getTransientKey($cache_key, $group));
- }
-
- return (is_array($value) && array_key_exists('data', $value)) ? $value['data'] : $value;
- }
-
- public function getTimestamp(string|array $key, ?string $group = null): mixed
- {
- $group = $group ?: $this->group;
-
- $key = $this->normalizeKey($key);
-
- $cache_key = $this->buildKey($key);
-
- // Use appropriate cache method
- if (static::$use_object_cache) {
- $value = wp_cache_get($cache_key, $group);
- } else {
- // Fallback to transients for local development
- $value = get_transient($this->getTransientKey($cache_key, $group));
- }
-
- return (is_array($value) && array_key_exists('last_modified', $value)) ? $value['last_modified'] : false;
+ return wp_cache_get($cache_key, $group);
}
/**
@@ -84,23 +252,13 @@
{
$ttl = $ttl ?: $this->cache_ttl;
$group = $group ?: $this->group;
-
$key = $this->normalizeKey($key);
-
$cache_key = $this->buildKey($key);
- $temp = [
- 'data' => $value,
- 'last_modified' => time(),
- ];
- $value = $temp;
- // Use appropriate cache method
- if (static::$use_object_cache) {
- return wp_cache_set($cache_key, $value, $group, $ttl);
- } else {
- // Fallback to transients
- return set_transient($this->getTransientKey($cache_key, $group), $value, $ttl);
- }
+ // Update timestamp when setting new data
+ self::updateTimestamp($this->group);
+
+ return wp_cache_set($cache_key, $value, $group, $ttl);
}
/**
@@ -112,147 +270,28 @@
public function delete(string|array $key, ?string $group = null): bool
{
$group = $group ?: $this->group;
-
$key = $this->normalizeKey($key);
-
$cache_key = $this->buildKey($key);
- // Use appropriate cache method
- if (static::$use_object_cache) {
- return wp_cache_delete($cache_key, $group);
- } else {
- return delete_transient($this->getTransientKey($cache_key, $group));
- }
- }
-
- public function clear():bool
- {
- try {
- if (static::$use_object_cache) {
- // With Redis, this could be implemented with SCAN command
- // but wp_cache_* doesn't expose this, so we'd need direct Redis access
- // For now, just flush the group as a nuclear option
- if (function_exists('wp_cache_flush_group')) {
- wp_cache_flush_group($this->group);
- return true;
- }
- return false;
- } else {
- // For transients, search and delete
- global $wpdb;
-
- $prefix = self::getTransientPrefix($this->group);
- $sql = "SELECT option_name FROM {$wpdb->options}
- WHERE option_name LIKE %s
- AND option_name LIKE %s";
-
- $keys = $wpdb->get_col($wpdb->prepare(
- $sql,
- '_transient_' . $prefix . '%'
- ));
-
- foreach ($keys as $key) {
- $transient_key = str_replace('_transient_', '', $key);
- delete_transient($transient_key);
- }
- return true;
- }
- } catch (\Exception $e) {
-
- } finally {
- return false;
- }
+ return wp_cache_delete($cache_key, $group);
}
/**
- * Alias for delete() for backwards compatibility
- * @param string $key The key to look up (auto-generates key from array of key=>values)
- * @param string|null $group The group to delete from (defaults to current group))
- * @return void
- */
- public function invalidate(string $key, ?string $group = null): void
- {
- $this->delete($key, $group);
- }
-
- /**
- * Clear all cache entries for a group
- * @param string $group The group to clear
+ * Clear all cache for this group
* @return bool
*/
- public static function invalidateGroup(string $group): bool
+ public function clear(): bool
{
- $group = jvbNoBase($group);
-
- if (wp_using_ext_object_cache()) {
- // With Redis/Memcached, use native group flush
- if (function_exists('wp_cache_flush_group')) {
- return wp_cache_flush_group($group);
- } else {
- // Fallback for older WP versions - flush everything (not ideal)
- return wp_cache_flush();
- }
- } else {
- // For transients, we need to delete them from database
- global $wpdb;
-
- $prefix = self::getTransientPrefix($group);
-
- // Delete transients and their timeouts
- $sql = "DELETE FROM {$wpdb->options}
- WHERE option_name LIKE %s
- OR option_name LIKE %s";
-
- $result = $wpdb->query($wpdb->prepare(
- $sql,
- '_transient_' . $prefix . '%',
- '_transient_timeout_' . $prefix . '%'
- ));
-
- return $result !== false;
- }
- }
-
- /**
- * Clear cache entries by pattern (only works efficiently with Redis)
- * @param string $pattern
- * @return int
- */
- public function clearPattern(string $pattern): int
- {
- $count = 0;
-
- if (static::$use_object_cache) {
- // With Redis, this could be implemented with SCAN command
- // but wp_cache_* doesn't expose this, so we'd need direct Redis access
- // For now, just flush the group as a nuclear option
+ try {
if (function_exists('wp_cache_flush_group')) {
wp_cache_flush_group($this->group);
- return $count;
+ self::updateTimestamp($this->group);
+ return true;
}
- } else {
- // For transients, search and delete
- global $wpdb;
-
- $prefix = self::getTransientPrefix($this->group);
- $sql = "SELECT option_name FROM {$wpdb->options}
- WHERE option_name LIKE %s
- AND option_name LIKE %s";
-
- $keys = $wpdb->get_col($wpdb->prepare(
- $sql,
- '_transient_' . $prefix . '%',
- '%' . $pattern . '%'
- ));
-
- foreach ($keys as $key) {
- $transient_key = str_replace('_transient_', '', $key);
- delete_transient($transient_key);
- $count++;
- }
+ return false;
+ } catch (\Exception $e) {
+ return false;
}
-
- return $count;
}
/**
@@ -289,22 +328,18 @@
{
$group = $group ?: $this->group;
$ttl = $ttl ?: $this->cache_ttl;
-
$key = $this->normalizeKey($key);
$value = $this->get($key, $group);
+
if ($value === false) {
$value = $callback();
- if ($value !== false) {
- $value = [
- 'data' => $value,
- 'last_modified' => time(),
- ];
+ if ($value !== false && $value !== null) {
$this->set($key, $value, $ttl, $group);
}
}
- return (is_array($value) && array_key_exists('data', $value)) ? $value['data']: $value;
+ return $value;
}
/**
@@ -318,59 +353,315 @@
}
/**
- * Get transient key for fallback mode
- * @param string $key
- * @param string $group
- * @return string
+ * Get instance group name (for debugging)
*/
- private function getTransientKey(string $key, string $group): string
+ public function getGroup(): string
{
- // Transients have a 172 character limit
- $full_key = $group . '_' . $key;
+ return $this->group;
+ }
- if (strlen($full_key) > 160) {
- // Use hash for long keys, but keep group prefix for clearPattern()
- return substr($group, 0, 20) . '_' . md5($full_key);
+ // ===== RELATIONSHIP MANAGEMENT =====
+
+ /**
+ * Register cache relationship
+ * When $type is invalidated, these related types are also invalidated
+ *
+ * @param string $type Primary type
+ * @param array $config Relationship configuration
+ * - 'author' => bool - Invalidate user content caches
+ * - 'taxonomies' => array - List of taxonomy types to invalidate
+ * - 'content_types' => array - List of content types to invalidate
+ * - 'related' => array - Generic related types to invalidate
+ * - 'cascade' => callable - Custom cascade function
+ */
+ public static function registerRelationship(string $type, array $config): void
+ {
+ $type = jvbNoBase($type);
+
+ // Merge with existing relationships
+ self::$relationships[$type] = array_merge(
+ self::$relationships[$type] ?? [],
+ $config
+ );
+
+ // Build reverse relationships for bidirectional linking
+ self::buildReverseRelationships($type, $config);
+ }
+
+ /**
+ * Build reverse relationships (if A relates to B, B should know about A)
+ *
+ * @param string $type The type being registered
+ * @param array $config Its relationship config
+ */
+ private static function buildReverseRelationships(string $type, array $config): void
+ {
+ // If this type relates to taxonomies, those taxonomies should know about this type
+ if (!empty($config['taxonomies'])) {
+ foreach ($config['taxonomies'] as $taxonomy) {
+ $taxonomy = jvbNoBase($taxonomy);
+ self::$relationships[$taxonomy]['content_types'] =
+ array_unique(array_merge(
+ self::$relationships[$taxonomy]['content_types'] ?? [],
+ [$type]
+ ));
+ }
}
- return $full_key;
+ // If this type relates to content_types, those types should know about this taxonomy
+ if (!empty($config['content_types'])) {
+ foreach ($config['content_types'] as $content_type) {
+ $content_type = jvbNoBase($content_type);
+ self::$relationships[$content_type]['related'] =
+ array_unique(array_merge(
+ self::$relationships[$content_type]['related'] ?? [],
+ [$type]
+ ));
+ }
+ }
}
/**
- * Get transient prefix for a group
+ * Load relationships from JVB_CONTENT and JVB_TAXONOMY
*/
- private static function getTransientPrefix(string $group): string
+ private static function loadRelationships(): void
{
- return $group . '_jvb_';
- }
-
- /**
- * Check if using object cache
- */
- public function isUsingObjectCache(): bool
- {
- return static::$use_object_cache;
- }
-
-
- /**
- * Cleanup expired transients (maintenance method for non-Redis environments)
- */
- public static function cleanupExpiredTransients(): int
- {
- if (wp_using_ext_object_cache()) {
- return 0; // Not needed with Redis
+ if (self::$relationships_loaded) {
+ return;
}
- global $wpdb;
+ // Load post type relationships
+ if (defined('JVB_CONTENT')) {
+ foreach (JVB_CONTENT as $slug => $config) {
+ $relationships = [];
- // Delete expired transients
- $sql = "DELETE a, b FROM {$wpdb->options} a, {$wpdb->options} b
- WHERE a.option_name LIKE '_transient_%'
- AND a.option_name NOT LIKE '_transient_timeout_%'
- AND b.option_name = CONCAT('_transient_timeout_', SUBSTRING(a.option_name, 12))
- AND b.option_value < %d";
+ // Author relationship
+ if (!($config['no_author'] ?? false)) {
+ $relationships['author'] = true;
+ }
- return $wpdb->query($wpdb->prepare($sql, time()));
+ // Taxonomy relationships
+ if (!empty($config['taxonomies'])) {
+ $relationships['taxonomies'] = array_map('jvbNoBase', $config['taxonomies']);
+ }
+
+ // Custom relationships from config
+ if (!empty($config['cache_relationships'])) {
+ $relationships = array_merge($relationships, $config['cache_relationships']);
+ }
+
+ if (!empty($relationships)) {
+ self::registerRelationship($slug, $relationships);
+ }
+ }
+ }
+
+ // Load taxonomy relationships
+ if (defined('JVB_TAXONOMY')) {
+ foreach (JVB_TAXONOMY as $slug => $config) {
+ $relationships = [];
+
+ // Content type relationships
+ if (!empty($config['for_content'])) {
+ $relationships['content_types'] = array_map('jvbNoBase', $config['for_content']);
+ }
+
+ // Always include generic 'terms' cache
+ $relationships['related'] = ['terms'];
+
+ // Custom relationships from config
+ if (!empty($config['cache_relationships'])) {
+ $relationships = array_merge($relationships, $config['cache_relationships']);
+ }
+
+ if (!empty($relationships)) {
+ self::registerRelationship($slug, $relationships);
+ }
+ }
+ }
+
+ self::$relationships_loaded = true;
+
+ do_action('jvb_cache_relationships_loaded', self::$relationships);
+ }
+
+ /**
+ * Get relationships for a type (for debugging)
+ *
+ * @param string|null $type Specific type or null for all
+ * @return array Relationships
+ */
+ public static function getRelationships(?string $type = null): array
+ {
+ self::loadRelationships();
+
+ if ($type !== null) {
+ return self::$relationships[jvbNoBase($type)] ?? [];
+ }
+
+ return self::$relationships;
+ }
+
+ /**
+ * Cascade invalidation to related types based on relationships
+ *
+ * @param string $type Primary type being invalidated
+ * @param mixed $context Context with relationship data
+ */
+ /**
+ * Cascade invalidation to related types based on relationships
+ */
+ private static function cascadeInvalidation(string $type, $context): void
+ {
+ self::loadRelationships();
+
+ $relationships = self::$relationships[$type] ?? [];
+ if (empty($relationships)) {
+ return;
+ }
+
+ $data = self::extractContext($context);
+
+ // Author relationship - SIMPLIFIED
+ if (!empty($relationships['author'])) {
+ $user_ids = self::extractUserIds($data, $relationships['author']);
+
+ foreach ($user_ids as $user_id) {
+ // Single clean call - handles content, profile, everything
+ self::invalidateAll("user_{$user_id}");
+ }
+ }
+
+ // Taxonomy relationships
+ if (!empty($relationships['taxonomies']) && !empty($data['ID'])) {
+ foreach ($relationships['taxonomies'] as $taxonomy) {
+ $taxonomy_full = jvbCheckBase($taxonomy);
+ $terms = wp_get_post_terms($data['ID'], $taxonomy_full, ['fields' => 'ids']);
+
+ if (!empty($terms) && !is_wp_error($terms)) {
+ self::updateTimestamp($taxonomy);
+ wp_cache_flush_group($taxonomy);
+ }
+ }
+ }
+
+ // Content type relationships (for taxonomies)
+ if (!empty($relationships['content_types'])) {
+ foreach ($relationships['content_types'] as $content_type) {
+ self::updateTimestamp($content_type);
+ wp_cache_flush_group($content_type);
+ }
+ }
+
+ // Generic related caches
+ if (!empty($relationships['related'])) {
+ foreach ($relationships['related'] as $related_type) {
+ self::updateTimestamp($related_type);
+ wp_cache_flush_group($related_type);
+ }
+ }
+
+ // Custom cascade function
+ if (!empty($relationships['cascade']) && is_callable($relationships['cascade'])) {
+ call_user_func($relationships['cascade'], $type, $data);
+ }
+ }
+
+ /**
+ * Extract user IDs from context based on relationship config
+ * Supports multiple authors, contributors, etc.
+ *
+ * @param array $data Context data
+ * @param mixed $config Author relationship config (bool or array)
+ * @return array User IDs to invalidate
+ */
+ private static function extractUserIds(array $data, $config): array
+ {
+ $user_ids = [];
+
+ // Simple case: 'author' => true
+ if ($config === true) {
+ if (!empty($data['post_author'])) {
+ $user_ids[] = $data['post_author'];
+ }
+ return array_filter($user_ids);
+ }
+
+ // Advanced case: 'author' => ['post_author', 'contributors', 'linked_user']
+ if (is_array($config)) {
+ foreach ($config as $field) {
+ // Handle meta fields
+ if (str_starts_with($field, 'meta:') && !empty($data['ID'])) {
+ $meta_key = substr($field, 5);
+ $value = get_post_meta($data['ID'], BASE . $meta_key, true);
+
+ if (is_array($value)) {
+ $user_ids = array_merge($user_ids, $value);
+ } elseif ($value) {
+ $user_ids[] = $value;
+ }
+ }
+ // Handle direct data fields
+ elseif (!empty($data[$field])) {
+ if (is_array($data[$field])) {
+ $user_ids = array_merge($user_ids, $data[$field]);
+ } else {
+ $user_ids[] = $data[$field];
+ }
+ }
+ }
+ }
+
+ // Callable: 'author' => function($data) { return [...user_ids]; }
+ if (is_callable($config)) {
+ $result = call_user_func($config, $data);
+ if (is_array($result)) {
+ $user_ids = array_merge($user_ids, $result);
+ } elseif ($result) {
+ $user_ids[] = $result;
+ }
+ }
+
+ return array_unique(array_filter(array_map('intval', $user_ids)));
+ }
+
+ /**
+ * Extract context data from various formats
+ * Converts WP objects to arrays with relevant data
+ *
+ * @param mixed $context Post/Term object, array, or ID
+ * @return array Normalized context data
+ */
+ private static function extractContext($context): array
+ {
+ if (is_array($context)) {
+ return $context;
+ }
+
+ if ($context instanceof \WP_Post) {
+ return [
+ 'ID' => $context->ID,
+ 'post_author' => $context->post_author,
+ 'post_type' => $context->post_type,
+ 'post_status' => $context->post_status,
+ ];
+ }
+
+ if ($context instanceof \WP_Term) {
+ return [
+ 'term_id' => $context->term_id,
+ 'taxonomy' => $context->taxonomy,
+ 'parent' => $context->parent,
+ ];
+ }
+
+ if (is_numeric($context)) {
+ $post = get_post($context);
+ if ($post) {
+ return self::extractContext($post);
+ }
+ }
+
+ return [];
}
}
diff --git a/inc/managers/CacheManagerOld.php b/inc/managers/CacheManagerOld.php
new file mode 100644
index 0000000..38297f6
--- /dev/null
+++ b/inc/managers/CacheManagerOld.php
@@ -0,0 +1,376 @@
+<?php
+namespace JVBase\managers;
+
+if (!defined('ABSPATH')) {
+ exit; // Exit if accessed directly
+}
+
+class CacheManagerOld
+{
+ private string $prefix = 'jvb_';
+ private string $group;
+ private int $cache_ttl;
+ private static ?bool $use_object_cache = null;
+
+ /**
+ * @param string|null $group The group name for this cache instance
+ * @param int|null $ttl The default ttl for this instance
+ */
+ public function __construct(?string $group = null, ?int $ttl = null)
+ {
+ $this->group = $group ?: 'jvb_default';
+ $this->cache_ttl = $ttl ?: 3600;
+
+ // Check if Redis/Memcached is available
+ if (is_null(static::$use_object_cache)) {
+ static::$use_object_cache = !is_null(wp_using_ext_object_cache());
+// error_log((static::$use_object_cache) ? 'Using Object Cache' : 'Not using Object Cache');
+ }
+ }
+
+ /**
+ * Get a value from the cache
+ * @param string|array $key The key to look up (auto-generates key from array of key=>values)
+ * @param string|null $group The group to get from. Defaults to current group
+ * @return mixed
+ */
+ public function get(string|array $key, ?string $group = null): mixed
+ {
+ $group = $group ?: $this->group;
+
+ $key = $this->normalizeKey($key);
+
+ $cache_key = $this->buildKey($key);
+
+ // Use appropriate cache method
+ if (static::$use_object_cache) {
+ $value = wp_cache_get($cache_key, $group);
+ } else {
+ // Fallback to transients for local development
+ $value = get_transient($this->getTransientKey($cache_key, $group));
+ }
+
+ return (is_array($value) && array_key_exists('data', $value)) ? $value['data'] : $value;
+ }
+
+ public function getTimestamp(string|array $key, ?string $group = null): mixed
+ {
+ $group = $group ?: $this->group;
+
+ $key = $this->normalizeKey($key);
+
+ $cache_key = $this->buildKey($key);
+
+ // Use appropriate cache method
+ if (static::$use_object_cache) {
+ $value = wp_cache_get($cache_key, $group);
+ } else {
+ // Fallback to transients for local development
+ $value = get_transient($this->getTransientKey($cache_key, $group));
+ }
+
+ return (is_array($value) && array_key_exists('last_modified', $value)) ? $value['last_modified'] : false;
+ }
+
+ /**
+ * Store a value in cache
+ * @param string|array $key The key to look up (auto-generates key from array of key=>values)
+ * @param mixed $value The Value to set
+ * @param int|null $ttl The ttl (defaults to current set ttl)
+ * @param string|null $group The group to add cache to (defaults to current group))
+ * @return bool
+ */
+ public function set(string|array $key, mixed $value, ?int $ttl = null, ?string $group = null): bool
+ {
+ $ttl = $ttl ?: $this->cache_ttl;
+ $group = $group ?: $this->group;
+
+ $key = $this->normalizeKey($key);
+
+ $cache_key = $this->buildKey($key);
+ $temp = [
+ 'data' => $value,
+ 'last_modified' => time(),
+ ];
+ $value = $temp;
+
+ // Use appropriate cache method
+ if (static::$use_object_cache) {
+ return wp_cache_set($cache_key, $value, $group, $ttl);
+ } else {
+ // Fallback to transients
+ return set_transient($this->getTransientKey($cache_key, $group), $value, $ttl);
+ }
+ }
+
+ /**
+ * Delete a cached value
+ * @param string|array $key The key to look up (auto-generates key from array of key=>values)
+ * @param string|null $group The group to delete from (defaults to current group)
+ * @return bool
+ */
+ public function delete(string|array $key, ?string $group = null): bool
+ {
+ $group = $group ?: $this->group;
+
+ $key = $this->normalizeKey($key);
+
+ $cache_key = $this->buildKey($key);
+
+ // Use appropriate cache method
+ if (static::$use_object_cache) {
+ return wp_cache_delete($cache_key, $group);
+ } else {
+ return delete_transient($this->getTransientKey($cache_key, $group));
+ }
+ }
+
+ public function clear():bool
+ {
+ try {
+ if (static::$use_object_cache) {
+ // With Redis, this could be implemented with SCAN command
+ // but wp_cache_* doesn't expose this, so we'd need direct Redis access
+ // For now, just flush the group as a nuclear option
+ if (function_exists('wp_cache_flush_group')) {
+ wp_cache_flush_group($this->group);
+ return true;
+ }
+ return false;
+ } else {
+ // For transients, search and delete
+ global $wpdb;
+
+ $prefix = self::getTransientPrefix($this->group);
+ $sql = "SELECT option_name FROM {$wpdb->options}
+ WHERE option_name LIKE %s
+ AND option_name LIKE %s";
+
+ $keys = $wpdb->get_col($wpdb->prepare(
+ $sql,
+ '_transient_' . $prefix . '%'
+ ));
+
+ foreach ($keys as $key) {
+ $transient_key = str_replace('_transient_', '', $key);
+ delete_transient($transient_key);
+ }
+ return true;
+ }
+ } catch (\Exception $e) {
+
+ } finally {
+ return false;
+ }
+ }
+
+ /**
+ * Alias for delete() for backwards compatibility
+ * @param string $key The key to look up (auto-generates key from array of key=>values)
+ * @param string|null $group The group to delete from (defaults to current group))
+ * @return void
+ */
+ public function invalidate(string $key, ?string $group = null): void
+ {
+ $this->delete($key, $group);
+ }
+
+ /**
+ * Clear all cache entries for a group
+ * @param string $group The group to clear
+ * @return bool
+ */
+ public static function invalidateGroup(string $group): bool
+ {
+ $group = jvbNoBase($group);
+
+ if (wp_using_ext_object_cache()) {
+ // With Redis/Memcached, use native group flush
+ if (function_exists('wp_cache_flush_group')) {
+ return wp_cache_flush_group($group);
+ } else {
+ // Fallback for older WP versions - flush everything (not ideal)
+ return wp_cache_flush();
+ }
+ } else {
+ // For transients, we need to delete them from database
+ global $wpdb;
+
+ $prefix = self::getTransientPrefix($group);
+
+ // Delete transients and their timeouts
+ $sql = "DELETE FROM {$wpdb->options}
+ WHERE option_name LIKE %s
+ OR option_name LIKE %s";
+
+ $result = $wpdb->query($wpdb->prepare(
+ $sql,
+ '_transient_' . $prefix . '%',
+ '_transient_timeout_' . $prefix . '%'
+ ));
+
+ return $result !== false;
+ }
+ }
+
+ /**
+ * Clear cache entries by pattern (only works efficiently with Redis)
+ * @param string $pattern
+ * @return int
+ */
+ public function clearPattern(string $pattern): int
+ {
+ $count = 0;
+
+ if (static::$use_object_cache) {
+ // With Redis, this could be implemented with SCAN command
+ // but wp_cache_* doesn't expose this, so we'd need direct Redis access
+ // For now, just flush the group as a nuclear option
+ if (function_exists('wp_cache_flush_group')) {
+ wp_cache_flush_group($this->group);
+ return $count;
+ }
+ } else {
+ // For transients, search and delete
+ global $wpdb;
+
+ $prefix = self::getTransientPrefix($this->group);
+ $sql = "SELECT option_name FROM {$wpdb->options}
+ WHERE option_name LIKE %s
+ AND option_name LIKE %s";
+
+ $keys = $wpdb->get_col($wpdb->prepare(
+ $sql,
+ '_transient_' . $prefix . '%',
+ '%' . $pattern . '%'
+ ));
+
+ foreach ($keys as $key) {
+ $transient_key = str_replace('_transient_', '', $key);
+ delete_transient($transient_key);
+ $count++;
+ }
+ }
+
+ return $count;
+ }
+
+ /**
+ * Helper to generateKey from array if applicable
+ * @param string|array $key
+ * @return string
+ */
+ private function normalizeKey(string|array $key): string
+ {
+ return is_array($key) ? $this->generateKey($key) : $key;
+ }
+
+ /**
+ * Generate a cache key from parameters
+ * @param array $params An array of key/values that differentiates this cache item from others
+ * @return string
+ */
+ public function generateKey(array $params): string
+ {
+ // Sort params for consistent key generation
+ ksort($params);
+ return md5(serialize($params));
+ }
+
+ /**
+ * The workhorse shorthand of CacheManager. Tests the cache, and calls the callback if nothing is found.
+ * @param string|array $key The key to look up (auto-generates key from array of key=>values)
+ * @param callable $callback The callback to generate the value for this key
+ * @param int|null $ttl The time-to-live for the cache. Defaults to constructor
+ * @param string|null $group The group to save cache to. Defaults to constructor
+ * @return mixed
+ */
+ public function remember(string|array $key, callable $callback, ?int $ttl = null, ?string $group = null): mixed
+ {
+ $group = $group ?: $this->group;
+ $ttl = $ttl ?: $this->cache_ttl;
+
+ $key = $this->normalizeKey($key);
+
+ $value = $this->get($key, $group);
+ if ($value === false) {
+ $value = $callback();
+ if ($value !== false) {
+ $value = [
+ 'data' => $value,
+ 'last_modified' => time(),
+ ];
+ $this->set($key, $value, $ttl, $group);
+ }
+ }
+
+ return (is_array($value) && array_key_exists('data', $value)) ? $value['data']: $value;
+ }
+
+ /**
+ * Build the cache key
+ * @param string $key
+ * @return string
+ */
+ private function buildKey(string $key): string
+ {
+ return $this->prefix . $key;
+ }
+
+ /**
+ * Get transient key for fallback mode
+ * @param string $key
+ * @param string $group
+ * @return string
+ */
+ private function getTransientKey(string $key, string $group): string
+ {
+ // Transients have a 172 character limit
+ $full_key = $group . '_' . $key;
+
+ if (strlen($full_key) > 160) {
+ // Use hash for long keys, but keep group prefix for clearPattern()
+ return substr($group, 0, 20) . '_' . md5($full_key);
+ }
+
+ return $full_key;
+ }
+
+ /**
+ * Get transient prefix for a group
+ */
+ private static function getTransientPrefix(string $group): string
+ {
+ return $group . '_jvb_';
+ }
+
+ /**
+ * Check if using object cache
+ */
+ public function isUsingObjectCache(): bool
+ {
+ return static::$use_object_cache;
+ }
+
+
+ /**
+ * Cleanup expired transients (maintenance method for non-Redis environments)
+ */
+ public static function cleanupExpiredTransients(): int
+ {
+ if (wp_using_ext_object_cache()) {
+ return 0; // Not needed with Redis
+ }
+
+ global $wpdb;
+
+ // Delete expired transients
+ $sql = "DELETE a, b FROM {$wpdb->options} a, {$wpdb->options} b
+ WHERE a.option_name LIKE '_transient_%'
+ AND a.option_name NOT LIKE '_transient_timeout_%'
+ AND b.option_name = CONCAT('_transient_timeout_', SUBSTRING(a.option_name, 12))
+ AND b.option_value < %d";
+
+ return $wpdb->query($wpdb->prepare($sql, time()));
+ }
+}
diff --git a/inc/managers/DashboardManager.php b/inc/managers/DashboardManager.php
index 0f75df3..276d3c1 100644
--- a/inc/managers/DashboardManager.php
+++ b/inc/managers/DashboardManager.php
@@ -1,7 +1,7 @@
<?php
namespace JVBase\managers;
-use JVBase\managers\CRUD;
+use JVBase\forms\TaxonomySelector;use JVBase\managers\CRUD;
use JVBase\meta\MetaManager;
use JVBase\utility\Features;
use WP_User;
@@ -22,8 +22,8 @@
public function __construct()
{
- $this->cache = new CacheManager('dashboard');
- $this->cache->invalidateGroup('dashboard');
+ $this->cache = CacheManager::for('dashboard', WEEK_IN_SECONDS);
+ $this->cache->invalidate();
add_action('init', [$this, 'registerDashboard']);
if (!$this->isRegistered()) {
add_action('init', [$this, 'buildDashboard']);
@@ -32,9 +32,12 @@
$this->role = jvbUserRole();
$this->userLink = (int)get_user_meta($this->user->ID, BASE.'link', true);
+
+ add_action('template_redirect', [$this, 'handleRedirects']);
add_action('template_include', [$this, 'dashboardTemplates']);
add_action('admin_init', [$this, 'redirectFromAdmin']);
add_action('wp_enqueue_scripts', [$this, 'dashboardScripts'], 50);
+ add_filter('jvbDashboardPage', [$this, 'renderIndex'], 10, 2);
}
/**
@@ -85,22 +88,121 @@
if (current_user_can('manage_options')) {
return;
}
-
// Redirect to custom dashboard
- wp_redirect(home_url('dash'));
- exit;
+ $this->redirectToDashboard();
}
+ protected function redirectToLogin():void
+ {
+ wp_redirect(wp_login_url(get_home_url(null, '/dash')));
+ exit;
+ }
+
+ protected function redirectToDashboard():void
+ {
+ wp_redirect(get_home_url(null, '/dash'));
+ exit;
+ }
+
+
+ protected function getConfig(string $page):array
+ {
+ $pages = $this->getAllDashboardPages();
+ $key = array_search($page, $pages);
+ if ($key === false || is_numeric($key)) {
+ return [];
+ }
+ return Features::getConfig($key);
+ }
+
+ /**
+ * Check if user can access page and redirect if not
+ * @param string $page
+ * @param int|null $userID
+ * @return void
+ */
+ protected function requirePageAccess(string $page, ?int $userID = null):void
+ {
+ $allowedPages = $this->getUserAllowedPages($userID);
+
+ if (!in_array($page, $allowedPages)) {
+ $this->redirectToDashboard();
+ }
+ }
+
+ protected function getTitle(string $slug):string
+ {
+ $config = $this->getConfig($slug);
+ if (!empty($config)) {
+ return $config['dash_title']??$config['plural'];
+ }
+ return ucwords(str_replace('-', ' ', str_replace('_', ' ', $slug)));
+ }
+
+ public function handleRedirects():void
+ {
+ // Only process dashboard-related pages and 404s
+ if (!is_singular(BASE.'dash') && !is_post_type_archive(BASE.'dash') && !is_404()) {
+ return;
+ }
+
+ // Check if user is logged in first
+ if (!is_404() && !is_user_logged_in()) {
+ error_log('Redirecting to login - user not logged in');
+ $this->redirectToLogin();
+ return;
+ }
+
+ // If logged in but doesn't have dashboard access, redirect to home
+ if (!is_404() && !isOurPeople() && !current_user_can('manage_options')) {
+ error_log('Redirecting to home - user lacks dashboard access');
+ wp_redirect(home_url());
+ exit;
+ }
+
+ // Handle 404s that are trying to access dashboard URLs
+ global $wp;
+ if (is_404() && (str_starts_with($wp->request, 'dash/') || $wp->request === 'dash')) {
+ error_log('404 on dashboard URL, redirecting to dashboard home');
+ $this->redirectToDashboard();
+ return;
+ }
+
+ // For valid dashboard pages, check access permissions
+ if (!is_404()) {
+ $page = $this->getCurrentPage();
+
+ // Dashboard home is always accessible (if authenticated)
+ if ($page === '' || $page === 'dash') {
+ return;
+ }
+
+ // Check if page exists in allowed pages
+ $allowedPages = $this->getUserAllowedPages();
+ if (!in_array($page, $allowedPages)) {
+ error_log("User not allowed to access page: {$page}");
+ $this->redirectToDashboard();
+ return;
+ }
+ }
+ }
+
/**
- * Ensures the necessary pages ar created
+ * Ensures the necessary pages are created
* @return void
*/
public function buildDashboard():void
{
- $manageableContent = jvbGetAllDashboardPages();
- foreach ($manageableContent as $slug) {
+ $manageableContent = $this->getAllDashboardPages();
+ foreach ($manageableContent as $key => $slug) {
+ if ($slug === 'dash') {
+ continue;
+ }
+ $existing = get_page_by_path($slug, OBJECT, BASE.'dash');
+ if ($existing) {
+ continue;
+ }
$title = $this->getTitle($slug);
- $slug = sanitize_title($title);
$ID = wp_insert_post(array(
'post_title' => $title,
@@ -110,174 +212,56 @@
));
if ($title === 'Integrations') {
- $integrations = ['BlueSky', 'Cloudflare', 'Facebook', 'Google Maps', 'Google My Business', 'Helcim', 'Instagram', 'Square', 'Umami'];
- foreach ($integrations as $integration) {
- $slug = sanitize_title($integration);
- wp_insert_post([
- 'post_title' => $integration,
- 'post_name' => $slug,
- 'post_type' => BASE.'dash',
- 'post_status' => 'publish',
- 'post_parent' => $ID
- ]);
- }
+ $this->buildIntegrationPages($ID);
}
}
update_option(BASE.'dashboard_registered', true);
remove_action('init', [$this, 'buildDashboard']);
}
- protected function getAllDashboardPages():array
+ /**
+ * Build integration sub-pages
+ * @param int $parentID
+ * @return void
+ */
+ protected function buildIntegrationPages(int $parentID):void
{
- $manageableContent = get_option(BASE.'all_dashboard_pages');
- if (JVB_TESTING) {
- $manageableContent = false;
+ $integrations = JVB()->getAvailableServices(false);
+
+ foreach ($integrations as $name => $integration) {
+ $title = $integration->getTitle();
+ $slug = sanitize_title($title);
+
+ // Check if integration page already exists
+ $existing = get_posts([
+ 'post_type' => BASE.'dash',
+ 'name' => $slug,
+ 'post_parent' => $parentID,
+ 'posts_per_page' => 1,
+ ]);
+
+ if (!empty($existing)) {
+ continue; // Skip if exists
+ }
+
+ wp_insert_post([
+ 'post_title' => $title,
+ 'post_name' => $slug,
+ 'post_type' => BASE.'dash',
+ 'post_status' => 'publish',
+ 'post_parent' => $parentID
+ ]);
}
- if ($manageableContent === false) {
-
- $manageableContent = [];
- $bios = [];
- foreach (JVB_USER as $role => $config) {
- $manageableContent = array_merge($manageableContent, jvbRolePages($role));
- }
-
- if (Features::forSite()->has('referrals')) {
- $manageableContent[] = 'referrals';
- }
- foreach (JVB_TAXONOMY as $tax => $config) {
- if (Features::forTaxonomy($tax)->has('is_content')) {
- $manageableContent[] = strtolower($config['plural']);
- }
- }
- if (Features::forMembership()->has('can_invite')) {
- $manageableContent[] = 'invites';
- }
-
- if (Features::forMembership()->has('term_approval')) {
- $manageableContent[] = 'approvals';
- }
-
- if (Features::forMembership()->has('forum')) {
- $manageableContent[] = 'news';
- }
-
- if (Features::forMembership()->has('member_content')) {
- $manageableContent[] = 'metrics';
- }
-
- if (!empty($bios)) {
- $manageableContent[] = 'bio';
- }
-
- if (Features::forSite()->has('favourites')) {
- $manageableContent[] = 'favourites';
- }
-
- if (Features::anyContentHas('karma') || Features::anyTaxonomyHas('karma') || Features::anyUserHas('karma')){
- $manageableContent[] = 'karmic-score';
- }
-
- if (Features::forSite()->has('notifications')) {
- $manageableContent[] = 'notifications';
- }
-
- if (Features::forSite()->has('support')){
- $manageableContent[] = 'support';
- }
-
- if (Features::hasAnyIntegration()) {
- $manageableContent[] = 'integrations';
- }
-
- $manageableContent[] = 'admin';
- $manageableContent = apply_filters('jvbDashboardPages', $manageableContent);
- $manageableContent = array_unique($manageableContent);
- sort($manageableContent);
- $manageableContent = array_map(function ($content) {
- return str_replace('_', '-', $content);
- }, $manageableContent);
- update_option(BASE.'all_dashboard_pages', $manageableContent);
- }
-
-
- return $manageableContent;
}
- protected function getRolePages(string $role):array
- {
- if (!array_key_exists(jvbNoBase($role), JVB_USER)) {
- return [];
- }
- $manageableContent = get_option(BASE.$role.'_pages');
- if (JVB_TESTING) {
- $manageableContent = false;
- }
- if ($manageableContent === false) {
- $manageableContent = [];
- $config = JVB_USER[$role];
- $content = $config['can_create'];
- $settings = $bio = false;
- if (array_key_exists('profile', $config)) {
- $manageableContent[] = $config['profile'];
- }
-
- foreach ($content as $c) {
- if (is_array($c)) {
- foreach ($c as $type => $contents) {
- $manageableContent = array_merge($manageableContent, $contents);
- }
- } else {
- $manageableContent = array_merge($manageableContent, [$c]);
- }
- }
-
- if (array_key_exists('has_dashboard', $config)) {
- $manageableContent[] = 'settings';
- }
-
- update_option(BASE.$role.'_pages', $manageableContent);
- }
-
- return $manageableContent;
- }
-
- protected function getTitle(string $page):string
- {
- $content = JVB_CONTENT;
- $contentTax = array_filter(JVB_TAXONOMY, function ($tax) {
- return jvbCheck('is_content', $tax);
- });
- $content = array_merge($content, $contentTax);
- $title = '';
-
-
- if (array_key_exists($page, $content)) {
- $config = $content[$page];
- $title = (array_key_exists('dash_title', $config)) ? $config['dash_title'] : $config['plural'];
- } else {
- switch ($page) {
- case 'admin':
- $title = 'Admin';
- break;
- default:
- $title = ucwords(str_replace('_', ' ', str_replace('-', ' ', $page)));
- }
- }
-
- return $title;
- }
protected function getDescription(string $page):string
{
- $content = JVB_CONTENT;
- $contentTax = array_filter(JVB_TAXONOMY, function ($tax) {
- return jvbCheck('is_content', $tax);
- });
- $content = array_merge($content, $contentTax);
- if (array_key_exists($page, $content)) {
- $config = $content[$page];
+ $config = $this->getConfig($page);
+ if (!empty($config)) {
$description = (array_key_exists('dash_description', $config)) ? $config['dash_description'] : '';
} else {
+ $description = apply_filters('jvbDashboardDescription', $page);
switch ($page) {
case 'approval':
$description = 'See your approval requests for term creation, joining shops, or joining edmonton.ink. You can also help shape the community by approving other\'s requests!';
@@ -320,147 +304,167 @@
if (!is_singular(BASE.'dash') && !is_post_type_archive(BASE.'dash')) {
return $template;
}
- if (!isOurPeople() && !current_user_can('manage_options')) {
- error_log('Redirecting because:');
- if (!isOurPeople()) {
- error_log('Not our people');
- }
- if (!current_user_can('manage_options')) {
- error_log('Cannot manage options');
- }
- wp_redirect(wp_login_url(get_home_url(null, '/dash')));
- exit;
- }
// Get current page/section
-
$page = $this->getCurrentPage();
+ $integrationSlugs = array_map(function($name) {
+ return sanitize_title(str_replace('_', '-', $name));
+ }, array_keys(JVB()->getAvailableServices(false)));
- switch ($page) {
- case 'integrations':
- if (!Features::hasAnyIntegration('user', $this->role)) {
- wp_redirect(get_home_url(null, '/dash'));
- exit;
- }
- break;
- case 'bluesky':
- case 'cloudflare':
- case 'facebook':
- case 'google-maps':
- case 'google-my-business':
- case 'helcim':
- case 'instagram':
- case 'square':
- case 'umami':
- if (!Features::hasIntegration($page,'user', $this->role)) {
- wp_redirect(get_home_url(null, '/dash'));
- exit;
- }
- break;
- case 'bio':
- $permission = JVB_USER[$this->role]['profile']??false;
- if (!$permission || (!current_user_can('manage_'.$permission) && !current_user_can('manage_options'))) {
- wp_redirect(get_home_url(null, '/dash'));
- exit;
- }
- break;
- case 'settings':
- if (!current_user_can('manage_settings') && !current_user_can('manage_options')) {
- wp_redirect(get_home_url(null, '/dash'));
- exit;
- }
- break;
- case 'approval':
- if (!current_user_can('skip_moderation')) {
- wp_redirect(get_home_url(null, '/dash'));
- exit;
- }
- break;
- case 'dash':
-
- break;
- default:
- $type = match($page) {
- 'menu-item' => 'menu_item',
- 'events' => 'event',
- default => $page
- };
-
- $permission = strtolower(str_replace(' ', '_',JVB_CONTENT[$type]['plural']??$type.'s'));
-
- if (!current_user_can('edit_'.$permission)) {
- error_log('User cannot edit: '.$permission);
- wp_redirect(get_home_url(null, '/dash'));
- exit;
- }
- break;
+ // Check if this is an integration subpage
+ if (in_array($page, $integrationSlugs)) {
+ // Pass along to the Integrations template handler which knows to check for subpages
+ $page = 'integrations';
}
- // Enqueue needed styles/scripts
+
+ echo $this->cache->remember(
+ $page,
+ function() use ($page) {
+ return $this->renderDashboard($page);
+ }
+ );
+
+ return '';
+ }
+
+ protected function getConstantSlug(string $page):string
+ {
+ $slug = array_search($page, $this->getAllDashboardPages());
+ return (is_numeric($slug)) ? '' : $slug;
+ }
+ protected function renderDashboard(string $page):string
+ {
+ ob_start();
jvbInlineStyles('nav');
jvbInlineStyles('dash');
jvbInlineStyles('forms');
- $this->cache->delete($page);
- echo $this->cache->remember(
+ $this->renderHeader();
+ // Pass to page handler
+ $constantSlug = $this->getConstantSlug($page);
+ echo apply_filters(
+ 'jvbDashboardPage',
+ $this->renderPage($page),
$page,
- function() use ($page) {
- ob_start();
- $this->renderHeader();
-
- switch ($page) {
- case 'dash':
- if (current_user_can('manage_options')) {
- $content = apply_filters('jvbAdminDashboard', '');
-
- if ($content !== '') {
- echo $content;
- }else {
- $this->renderAdmin();
- }
- } else {
- $this->renderIndex();
- }
-
- break;
- case 'admin':
- $this->renderAdmin();
- break;
- case 'bio':
- $this->renderForm(JVB_USER[$this->role]['profile']);
- break;
- case 'settings':
- $this->renderSettings();
- break;
- case 'integrations':
- case 'bluesky':
- case 'cloudflare':
- case 'facebook':
- case 'google-maps':
- case 'google-my-business':
- case 'helcim':
- case 'instagram':
- case 'square':
- case 'umami':
- $this->renderIntegrations($page);
- break;
- case 'approval':
- $this->renderApprovals();
- break;
- default:
- $this->renderCRUD($page);
- break;
- }
-
- echo jvbLoadingScreen();
- $this->renderFooter();
-
- // Get buffer contents and clean buffer
- return ob_get_clean();
- }
+ $constantSlug
);
- // Return empty string to prevent default template
- return '';
- }
+ $this->renderFooter();
+ return ob_get_clean();
+// $integrationSlugs = array_map(function($name) {
+// return sanitize_title(str_replace('_', '-', $name));
+// }, array_keys(JVB()->getAvailableServices(false)));
+//
+// if ($page === 'integrations' || in_array($page, $integrationSlugs)) {
+// // Check integration access
+// if ($page === 'integrations') {
+// if (!Features::hasAnyIntegration('user', $this->role)) {
+// $this->redirectToDashboard();
+// }
+// } else {
+// if (!Features::hasIntegration($page, 'user', $this->role)) {
+// $this->redirectToDashboard();
+// }
+// }
+// } elseif ($page === 'bio') {
+// // Bio page logic
+// $permission = JVB_USER[$this->role]['profile'] ?? false;
+// if (!$permission || (!current_user_can('manage_'.$permission) && !current_user_can('manage_options'))) {
+// $this->redirectToDashboard();
+// }
+// } elseif ($page === 'settings') {
+// // Settings page logic
+// if (!current_user_can('manage_settings') && !current_user_can('manage_options')) {
+// $this->redirectToDashboard();
+// }
+// } elseif ($page === 'approval') {
+// // Approval page logic
+// if (!current_user_can('skip_moderation')) {
+// $this->redirectToDashboard();
+// }
+// } elseif ($page !== 'dash') {
+// // Regular content type - check permission
+// $type = match($page) {
+// 'menu-item' => 'menu_item',
+// 'events' => 'event',
+// default => $page
+// };
+//
+// $permission = $this->getPermissionForType($type);
+// if (!current_user_can($permission)) {
+// $this->redirectToDashboard();
+// }
+// }
+// // Enqueue needed styles/scripts
+//
+// $this->cache->delete($page);
+// echo $this->cache->remember(
+// $page,
+// function() use ($page) {
+// ob_start();
+// $this->renderHeader();
+//
+// switch ($page) {
+// case 'dash':
+// if (current_user_can('manage_options')) {
+// $content = apply_filters('jvbAdminDashboard', '');
+//
+// if ($content !== '') {
+// echo $content;
+// }else {
+// $this->renderAdmin();
+// }
+// } else {
+// $this->renderIndex();
+// }
+//
+// break;
+// case 'admin':
+// $this->renderAdmin();
+// break;
+// case 'bio':
+// $this->renderForm(JVB_USER[$this->role]['profile']);
+// break;
+// case 'settings':
+// $this->renderSettings();
+// break;
+// case 'integrations':
+// case 'bluesky':
+// case 'cloudflare':
+// case 'facebook':
+// case 'google-maps':
+// case 'google-my-business':
+// case 'helcim':
+// case 'instagram':
+// case 'square':
+// case 'umami':
+// $this->renderIntegrations($page);
+// break;
+// case 'approval':
+// $this->renderApprovals();
+// break;
+// default:
+// $this->renderCRUD($page);
+// break;
+// }
+//
+// echo jvbLoadingScreen();
+// $this->renderFooter();
+//
+// // Get buffer contents and clean buffer
+// return ob_get_clean();
+// }
+// );
+//
+// // Return empty string to prevent default template
+// return '';
+ }
+
+ protected function renderPage(string $page):string
+ {
+ return '<h1>Whoops</h1>
+ <p>It seems this page isn\'t configured yet.</p>
+ <p>If this keeps happening, maybe contact the admin.</p>';
+ }
/**
* Enqueues necessary scripts
@@ -468,52 +472,48 @@
*/
public function dashboardScripts():void
{
- if (is_post_type_archive(BASE.'dash') || is_singular(BASE.'dash')) {
+ if (!is_singular(BASE.'dash') && !is_post_type_archive(BASE.'dash')) {
+ return;
+ }
+ wp_enqueue_script('jvb-loading');
+ wp_enqueue_script('jvb-form');
-// wp_enqueue_style('quill-css', 'https://cdn.quilljs.com/1.3.6/quill.snow.css');
+ // Consolidate all dashboard settings
+ wp_localize_script('jvb-loading', 'dashboardSettings', array(
+ 'loadingMessages' => array(
+ 'default' => 'Loading...',
+ 'error' => 'Failed to load page'
+ ),
+ 'strings' => array(
+ 'deleteConfirm' => 'Are you sure you want to delete this item?',
+ 'bulkDeleteConfirm' => 'Are you sure you want to delete these items?',
+ 'deleteSuccess' => 'Item(s) deleted successfully',
+ 'deleteError' => 'Error deleting item(s)',
+ 'saveSuccess' => 'Changes saved successfully',
+ 'saveError' => 'Error saving changes',
+ 'loadError' => 'Error loading content'
+ ),
+ 'currentUser' => array(
+ 'id' => $this->user->ID,
+ 'name' => $this->user->display_name,
+ 'role' => array_values($this->user->roles)[0] ?? '',
+ 'type' => str_replace(BASE, '', array_values($this->user->roles)[0]),
+ 'city' => '', // Add if needed,
+ 'artistID' => $this->userLink,
+ )
+ ));
+ wp_enqueue_script('jvb-selector');
+ wp_enqueue_script('jvb-uploader');
+ wp_enqueue_script('jvb-content');
+ wp_enqueue_script('jvb-crud');
- wp_enqueue_script('jvb-loading');
- wp_enqueue_script('jvb-form');
-
-
- // Consolidate all dashboard settings
- wp_localize_script('jvb-loading', 'dashboardSettings', array(
- 'loadingMessages' => array(
- 'default' => 'Loading...',
- 'error' => 'Failed to load page'
- ),
- 'strings' => array(
- 'deleteConfirm' => 'Are you sure you want to delete this item?',
- 'bulkDeleteConfirm' => 'Are you sure you want to delete these items?',
- 'deleteSuccess' => 'Item(s) deleted successfully',
- 'deleteError' => 'Error deleting item(s)',
- 'saveSuccess' => 'Changes saved successfully',
- 'saveError' => 'Error saving changes',
- 'loadError' => 'Error loading content'
- ),
- 'currentUser' => array(
- 'id' => $this->user->ID,
- 'name' => $this->user->display_name,
- 'role' => array_values($this->user->roles)[0] ?? '',
- 'type' => str_replace(BASE, '', array_values($this->user->roles)[0]),
- 'city' => '', // Add if needed,
- 'artistID' => $this->userLink,
- )
- ));
-
- wp_enqueue_script('jvb-selector');
- wp_enqueue_script('jvb-uploader');
- wp_enqueue_script('jvb-content');
- wp_enqueue_script('jvb-crud');
-// wp_enqueue_script('jvb-dashboard-navigator');
-
- $page = $this->getCurrentPage();
+ $page = $this->getCurrentPage();
switch ($page) {
case 'notifications':
- if (jvbSiteHasNotifications()) {
+ if (Features::forSite()->has('notifications')) {
wp_enqueue_script('jvb-notification-manager');
}
break;
@@ -547,7 +547,7 @@
}
break;
}
- if (jvbSiteHasFavourites()) {
+ if (Features::forSite()->has('favourites')) {
wp_enqueue_script('jvb-favourites');
wp_localize_script('jvb-favourites-manager', 'favouritesSettings', [
'strings' => [
@@ -563,22 +563,24 @@
wp_enqueue_script('jvb-creator');
- if (jvbSiteHasForum()) {
+ if (Features::forSite()->has('forum')) {
wp_enqueue_script('jvb-news');
}
do_action('jvbDashScripts', $page);
- }
}
- protected function getCurrentPage():string
+ protected function getCurrentPage():string
{
- global $wp;
- $dash = str_replace('dash/', '', $wp->request);
- if (str_starts_with($dash, 'integrations/')) {
- $dash = str_replace('integrations/', '', $dash);
+ if (is_post_type_archive(BASE.'dash')) {
+ return 'dash';
}
- return ($dash === '') ? 'dash' : $dash;
+ global $post;
+ if (!$post) {
+ return '';
+ }
+
+ return $post->post_name;
}
protected function renderHeader():void
@@ -591,7 +593,8 @@
<meta charset="<?php bloginfo('charset'); ?>">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<?php
- $pages = jvbGetUserDashboardPages();
+ $pages = $this->getUserAllowedPages();
+
foreach($pages as $page) {
$page = str_replace('_', '-', $page);
$link = ($page === 'dash') ? '/'.$page : "/dash/$page";
@@ -610,13 +613,26 @@
$checked = (is_user_logged_in() && current_user_can('prefers_dark_theme', true)) ? ' checked' : '';
$title = ($checked == '') ? 'Toggle Dark Mode' : 'Toggle Light Mode';
echo '<label title="'.$title.'" id="theme-switch" class="toggle-switch" for="theme-switcher">
- <input class="theme-switch row" id="theme-switcher" type="checkbox"'.$checked.' role="switch" name="dark-mode"><span class="slider">'.
- jvbIcon('light').
- jvbIcon('dark').'</span></label>';
+ <input class="theme-switch row" id="theme-switcher" type="checkbox"'.$checked.' data-setting="theme" data-theme role="switch" name="dark-mode"><span class="slider">'.
+ jvbIcon('light', ['title'=> 'Light Mode']).
+ jvbIcon('dark', ['title'=>'Dark Mode']).
+ '</span></label>';
?>
<p class="title">
<a href="<?= get_home_url(); ?>" rel="home" title="Back to Site">
- <?= jvbIcon('logo-basic'); ?>
+ <?php
+ $icon = (int) get_option( 'site_icon' );
+ $out = '';
+ if ($icon > 0) {
+ $url = wp_get_attachment_image_url( $icon);
+ if ($url) {
+ $out = '<img src="'.$url.'">';
+ }
+ }
+ if ($out == '') {
+ $out =jvbIcon('home');
+ }
+ ?><?= $out ?>
</a>
</p>
@@ -637,24 +653,24 @@
?>
</section>
<footer class="col">
+ <?= jvbLoadingScreen() ?>
+ <?= TaxonomySelector::outputSelectorModal() ?>
<nav class="dashboard-nav">
<?php
$current_page = $this->getCurrentPage();
- $pages = jvbGetUserDashboardPages()?:[];
+ $pages = $this->getUserAllowedPages()?:[];
+ error_log('PageS: '.print_r($pages, true));
- global $jvb_everything;
echo '<ul>';
foreach ($pages as $page) {
// Add data-page attribute for the navigator
$active = ($current_page == $page) ? ' class="current"' : '';
$current = ($current_page == $page) ? ' aria-current="page"' : '';
- $icon = (array_key_exists($page, $jvb_everything)) ? $jvb_everything[$page]['icon'] ?? $page : $page;
-
- $title = $this->getTitle($page);
- $page = str_replace('_', '-', $page);
+ $config = $this->getConfig($page);
+ $icon = $config['icon']??$page;
+ $title = ucwords(str_replace('-', ' ', $page));
$link = ($page === 'dash') ? '/'.$page : "/dash/$page";
-
printf(
'<li%s><a href="%s"%s data-page="%s" data-dash title="%s">%s<span>%s</span></a></li>',
$active,
@@ -684,37 +700,47 @@
<?php
}
- protected function renderIndex():void
+ public function renderIndex(string $content, string $page):string
{
- $name = get_post_meta($this->userLink, BASE.'firstname', true);
- $name = ($name === '') ? $this->user->display_name : $name;
+ if ($page !== '' && $page !== 'dash') {
+ return $content;
+ }
+ ob_start();
+ $name = ($this->user->first_name !== '') ? $this->user->first_name : $this->user->display_name;
echo '<h1 style="text-transform:none;margin-top:2em!important;">Hey '.$name.'</h1>';
echo '<p>Welcome back!</p>';
- $pages = jvbGetUserDashboardPages();
+ $pages = $this->getUserAllowedPages();
echo '<h2>What would you like to do today?</h2>';
- global $jvb_everything;
echo '<ul>';
- foreach ($pages as $page) {
-
+ foreach ($pages as $slug => $page) {
+ if ($page === 'dash') {
+ continue;
+ }
$title = $this->getTitle($page);
$url = sanitize_title($title);
$description = $this->getDescription($page);
-
+ $icon = $page;
+ if (!is_numeric($slug)) {
+ $config = Features::getConfig($slug);
+ if (array_key_exists('icon', $config)) {
+ $icon = $config['icon'];
+ }
+ }
if ($title !== '') {
echo '<li><p><a href="'.get_home_url(null, '/dash/'.$url.'/').'"
- data-page="'.$url.'" data-dash>'.jvbIcon($page).ucfirst($title).'</a></p>'.$description.'</li>';
+ data-page="'.$url.'" data-dash>'.jvbIcon($icon).ucwords($title).'</a></p></li>';
}
}
echo '</ul>';
echo '<p>Everything saves auto-magically, so rest easy.</p>';
-
+ return ob_get_clean();
}
/**
* Similar to CRUD, except it only manages a single item, such as a user's profile or a shop
@@ -887,20 +913,13 @@
}
- protected function renderCRUD(string $type):void
- {
- $type = match($type) {
- 'menu-item' => 'menu_item',
- 'events' => 'event',
- default => $type
- };
- $crud = new CRUD($type);
- $crud->render();
- }
-
- protected function renderAdmin():void
+ public function renderAdmin(string $content, string $page):string
{
+ if ($page !== '' && $page !== 'dash') {
+ return $content;
+ }
+ ob_start();
?>
<nav class="tabs row start" role="tablist">
<?php
@@ -918,7 +937,7 @@
$active = ($i === 1) ? ' active' : '';
?>
<button type="button" class="tab<?=$active?>" data-tab="<?=$type?>" role="tab" aria-selected="<?= ($active !== '') ? 'true' : 'false'?>">
- <h2><?=jvbIcon($type)?> <?= $settings['plural'] ?></h2>
+ <h2><?=jvbIcon($settings['icon']??$key)?> <?= $settings['plural'] ?></h2>
</button>
<?php
$i++;
@@ -953,7 +972,8 @@
</div>
<?php
- global $jvb_everything;
+
+ $jvb_everything = array_merge(JVB_CONTENT, JVB_TAXONOMY);
foreach ($jvb_everything as $type => $settings) {
$meta = new MetaManager(null, 'form');
@@ -1044,5 +1064,351 @@
$meta->renderForm('admin', [], $fields)
);
}
+
+ return ob_get_clean();
}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ /**
+ * Get all possible dashboard pages regardless of user
+ * Used during dashboard build process
+ * @return array
+ */
+ protected function getAllDashboardPages():array
+ {
+ $cacheKey = 'all_pages';
+ $pages = $this->cache->get($cacheKey);
+ if ($pages === false || JVB_TESTING) {
+ $pages = [];
+
+ // Add feature-dependent pages (non-config)
+ if (Features::forSite()->has('referrals')) {
+ $pages[] = 'referrals';
+ }
+
+ if (Features::forMembership()->has('can_invite')) {
+ $pages[] = 'invites';
+ }
+
+ if (Features::forMembership()->has('term_approval')) {
+ $pages[] = 'approvals';
+ }
+
+ if (Features::forMembership()->has('forum')) {
+ $pages[] = 'news';
+ }
+
+ if (Features::forMembership()->has('member_content')) {
+ $pages[] = 'metrics';
+ }
+
+ if (Features::forSite()->has('favourites')) {
+ $pages[] = 'favourites';
+ }
+
+ if (Features::anyContentHas('karma') || Features::anyTaxonomyHas('karma') || Features::anyUserHas('karma')) {
+ $pages[] = 'karmic-score';
+ }
+
+ if (Features::forSite()->has('notifications')) {
+ $pages[] = 'notifications';
+ }
+
+ if (Features::forSite()->has('support')) {
+ $pages[] = 'support';
+ }
+
+ if (Features::hasAnyIntegration()) {
+ $pages[] = 'integrations';
+ }
+
+ // Add all content types (with config keys)
+ foreach (JVB_CONTENT as $slug => $config) {
+ $pages[$slug] = sanitize_title($config['plural']);
+ }
+
+ foreach (JVB_TAXONOMY as $slug=>$config) {
+ $pages[$slug] = sanitize_title($config['plural']);
+ }
+
+ // Allow filtering
+ $pages = apply_filters('jvbAllDashboardPages', $pages);
+
+ // Remove duplicates while preserving keys
+ $pages = array_unique($pages);
+
+ // Dash home always first
+ array_unshift($pages, 'dash');
+
+ $this->cache->set($cacheKey, $pages, WEEK_IN_SECONDS);
+ }
+
+ return $pages;
+ }
+
+ /**
+ * Get pages available to a specific role
+ * @param string $role The role slug (with or without BASE prefix)
+ * @return array
+ */
+ protected function getRolePages(string $role):array
+ {
+ $role = jvbNoBase($role);
+
+ if (!array_key_exists($role, JVB_USER)) {
+ return [];
+ }
+
+ return Features::forUser($role)->getDashboardPages();
+ }
+
+ /**
+ * Get pages that a specific user is allowed to access
+ * Filters based on capabilities and features
+ * @param int|null $userID Optional user ID (defaults to current user)
+ * @return array
+ */
+ public function getUserAllowedPages(?int $userID = null):array
+ {
+ if ($userID === null) {
+ $user = $this->user;
+ $userID = $user->ID;
+ } else {
+ $user = get_userdata($userID);
+ }
+
+ if (!$user || !$this->userHasDashboardAccess($user)) {
+ return [];
+ }
+
+ $cacheKey = "user_pages_{$userID}";
+ $pages = $this->cache->get($cacheKey);
+
+ if ($pages === false || JVB_TESTING) {
+ if (user_can($userID, 'manage_options')) {
+ // Admin gets all pages as flat array
+ $pages = $this->getAllDashboardPages();
+ // Extract just the values (slugs)
+ $this->cache->set($cacheKey, $pages, WEEK_IN_SECONDS);
+ return $pages;
+ }
+ $roles = array_map('jvbNoBase', $user->roles);
+ $pages = $this->getAllDashboardPages();
+
+ $canSkip = user_can($userID, 'skip_moderation');
+ foreach($pages as $key => $slug) {
+ //Default to Remove pages
+ $remove = true;
+ if (!is_numeric($key)) {
+ $type = Features::getType($key);
+ if ($type) {
+ $permission = RoleManager::getPlural($key);
+ }
+ switch ($type) {
+ case 'content':
+ if (!user_can($userID, "edit_{$permission}")) {
+ $remove = false;
+ }
+ break;
+ case 'taxonomy':
+ $config = Features::getConfig($key, 'taxonomy');
+ if (array_key_exists('is_content', $config) && $config['is_content'] && (user_can($userID, "own_{$key}") || user_can($userID, "manage_{$key}"))) {
+ $remove = false;
+ }
+ break;
+ }
+ } else {
+ switch ($slug) {
+ case 'integrations':
+ foreach($roles as $role) {
+ if (Features::hasAnyIntegration('user', $role)) {
+ $remove = false;
+ }
+ }
+ break;
+ case 'invites':
+ $canInvite = JVB_MEMBERSHIP['can_invite']??[];
+ foreach ($roles as $role) {
+ if (array_key_exists($role, $canInvite)) {
+ $remove = false;
+ }
+ }
+ if ($remove) {
+ if ($canSkip || array_key_exists('invitable', $config)) {
+ $remove = false;
+ }
+ }
+ break;
+ case 'approvals':
+ $canApprove = false;
+ if (Features::forMembership()->has('term_approval')) {
+ if (array_key_exists('can_approve', JVB_MEMBERSHIP)) {
+ foreach ($roles as $role) {
+ if (in_array($role, JVB_MEMBERSHIP['can_approve'])) {
+ $canApprove = true;
+ }
+ }
+ } else {
+ //Anyone can approve
+ $canApprove = true;
+ }
+ }
+ if ($canSkip && $canApprove) {
+ $remove = false;
+ }
+ break;
+ case 'news':
+ $canAccess = false;
+ if (array_key_exists('member_only', JVB_MEMBERSHIP)){
+ foreach ($roles as $role) {
+ if (in_array($role, JVB_MEMBERSHIP['member_only'])) {
+ $canAccess = true;
+ }
+ }
+ }
+ if ($canAccess && $canSkip) {
+ $remove = false;
+ }
+ break;
+ case 'metrics':
+ foreach ($roles as $role) {
+ if (!empty(Features::forUser($role)->getCreatableContent())) {
+ $remove = false;
+ }
+ }
+ break;
+ case 'karmic-score':
+ foreach ($roles as $role) {
+ $contents = Features::forUser($role)->getCreatableContent();
+ if (!empty($contents)) {
+ foreach($contents as $content) {
+ if (Features::forContent($content)->has('karma')) {
+ $remove = false;
+ }
+ }
+ }
+ }
+ break;
+ case 'favourites':
+ case 'notifications':
+ case 'support':
+ $remove = false;
+ break;
+ default:
+ break;
+ }
+ if ($remove) {
+ unset($pages[$key]);
+ }
+ }
+ }
+
+ //Allow Filtering
+ $pages = apply_filters('jvbUserDashboardPages', $pages, $user->roles, $userID);
+ $pages = array_unique($pages);
+
+ $this->cache->set($cacheKey, $pages, WEEK_IN_SECONDS);
+ }
+
+ return $pages;
+ }
+
+ /**
+ * Check if user can create content
+ * Replaces jvbUserCanCreate()
+ * @param int $userID
+ * @return bool
+ */
+ protected function userCanCreate(int $userID = 0):bool
+ {
+ $user = ($userID === 0) ? wp_get_current_user() : get_userdata($userID);
+ $roles = array_intersect(
+ $this->getRolesWithDashboard(),
+ array_map('jvbNoBase', $user->roles)
+ );
+
+ $creatable = [];
+ foreach ($roles as $role) {
+ $roleCreatable = Features::forUser($role)->getCreatableContent();
+ $creatable = array_merge($creatable, $roleCreatable);
+ }
+
+ return !empty($creatable);
+ }
+
+ /**
+ * Get user roles that have dashboard access
+ * Replaces jvbRolesWithDashboard()
+ * @return array
+ */
+ protected function getRolesWithDashboard():array
+ {
+ return array_keys(array_filter(JVB_USER, function ($role) {
+ return Features::forUser(array_search($role, JVB_USER))->has('has_dashboard');
+ }));
+ }
+
+ /**
+ * Check if user has dashboard access
+ * @param WP_User $user
+ * @return bool
+ */
+ protected function userHasDashboardAccess(WP_User $user):bool
+ {
+ if (user_can($user, 'manage_options')) {
+ return true;
+ }
+
+ $userRoles = array_map('jvbNoBase', $user->roles);
+ $dashboardRoles = $this->getRolesWithDashboard();
+
+ return count(array_intersect($dashboardRoles, $userRoles)) > 0;
+ }
+
+ /**
+ * Get the capability needed to access a content type
+ * @param string $type
+ * @return string
+ */
+ protected function getPermissionForType(string $type):string
+ {
+ // Check if it's a registered content type
+ if (array_key_exists($type, JVB_CONTENT)) {
+ $plural = JVB_CONTENT[$type]['plural'];
+ return 'edit_'.$plural;
+ }
+
+ // Default to edit_{type}s
+ return 'edit_'.$type.'s';
+ }
+
+ /**
+ * Invalidate dashboard page cache for a user or all users
+ * Call this when user roles or permissions change
+ * @param int|null $userID Specific user to invalidate, null for all
+ * @return void
+ */
+ public function invalidatePagesCache(?int $userID = null):void
+ {
+ if ($userID !== null) {
+ $this->cache->delete("user_pages_{$userID}");
+ } else {
+ // Invalidate all user caches by invalidating the group
+ $this->cache->invalidate();
+ }
+ }
}
diff --git a/inc/managers/DirectoryManager.php b/inc/managers/DirectoryManager.php
index 856eab8..6b5f4c9 100644
--- a/inc/managers/DirectoryManager.php
+++ b/inc/managers/DirectoryManager.php
@@ -21,7 +21,7 @@
if (empty(jvbGlobalDirectories())) {
return;
}
- $this->cache = new CacheManager('directory', WEEK_IN_SECONDS);
+ $this->cache = CacheManager::for('directory', WEEK_IN_SECONDS);
add_action('init', [$this, 'registerDirectories']);
jvb_register_do_once('directories_registered', [$this, 'activate']);
diff --git a/inc/managers/FormManager.php b/inc/managers/FormManager.php
index 28288b8..963b973 100644
--- a/inc/managers/FormManager.php
+++ b/inc/managers/FormManager.php
@@ -8,6 +8,7 @@
exit; // Exit if accessed directly
}
/**
+ * TODO: this is old, I think.
* Form Manager Class
* Mainly used for front-end forms.
* Handles form rendering and processing using MetaManager
@@ -46,7 +47,7 @@
$this->turnstile_site_key = JVB_CLOUDFLARE_SITE_KEY;
$this->turnstile_secret_key = JVB_CLOUDFLARE_SECRET_KEY;
$this->meta = new MetaManager(null, 'form');
- $this->cache = new CacheManager('forms', WEEK_IN_SECONDS);
+ $this->cache = CacheManager::for('forms', WEEK_IN_SECONDS);
}
/**
diff --git a/inc/managers/LoginManager.php b/inc/managers/LoginManager.php
index 7bce0db..dd12a81 100644
--- a/inc/managers/LoginManager.php
+++ b/inc/managers/LoginManager.php
@@ -1,1054 +1,1308 @@
<?php
namespace JVBase\managers;
+use JVBase\blocks\CustomBlocks;
+use JVBase\forms\TaxonomySelector;
use JVBase\meta\MetaManager;
+use JVBase\meta\MetaForm;
+use JVBase\managers\AjaxRateLimiter;
+use JVBase\utility\Features;
use WP_Error;
use WP_User;
if (!defined('ABSPATH')) {
- exit; // Exit if accessed directly
+ exit;
}
class LoginManager
{
- private array|null $invitation_data = null;
- protected array $inviteData = [];
- private array $allowed_file_types = [
- 'image/jpeg',
- 'image/png',
- 'image/gif',
- 'application/pdf'
- ];
- private int $max_file_size = 5242880; // 5MB in bytes
+ protected Features $siteFeatures;
+ protected ?MagicLinkManager $magicLink = null;
+ protected ?MetaForm $metaForm = null;
+ protected EmailManager $emailManager;
+ protected AjaxRateLimiter $rateLimiter;
- public function __construct()
- {
- // Common login page customization
- add_action('login_enqueue_scripts', array($this, 'loginStyles'));
- add_action('login_header', array($this, 'loginHeader'), 0);
- add_action('login_footer', array($this, 'loginFooter'));
- // Login page filters
- add_filter('login_headerurl', array($this, 'logoUrl'));
- add_filter('login_headertext', array($this, 'logoTitle'));
- add_filter('login_message', array($this, 'loginMessage'));
- add_filter('login_errors', array($this, 'loginErrors'));
+ protected array $forms =[];
+ protected array $labels = [];
+ protected array $fields = [];
+ protected ?string $action = null;
+ protected string $title = '';
- // Login success handling
- add_action('wp_login', [$this, 'handleSuccessfulLogin'], 10, 2);
+ // Token handlers registry
+ protected array $tokenHandlers = [];
+ protected array $messageHandlers = [];
- // Registration-specific hooks
- if ($this->isRegistrationPage()) {
- $this->initRegistrationHooks();
- }
- }
+ private array $allowed_file_types = [
+ 'image/jpeg',
+ 'image/png',
+ 'image/gif',
+ 'application/pdf'
+ ];
+ private int $max_file_size = 5242880; // 5MB in bytes
- /**
- * Check if we're on the registration page
- */
- private function isRegistrationPage(): bool
- {
- return isset($_GET['action']) && $_GET['action'] === 'register';
- }
+ public function __construct()
+ {
+ $this->siteFeatures = Features::forSite();
+ $this->metaForm = new MetaForm();
+ $this->emailManager = new EmailManager();
+ $this->rateLimiter = new AjaxRateLimiter();
- /**
- * Initialize registration-specific hooks
- */
- private function initRegistrationHooks(): void
- {
- add_action('register_form', array($this, 'addRegistrationFields'));
- add_action('login_header', array($this, 'addRegistrationScript'));
- add_filter('registration_errors', array($this, 'registrationErrorsFilter'), 10, 3);
- add_action('user_register', array($this, 'saveRegistrationFields'), 999, 2);
- add_action('login_head', array($this, 'modifyRegistrationForm'));
- add_action('register_form', array($this, 'addUploadSupport'));
- add_filter('pre_user_login', array($this, 'setUserLogin'), 1);
- add_filter('pre_user_email', array($this, 'setUserEmail'), 1);
- add_filter('register_message', array($this, 'customRegisterMessage'));
- add_filter('wp_login_errors', array($this, 'registrationSuccessMessage'), 10, 2);
- add_filter('login_form_top', array($this, 'loginFormTop'));
- add_filter('login_form_bottom', array($this, 'loginFormBottom'));
- add_filter('login_form_middle', array($this, 'loginFormMiddle'));
+ // Register default token handlers
+ $this->registerDefaultHandlers();
- // Remove default username requirement for registration
- remove_filter('registration_errors', 'registration_auth_pass_filter', 10);
- }
+ // Initialize magic link support if enabled
+ if ($this->siteFeatures->has('magicLink')) {
+ $this->initMagicLinkSupport();
+ }
- /**
- * Combined login styles for both login and registration
- */
- public function loginStyles(): void
- {
- do_action('jvbLoginStyles');
- }
+ // Create login page if it doesn't exist
+ $this->ensureLoginPageExists();
- /**
- * Login header - used for both login and registration
- */
- public function loginHeader(): void
- {
- ?>
- <script type="text/javascript">
- document.addEventListener('DOMContentLoaded', function() {
- let loginLabel = document.querySelector('label[for="user_login"');
- loginLabel.innerHTML = '<?= jvbIcon('email', ['size' => 20]); ?> Your Email';
- let passwordLabel = document.querySelector('label[for="user_pass"');
- passwordLabel.innerHTML = '<?= jvbIcon('password', ['size' => 20]); ?> Your Password';
+ // Redirect wp-login.php to custom page
+ add_action('login_init', [$this, 'redirectToCustomLogin']);
+ add_action('template_include', [$this, 'renderLoginPage']);
- document.querySelector('form').classList.add('loaded');
- });
+ add_action('wp_enqueue_scripts', [$this, 'enqueueScripts'], 15);
- </script>
- <?php
- }
+ // Handle form submissions via AJAX
+ add_action('wp_ajax_nopriv_jvb_login', [$this, 'handleAjaxLogin']);
+ add_action('wp_ajax_nopriv_jvb_register', [$this, 'handleAjaxRegister']);
+ add_action('wp_ajax_nopriv_jvb_lostpassword', [$this, 'handleAjaxLostPassword']);
+ add_action('wp_ajax_nopriv_jvb_resetpass', [$this, 'handleAjaxResetPassword']);
- /**
- * Login footer with donate section
- */
- public function loginFooter(): void
- {
- do_action('jvbLoginFooter');
+ // Login success handling
+ add_action('wp_login', [$this, 'handleSuccessfulLogin'], 10, 2);
- }
+ // Allow other features to register handlers
+ do_action('jvbLoginManagerInit', $this);
+ }
- /**
- * Logo URL
- */
- public function logoUrl(): string
- {
- return home_url();
- }
+ /**************************************************************************
+ * SETUP & CONFIGURATION
+ **************************************************************************/
- /**
- * Logo title
- */
- public function logoTitle(): string
- {
- return get_bloginfo('name');
- }
+ /**
+ * Redirect wp-login.php to custom login page
+ */
+ public function redirectToCustomLogin(): void
+ {
+ // Don't redirect if AJAX or REST
+ if ((defined('DOING_AJAX') && DOING_AJAX) || (defined('REST_REQUEST') && REST_REQUEST)) {
+ return;
+ }
+ // Build custom login URL with all query args
+ $custom_login_page = home_url('/login');
+ $query_args = $_GET;
- /**
- * Login message - handles both login and registration
- */
- public function loginMessage(string $message): string
- {
- if ($this->isRegistrationPage()) {
- if (jvbSiteHasInvitations() && $this->fromInvite()) {
- $data = JVB()->routes('invites')->verifyInvitation(sanitize_text_field($_GET['invite']), sanitize_email($_GET['email']));
- $name = $data->name;
- $inviters = json_decode($data->inviters, true);
- $names = [];
- foreach ($inviters as $inviter) {
- $artist = jvbContentFromUser((int)$inviter['user_id']);
- $names[] = ($artist['name'] === '') ? $artist['display_name'] : $artist['name'];
- }
- $message = (count($names) > 1) ? 'are already here, and have invited you to join in!' : ' is already here, and invited you to join in!';
- return '<h2>Join the Scene, '.$name.'</h2>
- <p style="text-align:center;">'.jvbCommaList($names).$message.'</p>';
- }
- if (jvbSiteHasFavourites() && $this->fromFavourites()) {
- return '<h2>'.JVB_LOGIN['login_from_favourite_header']??'Save your Favourites'.'</h2>';
- }
- return '<h2>'.JVB_LOGIN['join_header'].'</h2>';
- } else {
- if (jvbSiteHasFavourites()) {
- $login = (!$this->fromFavourites()) ? '<h2>'.JVB_LOGIN['login_header'].'</h2>' : '<h2>'.JVB_LOGIN['login_from_favourite_header'].'</h2>';
- } else {
- $login = '<h2>'.JVB_LOGIN['login_header'].'</h2>';
+ // Remove WordPress internal args
+ unset($query_args['interim-login'], $query_args['wp-auth-check']);
+
+ if (!empty($query_args)) {
+ $custom_login_page = add_query_arg($query_args, $custom_login_page);
+ }
+
+ wp_safe_redirect($custom_login_page);
+ exit;
+ }
+ protected function getRegistrationFormFields():array
+ {
+ $form = get_option(BASE.'registration_form_fields');
+ if (!$form) {
+ $form = [];
+
+ $select = [];
+ //Basic fields, for any
+ $fields = [
+ 'name' => [
+ 'type' => 'text',
+ 'required' => true,
+ 'label' => 'Your Name',
+ 'placeholder'=> 'Mister Meseeks'
+ ],
+ 'email' => [
+ 'type' => 'email',
+ 'required' => true,
+ 'label' => 'Your Email',
+ 'placeholder'=> 'look@me.com'
+ ]
+ ];
+ if (count(JVB_USER) > 1) {
+ foreach (JVB_USER as $slug => $config) {
+ if (!array_key_exists('can_register', $config) || !$config['can_register']) {
+ continue;
+ }
+ $icon = $config['icon'] ?? '';
+ $icon = ($icon !== '') ? jvbIcon($icon) : '';
+ $select[$slug] = '<span class="label">'.$icon.$config['label'].'</span><span class="text">'.$config['register']['text']??''.'</span>';
+ if (!empty($config['register']['fields']??[])){
+ foreach ($config['register']['fields'] as $field) {
+ $field['condition'] = [
+ 'field' => 'user_select',
+ 'value' => $slug,
+ 'operator' => '=='
+ ];
+ $fields[] = $field;
+ }
+ }
+ }
+ if (!empty($select)) {
+ $select = array_merge(
+ [
+ 'subscriber' => 'Subscriber',
+ ],
+ $select
+ );
+ $form = array_merge(
+ [
+ 'user_select' => [
+ 'type' => 'radio',
+ 'label' => 'Register as',
+ 'options' => $select,
+ 'required' => true,
+ 'default' => 'subscriber'
+ ]
+ ],
+ $fields
+ );
+ }
+ }else {
+ $form = $fields;
+ }
+ update_option(BASE.'registration_form_fields', $form);
+ }
+ return $form;
+
+ }
+
+ protected function setupFields():void
+ {
+ $fields = [];
+ switch($this->action) {
+ case 'register':
+ $fields = $this->getRegistrationFormFields();
+ break;
+ case 'lostpassword':
+ $fields = [
+ 'user_email' => [
+ 'type' => 'email',
+ 'label' => __('Email Address', 'jvb'),
+ 'required' => true,
+ 'placeholder' => 'look@me.com',
+ ],
+ ];
+ break;
+ case 'rp':
+ case 'resetpass':
+ $fields = [
+ 'pass1' => [
+ 'type' => 'text',
+ 'subtype' => 'password',
+ 'label' => __('New Password', 'jvb'),
+ 'required' => true,
+ ],
+ 'pass2' => [
+ 'type' => 'text',
+ 'subtype' => 'password',
+ 'label' => __('Confirm Password', 'jvb'),
+ 'required' => true,
+ ],
+ ];
+ break;
+ case 'login':
+ $fields = [
+ 'user_email' => [
+ 'type' => 'email',
+ 'label' => __('Email Address', 'jvb'),
+ 'required' => true,
+ 'placeholder' => 'look@me.com',
+ ],
+ 'user_password' => [
+ 'type' => 'text',
+ 'subtype'=> 'password',
+ 'label' => __('Password', 'jvb'),
+ 'required' => true,
+ ],
+ 'remember_me' => [
+ 'type' => 'true_false',
+ 'label' => __('Remember Me', 'jvb'),
+ 'default' => true
+ ]
+ ];
+ break;
+ case 'postpass':
+ $fields = [
+ 'post_password' => [
+ 'type' => 'text',
+ 'subtype' => 'password',
+ 'label' => __('Password', 'jvb'),
+ 'required' => true,
+ 'hint' => 'This post is password protected. Please enter the password to view it.',
+ ],
+ ];
+ break;
+ case 'confirmaction':
+
+ break;
+
+ }
+ $this->fields = $fields;
+ }
+
+ /**
+ * Ensure login page exists
+ */
+ protected function ensureLoginPageExists(): void
+ {
+ $login_page = $this->getLoginPage();
+
+ if (!$login_page || !is_int($login_page)) {
+ $page_id = get_page_by_path('login');
+ if (!$page_id) {
+ $page_id = wp_insert_post([
+ 'post_title' => 'Login',
+ 'post_name' => 'login',
+ 'post_content' => '[jvb_login_form]',
+ 'post_status' => 'publish',
+ 'post_type' => 'page',
+ 'post_author' => 1
+ ]);
}
- return (empty($message)) ? $login : $login.$message;
- }
- }
-
- protected function fromFavourites():bool
+ if ($page_id && !is_wp_error($page_id)) {
+ if (is_object($page_id)) {
+ $page_id = (int)$page_id->ID;
+ }
+ update_option(BASE.'login_page', $page_id);
+ // Hide from menus/search
+ update_post_meta($page_id, '_wp_page_template', 'default');
+ update_post_meta($page_id, BASE . 'exclude_from_search', true);
+ }
+ }
+ }
+ public function getLoginPage():int|false
{
- return array_key_exists('type', $_GET) && $_GET['type'] === 'favourites';
+ return (int)get_option(BASE.'login_page');
}
- /**
- * Customize login error messages
- */
- public function loginErrors(string $error): string
- {
- return str_replace(
- array(
- 'The password you entered for the username',
- 'Invalid username',
- 'Unknown username',
- 'Unknown email address'
- ),
- array(
- 'Wrong password',
- 'We can\'t find that username',
- 'We can\'t find that username',
- 'We can\'t find that email'
- ),
- $error
- );
- }
+ public function isLoginPage():bool
+ {
+ return is_page($this->getLoginPage());
+ }
- /**
- * Handle successful login
- */
- public function handleSuccessfulLogin(string $username, WP_User $user): void
- {
- if (isOurPeople() && !user_can($user, 'manage_options')) {
- wp_redirect(get_home_url(null, '/dash'));
- exit;
- }
- }
+ public static function isLogin():bool
+ {
+ $self = new self;
+ return $self->isLoginPage();
+ }
- // ===== REGISTRATION-SPECIFIC METHODS =====
-
- /**
- * Set user login for registration
- */
- public function setUserLogin(string $login): string
- {
- $user_type = isset($_POST['user_type']) ? $_POST['user_type'] : '';
- if (!empty($user_type)) {
- $email_field = $user_type . '_email';
- if (isset($_POST[$email_field])) {
- $email = sanitize_email($_POST[$email_field]);
- if (is_email($email)) {
- return $email;
- }
- }
- }
- return $login;
- }
-
- /**
- * Set user email for registration
- */
- public function setUserEmail(string $email): string
- {
- $user_type = isset($_POST['user_type']) ? $_POST['user_type'] : '';
- if (!empty($user_type)) {
- $email_field = $user_type . '_email';
- if (isset($_POST[$email_field])) {
- $email = sanitize_email($_POST[$email_field]);
- if (is_email($email)) {
- return $email;
- }
- }
- }
- return $email;
- }
-
- /**
- * Modify registration form
- */
- public function modifyRegistrationForm(): void
- {
- if (!$this->isRegistrationPage()) {
- return;
- }
-
- ?>
- <script type="text/javascript">
- document.addEventListener('DOMContentLoaded', function() {
- // Hide default fields
- const defaultFields = document.getElementById('registerform').querySelectorAll('p');
- defaultFields.forEach(field => {
- if (field.querySelector('label[for="user_login"]') ||
- field.querySelector('label[for="user_email"]')) {
- field.remove();
- }
- });
-
- // Hide the default registration info text
- const regInfo = document.querySelector('.message.register');
- if (regInfo) {
- regInfo.style.display = 'none';
- }
-
- <?php
- if ($this->fromInvite()) {
- $this->handleArtistInvitation();
- }
- ?>
-
- // Move submit button to the end of the form
- const submitButton = document.getElementById('registerform').querySelector('.submit');
- if (submitButton) {
- document.getElementById('registerform').appendChild(submitButton);
- }
- });
- </script>
- <?php
- }
-
- /**
- * Handle artist invitation pre-fill
- */
- protected function handleArtistInvitation(): void
- {
- $token = sanitize_text_field($_GET['invite']);
- $email = sanitize_email($_GET['email']);
- $data = JVB()->routes('invites')->verifyInvitation($token, $email);
-
- ?>
- document.querySelector('input#artist').checked = true;
- document.querySelector('#artist_first_name').value = '<?=$data->name?>';
- document.querySelector('#artist_email').value = '<?=$email?>';
- <?php
- if ($data->to_shop) {
- ?>
- document.querySelector('#artist_shop').value = '<?=$data->shop?>';
- <?php
- }
- ?>
- let form = document.getElementById('registerform')
- let input = document.createElement('input');
- let email = input.cloneNode(true);
- input.type = 'hidden';
- input.name = 'invite_token';
- input.value = '<?= $token ?>';
- email.type = 'hidden';
- email.name = 'invite_email';
- email.value = '<?= $email?>';
- form.append(input);
- form.append(email);
- <?php
- }
-
- /**
- * Add upload support for registration
- */
- public function addUploadSupport(): void
- {
- ?>
- <script>
- document.addEventListener('DOMContentLoaded', function() {
- const form = document.getElementById('registerform');
- if (form) {
- form.enctype = 'multipart/form-data';
- }
- });
- </script>
- <?php
- }
-
- /**
- * Add registration script
- */
- public function addRegistrationScript(): void
- {
- if (!$this->isRegistrationPage()) {
- return;
- }
- ?>
- <script>
- document.addEventListener('DOMContentLoaded', function() {
-
- // Initialize user type selection
- function initUserTypeSelection() {
- const userTypeRadios = document.querySelectorAll('input[name="user_type"]');
- const fieldGroups = document.querySelectorAll('.field-group');
-
- userTypeRadios.forEach(radio => {
- radio.addEventListener('change', function() {
- fieldGroups.forEach(group => group.classList.remove('active'));
- const selectedType = this.value;
- const targetGroup = document.querySelector(`.field-group[data-type="${selectedType}"]`);
- if (targetGroup) {
- targetGroup.classList.add('active');
- }
- });
- });
-
- const checkedRadio = document.querySelector('input[name="user_type"]:checked');
- if (checkedRadio) {
- const targetGroup = document.querySelector(`.field-group[data-type="${checkedRadio.value}"]`);
- if (targetGroup) {
- targetGroup.classList.add('active');
- }
- }
- }
-
- // Initialize shop selection
- function initShopSelection() {
- let form = document.getElementById('registerform');
- form.addEventListener('change', (e) => {
- if(e.target.id === 'artist_shop' || e.target.id === 'artist_city'){
- let next = e.target.parentNode.nextElementSibling;
- let input = next.querySelector('input');
-
- if(e.target.value === 'other'){
- next.style.display = 'block';
- next.style.animation = 'fadeIn 0.3s ease';
- input.required = true;
- input.focus();
- }else{
- input.required = false;
- input.value = '';
- }
- }
- });
- }
-
- // Initialize file upload handling
- function initFileUpload() {
- const fileInput = document.getElementById('certification_file');
- const filePreview = document.querySelector('.file-preview');
- const filePreviewName = document.querySelector('.file-preview-name');
- const fileError = document.querySelector('.file-error');
- const removeButton = document.querySelector('.file-preview-remove');
-
- if (!fileInput || !filePreview || !filePreviewName || !fileError || !removeButton) {
- return;
- }
-
- const maxSize = parseInt(fileInput.dataset.maxSize || 5242880);
-
- fileInput.addEventListener('change', function(e) {
- const file = e.target.files[0];
- fileError.classList.remove('active');
-
- if (file) {
- const validTypes = ['.jpg','.jpeg','.png','.gif','.pdf'];
- const fileExtension = '.' + file.name.split('.').pop().toLowerCase();
-
- if (!validTypes.includes(fileExtension)) {
- showError('Please upload a valid file type (JPG, PNG, GIF, or PDF)');
- fileInput.value = '';
- return;
- }
-
- if (file.size > maxSize) {
- showError('File size must be less than 5MB');
- fileInput.value = '';
- return;
- }
-
- filePreviewName.textContent = file.name;
- filePreview.classList.add('active');
- } else {
- filePreview.classList.remove('active');
- }
- });
-
- removeButton.addEventListener('click', function() {
- fileInput.value = '';
- filePreview.classList.remove('active');
- fileError.classList.remove('active');
- });
-
- function showError(message) {
- fileError.textContent = message;
- fileError.classList.add('active');
- filePreview.classList.remove('active');
- }
- }
-
- // Initialize all components
- initUserTypeSelection();
- initShopSelection();
- initFileUpload();
- });
- </script>
- <?php
- }
-
- /**
- * Add registration fields
- */
- public function addRegistrationFields(): void
- {
- echo '<input type="hidden" name="user_pass" value="' . wp_generate_password() . '">';
- ?>
- <div class="registration-intro">
- <?php
- foreach (JVB_LOGIN['join_intro']??[] as $intro) {
- echo '<p>'.$intro.'</p>';
- }
- ?>
-
- <?php if ($this->fromFavourites()): ?>
- <div class="favourites-login-message">
- <ul class="benefits-list">
- <?php
- foreach (JVB_LOGIN['from_favourites_benefits']??[] as $benefit) {
- echo '<li>'.$benefit.'</li>';
- }
- ?>
- </ul>
- </div>
- <?php endif; ?>
- </div>
-
- <?php
- if (array_key_exists('choose', JVB_LOGIN)) {
- ?>
- <h3><?= JVB_LOGIN['choose']?></h3>
- <?php
+ /**************************************************************************
+ TOKEN & MESSAGE HANDLERS
+ Extensible by other classes
+ **************************************************************************/
+ public function registerTokenHandler(string $token_key, callable $handler, int $priority = 10): void
+ {
+ if (!isset($this->tokenHandlers[$priority])) {
+ $this->tokenHandlers[$priority] = [];
}
- ?>
- <?php
- if (count(JVB_USER) > 1) {
- $this->renderUserTypeSelection();
+ $this->tokenHandlers[$priority][$token_key] = $handler;
+ ksort($this->tokenHandlers);
+ }
+
+ public function registerMessageHandler(string $type, callable $handler, ?callable $condition = null): void
+ {
+ $this->messageHandlers[$type] = [
+ 'handler' => $handler,
+ 'condition' => $condition
+ ];
+ }
+
+ protected function registerDefaultHandlers(): void
+ {
+ // Invitation handler
+ if ($this->siteFeatures->has('invitations')) {
+ $this->registerTokenHandler('invite', function($token, $email, $user_id) {
+ if (isset($_POST['invite_token'])) {
+ JVB()->routes('invites')->acceptInvitation(
+ sanitize_text_field($_POST['invite_token']),
+ sanitize_email($_POST['invite_email']),
+ $user_id
+ );
+ }
+ });
+
+ $this->registerMessageHandler('invitation',
+ function() {
+ $data = JVB()->routes('invites')->verifyInvitation(
+ sanitize_text_field($_GET['invite']),
+ sanitize_email($_GET['email'])
+ );
+ $name = $data->name;
+ $inviters = json_decode($data->inviters, true);
+ $names = [];
+
+ foreach ($inviters as $inviter) {
+ $artist = jvbContentFromUser((int)$inviter['user_id']);
+ $names[] = ($artist['name'] === '') ? $artist['display_name'] : $artist['name'];
+ }
+
+ $message = (count($names) > 1)
+ ? 'are already here, and have invited you to join in!'
+ : ' is already here, and invited you to join in!';
+
+ return '<h2>Join the Scene, '.$name.'</h2>
+ <p style="text-align:center;">'.jvbCommaList($names).$message.'</p>';
+ },
+ function() {
+ return isset($_GET['invite']) && isset($_GET['email']);
+ }
+ );
+ }
+
+ // List sharing handler (Favourites)
+ if ($this->siteFeatures->has('favourites')) {
+ $this->registerTokenHandler('list_token', function($token, $email, $user_id) {
+ if (!empty($_GET['list_token']) && !empty($_GET['email'])) {
+ JVB()->routes('favourites')->acceptListInvitation(
+ sanitize_text_field($_GET['list_token']),
+ sanitize_email($_GET['email']),
+ $user_id
+ );
+ }
+ });
+
+ $this->registerMessageHandler('favourites',
+ function() {
+ return '<h2>'.(JVB_LOGIN['login_from_favourite_header'] ?? 'Save your Favourites').'</h2>';
+ },
+ function() {
+ return isset($_GET['type']) && $_GET['type'] === 'favourites';
+ }
+ );
+ }
+
+ // Referral handler - FIXED VERSION
+ $this->registerTokenHandler('referral_code', function($code, $email, $user_id) {
+ // $code is already sanitized from processTokenHandlers
+ if (session_status() === PHP_SESSION_NONE) {
+ session_start();
+ }
+ $_SESSION[BASE . 'referral_code'] = $code;
+ setcookie(
+ BASE . 'referral_code',
+ $code,
+ time() + (86400 * 30),
+ '/'
+ );
+ }, 5);
+ }
+
+ protected function initMagicLinkSupport(): void
+ {
+ if (!Features::forSite()->has('magicLink')) {
+ return;
+ }
+ $this->magicLink = new MagicLinkManager();
+ }
+
+
+
+ /*********************************************************************
+ RENDERING
+ *********************************************************************/
+ public function renderLoginPage(string $template):string
+ {
+ if (!$this->isLoginPage()) {
+ return $template;
+ }
+ $this->setup();
+ ob_start();
+ jvbInlineStyles('nav');
+ jvbInlineStyles('dash');
+ jvbInlineStyles('forms');
+ $this->customStyles();
+
+ $this->renderHeader();
+ $this->renderForms();
+ $this->renderFooter();
+
+ echo ob_get_clean();
+ return '';
+ }
+
+ protected function setup():void
+ {
+ if (array_key_exists('action', $_GET)) {
+ switch ($_GET['action']){
+ case 'lostpassword':
+ case 'retrievepassword': // Alias
+ $action = 'lostpassword';
+ break;
+ case 'rp':
+ case 'resetpass':
+ $action = 'resetpass';
+ break;
+ default:
+ $action = $_GET['action'];
+ }
} else {
- ?>
- <p>
- <label for="first_name" class="required-field">First Name</label>
- <input type="text" id="first_name" name="first_name" class="input">
- </p>
- <p>
- <label for="email" class="required-field">Email</label>
- <input type="email" id="email" name="email" class="input">
- </p>
- <?php
+ $action = 'login';
}
- if ($this->invitation_data) {
- ?>
- <script>
- document.addEventListener('DOMContentLoaded', function() {
- const artistRadio = document.getElementById('artist');
- if (artistRadio) {
- artistRadio.checked = true;
- artistRadio.dispatchEvent(new Event('change'));
- }
- const emailField = document.getElementById('artist_email');
- if (emailField) {
- emailField.value = '<?= esc_js($this->invitation_data['email']); ?>';
- emailField.readOnly = true;
- }
+ $this->action = $action;
+ $this->setupLabels();
+ $this->setupFields();
+ $this->setupTitle();
+ }
- const shopSelect = document.getElementById('artist_shop');
- if (shopSelect) {
- shopSelect.value = '<?= esc_js($this->invitation_data['shop_id']); ?>';
- shopSelect.readOnly = true;
- }
- });
- </script>
- <input type="hidden" name="invitation_token" value="<?= sanitize_text_field($_GET['invite']) ?>">
- <input type="hidden" name="invitation_email" value="<?= sanitize_email($_GET['email']) ?>">
- <?php
- }
- }
+ protected function setupTitle():void
+ {
+ switch ($this->action) {
+ case 'lostpassword':
+ $title = 'Lost Your Password?';
+ break;
+ case 'resetpass':
+ $title = 'Reset Your Password';
+ break;
+ case 'register':
+ $title = 'Create Your Account';
+ break;
+ default:
+ $title = 'Log In To Your Account';
+ }
+ $this->title = $title;
+ }
- protected function renderUserTypeSelection():void
+ protected function customStyles():void
+ {
+ $logo = get_theme_mod('custom_logo');
+ $small = $large = '';
+ if ($logo) {
+ $small = wp_get_attachment_image_src($logo, 'medium')[0];
+ $large = wp_get_attachment_image_src($logo, 'large')[0];
+
+ }
+ echo '<style>
+ .login header,
+ .login footer {
+ display: none;
+ }
+ .login main {
+ display: flex;
+ flex-direction: column;
+ gap: 2rem;
+ justify-content: center;
+ position: relative;
+ }
+ .login main::before {
+ background-size: 20vw;
+ inset: 0;
+ z-index: 0;
+ content: "";
+ background-image: url("'.$small.'");
+ background-repeat: no-repeat;
+ position: absolute;
+ background-position: 40vw 1rem;
+ }
+ .login main .login-box {
+ --gap: .75rem;
+ padding: 1rem;
+ border-radius: var(--outerRadius);
+ background-color: var(--overlay-heavy);
+ box-shadow: var(--shadow-right), var(--shadow-down);
+ margin: 15vh auto 0!important;
+ }
+ .login main .login-box,
+ .login main .navigation {
+ z-index: 5;
+ max-width: 90vw!important;
+ }
+ .login main .navigation {
+ padding: 0 1rem;
+ margin: 0 auto!important;
+ font-size: var(--small);
+ }
+ .login-box .button {
+ --height: 2.5rem;
+ width: 100%;
+ }
+ .login-box .options {
+ padding: 0 .5rem;
+ }
+ label[for="user_select-subscriber"] {
+ position: absolute;
+ left: var(--offScreen);
+ }
+
+ @media (min-width:768px) {
+ .login main .navigation,
+ .login main .login-box {
+ max-width: 60vw!important;
+ margin: 0 2rem 0 auto!important;
+ }
+ .login main .login-box {
+ padding: 2rem;
+ --gap: 2rem;
+ }
+ .login main .navigation {
+ padding: 0 var(--offHeight);
+ }
+
+ .login-box .options {
+ padding: 0 4rem;
+ }
+ .login main::before {
+ background-size: 80vw;
+ inset: -5vw;
+ background-image: url("'.$large.'");
+ opacity: .25;
+ transform: rotate(-5deg);
+ background-position: -10vw center;
+ }
+ }
+ </style>';
+ }
+
+ protected function renderForms():void
{
+ $form = $this->action.'form';
- // Get list of tattoo shops and cities
- $shops = get_terms(array(
- 'taxonomy' => 'jvb_shop',
- 'hide_empty' => true
- ));
-
- $cities = get_terms(array(
- 'taxonomy' => 'jvb_city',
- 'hide_empty' => false,
- ));
?>
- <div class="user-type-section">
+ <section class="login-box col btw">
+ <h1><?=$this->labels['title']?></h1>
+ <?= $this->labels['description'] ?>
+ <form name="<?=$form?>" method="post" data-action="jvb_<?=$this->action?>">
+ <?php wp_nonce_field('jvb_'.$this->action, '_wpnonce'); ?>
+ <input type="hidden" name="action" value="jvb_<?=$this->action?>">
+ <input type="hidden" name="redirect_to" value="<?= esc_attr($_GET['redirect_to'] ?? '') ?>">
+ <input type="hidden" name="request_id" value="<?= wp_generate_password(16, false) ?>">
+ <?php
+ $this->addHiddenTokenFields();
+
+ foreach ($this->fields as $name => $config) {
+ $this->metaForm->render($name, '', $config);
+ }
+
+ $this->maybeTurnstile();
+ ?>
+ <div class="row btw nowrap">
+ <button type="submit" class="button button-primary button-large">Log In</button>
+ <?php $this->maybeMagicLink(); ?>
+ </div>
+ </form>
+
+ <?php
+ if (is_array($this->labels['extra'])) {
+ echo '<div class="extra">';
+ foreach($this->labels['extra'] as $extra) {
+ echo '<p>'.$extra.'</p>';
+ }
+ echo '</div>';
+ } else if ($this->labels['extra']!=='') {
+ echo '<div class="extra">'.$this->labels['extra'].'</div>';
+ }
+ ?>
+
+ <div class="options row btw">
+ <?php
+ switch ($this->action) {
+ case 'login': ?>
+ <a href="<?= add_query_arg('action', 'lostpassword', get_the_permalink()) ?>">Forgot Password?</a>
+ <a href="<?= add_query_arg('action', 'register', get_the_permalink()) ?>">Create Account</a>
+ <?php
+ break;
+ case 'register': ?>
+ <a href="<?= get_the_permalink() ?>">Or Login</a>
+ <a href="<?= add_query_arg('action', 'lostpassword', get_the_permalink()) ?>">Forgot Password?</a>
+ <?php
+ break;
+ case 'lostpassword': ?>
+ <a href="<?= get_the_permalink() ?>">Login Instead</a>
+ <a href="<?= add_query_arg('action', 'register', get_the_permalink()) ?>">Create Account</a>
+ <?php
+ break;
+
+ }
+ ?>
+
+ </div>
+ </section>
+ <div class="navigation row btw">
+ <a href="<?= get_home_url() ?>">Home</a>
+ <?php
+ $privacy = get_privacy_policy_url();
+ if ($privacy !== '') { ?>
+ <a href="<?= $privacy ?>">Our Privacy Policy</a>
+ <?php } ?>
+ </div>
+ <?php
+ }
+ protected function renderHeader():void
+ {
+ ?>
+ <!DOCTYPE html>
+ <html <?php language_attributes(); ?>>
+ <head>
+ <title><?= $this->title ?> | <?= get_bloginfo('name') ?></title>
+ <meta charset="<?php bloginfo('charset'); ?>">
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
+ <link rel="preconnect" href="<?= get_home_url()?>"/>
+ <?php wp_head(); ?>
+ </head>
+ <body class="login">
+ <?php jvbAccessibility();?>
+ <header>
<?php
- $i = 1;
- $radio = '<input type="radio" id="user0" name="user_type" value="subscriber" required checked>
- <label for="user0"></label>';
- $descriptions = '';
- foreach (JVB_USER as $role => $config) {
- if (jvbCheck('can_register', $config)) {
- $radio .= '<input type="radio" id="user'.$i.'" name="user_type" value="'.$role.'" required';
- $radio .= ($role === 'enthusiast' && $this->fromFavourites()) ? 'checked' : '';
- $radio .= '><label for="user'.$i.'">'.jvbIcon($role, ['title' =>$config['label'], 'size'=>40]).'<h4>'.$config['label'].'</h4><p>';
- $radio .= $config['join_text']??'';
- $radio .= '</p></label>';
-
- $descriptions .= '<div class="user'.$i.'">'.is_array($config['join_description']) ? implode('', array_map(function ($item) { return '<p>'.$item.'</p>'; }, $config['join_description'])) : '<p>'.$config['join_description'].'</p>'.'</div>';
-
- $i++;
- }
- }
-
- echo $radio;
- echo $descriptions;
+ $checked = (is_user_logged_in() && current_user_can('prefers_dark_theme', true)) ? ' checked' : '';
+ $title = ($checked == '') ? 'Toggle Dark Mode' : 'Toggle Light Mode';
+ echo '<label title="'.$title.'" id="theme-switch" class="toggle-switch" for="theme-switcher">
+ <input class="theme-switch row" id="theme-switcher" type="checkbox"'.$checked.' data-setting="theme" data-theme role="switch" name="dark-mode"><span class="slider">'.
+ jvbIcon('light', ['title'=> 'Light Mode']).
+ jvbIcon('dark', ['title'=>'Dark Mode']).
+ '</span></label>';
?>
- <input type="radio" id="enthusiast" name="user_type" value="enthusiast" required <?= ($this->fromFavourites()) ? 'checked' : '' ?>>
- <label for="enthusiast"><?=jvbIcon('heart', ['title' =>'Enthusiast', 'size'=>40])?><h4>Enthusiast</h4><p>Start here.</p></label>
- <input type="radio" id="artist" name="user_type" value="artist" required>
- <label for="artist"><?=jvbIcon('tattoo', ['title'=> 'Artist', 'size'=> 40])?><h4>Artist</h4><p>Show your talent.</p></label>
- <input type="radio" id="partner" name="user_type" value="partner" required>
- <label for="partner"><?=jvbIcon('partner', ['title'=>'Partner', 'size' => 40])?><h4>Partner</h4><p>Support the community.</p></label>
- <p class="enthusiast">Save your favourites. Get notified.</p>
- <p class="artist">Show off your work.</p>
- <p class="partner">Support the community.</p>
- </div>
+ <p class="title">
+ <a href="<?= get_home_url(); ?>" rel="home" title="Back to Site">
+ <?php
+ $icon = (int) get_option( 'site_icon' );
+ $out = '';
+ if ($icon > 0) {
+ $url = wp_get_attachment_image_url( $icon);
+ if ($url) {
+ $out = '<img src="'.$url.'">';
+ }
+ }
+ if ($out == '') {
+ $out =jvbIcon('home');
+ }
+ ?><?= $out ?>
+ </a>
+ </p>
+ </header>
+ <main>
+ <?php
+ }
- <!-- Enthusiast Fields -->
- <div class="field-group" data-type="enthusiast">
- <h4>Welcome to the scene.</h4>
- <p>Sign up with your email to:</p>
- <ul>
- <li>Save your favourites for easy access</li>
- <li>Get notified when your favourite artists add new content</li>
- <li>Stay in the loop with local flash days and events</li>
- <li>Discover styles and artists that match your vision</li>
- </ul>
- <p>
- <label for="enthusiast_first_name" class="required-field">First Name</label>
- <input type="text" id="enthusiast_first_name" name="enthusiast_first_name" class="input">
- </p>
- <p>
- <label for="enthusiast_email" class="required-field">Email</label>
- <input type="email" id="enthusiast_email" name="enthusiast_email" class="input">
- </p>
- <div><p><b>BONUS</b>: Everything's free. And always will be. We work with partners chosen by and for the community to keep the lights on.</p></div>
- </div>
+ protected function renderFooter():void
+ {
+ ?>
- <!-- Artist Fields -->
- <div class="field-group" data-type="artist">
- <h4>Welcome to the scene!</h4>
- <p>We'll start small, with the basics. Before your profile goes live, we need to verify:</p>
- <ul>
- <li>you are who you say you are</li>
- <li>you work at the shop you listed</li>
- <li>your certification</li>
- </ul>
- <p>
- <label for="artist_first_name" class="required-field">First Name</label>
- <input type="text" id="artist_first_name" name="artist_first_name" class="input">
- </p>
- <p>
- <label for="artist_last_name" class="required-field">Last Name</label>
- <input type="text" id="artist_last_name" name="artist_last_name" class="input">
- </p>
- <p>
- <label for="artist_email" class="required-field">Email</label>
- <input type="email" id="artist_email" name="artist_email" class="input">
- </p>
- <p>
- <label for="artist_shop" class="required-field">Shop</label>
- <select id="artist_shop" name="artist_shop" class="input">
- <option value="">Select a shop</option>
- <option value="other">Add New Shop</option>
- <?php foreach ($shops as $shop) : ?>
- <option value="<?= esc_attr($shop->term_id); ?>"><?= esc_html($shop->name); ?></option>
- <?php endforeach; ?>
- </select>
- </p>
- <p id="other_shop_field" style="display: none;">
- <label for="artist_shop_other" class="required-field">Shop Name</label>
- <input type="text" id="artist_shop_other" name="artist_shop_other" class="input" placeholder="Shop name">
- </p>
+ <footer class="col">
+ <?= $this->labels['footer'] ?>
+ <?= jvbLoadingScreen() ?>
+ <?= TaxonomySelector::outputSelectorModal() ?>
+ <?php
+ do_action('jvbLoginFooter');
+ ?>
+ <p>Made with ♡ by <a href="https://jakevan.ca/">JakeVan</a></p>
+ </footer>
- <p>
- <label for="artist_type" class="required-field">Type</label>
- <input type="radio" id="type-tattoo-artist" name="artist_type" value="tattoo-artist">
- <label for="type-tattoo-artist">Tattoo Artist</label>
- <input type="radio" id="type-piercer" name="artist_type" value="piercer">
- <label for="type-piercer">Piercer</label>
- <input type="radio" id="type-other" name="artist_type" value="other">
- <label for="type-other">Other</label>
- </p>
- <p>
- <label for="artist_city" class="required-field">City</label>
- <select id="artist_city" name="artist_city" class="input">
- <option value="">Select a city</option>
- <option value="other">Add New City</option>
- <?php foreach ($cities as $city) : ?>
- <option value="<?= esc_attr($city->term_id); ?>"><?= esc_html($city->name); ?></option>
- <?php endforeach; ?>
- </select>
- </p>
- <p id="other_city_field" style="display: none;">
- <label for="artist_city_other" class="required-field">City Name</label>
- <input type="text" id="artist_city_other" name="artist_city_other" class="input" placeholder="City">
- </p>
+ <?php wp_footer(); ?>
- <div class="file-upload-container">
- <label class="file-upload-label">Certification or Training Documents</label>
- <p><i>Optional</i> — If you've been certified in bloodborne pathogen safety, or any other tattoo safety course, pass along your certificate. This just eases the verification process.</p>
- <div class="file-upload-wrapper">
- <input type="file" name="certification_file" id="certification_file" accept=".jpg,.jpeg,.png,.gif,.pdf" data-max-size="<?= $this->max_file_size; ?>">
- <p class="file-upload-text">
- <strong>Click to upload</strong> or drag and drop<br>
- JPG, PNG, GIF or PDF (max. 5MB)
- </p>
- </div>
- <div class="file-preview">
- <div class="file-preview-content">
- <span class="file-preview-name"></span>
- <button type="button" class="file-preview-remove">Remove</button>
- </div>
- </div>
- <div class="file-error"></div>
- </div>
- <p>Once you click register:</p>
- <ul>
- <li>We'll start looking into your information (usually within 24-48 hours)</li>
- <li>You'll get a password reset email</li>
- <li>Upon setting your password, you can start filling in your profile - but it won't go live until we've verified your information.</li>
- </ul>
- <p>If you have any questions or concerns - or anything you'd like to follow up on - email us at get@edmonton.ink or message us on <a target="_blank" href="https://www.instagram.com/edmonton.ink/" title="@edmonton.ink on Instagram">Instagram</a>.</p>
- <div><p><b>BONUS</b>: Everything's free. And always will be. We work with partners chosen by and for the community to keep the lights on.</p></div>
- </div>
+ </body>
+ </html>
- <!-- Partner Fields -->
- <div class="field-group" data-type="partner">
- <h4>Howdy, partner!</h4>
- <p>We appreciate your interest!</p>
- <p>edmonton.ink is a great place to showcase what you do, whether you:</p>
- <ul>
- <li>provide goods or services that tattoo artists could use</li>
- <li>provide goods or services that are tattoo adjacent (such as art, merch, etc)</li>
- <li>provide goods or services that folks who love tattoos could also love</li>
- </ul>
+ <?php
+ }
- <p>We'll start with some basics, then we'll reach out to follow up (usually within 24-48 hours).</p>
- <p>
- <label for="partner_name" class="required-field">Contact Name</label>
- <input type="text" id="partner_name" name="partner_name" class="input">
- </p>
- <p>
- <label for="partner_email" class="required-field">Email</label>
- <input type="email" id="partner_email" name="partner_email" class="input">
- </p>
- <p>
- <label for="partner_business" class="required-field">Business Name</label>
- <input type="text" id="partner_business" name="partner_business" class="input">
- </p>
- <p>
- <label for="partner_website">Business Website</label>
- <input type="url" id="partner_website" name="partner_website" class="input">
- </p>
- <p>
- <label for="partner_description">Why would you be a good fit?</label>
- <textarea id="partner_description" name="partner_description" rows="8"></textarea>
- </p>
- <p><i>Note:</i> — you must have good standing in the tattoo community to stay a partner of edmonton.ink.</p>
- <p>If we receive multiple requests to terminate a partnership with you from member artists, we reserve the right to cancel your listings.</p>
- </div>
+ protected function addHiddenTokenFields(): void
+ {
+ foreach ($this->tokenHandlers as $priority => $handlers) {
+ foreach ($handlers as $token_key => $handler) {
+ if (isset($_GET[$token_key])) {
+ $value = sanitize_text_field($_GET[$token_key]);
+ echo '<input type="hidden" name="' . esc_attr($token_key) . '" value="' . esc_attr($value) . '">';
+ }
+ }
+ }
+
+ if (isset($_GET['email'])) {
+ echo '<input type="hidden" name="token_email" value="' . esc_attr(sanitize_email($_GET['email'])) . '">';
+ }
+ }
+
+ /*************************************************************************
+ AJAX HANDLERS
+ *************************************************************************/
+ public function handleAjaxLogin(): void
+ {
+ check_ajax_referer('jvb_login', '_wpnonce');
+
+ // Rate limiting
+ if (!$this->checkAjaxRateLimit('login')) {
+ wp_send_json_error([
+ 'message' => 'Too many attempts. Please wait a moment.',
+ 'code' => 'rate_limit'
+ ], 429);
+ }
+
+ // Duplicate submission check
+ if (!$this->checkRequestId()) {
+ wp_send_json_error([
+ 'message' => 'Duplicate request detected',
+ 'code' => 'duplicate_request'
+ ], 409);
+ }
+
+ $email = sanitize_email($_POST['user_email'] ?? '');
+ $password = $_POST['user_password'] ?? '';
+ $remember = !empty($_POST['remember_me']);
+
+ if (empty($email) || empty($password)) {
+ wp_send_json_error([
+ 'message' => 'Please fill in all fields',
+ 'field' => empty($email) ? 'user_email' : 'user_password',
+ 'code' => 'missing_fields'
+ ]);
+ }
+
+ // Verify Turnstile if enabled
+ if (!$this->verifyTurnstile()) {
+ wp_send_json_error([
+ 'message' => 'Security verification failed',
+ 'code' => 'turnstile_failed'
+ ]);
+ }
+
+ $user = get_user_by('email', $email);
+ if (!$user) {
+ wp_send_json_error([
+ 'message' => 'Unknown email address',
+ 'field' => 'user_email',
+ 'code' => 'invalid_email'
+ ]);
+ }
+
+ $user = wp_authenticate($user->user_login, $password);
+
+ if (is_wp_error($user)) {
+ wp_send_json_error([
+ 'message' => $user->get_error_message(),
+ 'field' => 'user_password',
+ 'code' => $user->get_error_code()
+ ]);
+ }
+
+ wp_clear_auth_cookie();
+ wp_set_current_user($user->ID);
+ wp_set_auth_cookie($user->ID, $remember);
+
+ do_action('wp_login', $user->user_login, $user);
+
+ $redirect = $_POST['redirect_to'] ?? home_url('/dash');
+ wp_send_json_success(['redirect' => $redirect]);
+ }
+
+ public function handleAjaxRegister(): void
+ {
+ check_ajax_referer('jvb_register', '_wpnonce');
+
+ // Rate limiting
+ if (!$this->checkAjaxRateLimit('register')) {
+ wp_send_json_error([
+ 'message' => 'Too many attempts. Please wait a moment.',
+ 'code' => 'rate_limit'
+ ], 429);
+ }
+
+ // Duplicate submission check
+ if (!$this->checkRequestId()) {
+ wp_send_json_error([
+ 'message' => 'Duplicate request detected',
+ 'code' => 'duplicate_request'
+ ], 409);
+ }
+
+ // Verify Turnstile
+ if (!$this->verifyTurnstile()) {
+ wp_send_json_error([
+ 'message' => 'Security verification failed',
+ 'code' => 'turnstile_failed'
+ ]);
+ }
+
+ $name = sanitize_text_field($_POST['name'] ?? '');
+ $email = sanitize_email($_POST['email'] ?? '');
+ $user_type = sanitize_text_field($_POST['user_select'] ?? 'subscriber');
+
+ // Spam prevention - if subscriber is selected and there are other options
+ if ($user_type === 'subscriber' && count(JVB_USER) > 0) {
+ $registerable = array_filter(JVB_USER, fn($config) => $config['can_register'] ?? false);
+ if (!empty($registerable)) {
+ wp_send_json_error([
+ 'message' => 'Please select a valid account type',
+ 'field' => 'user_select',
+ 'code' => 'invalid_user_type'
+ ]);
+ }
+ }
+
+ // Validate fields
+ if (empty($name)) {
+ wp_send_json_error([
+ 'message' => 'Name is required',
+ 'field' => 'name',
+ 'code' => 'missing_name'
+ ]);
+ }
+
+ if (empty($email)) {
+ wp_send_json_error([
+ 'message' => 'Email is required',
+ 'field' => 'email',
+ 'code' => 'missing_email'
+ ]);
+ }
+
+ // Check if role can register
+ if ($user_type !== 'subscriber') {
+ if (!isset(JVB_USER[$user_type]) || empty(JVB_USER[$user_type]['can_register'])) {
+ wp_send_json_error([
+ 'message' => 'Invalid account type',
+ 'field' => 'user_select',
+ 'code' => 'invalid_user_type'
+ ]);
+ }
+ }
+
+ // Check if email exists
+ if (email_exists($email)) {
+ wp_send_json_error([
+ 'message' => 'Email already registered',
+ 'field' => 'email',
+ 'code' => 'duplicate_email'
+ ]);
+ }
+
+ // Create user
+ $user_id = wp_create_user($email, wp_generate_password(), $email);
+
+ if (is_wp_error($user_id)) {
+ wp_send_json_error([
+ 'message' => $user_id->get_error_message(),
+ 'code' => 'user_creation_failed'
+ ]);
+ }
+
+ // Update user data
+ wp_update_user([
+ 'ID' => $user_id,
+ 'display_name' => $name,
+ 'first_name' => $name
+ ]);
+
+ // Set role
+ $user = new WP_User($user_id);
+ if ($user_type === 'subscriber') {
+ $user->set_role('subscriber');
+ } else {
+ $role = JVB_USER[$user_type]['role'] ?? 'subscriber';
+ $user->set_role($role);
+
+ // Check if needs approval
+ if (Features::forMembership()->has('memberVerified') &&
+ in_array($role, JVB_MEMBERSHIP['memberVerified'] ?? [])) {
+ $user->add_cap('skip_moderation', false);
+ update_user_meta($user_id, BASE . 'pending_approval', true);
+ }
+ }
+
+ // Save additional fields
+ update_user_meta($user_id, BASE . 'user_type', $user_type);
+
+ // Process additional fields from form
+ foreach ($_POST as $key => $value) {
+ if (in_array($key, ['name', 'email', 'action', '_wpnonce', 'request_id', 'user_select'])) {
+ continue;
+ }
+ update_user_meta($user_id, BASE . $key, sanitize_text_field($value));
+ }
+
+ // Handle token handlers
+ $this->processTokenHandlers($user_id, $email);
+
+ // Send welcome email with password setup link
+ $this->sendWelcomeEmail($user_id);
+
+ // Trigger registration action for other systems
+ do_action('jvbAfterUserRegistration', $user_id, $user_type, $_POST);
+
+ wp_send_json_success([
+ 'message' => 'Registration successful! Check your email.',
+ 'title' => $this->labels['successTitle'] ?? 'Success!',
+ 'description' => $this->labels['successDescription'] ?? 'Check your email for next steps',
+ 'user_id' => $user_id // Important for file upload dependencies!
+ ]);
+ }
+
+ public function handleAjaxLostPassword(): void
+ {
+ check_ajax_referer('jvb_lostpassword', '_wpnonce');
+
+ // Rate limiting
+ if (!$this->checkAjaxRateLimit('lostpassword')) {
+ wp_send_json_error([
+ 'message' => 'Too many attempts. Please wait a moment.',
+ 'code' => 'rate_limit'
+ ], 429);
+ }
+
+ $email = sanitize_email($_POST['user_email'] ?? '');
+
+ if (empty($email)) {
+ wp_send_json_error([
+ 'message' => 'Email required',
+ 'field' => 'user_email',
+ 'code' => 'missing_email'
+ ]);
+ }
+
+ // Verify Turnstile
+ if (!$this->verifyTurnstile()) {
+ wp_send_json_error([
+ 'message' => 'Security verification failed',
+ 'code' => 'turnstile_failed'
+ ]);
+ }
+
+ // Use WordPress's built-in function
+ $result = retrieve_password($email);
+
+ if (is_wp_error($result)) {
+ wp_send_json_error([
+ 'message' => $result->get_error_message(),
+ 'code' => $result->get_error_code()
+ ]);
+ }
+
+ wp_send_json_success(['message' => 'Check your email for reset link']);
+ }
+
+ public function handleAjaxResetPassword(): void
+ {
+ check_ajax_referer('jvb_resetpass', '_wpnonce');
+
+ // Rate limiting
+ if (!$this->checkAjaxRateLimit('resetpass')) {
+ wp_send_json_error([
+ 'message' => 'Too many attempts. Please wait a moment.',
+ 'code' => 'rate_limit'
+ ], 429);
+ }
+
+ $key = sanitize_text_field($_POST['key'] ?? $_GET['key'] ?? '');
+ $login = sanitize_text_field($_POST['login'] ?? $_GET['login'] ?? '');
+ $pass1 = $_POST['pass1'] ?? '';
+ $pass2 = $_POST['pass2'] ?? '';
+
+ if (empty($key) || empty($login)) {
+ wp_send_json_error([
+ 'message' => 'Invalid reset link',
+ 'code' => 'invalid_key'
+ ]);
+ }
+
+ if (empty($pass1) || empty($pass2)) {
+ wp_send_json_error([
+ 'message' => 'Please enter a password',
+ 'field' => empty($pass1) ? 'pass1' : 'pass2',
+ 'code' => 'missing_password'
+ ]);
+ }
+
+ if ($pass1 !== $pass2) {
+ wp_send_json_error([
+ 'message' => 'Passwords do not match',
+ 'field' => 'pass2',
+ 'code' => 'password_mismatch'
+ ]);
+ }
+
+ // Verify reset key
+ $user = check_password_reset_key($key, $login);
+
+ if (is_wp_error($user)) {
+ wp_send_json_error([
+ 'message' => 'Invalid or expired reset link',
+ 'code' => 'invalid_key'
+ ]);
+ }
+
+ // Reset password
+ reset_password($user, $pass1);
+
+ wp_send_json_success([
+ 'message' => 'Password reset successfully',
+ 'redirect' => home_url('/login')
+ ]);
+ }
+
+
+ /**********************************************************************
+ TOKEN PROCESSING
+ **********************************************************************/
+ protected function processTokenHandlers(int $user_id, string $email): void
+ {
+ foreach ($this->tokenHandlers as $priority => $handlers) {
+ foreach ($handlers as $token_key => $handler) {
+ if (isset($_POST[$token_key]) || isset($_GET[$token_key])) {
+ $token_value = $_POST[$token_key] ?? $_GET[$token_key];
+ call_user_func($handler, sanitize_text_field($token_value), $email, $user_id);
+ }
+ }
+ }
+ }
+
+
+ /***********************************************************************
+ EMAIL SENDING
+ ***********************************************************************/
+ protected function sendWelcomeEmail(int $user_id): void
+ {
+ $user = get_userdata($user_id);
+ if (!$user) {
+ return;
+ }
+
+ // Generate password reset key
+ $key = get_password_reset_key($user);
+ if (is_wp_error($key)) {
+ error_log('Failed to generate password reset key: ' . $key->get_error_message());
+ return;
+ }
+
+ $reset_url = add_query_arg([
+ 'action' => 'rp',
+ 'key' => $key,
+ 'login' => rawurlencode($user->user_login)
+ ], home_url('/login'));
+
+ $subject = $this->labels['email'] ?? 'Welcome to ' . get_bloginfo('name');
+
+ $message = '<h2>Welcome, ' . esc_html($user->display_name) . '!</h2>';
+ $message .= '<p>Your account has been created. Click the button below to set your password and get started:</p>';
+ $message .= jvbMailButton($reset_url, 'Set Your Password');
+ $message .= '<p>This link expires in 24 hours.</p>';
+
+ $this->emailManager->sendEmail($user->user_email, $subject, $message);
+ }
+
+ /*************************************************************************
+ * SECURITY & VALIDATION
+ *************************************************************************/
+ protected function checkAjaxRateLimit(string $action): bool
+ {
+ return $this->rateLimiter->checkLimit($action);
+ }
+ protected function checkRequestId(): bool
+ {
+ $request_id = $_POST['request_id'] ?? '';
+ if (empty($request_id)) {
+ return true; // No request_id provided, allow (for backward compat)
+ }
+
+ $cache_key = 'request_' . $request_id;
+ if (get_transient($cache_key)) {
+ return false; // Duplicate request
+ }
+
+ // Store request ID for 1 minute to prevent duplicates
+ set_transient($cache_key, true, 60);
+ return true;
+ }
+
+ protected function maybeTurnstile(): void
+ {
+ if (!Features::hasIntegration('cloudflare')) {
+ return;
+ }
+ JVB()->connect('cloudflare')->renderTurnstile();
+ }
+
+ protected function maybeTurnstileScripts(): void
+ {
+ if (!Features::hasIntegration('cloudflare')) {
+ return;
+ }
+ JVB()->connect('cloudflare')->enqueueTurnstileScripts();
+ }
+
+ protected function verifyTurnstile(): bool
+ {
+ if (!Features::hasIntegration('cloudflare')) {
+ return true; // Not enabled, pass verification
+ }
+
+ $token = $_POST['cf-turnstile-response'] ?? '';
+ if (empty($token)) {
+ return false;
+ }
+
+ return JVB()->connect('cloudflare')->verifyTurnstile($token);
+ }
+
+ /************************************************************************
+ LABELS & UI
+ ************************************************************************/
+ protected function setupLabels(): void
+ {
+ $default = $this->getDefaultLabels();
+ $this->labels = apply_filters('jvbLoginLabels', $default, $_GET);
+
+ foreach (['description', 'footer', 'extra'] as $location) {
+ $text = (!is_array($this->labels[$location])) ? [$this->labels[$location]] : $this->labels[$location];
+ if (!empty($text)) {
+ $this->labels[$location] = '<div class="'.$location.'">';
+ foreach ($text as $d) {
+ $this->labels[$location] .= '<p>'.$d.'</p>';
+ }
+ $this->labels[$location] .= '</div>';
+ }
+ }
+ }
+
+ protected function getDefaultLabels(): array
+ {
+ switch ($this->action) {
+ case 'register':
+ return [
+ 'title' => JVB_LOGIN['register']['title'] ?? 'Create Your Account',
+ 'description' => JVB_LOGIN['register']['description'] ?? [],
+ 'extra' => JVB_LOGIN['register']['extra'] ?? [],
+ 'footer' => JVB_LOGIN['register']['footer'] ?? '',
+ 'email' => JVB_LOGIN['register']['email']['subject'] ?? '['.get_bloginfo('name').'] Finish Creating Your Account',
+ 'submit' => JVB_LOGIN['register']['submit'] ?? 'Create Account',
+ 'successTitle' => JVB_LOGIN['register']['success']['title'] ?? 'Success!',
+ 'successDescription' => JVB_LOGIN['register']['success']['description'] ?? ['See your email for next steps','(Check your spam folder if you cannot find it after a couple minutes.)'],
+ ];
+ case 'lostpassword':
+ return [
+ 'title' => JVB_LOGIN['forgot_password']['title'] ?? 'Reset Password',
+ 'description' => JVB_LOGIN['forgot_password']['description'] ?? [],
+ 'extra' => JVB_LOGIN['forgot_password']['extra'] ?? [],
+ 'footer' => JVB_LOGIN['forgot_password']['footer'] ?? '',
+ 'submit' => JVB_LOGIN['forgot_password']['submit'] ?? 'Send Reset Link',
+ 'successTitle' => JVB_LOGIN['forgot_password']['success']['title'] ?? 'Success!',
+ 'successDescription' => JVB_LOGIN['forgot_password']['success']['description'] ?? ['Check your email for reset instructions'],
+ ];
+ case 'resetpass':
+ return [
+ 'title' => JVB_LOGIN['reset_pass']['title'] ?? 'Reset Your Password',
+ 'description' => JVB_LOGIN['reset_pass']['description'] ?? [],
+ 'extra' => JVB_LOGIN['reset_pass']['extra'] ?? [],
+ 'footer' => JVB_LOGIN['reset_pass']['footer'] ?? '',
+ 'submit' => JVB_LOGIN['reset_pass']['submit'] ?? 'Reset Password',
+ ];
+ case 'login':
+ default:
+ return [
+ 'title' => JVB_LOGIN['login']['title'] ?? 'Sign in',
+ 'description' => JVB_LOGIN['login']['description'] ?? [],
+ 'extra' => JVB_LOGIN['login']['extra'] ?? [],
+ 'footer' => JVB_LOGIN['login']['footer'] ?? '',
+ 'submit' => JVB_LOGIN['login']['submit'] ?? 'Sign In',
+ ];
+ }
+ }
+
+ protected function maybeMagicLink(): void
+ {
+ if (!$this->magicLink || !in_array($this->action, ['login', 'lostpassword'])) {
+ return;
+ }
+ ?>
+ <button type="button" id="magic-link-btn" class="button button-secondary button-large">
+ <?= jvbIcon('email', ['size' => 20]); ?>
+ Get Login Link
+ </button>
+ <script type="text/javascript">
+ document.getElementById('magic-link-btn')?.addEventListener('click', function(e) {
+ e.preventDefault();
+ const email = document.querySelector('input[name="user_email"]')?.value;
+ if (!email) {
+ alert('Please enter your email address first');
+ return;
+ }
+
+ fetch('<?= rest_url('jvb/v1/magic-link'); ?>', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'X-WP-Nonce': '<?= wp_create_nonce('wp_rest') ?>'
+ },
+ body: JSON.stringify({ email: email, type: 'login' })
+ })
+ .then(r => r.json())
+ .then(data => {
+ alert(data.success ? 'Check your email!' : (data.message || 'Failed to send link'));
+ });
+ });
+ </script>
<?php
}
- /**
- * Registration errors filter
- */
- public function registrationErrorsFilter(WP_Error $errors, string $sanitized_user_login, string $user_email): WP_Error
- {
- error_log('Registration Data: '.print_r($_POST, true));
- $user_type = isset($_POST['user_type']) ? $_POST['user_type'] : '';
- if (empty($user_type)) {
- $errors->add('user_type_error', 'Please select your user type.');
- return $errors;
- }
+ /************************************************************************
+ SCRIPTS
+ ************************************************************************/
+ public function enqueueScripts(): void
+ {
+ if (!$this->isLoginPage()) {
+ return;
+ }
- // Get email based on user type
- $email_field = $user_type . '_email';
- $email = isset($_POST[$email_field]) ? sanitize_email($_POST[$email_field]) : '';
+ $this->maybeTurnstileScripts();
+ wp_enqueue_script('jvb-form');
- // Remove WordPress's default username error
- $errors = new WP_Error();
+ $script = "
+ document.addEventListener('DOMContentLoaded', () => {
+ const form = document.querySelector('.login form');
+ if (form && window.jvbForm) {
+ let controller = new window.jvbForm();
+ controller.registerForm(form, {
+ autosave: false,
+ endpoint: false
+ });
+ } else if (form && !window.jvbForm) {
+ console.error('jvbForm not loaded');
+ }
+ });";
- // If this is an invited artist, validate the invitation
- $invite = (array_key_exists('invite_token', $_POST)) ? sanitize_text_field($_POST['invite_token']) : false;
- if ($invite && array_key_exists('role', $_POST)) {
- $handler = JVB()->routes('invites');
- $invitation = $handler->verifyInvitation($invite, sanitize_email($_POST['invite_email']), sanitize_text_field($_POST['role']));
+ wp_add_inline_script('jvb-form', $script);
+ }
- if (!$invitation) {
- $errors->add('invalid_invitation', 'Invalid invitation token.');
- } elseif (strtotime($invitation->expires_at) < current_time('timestamp')) {
- $errors->add('expired_invitation', 'This invitation has expired.');
- }
- }
+ /*************************************************************************
+ SUCCESS HANDLING
+ *************************************************************************/
+ public function handleSuccessfulLogin(string $username, WP_User $user): void
+ {
+ if (isOurPeople() && !user_can($user, 'manage_options')) {
+ wp_redirect(get_home_url(null, '/dash'));
+ exit;
+ }
+ }
- // Validate email first
- if (empty($email)) {
- $errors->add('email_error', 'Email is required.');
- } elseif (!is_email($email)) {
- $errors->add('email_error', 'Please enter a valid email address.');
- } elseif (email_exists($email)) {
- $errors->add('email_error', 'This email is already registered.');
- }
- switch ($user_type) {
- case 'enthusiast':
- if (empty($_POST['enthusiast_first_name'])) {
- $errors->add('first_name_error', 'First name is required.');
- }
- break;
+ /**
+ * Handle login errors
+ */
+ protected function handleLoginError(WP_Error $error): void
+ {
+ $login_url = wp_login_url();
+ $login_url = add_query_arg('login_error', urlencode($error->get_error_code()), $login_url);
- case 'artist':
- $required_fields = array(
- 'artist_first_name' => 'First name',
- 'artist_last_name' => 'Last name',
- 'artist_shop' => 'Shop',
- 'artist_city' => 'City',
- 'artist_type' => 'Type',
- );
- foreach ($required_fields as $field => $label) {
- if (empty($_POST[$field])) {
- $errors->add($field . '_error', $label . ' is required.');
- }
- }
- break;
+ if (isset($_REQUEST['redirect_to'])) {
+ $login_url = add_query_arg('redirect_to', urlencode($_REQUEST['redirect_to']), $login_url);
+ }
- case 'partner':
- $required_fields = array(
- 'partner_name' => 'Contact name',
- 'partner_business' => 'Business name'
- );
-
- foreach ($required_fields as $field => $label) {
- if (empty($_POST[$field])) {
- $errors->add($field . '_error', $label . ' is required.');
- }
- }
- break;
- }
-
- if (isset($_POST['user_type']) && $_POST['user_type'] === 'artist' && !empty($_FILES['certification_file']['name'])) {
- $file = $_FILES['certification_file'];
-
- // Validate file type
- if (!in_array($file['type'], $this->allowed_file_types)) {
- $errors->add('file_type_error', 'Please upload a valid file type (JPG, PNG, GIF, or PDF)');
- }
-
- // Validate file size
- if ($file['size'] > $this->max_file_size) {
- $errors->add('file_size_error', 'File size must be less than 5MB');
- }
- }
-
- return $errors;
- }
-
- /**
- * Save registration fields
- */
- public function saveRegistrationFields(int $user_id, array $userdata): void
- {
- $user_type = isset($_POST['user_type']) ? $_POST['user_type'] : false;
- if (!$user_type) {
- return;
- }
-
- // Set user role based on type
- $user = new WP_User($user_id);
- $caps = JVB()->roles();
- $email = false;
- $upload_dir = wp_upload_dir();
- $base_dir = $upload_dir['basedir'];
-
- switch ($user_type) {
- case 'artist':
- $user->set_role('jvb_artist');
- $user->remove_role('subscriber');
-
- $email = sanitize_email($_POST['artist_email']);
- $first = sanitize_text_field($_POST['artist_first_name']);
- $last = sanitize_text_field($_POST['artist_last_name']);
- $display_name = $first . ' ' . $last;
-
- // Save artist fields
- $temp = wp_update_user([
- 'ID' => $user_id,
- 'first_name' => $first,
- 'last_name' => $last,
- 'display_name' => $display_name
- ]);
- $user = get_userdata($temp);
-
- $link = $caps->addUserLink($user, 'artist');
- $meta = new MetaManager($link, 'post');
- $meta->setAll([
- 'first_name' => $first,
- 'email' => $email
- ]);
-
- // If this was an invited artist, handle the invitation
- if (array_key_exists('invite_token', $_POST)) {
- $handler = JVB()->routes('invites');
- $handler->acceptInvitation(sanitize_text_field($_POST['invite_token']), sanitize_email($_POST['invite_email']), $user->ID);
- }
-
- if (absint($_POST['artist_shop']) > 0) {
- JVB()->routes('shop')->requestShopAdmission($user_id, absint($_POST['artist_shop']));
- }
- if (absint($_POST['artist_city']) > 0) {
- wp_set_post_terms($link, (int)absint($_POST['artist_city']), BASE.'city');
- }
-
- //Create approval request and notify verified users
- JVB()->routes('approvals')->createArtistApprovalRequest($user_id);
-
- //Make base directories
- $artist_dir = $base_dir . '/artists/' . $user_id;
- wp_mkdir_p($artist_dir);
- wp_mkdir_p($artist_dir . '/artwork');
- wp_mkdir_p($artist_dir . '/events');
- wp_mkdir_p($artist_dir . '/profile');
- wp_mkdir_p($artist_dir . '/temp');
-
- switch ($_POST['artist_type']) {
- case 'tattoo-artist':
- $caps->setUserAs($user, 'tattoo-artist');
- $term = get_term_by('name', 'Tattoo Artists', BASE.'type');
- if ($term && !is_wp_error($term)) {
- wp_set_post_terms($link, $term->term_id, BASE.'type');
- }
- wp_mkdir_p($artist_dir . '/tattoos');
- break;
- case 'piercer':
- $caps->setUserAs($user, 'piercer');
- $term = get_term_by('name', 'Piercers', BASE.'type');
- if ($term && !is_wp_error($term)) {
- wp_set_post_terms($link, $term->term_id, BASE.'type');
- }
- wp_mkdir_p($artist_dir . '/piercings');
- break;
- }
- break;
-
- case 'partner':
- $user->set_role('jvb_partner');
- $user->remove_role('subscriber');
- $name = sanitize_text_field($_POST['partner_name']);
- $email = sanitize_email($_POST['partner_email']);
-
- $caps->setUserAs($user, 'partner');
- $link = $caps->addUserLink($user, 'partner');
-
- // Save partner fields
- update_user_meta($user_id, 'contact_name', sanitize_text_field($_POST['partner_name']));
- update_user_meta($user_id, 'business_name', sanitize_text_field($_POST['partner_business']));
- update_user_meta($user_id, 'business_website', esc_url_raw($_POST['partner_website']));
-
- // Create partner base directory
- $partner_dir = $base_dir . '/partners/' . $user_id;
- wp_mkdir_p($partner_dir);
- wp_mkdir_p($partner_dir . '/offers');
- wp_mkdir_p($partner_dir . '/events');
- wp_mkdir_p($partner_dir . '/profile');
- wp_mkdir_p($partner_dir . '/temp');
- break;
-
- case 'enthusiast':
- $user->set_role('jvb_enthusiast');
- $user->remove_role('subscriber');
- $caps->setUserAs($user, 'enthusiast');
- $name = sanitize_text_field($_POST['enthusiast_first_name']);
- $email = sanitize_email($_POST['enthusiast_email']);
-
- // Save enthusiast fields
- $temp = wp_update_user([
- 'ID' => $user_id,
- 'first_name' => $name,
- 'user_email' => $email,
- ]);
- break;
- }
-
- // Handle file upload for artists
- if (isset($_POST['user_type']) && $_POST['user_type'] === 'artist' && !empty($_FILES['certification_file']['name'])) {
- $file = $_FILES['certification_file'];
-
- // Setup upload directory
- $upload_dir = wp_upload_dir();
- $user_directory = 'artist-certifications/' . $user_id;
- $target_dir = $upload_dir['basedir'] . '/' . $user_directory;
-
- // Create directory if it doesn't exist
- wp_mkdir_p($target_dir);
-
- // Generate unique filename
- $file_extension = pathinfo($file['name'], PATHINFO_EXTENSION);
- $filename = 'certification-' . time() . '.' . $file_extension;
- $target_file = $target_dir . '/' . $filename;
-
- // Move uploaded file
- if (move_uploaded_file($file['tmp_name'], $target_file)) {
- // Save file information in user meta
- update_user_meta($user_id, 'certification_file', array(
- 'url' => $upload_dir['baseurl'] . '/' . $user_directory . '/' . $filename,
- 'file' => $target_file,
- 'type' => $file['type'],
- 'original_name' => $file['name']
- ));
- }
- }
-
- // Handle list invitation acceptance
- if (isset($_GET['list_token']) && !empty($_GET['list_token']) && isset($_GET['email'])) {
- $token = sanitize_text_field($_GET['list_token']);
- $email = sanitize_email($_GET['email']);
-
- if ($email) {
- JVB()->routes('favourites')->acceptListInvitation($token, $email, $user_id);
- }
- }
- }
-
- /**
- * Registration success message
- */
- public function registrationSuccessMessage(WP_Error $errors, string $redirect_to): WP_Error
- {
- if (isset($errors->errors['registered']) && isset($_POST['invitation_token'])) {
- // Custom message for invited artists
- $message = "WELCOME ABOARD!<br><br>" .
- "Password setup is in your inbox. <br>" .
- "Since you were invited by a shop, you can skip the verification wait and start building your profile right away! ♡";
-
- unset($errors->errors['registered']);
- $errors->add('registered', $message, 'message');
- }
-
- if (isset($errors->errors['registered'])) {
- $user_type = isset($_POST['user_type']) ? $_POST['user_type'] : 'user';
-
- switch ($user_type) {
- case 'enthusiast':
- $message = "YOU'RE IN!<br><br>Check your inbox - we've sent password setup details.<br>Get ready to build your dream artist collection! ♡";
- break;
-
- case 'artist':
- $message = "HELL YEAH!<br><br>Password setup is in your inbox. <br>While we verify your info (24-48hrs), you can start building your profile. <br>Just remember - it stays underground until you're cleared. ♡";
- break;
-
- case 'partner':
- $message = "ROCK ON!<br><br>Check your inbox - we've sent password setup details.<br>We'll check out your pitch in the next 24-48hrs. <br><br>Meanwhile, you can start prepping your presence - but you won't hit the streets until we give the nod. ♡";
- break;
-
- default:
- $message = "YOU'RE ON THE LIST!<br><br>Check your inbox for the next steps. ♡";
- }
-
- // Replace the default message
- unset($errors->errors['registered']);
- $errors->add('registered', $message, 'message');
- }
-
- return $errors;
- }
-
- /**
- * Check if registration is from invite
- */
- protected function fromInvite(): bool
- {
- return isset($_GET['invite']) && isset($_GET['email']);
- }
-
- /**
- * Custom register message
- */
- public function customRegisterMessage(string $message): string
- {
- return "Join Edmonton's tattoo community";
- }
+ wp_safe_redirect($login_url);
+ exit;
+ }
}
-// Initialize the consolidated auth manager
+// Initialize the login manager
new LoginManager();
diff --git a/inc/managers/LoginManagerOld.php b/inc/managers/LoginManagerOld.php
new file mode 100644
index 0000000..066e744
--- /dev/null
+++ b/inc/managers/LoginManagerOld.php
@@ -0,0 +1,1061 @@
+<?php
+namespace JVBase\managers;
+
+use JVBase\meta\MetaManager;
+use JVBase\utility\Features;
+use WP_Error;
+use WP_User;
+
+if (!defined('ABSPATH')) {
+ exit; // Exit if accessed directly
+}
+
+class LoginManagerOld
+{
+ protected ?MagicLinkManager $magicLink = null;
+ private array|null $invitation_data = null;
+ protected array $inviteData = [];
+ private array $allowed_file_types = [
+ 'image/jpeg',
+ 'image/png',
+ 'image/gif',
+ 'application/pdf'
+ ];
+ private int $max_file_size = 5242880; // 5MB in bytes
+
+ public function __construct()
+ {
+ // Common login page customization
+ add_action('login_enqueue_scripts', [$this, 'loginStyles']);
+ add_action('login_header', [$this, 'loginHeader'], 0);
+ add_action('login_footer', [$this, 'loginFooter']);
+
+ // Login page filters
+ add_filter('login_headerurl', [$this, 'logoUrl']);
+ add_filter('login_headertext', [$this, 'logoTitle']);
+ add_filter('login_message', [$this, 'loginMessage']);
+ add_filter('login_errors', [$this, 'loginErrors']);
+
+ // Login success handling
+ add_action('wp_login', [$this, 'handleSuccessfulLogin'], 10, 2);
+
+ if (Features::forSite()->has('magicLink')) {
+ $this->magicLink = new MagicLinkManager();
+ }
+
+ // Registration-specific hooks
+ if ($this->isRegistrationPage()) {
+ $this->initRegistrationHooks();
+ }
+ }
+
+ /**
+ * Check if we're on the registration page
+ */
+ private function isRegistrationPage(): bool
+ {
+ return isset($_GET['action']) && $_GET['action'] === 'register';
+ }
+
+ /**
+ * Initialize registration-specific hooks
+ */
+ private function initRegistrationHooks(): void
+ {
+ add_action('register_form', [$this, 'addRegistrationFields']);
+ add_action('login_header', [$this, 'addRegistrationScript']);
+ add_filter('registration_errors', [$this, 'registrationErrorsFilter'], 10, 3);
+ add_action('user_register', [$this, 'saveRegistrationFields'], 999, 2);
+ add_action('login_head', [$this, 'modifyRegistrationForm']);
+ add_action('register_form', [$this, 'addUploadSupport']);
+ add_filter('pre_user_login', [$this, 'setUserLogin'], 1);
+ add_filter('pre_user_email', [$this, 'setUserEmail'], 1);
+ add_filter('register_message', [$this, 'customRegisterMessage']);
+ add_filter('wp_login_errors', [$this, 'registrationSuccessMessage'], 10, 2);
+ add_filter('login_form_top', [$this, 'loginFormTop']);
+ add_filter('login_form_bottom', [$this, 'loginFormBottom']);
+ add_filter('login_form_middle', [$this, 'loginFormMiddle']);
+
+ // Remove default username requirement for registration
+ remove_filter('registration_errors', 'registration_auth_pass_filter', 10);
+ }
+
+ /**
+ * Combined login styles for both login and registration
+ */
+ public function loginStyles(): void
+ {
+ do_action('jvbLoginStyles');
+ }
+
+ /**
+ * Login header - used for both login and registration
+ */
+ public function loginHeader(): void
+ {
+ ?>
+ <script type="text/javascript">
+ document.addEventListener('DOMContentLoaded', function() {
+ let loginLabel = document.querySelector('label[for="user_login"');
+ loginLabel.innerHTML = '<?= jvbIcon('email', ['size' => 20]); ?> Your Email';
+
+ let passwordLabel = document.querySelector('label[for="user_pass"');
+ passwordLabel.innerHTML = '<?= jvbIcon('password', ['size' => 20]); ?> Your Password';
+
+ document.querySelector('form').classList.add('loaded');
+ });
+
+ </script>
+ <?php
+ }
+
+ /**
+ * Login footer with donate section
+ */
+ public function loginFooter(): void
+ {
+ do_action('jvbLoginFooter');
+
+ }
+
+ /**
+ * Logo URL
+ */
+ public function logoUrl(): string
+ {
+ return home_url();
+ }
+
+ /**
+ * Logo title
+ */
+ public function logoTitle(): string
+ {
+ return get_bloginfo('name');
+ }
+
+ /**
+ * Login message - handles both login and registration
+ */
+ public function loginMessage(string $message): string
+ {
+ if ($this->isRegistrationPage()) {
+ if (jvbSiteHasInvitations() && $this->fromInvite()) {
+ $data = JVB()->routes('invites')->verifyInvitation(sanitize_text_field($_GET['invite']), sanitize_email($_GET['email']));
+ $name = $data->name;
+ $inviters = json_decode($data->inviters, true);
+ $names = [];
+ foreach ($inviters as $inviter) {
+ $artist = jvbContentFromUser((int)$inviter['user_id']);
+ $names[] = ($artist['name'] === '') ? $artist['display_name'] : $artist['name'];
+ }
+ $message = (count($names) > 1) ? 'are already here, and have invited you to join in!' : ' is already here, and invited you to join in!';
+ return '<h2>Join the Scene, '.$name.'</h2>
+ <p style="text-align:center;">'.jvbCommaList($names).$message.'</p>';
+ }
+ if (jvbSiteHasFavourites() && $this->fromFavourites()) {
+ return '<h2>'.JVB_LOGIN['login_from_favourite_header']??'Save your Favourites'.'</h2>';
+ }
+ return '<h2>'.JVB_LOGIN['join_header'].'</h2>';
+ } else {
+ if (jvbSiteHasFavourites()) {
+ $login = (!$this->fromFavourites()) ? '<h2>'.JVB_LOGIN['login_header'].'</h2>' : '<h2>'.JVB_LOGIN['login_from_favourite_header'].'</h2>';
+ } else {
+ $login = '<h2>'.JVB_LOGIN['login_header'].'</h2>';
+ }
+
+ return (empty($message)) ? $login : $login.$message;
+ }
+ }
+
+ protected function fromFavourites():bool
+ {
+ return array_key_exists('type', $_GET) && $_GET['type'] === 'favourites';
+ }
+
+ /**
+ * Customize login error messages
+ */
+ public function loginErrors(string $error): string
+ {
+ return str_replace(
+ [
+ 'The password you entered for the username',
+ 'Invalid username',
+ 'Unknown username',
+ 'Unknown email address'
+ ],
+ [
+ 'Wrong password',
+ 'We can\'t find that username',
+ 'We can\'t find that username',
+ 'We can\'t find that email'
+ ],
+ $error
+ );
+ }
+
+ /**
+ * Handle successful login
+ */
+ public function handleSuccessfulLogin(string $username, WP_User $user): void
+ {
+ if (isOurPeople() && !user_can($user, 'manage_options')) {
+ wp_redirect(get_home_url(null, '/dash'));
+ exit;
+ }
+ }
+
+ // ===== REGISTRATION-SPECIFIC METHODS =====
+
+ /**
+ * Set user login for registration
+ */
+ public function setUserLogin(string $login): string
+ {
+ $user_type = isset($_POST['user_type']) ? $_POST['user_type'] : '';
+ if (!empty($user_type)) {
+ $email_field = $user_type . '_email';
+ if (isset($_POST[$email_field])) {
+ $email = sanitize_email($_POST[$email_field]);
+ if (is_email($email)) {
+ return $email;
+ }
+ }
+ }
+ return $login;
+ }
+
+ /**
+ * Set user email for registration
+ */
+ public function setUserEmail(string $email): string
+ {
+ $user_type = isset($_POST['user_type']) ? $_POST['user_type'] : '';
+ if (!empty($user_type)) {
+ $email_field = $user_type . '_email';
+ if (isset($_POST[$email_field])) {
+ $email = sanitize_email($_POST[$email_field]);
+ if (is_email($email)) {
+ return $email;
+ }
+ }
+ }
+ return $email;
+ }
+
+ /**
+ * Modify registration form
+ */
+ public function modifyRegistrationForm(): void
+ {
+ if (!$this->isRegistrationPage()) {
+ return;
+ }
+
+ ?>
+ <script type="text/javascript">
+ document.addEventListener('DOMContentLoaded', function() {
+ // Hide default fields
+ const defaultFields = document.getElementById('registerform').querySelectorAll('p');
+ defaultFields.forEach(field => {
+ if (field.querySelector('label[for="user_login"]') ||
+ field.querySelector('label[for="user_email"]')) {
+ field.remove();
+ }
+ });
+
+ // Hide the default registration info text
+ const regInfo = document.querySelector('.message.register');
+ if (regInfo) {
+ regInfo.style.display = 'none';
+ }
+
+ <?php
+ if ($this->fromInvite()) {
+ $this->handleArtistInvitation();
+ }
+ ?>
+
+ // Move submit button to the end of the form
+ const submitButton = document.getElementById('registerform').querySelector('.submit');
+ if (submitButton) {
+ document.getElementById('registerform').appendChild(submitButton);
+ }
+ });
+ </script>
+ <?php
+ }
+
+ /**
+ * Handle artist invitation pre-fill
+ */
+ protected function handleArtistInvitation(): void
+ {
+ $token = sanitize_text_field($_GET['invite']);
+ $email = sanitize_email($_GET['email']);
+ $data = JVB()->routes('invites')->verifyInvitation($token, $email);
+
+ ?>
+ document.querySelector('input#artist').checked = true;
+ document.querySelector('#artist_first_name').value = '<?=$data->name?>';
+ document.querySelector('#artist_email').value = '<?=$email?>';
+ <?php
+ if ($data->to_shop) {
+ ?>
+ document.querySelector('#artist_shop').value = '<?=$data->shop?>';
+ <?php
+ }
+ ?>
+ let form = document.getElementById('registerform')
+ let input = document.createElement('input');
+ let email = input.cloneNode(true);
+ input.type = 'hidden';
+ input.name = 'invite_token';
+ input.value = '<?= $token ?>';
+ email.type = 'hidden';
+ email.name = 'invite_email';
+ email.value = '<?= $email?>';
+ form.append(input);
+ form.append(email);
+ <?php
+ }
+
+ /**
+ * Add upload support for registration
+ */
+ public function addUploadSupport(): void
+ {
+ ?>
+ <script>
+ document.addEventListener('DOMContentLoaded', function() {
+ const form = document.getElementById('registerform');
+ if (form) {
+ form.enctype = 'multipart/form-data';
+ }
+ });
+ </script>
+ <?php
+ }
+
+ /**
+ * Add registration script
+ */
+ public function addRegistrationScript(): void
+ {
+ if (!$this->isRegistrationPage()) {
+ return;
+ }
+ ?>
+ <script>
+ document.addEventListener('DOMContentLoaded', function() {
+
+ // Initialize user type selection
+ function initUserTypeSelection() {
+ const userTypeRadios = document.querySelectorAll('input[name="user_type"]');
+ const fieldGroups = document.querySelectorAll('.field-group');
+
+ userTypeRadios.forEach(radio => {
+ radio.addEventListener('change', function() {
+ fieldGroups.forEach(group => group.classList.remove('active'));
+ const selectedType = this.value;
+ const targetGroup = document.querySelector(`.field-group[data-type="${selectedType}"]`);
+ if (targetGroup) {
+ targetGroup.classList.add('active');
+ }
+ });
+ });
+
+ const checkedRadio = document.querySelector('input[name="user_type"]:checked');
+ if (checkedRadio) {
+ const targetGroup = document.querySelector(`.field-group[data-type="${checkedRadio.value}"]`);
+ if (targetGroup) {
+ targetGroup.classList.add('active');
+ }
+ }
+ }
+
+ // Initialize shop selection
+ function initShopSelection() {
+ let form = document.getElementById('registerform');
+ form.addEventListener('change', (e) => {
+ if(e.target.id === 'artist_shop' || e.target.id === 'artist_city'){
+ let next = e.target.parentNode.nextElementSibling;
+ let input = next.querySelector('input');
+
+ if(e.target.value === 'other'){
+ next.style.display = 'block';
+ next.style.animation = 'fadeIn 0.3s ease';
+ input.required = true;
+ input.focus();
+ }else{
+ input.required = false;
+ input.value = '';
+ }
+ }
+ });
+ }
+
+ // Initialize file upload handling
+ function initFileUpload() {
+ const fileInput = document.getElementById('certification_file');
+ const filePreview = document.querySelector('.file-preview');
+ const filePreviewName = document.querySelector('.file-preview-name');
+ const fileError = document.querySelector('.file-error');
+ const removeButton = document.querySelector('.file-preview-remove');
+
+ if (!fileInput || !filePreview || !filePreviewName || !fileError || !removeButton) {
+ return;
+ }
+
+ const maxSize = parseInt(fileInput.dataset.maxSize || 5242880);
+
+ fileInput.addEventListener('change', function(e) {
+ const file = e.target.files[0];
+ fileError.classList.remove('active');
+
+ if (file) {
+ const validTypes = ['.jpg','.jpeg','.png','.gif','.pdf'];
+ const fileExtension = '.' + file.name.split('.').pop().toLowerCase();
+
+ if (!validTypes.includes(fileExtension)) {
+ showError('Please upload a valid file type (JPG, PNG, GIF, or PDF)');
+ fileInput.value = '';
+ return;
+ }
+
+ if (file.size > maxSize) {
+ showError('File size must be less than 5MB');
+ fileInput.value = '';
+ return;
+ }
+
+ filePreviewName.textContent = file.name;
+ filePreview.classList.add('active');
+ } else {
+ filePreview.classList.remove('active');
+ }
+ });
+
+ removeButton.addEventListener('click', function() {
+ fileInput.value = '';
+ filePreview.classList.remove('active');
+ fileError.classList.remove('active');
+ });
+
+ function showError(message) {
+ fileError.textContent = message;
+ fileError.classList.add('active');
+ filePreview.classList.remove('active');
+ }
+ }
+
+ // Initialize all components
+ initUserTypeSelection();
+ initShopSelection();
+ initFileUpload();
+ });
+ </script>
+ <?php
+ }
+
+ /**
+ * Add registration fields
+ */
+ public function addRegistrationFields(): void
+ {
+ echo '<input type="hidden" name="user_pass" value="' . wp_generate_password() . '">';
+ ?>
+ <div class="registration-intro">
+ <?php
+ foreach (JVB_LOGIN['join_intro']??[] as $intro) {
+ echo '<p>'.$intro.'</p>';
+ }
+ ?>
+
+ <?php if ($this->fromFavourites()): ?>
+ <div class="favourites-login-message">
+ <ul class="benefits-list">
+ <?php
+ foreach (JVB_LOGIN['from_favourites_benefits']??[] as $benefit) {
+ echo '<li>'.$benefit.'</li>';
+ }
+ ?>
+ </ul>
+ </div>
+ <?php endif; ?>
+ </div>
+
+ <?php
+ if (array_key_exists('choose', JVB_LOGIN)) {
+ ?>
+ <h3><?= JVB_LOGIN['choose']?></h3>
+ <?php
+ }
+ ?>
+
+ <?php
+ if (count(JVB_USER) > 1) {
+ $this->renderUserTypeSelection();
+ } else {
+ ?>
+ <p>
+ <label for="first_name" class="required-field">First Name</label>
+ <input type="text" id="first_name" name="first_name" class="input">
+ </p>
+ <p>
+ <label for="email" class="required-field">Email</label>
+ <input type="email" id="email" name="email" class="input">
+ </p>
+ <?php
+ }
+ if ($this->invitation_data) {
+ ?>
+ <script>
+ document.addEventListener('DOMContentLoaded', function() {
+ const artistRadio = document.getElementById('artist');
+ if (artistRadio) {
+ artistRadio.checked = true;
+ artistRadio.dispatchEvent(new Event('change'));
+ }
+
+ const emailField = document.getElementById('artist_email');
+ if (emailField) {
+ emailField.value = '<?= esc_js($this->invitation_data['email']); ?>';
+ emailField.readOnly = true;
+ }
+
+ const shopSelect = document.getElementById('artist_shop');
+ if (shopSelect) {
+ shopSelect.value = '<?= esc_js($this->invitation_data['shop_id']); ?>';
+ shopSelect.readOnly = true;
+ }
+ });
+ </script>
+ <input type="hidden" name="invitation_token" value="<?= sanitize_text_field($_GET['invite']) ?>">
+ <input type="hidden" name="invitation_email" value="<?= sanitize_email($_GET['email']) ?>">
+ <?php
+ }
+ }
+
+ protected function renderUserTypeSelection():void
+ {
+
+
+ // Get list of tattoo shops and cities
+ $shops = get_terms([
+ 'taxonomy' => 'jvb_shop',
+ 'hide_empty' => true
+ ]);
+
+ $cities = get_terms([
+ 'taxonomy' => 'jvb_city',
+ 'hide_empty' => false,
+ ]);
+ ?>
+ <div class="user-type-section">
+
+ <?php
+ $i = 1;
+ $radio = '<input type="radio" id="user0" name="user_type" value="subscriber" required checked>
+ <label for="user0"></label>';
+ $descriptions = '';
+ foreach (JVB_USER as $role => $config) {
+ if (jvbCheck('can_register', $config)) {
+ $radio .= '<input type="radio" id="user'.$i.'" name="user_type" value="'.$role.'" required';
+ $radio .= ($role === 'enthusiast' && $this->fromFavourites()) ? 'checked' : '';
+ $radio .= '><label for="user'.$i.'">'.jvbIcon($role, ['title' =>$config['label'], 'size'=>40]).'<h4>'.$config['label'].'</h4><p>';
+ $radio .= $config['join_text']??'';
+ $radio .= '</p></label>';
+
+ $descriptions .= '<div class="user'.$i.'">'.is_array($config['join_description']) ? implode('', array_map(function ($item) { return '<p>'.$item.'</p>'; }, $config['join_description'])) : '<p>'.$config['join_description'].'</p>'.'</div>';
+
+ $i++;
+ }
+ }
+
+ echo $radio;
+ echo $descriptions;
+ ?>
+ <input type="radio" id="enthusiast" name="user_type" value="enthusiast" required <?= ($this->fromFavourites()) ? 'checked' : '' ?>>
+ <label for="enthusiast"><?=jvbIcon('heart', ['title' =>'Enthusiast', 'size'=>40])?><h4>Enthusiast</h4><p>Start here.</p></label>
+ <input type="radio" id="artist" name="user_type" value="artist" required>
+ <label for="artist"><?=jvbIcon('tattoo', ['title'=> 'Artist', 'size'=> 40])?><h4>Artist</h4><p>Show your talent.</p></label>
+ <input type="radio" id="partner" name="user_type" value="partner" required>
+ <label for="partner"><?=jvbIcon('partner', ['title'=>'Partner', 'size' => 40])?><h4>Partner</h4><p>Support the community.</p></label>
+ <p class="enthusiast">Save your favourites. Get notified.</p>
+ <p class="artist">Show off your work.</p>
+ <p class="partner">Support the community.</p>
+ </div>
+
+ <!-- Enthusiast Fields -->
+ <div class="field-group" data-type="enthusiast">
+ <h4>Welcome to the scene.</h4>
+ <p>Sign up with your email to:</p>
+ <ul>
+ <li>Save your favourites for easy access</li>
+ <li>Get notified when your favourite artists add new content</li>
+ <li>Stay in the loop with local flash days and events</li>
+ <li>Discover styles and artists that match your vision</li>
+ </ul>
+ <p>
+ <label for="enthusiast_first_name" class="required-field">First Name</label>
+ <input type="text" id="enthusiast_first_name" name="enthusiast_first_name" class="input">
+ </p>
+ <p>
+ <label for="enthusiast_email" class="required-field">Email</label>
+ <input type="email" id="enthusiast_email" name="enthusiast_email" class="input">
+ </p>
+ <div><p><b>BONUS</b>: Everything's free. And always will be. We work with partners chosen by and for the community to keep the lights on.</p></div>
+ </div>
+
+ <!-- Artist Fields -->
+ <div class="field-group" data-type="artist">
+ <h4>Welcome to the scene!</h4>
+ <p>We'll start small, with the basics. Before your profile goes live, we need to verify:</p>
+ <ul>
+ <li>you are who you say you are</li>
+ <li>you work at the shop you listed</li>
+ <li>your certification</li>
+ </ul>
+ <p>
+ <label for="artist_first_name" class="required-field">First Name</label>
+ <input type="text" id="artist_first_name" name="artist_first_name" class="input">
+ </p>
+ <p>
+ <label for="artist_last_name" class="required-field">Last Name</label>
+ <input type="text" id="artist_last_name" name="artist_last_name" class="input">
+ </p>
+ <p>
+ <label for="artist_email" class="required-field">Email</label>
+ <input type="email" id="artist_email" name="artist_email" class="input">
+ </p>
+ <p>
+ <label for="artist_shop" class="required-field">Shop</label>
+ <select id="artist_shop" name="artist_shop" class="input">
+ <option value="">Select a shop</option>
+ <option value="other">Add New Shop</option>
+ <?php foreach ($shops as $shop) : ?>
+ <option value="<?= esc_attr($shop->term_id); ?>"><?= esc_html($shop->name); ?></option>
+ <?php endforeach; ?>
+ </select>
+ </p>
+ <p id="other_shop_field" style="display: none;">
+ <label for="artist_shop_other" class="required-field">Shop Name</label>
+ <input type="text" id="artist_shop_other" name="artist_shop_other" class="input" placeholder="Shop name">
+ </p>
+
+ <p>
+ <label for="artist_type" class="required-field">Type</label>
+ <input type="radio" id="type-tattoo-artist" name="artist_type" value="tattoo-artist">
+ <label for="type-tattoo-artist">Tattoo Artist</label>
+ <input type="radio" id="type-piercer" name="artist_type" value="piercer">
+ <label for="type-piercer">Piercer</label>
+ <input type="radio" id="type-other" name="artist_type" value="other">
+ <label for="type-other">Other</label>
+ </p>
+ <p>
+ <label for="artist_city" class="required-field">City</label>
+ <select id="artist_city" name="artist_city" class="input">
+ <option value="">Select a city</option>
+ <option value="other">Add New City</option>
+ <?php foreach ($cities as $city) : ?>
+ <option value="<?= esc_attr($city->term_id); ?>"><?= esc_html($city->name); ?></option>
+ <?php endforeach; ?>
+ </select>
+ </p>
+ <p id="other_city_field" style="display: none;">
+ <label for="artist_city_other" class="required-field">City Name</label>
+ <input type="text" id="artist_city_other" name="artist_city_other" class="input" placeholder="City">
+ </p>
+
+ <div class="file-upload-container">
+ <label class="file-upload-label">Certification or Training Documents</label>
+ <p><i>Optional</i> — If you've been certified in bloodborne pathogen safety, or any other tattoo safety course, pass along your certificate. This just eases the verification process.</p>
+ <div class="file-upload-wrapper">
+ <input type="file" name="certification_file" id="certification_file" accept=".jpg,.jpeg,.png,.gif,.pdf" data-max-size="<?= $this->max_file_size; ?>">
+ <p class="file-upload-text">
+ <strong>Click to upload</strong> or drag and drop<br>
+ JPG, PNG, GIF or PDF (max. 5MB)
+ </p>
+ </div>
+ <div class="file-preview">
+ <div class="file-preview-content">
+ <span class="file-preview-name"></span>
+ <button type="button" class="file-preview-remove">Remove</button>
+ </div>
+ </div>
+ <div class="file-error"></div>
+ </div>
+ <p>Once you click register:</p>
+ <ul>
+ <li>We'll start looking into your information (usually within 24-48 hours)</li>
+ <li>You'll get a password reset email</li>
+ <li>Upon setting your password, you can start filling in your profile - but it won't go live until we've verified your information.</li>
+ </ul>
+ <p>If you have any questions or concerns - or anything you'd like to follow up on - email us at get@edmonton.ink or message us on <a target="_blank" href="https://www.instagram.com/edmonton.ink/" title="@edmonton.ink on Instagram">Instagram</a>.</p>
+ <div><p><b>BONUS</b>: Everything's free. And always will be. We work with partners chosen by and for the community to keep the lights on.</p></div>
+ </div>
+
+ <!-- Partner Fields -->
+ <div class="field-group" data-type="partner">
+ <h4>Howdy, partner!</h4>
+ <p>We appreciate your interest!</p>
+ <p>edmonton.ink is a great place to showcase what you do, whether you:</p>
+ <ul>
+ <li>provide goods or services that tattoo artists could use</li>
+ <li>provide goods or services that are tattoo adjacent (such as art, merch, etc)</li>
+ <li>provide goods or services that folks who love tattoos could also love</li>
+ </ul>
+
+ <p>We'll start with some basics, then we'll reach out to follow up (usually within 24-48 hours).</p>
+ <p>
+ <label for="partner_name" class="required-field">Contact Name</label>
+ <input type="text" id="partner_name" name="partner_name" class="input">
+ </p>
+ <p>
+ <label for="partner_email" class="required-field">Email</label>
+ <input type="email" id="partner_email" name="partner_email" class="input">
+ </p>
+ <p>
+ <label for="partner_business" class="required-field">Business Name</label>
+ <input type="text" id="partner_business" name="partner_business" class="input">
+ </p>
+ <p>
+ <label for="partner_website">Business Website</label>
+ <input type="url" id="partner_website" name="partner_website" class="input">
+ </p>
+ <p>
+ <label for="partner_description">Why would you be a good fit?</label>
+ <textarea id="partner_description" name="partner_description" rows="8"></textarea>
+ </p>
+ <p><i>Note:</i> — you must have good standing in the tattoo community to stay a partner of edmonton.ink.</p>
+ <p>If we receive multiple requests to terminate a partnership with you from member artists, we reserve the right to cancel your listings.</p>
+ </div>
+ <?php
+ }
+
+ /**
+ * Registration errors filter
+ */
+ public function registrationErrorsFilter(WP_Error $errors, string $sanitized_user_login, string $user_email): WP_Error
+ {
+ error_log('Registration Data: '.print_r($_POST, true));
+ $user_type = isset($_POST['user_type']) ? $_POST['user_type'] : '';
+
+ if (empty($user_type)) {
+ $errors->add('user_type_error', 'Please select your user type.');
+ return $errors;
+ }
+
+ // Get email based on user type
+ $email_field = $user_type . '_email';
+ $email = isset($_POST[$email_field]) ? sanitize_email($_POST[$email_field]) : '';
+
+ // Remove WordPress's default username error
+ $errors = new WP_Error();
+
+ // If this is an invited artist, validate the invitation
+ $invite = (array_key_exists('invite_token', $_POST)) ? sanitize_text_field($_POST['invite_token']) : false;
+ if ($invite && array_key_exists('role', $_POST)) {
+ $handler = JVB()->routes('invites');
+ $invitation = $handler->verifyInvitation($invite, sanitize_email($_POST['invite_email']), sanitize_text_field($_POST['role']));
+
+ if (!$invitation) {
+ $errors->add('invalid_invitation', 'Invalid invitation token.');
+ } elseif (strtotime($invitation->expires_at) < current_time('timestamp')) {
+ $errors->add('expired_invitation', 'This invitation has expired.');
+ }
+ }
+
+ // Validate email first
+ if (empty($email)) {
+ $errors->add('email_error', 'Email is required.');
+ } elseif (!is_email($email)) {
+ $errors->add('email_error', 'Please enter a valid email address.');
+ } elseif (email_exists($email)) {
+ $errors->add('email_error', 'This email is already registered.');
+ }
+
+ switch ($user_type) {
+ case 'enthusiast':
+ if (empty($_POST['enthusiast_first_name'])) {
+ $errors->add('first_name_error', 'First name is required.');
+ }
+ break;
+
+ case 'artist':
+ $required_fields = [
+ 'artist_first_name' => 'First name',
+ 'artist_last_name' => 'Last name',
+ 'artist_shop' => 'Shop',
+ 'artist_city' => 'City',
+ 'artist_type' => 'Type',
+ ];
+ foreach ($required_fields as $field => $label) {
+ if (empty($_POST[$field])) {
+ $errors->add($field . '_error', $label . ' is required.');
+ }
+ }
+ break;
+
+ case 'partner':
+ $required_fields = [
+ 'partner_name' => 'Contact name',
+ 'partner_business' => 'Business name'
+ ];
+
+ foreach ($required_fields as $field => $label) {
+ if (empty($_POST[$field])) {
+ $errors->add($field . '_error', $label . ' is required.');
+ }
+ }
+ break;
+ }
+
+ if (isset($_POST['user_type']) && $_POST['user_type'] === 'artist' && !empty($_FILES['certification_file']['name'])) {
+ $file = $_FILES['certification_file'];
+
+ // Validate file type
+ if (!in_array($file['type'], $this->allowed_file_types)) {
+ $errors->add('file_type_error', 'Please upload a valid file type (JPG, PNG, GIF, or PDF)');
+ }
+
+ // Validate file size
+ if ($file['size'] > $this->max_file_size) {
+ $errors->add('file_size_error', 'File size must be less than 5MB');
+ }
+ }
+
+ return $errors;
+ }
+
+ /**
+ * Save registration fields
+ */
+ public function saveRegistrationFields(int $user_id, array $userdata): void
+ {
+ $user_type = isset($_POST['user_type']) ? $_POST['user_type'] : false;
+ if (!$user_type) {
+ return;
+ }
+
+ // Set user role based on type
+ $user = new WP_User($user_id);
+ $caps = JVB()->roles();
+ $email = false;
+ $upload_dir = wp_upload_dir();
+ $base_dir = $upload_dir['basedir'];
+
+ switch ($user_type) {
+ case 'artist':
+ $user->set_role('jvb_artist');
+ $user->remove_role('subscriber');
+
+ $email = sanitize_email($_POST['artist_email']);
+ $first = sanitize_text_field($_POST['artist_first_name']);
+ $last = sanitize_text_field($_POST['artist_last_name']);
+ $display_name = $first . ' ' . $last;
+
+ // Save artist fields
+ $temp = wp_update_user([
+ 'ID' => $user_id,
+ 'first_name' => $first,
+ 'last_name' => $last,
+ 'display_name' => $display_name
+ ]);
+ $user = get_userdata($temp);
+
+ $link = $caps->addUserLink($user, 'artist');
+ $meta = new MetaManager($link, 'post');
+ $meta->setAll([
+ 'first_name' => $first,
+ 'email' => $email
+ ]);
+
+ // If this was an invited artist, handle the invitation
+ if (array_key_exists('invite_token', $_POST)) {
+ $handler = JVB()->routes('invites');
+ $handler->acceptInvitation(sanitize_text_field($_POST['invite_token']), sanitize_email($_POST['invite_email']), $user->ID);
+ }
+
+ if (absint($_POST['artist_shop']) > 0) {
+ JVB()->routes('shop')->requestShopAdmission($user_id, absint($_POST['artist_shop']));
+ }
+ if (absint($_POST['artist_city']) > 0) {
+ wp_set_post_terms($link, (int)absint($_POST['artist_city']), BASE.'city');
+ }
+
+ //Create approval request and notify verified users
+ JVB()->routes('approvals')->createArtistApprovalRequest($user_id);
+
+ //Make base directories
+ $artist_dir = $base_dir . '/artists/' . $user_id;
+ wp_mkdir_p($artist_dir);
+ wp_mkdir_p($artist_dir . '/artwork');
+ wp_mkdir_p($artist_dir . '/events');
+ wp_mkdir_p($artist_dir . '/profile');
+ wp_mkdir_p($artist_dir . '/temp');
+
+ switch ($_POST['artist_type']) {
+ case 'tattoo-artist':
+ $caps->setUserAs($user, 'tattoo-artist');
+ $term = get_term_by('name', 'Tattoo Artists', BASE.'type');
+ if ($term && !is_wp_error($term)) {
+ wp_set_post_terms($link, $term->term_id, BASE.'type');
+ }
+ wp_mkdir_p($artist_dir . '/tattoos');
+ break;
+ case 'piercer':
+ $caps->setUserAs($user, 'piercer');
+ $term = get_term_by('name', 'Piercers', BASE.'type');
+ if ($term && !is_wp_error($term)) {
+ wp_set_post_terms($link, $term->term_id, BASE.'type');
+ }
+ wp_mkdir_p($artist_dir . '/piercings');
+ break;
+ }
+ break;
+
+ case 'partner':
+ $user->set_role('jvb_partner');
+ $user->remove_role('subscriber');
+ $name = sanitize_text_field($_POST['partner_name']);
+ $email = sanitize_email($_POST['partner_email']);
+
+ $caps->setUserAs($user, 'partner');
+ $link = $caps->addUserLink($user, 'partner');
+
+ // Save partner fields
+ update_user_meta($user_id, 'contact_name', sanitize_text_field($_POST['partner_name']));
+ update_user_meta($user_id, 'business_name', sanitize_text_field($_POST['partner_business']));
+ update_user_meta($user_id, 'business_website', esc_url_raw($_POST['partner_website']));
+
+ // Create partner base directory
+ $partner_dir = $base_dir . '/partners/' . $user_id;
+ wp_mkdir_p($partner_dir);
+ wp_mkdir_p($partner_dir . '/offers');
+ wp_mkdir_p($partner_dir . '/events');
+ wp_mkdir_p($partner_dir . '/profile');
+ wp_mkdir_p($partner_dir . '/temp');
+ break;
+
+ case 'enthusiast':
+ $user->set_role('jvb_enthusiast');
+ $user->remove_role('subscriber');
+ $caps->setUserAs($user, 'enthusiast');
+ $name = sanitize_text_field($_POST['enthusiast_first_name']);
+ $email = sanitize_email($_POST['enthusiast_email']);
+
+ // Save enthusiast fields
+ $temp = wp_update_user([
+ 'ID' => $user_id,
+ 'first_name' => $name,
+ 'user_email' => $email,
+ ]);
+ break;
+ }
+
+ // Handle file upload for artists
+ if (isset($_POST['user_type']) && $_POST['user_type'] === 'artist' && !empty($_FILES['certification_file']['name'])) {
+ $file = $_FILES['certification_file'];
+
+ // Setup upload directory
+ $upload_dir = wp_upload_dir();
+ $user_directory = 'artist-certifications/' . $user_id;
+ $target_dir = $upload_dir['basedir'] . '/' . $user_directory;
+
+ // Create directory if it doesn't exist
+ wp_mkdir_p($target_dir);
+
+ // Generate unique filename
+ $file_extension = pathinfo($file['name'], PATHINFO_EXTENSION);
+ $filename = 'certification-' . time() . '.' . $file_extension;
+ $target_file = $target_dir . '/' . $filename;
+
+ // Move uploaded file
+ if (move_uploaded_file($file['tmp_name'], $target_file)) {
+ // Save file information in user meta
+ update_user_meta($user_id, 'certification_file', [
+ 'url' => $upload_dir['baseurl'] . '/' . $user_directory . '/' . $filename,
+ 'file' => $target_file,
+ 'type' => $file['type'],
+ 'original_name' => $file['name']
+ ]);
+ }
+ }
+
+ // Handle list invitation acceptance
+ if (isset($_GET['list_token']) && !empty($_GET['list_token']) && isset($_GET['email'])) {
+ $token = sanitize_text_field($_GET['list_token']);
+ $email = sanitize_email($_GET['email']);
+
+ if ($email) {
+ JVB()->routes('favourites')->acceptListInvitation($token, $email, $user_id);
+ }
+ }
+ }
+
+ /**
+ * Registration success message
+ */
+ public function registrationSuccessMessage(WP_Error $errors, string $redirect_to): WP_Error
+ {
+ if (isset($errors->errors['registered']) && isset($_POST['invitation_token'])) {
+ // Custom message for invited artists
+ $message = "WELCOME ABOARD!<br><br>" .
+ "Password setup is in your inbox. <br>" .
+ "Since you were invited by a shop, you can skip the verification wait and start building your profile right away! ♡";
+
+ unset($errors->errors['registered']);
+ $errors->add('registered', $message, 'message');
+ }
+
+ if (isset($errors->errors['registered'])) {
+ $user_type = isset($_POST['user_type']) ? $_POST['user_type'] : 'user';
+
+ switch ($user_type) {
+ case 'enthusiast':
+ $message = "YOU'RE IN!<br><br>Check your inbox - we've sent password setup details.<br>Get ready to build your dream artist collection! ♡";
+ break;
+
+ case 'artist':
+ $message = "HELL YEAH!<br><br>Password setup is in your inbox. <br>While we verify your info (24-48hrs), you can start building your profile. <br>Just remember - it stays underground until you're cleared. ♡";
+ break;
+
+ case 'partner':
+ $message = "ROCK ON!<br><br>Check your inbox - we've sent password setup details.<br>We'll check out your pitch in the next 24-48hrs. <br><br>Meanwhile, you can start prepping your presence - but you won't hit the streets until we give the nod. ♡";
+ break;
+
+ default:
+ $message = "YOU'RE ON THE LIST!<br><br>Check your inbox for the next steps. ♡";
+ }
+
+ // Replace the default message
+ unset($errors->errors['registered']);
+ $errors->add('registered', $message, 'message');
+ }
+
+ return $errors;
+ }
+
+ /**
+ * Check if registration is from invite
+ */
+ protected function fromInvite(): bool
+ {
+ return isset($_GET['invite']) && isset($_GET['email']);
+ }
+
+ /**
+ * Custom register message
+ */
+ public function customRegisterMessage(string $message): string
+ {
+ return "Join Edmonton's tattoo community";
+ }
+}
+
+// Initialize the consolidated auth manager
+//new LoginManager();
+error_log('LoginManager working');
diff --git a/inc/managers/MagicLinkManager.php b/inc/managers/MagicLinkManager.php
index 5b551d2..6b91d7a 100644
--- a/inc/managers/MagicLinkManager.php
+++ b/inc/managers/MagicLinkManager.php
@@ -11,8 +11,8 @@
/**
* Magic Link Authentication Manager
*
- * Handles passwordless authentication via email magic links.
- * Can be used for referral signups, password resets, or general login.
+ * NOTE: Login form integration is now handled by LoginManager.php
+ * This class focuses solely on magic link generation and verification
*/
class MagicLinkManager
{
@@ -24,7 +24,7 @@
protected int $rate_limit_window = 3600; // 1 hour
protected int $max_attempts_per_hour = 5;
- // Link types - allows different flows for different purposes
+ // Link types
const TYPE_LOGIN = 'login';
const TYPE_SIGNUP = 'signup';
const TYPE_REFERRAL = 'referral';
@@ -32,16 +32,15 @@
public function __construct()
{
- $this->cache = new CacheManager('magic_links', $this->token_expiry);
+ $this->cache = CacheManager::for('magic_links', $this->token_expiry);
$this->email = new EmailManager();
// Hook into WordPress auth flow
add_action('template_redirect', [$this, 'handleMagicLinkClick']);
add_action('wp_login_failed', [$this, 'handleFailedLogin']);
- // Add magic link option to login page
- add_action('login_form', [$this, 'addMagicLinkOption']);
- add_filter('authenticate', [$this, 'blockStandardAuth'], 30, 3);
+ // NOTE: LoginManager now handles the login form UI
+ // If magic_link integration is enabled, LoginManager will call addMagicLinkOption()
}
/**
@@ -86,34 +85,100 @@
}
/**
+ * Generate a secure token
+ */
+ protected function generateToken(string $email, string $type, array $data = []): string
+ {
+ $token = wp_generate_password(32, false);
+
+ $token_data = array_merge([
+ 'email' => $email,
+ 'type' => $type,
+ 'created' => time()
+ ], $data);
+
+ $this->cache->set($token, $token_data);
+
+ return $token;
+ }
+
+ /**
+ * Verify a token
+ */
+ protected function verifyToken(string $token, string $email): array|WP_Error
+ {
+ $token_data = $this->cache->get($token);
+
+ if (!$token_data) {
+ return new WP_Error('invalid_token', 'Invalid or expired token');
+ }
+
+ if ($token_data['email'] !== $email) {
+ return new WP_Error('email_mismatch', 'Token does not match email');
+ }
+
+ // Delete token after verification (single use)
+ $this->cache->delete($token);
+
+ return $token_data;
+ }
+
+ /**
+ * Check rate limiting for sending magic links
+ */
+ protected function checkRateLimit(string $email): bool|WP_Error
+ {
+ $cache_key = 'rate_limit_' . md5($email);
+ $attempts = $this->cache->get($cache_key);
+
+ if (!$attempts) {
+ $attempts = ['count' => 0, 'timestamp' => time()];
+ }
+
+ // Reset counter if window has passed
+ if (time() - $attempts['timestamp'] > $this->rate_limit_window) {
+ $attempts = ['count' => 0, 'timestamp' => time()];
+ }
+
+ // Check if limit exceeded
+ if ($attempts['count'] >= $this->max_attempts_per_hour) {
+ return new WP_Error(
+ 'rate_limit_exceeded',
+ 'Too many magic link requests. Please try again in an hour.'
+ );
+ }
+
+ // Increment counter
+ $attempts['count']++;
+ $this->cache->set($cache_key, $attempts, $this->rate_limit_window);
+
+ return true;
+ }
+
+ /**
* Send login magic link to existing user
*/
protected function sendLoginLink(string $email, array $context): bool|WP_Error
{
- // Check if user exists
$user = get_user_by('email', $email);
if (!$user) {
return new WP_Error('user_not_found', 'No account found with this email');
}
- // Generate token
$token = $this->generateToken($email, self::TYPE_LOGIN, [
'user_id' => $user->ID
]);
- // Build magic link URL
$magic_url = add_query_arg([
'magic_token' => $token,
'email' => urlencode($email),
'action' => 'magic_login'
], home_url('/'));
- // Add redirect if specified
if (!empty($context['redirect_to'])) {
$magic_url = add_query_arg('redirect_to', urlencode($context['redirect_to']), $magic_url);
}
- // Send email
$subject = 'Sign in to ' . get_bloginfo('name');
$message = $this->getLoginEmailTemplate($user->display_name, $magic_url);
@@ -125,14 +190,13 @@
/**
* Send signup magic link for new user registration
*/
- protected function sendSignupLink(string $email, array $context):bool|WP_Error
+ protected function sendSignupLink(string $email, array $context): bool|WP_Error
{
// Check if user already exists
if (email_exists($email)) {
return $this->sendLoginLink($email, $context);
}
- // Generate token with signup data
$token_data = [
'name' => $context['name'] ?? '',
'role' => $context['role'] ?? 'subscriber',
@@ -141,18 +205,16 @@
$token = $this->generateToken($email, self::TYPE_SIGNUP, $token_data);
- // Build signup completion URL
$magic_url = add_query_arg([
'magic_token' => $token,
'email' => urlencode($email),
'action' => 'magic_signup'
], home_url('/'));
- // Send welcome email
$subject = 'Complete your ' . get_bloginfo('name') . ' registration';
$message = $this->getSignupEmailTemplate($context['name'] ?? '', $magic_url);
- $sent = $this->email->sendEmail($email, $subject, $message, 'Confirm Your Account');
+ $sent = $this->email->sendEmail($email, $subject, $message, 'Complete Registration');
return $sent ? true : new WP_Error('email_failed', 'Failed to send signup link');
}
@@ -160,60 +222,45 @@
/**
* Send referral signup link
*/
- protected function sendReferralLink(string $email, array $context):bool|WP_Error
+ protected function sendReferralLink(string $email, array $context): bool|WP_Error
{
- // Check if user already exists
- if (email_exists($email)) {
- return new WP_Error('user_exists', 'This person already has an account');
- }
-
- // Validate referral code
if (empty($context['referral_code'])) {
- return new WP_Error('missing_referral_code', 'Referral code is required');
+ return new WP_Error('missing_referral', 'Referral code is required');
}
- // Get referrer info for personalized email
- $referrer_name = $context['referrer_name'] ?? 'A friend';
-
- // Generate token with referral context
$token_data = [
- 'name' => $context['name'] ?? '',
'referral_code' => $context['referral_code'],
- 'referrer_id' => $context['referrer_id'] ?? 0
+ 'name' => $context['name'] ?? '',
+ 'role' => $context['role'] ?? 'subscriber'
];
$token = $this->generateToken($email, self::TYPE_REFERRAL, $token_data);
- // Build referral signup URL
$magic_url = add_query_arg([
'magic_token' => $token,
'email' => urlencode($email),
'action' => 'magic_referral'
], home_url('/'));
- // Send personalized referral email
- $subject = $referrer_name . ' invited you to ' . get_bloginfo('name');
- $message = $this->getReferralEmailTemplate(
- $context['name'] ?? '',
- $referrer_name,
- $magic_url,
- $context['reward_text'] ?? ''
- );
+ $referrer_name = $context['referrer_name'] ?? 'A friend';
+ $reward_text = $context['reward_text'] ?? '';
- $sent = $this->email->sendEmail($email, $subject, $message, $referrer_name.' invites you to see the difference at Legacy');
+ $subject = $referrer_name . ' invited you to join ' . get_bloginfo('name');
+ $message = $this->getReferralEmailTemplate($context['name'] ?? '', $referrer_name, $magic_url, $reward_text);
- return $sent ? true : new WP_Error('email_failed', 'Failed to send referral invitation');
+ $sent = $this->email->sendEmail($email, $subject, $message, 'Accept Invitation');
+
+ return $sent ? true : new WP_Error('email_failed', 'Failed to send referral link');
}
/**
* Send password reset magic link
*/
- protected function sendResetLink(string $email, array $context):bool|WP_Error
+ protected function sendResetLink(string $email, array $context): bool|WP_Error
{
$user = get_user_by('email', $email);
if (!$user) {
- // Return success even if user doesn't exist (security best practice)
- return true;
+ return new WP_Error('user_not_found', 'No account found with this email');
}
$token = $this->generateToken($email, self::TYPE_RESET, [
@@ -229,7 +276,7 @@
$subject = 'Reset your password';
$message = $this->getResetEmailTemplate($user->display_name, $magic_url);
- $sent = $this->email->sendEmail($email, $subject, $message);
+ $sent = $this->email->sendEmail($email, $subject, $message, 'Reset Password');
return $sent ? true : new WP_Error('email_failed', 'Failed to send reset link');
}
@@ -239,7 +286,6 @@
*/
public function handleMagicLinkClick(): void
{
- // Check if this is a magic link request
if (!isset($_GET['action']) || !isset($_GET['magic_token']) || !isset($_GET['email'])) {
return;
}
@@ -248,12 +294,10 @@
$token = sanitize_text_field($_GET['magic_token']);
$email = sanitize_email($_GET['email']);
- // Only handle magic link actions
if (!in_array($action, ['magic_login', 'magic_signup', 'magic_referral', 'magic_reset'])) {
return;
}
- // Verify token
$token_data = $this->verifyToken($token, $email);
if (is_wp_error($token_data)) {
@@ -261,7 +305,6 @@
return;
}
- // Handle different action types
switch ($action) {
case 'magic_login':
$this->processLogin($token_data);
@@ -292,18 +335,14 @@
wp_die('Invalid user');
}
- // Log the user in
wp_clear_auth_cookie();
wp_set_current_user($user->ID);
wp_set_auth_cookie($user->ID, true);
- // Trigger login action
do_action('wp_login', $user->user_login, $user);
- // Determine redirect
$redirect = isset($_GET['redirect_to']) ? esc_url_raw($_GET['redirect_to']) : home_url('/dash');
- // Redirect
wp_safe_redirect($redirect);
exit;
}
@@ -313,56 +352,6 @@
*/
protected function processSignup(array $token_data): void
{
- // Create the user account
- $user_id = wp_create_user(
- $token_data['email'],
- wp_generate_password(20, true, true), // Random password
- $token_data['email']
- );
-
- if (is_wp_error($user_id)) {
- wp_die('Failed to create account: ' . $user_id->get_error_message());
- }
-
- // Set role
- $user = get_user_by('ID', $user_id);
- $user->set_role($token_data['role']);
-
- // Update display name if provided
- if (!empty($token_data['name'])) {
- wp_update_user([
- 'ID' => $user_id,
- 'display_name' => $token_data['name'],
- 'first_name' => $token_data['name']
- ]);
- }
-
- // Save any additional meta
- if (!empty($token_data['meta'])) {
- foreach ($token_data['meta'] as $key => $value) {
- update_user_meta($user_id, BASE . $key, $value);
- }
- }
-
- // Log the user in
- wp_set_current_user($user_id);
- wp_set_auth_cookie($user_id, true);
-
- // Trigger registration actions
- do_action('user_register', $user_id);
- do_action('wp_login', $user->user_login, $user);
-
- // Redirect to welcome page or dashboard
- wp_safe_redirect(home_url('/dash?welcome=1'));
- exit;
- }
-
- /**
- * Process referral signup via magic link
- */
- protected function processReferralSignup(array $token_data): void
- {
- // Create user account
$user_id = wp_create_user(
$token_data['email'],
wp_generate_password(20, true, true),
@@ -373,7 +362,9 @@
wp_die('Failed to create account: ' . $user_id->get_error_message());
}
- // Update user info
+ $user = get_user_by('ID', $user_id);
+ $user->set_role($token_data['role']);
+
if (!empty($token_data['name'])) {
wp_update_user([
'ID' => $user_id,
@@ -382,192 +373,109 @@
]);
}
- // Store referral code in session for ReferralManager to pick up
+ if (!empty($token_data['meta'])) {
+ foreach ($token_data['meta'] as $key => $value) {
+ update_user_meta($user_id, BASE . $key, $value);
+ }
+ }
+
+ wp_set_current_user($user_id);
+ wp_set_auth_cookie($user_id, true);
+
+ do_action('user_register', $user_id);
+ do_action('wp_login', $user->user_login, $user);
+
+ wp_safe_redirect(home_url('/dash?welcome=1'));
+ exit;
+ }
+
+ /**
+ * Process referral signup via magic link
+ */
+ protected function processReferralSignup(array $token_data): void
+ {
+ $user_id = wp_create_user(
+ $token_data['email'],
+ wp_generate_password(20, true, true),
+ $token_data['email']
+ );
+
+ if (is_wp_error($user_id)) {
+ wp_die('Failed to create account: ' . $user_id->get_error_message());
+ }
+
+ if (!empty($token_data['name'])) {
+ wp_update_user([
+ 'ID' => $user_id,
+ 'display_name' => $token_data['name'],
+ 'first_name' => $token_data['name']
+ ]);
+ }
+
+ // Store referral code for ReferralManager
if (session_status() === PHP_SESSION_NONE) {
session_start();
}
$_SESSION[BASE . 'referral_code'] = $token_data['referral_code'];
- setcookie(BASE . 'referral_code', $token_data['referral_code'], time() + (30 * DAY_IN_SECONDS), '/');
+ setcookie(
+ BASE . 'referral_code',
+ $token_data['referral_code'],
+ time() + (86400 * 30),
+ '/'
+ );
- // Process referral (this will be picked up by ReferralManager::processReferral)
- do_action('user_register', $user_id);
-
- // Log the user in
+ $user = get_user_by('ID', $user_id);
wp_set_current_user($user_id);
wp_set_auth_cookie($user_id, true);
- do_action('wp_login', get_user_by('ID', $user_id)->user_login, get_user_by('ID', $user_id));
- // Redirect with referral welcome message
- wp_safe_redirect(home_url('/dash?referral_welcome=1'));
+ do_action('user_register', $user_id);
+ do_action('wp_login', $user->user_login, $user);
+
+ wp_safe_redirect(home_url('/dash?welcome=1&referral=1'));
exit;
}
/**
- * Process password reset
+ * Process password reset via magic link
*/
protected function processPasswordReset(array $token_data): void
{
- // Redirect to password reset form with token
- wp_safe_redirect(add_query_arg([
- 'action' => 'rp',
- 'key' => $token_data['token'], // Could use magic token or generate WP reset key
- 'login' => $token_data['email']
- ], wp_login_url()));
+ $user = get_user_by('ID', $token_data['user_id']);
+
+ if (!$user) {
+ wp_die('Invalid user');
+ }
+
+ // Log user in and redirect to password change page
+ wp_set_current_user($user->ID);
+ wp_set_auth_cookie($user->ID, true);
+
+ wp_safe_redirect(admin_url('profile.php?password_reset=1'));
exit;
}
/**
- * Generate a secure token
- */
- protected function generateToken(string $email, string $type, array $data): string
- {
- // Create unique token
- $token = wp_generate_password(64, false, false);
-
- // Store token data in transient
- $token_data = [
- 'email' => $email,
- 'type' => $type,
- 'created_at' => time(),
- 'expires_at' => time() + $this->token_expiry,
- 'data' => $data
- ];
-
- $cache_key = 'magic_token_' . $token;
- set_transient($cache_key, $token_data, $this->token_expiry);
-
- // Also index by email for rate limiting
- $this->recordTokenGeneration($email);
-
- return $token;
- }
-
- /**
- * Verify a magic link token
- */
- protected function verifyToken(string $token, string $email)
- {
- // Retrieve token data
- $cache_key = 'magic_token_' . $token;
- $token_data = get_transient($cache_key);
-
- if (!$token_data) {
- return new WP_Error('expired_token', 'This link has expired. Please request a new one.');
- }
-
- // Verify email matches
- if ($token_data['email'] !== $email) {
- return new WP_Error('invalid_token', 'Invalid magic link');
- }
-
- // Check expiration
- if (time() > $token_data['expires_at']) {
- delete_transient($cache_key);
- return new WP_Error('expired_token', 'This link has expired. Please request a new one.');
- }
-
- // Token is valid - delete it (single use)
- delete_transient($cache_key);
-
- // Return merged data
- return array_merge($token_data['data'], [
- 'email' => $token_data['email'],
- 'type' => $token_data['type']
- ]);
- }
-
- /**
- * Rate limiting for magic link generation
- */
- protected function checkRateLimit(string $email):bool|WP_Error
- {
- $limit_key = 'magic_link_limit_' . md5($email);
- $attempts = (int) get_transient($limit_key);
-
- if ($attempts >= $this->max_attempts_per_hour) {
- return new WP_Error(
- 'rate_limit_exceeded',
- 'Too many login attempts. Please try again in an hour.'
- );
- }
-
- return true;
- }
-
- /**
- * Record token generation for rate limiting
- */
- protected function recordTokenGeneration(string $email): void
- {
- $limit_key = 'magic_link_limit_' . md5($email);
- $attempts = (int) get_transient($limit_key);
- set_transient($limit_key, $attempts + 1, $this->rate_limit_window);
- }
-
- /**
- * Handle invalid/expired tokens
+ * Handle invalid token
*/
protected function handleInvalidToken(WP_Error $error): void
{
- wp_die(
- $error->get_error_message(),
- 'Invalid Link',
- [
- 'response' => 400,
- 'back_link' => true
- ]
- );
+ wp_die($error->get_error_message());
}
/**
- * Add "Send me a magic link" option to login form
+ * Handle failed login - offer magic link option
*/
- public function addMagicLinkOption(): void
+ public function handleFailedLogin(string $username): void
{
- ?>
- <p class="magic-link-option">
- <a href="#" id="use-magic-link">Send me a login link instead</a>
- </p>
- <script>
- document.getElementById('use-magic-link')?.addEventListener('click', function(e) {
- e.preventDefault();
- const email = document.getElementById('user_login')?.value;
-
- if (!email) {
- alert('Please enter your email address first');
- return;
- }
-
- // Send magic link request
- fetch('<?php echo rest_url(BASE . '/v1/magic-link/send'); ?>', {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/json',
- },
- body: JSON.stringify({
- email: email,
- type: 'login'
- })
- })
- .then(r => r.json())
- .then(data => {
- if (data.success) {
- alert('Check your email! We sent you a login link.');
- } else {
- alert(data.message || 'Failed to send link');
- }
- });
- });
- </script>
- <?php
+ // Could add logic here to automatically offer magic link
+ // after multiple failed attempts
}
/**
- * Optionally block standard password auth for certain users
+ * Optionally block standard password auth for magic-link-only users
*/
public function blockStandardAuth($user, $username, $password)
{
- // Only block if user has magic-link-only flag
if ($user instanceof WP_User) {
$magic_only = get_user_meta($user->ID, BASE . 'magic_link_only', true);
if ($magic_only) {
@@ -609,17 +517,14 @@
protected function getReferralEmailTemplate(string $name, string $referrer_name, string $magic_url, string $reward_text): string
{
$content = '<h2>Hey' . ($name ? ' ' . esc_html($name) : '') . '!</h2>';
- $content .= '<p><strong>' . esc_html($referrer_name) . '</strong> thinks you\'d love ' . get_bloginfo('name') . ' and invited you to join!</p>';
+ $content .= '<p><strong>' . esc_html($referrer_name) . '</strong> thinks you\'d love ' . get_bloginfo('name') . '!</p>';
if ($reward_text) {
- $content .= '<div style="background: #e7f5ff; padding: 20px; border-radius: 8px; margin: 20px 0;">';
- $content .= '<h3 style="margin-top: 0;">🎉 Special Offer</h3>';
$content .= '<p>' . esc_html($reward_text) . '</p>';
- $content .= '</div>';
}
$content .= '<p style="text-align: center; margin: 30px 0;">';
- $content .= '<a href="' . $magic_url . '" style="background: #2271b1; color: #fff; padding: 12px 24px; text-decoration: none; border-radius: 4px; display: inline-block;">Accept Invitation</a>';
+ $content .= '<a href="' . $magic_url . '" style="background: #2271b1; color: #fff; padding: 12px 24px; text-decoration: none; border-radius: 4px; display: inline-block;">Join Now</a>';
$content .= '</p>';
$content .= '<p style="color: #666; font-size: 14px;">This link expires in 15 minutes.</p>';
@@ -628,12 +533,12 @@
protected function getResetEmailTemplate(string $name, string $magic_url): string
{
- $content = '<h2>Reset Your Password</h2>';
- $content .= '<p>Hey ' . esc_html($name) . ', we received a request to reset your password.</p>';
+ $content = '<h2>Hey ' . esc_html($name) . '!</h2>';
+ $content .= '<p>We received a request to reset your password. Click the button below to sign in and update your password.</p>';
$content .= '<p style="text-align: center; margin: 30px 0;">';
$content .= '<a href="' . $magic_url . '" style="background: #2271b1; color: #fff; padding: 12px 24px; text-decoration: none; border-radius: 4px; display: inline-block;">Reset Password</a>';
$content .= '</p>';
- $content .= '<p style="color: #666; font-size: 14px;">If you didn\'t request this, you can safely ignore this email.</p>';
+ $content .= '<p style="color: #666; font-size: 14px;">If you didn\'t request this, you can safely ignore this email. This link expires in 15 minutes.</p>';
return $content;
}
diff --git a/inc/managers/NewsRelationships.php b/inc/managers/NewsRelationships.php
index 1e9eb46..526ea30 100644
--- a/inc/managers/NewsRelationships.php
+++ b/inc/managers/NewsRelationships.php
@@ -16,13 +16,13 @@
class NewsRelationships
{
private string $table_name;
- private object $cache;
+ private CacheManager $cache;
public function __construct()
{
global $wpdb;
$this->table_name = $wpdb->prefix . BASE . 'news_relationships';
- $this->cache = new CacheManager('news_relationships', 3600); // 1 hour cache by default
+ $this->cache = CacheManager::for('news_relationships', WEEK_IN_SECONDS);
// Register hooks
add_action('init', [$this, 'registerHooks']);
@@ -512,7 +512,7 @@
}
// Update cache
- $this->cache->invalidate('shop_' . $shop_id);
+ $this->cache->delete($shop_id);
// Update shop total count
$this->updateShopTotal($shop_id);
@@ -534,7 +534,7 @@
);
// Update cache
- $this->cache->invalidate('shop_' . $shop_id);
+ $this->cache->delete($shop_id);
}
/**
@@ -566,8 +566,7 @@
*/
public function getShopNewsStats(int $shop_id):array
{
- $cache_key = 'shop_' . $shop_id;
- $cached = $this->cache->get($cache_key);
+ $cached = $this->cache->get($shop_id);
if ($cached !== false) {
return $cached;
@@ -596,7 +595,7 @@
'artists' => $stats
];
- $this->cache->set($cache_key, $result);
+ $this->cache->set($shop_id, $result);
return $result;
}
@@ -715,7 +714,7 @@
*/
public function getAllShopsNews():array
{
- $cache_key = 'all_shops_counts';
+ $cache_key = 'all';
$cached = $this->cache->get($cache_key);
if ($cached !== false) {
diff --git a/inc/managers/NotificationManager.php b/inc/managers/NotificationManager.php
index 146d2a0..abac97a 100644
--- a/inc/managers/NotificationManager.php
+++ b/inc/managers/NotificationManager.php
@@ -139,7 +139,7 @@
*/
public function __construct()
{
- $this->cache = new CacheManager('notifications', WEEK_IN_SECONDS); // 1 week cache
+ $this->cache = CacheManager::for('notifications', WEEK_IN_SECONDS);
// Add filter for bulk operation handling
add_filter(BASE . 'handle_bulk_operation', [ $this, 'processOperation' ], 10, 3);
@@ -1095,7 +1095,7 @@
}
$content = '';
- $cache = new CacheManager('digest_content', HOUR_IN_SECONDS * 6); // Cache for 6 hours
+ $cache = CacheManager::for('digest_content', HOUR_IN_SECONDS * 6); // Cache for 6 hours
// Group updates by artist
$updates_by_artist = [];
@@ -1630,8 +1630,9 @@
*/
protected function clearNotificationCache(int $user_id):void
{
- $this->cache->invalidate("user_{$user_id}_notifications_", 'notifications_' . $user_id);
- $this->cache->invalidate("user_{$user_id}_content_notifications_", 'notifications_' . $user_id);
+
+ $this->cache->delete("user_{$user_id}_notifications_", 'notifications_' . $user_id);
+ $this->cache->delete("user_{$user_id}_content_notifications_", 'notifications_' . $user_id);
}
/**
diff --git a/inc/managers/OperationQueue.php b/inc/managers/OperationQueue.php
index cf6f398..b4e3c9c 100644
--- a/inc/managers/OperationQueue.php
+++ b/inc/managers/OperationQueue.php
@@ -79,7 +79,7 @@
{
global $wpdb;
$this->wpdb = $wpdb;
- $this->cache = new CacheManager('queue');
+ $this->cache = CacheManager::for('queue', DAY_IN_SECONDS);
add_action('jvb_process_queue', [ $this, 'checkQueue' ]);
add_action('jvb_queue_maintenance', [$this, 'hourlyMaintenance']);
add_action('jvbEmailDailyMetricsReport', [$this, 'emailDailyMetricsReport']);
@@ -579,7 +579,7 @@
$this->updateLastModified($user_id);
$this->invalidateQueueCache();
- $this->cache->invalidate(self::CACHE_USER_QUEUE_PREFIX . $user_id);
+ $this->cache->delete(self::CACHE_USER_QUEUE_PREFIX . $user_id);
$this->runQueueOnShutdown();
return [
@@ -814,8 +814,8 @@
$this->processOperation($operation);
// Invalidate operation cache after processing
- $this->cache->invalidate(self::CACHE_OPERATION_PREFIX . $operation->id);
- $this->cache->invalidate(self::CACHE_USER_QUEUE_PREFIX . $operation->user_id);
+ $this->cache->delete(self::CACHE_OPERATION_PREFIX . $operation->id);
+ $this->cache->delete(self::CACHE_USER_QUEUE_PREFIX . $operation->user_id);
}
// Batch invalidate caches at the end
@@ -1025,13 +1025,12 @@
$keys = $cacheKeys[$scope] ?? $cacheKeys['all'];
foreach ($keys as $key) {
- $this->cache->invalidate($key);
+ $this->cache->delete($key);
}
if ($scope === 'all') {
// Clear entire group for complete refresh
- $this->cache->invalidateGroup($this->cacheGroup);
- jvbUpdateCacheTimestamp('queue');
+ $this->cache->invalidate();
delete_transient('jvb_queue_status_counts');
}
}
@@ -1406,7 +1405,7 @@
}
}
// Clear operation cache after any update
- $this->cache->invalidate(self::CACHE_OPERATION_PREFIX . $operation->id);
+ $this->cache->delete(self::CACHE_OPERATION_PREFIX . $operation->id);
$this->updateLastModified($operation->user_id);
return $filterResult;
diff --git a/inc/managers/ReferralManager.php b/inc/managers/ReferralManager.php
index 9777fa4..1bac16a 100644
--- a/inc/managers/ReferralManager.php
+++ b/inc/managers/ReferralManager.php
@@ -40,7 +40,7 @@
{
global $wpdb;
$this->wpdb = $wpdb;
- $this->cache = new CacheManager('referrals');
+ $this->cache = CacheManager::for('referrals', WEEK_IN_SECONDS);
$this->referrals_table = BASE . 'referrals';
$this->rewards_table = BASE . 'referral_rewards';
$this->magic_link = new MagicLinkManager();
@@ -64,6 +64,21 @@
add_action('wp_enqueue_scripts', [$this, 'enqueueScripts']);
// Schedule cron jobs for reports
$this->registerCronJobs();
+
+ // Register admin subpage
+ add_filter('jvbAdminSubpages', [$this, 'addSubpage'], 10, 1);
+
+ // Add admin bar label for referral page
+ add_action('admin_bar_menu', [$this, 'addReferralPageLabel'], 999);
+
+ // Add admin notice to referral page edit screen
+ add_action('admin_notices', [$this, 'showReferralPageNotice']);
+
+
+ add_filter('jvbDashboardPage', [$this, 'renderDashPage'], 10, 2);
+
+ // Handle settings save
+ add_action('admin_init', [$this, 'registerSettings']);
}
public function enqueueScripts():void
@@ -887,7 +902,7 @@
$content .= '</aside>';
$actions[] =[
- 'button' => '<button type="button" class="toggle-referral row" title="Your Referrals" data-action="toggle-referral" aria-label="Open Referral Sidebar" aria-controls="referral" aria-expanded="false">
+ 'button' => '<button type="button" class="attn toggle-referral row" title="Your Referrals" data-action="toggle-referral" aria-label="Open Referral Sidebar" aria-controls="referral" aria-expanded="false">
'.jvbIcon('hand-heart').'<span class="screen-reader-text"></span>
</button>',
'content' => $content
@@ -1447,5 +1462,352 @@
return $csv_content;
}
+
+ /**
+ * Add referral settings subpage to admin menu
+ *
+ * @param array $subpages
+ * @return array
+ */
+ public function addSubpage(array $subpages): array
+ {
+ $subpages[] = [
+ 'page_title' => 'Referral Settings',
+ 'menu_title' => 'Referrals',
+ 'capability' => 'manage_options',
+ 'menu_slug' => 'jvb-referrals',
+ 'callback' => [$this, 'renderAdminPage'],
+ 'icon' => 'users',
+ ];
+
+ return $subpages;
+ }
+
+ /**
+ * Register settings
+ */
+ public function registerSettings(): void
+ {
+ register_setting(
+ BASE . 'referral_settings',
+ BASE . 'referral_page_id',
+ [
+ 'type' => 'integer',
+ 'sanitize_callback' => 'absint',
+ 'default' => 0
+ ]
+ );
+
+ register_setting(
+ BASE . 'referral_settings',
+ BASE . 'referral_reward_settings',
+ [
+ 'type' => 'array',
+ 'sanitize_callback' => [$this, 'sanitizeRewardSettings'],
+ 'default' => $this->default_settings
+ ]
+ );
+ }
+
+ /**
+ * Sanitize reward settings
+ */
+ public function sanitizeRewardSettings(array $settings): array
+ {
+ return [
+ 'referrer_reward_applies_to' => in_array($settings['referrer_reward_applies_to'] ?? '', ['per_user', 'flat_total'])
+ ? $settings['referrer_reward_applies_to']
+ : 'per_user',
+ 'referrer_reward_amount' => floatval($settings['referrer_reward_amount'] ?? 25.00),
+ 'referrer_reward_type' => in_array($settings['referrer_reward_type'] ?? '', ['fixed', 'percentage'])
+ ? $settings['referrer_reward_type']
+ : 'fixed',
+ 'referee_reward_type' => in_array($settings['referee_reward_type'] ?? '', ['percentage', 'fixed'])
+ ? $settings['referee_reward_type']
+ : 'percentage',
+ 'referee_reward_amount' => floatval($settings['referee_reward_amount'] ?? 20),
+ 'referee_reward_applies_to' => in_array($settings['referee_reward_applies_to'] ?? '', ['first_order', 'all_orders'])
+ ? $settings['referee_reward_applies_to']
+ : 'first_order',
+ ];
+ }
+
+ /**
+ * Render the admin settings page
+ */
+ public function renderAdminPage(): void
+ {
+ // Handle form submission
+ if (isset($_POST['submit']) && check_admin_referer(BASE . 'referral_settings_nonce')) {
+ update_option(BASE . 'referral_page_id', absint($_POST[BASE . 'referral_page_id'] ?? 0));
+
+ $reward_settings = [
+ 'referrer_reward_applies_to' => sanitize_text_field($_POST['referrer_reward_applies_to'] ?? 'per_user'),
+ 'referrer_reward_amount' => floatval($_POST['referrer_reward_amount'] ?? 25.00),
+ 'referrer_reward_type' => sanitize_text_field($_POST['referrer_reward_type'] ?? 'fixed'),
+ 'referee_reward_type' => sanitize_text_field($_POST['referee_reward_type'] ?? 'percentage'),
+ 'referee_reward_amount' => floatval($_POST['referee_reward_amount'] ?? 20),
+ 'referee_reward_applies_to' => sanitize_text_field($_POST['referee_reward_applies_to'] ?? 'first_order'),
+ ];
+
+ update_option(BASE . 'referral_reward_settings', $this->sanitizeRewardSettings($reward_settings));
+
+ echo '<div class="notice notice-success is-dismissible"><p>Settings saved successfully.</p></div>';
+ }
+
+ $referral_page_id = $this->getReferralPageId();
+ $settings = $this->getRewardSettings();
+
+ echo $this->renderAdminHTML();
+ }
+
+ protected function renderAdminHTML():string
+ {
+ ob_start();
+ ?>
+ <div class="wrap">
+ <h1>Referral Settings</h1>
+
+ <form method="post" action="">
+ <?php wp_nonce_field(BASE . 'referral_settings_nonce'); ?>
+
+ <div class="card">
+ <h2>Referral Page</h2>
+ <p>Select the page where users can access their referral dashboard.</p>
+
+ <table class="form-table">
+ <tr>
+ <th scope="row">
+ <label for="<?= BASE ?>referral_page_id">Referral Page</label>
+ </th>
+ <td>
+ <?php
+ wp_dropdown_pages([
+ 'name' => BASE . 'referral_page_id',
+ 'id' => BASE . 'referral_page_id',
+ 'selected' => $referral_page_id,
+ 'show_option_none' => __('— Select —', 'jvbase'),
+ 'option_none_value' => '0'
+ ]);
+ ?>
+ <p class="description">
+ This page will show "Referral Page" in the admin bar when editing.
+ </p>
+ </td>
+ </tr>
+ </table>
+ </div>
+
+ <div class="card">
+ <h2>Reward Settings</h2>
+
+ <table class="form-table">
+ <tr>
+ <th colspan="2"><h3>Referrer Rewards</h3></th>
+ </tr>
+ <tr>
+ <th scope="row">
+ <label for="referrer_reward_type">Reward Type</label>
+ </th>
+ <td>
+ <select name="referrer_reward_type" id="referrer_reward_type">
+ <option value="fixed" <?php selected($settings['referrer_reward_type'], 'fixed'); ?>>Fixed Amount</option>
+ <option value="percentage" <?php selected($settings['referrer_reward_type'], 'percentage'); ?>>Percentage</option>
+ </select>
+ </td>
+ </tr>
+ <tr>
+ <th scope="row">
+ <label for="referrer_reward_amount">Reward Amount</label>
+ </th>
+ <td>
+ <input type="number"
+ name="referrer_reward_amount"
+ id="referrer_reward_amount"
+ value="<?= esc_attr($settings['referrer_reward_amount']) ?>"
+ step="0.01"
+ min="0">
+ <p class="description">Amount in dollars or percentage</p>
+ </td>
+ </tr>
+ <tr>
+ <th scope="row">
+ <label for="referrer_reward_applies_to">Applies To</label>
+ </th>
+ <td>
+ <select name="referrer_reward_applies_to" id="referrer_reward_applies_to">
+ <option value="per_user" <?php selected($settings['referrer_reward_applies_to'], 'per_user'); ?>>Per User Referred</option>
+ <option value="flat_total" <?php selected($settings['referrer_reward_applies_to'], 'flat_total'); ?>>Flat Total</option>
+ </select>
+ </td>
+ </tr>
+
+ <tr>
+ <th colspan="2"><h3>Referee (New User) Rewards</h3></th>
+ </tr>
+ <tr>
+ <th scope="row">
+ <label for="referee_reward_type">Reward Type</label>
+ </th>
+ <td>
+ <select name="referee_reward_type" id="referee_reward_type">
+ <option value="percentage" <?php selected($settings['referee_reward_type'], 'percentage'); ?>>Percentage</option>
+ <option value="fixed" <?php selected($settings['referee_reward_type'], 'fixed'); ?>>Fixed Amount</option>
+ </select>
+ </td>
+ </tr>
+ <tr>
+ <th scope="row">
+ <label for="referee_reward_amount">Reward Amount</label>
+ </th>
+ <td>
+ <input type="number"
+ name="referee_reward_amount"
+ id="referee_reward_amount"
+ value="<?= esc_attr($settings['referee_reward_amount']) ?>"
+ step="0.01"
+ min="0">
+ <p class="description">Amount in dollars or percentage</p>
+ </td>
+ </tr>
+ <tr>
+ <th scope="row">
+ <label for="referee_reward_applies_to">Applies To</label>
+ </th>
+ <td>
+ <select name="referee_reward_applies_to" id="referee_reward_applies_to">
+ <option value="first_order" <?php selected($settings['referee_reward_applies_to'], 'first_order'); ?>>First Order Only</option>
+ <option value="all_orders" <?php selected($settings['referee_reward_applies_to'], 'all_orders'); ?>>All Orders</option>
+ </select>
+ </td>
+ </tr>
+ </table>
+ </div>
+
+ <p class="submit">
+ <button type="submit" name="submit" class="button button-primary">Save Settings</button>
+ </p>
+ </form>
+
+ <?= $this->renderReferralStats(true) ?>
+ </div>
+ <?php
+ return ob_get_clean();
+ }
+
+ /**
+ * Render referral statistics
+ */
+ protected function renderReferralStats(bool $wrapCard = false):string
+ {
+ ob_start();
+ global $wpdb;
+
+ $total_referrals = $wpdb->get_var("SELECT COUNT(*) FROM {$this->referrals_table}");
+ $pending_referrals = $wpdb->get_var("SELECT COUNT(*) FROM {$this->referrals_table} WHERE status = 'pending'");
+ $treated_referrals = $wpdb->get_var("SELECT COUNT(*) FROM {$this->referrals_table} WHERE status = 'treated'");
+
+ ?>
+ <table class="widefat">
+ <tr>
+ <th>Total Referrals</th>
+ <td><?= esc_html($total_referrals) ?></td>
+ </tr>
+ <tr>
+ <th>Pending</th>
+ <td><?= esc_html($pending_referrals) ?></td>
+ </tr>
+ <tr>
+ <th>Treated</th>
+ <td><?= esc_html($treated_referrals) ?></td>
+ </tr>
+ </table>
+ <?php
+ $table = ob_get_clean();
+ if ($wrapCard) {
+ $table = '<div class="card">
+ <h2>Referral Statistics</h2>
+ '.$table.'
+ </div>';
+ }
+ return $table;
+ }
+
+ /**
+ * Add "Referral Page" label to admin bar
+ *
+ * @param WP_Admin_Bar $wp_admin_bar
+ */
+ public function addReferralPageLabel($wp_admin_bar): void
+ {
+ if (!is_admin()) {
+ return;
+ }
+
+ $referral_page_id = $this->getReferralPageId();
+
+ if (!$referral_page_id) {
+ return;
+ }
+
+ global $pagenow, $post;
+
+ // Check if we're editing the referral page
+ if ('post.php' === $pagenow && $post && $post->ID === $referral_page_id) {
+ $wp_admin_bar->add_node([
+ 'id' => 'referral-page',
+ 'parent' => 'top-secondary',
+ 'title' => __('Referral Page', 'jvbase'),
+ 'meta' => [
+ 'class' => 'referral-page-notice'
+ ]
+ ]);
+ }
+ }
+
+ /**
+ * Get the referral page ID
+ *
+ * @return int|null
+ */
+ public function getReferralPageId(): ?int
+ {
+ $page_id = get_option(BASE . 'referral_page_id');
+ return $page_id ? (int) $page_id : null;
+ }
+
+ /**
+ * Show admin notice on referral page edit screen
+ */
+ public function showReferralPageNotice(): void
+ {
+ global $pagenow, $post;
+
+ if ('post.php' !== $pagenow || !$post) {
+ return;
+ }
+
+ $referral_page_id = $this->getReferralPageId();
+
+ if ($post->ID === $referral_page_id) {
+ echo '<div class="notice notice-info">';
+ echo '<p>' . __('This page is designated as the <strong>Referral Page</strong>.', 'jvbase') . '</p>';
+ echo '</div>';
+ }
+ }
+
+ public function renderDashPage(string $content, string $page):string
+ {
+ if ($page !== 'referrals') {
+ return $content;
+ }
+ $out = '';
+ if (current_user_can('manage_options')) {
+ $out .= $this->renderAdminHTML();
+ } else {
+ $out .= $this->renderReferralStats(true);
+ }
+ return ($out === '') ? $content : '<form id="referrals" class="col" data-save="referrals">'.$out.'</form>';
+ }
}
diff --git a/inc/managers/RoleManager.php b/inc/managers/RoleManager.php
index b575a04..417c2ee 100644
--- a/inc/managers/RoleManager.php
+++ b/inc/managers/RoleManager.php
@@ -356,22 +356,29 @@
}
}
+ /**
+ * @param string $content
+ * @return array|string[]
+ * Note: must match what is created in PostTypeRegistrar.php::register
+ */
protected function getCapabilities(string $content):array
{
$content = jvbNoBase($content);
if (!$this->isValidContentType($content)) {
return [];
}
+
$plural = $this->getContentPlural($content);
+
return [
- 'edit_' . $plural,
- 'delete_' . $plural,
- 'read_' . $plural,
- 'edit_published_' . $plural,
- 'delete_published_' . $plural,
- 'edit_private_' . $plural,
- 'delete_private_' . $plural,
- 'publish_' . $plural,
+ "edit_{$content}",
+ "read_{$content}",
+ "delete_{$content}",
+ "edit_{$plural}",
+ "edit_others_{$plural}",
+ "publish_{$plural}",
+ "read_private_{$plural}",
+ "edit_{$plural}",
];
}
protected function getOthersCapabilities(string $content):array
@@ -382,23 +389,32 @@
}
$plural = $this->getContentPlural($content);
return [
- 'edit_others_' . $plural,
- 'delete_others_' . $plural,
- 'read_private_' . $plural,
- 'edit_private_' . $plural,
- 'delete_private_' . $plural,
+ "edit_others_{$plural}",
+ "delete_others_{$plural}",
+ "read_private_{$plural}",
+ "edit_private_{$plural}",
+ "delete_private_{$plural}",
];
}
- private function getContentPlural(string $content): string
+ public static function getPlural(string $content): string
+ {
+ $self = new self;
+ return $self->getContentPlural($content);
+ }
+ public function getContentPlural(string $content): string
{
$content = jvbNoBase($content);
-
- if (array_key_exists($content, JVB_CONTENT)) {
- return strtolower(JVB_CONTENT[$content]['plural'] ?? $content . 's');
+ $config = Features::getConfig($content);
+ $capsMap = $config['capability_type']??[];
+ if (empty($capsMap)){
+ $capsMap = [
+ $content,
+ str_replace('-', '_',sanitize_title(strtolower(JVB_CONTENT[$content]['plural'])))
+ ];
+ return $capsMap[1];
}
-
- return strtolower($content . 's');
+ return str_replace('-', '_', sanitize_title(strtolower($content . 's')));
}
public function activate(): void
diff --git a/inc/managers/TaxonomyRelationships.php b/inc/managers/TaxonomyRelationships.php
index c9cf044..9ce35f5 100644
--- a/inc/managers/TaxonomyRelationships.php
+++ b/inc/managers/TaxonomyRelationships.php
@@ -19,7 +19,7 @@
{
global $wpdb;
$this->table_name = $wpdb->prefix . BASE.'taxonomy_relationships';
- $this->cache = new CacheManager('term_relationship', 86400);
+ $this->cache = CacheManager::for('term_relationship', WEEK_IN_SECONDS);
// Ensure the table exists
// $this->create_table_if_not_exists();
@@ -59,7 +59,7 @@
*/
public function updatePostRelationships(int $post_id):void
{
- $this->cache->invalidateGroup('term_relationships');
+ $this->cache->invalidate();
$post_type = get_post_type($post_id);
if (in_array($post_type, [BASE.'directory', BASE.'dash'])) {
return;
@@ -338,7 +338,7 @@
*/
public function rebuildAllRelationships():bool
{
- $this->cache->invalidateGroup('term_relationships');
+ $this->cache->invalidate();
global $wpdb;
// Clear existing relationships
@@ -370,7 +370,7 @@
]
);
- $this->cache->invalidateGroup('term_relationships');
+ $this->cache->invalidate();
return true;
}
@@ -448,6 +448,6 @@
$term_id,
$term_id
));
- $this->cache->invalidateGroup('term_relationships');
+ $this->cache->invalidate();
}
}
diff --git a/inc/managers/UmamiMetrics.php b/inc/managers/UmamiMetrics.php
index de259ae..5330133 100644
--- a/inc/managers/UmamiMetrics.php
+++ b/inc/managers/UmamiMetrics.php
@@ -37,7 +37,7 @@
$this->website_id = get_option('jvb_umami_website_id', UMAMI_WEBSITE_ID);
// Initialize cache manager
- $this->cache = new CacheManager('umami_metrics', DAY_IN_SECONDS);
+ $this->cache = CacheManager::for('umami_metrics', DAY_IN_SECONDS);
// Register hooks
add_action('jvb_daily_umami_collection', [$this, 'collectDailyData']);
@@ -330,7 +330,7 @@
);
// Clear cache for the processed date
- $this->cache->invalidate('metrics_' . $date);
+ $this->cache->invalidate();
} catch (Exception $e) {
$results['errors'][] = 'Exception during data collection: ' . $e->getMessage();
diff --git a/inc/managers/UploadManager.php b/inc/managers/UploadManager.php
index d1cd220..fdc3e2e 100644
--- a/inc/managers/UploadManager.php
+++ b/inc/managers/UploadManager.php
@@ -505,7 +505,7 @@
return apply_filters(
'jvb_upload_filename',
- "{$username}-{$base_name}-{$timestamp}",
+ "{$base_name}-{$timestamp}",
$context
);
}
diff --git a/inc/managers/UserTermsManager.php b/inc/managers/UserTermsManager.php
index efde9cd..5eb29f2 100644
--- a/inc/managers/UserTermsManager.php
+++ b/inc/managers/UserTermsManager.php
@@ -23,8 +23,6 @@
global $wpdb;
$this->wpdb = $wpdb;
$this->table_name = $this->wpdb->prefix . BASE . 'user_term_index';
- // Get cache manager instance
- $this->cache = new CacheManager($this->cacheGroup, $this->ttl);
// Register hooks
add_action('save_post', [$this, 'updatePostUserTerms'], 10, 3);
@@ -43,14 +41,11 @@
*/
public function clearUserCache(int $user_id, string|null $taxonomy = null):void
{
+ $cache = CacheManager::for($user_id.'_term_relationships');
if ($taxonomy) {
- // Clear specific taxonomy cache
- $pattern = "user_{$user_id}_" . str_replace(BASE, '', $taxonomy);
- $this->cache->clearPattern($pattern);
+ $cache->delete(jvbNoBase($taxonomy));
} else {
- // Clear all user term caches
- $pattern = "user_{$user_id}_";
- $this->cache->clearPattern($pattern);
+ $cache->invalidate();
}
}
@@ -592,7 +587,8 @@
private function fetchUserTerms(int $user_id, string $taxonomy, array $args):array
{
$taxonomy = jvbCheckBase($taxonomy);
- $key = $this->cache->generateKey(array_merge(
+ $cache = CacheManager::for($user_id.'_term_relationships');
+ $key = $cache->generateKey(array_merge(
[
'user' => $user_id,
'taxonomy' => $taxonomy,
@@ -600,9 +596,9 @@
$args
));
if (!$args['skip_cache']) {
- $cache = $this->cache->get($key);
- if ($cache) {
- return $cache;
+ $cached = $cache->get($key);
+ if ($cached) {
+ return $cached;
}
}
@@ -654,7 +650,7 @@
$this->wpdb->prepare($query, $query_args),
ARRAY_A
);
- $this->cache->set($key, $results);
+ $cache->set($key, $results);
return $results;
}
diff --git a/inc/managers/_setup.php b/inc/managers/_setup.php
index 127238d..f128055 100644
--- a/inc/managers/_setup.php
+++ b/inc/managers/_setup.php
@@ -6,11 +6,12 @@
require(JVB_DIR . '/inc/managers/ErrorHandler.php');
require(JVB_DIR . '/inc/managers/OperationQueue.php');
require(JVB_DIR . '/inc/managers/EmailManager.php');
-require(JVB_DIR . '/inc/managers/LoginManager.php');
if (Features::forSite()->has('magicLink')) {
require(JVB_DIR . '/inc/managers/MagicLinkManager.php');
}
+require(JVB_DIR . '/inc/managers/AjaxRateLimiter.php');
+require(JVB_DIR . '/inc/managers/LoginManager.php');
//IF SITE HAS DASHBOARD AND FEED BLOCK
diff --git a/inc/meta/MetaForm.php b/inc/meta/MetaForm.php
index 91d9980..cf1e401 100644
--- a/inc/meta/MetaForm.php
+++ b/inc/meta/MetaForm.php
@@ -15,6 +15,7 @@
class MetaForm
{
protected int $max_file_size = 5242880;
+ protected ?MetaTypeManager $type_manager = null;
/* ========== MAIN RENDER METHOD ========== */
public function return(string $name, mixed $value, array $config, bool $showHidden = false)
@@ -24,7 +25,6 @@
public function render(string $name, mixed $value, array $config, bool $showHidden = false, bool $return = false): mixed
{
$out = '';
-
if (jvbCheck('hidden', $config) && !$showHidden) {
return $out;
}
@@ -32,6 +32,9 @@
if (!array_key_exists('type', $config)) {
return $out;
}
+ if (!$value) {
+ $value = $this->getDefaultValue($config['type']);
+ }
// Handle hidden display type
if (array_key_exists('display', $config) && $config['display'] === 'hidden') {
@@ -71,6 +74,18 @@
return $out;
}
+ public function getDefaultValue(string $type):mixed {
+ if (!$this->type_manager) {
+ $this->type_manager = new MetaTypeManager();
+ }
+ return match ($this->type_manager->getMetaType($type)) {
+ 'object', 'array' => [],
+ 'boolean' => false,
+ 'integer' => 0,
+ default => '',
+ };
+ }
+
/* ========== HELPER METHODS ========== */
/**
@@ -164,6 +179,7 @@
if (!empty($field['validation_message'])) {
$attrs['data-validation-message'] = $field['validation_message'];
}
+
$attrs['data-type'] = $field['type'];
$attrString = '';
@@ -217,44 +233,6 @@
<?php
}
- protected function renderComplexFieldWrapper(string $name, array $field, callable $renderContent): void
- {
- $data = $this->prepareFieldData($name, $field['value'] ?? '', $field);
- $validationAttrs = $this->buildValidationAttributes($field);
- $conditional = array_key_exists('condition', $field) ? $this->handleConditionalField($field) : '';
- $describedBy = (!empty($field['description'])) ? ' aria-describedby="' . $name . '-help"' : '';
-
- // Additional data attributes for complex fields
- $dataAttrs = '';
- if (array_key_exists('data', $field) && !empty($field['data'])) {
- foreach ($field['data'] as $key => $val) {
- $dataAttrs .= ($val === '') ? ' data-' . $key : ' data-' . $key . '="' . esc_attr($val) . '"';
- }
- }
-
- ?>
- <div class="field <?= esc_attr($field['type']) ?> <?= esc_attr($name) ?>"
- <?= $conditional ?>
- data-field="<?= esc_attr($name) ?>"
- <?= $validationAttrs ?>
- <?= $dataAttrs ?>
- <?= $describedBy ?>>
-
- <?php if (!empty($field['label']) && (!isset($field['show_label']) || $field['show_label'])) : ?>
- <h3 class="field-label"><?= esc_html($field['label']) ?></h3>
- <?php endif; ?>
-
- <?php $this->renderHintAndDescription($field, $name); ?>
-
- <div class="field-content">
- <?php $renderContent($name, $data, $field); ?>
- </div>
-
- <span class="validation-message" hidden role="alert"></span>
- </div>
- <?php
- }
-
/**
* Render field label with optional character count
*/
@@ -280,20 +258,36 @@
*/
protected function renderHintAndDescription(array $field, string $name): void
{
- if (array_key_exists('hint', $field)) {
+ if (!empty($field['hint'])) {
$this->renderHint($field['hint']);
}
- if (array_key_exists('description', $field)) {
+ if (!empty($field['description'])) {
$this->renderDescription($field['description'], $name);
}
}
+ protected function renderHint(string $hint): void
+ {
+ ?>
+ <span class="hint"><?= esc_html($hint) ?></span>
+ <?php
+ }
+
+ protected function renderDescription(string $description, string $name): void
+ {
+ ?>
+ <p class="description" id="<?= esc_attr($name) ?>-help">
+ <?= wp_kses_post($description) ?>
+ </p>
+ <?php
+ }
+
/* ========== SIMPLE INPUT FIELD TYPES ========== */
public function renderTextField(string $name, mixed $value, array $field): void
{
- $this->renderStandardInput($name, $value, $field, $field['input_type'] ?? 'text');
+ $this->renderStandardInput($name, $value, $field, $field['subtype'] ?? 'text');
}
public function renderEmailField(string $name, mixed $value, array $field): void
@@ -524,15 +518,16 @@
</legend>
<?php foreach ($field['options'] as $key => $label) : ?>
- <label class="radio-option">
- <input
- type="radio"
- name="<?= esc_attr($data['name']) ?>"
- value="<?= esc_attr($key) ?>"
- <?php checked($value, $key); ?>
- <?= !empty($field['required']) ? 'required' : '' ?>
- >
- <span><?= esc_html($label) ?></span>
+ <input
+ type="radio"
+ id="<?= esc_attr($data['name']) ?>-<?= esc_attr($key)?>"
+ name="<?= esc_attr($data['name']) ?>"
+ value="<?= esc_attr($key) ?>"
+ <?php checked($value, $key); ?>
+ <?= !empty($field['required']) ? 'required' : '' ?>
+ >
+ <label class="radio-option" for="<?= esc_attr($data['name']) ?>-<?= esc_attr($key)?>">
+ <span><?= $label ?></span>
</label>
<?php endforeach; ?>
</fieldset>
@@ -627,20 +622,33 @@
/* ========== REPEATER FIELD ========== */
- private function renderRepeaterField(string $name, mixed $value, array $field): void
+ private function renderRepeaterField(string $name, mixed $value, array $field):void
{
+ error_log('Rendering Repeater Field!');
+ $values = is_array($value) ? $value : array();
+
+ $conditional = $this->handleConditionalField($field);
+ $row_label = isset($field['row_label']) ? $field['row_label'] : '';
+ $rowTitle = (array_key_exists('new_row', $field)) ? $field['new_row'] : 'New Item';
if (array_key_exists('group', $field)) {
- $name = $field['group'] . '::' . $name;
+ $name = $field['group'].'::'.$name;
}
-
- $this->renderComplexFieldWrapper($name, $field, function($name, $data, $field) use ($value) {
- $values = is_array($value) ? $value : [];
- $rowLabel = $field['row_label'] ?? '';
- $rowTitle = $field['new_row'] ?? 'New Item';
- $addLabel = $field['add_label'] ?? 'Add Item';
-
+ $describedBy = (!empty($field['description'])) ? ' aria-describedby="'.$name.'-help"' : '';
+ ?>
+ <div class="field repeater <?=$name?>"
+ data-field="<?= esc_attr($name); ?>"
+ <?= $describedBy ?>
+ <?= $row_label ? 'data-label="' . esc_attr($row_label) . '"' : ''; ?>
+ <?=$conditional?>>
+ <?php
+ if (!array_key_exists('label', $field)) {
+ error_log('No label for: '.print_r($name, true));
+ }
?>
- <div class="repeater-items" data-label="<?= esc_attr($rowLabel) ?>">
+ <h3><?= esc_html($field['label']); ?></h3>
+
+
+ <div class="repeater-items">
<?php
if (!empty($values)) {
foreach ($values as $index => $row) {
@@ -650,39 +658,45 @@
?>
</div>
- <template class="<?= uniqid('repeaterTemplate') ?>">
- <?php $this->renderRepeaterRow($field['fields'], [], '', $name, $rowTitle); ?>
+ <template class="<?=uniqid('repeaterTemplate')?>">
+ <?php $this->renderRepeaterRow($field['fields'], array(), '', '', $rowTitle); ?>
</template>
- <button type="button" class="add-repeater-row button secondary">
- <?= jvbIcon('plus', ['title' => 'Add']) ?>
- <span><?= esc_html($addLabel) ?></span>
+ <button type="button" class="add-repeater-row">
+ <?= jvbIcon('plus', ['title'=> 'Add']); ?> <?= (array_key_exists('add_label', $field)) ? $field['add_label'] : 'Add Item'; ?>
</button>
- <?php
- });
+ <?php if (array_key_exists('hint', $field)) { $this->renderHint($field['hint']); } ?>
+ <?php if (array_key_exists('description', $field)) { $this->renderDescription($field['description'], $name); } ?>
+ </div>
+ <?php
}
- private function renderRepeaterRow(array $fields, array $values, int|string $index, string $base_name, string $rowTitle): void
+ private function renderRepeaterRow(array $fields, array $values, int|string $index, string $base_name, string $rowTitle = 'New Item'):void
{
- $display_number = is_string($index) ? $index : ($index + 1);
+ $display_number = (is_string($index)) ? $index : ($index + 1);
?>
- <div class="repeater-row" data-index="<?= esc_attr($index) ?>">
- <details <?= is_string($index) ? 'open' : '' ?>>
+ <div class="repeater-row" data-index="<?= esc_attr($index); ?>">
+ <details <?= (is_string($index)) ? 'open' : ''; ?>>
<summary class="repeater-row-header row btw">
- <span class="drag-handle"><?= jvbIcon('grab') ?></span>
- <span class="row-number">#<?= esc_html($display_number) ?></span>
- <span class="row-title"><?= esc_html($this->getRowTitle($fields, $values, $rowTitle)) ?></span>
+ <span class="drag-handle"><?= jvbIcon('grab'); ?></span>
+ <span class="row-number">#<?= esc_html($display_number); ?></span>
+ <span class="row-title"><?= esc_html($this->getRowTitle($fields, $values, $rowTitle)); ?></span>
<button type="button" class="remove-row" title="Remove">
- <?= jvbIcon('delete', ['title' => 'Remove']) ?>
+ <?= jvbIcon('delete', ['title'=>'Remove']); ?>
</button>
</summary>
<div class="repeater-row-content">
<?php
- foreach ($fields as $slug => $field) {
- $field_name = ($base_name === '') ? $slug : sprintf('%s:%s:%s', $base_name, $index, $slug);
- $field_value = $values[$slug] ?? '';
- $this->render($field_name, $field_value, $field);
- }
+ foreach ($fields as $slug => $field) :
+ if ($base_name === '') {
+ $field_name = $slug;
+ } else {
+ $field_name = sprintf('%s:%s:%s', $base_name, $index, $slug);
+ }
+ $field_value = isset($values[$slug]) ? $values[$slug] : '';
+ $name = $field_name;
+ $this->render($name, $field_value, $field);
+ endforeach;
?>
</div>
</details>
@@ -776,22 +790,30 @@
}
/* ========== UPLOAD FIELD ========== */
-
+ private function renderGalleryField(string $name, mixed $value, array $field):void
+ {
+ $field['multiple'] = true;
+ $this->renderUploadField($name, $value, $field);
+ }
private function renderUploadField(string $name, mixed $value, array $field): void
{
- // Merge with defaults
- $config = array_merge([
- 'subtype' => 'image',
- 'accepted_types' => null,
- 'multiple' => false,
- 'limit' => 0,
- 'mode' => 'direct',
- 'destination' => 'meta',
- 'max_size' => null,
- 'convert' => 'webp',
- 'quality' => 80,
+ $defaultConfig = [
+ //File Type
+ 'subtype' => 'image', // 'image', 'video', 'document', 'any'
+ 'accepted_types' => null, // null = use subtype defaults, or array of specific MIME types
+ //Upload Behaviour
+ 'multiple' => false, // Single or multiple uploads
+ 'limit' => 0, // Max number of uploads (0 = unlimited)
+ 'mode' => 'direct', // 'direct' or 'selection'
+ //Destination
+ 'destination' => 'meta', // 'meta', 'post', 'post_group'
+ //Processing Options
+ 'max_size' => null, // Override default size limits
+ 'convert' => 'webp', // Image conversion format
+ 'quality' => 80, // Conversion quality
'create_thumbnails' => true,
- ], $field);
+ ];
+ $config = array_merge($defaultConfig, $field);
// Validate destination config
if (in_array($config['destination'], ['post', 'post_group']) && empty($config['content'])) {
@@ -799,69 +821,217 @@
return;
}
- if (array_key_exists('group', $field)) {
+ // Get accepted types
+ $acceptedTypes = $this->getAllowedTypes($config);
+
+ // Build accept attribute for input
+ $acceptExtensions = $this->getMimeExtensions($acceptedTypes);
+ $acceptAttr = implode(',', $acceptExtensions);
+
+ // Determine field attributes
+ $subtype = $config['subtype'] ?? 'image';
+ $multiple = $config['multiple'] ?? false;
+ $limit = $config['limit'] ?? 0;
+ $mode = $config['mode'] ?? 'direct';
+ $destination = $config['destination'];
+
+ // Get existing attachments
+ $attachmentIds = $this->parseAttachmentIds($value);
+
+ // Determine field type for UI
+ $fieldType = $multiple ? 'gallery' : 'single';
+
+ // Build data attributes
+ $dataAttrs = [
+ 'data-field' => $name,
+ 'data-upload-field' => '',
+ 'data-mode' => $mode,
+ 'data-type' => $fieldType,
+ 'data-subtype' => $subtype,
+ 'data-destination' => $destination,
+ ];
+ if (!empty($field['content'])) {
+ $dataAttrs['data-content'] = $field['content'];
+ }
+ if ($limit > 0) {
+ $dataAttrs['data-limit'] = $limit;
+ }
+
+ // Build data attributes
+ $conditional = $this->handleConditionalField($field);
+ $describedBy = !empty($field['description']) ? ' aria-describedby="' . esc_attr($name) . '-help"' : '';
+
+ if (!empty($field['group'])) {
$name = $field['group'] . '::' . $name;
}
- // Prepare upload configuration
- $acceptedTypes = $this->getAllowedTypes($config);
- $acceptExtensions = $this->getMimeExtensions($acceptedTypes);
- $acceptAttr = implode(',', $acceptExtensions);
- $attachmentIds = $this->parseAttachmentIds($value);
- $fieldType = $config['multiple'] ? 'gallery' : 'image';
-
- // Build data attributes for uploader.js
- $uploadData = [
- 'data-subtype' => $config['subtype'],
- 'data-mode' => $config['mode'],
- 'data-destination' => $config['destination'],
- 'data-multiple' => $config['multiple'] ? 'true' : 'false',
- 'data-limit' => $config['limit'],
- 'data-convert' => $config['convert'],
- 'data-quality' => $config['quality'],
- ];
-
- if (!empty($config['content'])) {
- $uploadData['data-content'] = $config['content'];
+ // Convert data attributes to string
+ $dataAttrString = '';
+ foreach ($dataAttrs as $attr => $val) {
+ $dataAttrString .= ' ' . $attr . ($val !== '' ? '="' . esc_attr($val) . '"' : '');
}
+ ?>
+ <div class="field upload <?= esc_attr($name) ?>"
+ <?= $dataAttrString ?>
+ <?= $conditional ?>>
- $this->renderComplexFieldWrapper($name, $field, function($name, $data, $field) use (
- $config, $acceptAttr, $attachmentIds, $fieldType, $uploadData, $value
- ) {
- ?>
- <div class="upload-field-wrapper <?= esc_attr($fieldType) ?>"
- <?php foreach ($uploadData as $attr => $val) : ?>
- <?= $attr ?>="<?= esc_attr($val) ?>"
- <?php endforeach; ?>>
-
- <!-- Preview Area -->
- <div class="upload-preview-area">
- <?php $this->renderUploadPreviews($attachmentIds, $config); ?>
- </div>
-
- <!-- Upload Area -->
<div class="file-upload-container">
<div class="file-upload-wrapper">
<input type="file"
- name="<?= esc_attr($data['name']) ?>_temp"
+ name="<?= !empty($field['base']) ? esc_attr($field['base']) : '' ?><?= esc_attr($name) ?>_temp"
+ id="<?= !empty($field['base']) ? esc_attr($field['base']) : '' ?><?= esc_attr($name) ?>_temp"
accept="<?= esc_attr($acceptAttr) ?>"
- <?= $config['multiple'] ? 'multiple' : '' ?>>
+ data-max-size="<?= esc_attr($this->getMaxFileSize($subtype)) ?>"
+ <?= $multiple ? 'multiple' : '' ?>
+ <?= !empty($field['required']) ? 'required' : '' ?>>
+
+ <h2><?= esc_html($field['label']) ?></h2>
+
+ <?php if (!empty($field['description'])) : ?>
+ <p><?= esc_html($field['description']) ?></p>
+ <?php endif; ?>
+
<p class="file-upload-text">
<strong>Click to upload</strong> or drag and drop<br>
- <?= esc_html($this->getUploadInstructions($config)) ?>
+ <?= esc_html($this->getAcceptedTypesLabel($subtype, $acceptExtensions)) ?>
+ (max. <?= esc_html($this->formatFileSize($this->getMaxFileSize($subtype))) ?>)
</p>
+
+ <?php if ($destination === 'post_group') {
+ $plural = (array_key_exists($field['content'], JVB_CONTENT)) ? JVB_CONTENT[$field['content']]['plural'] : (array_key_exists($field['content'], JVB_TAXONOMY) ? JVB_TAXONOMY[$field['content']]['plural'] : str_replace('_', ' ',$field['content']).'s');
+ $singular = (array_key_exists($field['content'], JVB_CONTENT)) ? JVB_CONTENT[$field['content']]['singular'] : (array_key_exists($field['content'], JVB_TAXONOMY) ? JVB_TAXONOMY[$field['content']]['singular'] : str_replace('_', ' ',$field['content']));
+ ?>
+ <p class="hint">You can group images to create separate <?= $plural ?>.</p>
+ <p class="hint">If a <?=$singular?> has multiple images, you can select the <?= jvbIcon('star')?> to set an image as the main one.</p>
+ <?php }
+ if (!empty($field['upload_description'])) : ?>
+ <p><?= esc_html($field['upload_description']); ?></p>
+ <?php endif; ?>
+ <div class="file-error"></div>
</div>
- <div class="file-error"></div>
</div>
- <!-- Hidden input for storing the IDs -->
- <input type="hidden"
- name="<?= esc_attr($data['name']) ?>"
- value="<?= esc_attr($value) ?>"
- <?= !empty($field['required']) ? 'required' : '' ?>>
+
+ <?php if ($destination === 'post_group') : ?>
+ <div class="group-display flex col" hidden>
+ <div class="preview-wrap flex col">
+ <div class="preview-actions">
+ <div class="selection-controls">
+ <div class="selected">
+ <div class="field">
+ <input type="checkbox" id="select-all-uploads" name="select-all-uploads">
+ <label for="select-all-uploads">
+ Select All
+ </label>
+ </div>
+ <div class="info" hidden>
+
+ </div>
+ </div>
+
+ <div class="selection-actions row btw" hidden>
+ <button type="button" data-action="add-to-group">
+ <?= jvbIcon('add') ?>
+ Group
+ </button>
+ <button type="button" data-action="delete-upload">
+ <?= jvbIcon('delete') ?>
+ Delete
+ </button>
+ </div>
+ </div>
+
+ <button type="button" data-action="upload" class="submit-uploads">
+ <?= jvbIcon('upload') ?> Upload <?= esc_html($plural ?? 'Content'); ?>
+ </button>
+ </div>
+ <?php endif; ?>
+
+ <?php jvbRenderProgressBar('<span class="text">Processing files...</span>
+ <span class="count">0/0</span>'); ?>
+ <div class="item-grid preview">
+ <?php
+ // Render existing attachments
+ foreach ($attachmentIds as $attachmentId) {
+ echo $this->renderExistingAttachment($attachmentId, $subtype);
+ }
+ ?>
+ </div>
+
+ <?php if ($destination === 'post_group') : ?>
+ <p class="hint"><?= jvbIcon('elbow-left-up') ?> These will become individual <?= $plural ?> <?= jvbIcon('elbow-right-up')?></p>
+ </div>
+ <div class="sidebar flex col">
+ <div class="header">
+ <h4>New <?= $plural?></h4>
+ <p class="hint">Drag or select multiple images into groups to create separate <?= $plural ?>.</p>
+ </div>
+ <div class="item-grid groups">
+ <div class="empty-group">
+ <p>Drag here to create a new <?= $singular ?>!</p>
+ </div>
+ </div>
+ <p class="hint"><?= jvbIcon('elbow-left-up') ?> Each group will become its own <?= $singular ?> <?= jvbIcon('elbow-right-up')?></p>
+ </div>
</div>
- <?php
- });
+ <?php endif; ?>
+
+ <?php if ($destination === 'meta') : ?>
+ <input type="hidden"
+ name="<?= array_key_exists('base', $field) ? esc_attr($field['base']) : ''?><?= esc_attr($name); ?>"
+ value="<?= esc_attr($value); ?>"
+ <?= !empty($field['required']) ? 'required' : ''; ?>>
+ <?php endif; ?>
+ </div>
+ <?php
+ }
+
+ /**
+ * Get max file size for subtype
+ */
+ private function getMaxFileSize(string $subtype): int
+ {
+ $sizes = [
+ 'image' => 5242880, // 5MB
+ 'video' => 104857600, // 100MB
+ 'document' => 10485760 // 10MB
+ ];
+
+ return $sizes[$subtype] ?? $sizes['image'];
+ }
+
+ /**
+ * Format file size for display
+ */
+ private function formatFileSize(int $bytes): string
+ {
+ if ($bytes >= 1073741824) {
+ return number_format($bytes / 1073741824, 1) . 'GB';
+ }
+ if ($bytes >= 1048576) {
+ return number_format($bytes / 1048576, 1) . 'MB';
+ }
+ if ($bytes >= 1024) {
+ return number_format($bytes / 1024, 1) . 'KB';
+ }
+ return $bytes . 'B';
+ }
+
+ /**
+ * Get accepted types label
+ */
+ private function getAcceptedTypesLabel(string $subtype, array $extensions): string
+ {
+ $labels = [
+ 'image' => 'JPG, PNG, GIF, or WEBP',
+ 'video' => 'MP4, WEBM, or MOV',
+ 'document' => 'PDF, DOC, XLS, or TXT',
+ 'any' => 'Images, Videos, or Documents'
+ ];
+
+ return $labels[$subtype] ?? strtoupper(implode(', ', array_map(function($ext) {
+ return ltrim($ext, '.');
+ }, array_slice($extensions, 0, 3))));
}
/**
@@ -874,20 +1044,241 @@
}
foreach ($attachmentIds as $id) {
- if ($config['subtype'] === 'image') {
- $url = wp_get_attachment_image_url($id, 'thumbnail');
- if ($url) {
- echo '<div class="upload-item" data-id="' . esc_attr($id) . '">';
- echo '<img src="' . esc_url($url) . '" alt="">';
- echo '<button type="button" class="remove-preview">' .
- jvbIcon('trash', ['title' => 'Remove']) . '</button>';
- echo '</div>';
- }
+ switch ($config['subtype']) {
+ case 'image':
+ $this->renderImagePreview($id, $config);
+ break;
+ case 'video':
+ $this->renderVideoPreview($id, $config);
+ break;
+ case 'file':
+ $this->renderFilePreview($id, $config);
+ break;
}
- // Add other subtypes (video, document) as needed
}
}
+ public function renderImagePreview(?int $id = null, array $config = []):void
+ {
+ $attachment = ($id) ? wp_get_attachment_image($id, 'thumbnail', false) : false;
+ $caption = ($id) ? wp_get_attachment_caption($id) : '';
+ $alt = ($id) ? get_post_meta($id, '_wp_attachment_image_alt',true) : '';
+ $title = ($id) ? get_the_title($id) : '';
+ $addID = ($id) ? '-'.$id : '';
+ $dataID = ($id) ? ['id' => $id] : '';
+ ?>
+ <div class="item upload"<?= ($id) ? ' data-id="'.$id.'"' : '' ?>>
+ <div class="preview">
+ <?php jvbRenderProgressBar('',true) ?>
+ <input type="checkbox" class="upload-select" name="select-item" id="select-item<?=$addID?>">
+ <label for="select-item<?=$addID?>" aria-label="Select image">
+ <?= ($attachment) ? $attachment : '<img>
+ <video></video>
+ <span></span>' ?>
+ </label>
+ <div class="item-actions row btw">
+ <div class="radio-button">
+ <input type="radio" class="featured btn" name="featured" id="featured" hidden>
+ <label for="featured">
+ <?=jvbIcon('star')?>
+ <?=jvbIcon('star', ['style' => 'fill'])?>
+ <span class="screen-reader-text">Set as featured image</span>
+ </label>
+ </div>
+
+ <button type="button" data-action="delete-upload" title="Remove from Group">
+ <?=jvbIcon('delete')?>
+ </button>
+ </div>
+ </div>
+ <details>';
+ <summary class="row btw"><?=jvbIcon('edit')?><span>Edit Info</span></summary>
+
+ <?php
+
+ $fields = array_key_exists('fields', $config) ? $config['fields'] : [];
+ $fields = array_merge([
+ 'upload_data' => [
+ 'type' => 'group',
+ 'wrap' => 'details',
+ 'label' => 'Image Info',
+ 'hint' => 'These will be automatically generated if left blank.',
+ 'fields' => [
+ 'image-title'.$addID => [
+ 'type' => 'text',
+ 'label' => 'Image Title',
+ 'value' => $title,
+ 'data' => $dataID
+ ],
+ 'image-alt-text'.$addID => [
+ 'type' => 'text',
+ 'label' => 'Alt Text',
+ 'value' => $alt,
+ 'hint' => 'Alt text helps the visually impaired, as well as some benefits for SEO.',
+ 'data' => $dataID
+ ],
+ 'image-caption'.$addID => [
+ 'type' => 'textarea',
+ 'value' => $caption,
+ 'label' => 'Image Caption',
+ 'data' => $dataID
+ ]
+ ]
+ ]
+ ], $fields);
+
+ $this->render('upload_data', null, $fields);
+ ?>
+ </details>
+ </div>
+ <?php
+ }
+ public function renderVideoPreview(?int $id = null, array $config = []):void
+ {
+ $attachment = ($id) ? wp_get_attachment_image($id, 'thumbnail', true) : false;
+ $caption = ($id) ? wp_get_attachment_caption($id) : '';
+ $description = ($id) ? get_the_content($id) : '';
+ $title = ($id) ? get_the_title($id) : '';
+ $addID = ($id) ? '-'.$id : '';
+ $dataID = ($id) ? ['id' => $id] : '';
+ ?>
+ <div class="item upload"<?= ($id) ? ' data-id="'.$id.'"' : '' ?>>
+ <div class="preview">
+ <?php jvbRenderProgressBar('',true) ?>
+ <input type="checkbox" class="upload-select" name="select-item" id="select-item<?=$addID?>">
+ <label for="select-item<?=$addID?>" aria-label="Select image">
+ <?= ($attachment) ? $attachment : '<img>
+ <video></video>
+ <span></span>'; ?>
+ </label>
+ <div class="item-actions row btw">
+ <div class="radio-button">
+ <input type="radio" class="featured btn" name="featured" id="featured" hidden>
+ <label for="featured">
+ <?=jvbIcon('star')?>
+ <?=jvbIcon('star', ['style' => 'fill'])?>
+ <span class="screen-reader-text">Set as featured image</span>
+ </label>
+ </div>
+
+ <button type="button" data-action="delete-upload" title="Remove from Group">
+ <?=jvbIcon('delete')?>
+ </button>
+ </div>
+ </div>
+ <details>';
+ <summary class="row btw"><?=jvbIcon('edit')?><span>Edit Info</span></summary>
+
+ <?php
+ $fields = array_key_exists('fields', $config) ? $config['fields'] : [];
+ $fields = array_merge([
+ 'upload_data' => [
+ 'type' => 'group',
+ 'wrap' => 'details',
+ 'label' => 'Video Info',
+ 'hint' => 'These will be automatically generated if left blank.',
+ 'fields' => [
+ 'title' => [
+ 'type' => 'text',
+ 'label' => 'Video Title',
+ 'value' => $title,
+ 'data' => $dataID
+ ],
+ 'caption' => [
+ 'type' => 'textarea',
+ 'value' => $caption,
+ 'label' => 'Video Caption',
+ 'data' => $dataID
+ ],
+ 'description' => [
+ 'type' => 'textarea',
+ 'value' => $description,
+ 'label' => 'Video Description',
+ 'data' => $dataID
+ ]
+ ]
+ ]
+ ], $fields);
+ $this->render('upload_data', null, $fields);
+ ?>
+ </details>
+ </div>
+ <?php
+ }
+ public function renderFilePreview(?int $id = null, array $config = []):void
+ {
+
+ $attachment = ($id) ? wp_get_attachment_image($id, 'thumbnail', true) : false;
+ $caption = ($id) ? wp_get_attachment_caption($id) : '';
+ $description = ($id) ? get_the_content($id) : '';
+ $title = ($id) ? get_the_title($id) : '';
+ $addID = ($id) ? '-'.$id : '';
+ $dataID = ($id) ? ['id' => $id] : '';
+ ?>
+ <div class="item upload"<?= ($id) ? ' data-id="'.$id.'"' : '' ?>>
+ <div class="preview">
+ <?php jvbRenderProgressBar('',true) ?>
+ <input type="checkbox" class="upload-select" name="select-item" id="select-item<?=$addID?>">
+ <label for="select-item<?=$addID?>" aria-label="Select image">
+ <?= ($attachment) ? $attachment : '<img>
+ <video></video>
+ <span></span>'; ?>
+ </label>
+ <div class="item-actions row btw">
+ <div class="radio-button">
+ <input type="radio" class="featured btn" name="featured" id="featured" hidden>
+ <label for="featured">
+ <?=jvbIcon('star')?>
+ <?=jvbIcon('star', ['style' => 'fill'])?>
+ <span class="screen-reader-text">Set as featured image</span>
+ </label>
+ </div>
+
+ <button type="button" data-action="delete-upload" title="Remove from Group">
+ <?=jvbIcon('delete')?>
+ </button>
+ </div>
+ </div>
+ <details>';
+ <summary class="row btw"><?=jvbIcon('edit')?><span>Edit Info</span></summary>
+
+ <?php
+ $fields = array_key_exists('fields', $config) ? $config['fields'] : [];
+ $fields = array_merge([
+ 'upload_data' => [
+ 'type' => 'group',
+ 'wrap' => 'details',
+ 'label' => 'File Info',
+ 'hint' => 'These will be automatically generated if left blank.',
+ 'fields' => [
+ 'title' => [
+ 'type' => 'text',
+ 'label' => 'File Title',
+ 'value' => $title,
+ 'data' => $dataID
+ ],
+ 'caption' => [
+ 'type' => 'textarea',
+ 'value' => $caption,
+ 'label' => 'File Caption',
+ 'data' => $dataID
+ ],
+ 'description' => [
+ 'type' => 'textarea',
+ 'value' => $description,
+ 'label' => 'File Description',
+ 'data' => $dataID
+ ]
+ ]
+ ]
+ ], $fields);
+ $this->render('upload_data', null, $fields);
+ ?>
+ </details>
+ </div>
+ <?php
+ }
+
/**
* Get upload instruction text based on config
*/
@@ -903,7 +1294,7 @@
/* ========== TAXONOMY/USER SELECTOR FIELDS ========== */
- private function renderTaxonomyField(string $name, mixed $value, array $field): void
+ private function renderTaxonomyField(string $name, string $value, array $field): void
{
if (array_key_exists('group', $field)) {
$name = $field['group'] . '::' . $name;
@@ -912,7 +1303,7 @@
$this->renderSelectorField($name, $value, $field, 'taxonomy');
}
- private function renderUserField(string $name, mixed $value, array $field): void
+ private function renderUserField(string $name, string $value, array $field): void
{
if (array_key_exists('group', $field)) {
$name = $field['group'] . '::' . $name;
@@ -931,55 +1322,64 @@
$validationAttrs = $this->buildValidationAttributes($field);
$describedBy = (!empty($field['description'])) ? ' aria-describedby="' . $name . '-help"' : '';
+ $isSimple = (array_key_exists('mode', $field) && $field['mode']==='simple');
// Parse selected values
+ $value = (is_array($value)) ? array_filter(array_map('absint', $value)): $value;
$selected = ($value === '') ? [] : (is_array($value) ? $value : explode(',', $value));
- // Create selector instance
+ // Generate unique container ID
+ $containerId = $name . '-' . $type . '-selector';
+
+ // Create selector instance with proper parameters
if ($type === 'taxonomy') {
$taxonomy = $field['taxonomy'];
+ $icon = JVB_TAXONOMY[$taxonomy]['icon']??'';
+
+ // Map field config to selector config
$selectorConfig = [
- 'multiple' => $field['multiple'] ?? true,
- 'placeholder' => $field['placeholder'] ?? 'Search terms...',
- 'noResults' => 'No terms found',
- 'onClose' => 'updateMetaFormTaxonomy'
+ 'max' => $field['max'] ?? 0, // 0 = unlimited
+ 'search' => $field['search'] ?? true,
+ 'label' => $field['label'] ?? '',
+ 'createNew' => $field['createNew'] ?? false,
+ 'required' => $field['required'] ?? false,
+ 'base' => $field['base'] ?? '',
+ 'update' => $field['update'] ?? true,
+ 'name' => $name,
+ 'autocomplete' => $field['autocomplete'] ?? false,
];
- $selector = new TaxonomySelector($taxonomy, $selectorConfig);
+ if ($icon !== '') {
+ $selectorConfig['icon'] = $icon;
+ }
+
+ $selector = new TaxonomySelector($containerId, $taxonomy, $selectorConfig);
$icon = $taxonomy;
} else {
$postType = $field['post_type'];
+
+ // Map field config to selector config
$selectorConfig = [
- 'multiple' => $field['multiple'] ?? true,
- 'placeholder' => $field['placeholder'] ?? 'Search posts...',
- 'noResults' => 'No posts found',
- 'shop_id' => $field['shop_id'] ?? null,
- 'onClose' => 'updateMetaFormPost'
+ 'max' => $field['max'] ?? 0,
+ 'search' => $field['search'] ?? true,
+ 'label' => $field['label'] ?? '',
+ 'required' => $field['required'] ?? false,
+ 'base' => $field['base'] ?? '',
+ 'update' => $field['update'] ?? true,
+ 'shop_id' => $field['shop_id'] ?? null,
+ 'autocomplete'=> $field['autocomplete'] ?? true,
];
- $selector = new PostSelector($postType, $selectorConfig);
+
+ $selector = new PostSelector($containerId, $postType, $selectorConfig);
$icon = $postType;
}
- $containerId = $name . '-' . $type . '-selector';
-
?>
- <div class="field <?= esc_attr($type) ?>-selector <?= esc_attr($name) ?>"
+ <div class="field <?= esc_attr($type) ?> <?= esc_attr($name) ?>"
<?= $conditional ?>
data-field="<?= esc_attr($name) ?>"
<?= $validationAttrs ?>
<?= $describedBy ?>>
- <div class="field-group-header row btw">
- <label class="toggle row">
- <?= jvbIcon($icon) ?>
- <span><?= esc_html($field['label'] ?? ucfirst($type)) ?></span>
- </label>
- <button type="button"
- class="add-item-btn button secondary"
- title="Add <?= esc_attr(ucfirst($type)) ?>">
- <?= jvbIcon('add', ['title' => 'Add ' . ucfirst($type)]) ?>
- </button>
- </div>
-
- <?= $selector->render($selected, $containerId) ?>
+ <?= $selector->render($selected) ?>
<!-- Hidden input for form submission -->
<input type="hidden"
@@ -1006,7 +1406,7 @@
return;
}
- // Parse stored data
+ // Extract stored values
if (is_string($value)) {
$value = maybe_unserialize($value);
}
@@ -1015,15 +1415,18 @@
$address = $stored_data['address'] ?? '';
$lat = $stored_data['lat'] ?? '';
$lng = $stored_data['lng'] ?? '';
- $street = $stored_data['street'] ?? '';
+ // Generate unique field ID
+ $field_id = esc_attr($name);
+ $map_id = $field_id . '_map';
+
+ // Handle grouped fields
if (array_key_exists('group', $field)) {
$name = $field['group'] . '::' . $name;
}
+ $describedBy = (!empty($field['description'])) ? ' aria-describedby="'.$name.'-help"' : '';
- // Prepare JavaScript configuration
- $field_id = esc_attr($name);
- $map_id = $field_id . '_map';
+ // Prepare configuration for JavaScript initialization
$js_config = [
'fieldId' => $field_id,
'initialCoords' => (!empty($lat) && !empty($lng)) ? [
@@ -1032,42 +1435,67 @@
] : null
];
- $this->renderComplexFieldWrapper($name, $field, function($name, $data, $field) use (
- $stored_data, $street, $address, $lat, $lng, $map_id, $js_config
- ) {
- ?>
- <div class="location-field-wrapper"
- data-location-field-init="<?= esc_attr(json_encode($js_config)) ?>">
+ // IMPORTANT: Properly escape the JSON for use in HTML attribute
+ $json_config = htmlspecialchars(json_encode($js_config), ENT_QUOTES, 'UTF-8');
+ ?>
- <?php if (!empty($street)) : ?>
- <p class="current-location">
- <strong>Current location:</strong> <?= esc_html($street) ?>
- </p>
- <?php endif; ?>
+ <div class="field location <?= esc_attr($field_id) ?>"
+ data-field="<?= esc_attr($field_id) ?>"
+ data-location-field-init="<?= $json_config ?>"<?=$describedBy?>>
- <label for="<?= esc_attr($data['id']) ?>">Address</label>
- <input type="text"
- id="<?= esc_attr($data['id']) ?>"
- name="<?= esc_attr($data['name']) ?>[address]"
- value="<?= esc_attr($address) ?>"
- placeholder="Enter an address"
- class="location-search-input"
- autocomplete="off"
- <?= !empty($field['required']) ? 'required' : '' ?>>
-
- <div id="<?= esc_attr($map_id) ?>" class="location-map" style="height: 300px;"></div>
-
- <!-- Hidden fields for lat/lng -->
- <input type="hidden" name="<?= esc_attr($data['name']) ?>[lat]" value="<?= esc_attr($lat) ?>" class="location-lat">
- <input type="hidden" name="<?= esc_attr($data['name']) ?>[lng]" value="<?= esc_attr($lng) ?>" class="location-lng">
- <input type="hidden" name="<?= esc_attr($data['name']) ?>[street]" value="<?= esc_attr($stored_data['street'] ?? '') ?>" class="location-street">
- <input type="hidden" name="<?= esc_attr($data['name']) ?>[city]" value="<?= esc_attr($stored_data['city'] ?? '') ?>" class="location-city">
- <input type="hidden" name="<?= esc_attr($data['name']) ?>[province]" value="<?= esc_attr($stored_data['province'] ?? '') ?>" class="location-province">
- <input type="hidden" name="<?= esc_attr($data['name']) ?>[postal]" value="<?= esc_attr($stored_data['postal'] ?? '') ?>" class="location-postal">
- <input type="hidden" name="<?= esc_attr($data['name']) ?>[country]" value="<?= esc_attr($stored_data['country'] ?? '') ?>" class="location-country">
- </div>
<?php
- });
+ if (!empty($stored_data['street'])) {
+ echo '<p><b>Current location:</b> '.esc_html($stored_data['street']).'</p>';
+ echo '<p class="hint"><b>Search below to change:</b></p>';
+ }
+ ?>
+ <?php if (array_key_exists('hint', $field)) { $this->renderHint($field['hint']); } ?>
+ <?php if (array_key_exists('description', $field)) { $this->renderDescription($field['description'], $name); } ?>
+
+ <div class="location-search-wrapper">
+ <div class="autocomplete-wrapper"></div>
+
+ <!-- Map container -->
+ <div class="location-preview">
+ <div id="<?= esc_attr($map_id); ?>"
+ class="location-map">
+ </div>
+
+ <?php if (!empty($stored_data)):
+ jvbLocationLinks($stored_data);
+ endif; ?>
+ </div>
+
+ <!-- Hidden inputs for data storage -->
+ <input type="hidden"
+ name="<?= array_key_exists('base', $field) ? esc_attr($field['base']) : ''?><?= esc_attr($name); ?>[address]"
+ value="<?= esc_attr($address); ?>"
+ data-location-field="address">
+
+ <input type="hidden"
+ name="<?= array_key_exists('base', $field) ? esc_attr($field['base']) : ''?><?= esc_attr($name); ?>[lat]"
+ value="<?= esc_attr($lat); ?>"
+ data-location-field="lat">
+
+ <input type="hidden"
+ name="<?= array_key_exists('base', $field) ? esc_attr($field['base']) : ''?><?= esc_attr($name); ?>[lng]"
+ value="<?= esc_attr($lng); ?>"
+ data-location-field="lng">
+
+ <?php
+ // Component fields
+ $components = ['street', 'city', 'province', 'postal_code', 'country'];
+ foreach ($components as $component):
+ ?>
+ <input type="hidden"
+ name="<?= array_key_exists('base', $field) ? esc_attr($field['base']) : ''?><?= esc_attr($name); ?>[<?= $component; ?>]"
+ value="<?= esc_attr($stored_data[$component] ?? ''); ?>"
+ data-location-field="<?= esc_attr($component); ?>">
+ <?php endforeach; ?>
+
+ </div>
+ </div>
+ <?php
}
/* ========== HTML FIELD ========== */
@@ -1109,22 +1537,6 @@
);
}
- protected function renderHint(string $hint): void
- {
- ?>
- <span class="hint"><?= esc_html($hint) ?></span>
- <?php
- }
-
- protected function renderDescription(string $description, string $name): void
- {
- ?>
- <p class="description" id="<?= esc_attr($name) ?>-help">
- <?= wp_kses_post($description) ?>
- </p>
- <?php
- }
-
protected function getAllowedTypes(array $config): array
{
if (!empty($config['accepted_types'])) {
diff --git a/inc/meta/MetaManager.php b/inc/meta/MetaManager.php
index e4644f2..380ac9a 100644
--- a/inc/meta/MetaManager.php
+++ b/inc/meta/MetaManager.php
@@ -506,6 +506,7 @@
if (!empty($this->fields)) {
return $this->fields;
}
+ $type = false;
switch ($this->object_type) {
case 'post':
$type = get_post_type((int)$this->object_id);
@@ -519,6 +520,9 @@
case 'options':
return jvbGetFields('options');
}
+ if (!$type) {
+ return [];
+ }
return jvbGetFields($type, $this->object_type);
}
@@ -546,6 +550,7 @@
protected function getSections():array
{
+ $type = false;
switch ($this->object_type) {
case 'post':
$type = get_post_type((int)$this->object_id);
diff --git a/inc/registry/CheckCustomTables.php b/inc/registry/CheckCustomTables.php
index 57b4795..ed5166a 100644
--- a/inc/registry/CheckCustomTables.php
+++ b/inc/registry/CheckCustomTables.php
@@ -19,6 +19,11 @@
protected array $JVB_TAXONOMY;
protected array $JVB_USER;
+ protected string $userTable;
+ protected string $userIDType;
+ protected string $termIDType;
+ protected string $postIDType;
+
public function __construct()
{
global $wpdb;
@@ -33,7 +38,65 @@
$this->JVB_CONTENT = apply_filters('jvb_content', []);
$this->JVB_TAXONOMY = apply_filters('jvb_taxonomy', []);
$this->JVB_USER = apply_filters('jvb_user', []);
- }
+
+ $this->userTable = (is_multisite()) ? $this->getMultisiteUsersTable() : $this->wpdb->users;
+
+ $this->userIDType = $this->getColumnType($this->userTable, 'ID');
+ $this->termIDType = $this->getColumnType($this->wpdb->terms, 'term_id');
+ $this->postIDType = $this->getColumnType($this->wpdb->posts, 'ID');
+ error_log("JVB FK Types: users.ID={$this->userIDType}, terms.term_id={$this->termIDType}, posts.ID={$this->postIDType}");
+ }
+
+ protected function getMultisiteUsersTable():string
+ {
+ $siteUsersTable = $this->wpdb->prefix . 'users';
+ $siteExists = $this->wpdb->get_var(
+ $this->wpdb->prepare("SHOW TABLES LIKE %s", $siteUsersTable)
+ );
+ if ($siteExists) {
+ return $siteUsersTable;
+ }
+ //fallback to main one
+ return $this->wpdb->users;
+ }
+
+ /**
+ * Get the exact column type from a WordPress core table
+ * This ensures foreign keys match the parent table exactly
+ */
+ protected function getColumnType(string $table, string $column): string
+ {
+ // First verify the table exists
+ $tableExists = $this->wpdb->get_var(
+ $this->wpdb->prepare("SHOW TABLES LIKE %s", $table)
+ );
+
+ if (!$tableExists) {
+ error_log("JVB ERROR: Table {$table} does not exist!");
+ return 'bigint(20)'; // Fallback
+ }
+
+ $result = $this->wpdb->get_row(
+ $this->wpdb->prepare(
+ "SELECT COLUMN_TYPE
+ FROM INFORMATION_SCHEMA.COLUMNS
+ WHERE TABLE_SCHEMA = DATABASE()
+ AND TABLE_NAME = %s
+ AND COLUMN_NAME = %s",
+ $table,
+ $column
+ )
+ );
+
+ if ($result && isset($result->COLUMN_TYPE)) {
+ error_log("JVB: Found Column Type for {$table}.{$column}: " . $result->COLUMN_TYPE);
+ return $result->COLUMN_TYPE;
+ }
+
+ // Fallback to signed bigint if we can't determine
+ error_log("JVB WARNING: Could not determine column type for {$table}.{$column}, using bigint(20) as fallback");
+ return 'bigint(20)';
+ }
public function maybeCreateTables()
{
@@ -379,11 +442,12 @@
protected function queueTables():array
{
+
return [
'_operation_queue' => "(
`id` VARCHAR(64) NOT NULL,
`type` varchar(50) NOT NULL,
- `user_id` bigint(20) NOT NULL,
+ `user_id` {$this->userIDType} NOT NULL,
`request_data` JSON NOT NULL,
`count` int(11) NOT NULL DEFAULT 1,
`progress_count` int(11) DEFAULT 0,
@@ -435,6 +499,7 @@
protected function errorLogTables():array
{
+
return [
'error_log'=> "(
`id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
@@ -443,7 +508,7 @@
`message` text NOT NULL,
`context` JSON,
`severity` varchar(20) NOT NULL,
- `user_id` bigint(20) unsigned,
+ `user_id` {$this->userIDType} NOT NULL,
`created_at` timestamp DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `error_lookup` (`error_type`, `severity`, `created_at`),
@@ -454,10 +519,11 @@
protected function userIntegrationsTable():array
{
+
return [
'user_integration_logs'=> "(
id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
- user_id bigint(20) unsigned NOT NULL,
+ `user_id` {$this->userIDType} NOT NULL,
service varchar(50) NOT NULL,
action varchar(50) NOT NULL,
status enum('success','error','warning') DEFAULT 'success',
@@ -474,13 +540,14 @@
protected function notificationTables():array
{
+
return [
// Main notifications table
'notifications' => "(
`id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
- `owner_id` bigint(20) unsigned NOT NULL,
- `action_user_id` bigint(20) unsigned DEFAULT NULL,
- `target_id` bigint(20) unsigned DEFAULT NULL,
+ `owner_id` {$this->userIDType} NOT NULL,
+ `action_user_id` {$this->userIDType} NOT NULL,
+ `target_id` bigint(20) DEFAULT NULL,
`target_type` varchar(30) DEFAULT NULL,
`type` enum('new_favourite','new_artist','artist_approved','artist_invitation',
'new_term','term_approved','term_rejected','list_shared',
@@ -503,15 +570,15 @@
KEY `requires_action` (`owner_id`, `requires_action`, `action_taken`),
KEY `acting_user_lookup` (`owner_id`, `action_user_id`, `type`, `status`, `created_at`),
CONSTRAINT `{$this->base}notify_owner` FOREIGN KEY (`owner_id`)
- REFERENCES {$this->wpdb->users} (`ID`) ON DELETE CASCADE,
+ REFERENCES {$this->userTable} (`ID`) ON DELETE CASCADE,
CONSTRAINT `{$this->base}action_id` FOREIGN KEY (`action_user_id`)
- REFERENCES {$this->wpdb->users} (`ID`) ON DELETE CASCADE
+ REFERENCES {$this->userTable} (`ID`) ON DELETE CASCADE
)",
'notifications_content' => "(
`id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
- `user_id` bigint(20) unsigned NOT NULL,
+ `user_id` {$this->userIDType} NOT NULL,
`date` date NOT NULL,
`frequency` enum('daily','weekly','monthly') NOT NULL,
`tattoo_count` int unsigned NOT NULL DEFAULT 0,
@@ -531,12 +598,12 @@
KEY `recent_content` (`date`, `frequency`),
KEY `artist_frequency` (`user_id`, `frequency`),
CONSTRAINT `{$this->base}content_artist` FOREIGN KEY (`user_id`)
- REFERENCES `wp_users` (`ID`) ON DELETE CASCADE
+ REFERENCES `{$this->userTable}` (`ID`) ON DELETE CASCADE
)",
'notifications_user_seen' => "(
`id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
- `user_id` bigint(20) unsigned NOT NULL,
+ `user_id` {$this->userIDType} NOT NULL,
`content_notification_id` bigint(20) unsigned NOT NULL,
`status` enum('unread','read','dismissed') NOT NULL DEFAULT 'unread',
`read_at` datetime DEFAULT NULL,
@@ -545,7 +612,7 @@
UNIQUE KEY `user_content_notif` (`user_id`, `content_notification_id`),
KEY `user_status` (`user_id`, `status`),
CONSTRAINT `{$this->base}user_content_user` FOREIGN KEY (`user_id`)
- REFERENCES {$this->wpdb->users} (`ID`) ON DELETE CASCADE,
+ REFERENCES {$this->userTable} (`ID`) ON DELETE CASCADE,
CONSTRAINT `{$this->base}user_content_notification` FOREIGN KEY (`content_notification_id`)
REFERENCES `{$this->prefixed}notifications_content` (`id`) ON DELETE CASCADE
)",
@@ -553,8 +620,8 @@
// User notification preferences
'notification_preferences' => "(
`id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
- `user_id` bigint(20) unsigned NOT NULL,
- `item_id` bigint(20) unsigned NOT NULL,
+ `user_id` {$this->userIDType} NOT NULL,
+ `item_id` bigint(20) NOT NULL,
`notification_type` varchar(50) NOT NULL,
`frequency` enum('never','daily','weekly','monthly') DEFAULT 'never',
`last_sent` datetime DEFAULT NULL,
@@ -565,13 +632,13 @@
KEY `user_frequency` (`user_id`, `frequency`),
KEY `frequency_lookup` (`frequency`, `last_sent`),
CONSTRAINT `{$this->base}notification_pref_user` FOREIGN KEY (`user_id`)
- REFERENCES {$this->wpdb->users} (`ID`) ON DELETE CASCADE
+ REFERENCES {$this->userTable} (`ID`) ON DELETE CASCADE
)",
// Notification digest scheduling and tracking
'notification_digests' => "(
`id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
- `user_id` bigint(20) unsigned NOT NULL,
+ `user_id` {$this->userIDType} NOT NULL,
`frequency` enum('daily','weekly','monthly') NOT NULL,
`scheduled_at` datetime NOT NULL,
`sent_at` datetime DEFAULT NULL,
@@ -582,14 +649,14 @@
KEY `scheduled_digests` (`frequency`, `scheduled_at`, `status`),
KEY `user_digests` (`user_id`, `frequency`),
CONSTRAINT `{$this->base}digest_user` FOREIGN KEY (`user_id`)
- REFERENCES {$this->wpdb->users} (`ID`) ON DELETE CASCADE
+ REFERENCES {$this->userTable} (`ID`) ON DELETE CASCADE
)",
// Analytics on notification interactions
'stats__notifications' => "(
`id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
`notification_id` bigint(20) unsigned NOT NULL,
- `user_id` bigint(20) unsigned NOT NULL,
+ `user_id` {$this->userIDType} NOT NULL,
`action` varchar(30) NOT NULL,
`action_source` enum('web','email','app') DEFAULT 'web',
`action_details` JSON DEFAULT NULL,
@@ -599,7 +666,7 @@
KEY `user_actions` (`user_id`, `action`),
KEY `action_analysis` (`action`, `action_source`),
CONSTRAINT `{$this->base}metrics_user` FOREIGN KEY (`user_id`)
- REFERENCES {$this->wpdb->users} (`ID`) ON DELETE CASCADE,
+ REFERENCES {$this->userTable} (`ID`) ON DELETE CASCADE,
CONSTRAINT `{$this->base}metrics_notification` FOREIGN KEY (`notification_id`)
REFERENCES {$this->prefixed}notifications (`id`) ON DELETE CASCADE
)"
@@ -610,11 +677,12 @@
{
$tables = [];
$save = [];
+
foreach ($types as $type => $config) {
$save[$type] = ($type === 'term') ? $config : 'user';
$tables['approval_'.$type.'_requests'] = "(
`id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
- `user_id` bigint(20) unsigned NOT NULL,
+ `user_id` {$this->userIDType} NOT NULL,
`status` enum('pending','approved','rejected','appealed','expired') DEFAULT 'pending',
`required_approvals` int unsigned DEFAULT 3,
`current_approvals` int unsigned DEFAULT 0,
@@ -629,12 +697,12 @@
KEY `status` (`status`),
KEY `expiring_requests` (`status`, `expires_at`),
CONSTRAINT `{$this->base}{$type}_approval_requester` FOREIGN KEY (`user_id`)
- REFERENCES {$this->wpdb->users} (`ID`) ON DELETE CASCADE
+ REFERENCES {$this->userTable} (`ID`) ON DELETE CASCADE
)";
$tables['approval_'.$type.'_votes'] = "(
`id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
`request_id` bigint(20) unsigned NOT NULL,
- `user_id` bigint(20) unsigned NOT NULL,
+ `user_id` {$this->userIDType} NOT NULL,
`vote` enum('approve','reject','dismissed') NOT NULL,
`notes` text DEFAULT NULL,
`created_at` datetime DEFAULT CURRENT_TIMESTAMP,
@@ -644,7 +712,7 @@
CONSTRAINT `{$this->base}{$type}_user_approval_request` FOREIGN KEY (`request_id`)
REFERENCES {$this->prefixed}approval_{$type}_requests (`id`) ON DELETE CASCADE,
CONSTRAINT `{$this->base}{$type}_user_approval_voter` FOREIGN KEY (`user_id`)
- REFERENCES {$this->wpdb->users} (`ID`) ON DELETE CASCADE
+ REFERENCES {$this->userTable} (`ID`) ON DELETE CASCADE
)";
}
if (!empty($save)) {
@@ -654,73 +722,76 @@
}
- protected function taxonomyRelationshipsTables():array
- {
+ protected function taxonomyRelationshipsTables():array
+ {
+ $tables = [
+ 'taxonomy_relationships' => "(
+ `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
+ `term_id` {$this->termIDType} NOT NULL,
+ `related_term_id` {$this->termIDType} NOT NULL,
+ `taxonomy` varchar(32) NOT NULL,
+ `related_taxonomy` varchar(32) NOT NULL,
+ `post_count` int(11) NOT NULL DEFAULT 0,
+ `is_direct` tinyint(1) NOT NULL DEFAULT 1,
+ `is_hierarchical` tinyint(1) NOT NULL DEFAULT 0,
+ `last_updated` timestamp DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+ PRIMARY KEY (`id`),
+ KEY `term_id` (`term_id`),
+ KEY `related_term_id` (`related_term_id`),
+ KEY `taxonomy` (`taxonomy`),
+ KEY `related_taxonomy` (`related_taxonomy`),
+ UNIQUE KEY `term_relation` (`term_id`, `related_term_id`, `taxonomy`, `related_taxonomy`),
+ CONSTRAINT `{$this->base}tax_rel_term_id` FOREIGN KEY (`term_id`)
+ REFERENCES {$this->wpdb->terms} (`term_id`) ON DELETE CASCADE,
+ CONSTRAINT `{$this->base}tax_rel_related_id` FOREIGN KEY (`related_term_id`)
+ REFERENCES {$this->wpdb->terms} (`term_id`) ON DELETE CASCADE
+ )"
+ ];
- $tables = [
- 'taxonomy_relationships' => "(
- `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
- `term_id` bigint(20) unsigned NOT NULL,
- `related_term_id` bigint(20) unsigned NOT NULL,
- `taxonomy` varchar(32) NOT NULL,
- `related_taxonomy` varchar(32) NOT NULL,
- `post_count` int(11) NOT NULL DEFAULT 0,
- `is_direct` tinyint(1) NOT NULL DEFAULT 1,
- `is_hierarchical` tinyint(1) NOT NULL DEFAULT 0,
- `last_updated` timestamp DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
- PRIMARY KEY (`id`),
- KEY `term_id` (`term_id`),
- KEY `related_term_id` (`related_term_id`),
- KEY `taxonomy` (`taxonomy`),
- KEY `related_taxonomy` (`related_taxonomy`),
- UNIQUE KEY `term_relation` (`term_id`, `related_term_id`, `taxonomy`, `related_taxonomy`),
- CONSTRAINT `{$this->base}tax_rel_term_id` FOREIGN KEY (`term_id`)
- REFERENCES {$this->wpdb->terms} (`term_id`) ON DELETE CASCADE,
- CONSTRAINT `{$this->base}related_term_id` FOREIGN KEY (`related_term_id`)
- REFERENCES {$this->wpdb->terms} (`term_id`) ON DELETE CASCADE
- )"
- ];
- if ((array_key_exists('dashboard', $this->JVB_SITE) && $this->JVB_SITE['dashboard'] === true) || array_key_exists('use_feed_block', $this->JVB_SITE) && $this->JVB_SITE['use_feed_block']) {
- $tables['user_term_index'] = "(
- `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
- `user_id` bigint(20) unsigned NOT NULL,
- `term_id` bigint(20) unsigned NOT NULL,
- `taxonomy` varchar(32) NOT NULL,
- `post_count` int(11) NOT NULL DEFAULT 1,
- `is_parent` tinyint(1) NOT NULL DEFAULT 0,
- `last_used` timestamp DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
- PRIMARY KEY (`id`),
- UNIQUE KEY `user_term` (`user_id`, `term_id`, `taxonomy`),
- KEY `user_taxonomy` (`user_id`, `taxonomy`),
- KEY `taxonomy` (`taxonomy`),
- CONSTRAINT `{$this->base}user_term_user` FOREIGN KEY (`user_id`)
- REFERENCES {$this->wpdb->users} (`ID`) ON DELETE CASCADE,
- CONSTRAINT `{$this->base}user_term_term` FOREIGN KEY (`term_id`)
- REFERENCES {$this->wpdb->terms} (`term_id`) ON DELETE CASCADE
- )";
- }
+ if ((array_key_exists('dashboard', $this->JVB_SITE) && $this->JVB_SITE['dashboard'] === true) || array_key_exists('use_feed_block', $this->JVB_SITE) && $this->JVB_SITE['use_feed_block']) {
+ $tables['user_term_index'] = "(
+ `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
+ `user_id` {$this->userIDType} NOT NULL,
+ `term_id` {$this->termIDType} NOT NULL,
+ `taxonomy` varchar(32) NOT NULL,
+ `post_count` int(11) NOT NULL DEFAULT 1,
+ `is_parent` tinyint(1) NOT NULL DEFAULT 0,
+ `last_used` timestamp DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+ PRIMARY KEY (`id`),
+ UNIQUE KEY `user_term` (`user_id`, `term_id`, `taxonomy`),
+ KEY `user_taxonomy` (`user_id`, `taxonomy`),
+ KEY `taxonomy` (`taxonomy`),
+ CONSTRAINT `{$this->base}user_term_user_fk` FOREIGN KEY (`user_id`)
+ REFERENCES {$this->userTable} (`ID`) ON DELETE CASCADE,
+ CONSTRAINT `{$this->base}user_term_term_fk` FOREIGN KEY (`term_id`)
+ REFERENCES {$this->wpdb->terms} (`term_id`) ON DELETE CASCADE
+ )";
+ }
- return $tables;
- }
+ return $tables;
+ }
protected function favouriteTables():array
{
+
return [
'favourites' => "(
`id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
- `user_id` bigint(20) unsigned NOT NULL,
+ `user_id` {$this->userIDType} NOT NULL,
`type` varchar(50) NOT NULL,
- `target_id` bigint(20) unsigned NOT NULL,
+ `target_id` bigint(20) NOT NULL,
`notes` text DEFAULT NULL,
`date_added` datetime DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `unique_favourite` (`user_id`, `type`, `target_id`),
KEY `user_type` (`user_id`, `type`),
- KEY `target_type` (`target_id`, `type`)
+ KEY `target_type` (`target_id`, `type`),
+ CONSTRAINT `{$this->base}favourites_user` FOREIGN KEY (`user_id`)
+ REFERENCES {$this->userTable} (`ID`) ON DELETE CASCADE
)",
'favourites_lists' => "(
`id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
- `user_id` bigint(20) unsigned NOT NULL,
+ `user_id` {$this->userIDType} NOT NULL,
`name` varchar(255) NOT NULL,
`description` text,
`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
@@ -728,13 +799,13 @@
PRIMARY KEY (`id`),
KEY `user_lists` (`user_id`),
CONSTRAINT `{$this->base}list_user` FOREIGN KEY (`user_id`)
- REFERENCES {$this->wpdb->users} (`ID`) ON DELETE CASCADE
+ REFERENCES {$this->userTable} (`ID`) ON DELETE CASCADE
)",
'favourites_list_items' => "(
`id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
`list_id` bigint(20) unsigned NOT NULL,
`item_type` varchar(50) NOT NULL,
- `item_id` bigint(20) unsigned NOT NULL,
+ `item_id` bigint(20) NOT NULL,
`favourite_id` bigint(20) unsigned DEFAULT NULL,
`added_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
@@ -749,7 +820,7 @@
'favourites_list_shares' => "(
`id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
`list_id` bigint(20) unsigned NOT NULL,
- `user_id` bigint(20) unsigned NULL,
+ `user_id` {$this->userIDType} NOT NULL,
`email` varchar(255) NOT NULL,
`permission_type` enum('view', 'edit') NOT NULL DEFAULT 'view',
`status` enum('pending', 'accepted', 'rejected', 'revoked') NOT NULL DEFAULT 'pending',
@@ -764,12 +835,12 @@
CONSTRAINT `{$this->base}share_list` FOREIGN KEY (`list_id`)
REFERENCES {$this->prefixed}favourites_lists (`id`) ON DELETE CASCADE,
CONSTRAINT `{$this->base}share_user` FOREIGN KEY (`user_id`)
- REFERENCES {$this->wpdb->users} (`ID`) ON DELETE CASCADE
+ REFERENCES {$this->userTable} (`ID`) ON DELETE CASCADE
)",
'favourites_list_stats' => "(
`id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
`item_type` varchar(50) NOT NULL,
- `item_id` bigint(20) unsigned NOT NULL,
+ `item_id` bigint(20) NOT NULL,
`list_count` int NOT NULL DEFAULT 0,
`last_added` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
@@ -784,9 +855,9 @@
return [
'news_relationships' => "(
`id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
- `shop_id` bigint(20) unsigned NOT NULL,
- `user_id` bigint(20) unsigned NOT NULL,
- `artist_id` bigint(20) unsigned DEFAULT NULL,
+ `shop_id` {$this->termIDType} NOT NULL,
+ `user_id` {$this->userIDType} NOT NULL,
+ `artist_id` {$this->postIDType} NOT NULL,
`news_count` int(10) unsigned NOT NULL DEFAULT 0,
`last_post_date` datetime DEFAULT NULL,
`last_updated` timestamp DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
@@ -798,7 +869,7 @@
CONSTRAINT `{$this->base}nr_shop_news_shop` FOREIGN KEY (`shop_id`)
REFERENCES {$this->wpdb->terms} (`term_id`) ON DELETE CASCADE,
CONSTRAINT `{$this->base}nr_shop_news_user` FOREIGN KEY (`user_id`)
- REFERENCES {$this->wpdb->users} (`ID`) ON DELETE CASCADE,
+ REFERENCES {$this->userTable} (`ID`) ON DELETE CASCADE,
CONSTRAINT `{$this->base}nr_shop_news_artist` FOREIGN KEY (`artist_id`)
REFERENCES {$this->wpdb->posts} (`ID`) ON DELETE SET NULL
)"
@@ -810,9 +881,9 @@
return [
'responses' => "(
`id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
- `item_id` bigint(20) unsigned NOT NULL,
+ `item_id` {$this->postIDType} NOT NULL,
`content` text NOT NULL,
- `user_id` bigint(20) unsigned DEFAULT NULL,
+ `user_id` {$this->userIDType} NOT NULL,
`parent_id` bigint(20) unsigned DEFAULT NULL,
`response` text NOT NULL,
`status` enum('published','hidden','flagged','deleted') DEFAULT 'published',
@@ -834,8 +905,8 @@
)",
'karma_response' => "(
`id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
- `item_id` bigint(20) unsigned NOT NULL,
- `user_id` bigint(20) unsigned NOT NULL,
+ `item_id` bigint(20) NOT NULL,
+ `user_id` {$this->userIDType} NOT NULL,
`vote` enum('up','down') NOT NULL,
`date` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
@@ -845,7 +916,7 @@
CONSTRAINT `{$this->base}_response_item_id` FOREIGN KEY (`item_id`)
REFERENCES {$this->prefixed}responses (`id`) ON DELETE CASCADE,
CONSTRAINT `{$this->base}_response_user_id` FOREIGN KEY (`user_id`)
- REFERENCES {$this->wpdb->users} (`ID`) ON DELETE CASCADE
+ REFERENCES {$this->userTable} (`ID`) ON DELETE CASCADE
)"
];
}
@@ -866,25 +937,29 @@
if (!$t) {
continue;
}
+
switch ($t) {
case 'posts':
+ $referenceType = $this->postIDType;
$reference_table = $this->wpdb->posts;
$reference_column = 'ID';
break;
case 'terms':
+ $referenceType = $this->termIDType;
$reference_table = $this->wpdb->terms;
$reference_column = 'term_id';
break;
case 'users':
- $reference_table = $this->wpdb->users;
+ $referenceType = $this->userIDType;
+ $reference_table = $this->userTable;
$reference_column = 'ID';
break;
}
$tables['karma_'.$type] = "(
`id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
- `item_id` bigint(20) unsigned NOT NULL,
- `user_id` bigint(20) unsigned NOT NULL,
+ `item_id` {$referenceType} NOT NULL,
+ `user_id` {$this->userIDType} NOT NULL,
`vote` enum('up','down') NOT NULL,
`date` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
@@ -894,7 +969,7 @@
CONSTRAINT `{$this->base}kt_{$type}_item_id` FOREIGN KEY (`item_id`)
REFERENCES {$reference_table} ({$reference_column}) ON DELETE CASCADE,
CONSTRAINT `{$this->base}kt_{$type}_user_id` FOREIGN KEY (`user_id`)
- REFERENCES {$this->wpdb->users} (`ID`) ON DELETE CASCADE
+ REFERENCES {$this->userTable} (`ID`) ON DELETE CASCADE
)";
}
@@ -908,13 +983,13 @@
foreach ($types as $type => $config) {
$tables['calendar_'.$type] = "(
`id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
- `post_id` bigint(20) unsigned NOT NULL,
- `event_type` bigint(20) unsigned,
+ `post_id` {$this->postIDType} NOT NULL,
+ `event_type` {$this->termIDType} unsigned,
-- Basic event details
`title` varchar(255) NOT NULL,
- `shop_id` bigint(20) unsigned,
- `user_id` bigint(20) unsigned,
+ `shop_id` {$this->termIDType} NOT NULL
+ `user_id` {$this->userIDType} NOT NULL,
-- Location handling
`location_type` enum('shop', 'custom', 'online') DEFAULT 'shop',
@@ -977,13 +1052,13 @@
CONSTRAINT `{$this->base}cal_{$type}_shop` FOREIGN KEY (`shop_id`)
REFERENCES {$this->wpdb->terms} (`term_id`) ON DELETE SET NULL,
CONSTRAINT `{$this->base}cal_{$type}_user` FOREIGN KEY (`user_id`)
- REFERENCES {$this->wpdb->users} (`ID`) ON DELETE SET NULL
+ REFERENCES {$this->userTable} (`ID`) ON DELETE SET NULL
)";
$tables['calendar_'.$type.'_participants'] = "(
`id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
`event_id` bigint(20) unsigned NOT NULL,
- `user_id` bigint(20) unsigned NOT NULL,
+ `user_id` {$this->userIDType} NOT NULL,
`status` enum('interested','going') NOT NULL,
`created_at` timestamp DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
@@ -991,7 +1066,7 @@
CONSTRAINT `{$this->base}cal_{$type}_participant_event` FOREIGN KEY (`event_id`)
REFERENCES {$this->prefixed}calendar_{$type} (`id`) ON DELETE CASCADE,
CONSTRAINT `{$this->base}cal_{$type}_participant_user` FOREIGN KEY (`user_id`)
- REFERENCES {$this->wpdb->users} (`ID`) ON DELETE CASCADE
+ REFERENCES {$this->userTable} (`ID`) ON DELETE CASCADE
)";
$tables['calendar_'.$type.'_recurrence_exceptions'] = "(
@@ -1012,6 +1087,7 @@
protected function umamiTracking():array
{
+
return [
'umami_events' => "(
`id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
@@ -1019,12 +1095,12 @@
`timestamp` datetime NOT NULL,
`event` varchar(50) NOT NULL,
`event_type` varchar(50) NOT NULL,
- `user_id` bigint(20) unsigned DEFAULT NULL,
- `content_id` bigint(20) unsigned DEFAULT NULL,
+ `user_id` {$this->userIDType} NOT NULL,
+ `content_id` bigint(20) DEFAULT NULL,
`content_type` varchar(50) DEFAULT NULL,
- `source_id` bigint(20) unsigned DEFAULT NULL,
+ `source_id` bigint(20) DEFAULT NULL,
`source_type` varchar(50) DEFAULT NULL,
- `owner_id` bigint(20) unsigned DEFAULT NULL,
+ `owner_id` {$this->userIDType} NOT NULL,
`owner_type` varchar(50) DEFAULT NULL,
`referrer` varchar(100) DEFAULT NULL,
`metadata` JSON DEFAULT NULL,
@@ -1036,14 +1112,14 @@
KEY `user_idx` (`user_id`),
KEY `owner_idx` (`owner_id`),
CONSTRAINT `{$this->base}umami_user_id_link` FOREIGN KEY (`user_id`)
- REFERENCES {$this->wpdb->users} (`ID`) ON DELETE CASCADE,
+ REFERENCES {$this->userTable} (`ID`) ON DELETE CASCADE,
CONSTRAINT `{$this->base}umami_owner_id_link` FOREIGN KEY (`owner_id`)
- REFERENCES {$this->wpdb->users} (`ID`) ON DELETE CASCADE
+ REFERENCES {$this->userTable} (`ID`) ON DELETE CASCADE
)",
'stats_performance' => "(
`id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
`date` date NOT NULL,
- `user_id` bigint(20) unsigned DEFAULT NULL,
+ `user_id` {$this->userIDType} NOT NULL,
`profile_view_count` bigint(20) unsigned DEFAULT 0,
`feed_view_count` bigint(20) unsigned DEFAULT 0,
`top_content` json DEFAULT null,
@@ -1056,13 +1132,14 @@
PRIMARY KEY (`id`),
KEY `user_date_idx` (`user_id`, `date`),
CONSTRAINT `{$this->base}performance_user_id_link` FOREIGN KEY (`user_id`)
- REFERENCES {$this->wpdb->users} (`ID`) ON DELETE CASCADE
+ REFERENCES {$this->userTable} (`ID`) ON DELETE CASCADE
)"
];
}
protected function invitationTables($types)
{
+
$tables = [];
foreach ($types as $role => $config) {
$definitions = "(
@@ -1073,9 +1150,9 @@
`status` enum('pending', 'accepted', 'rejected', 'expired','revoked') DEFAULT 'pending',
`inviters` JSON NOT NULL,";
foreach($config['to_terms']??[] as $term) {
- $definitions .= "`to_{$term}` bigint(20) unsigned DEFAULT NULL,";
+ $definitions .= "`to_{$term}` {$this->termIDType} DEFAULT NULL,";
}
- $definitions .= "`new_user_id` bigint(20) unsigned DEFAULT NULL,
+ $definitions .= "`new_user_id` bigint(20) NOT NULL,
`expires_at` datetime NOT NULL,
`accepted_at` datetime DEFAULT NULL,
`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
@@ -1086,6 +1163,10 @@
KEY `status_expiry` (`status`, `expires_at`),
KEY `name_status` (`name`, `status`)
)";
+ foreach($config['to_terms']??[] as $term) {
+ $definitions .= "CONSTRAINT `{$this->base}_{$term}_link` FOREIGN KEY (`to_{$term}`)
+ REFERENCES {$this->wpdb->terms} (`term_id`) ON DELETE CASCADE";
+ }
$tables['invitations_'.$role] = $definitions;
}
@@ -1101,9 +1182,9 @@
foreach ($contents as $content) {
$tables['history_'.$content.'_'.$type] = "(
`id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
- `user_id` bigint(20) unsigned NOT NULL,
- `content_id` bigint(20) unsigned NOT NULL,
- `term_id` bigint(20) unsigned NOT NULL,
+ `user_id` {$this->userIDType} NOT NULL,
+ `content_id` bigint(20) NOT NULL,
+ `term_id` {$this->termIDType} NOT NULL,
`role` varchar(50) DEFAULT 'artist',
`is_primary` tinyint(1) DEFAULT 0,
`start_date` date DEFAULT NULL,
@@ -1113,7 +1194,7 @@
UNIQUE KEY `content_term` (`content_id`, `term_id`),
KEY content_role (`term_id`, `role`),
CONSTRAINT `{$this->base}{$content}_{$type}_history_user` FOREIGN KEY (`user_id`)
- REFERENCES {$this->wpdb->users} (`ID`) ON DELETE CASCADE,
+ REFERENCES {$this->userTable} (`ID`) ON DELETE CASCADE,
CONSTRAINT `{$this->base}{$content}_{$type}_history_term` FOREIGN KEY (`term_id`)
REFERENCES {$this->wpdb->terms} (`term_id`) ON DELETE CASCADE
)";
@@ -1130,9 +1211,9 @@
foreach ($contents as $content) {
$tables[$content.'_'.$type.'_requests'] = "(
`id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
- `user_id` bigint(20) unsigned NOT NULL,
- `content_id` bigint(20) unsigned NOT NULL,
- `term_id` bigint(20) unsigned NOT NULL,
+ `user_id` {$this->userIDType} NOT NULL,
+ `content_id` bigint(20) NOT NULL,
+ `term_id` {$this->termIDType} NOT NULL,
`managers` json DEFAULT NULL,
`status` ENUM('requested', 'rejected', 'accepted') DEFAULT 'requested',
`dismissed` smallint(1) unsigned DEFAULT NULL,
@@ -1142,7 +1223,7 @@
PRIMARY KEY (`id`),
UNIQUE KEY `{$this->base}content_term` (`content_id`, `term_id`),
CONSTRAINT `{$this->base}{$content}_{$type}_request_user` FOREIGN KEY (`user_id`)
- REFERENCES {$this->wpdb->users} (`ID`) ON DELETE CASCADE,
+ REFERENCES {$this->userTable} (`ID`) ON DELETE CASCADE,
CONSTRAINT `{$this->base}{$content}_{$type}_request_term` FOREIGN KEY (`term_id`)
REFERENCES {$this->wpdb->terms} (`term_id`) ON DELETE CASCADE
)";
@@ -1161,58 +1242,106 @@
*/
protected function referralTables(): array
{
- $tables = [];
+ // Create tables in dependency order
+ // First: referrals (depends only on wp_users)
+ $mainTable['referrals'] = "(
+ `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
+ `referrer_id` {$this->userIDType} NOT NULL,
+ `referee_id` {$this->userIDType} NOT NULL,
+ `referee_name` varchar(255) NOT NULL,
+ `referee_email` varchar(255) NOT NULL,
+ `referee_phone` varchar(50) DEFAULT NULL,
+ `referral_code` varchar(50) NOT NULL,
+ `status` enum('pending', 'consulted', 'treated', 'cancelled') DEFAULT 'pending',
+ `referred_at` datetime NOT NULL,
+ `consulted_at` datetime DEFAULT NULL,
+ `treated_at` datetime DEFAULT NULL,
+ `treatment_count` int DEFAULT 0,
+ `notes` text DEFAULT NULL,
+ PRIMARY KEY (`id`),
+ UNIQUE KEY `referee_unique` (`referee_id`),
+ KEY `referrer_idx` (`referrer_id`),
+ KEY `status_idx` (`status`),
+ KEY `code_idx` (`referral_code`),
+ KEY `date_idx` (`referred_at`),
+ KEY `consult_idx` (`consulted_at`),
+ CONSTRAINT `{$this->base}referral_referrer_fk` FOREIGN KEY (`referrer_id`)
+ REFERENCES {$this->userTable} (`ID`) ON DELETE CASCADE,
+ CONSTRAINT `{$this->base}referral_referee_fk` FOREIGN KEY (`referee_id`)
+ REFERENCES {$this->userTable} (`ID`) ON DELETE CASCADE
+)";
- // Main referrals table
- $tables['referrals'] = "(
- `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
- `referrer_id` bigint(20) unsigned NOT NULL,
- `referee_id` bigint(20) unsigned NOT NULL,
- `referee_name` varchar(255) NOT NULL,
- `referee_email` varchar(255) NOT NULL,
- `referee_phone` varchar(50) DEFAULT NULL,
- `referral_code` varchar(50) NOT NULL,
- `status` enum('pending', 'treated', 'cancelled') DEFAULT 'pending',
- `referred_at` datetime NOT NULL,
- `treated_at` datetime DEFAULT NULL,
- `notes` text DEFAULT NULL,
- PRIMARY KEY (`id`),
- UNIQUE KEY `referee_unique` (`referee_id`),
- KEY `referrer_idx` (`referrer_id`),
- KEY `status_idx` (`status`),
- KEY `code_idx` (`referral_code`),
- KEY `date_idx` (`referred_at`),
- CONSTRAINT `{$this->base}referral_referrer_fk` FOREIGN KEY (`referrer_id`)
- REFERENCES {$this->wpdb->users} (`ID`) ON DELETE CASCADE,
- CONSTRAINT `{$this->base}referral_referee_fk` FOREIGN KEY (`referee_id`)
- REFERENCES {$this->wpdb->users} (`ID`) ON DELETE CASCADE
- )";
+ // Create the main referrals table first
+ $this->createTables($mainTable);
- // Rewards table
- $tables['referral_rewards'] = "(
- `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
- `referral_id` bigint(20) unsigned NOT NULL,
- `user_id` bigint(20) unsigned NOT NULL,
- `reward_type` enum('referrer', 'referee') NOT NULL,
- `amount` decimal(10,2) NOT NULL,
- `reward_calculation` varchar(20) DEFAULT NULL COMMENT 'percentage or fixed',
- `status` enum('available', 'redeemed', 'expired', 'cancelled') DEFAULT 'available',
- `created_at` datetime NOT NULL,
- `redeemed_at` datetime DEFAULT NULL,
- `expires_at` datetime DEFAULT NULL,
- `notes` text DEFAULT NULL,
- PRIMARY KEY (`id`),
- KEY `referral_idx` (`referral_id`),
- KEY `user_idx` (`user_id`),
- KEY `status_idx` (`status`),
- KEY `type_idx` (`reward_type`),
- CONSTRAINT `{$this->base}reward_referral_fk` FOREIGN KEY (`referral_id`)
- REFERENCES {$this->wpdb->prefix}" . BASE . "referrals (`id`) ON DELETE CASCADE,
- CONSTRAINT `{$this->base}reward_user_fk` FOREIGN KEY (`user_id`)
- REFERENCES {$this->wpdb->users} (`ID`) ON DELETE CASCADE
- )";
+ // Now create dependent tables
+ $dependentTables = [];
- return $tables;
+ // Second: jane_clients (depends only on wp_users)
+ $dependentTables['jane_clients'] = "(
+ `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
+ `patient_guid` varchar(50) NOT NULL,
+ `user_id` {$this->userIDType} NOT NULL,
+ `first_name` varchar(100) NOT NULL,
+ `last_name` varchar(100) NOT NULL,
+ `email` varchar(255) NOT NULL,
+ `imported_at` datetime DEFAULT CURRENT_TIMESTAMP,
+ `updated_at` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+ PRIMARY KEY (`id`),
+ UNIQUE KEY `patient_guid_unique` (`patient_guid`),
+ KEY `user_idx` (`user_id`),
+ KEY `email_idx` (`email`),
+ CONSTRAINT `{$this->base}jane_client_user_fk` FOREIGN KEY (`user_id`)
+ REFERENCES {$this->userTable} (`ID`) ON DELETE CASCADE
+)";
+
+ // Third: referral_treatments (depends on referrals AND wp_users)
+ $dependentTables['referral_treatments'] = "(
+ `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
+ `referral_id` bigint(20) unsigned NOT NULL,
+ `user_id` {$this->userIDType} NOT NULL,
+ `treatment_type` varchar(100) NOT NULL COMMENT 'Tier 1-6, Brows, etc',
+ `treatment_date` datetime NOT NULL,
+ `invoice_number` varchar(50) DEFAULT NULL,
+ `amount` decimal(10,2) DEFAULT NULL,
+ `status` enum('completed', 'no_show', 'cancelled') DEFAULT 'completed',
+ `imported_at` datetime DEFAULT CURRENT_TIMESTAMP,
+ PRIMARY KEY (`id`),
+ KEY `referral_idx` (`referral_id`),
+ KEY `user_idx` (`user_id`),
+ KEY `date_idx` (`treatment_date`),
+ KEY `type_idx` (`treatment_type`),
+ CONSTRAINT `{$this->base}treatment_referral_fk` FOREIGN KEY (`referral_id`)
+ REFERENCES {$this->prefixed}referrals (`id`) ON DELETE CASCADE,
+ CONSTRAINT `{$this->base}treatment_user_fk` FOREIGN KEY (`user_id`)
+ REFERENCES {$this->userTable} (`ID`) ON DELETE CASCADE
+)";
+
+ // Fourth: referral_rewards (depends on referrals AND wp_users)
+ $dependentTables['referral_rewards'] = "(
+ `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
+ `referral_id` bigint(20) unsigned NOT NULL,
+ `user_id` {$this->userIDType} NOT NULL,
+ `reward_type` enum('referrer', 'referee') NOT NULL,
+ `amount` decimal(10,2) NOT NULL,
+ `reward_calculation` varchar(20) DEFAULT NULL COMMENT 'percentage or fixed',
+ `status` enum('available', 'redeemed', 'expired', 'cancelled') DEFAULT 'available',
+ `created_at` datetime NOT NULL,
+ `redeemed_at` datetime DEFAULT NULL,
+ `expires_at` datetime DEFAULT NULL,
+ `notes` text DEFAULT NULL,
+ PRIMARY KEY (`id`),
+ KEY `referral_idx` (`referral_id`),
+ KEY `user_idx` (`user_id`),
+ KEY `status_idx` (`status`),
+ KEY `type_idx` (`reward_type`),
+ CONSTRAINT `{$this->base}reward_referral_fk` FOREIGN KEY (`referral_id`)
+ REFERENCES {$this->prefixed}referrals (`id`) ON DELETE CASCADE,
+ CONSTRAINT `{$this->base}reward_user_fk` FOREIGN KEY (`user_id`)
+ REFERENCES {$this->userTable} (`ID`) ON DELETE CASCADE
+)";
+
+ return $dependentTables;
}
/*******************************************************************************************
@@ -1238,9 +1367,9 @@
'time' => "time {$nullable}",
'datetime' => "datetime {$nullable}",
'true_false' => "boolean DEFAULT FALSE",
- 'image', 'file' => "bigint(20) unsigned {$nullable}",
+ 'image', 'file' => "bigint(20) {$nullable}",
'taxonomy' => $this->getTaxonomyColumnDefinition($fieldConfig, $nullable),
- 'user' => "bigint(20) unsigned {$nullable}",
+ 'user' => "bigint(20) {$nullable}",
'location' => $this->getLocationColumnDefinition($fieldName, $nullable),
'select', 'radio' => $this->getSelectColumnDefinition($fieldConfig, $nullable),
'set', 'checkbox', 'repeater', 'gallery' => "json {$nullable}",
@@ -1289,7 +1418,7 @@
$limit = $fieldConfig['limit'] ?? null;
if ($limit === 1) {
- return "bigint(20) unsigned {$nullable}";
+ return "{$this->termIDType} {$nullable}";
}
// Multiple selections stored as JSON
@@ -1334,7 +1463,7 @@
REFERENCES {$this->wpdb->terms} (`term_id`) ON DELETE SET NULL";
} elseif ($type === 'user') {
$constraints[] = "CONSTRAINT `{$constraintName}` FOREIGN KEY (`{$fieldName}`)
- REFERENCES {$this->wpdb->users} (`ID`) ON DELETE SET NULL";
+ REFERENCES {$this->userTable} (`ID`) ON DELETE SET NULL";
} elseif ($type === 'image' || $type === 'file') {
$constraints[] = "CONSTRAINT `{$constraintName}` FOREIGN KEY (`{$fieldName}`)
REFERENCES {$this->wpdb->posts} (`ID`) ON DELETE SET NULL";
@@ -1366,9 +1495,8 @@
try {
$tableName = 'content_' . $type;
$columns = [];
-
// Always include primary key
- $columns[] = "`term_id` bigint(20) unsigned NOT NULL";
+ $columns[] = "`term_id` {$this->termIDType} NOT NULL";
$columns[] = "`name` varchar(255) NOT NULL";
$columns[] = "`slug` varchar(255) NOT NULL";
@@ -1477,7 +1605,7 @@
// Base columns that every user stats table should have
$columns = [
- "`user_id` bigint(20) unsigned NOT NULL",
+ "`user_id` {$this->userIDType} NOT NULL",
"`display_name` VARCHAR(255) NULL",
"`email` VARCHAR(255) NULL",
"`city` VARCHAR(100) NULL",
@@ -1500,12 +1628,11 @@
error_log('JVB: Favourites column added');
}
- // CRITICAL FIX: Simplified profile-specific fields processing
if (isset($config['profile_type'])) {
$profileType = $config['profile_type'];
error_log("JVB: Processing profile type: {$profileType}");
- // SAFE check for profile fields
+
if (isset($this->JVB_CONTENT[$profileType]) &&
isset($this->JVB_CONTENT[$profileType]['fields']) &&
is_array($this->JVB_CONTENT[$profileType]['fields'])) {
@@ -1600,7 +1727,7 @@
$constraints = [
"CONSTRAINT `{$this->base}_{$userType}_stats_user` FOREIGN KEY (`user_id`)
- REFERENCES {$this->wpdb->users} (`ID`) ON DELETE CASCADE"
+ REFERENCES {$this->userTable} (`ID`) ON DELETE CASCADE"
];
$allDefinitions = array_merge($columns, $indexes, $constraints);
diff --git a/inc/registry/PostTypeRegistrar.php b/inc/registry/PostTypeRegistrar.php
index 9bd39c6..1d6c2f4 100644
--- a/inc/registry/PostTypeRegistrar.php
+++ b/inc/registry/PostTypeRegistrar.php
@@ -1,6 +1,9 @@
<?php
namespace JVBase\registry;
+use JVBase\forms\TaxonomySelector;
+use JVBase\managers\CRUD;
+use JVBase\utility\Features;
use WP_Post;
use JVBase\meta\MetaRegistry;
use JVBase\managers\CacheManager;
@@ -31,6 +34,13 @@
$singular = $this->config['singular'] ?? ucfirst($this->slug);
$plural = $this->config['plural'] ?? $singular . 's';
$loweredPlural = strtolower($plural);
+ $capsMap = $this->config['capability_type']??[];
+ if (empty($capsMap)){
+ $capsMap = [
+ $this->slug,
+ str_replace('-', '_',sanitize_title($loweredPlural))
+ ];
+ }
$args = [
'labels' => $this->buildLabels($singular, $plural),
'public' => $this->config['public'] ?? true,
@@ -40,16 +50,16 @@
'show_in_menu' => $this->config['show_in_menu'] ?? true,
'query_var' => $this->config['query_var'] ?? true,
'rewrite' => $this->config['rewrite'] ?? ['slug' => $this->slug, 'with_front' => false],
- 'capability_type' => [$this->slug, $loweredPlural],
+ 'capability_type' => $capsMap,
'capabilities' => [
'edit_post' => "edit_{$this->slug}",
'read_post' => "read_{$this->slug}",
'delete_post' => "delete_{$this->slug}",
- 'edit_posts' => "edit_{$loweredPlural}",
- 'edit_others_posts' => "edit_others_{$loweredPlural}",
- 'publish_posts' => "publish_{$loweredPlural}",
- 'read_private_posts' => "read_private_{$loweredPlural}",
- 'create_posts' => "edit_{$loweredPlural}",
+ 'edit_posts' => "edit_{$capsMap[1]}",
+ 'edit_others_posts' => "edit_others_{$capsMap[1]}",
+ 'publish_posts' => "publish_{$capsMap[1]}",
+ 'read_private_posts' => "read_private_{$capsMap[1]}",
+ 'create_posts' => "edit_{$capsMap[1]}",
],
'has_archive' => $this->config['has_archive'] ?? true,
'hierarchical' => $this->config['hierarchical'] ?? false,
@@ -58,12 +68,18 @@
'show_in_rest' => $this->config['show_in_rest'] ?? true,
];
- if (jvbCheck('is_calendar', $this->config)) {
+ if ($this->config['is_calendar']??false) {
$args['rewrite']['slug'] = $args['rewrite']['slug']??$this->slug.'/%eyear%/%emonth%/%eday%';
}
- if (isset($this->config['icon'])) {
+ if ($this->config['rewrite_taxonomy']??false && array_key_exists($this->config['rewrite_taxonomy'], JVB_TAXONOMY)) {
+ $args['rewrite']['slug'] = "{$this->slug}/%{$this->config['rewrite_taxonomy']}%";
+ }
+ if ($this->config['icon']??false) {
$args['menu_icon'] = jvbCSSIcon($this->config['icon']);
}
+ if ($this->config['is_timeline']??false) {
+
+ }
register_post_type($this->post_type, $args);
@@ -103,6 +119,18 @@
}
}
+ add_filter('jvbDashboardPage', [$this, 'renderDashPage'], 10, 3);
+
+ if ($this->config['hide_children'] ?? false) {
+ add_action('template_redirect', [$this, 'redirectChildToParent']);
+ }
+
+ if (array_key_exists('rewrite_taxonomy', $this->config) && array_key_exists($this->config['rewrite_taxonomy'], JVB_TAXONOMY)) {
+ add_action('init', [$this, 'addRewriteRules'], 20);
+ add_action('post_type_link', [$this, 'rewriteTaxonomySingle'], 15, 2);
+ add_filter('post_type_archive_link', [$this, 'rewriteTaxonomyArchive'], 15, 2);
+ }
+
$postType = $this->post_type;
add_action("save_post_{$this->post_type}", function($post_id, $post, $update) use ($postType) {
if (jvbNoSaveIt($post_id, $post)) {
@@ -128,20 +156,14 @@
protected function invalidatePostCache(string $type, $post, string $action) {
error_log('Clearing Cache for '.print_r($type, true));
- $cache = new CacheManager(jvbNoBase($type));
- $cache->delete($post->ID);
-
- // Clear specific cache groups
- CacheManager::invalidateGroup($type);
-
- CacheManager::invalidateGroup("user_content_{$post->post_author}");
+ $cache = CacheManager::for(jvbNoBase($type))->invalidate();
// Clear related caches (taxonomies attached to this post)
$taxonomies = get_object_taxonomies($post->post_type);
foreach ($taxonomies as $taxonomy) {
$terms = wp_get_post_terms($post->ID, $taxonomy, ['fields' => 'ids']);
if (!empty($terms)) {
- CacheManager::invalidateGroup($taxonomy);
+ CacheManager::for(jvbNoBase($taxonomy))->invalidate();
}
}
@@ -180,4 +202,91 @@
return get_post_type_archive_link($post->post_type);
}
+ public function addRewriteRules(): void
+ {
+ $type = $this->config['rewrite_taxonomy'];
+ $taxonomy = jvbCheckBase($type);
+
+ // Rule 1: Post type archive - /faq/
+ add_rewrite_rule(
+ "{$this->slug}/?$",
+ "index.php?post_type={$this->post_type}",
+ 'top'
+ );
+
+ // Rule 2: Single posts with taxonomy - /faq/section/post/
+ add_rewrite_rule(
+ "{$this->slug}/([^/]+)/([^/]+)/?$",
+ "index.php?post_type={$this->post_type}&name=\$matches[2]&{$taxonomy}=\$matches[1]",
+ 'top'
+ );
+
+ // Rule 3: Un-sectioned posts - /faq/post/
+ // Use 'bottom' priority so taxonomy rules match first
+ add_rewrite_rule(
+ "{$this->slug}/([^/]+)/?$",
+ "index.php?post_type={$this->post_type}&name=\$matches[1]",
+ 'bottom'
+ );
+ }
+ public function rewriteTaxonomySingle(string $url, \WP_Post $post): string
+ {
+ if ($post->post_type === $this->post_type) {
+ $type = $this->config['rewrite_taxonomy'];
+ $taxonomy = jvbCheckBase($type);
+ $terms = wp_get_post_terms($post->ID, $taxonomy);
+ if (!empty($terms) && !is_wp_error($terms)) {
+ $path = TaxonomySelector::getTermPath($terms[0], true);
+ $path = implode('/', array_map(function($term) {
+ return sanitize_title($term);
+ }, $path));
+ return str_replace("%{$type}%", $path, $url);
+ }
+ return str_replace("/%{$type}%", '', $url);
+ }
+ return $url;
+ }
+
+ public function rewriteTaxonomyArchive(string $url, string $post_type):string
+ {
+ if ($post_type === $this->post_type) {
+ $url = get_home_url(null, "/{$this->slug}/");
+ }
+ return $url;
+ }
+
+ /**
+ * Redirect child timeline posts to their parent post
+ */
+ public function redirectChildToParent(): void
+ {
+ if (!is_singular($this->post_type)) {
+ return;
+ }
+
+ global $post;
+
+ // If this post has a parent, redirect to parent
+ if ($post->post_parent) {
+ $parent_url = get_permalink($post->post_parent);
+
+ // Add anchor or query param to indicate which child was accessed
+ $redirect_url = add_query_arg('update', $post->ID, $parent_url);
+
+ wp_redirect($redirect_url, 301);
+ exit;
+ }
+ }
+
+ public function renderDashPage(string $content, string $page, string $slug):string
+ {
+ if ($slug === $this->slug) {
+ ob_start();
+ $crud = new CRUD($slug);
+ $crud->render();
+ return ob_get_clean();
+ }
+
+ return $content;
+ }
}
diff --git a/inc/registry/TaxonomyRegistrar.php b/inc/registry/TaxonomyRegistrar.php
index bd0c049..e31fc5a 100644
--- a/inc/registry/TaxonomyRegistrar.php
+++ b/inc/registry/TaxonomyRegistrar.php
@@ -336,19 +336,15 @@
if ($tax !== $taxonomy) return;
$term = get_term($term_id, $tax);
- jvbUpdateCacheTimestamp('term', $term);
- // Clear taxonomy cache
- CacheManager::invalidateGroup($taxonomy);
- CacheManager::invalidateGroup('terms');
- CacheManager::invalidateGroup('termCheck');
+ CacheManager::for(jvbNoBase($taxonomy))->invalidate();
// Clear cache for associated content types
$checker = Checker::getInstance();
$content_types = $checker->getContentForTaxonomy($taxonomy);
foreach ($content_types as $content_type) {
- CacheManager::invalidateGroup($content_type);
+ CacheManager::for($content_type)->invalidate();
}
do_action("jvb_taxonomy_cache_invalidated_{$taxonomy}", $term, $action);
diff --git a/inc/rest/RestRouteManager.php b/inc/rest/RestRouteManager.php
index 91f76f2..8f1b1ba 100644
--- a/inc/rest/RestRouteManager.php
+++ b/inc/rest/RestRouteManager.php
@@ -50,7 +50,7 @@
$this->base = BASE;
$this->rate_limiter = new RateLimiter();
if ($this->cache_name !== '') {
- $this->cache = new CacheManager($this->cache_name, $this->cache_ttl);
+ $this->cache = CacheManager::for($this->cache_name, $this->cache_ttl);
}
add_action('rest_api_init', [$this, 'registerRoutes']);
}
@@ -94,6 +94,7 @@
{
// Check rate limits first
if (!$this->rate_limiter->checkLimit($request)) {
+ error_log('Rate Limit Reached');
return new WP_Error(
'rate_limit_reached',
'Rate limit reached',
@@ -102,6 +103,7 @@
}
$user_id = $request->get_param('user');
if (!empty($user_id) && !$this->userCheck($user_id)) {
+ error_log('Usercheck failed');
return false;
}
// Verify nonces
@@ -180,82 +182,64 @@
}
}
- /**
- * @param int $userID The user ID to check
- *
- * @return bool whether user exists
- */
- protected function checkUser(int $userID):bool
- {
- $checked = $this->cache->get($userID, 'checked_users');
- if ($checked) {
- return $checked;
- }
- $test = (bool)get_userdata($userID);
+ /**
+ * Check if user exists (cached)
+ */
+ protected function checkUser(int $userID): bool
+ {
+ $cache = CacheManager::for('users');
- $this->cache->set($userID, $test, null, 'checked_users');
- return $test;
- }
+ return $cache->remember("user_exists_{$userID}", function() use ($userID) {
+ return (bool)get_userdata($userID);
+ }, DAY_IN_SECONDS);
+ }
- /**
- * @param int $shopID the shop ID to check
- *
- * @return bool whether the shop exists
- */
- protected function checkShop(int $shopID):bool
- {
- $checked = $this->cache->get($shopID, 'checked_shops');
- if ($checked) {
- return (bool)$checked;
- }
- $test = term_exists($shopID, BASE . 'shop');
- $this->cache->set($shopID, (int)$test, null, 'checked_shops');
- return $test;
- }
+ /**
+ * Check if shop exists (cached)
+ */
+ protected function checkShop(int $shopID): bool
+ {
+ $cache = CacheManager::for('shop');
- protected function checkTerm(array $args) {
- $termID = $args['to_term']??$args['term_id']??false;
+ return $cache->remember("shop_exists_{$shopID}", function() use ($shopID) {
+ return (bool)term_exists($shopID, BASE . 'shop');
+ }, DAY_IN_SECONDS);
+ }
+
+ /**
+ * Check if term exists (cached)
+ */
+ protected function checkTerm(array $args): bool
+ {
+ $termID = $args['to_term'] ?? $args['term_id'] ?? false;
if (!$termID) {
return false;
}
- $taxonomy = $args['taxonomy']??false;
+
+ $taxonomy = $args['taxonomy'] ?? false;
if (!$taxonomy) {
return false;
}
- $checked = $this->cache->get($termID, 'checked_'.$taxonomy);
- if ($checked) {
- return (bool) $checked;
- }
- $test = term_exists($termID, jvbCheckBase($taxonomy));
- $this->cache->set($termID, (int)$test, null, 'checked_'.$taxonomy);
- return (bool)$test;
+
+ $taxonomy = jvbCheckBase($taxonomy);
+ $cache = CacheManager::for($taxonomy);
+
+ return $cache->remember("term_exists_{$termID}", function() use ($termID, $taxonomy) {
+ return (bool)term_exists($termID, $taxonomy);
+ }, DAY_IN_SECONDS);
}
- /**
- * Check if an artist is verified
- *
- * @param int $user_id User ID
- * @return bool True if verified
- */
- public function isVerifiedUser(int $user_id):bool
- {
- // Cache result to avoid repeated checks
- $cache_key = "verified_users";
- $verified = $this->cache->get($cache_key, 'users');
- $verified = ($verified) ?: [];
- if (array_key_exists($user_id, $verified)) {
- return (bool) $verified[$user_id];
- }
+ /**
+ * Check if an artist is verified
+ */
+ public function isVerifiedUser(int $user_id): bool
+ {
+ $cache = CacheManager::forUser($user_id);
- // Check if user has the skip_moderation capability
- $is_verified = user_can($user_id, 'skip_moderation');
-
- $verified[$user_id] = $is_verified;
- // Cache for a day
- $this->cache->set($cache_key, $verified, DAY_IN_SECONDS, 'users');
-
- return $is_verified;
- }
+ return $cache->remember('is_verified', function() use ($user_id) {
+ return user_can($user_id, 'skip_moderation');
+ }, DAY_IN_SECONDS);
+ }
protected function applyTaxonomyFilters(array $args, array $data):array
{
@@ -405,27 +389,219 @@
return $wpdb->get_var("SHOW TABLES LIKE '{$tableName}'") !== $tableName;
}
- protected function ifModifiedSince($lastModified, $params, $request):WP_REST_Response|null {
- $etag = '"' . md5(serialize($params)) . '"';
- // Check ETag
+ // ========== HTTP CACHING METHODS ==========
+
+ /**
+ * Check HTTP caching headers (ETag and If-Modified-Since)
+ * Returns 304 Not Modified if content hasn't changed
+ *
+ * @param WP_REST_Request $request The REST request
+ * @param string|array $content_types Content type(s) to check timestamps for
+ * @param array $additional_params Additional params for ETag uniqueness (e.g., user_id, filters)
+ * @return WP_REST_Response|null Returns 304 response if not modified, null to continue processing
+ */
+ protected function checkHeaders(
+ WP_REST_Request $request,
+ string|array $content_types,
+ array $additional_params = []
+ ): WP_REST_Response|null {
+
+ // Get latest timestamp for the content type(s)
+ $last_modified = CacheManager::getTimestamp($content_types);
+
+ // Generate ETag from request params + timestamp
+ $etag = $this->generateETag($request->get_params(), $additional_params, $last_modified);
+
+ // Check If-None-Match (ETag) header
$if_none_match = $request->get_header('If-None-Match');
- if ($if_none_match && $if_none_match === $etag) {
- return new WP_REST_Response(null, 304);
+ if ($if_none_match === $etag) {
+ return $this->createNotModifiedResponse($etag, $last_modified);
}
+ // Check If-Modified-Since header
$if_modified_since = $request->get_header('If-Modified-Since');
- if ($if_modified_since && $lastModified) {
+ if ($if_modified_since) {
$if_modified_timestamp = strtotime($if_modified_since);
- if ($lastModified <= $if_modified_timestamp) {
- return new WP_REST_Response(null, 304);
+ if ($last_modified <= $if_modified_timestamp) {
+ return $this->createNotModifiedResponse($etag, $last_modified);
}
}
- header('ETag: ' . $etag); // Add this line
- if ($lastModified) {
- header('Last-Modified: ' . gmdate('D, d M Y H:i:s', $lastModified) . ' GMT');
+ // Content has changed - store headers to add to successful response
+ $this->response_headers = $this->buildCacheHeaders($etag, $last_modified);
+
+ return null; // Continue processing
+ }
+
+ /**
+ * Generate ETag from request parameters and timestamp
+ *
+ * @param array $params Request parameters
+ * @param array $additional Additional parameters for uniqueness
+ * @param int $timestamp Last modified timestamp
+ * @return string ETag value with quotes
+ */
+ private function generateETag(array $params, array $additional, int $timestamp): string
+ {
+ // Combine all data that makes this response unique
+ $etag_data = array_merge(
+ $params,
+ $additional,
+ ['t' => $timestamp]
+ );
+
+ return '"' . md5(serialize($etag_data)) . '"';
+ }
+
+ /**
+ * Create 304 Not Modified response with proper headers
+ *
+ * @param string $etag ETag value
+ * @param int $last_modified Last modified timestamp
+ * @return WP_REST_Response 304 response
+ */
+ private function createNotModifiedResponse(string $etag, int $last_modified): WP_REST_Response
+ {
+ $response = new WP_REST_Response(null, 304);
+ $response->set_headers($this->buildCacheHeaders($etag, $last_modified));
+ return $response;
+ }
+
+ /**
+ * Build cache headers array
+ *
+ * @param string $etag ETag value
+ * @param int $last_modified Last modified timestamp
+ * @return array Headers array
+ */
+ private function buildCacheHeaders(string $etag, int $last_modified): array
+ {
+ return [
+ 'ETag' => $etag,
+ 'Last-Modified' => gmdate('D, d M Y H:i:s', $last_modified) . ' GMT',
+ 'Cache-Control' => 'private, max-age=60, must-revalidate'
+ ];
+ }
+
+ /**
+ * Add stored cache headers to a response
+ * Call this on your final WP_REST_Response before returning
+ *
+ * @param WP_REST_Response $response The response to add headers to
+ * @return WP_REST_Response The response with headers added
+ */
+ protected function addCacheHeaders(WP_REST_Response $response): WP_REST_Response
+ {
+ if (!empty($this->response_headers)) {
+ $response->set_headers($this->response_headers);
+ $this->response_headers = []; // Clear after use
}
- header('Cache-Control: private, max-age=30');
- return null;
+ return $response;
+ }
+
+ /**
+ * Helper: Check headers for user-specific endpoints
+ * Automatically includes user_id in ETag
+ *
+ * @param WP_REST_Request $request The REST request
+ * @param int $user_id User ID
+ * @param string|array $content_types Content type(s)
+ * @return WP_REST_Response|null
+ */
+ protected function checkUserHeaders(
+ WP_REST_Request $request,
+ int $user_id,
+ string|array $content_types = 'user'
+ ): WP_REST_Response|null {
+
+ // Include user-specific timestamp
+ $types = is_array($content_types) ? $content_types : [$content_types];
+ $types[] = "user_{$user_id}";
+
+ return $this->checkHeaders($request, $types, ['user_id' => $user_id]);
}
}
+//
+//Simple example:
+//public function getTattoos(WP_REST_Request $request): WP_REST_Response
+//{
+// // Check HTTP cache headers first
+// $cache_check = $this->checkHeaders($request, 'tattoo');
+// if ($cache_check) {
+// return $cache_check; // Returns 304 Not Modified
+// }
+//
+// // Get data (use CacheManager for data caching too!)
+// $filters = $request->get_params();
+// $cache = CacheManager::for('tattoo');
+//
+// $tattoos = $cache->remember($filters, function() use ($filters) {
+// return $this->queryTattoos($filters);
+// }, 300);
+//
+// $response = new WP_REST_Response(['items' => $tattoos]);
+// return $this->addCacheHeaders($response); // Add ETag and Last-Modified
+//}
+//
+//Multiple Content Types:
+//public function getTermsWithContent(WP_REST_Request $request): WP_REST_Response
+//{
+// $taxonomy = $request->get_param('taxonomy');
+//
+// // Check both taxonomy and its content types
+// $cache_check = $this->checkHeaders($request, [$taxonomy, 'tattoo', 'artwork']);
+// if ($cache_check) {
+// return $cache_check;
+// }
+//
+// // ... fetch data ...
+//
+// $response = new WP_REST_Response($data);
+// return $this->addCacheHeaders($response);
+//}
+//
+//User-specific:
+//public function getUserFavorites(WP_REST_Request $request): WP_REST_Response
+//{
+// $user_id = $request->get_param('user');
+//
+// // Automatically checks user_{$user_id} timestamp + includes user_id in ETag
+// $cache_check = $this->checkUserHeaders($request, $user_id);
+// if ($cache_check) {
+// return $cache_check;
+// }
+//
+// // Get user's favorites (cached per user)
+// $favorites = CacheManager::forUser($user_id)->remember('favorites', function() use ($user_id) {
+// return $this->getUserFavorites($user_id);
+// }, 1800);
+//
+// $response = new WP_REST_Response(['items' => $favorites]);
+// return $this->addCacheHeaders($response);
+//}
+//
+//Complex with additional params:
+//public function getFilteredContent(WP_REST_Request $request): WP_REST_Response
+//{
+// $user_id = get_current_user_id();
+// $filters = $request->get_params();
+//
+// // Include custom params in ETag for uniqueness
+// $cache_check = $this->checkHeaders(
+// $request,
+// 'tattoo',
+// [
+// 'user_id' => $user_id,
+// 'is_verified' => $this->isVerifiedUser($user_id)
+// ]
+// );
+//
+// if ($cache_check) {
+// return $cache_check;
+// }
+//
+// // ... fetch filtered data ...
+//
+// $response = new WP_REST_Response($data);
+// return $this->addCacheHeaders($response);
+//}
diff --git a/inc/rest/routes/BioRoutes.php b/inc/rest/routes/BioRoutes.php
index a265186..685c0b2 100644
--- a/inc/rest/routes/BioRoutes.php
+++ b/inc/rest/routes/BioRoutes.php
@@ -225,9 +225,7 @@
if (!$this->checkUser($user_id)) {
return [];
}
- //TODO: Check we're clearing this cache
- $key = sprintf('user_%c_thumbnail_data', $user_id);
- $cache = $this->cache->get($key);
+ $cache = $this->cache->get($user_id);
if ($cache) {
return $cache;
}
@@ -261,7 +259,7 @@
'type' => jvbArtistType($link),
];
- $this->cache->set($key, $data);
+ $this->cache->set($user_id, $data);
return $data;
}
diff --git a/inc/rest/routes/ContentRoutes.php b/inc/rest/routes/ContentRoutes.php
index 6916093..ff056a9 100644
--- a/inc/rest/routes/ContentRoutes.php
+++ b/inc/rest/routes/ContentRoutes.php
@@ -5,6 +5,8 @@
use JVBase\rest\RestRouteManager;
use JVBase\managers\CacheManager;
use JVBase\meta\MetaManager;
+use JVBase\utility\Features;
+use WP_Post;
use WP_Query;
use WP_Error;
use WP_REST_Request;
@@ -185,7 +187,7 @@
}
$post_type = str_replace('-', '_',jvbCheckBase($params['content']));
- $config = (array_key_exists($params['content'], JVB_CONTENT) && !empty(JVB_CONTENT[$params['content']])) ? JVB_CONTENT[$params['content']] : [];
+ $config = Features::getConfig($params['content']);
@@ -199,9 +201,13 @@
'author' => $user_id,
'post_status' => $post_status
];
+ //Only top level posts for timeline types
+ if (Features::forContent($post_type)->has('is_timeline')) {
+ $args['post_parent'] = 0;
+ }
//Calendar filters
- if (jvbCheck('is_calendar', $config)) {
+ if (Features::forContent($post_type)->has('is_calendar')) {
$args = $this->applyCalendarFilters($args, $params);
}
if (array_key_exists('taxonomies', $params)) {
@@ -218,27 +224,26 @@
$args['s'] = sanitize_text_field($params['search']);
}
+
+
error_log('Content Routes final args: '.print_r($args, true));
$key = $this->cache->generateKey($args);
- $lastModified = $this->cache->getTimestamp($key);
- if ($lastModified !== false) {
- $headerCheck = $this->ifModifiedSince($lastModified, $args, $request);
- if (!is_null($headerCheck)) {
- return $headerCheck;
- }
- } else {
- // No timestamp yet, but we can still set ETag
- $etag = '"' . md5(serialize($args)) . '"';
- header('ETag: ' . $etag);
- header('Cache-Control: private, max-age=30');
+ // Check HTTP cache headers with the specific content type
+ $content_type = $params['content'] ?? $params['type'];
+ $cache_check = $this->checkHeaders($request, $content_type, [
+ 'filter_hash' => $key,
+ ]);
+ if ($cache_check) {
+ return $cache_check;
}
$cache = $this->cache->get($key);
$cache = false;
if ($cache) {
- return new WP_REST_Response($cache);
+ $response = new WP_REST_Response($cache);
+ return $this->addCacheHeaders($response);
}
// Run query
@@ -260,7 +265,8 @@
$this->cache->set($key, $data);
- return new WP_REST_Response($data);
+ $response = new WP_REST_Response($data);
+ return $this->addCacheHeaders($response);
}
/**
@@ -306,6 +312,10 @@
$results = [];
foreach ($posts as $ID => $post_data) {
+ if (Features::forContent($post_data['content'])->has('is_timeline')) {
+ $results[$ID] =$this->processTimelinePost($ID, $post_data);
+ continue;
+ }
if (str_starts_with($ID, 'new')) {
error_log('New post detected. Creating... with: '.print_r([
@@ -411,6 +421,97 @@
];
}
+ /**
+ * Extracts the postdata for timeline post child posts from the pseudo-repeater element
+ * @param int $parent_id
+ * @param array $post_data
+ * @return array|true[]
+ */
+ protected function processTimelinePost(int $parent_id, array $post_data):array
+ {
+ if (!$this->verifyOwnership($parent_id)) {
+ return ['success' => false, 'message' => 'No permission'];
+ }
+
+ $rows = $post_data['fields'] ?? [];
+ if (empty($rows)) {
+ return ['success' => false, 'message' => 'No data'];
+ }
+
+ $fields = jvbGetFields($post_data['content']);
+
+ // First row = parent post
+ $parent_row = array_shift($rows);
+ if (($parent_row['id'] ?? null) != $parent_id) {
+ return ['success' => false, 'message' => 'Parent ID mismatch'];
+ }
+
+ $allowedFields = array_filter($parent_row, function($key) use ($fields) {
+ return array_key_exists($key, $fields);
+ }, ARRAY_FILTER_USE_KEY);
+
+ $parentMeta = new MetaManager($parent_id, 'post');
+ $parentMeta->setAll($allowedFields);
+
+ // Get existing children to track deletions
+ $existing_children = get_children([
+ 'post_parent' => $parent_id,
+ 'post_type' => jvbCheckBase($post_data['content']),
+ 'fields' => 'ids'
+ ]);
+
+ $processed_ids = [];
+
+ // Process remaining rows as children
+ foreach ($rows as $index => $row_data) {
+ $row_id = $row_data['id'] ?? null;
+
+ // New child post
+ if (!$row_id || str_starts_with($row_id, 'new')) {
+ $child_id = wp_insert_post([
+ 'post_type' => jvbCheckBase($post_data['content']),
+ 'post_parent' => $parent_id,
+ 'post_author' => $this->user_id,
+ 'post_status' => $post_data['status'] ?? 'draft',
+ 'menu_order' => $index
+ ]);
+ }
+ // Existing child post
+ else {
+ $child_id = (int) $row_id;
+
+ // Verify ownership via parent
+ if (!in_array($child_id, $existing_children)) {
+ continue; // Skip if not actually a child of this parent
+ }
+
+ // Update menu_order (position may have changed)
+ wp_update_post([
+ 'ID' => $child_id,
+ 'menu_order' => $index
+ ]);
+ }
+
+ // Update child meta
+ $allowedChildFields = array_filter($row_data, function($key) use ($fields) {
+ return array_key_exists($key, $fields) && $key !== 'id' && $key !== 'draggable';
+ }, ARRAY_FILTER_USE_KEY);
+
+ $childMeta = new MetaManager($child_id, 'post');
+ $childMeta->setAll($allowedChildFields);
+
+ $processed_ids[] = $child_id;
+ }
+
+ // Delete removed children
+ $deleted_ids = array_diff($existing_children, $processed_ids);
+ foreach ($deleted_ids as $delete_id) {
+ wp_delete_post($delete_id, true);
+ }
+
+ return ['success' => true, 'processed' => $processed_ids];
+ }
+
/**
* Handle batch content creation from uploads
* @param WP_REST_Request $request
@@ -485,12 +586,15 @@
}
/**
- * @param object $post the wordpress post object
+ * @param WP_Post $post the wordpress post object
*
* @return array
*/
- protected function prepareItem(object $post):array
+ protected function prepareItem(WP_Post $post, $skip = false):array
{
+ if (!$skip && Features::forContent($post->post_type)->has('is_timeline')) {
+ return $this->formatTimeline($post);
+ }
$this->meta = new MetaManager($post->ID, 'post');
$data = [
'id' => $post->ID,
@@ -520,15 +624,25 @@
];
}
+ $images = $this->extractImages();
- //Extract images
+
+ if (!empty($images)) {
+ $data['images'] = $images;
+ }
+
+ return $data;
+ }
+ protected function extractImages():array
+ {
+ //Extract images
$images = [];
$get = [];
- foreach ($this->fields as $field => $config) {
- if ($config['type'] === 'gallery' || $config['type'] === 'image' || $field === 'post_thumbnail') {
+ foreach ($this->fields as $field => $config) {
+ if ($config['type'] === 'gallery' || $config['type'] === 'image' || $field === 'post_thumbnail') {
$get[] = $field;
- }
- }
+ }
+ }
if (!empty($get)) {
$allImages = $this->meta->getAll($get);
@@ -541,13 +655,42 @@
}
}
}
+ return $images;
+ }
- if (!empty($images)) {
- $data['images'] = $images;
- }
+ protected function formatTimeline(WP_Post $post):array
+ {
+ $data = $this->prepareItem($post, true);
+ $firstRow = $data['fields'];
+ $firstRow['id'] = $post->ID;
+ $firstRow['draggable'] = false;
+ $fields = [$firstRow];
- return $data;
- }
+ $children = get_children(['post_parent' => $post->ID, 'orderby' => 'menu_order']);
+ $allImages = [];
+
+ foreach ($children as $child) {
+ $this->meta = new MetaManager($child->ID, 'post');
+ $row = $this->meta->getAll(); // Store in variable first
+ $row['id'] = $child->ID; // Add ID to the row
+ $row['draggable'] = true; // Mark as draggable
+ $fields[] = $row; // Then append to fields
+
+ $images = $this->extractImages();
+ if (!empty($images)) {
+ $allImages = $allImages + $images;
+ }
+ }
+
+ if (!empty($allImages)) {
+ if (!array_key_exists('images', $data)) {
+ $data['images'] = [];
+ }
+ $data['images'] = $data['images'] + $allImages;
+ }
+ $data['fields']['timeline'] = $fields;
+ return $data;
+ }
/**
* Builds the taxonomy query
diff --git a/inc/rest/routes/FavouritesRoutes.php b/inc/rest/routes/FavouritesRoutes.php
index 5ab3310..b1483b3 100644
--- a/inc/rest/routes/FavouritesRoutes.php
+++ b/inc/rest/routes/FavouritesRoutes.php
@@ -121,17 +121,26 @@
'success' => false,
'message' => 'No user set'
];
- }elseif (count($args) === 1 || (array_key_exists('all', $args) && $args['all'] === true)) {
+ }
+ // Check HTTP cache headers for user-specific data
+ $cache_check = $this->checkUserHeaders($request, $args['user'], 'favourites');
+ if ($cache_check) {
+ return $cache_check;
+ }
+
+ if (count($args) === 1 || (array_key_exists('all', $args) && $args['all'] === true)) {
$result = $this->getAllFavourites($args['user']);
} else {
$result = $this->cache->remember(
$args,
function() use ($args) {
- return $this->getFilteredFavourites($args);
+ $response = new WP_REST_Response($this->getFilteredFavourites($args));
+ return $this->addCacheHeaders($response);
}
);
}
- return new WP_REST_Response($result);
+ $response = new WP_REST_Response($result);
+ return $this->addCacheHeaders($response);
}
protected function getFilteredFavourites(array $args):array
@@ -382,6 +391,20 @@
public function getLists(WP_REST_Request $request):WP_REST_Response
{
$user_id = get_current_user_id();
+
+ if (!$user_id || !$this->userCheck($user_id)) {
+ return new WP_REST_Response([
+ 'success' => false,
+ 'message' => 'Invalid user'
+ ]);
+ }
+
+ // Check HTTP cache headers
+ $cache_check = $this->checkUserHeaders($request, $user_id, 'favourites_lists');
+ if ($cache_check) {
+ return $cache_check;
+ }
+
$list_id = $request->get_param('id');
if ($list_id) {
@@ -390,7 +413,8 @@
$response = $this->getAvailableLists($user_id);
}
- return new WP_REST_Response($response);
+ $response = new WP_REST_Response($response);
+ return $this->addCacheHeaders($response);
}
/**
* Get lists available to a user (owned and shared)
@@ -798,8 +822,21 @@
*/
public function getShares(WP_REST_Request $request):WP_REST_Response
{
- $list_id = $request->get_param('list_id');
- $user_id = get_current_user_id();
+ $user_id = $request->get_param('user');
+
+ if (!$user_id || !$this->userCheck($user_id)) {
+ return new WP_REST_Response([
+ 'success' => false,
+ 'message' => 'Invalid user'
+ ]);
+ }
+
+ // Check HTTP cache headers
+ $cache_check = $this->checkUserHeaders($request, $user_id, 'favourites_shares');
+ if ($cache_check) {
+ return $cache_check;
+ }
+ $list_id = $request->get_param('list_id');
if (!$list_id) {
return $this->createErrorResponse(
@@ -891,7 +928,8 @@
// Cache the results
$this->cache->set($key, $response_data, 'favourites_list_shares');
- return new WP_REST_Response($response_data);
+ $response = new WP_REST_Response($response_data);
+ return $this->addCacheHeaders($response);
} catch (Exception $e) {
return $this->createErrorResponse(
diff --git a/inc/rest/routes/FeedRoutes.php b/inc/rest/routes/FeedRoutes.php
index eb7024e..90993b4 100644
--- a/inc/rest/routes/FeedRoutes.php
+++ b/inc/rest/routes/FeedRoutes.php
@@ -319,6 +319,20 @@
error_log('Final Args: '.print_r($args, true));
+ // Determine content type(s) for cache checking
+ $content_types = [];
+ if (!empty($data['content'])) {
+ $content_types[] = $data['content'];
+ }
+ if (!empty($data['type'])) {
+ $types = is_array($data['type']) ? $data['type'] : [$data['type']];
+ $content_types = array_merge($content_types, $types);
+ }
+ // Check HTTP cache headers first
+ $cache_check = $this->checkHeaders($request, $content_types ?: ['feed']);
+ if ($cache_check) {
+ return $cache_check; // Returns 304 Not Modified
+ }
$key = $this->cache->generateKey($args);
$cached = $this->cache->get($key);
@@ -328,7 +342,8 @@
$args['highlight'] = $highlight;
}
$cached['items'] = $this->processHighlightedItem($cached['items'], $args);
- return new WP_REST_Response($cached);
+ $response = new WP_REST_Response($cached);
+ return $this->addCacheHeaders($response);
}
// Fetch and format items
$items = $this->fetchFeedItems($args);
@@ -343,7 +358,8 @@
}
$items['items'] = $this->processHighlightedItem($items['items'], $args);
- return new WP_REST_Response($items);
+ $response = new WP_REST_Response($items);
+ return $this->addCacheHeaders($response);
}
/**
diff --git a/inc/rest/routes/FormRoutes.php b/inc/rest/routes/FormRoutes.php
index 0e5cfd2..198e702 100644
--- a/inc/rest/routes/FormRoutes.php
+++ b/inc/rest/routes/FormRoutes.php
@@ -30,7 +30,7 @@
{
parent::__construct();
$this->action = 'form-';
- $this->cache = new CacheManager('form_submissions', HOUR_IN_SECONDS);
+ $this->cache = CacheManager::for('forms', HOUR_IN_SECONDS);
// Initialize Cloudflare Turnstile if available
$this->turnstile = class_exists('JVBase\managers\CloudflareTurnstile') && jvbSiteUsesCloudflare()
@@ -159,7 +159,7 @@
}
// Store submission data temporarily for success display
- $this->cache->set('submission_' . $form_id, $processed_data, HOUR_IN_SECONDS);
+ $this->cache->set('submission_' . $form_id, $processed_data);
// Log successful submission
$this->recordSubmission($_SERVER['REMOTE_ADDR'], $processed_data['email'] ?? '');
diff --git a/inc/rest/routes/ImporterRoutes.php b/inc/rest/routes/ImporterRoutes.php
new file mode 100644
index 0000000..1dcc50b
--- /dev/null
+++ b/inc/rest/routes/ImporterRoutes.php
@@ -0,0 +1,326 @@
+<?php
+
+namespace JVBase\routes;
+
+use JVBase\managers\JaneClientImporter;
+use JVBase\managers\JaneSalesImporter;
+use WP_REST_Request;
+use WP_REST_Response;
+use WP_Error;
+
+if (!defined('ABSPATH')) {
+ exit;
+}
+
+/**
+ * JaneApp Import Routes
+ *
+ * REST API endpoints for importing JaneApp data
+ */
+class JaneImportRoutes
+{
+ protected string $namespace;
+
+ public function __construct()
+ {
+ $this->namespace = BASE . 'v1';
+ }
+
+ /**
+ * Register REST routes
+ */
+ public function registerRoutes(): void
+ {
+ // Client import endpoint
+ register_rest_route($this->namespace, '/jane/import-clients', [
+ 'methods' => 'POST',
+ 'callback' => [$this, 'importClients'],
+ 'permission_callback' => [$this, 'checkAdminPermission'],
+ 'args' => [
+ 'file' => [
+ 'required' => true,
+ 'description' => 'CSV file containing client data'
+ ],
+ 'options' => [
+ 'required' => false,
+ 'default' => [],
+ 'description' => 'Import options'
+ ]
+ ]
+ ]);
+
+ // Sales import endpoint
+ register_rest_route($this->namespace, '/jane/import-sales', [
+ 'methods' => 'POST',
+ 'callback' => [$this, 'importSales'],
+ 'permission_callback' => [$this, 'checkAdminPermission'],
+ 'args' => [
+ 'file' => [
+ 'required' => true,
+ 'description' => 'CSV file containing sales data'
+ ],
+ 'options' => [
+ 'required' => false,
+ 'default' => [],
+ 'description' => 'Import options'
+ ]
+ ]
+ ]);
+
+ // Get import status
+ register_rest_route($this->namespace, '/jane/import-status/(?P<id>[\w-]+)', [
+ 'methods' => 'GET',
+ 'callback' => [$this, 'getImportStatus'],
+ 'permission_callback' => [$this, 'checkAdminPermission']
+ ]);
+ }
+
+ /**
+ * Check if user has admin permissions
+ */
+ public function checkAdminPermission(): bool
+ {
+ return current_user_can('manage_options');
+ }
+
+ /**
+ * Import clients from CSV
+ *
+ * @param WP_REST_Request $request
+ * @return WP_REST_Response|WP_Error
+ */
+ public function importClients(WP_REST_Request $request)
+ {
+ // Get uploaded file
+ $files = $request->get_file_params();
+ if (empty($files['file'])) {
+ return new WP_Error('no_file', 'No file uploaded', ['status' => 400]);
+ }
+
+ $file = $files['file'];
+
+ // Validate file type
+ if (!$this->isValidCSV($file)) {
+ return new WP_Error('invalid_file', 'Invalid file type. Please upload a CSV file.', ['status' => 400]);
+ }
+
+ // Get options
+ $options = $request->get_param('options') ?: [];
+ $default_options = [
+ 'update_existing' => true,
+ 'create_users' => true,
+ 'send_welcome_email' => false
+ ];
+ $options = wp_parse_args($options, $default_options);
+
+ // Process import
+ $importer = new JaneClientImporter();
+ $results = $importer->importFromCSV($file['tmp_name'], $options);
+
+ if (is_wp_error($results)) {
+ return new WP_Error(
+ 'import_failed',
+ $results->get_error_message(),
+ ['status' => 500]
+ );
+ }
+
+ // Store results in transient for status checking
+ $import_id = wp_generate_password(12, false);
+ set_transient('jane_import_' . $import_id, [
+ 'type' => 'clients',
+ 'results' => $results,
+ 'completed_at' => current_time('mysql')
+ ], HOUR_IN_SECONDS);
+
+ return new WP_REST_Response([
+ 'success' => true,
+ 'import_id' => $import_id,
+ 'results' => $results,
+ 'summary' => $this->generateClientImportSummary($results)
+ ], 200);
+ }
+
+ /**
+ * Import sales from CSV
+ *
+ * @param WP_REST_Request $request
+ * @return WP_REST_Response|WP_Error
+ */
+ public function importSales(WP_REST_Request $request)
+ {
+ // Get uploaded file
+ $files = $request->get_file_params();
+ if (empty($files['file'])) {
+ return new WP_Error('no_file', 'No file uploaded', ['status' => 400]);
+ }
+
+ $file = $files['file'];
+
+ // Validate file type
+ if (!$this->isValidCSV($file)) {
+ return new WP_Error('invalid_file', 'Invalid file type. Please upload a CSV file.', ['status' => 400]);
+ }
+
+ // Get options
+ $options = $request->get_param('options') ?: [];
+ $default_options = [
+ 'skip_existing' => true
+ ];
+ $options = wp_parse_args($options, $default_options);
+
+ // Process import
+ $importer = new JaneSalesImporter();
+ $results = $importer->importFromCSV($file['tmp_name'], $options);
+
+ if (is_wp_error($results)) {
+ return new WP_Error(
+ 'import_failed',
+ $results->get_error_message(),
+ ['status' => 500]
+ );
+ }
+
+ // Store results in transient for status checking
+ $import_id = wp_generate_password(12, false);
+ set_transient('jane_import_' . $import_id, [
+ 'type' => 'sales',
+ 'results' => $results,
+ 'completed_at' => current_time('mysql')
+ ], HOUR_IN_SECONDS);
+
+ return new WP_REST_Response([
+ 'success' => true,
+ 'import_id' => $import_id,
+ 'results' => $results,
+ 'summary' => $this->generateSalesImportSummary($results)
+ ], 200);
+ }
+
+ /**
+ * Get import status by ID
+ *
+ * @param WP_REST_Request $request
+ * @return WP_REST_Response|WP_Error
+ */
+ public function getImportStatus(WP_REST_Request $request)
+ {
+ $import_id = $request->get_param('id');
+ $import_data = get_transient('jane_import_' . $import_id);
+
+ if (!$import_data) {
+ return new WP_Error(
+ 'import_not_found',
+ 'Import not found or expired',
+ ['status' => 404]
+ );
+ }
+
+ return new WP_REST_Response([
+ 'success' => true,
+ 'data' => $import_data
+ ], 200);
+ }
+
+ /**
+ * Validate CSV file
+ *
+ * @param array $file Uploaded file data
+ * @return bool
+ */
+ protected function isValidCSV(array $file): bool
+ {
+ // Check file extension
+ $ext = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION));
+ if ($ext !== 'csv') {
+ return false;
+ }
+
+ // Check MIME type
+ $allowed_types = ['text/csv', 'text/plain', 'application/csv', 'application/vnd.ms-excel'];
+ if (!in_array($file['type'], $allowed_types)) {
+ return false;
+ }
+
+ // Check if file is actually readable as CSV
+ $handle = fopen($file['tmp_name'], 'r');
+ if (!$handle) {
+ return false;
+ }
+
+ $header = fgetcsv($handle);
+ fclose($handle);
+
+ return !empty($header);
+ }
+
+ /**
+ * Generate human-readable summary of client import
+ *
+ * @param array $results Import results
+ * @return string
+ */
+ protected function generateClientImportSummary(array $results): string
+ {
+ $summary = [];
+
+ if ($results['created'] > 0) {
+ $summary[] = "{$results['created']} new users created";
+ }
+
+ if ($results['updated'] > 0) {
+ $summary[] = "{$results['updated']} existing users updated";
+ }
+
+ if ($results['skipped'] > 0) {
+ $summary[] = "{$results['skipped']} rows skipped";
+ }
+
+ if (count($results['errors']) > 0) {
+ $summary[] = count($results['errors']) . " errors encountered";
+ }
+
+ if (count($results['unmatched_emails']) > 0) {
+ $summary[] = count($results['unmatched_emails']) . " unmatched emails";
+ }
+
+ return implode('. ', $summary) . '.';
+ }
+
+ /**
+ * Generate human-readable summary of sales import
+ *
+ * @param array $results Import results
+ * @return string
+ */
+ protected function generateSalesImportSummary(array $results): string
+ {
+ $summary = [];
+
+ if ($results['consultations'] > 0) {
+ $summary[] = "{$results['consultations']} consultations processed";
+ }
+
+ if ($results['treatments'] > 0) {
+ $summary[] = "{$results['treatments']} treatments recorded";
+ }
+
+ if ($results['skipped'] > 0) {
+ $summary[] = "{$results['skipped']} rows skipped";
+ }
+
+ if (count($results['errors']) > 0) {
+ $summary[] = count($results['errors']) . " errors encountered";
+ }
+
+ if (count($results['unmatched_guids']) > 0) {
+ $summary[] = count($results['unmatched_guids']) . " unmatched patient GUIDs";
+ }
+
+ if (count($results['no_referral']) > 0) {
+ $summary[] = count($results['no_referral']) . " users without referral records";
+ }
+
+ return implode('. ', $summary) . '.';
+ }
+}
diff --git a/inc/rest/routes/Invitations.php b/inc/rest/routes/Invitations.php
index 401cb07..1372cb4 100644
--- a/inc/rest/routes/Invitations.php
+++ b/inc/rest/routes/Invitations.php
@@ -48,6 +48,23 @@
// Add hooks for processing accepted invitations
add_action('user_register', [$this, 'checkInvitation'], 10, 1);
+ add_action('jvbLoginManagerInit', function($loginManager) {
+ $loginManager->registerTokenHandler('invite', function($token, $email, $user_id) {
+ JVB()->routes('invites')->acceptInvitation($token, $email, $user_id);
+ });
+
+ $loginManager->registerMessageHandler('invitation',
+ function() {
+ return '<h2>You\'ve been invited!</h2><p>Create your account to accept.</p>';
+ },
+ function() {
+ return isset($_GET['invite']);
+ }
+ );
+ });
+
+
+
add_action('jvb_daily_maintenance', [$this, 'cleanupExpiredInvitations']);
// Add filter for bulk operation handling
diff --git a/inc/rest/routes/NotificationsRoutes.php b/inc/rest/routes/NotificationsRoutes.php
index cbf36fc..ea6fa2c 100644
--- a/inc/rest/routes/NotificationsRoutes.php
+++ b/inc/rest/routes/NotificationsRoutes.php
@@ -379,15 +379,19 @@
{
$data = $request->get_params();
$user_id = $data['user'];
- if (!$this->userCheck($user_id)) {
- $this->logError("Invalid user ID for notifications", [
- 'user' => $user_id
- ], 'warning');
- return new WP_REST_Response([
- 'success' => false,
- 'message' => 'User doesn\'t match. Are you a bot?'
- ]);
- }
+ if (!$this->userCheck($user_id)) {
+ $this->logError("Invalid user ID for notifications", ['user' => $user_id], 'warning');
+ return new WP_REST_Response([
+ 'success' => false,
+ 'message' => 'User doesn\'t match. Are you a bot?'
+ ]);
+ }
+
+ // Check HTTP cache headers (includes notification types in timestamp check)
+ $cache_check = $this->checkUserHeaders($request, $user_id, 'notifications');
+ if ($cache_check) {
+ return $cache_check;
+ }
// Step 1: Build status/order/filter params
$params = $this->getSanitizedData($user_id, $data);
@@ -400,7 +404,8 @@
$cache_key = "user_{$user_id}_merged_notifications_{$status}_{$type}_{$limit}_{$offset}";
$cached = $this->cache->get($cache_key);
if ($cached) {
- return new WP_REST_Response($cached);
+ $response = new WP_REST_Response($cached);
+ return $this->addCacheHeaders($response);
}
try {
@@ -444,7 +449,8 @@
// Cache the result
$this->cache->set($cache_key, $response, 'notifications_' . $user_id);
- return new WP_REST_Response($response);
+ $response = new WP_REST_Response($response);
+ return $this->addCacheHeaders($response);
} catch (Exception $e) {
$this->logError("Error retrieving notifications", [
'user_id' => $user_id,
diff --git a/inc/rest/routes/OptionsRoutes.php b/inc/rest/routes/OptionsRoutes.php
index af77ddb..ebf18d6 100644
--- a/inc/rest/routes/OptionsRoutes.php
+++ b/inc/rest/routes/OptionsRoutes.php
@@ -109,8 +109,8 @@
do_action('jvbOptionsRoute', $data);
- $cache = new CacheManager('options', 1800);
- $cache::invalidateGroup('options');
+ $cache = CacheManager::for('options', 1800);
+ $cache->invalidate();
return [
'success' => true,
'result' => $results
diff --git a/inc/rest/routes/SettingsRoutes.php b/inc/rest/routes/SettingsRoutes.php
index f02fad9..811984a 100644
--- a/inc/rest/routes/SettingsRoutes.php
+++ b/inc/rest/routes/SettingsRoutes.php
@@ -48,10 +48,9 @@
*/
public function saveSettings(WP_REST_Request $request):WP_REST_Response
{
- $this->queue = JVB()->queue();
$data = $request->get_params();
-
+ error_log('User: '.print_r($data['user'], true));
error_log('Settings routes data: '.print_r($data, true));
$user_id = (int)$data['user'];
if (!$this->userCheck($user_id)) {
@@ -61,9 +60,8 @@
]);
}
+ $this->queue = JVB()->queue();
- $operation_id = $data['id'];
- unset($data['id']);
$fields = JVB()->getFields('user');
$meta = new MetaSanitizer();
@@ -83,8 +81,7 @@
//Sanitize values
$data[$name] = $meta->sanitize($value, $fields[$name]);
if ($name === 'notify') {
- $cache = new CacheManager('usernames');
- $cache->invalidate($user_id);
+ CacheManager::for('usernames')->delete($user_id);
}
}
}
@@ -93,12 +90,11 @@
}
$this->queue->queueOperation(
- $this->type,
+ 'user_settings',
$user_id,
$data,
[
'count' => 1,
- 'operation_id' => $operation_id
]
);
@@ -106,7 +102,6 @@
return new WP_REST_Response([
'success' =>true,
'message' => 'Request received and queued',
- 'operation_id' => $operation_id,
'status' => 'queued'
]);
}
@@ -154,7 +149,7 @@
}
}
- CacheManager::invalidateGroup($this->cache_name);
+ CacheManager::for($this->cache_name)->invalidate();
}
return [
'success' => true,
@@ -223,7 +218,7 @@
// Success - commit transaction
$wpdb->query('COMMIT');
- CacheManager::invalidateGroup($this->cache_name);
+ CacheManager::for($this->cache_name)->invalidate();
return [
'success' => true,
'result' => 'Notification preferences updated successfully'
diff --git a/inc/rest/routes/TermRoutes.php b/inc/rest/routes/TermRoutes.php
index da8f52c..7526566 100644
--- a/inc/rest/routes/TermRoutes.php
+++ b/inc/rest/routes/TermRoutes.php
@@ -149,6 +149,14 @@
public function getTermDetails(WP_REST_Request $request):WP_REST_Response
{
$data = $request->get_params();
+ // Collect all taxonomies being queried
+ $taxonomies = array_keys($data);
+
+ // Check HTTP cache headers
+ $cache_check = $this->checkHeaders($request, $taxonomies);
+ if ($cache_check) {
+ return $cache_check;
+ }
$terms = [];
foreach ($data as $tax => $IDs) {
$args = [
@@ -158,9 +166,10 @@
$terms[$tax] = $this->formatTerms($args, BASE.$tax);
}
- return new WP_REST_Response([
+ $response = new WP_REST_Response([
'items' => $terms,
]);
+ return $this->addCacheHeaders($response);
}
/**
@@ -173,6 +182,14 @@
$data = $request->get_params();
$taxonomy = jvbCheckBase($data['taxonomy']);
+ error_log('Term Request Data for '.$taxonomy.': '.print_r($data, true));
+ // Check HTTP cache headers
+ $cache_check = $this->checkHeaders($request, $taxonomy);
+ if ($cache_check) {
+ error_log('Header Check failed');
+ return $cache_check;
+ }
+
if (array_key_exists('termIDs', $data)) {
$args = [
'taxonomy' => $taxonomy,
@@ -182,7 +199,8 @@
$key = $this->cache->generateKey($args);
$cached = $this->cache->get($key);
if ($cached) {
- return new WP_REST_Response($cached);
+ $response = new WP_REST_Response($cached);
+ return $this->addCacheHeaders($response);
}
$formatted = $this->formatTerms($args, $taxonomy);
@@ -190,14 +208,16 @@
'items' => $formatted
];
$this->cache->set($key, $response);
- return new WP_REST_Response($response);
+ $response = new WP_REST_Response($response);
+ return $this->addCacheHeaders($response);
}
if (array_key_exists('content', $data)) {
// If content_type is provided, use the specialized endpoint
$content_type = $request->get_param('content');
global $feed_types;
if (taxIsJVBContentTax($content_type)) {
- return $this->getTermsForContentType($request);
+ $response = $this->getTermsForContentType($request);
+ return $this->addCacheHeaders($response);
}
}
@@ -225,7 +245,9 @@
// If searching, handle differently
if (!empty($search)) {
- return $this->handleTermSearch($request);
+ error_log('Handling search...');
+ $response = $this->handleTermSearch($request);
+ return $this->addCacheHeaders($response);
}
// Get terms for current level with child count
@@ -248,7 +270,7 @@
$related = $manager->getUserTermIDs($userID, $taxonomy);
if (empty($related)) {
- return new WP_REST_Response([
+ $response = new WP_REST_Response([
'items' => [],
'pagination' => [
'page' => 1,
@@ -258,6 +280,7 @@
'has_more' => false
]
]);
+ return $this->addCacheHeaders($response);
}
$args['include'] = $related;
@@ -270,7 +293,7 @@
$related = $manager->getRelatedTerms($ID, BASE.$request->get_param('taxonomy'));
if (empty($related)) {
- return new WP_REST_Response([
+ $response = new WP_REST_Response([
'items' => [],
'pagination' => [
'page' => 1,
@@ -280,6 +303,7 @@
'has_more' => false
]
]);
+ return $this->addCacheHeaders($response);
}
$args['tax_query'] = [
'taxonomy' => $taxonomy,
@@ -328,7 +352,7 @@
$args['include'] = $related_term_ids;
} else {
// No related terms found, return empty result
- return new WP_REST_Response([
+ $response = new WP_REST_Response([
'items' => [],
'pagination' => [
'page' => 1,
@@ -338,6 +362,8 @@
'has_more' => false
]
]);
+
+ return $this->addCacheHeaders($response);
}
}
@@ -347,7 +373,8 @@
$cache = $this->cache->get($key);
$cache = false;
if ($cache) {
- return $cache;
+ $response = new WP_ReST_Response($cache);
+ return $this->addCacheHeaders($response);
}
$formatted_terms = $this->formatTerms($args, $taxonomy);
@@ -375,7 +402,8 @@
];
$this->cache->set($key, $response);
- return new WP_REST_Response($response);
+ $response = new WP_REST_Response($response);
+ return $this->addCacheHeaders($response);
}
/**
diff --git a/inc/rest/routes/UploadRoutes.php b/inc/rest/routes/UploadRoutes.php
index e47b5e9..56daf36 100644
--- a/inc/rest/routes/UploadRoutes.php
+++ b/inc/rest/routes/UploadRoutes.php
@@ -277,7 +277,7 @@
unset($context['upload_ids']);
$config = $this->getFieldConfig($args);
-
+ error_log('secureFiles: '.print_r($files, true));
$file_array = $files['files'] ?? $files;
$tmp_names = isset($file_array['tmp_name'][0]) && is_array($file_array['tmp_name'][0])
? $file_array['tmp_name'][0]
@@ -1092,6 +1092,11 @@
$args = $this->buildUploadArgs($request);
$data = $request->get_params();
+
+ error_log('[UploadRoutes]:handleGroupingRequest: data'.print_r($data, true));
+ error_log('[UploadRoutes]:handleGroupingRequest: args'.print_r($args, true));
+
+
if (!$args['content'] || !$args['user'] || !$args['posts']) {
$this->logError('Missing required data');
@@ -1215,6 +1220,9 @@
}
$content = jvbCheckBase($data['content']);
+ if (Features::forContent($data['content'])->has('is_timeline')) {
+ return $this->processTimelineUploads($data, $uploads, $all_uploaded_images, $operation);
+ }
$user = (int)$data['user'];
$created_posts = [];
$used_upload_ids = [];
@@ -1229,13 +1237,15 @@
? sanitize_textarea_field($post['fields']['post_excerpt'])
: '';
- $new_post_id = wp_insert_post([
+ $args =[
'post_type' => $content,
'post_author' => $user,
'post_status' => 'draft',
'post_title' => $post_title,
'post_excerpt' => $post_excerpt,
- ]);
+ ];
+
+ $new_post_id = wp_insert_post($args);
if ($new_post_id && !is_wp_error($new_post_id)) {
$created_posts[] = $new_post_id;
@@ -1312,6 +1322,103 @@
}
}
+ protected function processTimelineUploads(array $data, array $uploads, array $uploadMap, object $operation):array
+ {
+ try {
+ $user = (int)$data['user'];
+ $created_posts = [];
+ $used_upload_ids = [];
+
+ $content = jvbCheckBase($data['content']);
+ $config = Features::getConfig($content);
+ $defaultTitle = 'New '.$config['singular']. ' ';
+ foreach ($data['posts'] as $index=> $post) {
+ $title = !empty($post['fields']['post_title'])
+ ? sanitize_text_field($post['fields']['post_title'])
+ : $defaultTitle.($index + 1);
+ $excerpt = !empty($post['fields']['post_excerpt'])
+ ? sanitize_textarea_field($post['fields']['post_excerpt'])
+ : '';
+
+ $args =[
+ 'post_type' => $content,
+ 'post_author' => $user,
+ 'post_status' => 'draft',
+ 'post_title' => $title,
+ 'post_excerpt' => $excerpt
+ ];
+ $parent = wp_insert_post($args);
+
+ if ($parent && !is_wp_error($parent)) {
+ //Get the attachment IDs first
+ $childPosts = [];
+ $featured = $post['fields']['featured']??null;
+ $featuredID = null;
+ foreach ($post['images'] as $key => $img) {
+ $upload_id = $img['upload_id'];
+ $used_upload_ids[] = $upload_id;
+
+ if (isset($uploadMap[$upload_id])) {
+ $attachment_id = (int)$uploadMap[$upload_id]['attachment_id'];
+ if ($upload_id === $featured) {
+ $featuredID = $attachment_id;
+ } else {
+ $childPosts[] = $attachment_id;
+ }
+ }
+ }
+ // Set the featured image for the parent
+ if ($featuredID) {
+ set_post_thumbnail($parent, $featuredID);
+ } elseif (!empty($childPosts)) {
+ //use first image if no set featured
+ set_post_thumbnail($parent, (int)$childPosts[0]);
+ array_shift($childPosts);
+ }
+
+ //Create Child Posts
+ if (!empty($childPosts)) {
+ $args['post_parent'] = $parent;
+ $created_posts[$parent] = [];
+ foreach ($childPosts as $i => $imgID) {
+ $treatment = $i + 1;
+ $childTitle = $title.' - Treatment '.$treatment;
+ $childDesc = '';
+ $args['post_title'] = $childTitle;
+ $args['post_excerpt'] = $childDesc;
+ $child = wp_insert_post($args);
+ if ($child && !is_wp_error($child)) {
+ $created_posts[$parent][] = $child;
+ set_post_thumbnail($child, $imgID);
+ }
+ }
+ }
+ }
+ }
+ return [
+ 'success' => true,
+ 'result' => [
+ 'created_posts' => $created_posts,
+ 'used_images' => $used_upload_ids
+ ]
+ ];
+ } catch (Exception $e) {
+ JVB()->error()->log(
+ '[UploadRoutes]:processTimelineUploads',
+ $e->getMessage(),
+ [
+ 'operation_id' => $operation->id,
+ 'user_id' => $operation->user_id
+ ]
+ );
+
+ return [
+ 'success' => false,
+ 'result' => $e->getMessage()
+ ];
+ }
+ }
+
protected function cleanupUnusedImages(array $unused_images): array
{
$cleaned_count = 0;
diff --git a/inc/utility/Features.php b/inc/utility/Features.php
index 9eaa314..d5f76bb 100644
--- a/inc/utility/Features.php
+++ b/inc/utility/Features.php
@@ -22,7 +22,7 @@
const CONTENT_FEATURES = [
'hide_single', 'show_feed', 'show_directory', 'karma',
'favouritable', 'responses', 'is_calendar', 'single_image',
- 'redirectToAuthor', 'syncWithSquare', 'approve_new'
+ 'redirectToAuthor', 'syncWithSquare', 'approve_new', 'is_gallery'
];
const TAXONOMY_FEATURES = [
@@ -297,7 +297,6 @@
if (isset(self::$globalCache[$cacheKey])) {
return self::$globalCache[$cacheKey];
}
-
foreach (JVB_CONTENT as $slug => $config) {
$flags = new self($config, 'content', $slug);
if ($flags->has($feature)) {
@@ -503,6 +502,284 @@
};
}
+ /*****************************************************************
+ * Dashboard Utilitiies
+ *****************************************************************/
+ /**
+ * Get content types that a user role can create
+ * Extracts and flattens from 'can_create' config
+ *
+ * @return array Array of content type slugs
+ *
+ * Usage:
+ * Features::forUser('artist')->getCreatableContent()
+ * // Returns: ['tattoo', 'piercing', 'artwork']
+ */
+ public function getCreatableContent(): array
+ {
+ if ($this->type !== 'user') {
+ return [];
+ }
+
+ $canCreate = $this->getValue('can_create', []);
+
+ if (empty($canCreate)) {
+ return [];
+ }
+
+ $content = [];
+
+ foreach ($canCreate as $item) {
+ if (is_array($item)) {
+ // Handle nested arrays like ['shop' => ['tattoo', 'piercing']]
+ foreach ($item as $type => $contents) {
+ $content = array_merge($content, $contents);
+ }
+ } else {
+ // Handle simple strings
+ $content[] = $item;
+ }
+ }
+
+ return array_unique($content);
+ }
+
+ /**
+ * Get all dashboard pages for a user role
+ * Includes profile, creatable content, and settings
+ *
+ * @return array Array of page slugs
+ *
+ * Usage:
+ * Features::forUser('artist')->getDashboardPages()
+ * // Returns: ['artist-profile', 'tattoo', 'piercing', 'settings']
+ */
+ public function getDashboardPages(): array
+ {
+ if ($this->type !== 'user') {
+ return [];
+ }
+
+ $pages = [];
+
+ // Add profile page if configured
+ $profile = $this->getValue('profile');
+ if ($profile) {
+ $pages[] = $profile;
+ }
+
+ // Add creatable content types
+ $pages = array_merge($pages, $this->getCreatableContent());
+
+ // Add settings if user has dashboard
+ if ($this->has('has_dashboard')) {
+ $pages[] = 'settings';
+ }
+
+ return array_unique($pages);
+ }
+
+ /**
+ * Check if user role can create a specific content type
+ *
+ * @param string $contentType
+ * @return bool
+ *
+ * Usage:
+ * Features::forUser('artist')->canCreate('tattoo') // true/false
+ */
+ public function canCreate(string $contentType): bool
+ {
+ return in_array($contentType, $this->getCreatableContent());
+ }
+
+ /**
+ * Get the profile type for a user role
+ *
+ * @return string|null Profile slug or null if none
+ *
+ * Usage:
+ * Features::forUser('artist')->getProfile() // 'artist-profile'
+ */
+ public function getProfile(): ?string
+ {
+ if ($this->type !== 'user') {
+ return null;
+ }
+
+ return $this->getValue('profile');
+ }
+
+ /**
+ * Check if user role has a profile page
+ *
+ * @return bool
+ *
+ * Usage:
+ * Features::forUser('artist')->hasProfile() // true/false
+ */
+ public function hasProfile(): bool
+ {
+ return $this->getProfile() !== null;
+ }
+
+ /**
+ * Get content types grouped by parent type (if nested)
+ *
+ * @return array Associative array with parent types as keys
+ *
+ * Usage:
+ * Features::forUser('artist')->getGroupedContent()
+ * // Returns: ['shop' => ['tattoo', 'piercing'], 'standalone' => ['artwork']]
+ */
+ public function getGroupedContent(): array
+ {
+ if ($this->type !== 'user') {
+ return [];
+ }
+
+ $canCreate = $this->getValue('can_create', []);
+
+ if (empty($canCreate)) {
+ return [];
+ }
+
+ $grouped = [];
+
+ foreach ($canCreate as $item) {
+ if (is_array($item)) {
+ // Handle nested arrays like ['shop' => ['tattoo', 'piercing']]
+ foreach ($item as $parent => $contents) {
+ if (!isset($grouped[$parent])) {
+ $grouped[$parent] = [];
+ }
+ $grouped[$parent] = array_merge($grouped[$parent], $contents);
+ }
+ } else {
+ // Handle simple strings - add to 'standalone'
+ if (!isset($grouped['standalone'])) {
+ $grouped['standalone'] = [];
+ }
+ $grouped['standalone'][] = $item;
+ }
+ }
+
+ return $grouped;
+ }
+
+ /**
+ * Static method to get all content types across all user roles
+ *
+ * @return array Array of unique content type slugs
+ *
+ * Usage:
+ * Features::getAllUserContent()
+ * // Returns: ['tattoo', 'piercing', 'artwork', 'event', ...]
+ */
+ public static function getAllUserContent(): array
+ {
+ $allContent = [];
+
+ foreach (JVB_USER as $slug => $config) {
+ $features = self::forUser($slug);
+ $allContent = array_merge($allContent, $features->getCreatableContent());
+ }
+
+ return array_unique($allContent);
+ }
+
+ /**
+ * Static method to get all user roles that can create specific content
+ *
+ * @param string $contentType
+ * @return array Array of role slugs
+ *
+ * Usage:
+ * Features::getRolesForContent('tattoo')
+ * // Returns: ['artist', 'shop']
+ */
+ public static function getRolesForContent(string $contentType): array
+ {
+ $roles = [];
+
+ foreach (JVB_USER as $slug => $config) {
+ $features = self::forUser($slug);
+ if ($features->canCreate($contentType)) {
+ $roles[] = $slug;
+ }
+ }
+
+ return $roles;
+ }
+
+ /**
+ * Get all dashboard pages across all user roles
+ *
+ * @return array Array of unique page slugs
+ *
+ * Usage:
+ * Features::getAllDashboardPages()
+ * // Returns: ['artist-profile', 'shop-profile', 'tattoo', 'piercing', ...]
+ */
+ public static function getAllDashboardPages(): array
+ {
+ $allPages = [];
+
+ foreach (JVB_USER as $slug => $config) {
+ $features = self::forUser($slug);
+ $allPages = array_merge($allPages, $features->getDashboardPages());
+ }
+
+ return array_unique($allPages);
+ }
+
+ public static function getType(string $slug):?string
+ {
+ if (array_key_exists($slug, JVB_CONTENT)) {
+ return 'content';
+ }
+ if (array_key_exists($slug, JVB_USER)) {
+ return 'user';
+ }
+ if (array_key_exists($slug, JVB_TAXONOMY)) {
+ return 'taxonomy';
+ }
+ if (array_key_exists($slug, JVB_OPTIONS)) {
+ return 'option';
+ }
+ return null;
+ }
+
+ public static function getConfig(string $slug, ?string $type = null): array
+ {
+ $all = ['post', 'content', 'taxonomy', 'user'];
+ $types = (!$type || !in_array($type, $all)) ? $all : [$type];
+
+ foreach($types as $type) {
+ switch($type) {
+ case 'post':
+ case 'content':
+ if (array_key_exists($slug, JVB_CONTENT)) {
+ return JVB_CONTENT[$slug];
+ }
+ break;
+ case 'taxonomy':
+ if (array_key_exists($slug, JVB_TAXONOMY)) {
+ return JVB_TAXONOMY[$slug];
+ }
+ break;
+ case 'user':
+ if (array_key_exists($slug, JVB_USER)) {
+ return JVB_USER[$slug];
+ }
+ break;
+ default:
+ return [];
+ }
+ }
+ error_log('No config found for: '.$slug);
+ return [];
+ }
/**
* Generate cache key
*/
diff --git a/inc/utility/Validator.php b/inc/utility/Validator.php
index 665a502..6cd291f 100644
--- a/inc/utility/Validator.php
+++ b/inc/utility/Validator.php
@@ -365,10 +365,10 @@
break;
case 'repeater':
- if (empty($config['sub_fields'])) {
+ if (empty($config['fields'])) {
$this->addError(
"{$path}.fields.{$fieldName}",
- "Repeater field requires 'sub_fields' array"
+ "Repeater field requires 'fields' definition array"
);
}
break;
diff --git a/jvb.php b/jvb.php
index fb13350..9fd28df 100644
--- a/jvb.php
+++ b/jvb.php
@@ -21,7 +21,7 @@
const JVB_LOCAL = 'northeh.test';
add_filter('show_admin_bar', '__return_false');
-const JVB_TESTING = false;
+const JVB_TESTING = true;
const JVB_DIR = WP_PLUGIN_DIR . '/jvb';
define('JVB_URL', plugin_dir_url(__FILE__));
@@ -59,10 +59,10 @@
require(JVB_DIR . '/activate.php');
require(JVB_DIR . '/inc/helpers/all.php');
+require(JVB_DIR . '/inc/meta/_setup.php');
require(JVB_DIR . '/inc/managers/_setup.php');
require(JVB_DIR . '/inc/integrations/_setup.php');
require(JVB_DIR . '/inc/rest/_setup.php');
-require(JVB_DIR . '/inc/meta/_setup.php');
add_filter( 'cron_schedules', 'jvbCronSchedules');
function jvbCronSchedules($schedules)
@@ -161,7 +161,7 @@
/**
* Scripts
*/
-add_action('wp_enqueue_scripts', 'jvbScripts', 999);
+add_action('wp_enqueue_scripts', 'jvbScripts', 10);
function jvbScripts():void
{
@@ -208,6 +208,21 @@
);
wp_register_script(
+ 'jvb-settings',
+ JVB_URL.'assets/js/min/settings.min.js',
+ [
+// 'jvb-queue',
+ 'jvb-utility',
+ 'jvb-data-store'
+ ],
+ '1.0.0',
+ [
+ 'strategy' => 'defer',
+ 'in_footer' => true,
+ ]
+ );
+
+ wp_register_script(
'jvb-popup',
JVB_URL.'assets/js/min/popup.min.js',
[
@@ -594,7 +609,8 @@
'jvb-selector',
'jvb-uploader',
'sortable-js',
- 'jvb-populate-form'
+ 'jvb-populate-form',
+ 'jvb-quill',
],
'1.0.0',
[
@@ -631,6 +647,7 @@
JVB_URL.'assets/js/min/crud.min.js',
[
'jvb-selector',
+ 'jvb-settings',
'jvb-a11y',
'jvb-error',
'jvb-data-store',
@@ -653,6 +670,7 @@
'jvb-view',
JVB_URL.'assets/js/min/view.min.js',
[
+ 'jvb-settings',
'jvb-a11y',
'jvb-utility',
'jvb-data-store',
@@ -778,18 +796,15 @@
add_action('wp_head', 'jvbInlineNavStyles');
- wp_enqueue_script('jvb-queue');
+ if (Features::forSite()->has('dashboard')) {
+ wp_enqueue_script('jvb-queue');
+ }
+
+ wp_enqueue_script('jvb-settings');
wp_enqueue_script('jvb-navigation');
// wp_enqueue_script('jvb-ui');
wp_enqueue_script('jvb-media');
wp_enqueue_script('jvb-cache');
- wp_localize_script(
- 'jvb-cache',
- 'cacheJVB',
- [
- 'cache' => json_encode(jvbGetCache())
- ]
- );
@@ -815,7 +830,9 @@
'labels' => jvbGetLabels(),
];
- wp_localize_script('jvb-queue', 'jvbSettings', $queue);
+ wp_localize_script('jvb-utility', 'jvbSettings', $queue);
+
+
$initUserSettings = 'async function initUserItems() {
if (!jvbSettings.currentUser) return;
@@ -919,7 +936,7 @@
// ');
// }
}
- if (is_user_logged_in()) {
+ if (is_user_logged_in() && Features::forSite()->has('notifications')) {
wp_enqueue_script('jvb-notifications');
wp_localize_script('jvb-notifications', 'notificationSettings', array(
@@ -995,21 +1012,7 @@
//add_action('wp_head', 'jvbDumpIt');
function jvbDumpIt()
{
-
+ jvbDump(get_post_type_archive_link(BASE.'faq'));
}
-//add_filter('map_meta_cap', function($caps, $cap, $user_id, $args) {
-// error_log('Caps: '.print_r($caps, true));
-// error_log('Cap: '.print_r($cap, true));
-// error_log('User ID: '.print_r($user_id, true));
-// error_log('Args: '.print_r($args, true));
-// return $caps;
-//}, 10, 4);
-
-add_action( 'doing_it_wrong_run', function( $function, $message, $version ) {
- if ( 'map_meta_cap' === $function ) {
- error_log( "Map Meta Cap Wrong: $message" );
- error_log( print_r( wp_debug_backtrace_summary( null, 0, false ), true ) );
- }
-}, 10, 3 );
diff --git a/package-lock.json b/package-lock.json
index 6448ac9..180947c 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -9,7 +9,7 @@
"version": "0.1.0",
"license": "GPL-2.0-or-later",
"devDependencies": {
- "@wordpress/scripts": "^30.10.0"
+ "@wordpress/scripts": "^30.26.0"
}
},
"node_modules/@ampproject/remapping": {
@@ -57,6 +57,7 @@
"integrity": "sha512-yJ474Zv3cwiSOO9nXJuqzvwEeM+chDuQ8GJirw+pZ91sCGCyOZ3dJkVE09fTV0VEVzXyLWhh3G/AolYTPX7Mow==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@ampproject/remapping": "^2.2.0",
"@babel/code-frame": "^7.25.7",
@@ -325,9 +326,9 @@
}
},
"node_modules/@babel/helper-string-parser": {
- "version": "7.25.9",
- "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.25.9.tgz",
- "integrity": "sha512-4A/SCr/2KLd5jrtOMFzaKjVtAei3+2r/NChoBNoZ3EyP/+GlhoaEGoWOZUmFmoITP7zOJyHIMm+DYRd8o3PvHA==",
+ "version": "7.27.1",
+ "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
+ "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==",
"dev": true,
"license": "MIT",
"engines": {
@@ -335,9 +336,9 @@
}
},
"node_modules/@babel/helper-validator-identifier": {
- "version": "7.25.9",
- "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.25.9.tgz",
- "integrity": "sha512-Ed61U6XJc3CVRfkERJWDz4dJwKe7iLmmJsbOGu9wSloNSFttHV0I8g6UAgb7qnK5ly5bGLPd4oXZlxCdANBOWQ==",
+ "version": "7.27.1",
+ "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz",
+ "integrity": "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==",
"dev": true,
"license": "MIT",
"engines": {
@@ -1919,14 +1920,14 @@
}
},
"node_modules/@babel/types": {
- "version": "7.26.7",
- "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.26.7.tgz",
- "integrity": "sha512-t8kDRGrKXyp6+tjUh7hw2RLyclsW4TRoRvRHtSyAX9Bb5ldlFh+90YAYY6awRXrlB4G5G2izNeGySpATlFzmOg==",
+ "version": "7.28.4",
+ "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.4.tgz",
+ "integrity": "sha512-bkFqkLhh3pMBUQQkpVgWDWq/lqzc2678eUyDlTBhRqhCHFguYYGM0Efga7tYk4TogG/3x0EEl66/OQ+WGbWB/Q==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@babel/helper-string-parser": "^7.25.9",
- "@babel/helper-validator-identifier": "^7.25.9"
+ "@babel/helper-string-parser": "^7.27.1",
+ "@babel/helper-validator-identifier": "^7.27.1"
},
"engines": {
"node": ">=6.9.0"
@@ -1939,10 +1940,81 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/@cacheable/memoize": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/@cacheable/memoize/-/memoize-2.0.3.tgz",
+ "integrity": "sha512-hl9wfQgpiydhQEIv7fkjEzTGE+tcosCXLKFDO707wYJ/78FVOlowb36djex5GdbSyeHnG62pomYLMuV/OT8Pbw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@cacheable/utils": "^2.0.3"
+ }
+ },
+ "node_modules/@cacheable/memory": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/@cacheable/memory/-/memory-2.0.3.tgz",
+ "integrity": "sha512-R3UKy/CKOyb1LZG/VRCTMcpiMDyLH7SH3JrraRdK6kf3GweWCOU3sgvE13W3TiDRbxnDKylzKJvhUAvWl9LQOA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@cacheable/memoize": "^2.0.3",
+ "@cacheable/utils": "^2.0.3",
+ "@keyv/bigmap": "^1.0.2",
+ "hookified": "^1.12.1",
+ "keyv": "^5.5.3"
+ }
+ },
+ "node_modules/@cacheable/memory/node_modules/@keyv/bigmap": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/@keyv/bigmap/-/bigmap-1.1.0.tgz",
+ "integrity": "sha512-MX7XIUNwVRK+hjZcAbNJ0Z8DREo+Weu9vinBOjGU1thEi9F6vPhICzBbk4CCf3eEefKRz7n6TfZXwUFZTSgj8Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "hookified": "^1.12.2"
+ },
+ "engines": {
+ "node": ">= 18"
+ },
+ "peerDependencies": {
+ "keyv": "^5.5.3"
+ }
+ },
+ "node_modules/@cacheable/memory/node_modules/keyv": {
+ "version": "5.5.3",
+ "resolved": "https://registry.npmjs.org/keyv/-/keyv-5.5.3.tgz",
+ "integrity": "sha512-h0Un1ieD+HUrzBH6dJXhod3ifSghk5Hw/2Y4/KHBziPlZecrFyE9YOTPU6eOs0V9pYl8gOs86fkr/KN8lUX39A==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "@keyv/serialize": "^1.1.1"
+ }
+ },
+ "node_modules/@cacheable/utils": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/@cacheable/utils/-/utils-2.1.0.tgz",
+ "integrity": "sha512-ZdxfOiaarMqMj+H7qwlt5EBKWaeGihSYVHdQv5lUsbn8MJJOTW82OIwirQ39U5tMZkNvy3bQE+ryzC+xTAb9/g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "keyv": "^5.5.3"
+ }
+ },
+ "node_modules/@cacheable/utils/node_modules/keyv": {
+ "version": "5.5.3",
+ "resolved": "https://registry.npmjs.org/keyv/-/keyv-5.5.3.tgz",
+ "integrity": "sha512-h0Un1ieD+HUrzBH6dJXhod3ifSghk5Hw/2Y4/KHBziPlZecrFyE9YOTPU6eOs0V9pYl8gOs86fkr/KN8lUX39A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@keyv/serialize": "^1.1.1"
+ }
+ },
"node_modules/@csstools/css-parser-algorithms": {
- "version": "3.0.4",
- "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.4.tgz",
- "integrity": "sha512-Up7rBoV77rv29d3uKHUIVubz1BTcgyUK72IvCQAbfbMv584xHcGKCKbWh7i8hPrRJ7qU4Y8IO3IY9m+iTB7P3A==",
+ "version": "3.0.5",
+ "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz",
+ "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==",
"dev": true,
"funding": [
{
@@ -1955,17 +2027,18 @@
}
],
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">=18"
},
"peerDependencies": {
- "@csstools/css-tokenizer": "^3.0.3"
+ "@csstools/css-tokenizer": "^3.0.4"
}
},
"node_modules/@csstools/css-tokenizer": {
- "version": "3.0.3",
- "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.3.tgz",
- "integrity": "sha512-UJnjoFsmxfKUdNYdWgOB0mWUypuLvAfQPH1+pyvRJs6euowbFkFC6P13w1l8mJyi3vxYMxc9kld5jZEGRQs6bw==",
+ "version": "3.0.4",
+ "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz",
+ "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==",
"dev": true,
"funding": [
{
@@ -1978,6 +2051,7 @@
}
],
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">=18"
}
@@ -2017,14 +2091,14 @@
}
},
"node_modules/@dual-bundle/import-meta-resolve": {
- "version": "4.1.0",
- "resolved": "https://registry.npmjs.org/@dual-bundle/import-meta-resolve/-/import-meta-resolve-4.1.0.tgz",
- "integrity": "sha512-+nxncfwHM5SgAtrVzgpzJOI1ol0PkumhVo469KCf9lUi21IGcY90G98VuHm9VRrUypmAzawAHO9bs6hqeADaVg==",
+ "version": "4.2.1",
+ "resolved": "https://registry.npmjs.org/@dual-bundle/import-meta-resolve/-/import-meta-resolve-4.2.1.tgz",
+ "integrity": "sha512-id+7YRUgoUX6CgV0DtuhirQWodeeA7Lf4i2x71JS/vtA5pRb/hIGWlw+G6MeXvsM+MXrz0VAydTGElX1rAfgPg==",
"dev": true,
"license": "MIT",
"funding": {
"type": "github",
- "url": "https://github.com/sponsors/wooorm"
+ "url": "https://github.com/sponsors/JounQin"
}
},
"node_modules/@es-joy/jsdoccomment": {
@@ -2043,9 +2117,9 @@
}
},
"node_modules/@eslint-community/eslint-utils": {
- "version": "4.4.1",
- "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.4.1.tgz",
- "integrity": "sha512-s3O3waFUrMV8P/XaF/+ZTp1X9XBZW1a4B97ZnjQF2KYWaFD2A8KyFBsrsfSjEmjn3RGWAIuvlneuZm3CUK3jbA==",
+ "version": "4.9.0",
+ "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.0.tgz",
+ "integrity": "sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -2116,9 +2190,9 @@
"license": "Python-2.0"
},
"node_modules/@eslint/eslintrc/node_modules/brace-expansion": {
- "version": "1.1.11",
- "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
- "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==",
+ "version": "1.1.12",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
+ "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -2192,59 +2266,59 @@
}
},
"node_modules/@formatjs/ecma402-abstract": {
- "version": "2.3.2",
- "resolved": "https://registry.npmjs.org/@formatjs/ecma402-abstract/-/ecma402-abstract-2.3.2.tgz",
- "integrity": "sha512-6sE5nyvDloULiyOMbOTJEEgWL32w+VHkZQs8S02Lnn8Y/O5aQhjOEXwWzvR7SsBE/exxlSpY2EsWZgqHbtLatg==",
+ "version": "2.3.6",
+ "resolved": "https://registry.npmjs.org/@formatjs/ecma402-abstract/-/ecma402-abstract-2.3.6.tgz",
+ "integrity": "sha512-HJnTFeRM2kVFVr5gr5kH1XP6K0JcJtE7Lzvtr3FS/so5f1kpsqqqxy5JF+FRaO6H2qmcMfAUIox7AJteieRtVw==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@formatjs/fast-memoize": "2.2.6",
- "@formatjs/intl-localematcher": "0.5.10",
- "decimal.js": "10",
- "tslib": "2"
+ "@formatjs/fast-memoize": "2.2.7",
+ "@formatjs/intl-localematcher": "0.6.2",
+ "decimal.js": "^10.4.3",
+ "tslib": "^2.8.0"
}
},
"node_modules/@formatjs/fast-memoize": {
- "version": "2.2.6",
- "resolved": "https://registry.npmjs.org/@formatjs/fast-memoize/-/fast-memoize-2.2.6.tgz",
- "integrity": "sha512-luIXeE2LJbQnnzotY1f2U2m7xuQNj2DA8Vq4ce1BY9ebRZaoPB1+8eZ6nXpLzsxuW5spQxr7LdCg+CApZwkqkw==",
+ "version": "2.2.7",
+ "resolved": "https://registry.npmjs.org/@formatjs/fast-memoize/-/fast-memoize-2.2.7.tgz",
+ "integrity": "sha512-Yabmi9nSvyOMrlSeGGWDiH7rf3a7sIwplbvo/dlz9WCIjzIQAfy1RMf4S0X3yG724n5Ghu2GmEl5NJIV6O9sZQ==",
"dev": true,
"license": "MIT",
"dependencies": {
- "tslib": "2"
+ "tslib": "^2.8.0"
}
},
"node_modules/@formatjs/icu-messageformat-parser": {
- "version": "2.11.0",
- "resolved": "https://registry.npmjs.org/@formatjs/icu-messageformat-parser/-/icu-messageformat-parser-2.11.0.tgz",
- "integrity": "sha512-Hp81uTjjdTk3FLh/dggU5NK7EIsVWc5/ZDWrIldmf2rBuPejuZ13CZ/wpVE2SToyi4EiroPTQ1XJcJuZFIxTtw==",
+ "version": "2.11.4",
+ "resolved": "https://registry.npmjs.org/@formatjs/icu-messageformat-parser/-/icu-messageformat-parser-2.11.4.tgz",
+ "integrity": "sha512-7kR78cRrPNB4fjGFZg3Rmj5aah8rQj9KPzuLsmcSn4ipLXQvC04keycTI1F7kJYDwIXtT2+7IDEto842CfZBtw==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@formatjs/ecma402-abstract": "2.3.2",
- "@formatjs/icu-skeleton-parser": "1.8.12",
- "tslib": "2"
+ "@formatjs/ecma402-abstract": "2.3.6",
+ "@formatjs/icu-skeleton-parser": "1.8.16",
+ "tslib": "^2.8.0"
}
},
"node_modules/@formatjs/icu-skeleton-parser": {
- "version": "1.8.12",
- "resolved": "https://registry.npmjs.org/@formatjs/icu-skeleton-parser/-/icu-skeleton-parser-1.8.12.tgz",
- "integrity": "sha512-QRAY2jC1BomFQHYDMcZtClqHR55EEnB96V7Xbk/UiBodsuFc5kujybzt87+qj1KqmJozFhk6n4KiT1HKwAkcfg==",
+ "version": "1.8.16",
+ "resolved": "https://registry.npmjs.org/@formatjs/icu-skeleton-parser/-/icu-skeleton-parser-1.8.16.tgz",
+ "integrity": "sha512-H13E9Xl+PxBd8D5/6TVUluSpxGNvFSlN/b3coUp0e0JpuWXXnQDiavIpY3NnvSp4xhEMoXyyBvVfdFX8jglOHQ==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@formatjs/ecma402-abstract": "2.3.2",
- "tslib": "2"
+ "@formatjs/ecma402-abstract": "2.3.6",
+ "tslib": "^2.8.0"
}
},
"node_modules/@formatjs/intl-localematcher": {
- "version": "0.5.10",
- "resolved": "https://registry.npmjs.org/@formatjs/intl-localematcher/-/intl-localematcher-0.5.10.tgz",
- "integrity": "sha512-af3qATX+m4Rnd9+wHcjJ4w2ijq+rAVP3CCinJQvFv1kgSu1W6jypUmvleJxcewdxmutM8dmIRZFxO/IQBZmP2Q==",
+ "version": "0.6.2",
+ "resolved": "https://registry.npmjs.org/@formatjs/intl-localematcher/-/intl-localematcher-0.6.2.tgz",
+ "integrity": "sha512-XOMO2Hupl0wdd172Y06h6kLpBz6Dv+J4okPLl4LPtzbr8f66WbIoy4ev98EBuZ6ZK4h5ydTN6XneT4QVpD7cdA==",
"dev": true,
"license": "MIT",
"dependencies": {
- "tslib": "2"
+ "tslib": "^2.8.0"
}
},
"node_modules/@hapi/hoek": {
@@ -2281,9 +2355,9 @@
}
},
"node_modules/@humanwhocodes/config-array/node_modules/brace-expansion": {
- "version": "1.1.11",
- "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
- "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==",
+ "version": "1.1.12",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
+ "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -2568,9 +2642,9 @@
}
},
"node_modules/@jest/reporters/node_modules/semver": {
- "version": "7.7.1",
- "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.1.tgz",
- "integrity": "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA==",
+ "version": "7.7.3",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
+ "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==",
"dev": true,
"license": "ISC",
"bin": {
@@ -2750,39 +2824,11 @@
}
},
"node_modules/@keyv/serialize": {
- "version": "1.0.2",
- "resolved": "https://registry.npmjs.org/@keyv/serialize/-/serialize-1.0.2.tgz",
- "integrity": "sha512-+E/LyaAeuABniD/RvUezWVXKpeuvwLEA9//nE9952zBaOdBd2mQ3pPoM8cUe2X6IcMByfuSLzmYqnYshG60+HQ==",
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/@keyv/serialize/-/serialize-1.1.1.tgz",
+ "integrity": "sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA==",
"dev": true,
- "license": "MIT",
- "dependencies": {
- "buffer": "^6.0.3"
- }
- },
- "node_modules/@keyv/serialize/node_modules/buffer": {
- "version": "6.0.3",
- "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz",
- "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==",
- "dev": true,
- "funding": [
- {
- "type": "github",
- "url": "https://github.com/sponsors/feross"
- },
- {
- "type": "patreon",
- "url": "https://www.patreon.com/feross"
- },
- {
- "type": "consulting",
- "url": "https://feross.org/support"
- }
- ],
- "license": "MIT",
- "dependencies": {
- "base64-js": "^1.3.1",
- "ieee754": "^1.2.1"
- }
+ "license": "MIT"
},
"node_modules/@leichtgewicht/ip-codec": {
"version": "2.0.5",
@@ -2839,6 +2885,611 @@
"node": ">= 8"
}
},
+ "node_modules/@opentelemetry/api": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz",
+ "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "peer": true,
+ "engines": {
+ "node": ">=8.0.0"
+ }
+ },
+ "node_modules/@opentelemetry/api-logs": {
+ "version": "0.57.2",
+ "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.57.2.tgz",
+ "integrity": "sha512-uIX52NnTM0iBh84MShlpouI7UKqkZ7MrUszTmaypHBu4r7NofznSnQRfJ+uUeDtQDj6w8eFGg5KBLDAwAPz1+A==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@opentelemetry/api": "^1.3.0"
+ },
+ "engines": {
+ "node": ">=14"
+ }
+ },
+ "node_modules/@opentelemetry/context-async-hooks": {
+ "version": "1.30.1",
+ "resolved": "https://registry.npmjs.org/@opentelemetry/context-async-hooks/-/context-async-hooks-1.30.1.tgz",
+ "integrity": "sha512-s5vvxXPVdjqS3kTLKMeBMvop9hbWkwzBpu+mUO2M7sZtlkyDJGwFe33wRKnbaYDo8ExRVBIIdwIGrqpxHuKttA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "peer": true,
+ "engines": {
+ "node": ">=14"
+ },
+ "peerDependencies": {
+ "@opentelemetry/api": ">=1.0.0 <1.10.0"
+ }
+ },
+ "node_modules/@opentelemetry/core": {
+ "version": "1.30.1",
+ "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-1.30.1.tgz",
+ "integrity": "sha512-OOCM2C/QIURhJMuKaekP3TRBxBKxG/TWWA0TL2J6nXUtDnuCtccy49LUJF8xPFXMX+0LMcxFpCo8M9cGY1W6rQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "peer": true,
+ "dependencies": {
+ "@opentelemetry/semantic-conventions": "1.28.0"
+ },
+ "engines": {
+ "node": ">=14"
+ },
+ "peerDependencies": {
+ "@opentelemetry/api": ">=1.0.0 <1.10.0"
+ }
+ },
+ "node_modules/@opentelemetry/core/node_modules/@opentelemetry/semantic-conventions": {
+ "version": "1.28.0",
+ "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.28.0.tgz",
+ "integrity": "sha512-lp4qAiMTD4sNWW4DbKLBkfiMZ4jbAboJIGOQr5DvciMRI494OapieI9qiODpOt0XBr1LjIDy1xAGAnVs5supTA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=14"
+ }
+ },
+ "node_modules/@opentelemetry/instrumentation": {
+ "version": "0.57.2",
+ "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation/-/instrumentation-0.57.2.tgz",
+ "integrity": "sha512-BdBGhQBh8IjZ2oIIX6F2/Q3LKm/FDDKi6ccYKcBTeilh6SNdNKveDOLk73BkSJjQLJk6qe4Yh+hHw1UPhCDdrg==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@opentelemetry/api-logs": "0.57.2",
+ "@types/shimmer": "^1.2.0",
+ "import-in-the-middle": "^1.8.1",
+ "require-in-the-middle": "^7.1.1",
+ "semver": "^7.5.2",
+ "shimmer": "^1.2.1"
+ },
+ "engines": {
+ "node": ">=14"
+ },
+ "peerDependencies": {
+ "@opentelemetry/api": "^1.3.0"
+ }
+ },
+ "node_modules/@opentelemetry/instrumentation-amqplib": {
+ "version": "0.46.1",
+ "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-amqplib/-/instrumentation-amqplib-0.46.1.tgz",
+ "integrity": "sha512-AyXVnlCf/xV3K/rNumzKxZqsULyITJH6OVLiW6730JPRqWA7Zc9bvYoVNpN6iOpTU8CasH34SU/ksVJmObFibQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@opentelemetry/core": "^1.8.0",
+ "@opentelemetry/instrumentation": "^0.57.1",
+ "@opentelemetry/semantic-conventions": "^1.27.0"
+ },
+ "engines": {
+ "node": ">=14"
+ },
+ "peerDependencies": {
+ "@opentelemetry/api": "^1.3.0"
+ }
+ },
+ "node_modules/@opentelemetry/instrumentation-connect": {
+ "version": "0.43.1",
+ "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-connect/-/instrumentation-connect-0.43.1.tgz",
+ "integrity": "sha512-ht7YGWQuV5BopMcw5Q2hXn3I8eG8TH0J/kc/GMcW4CuNTgiP6wCu44BOnucJWL3CmFWaRHI//vWyAhaC8BwePw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@opentelemetry/core": "^1.8.0",
+ "@opentelemetry/instrumentation": "^0.57.1",
+ "@opentelemetry/semantic-conventions": "^1.27.0",
+ "@types/connect": "3.4.38"
+ },
+ "engines": {
+ "node": ">=14"
+ },
+ "peerDependencies": {
+ "@opentelemetry/api": "^1.3.0"
+ }
+ },
+ "node_modules/@opentelemetry/instrumentation-dataloader": {
+ "version": "0.16.1",
+ "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-dataloader/-/instrumentation-dataloader-0.16.1.tgz",
+ "integrity": "sha512-K/qU4CjnzOpNkkKO4DfCLSQshejRNAJtd4esgigo/50nxCB6XCyi1dhAblUHM9jG5dRm8eu0FB+t87nIo99LYQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@opentelemetry/instrumentation": "^0.57.1"
+ },
+ "engines": {
+ "node": ">=14"
+ },
+ "peerDependencies": {
+ "@opentelemetry/api": "^1.3.0"
+ }
+ },
+ "node_modules/@opentelemetry/instrumentation-express": {
+ "version": "0.47.1",
+ "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-express/-/instrumentation-express-0.47.1.tgz",
+ "integrity": "sha512-QNXPTWteDclR2B4pDFpz0TNghgB33UMjUt14B+BZPmtH1MwUFAfLHBaP5If0Z5NZC+jaH8oF2glgYjrmhZWmSw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@opentelemetry/core": "^1.8.0",
+ "@opentelemetry/instrumentation": "^0.57.1",
+ "@opentelemetry/semantic-conventions": "^1.27.0"
+ },
+ "engines": {
+ "node": ">=14"
+ },
+ "peerDependencies": {
+ "@opentelemetry/api": "^1.3.0"
+ }
+ },
+ "node_modules/@opentelemetry/instrumentation-fs": {
+ "version": "0.19.1",
+ "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-fs/-/instrumentation-fs-0.19.1.tgz",
+ "integrity": "sha512-6g0FhB3B9UobAR60BGTcXg4IHZ6aaYJzp0Ki5FhnxyAPt8Ns+9SSvgcrnsN2eGmk3RWG5vYycUGOEApycQL24A==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@opentelemetry/core": "^1.8.0",
+ "@opentelemetry/instrumentation": "^0.57.1"
+ },
+ "engines": {
+ "node": ">=14"
+ },
+ "peerDependencies": {
+ "@opentelemetry/api": "^1.3.0"
+ }
+ },
+ "node_modules/@opentelemetry/instrumentation-generic-pool": {
+ "version": "0.43.1",
+ "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-generic-pool/-/instrumentation-generic-pool-0.43.1.tgz",
+ "integrity": "sha512-M6qGYsp1cURtvVLGDrPPZemMFEbuMmCXgQYTReC/IbimV5sGrLBjB+/hANUpRZjX67nGLdKSVLZuQQAiNz+sww==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@opentelemetry/instrumentation": "^0.57.1"
+ },
+ "engines": {
+ "node": ">=14"
+ },
+ "peerDependencies": {
+ "@opentelemetry/api": "^1.3.0"
+ }
+ },
+ "node_modules/@opentelemetry/instrumentation-graphql": {
+ "version": "0.47.1",
+ "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-graphql/-/instrumentation-graphql-0.47.1.tgz",
+ "integrity": "sha512-EGQRWMGqwiuVma8ZLAZnExQ7sBvbOx0N/AE/nlafISPs8S+QtXX+Viy6dcQwVWwYHQPAcuY3bFt3xgoAwb4ZNQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@opentelemetry/instrumentation": "^0.57.1"
+ },
+ "engines": {
+ "node": ">=14"
+ },
+ "peerDependencies": {
+ "@opentelemetry/api": "^1.3.0"
+ }
+ },
+ "node_modules/@opentelemetry/instrumentation-hapi": {
+ "version": "0.45.2",
+ "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-hapi/-/instrumentation-hapi-0.45.2.tgz",
+ "integrity": "sha512-7Ehow/7Wp3aoyCrZwQpU7a2CnoMq0XhIcioFuKjBb0PLYfBfmTsFTUyatlHu0fRxhwcRsSQRTvEhmZu8CppBpQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@opentelemetry/core": "^1.8.0",
+ "@opentelemetry/instrumentation": "^0.57.1",
+ "@opentelemetry/semantic-conventions": "^1.27.0"
+ },
+ "engines": {
+ "node": ">=14"
+ },
+ "peerDependencies": {
+ "@opentelemetry/api": "^1.3.0"
+ }
+ },
+ "node_modules/@opentelemetry/instrumentation-http": {
+ "version": "0.57.2",
+ "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-http/-/instrumentation-http-0.57.2.tgz",
+ "integrity": "sha512-1Uz5iJ9ZAlFOiPuwYg29Bf7bJJc/GeoeJIFKJYQf67nTVKFe8RHbEtxgkOmK4UGZNHKXcpW4P8cWBYzBn1USpg==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@opentelemetry/core": "1.30.1",
+ "@opentelemetry/instrumentation": "0.57.2",
+ "@opentelemetry/semantic-conventions": "1.28.0",
+ "forwarded-parse": "2.1.2",
+ "semver": "^7.5.2"
+ },
+ "engines": {
+ "node": ">=14"
+ },
+ "peerDependencies": {
+ "@opentelemetry/api": "^1.3.0"
+ }
+ },
+ "node_modules/@opentelemetry/instrumentation-http/node_modules/@opentelemetry/semantic-conventions": {
+ "version": "1.28.0",
+ "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.28.0.tgz",
+ "integrity": "sha512-lp4qAiMTD4sNWW4DbKLBkfiMZ4jbAboJIGOQr5DvciMRI494OapieI9qiODpOt0XBr1LjIDy1xAGAnVs5supTA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=14"
+ }
+ },
+ "node_modules/@opentelemetry/instrumentation-http/node_modules/semver": {
+ "version": "7.7.3",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
+ "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==",
+ "dev": true,
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/@opentelemetry/instrumentation-ioredis": {
+ "version": "0.47.1",
+ "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-ioredis/-/instrumentation-ioredis-0.47.1.tgz",
+ "integrity": "sha512-OtFGSN+kgk/aoKgdkKQnBsQFDiG8WdCxu+UrHr0bXScdAmtSzLSraLo7wFIb25RVHfRWvzI5kZomqJYEg/l1iA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@opentelemetry/instrumentation": "^0.57.1",
+ "@opentelemetry/redis-common": "^0.36.2",
+ "@opentelemetry/semantic-conventions": "^1.27.0"
+ },
+ "engines": {
+ "node": ">=14"
+ },
+ "peerDependencies": {
+ "@opentelemetry/api": "^1.3.0"
+ }
+ },
+ "node_modules/@opentelemetry/instrumentation-kafkajs": {
+ "version": "0.7.1",
+ "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-kafkajs/-/instrumentation-kafkajs-0.7.1.tgz",
+ "integrity": "sha512-OtjaKs8H7oysfErajdYr1yuWSjMAectT7Dwr+axIoZqT9lmEOkD/H/3rgAs8h/NIuEi2imSXD+vL4MZtOuJfqQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@opentelemetry/instrumentation": "^0.57.1",
+ "@opentelemetry/semantic-conventions": "^1.27.0"
+ },
+ "engines": {
+ "node": ">=14"
+ },
+ "peerDependencies": {
+ "@opentelemetry/api": "^1.3.0"
+ }
+ },
+ "node_modules/@opentelemetry/instrumentation-knex": {
+ "version": "0.44.1",
+ "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-knex/-/instrumentation-knex-0.44.1.tgz",
+ "integrity": "sha512-U4dQxkNhvPexffjEmGwCq68FuftFK15JgUF05y/HlK3M6W/G2iEaACIfXdSnwVNe9Qh0sPfw8LbOPxrWzGWGMQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@opentelemetry/instrumentation": "^0.57.1",
+ "@opentelemetry/semantic-conventions": "^1.27.0"
+ },
+ "engines": {
+ "node": ">=14"
+ },
+ "peerDependencies": {
+ "@opentelemetry/api": "^1.3.0"
+ }
+ },
+ "node_modules/@opentelemetry/instrumentation-koa": {
+ "version": "0.47.1",
+ "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-koa/-/instrumentation-koa-0.47.1.tgz",
+ "integrity": "sha512-l/c+Z9F86cOiPJUllUCt09v+kICKvT+Vg1vOAJHtHPsJIzurGayucfCMq2acd/A/yxeNWunl9d9eqZ0G+XiI6A==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@opentelemetry/core": "^1.8.0",
+ "@opentelemetry/instrumentation": "^0.57.1",
+ "@opentelemetry/semantic-conventions": "^1.27.0"
+ },
+ "engines": {
+ "node": ">=14"
+ },
+ "peerDependencies": {
+ "@opentelemetry/api": "^1.3.0"
+ }
+ },
+ "node_modules/@opentelemetry/instrumentation-lru-memoizer": {
+ "version": "0.44.1",
+ "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-lru-memoizer/-/instrumentation-lru-memoizer-0.44.1.tgz",
+ "integrity": "sha512-5MPkYCvG2yw7WONEjYj5lr5JFehTobW7wX+ZUFy81oF2lr9IPfZk9qO+FTaM0bGEiymwfLwKe6jE15nHn1nmHg==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@opentelemetry/instrumentation": "^0.57.1"
+ },
+ "engines": {
+ "node": ">=14"
+ },
+ "peerDependencies": {
+ "@opentelemetry/api": "^1.3.0"
+ }
+ },
+ "node_modules/@opentelemetry/instrumentation-mongodb": {
+ "version": "0.52.0",
+ "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-mongodb/-/instrumentation-mongodb-0.52.0.tgz",
+ "integrity": "sha512-1xmAqOtRUQGR7QfJFfGV/M2kC7wmI2WgZdpru8hJl3S0r4hW0n3OQpEHlSGXJAaNFyvT+ilnwkT+g5L4ljHR6g==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@opentelemetry/instrumentation": "^0.57.1",
+ "@opentelemetry/semantic-conventions": "^1.27.0"
+ },
+ "engines": {
+ "node": ">=14"
+ },
+ "peerDependencies": {
+ "@opentelemetry/api": "^1.3.0"
+ }
+ },
+ "node_modules/@opentelemetry/instrumentation-mongoose": {
+ "version": "0.46.1",
+ "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-mongoose/-/instrumentation-mongoose-0.46.1.tgz",
+ "integrity": "sha512-3kINtW1LUTPkiXFRSSBmva1SXzS/72we/jL22N+BnF3DFcoewkdkHPYOIdAAk9gSicJ4d5Ojtt1/HeibEc5OQg==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@opentelemetry/core": "^1.8.0",
+ "@opentelemetry/instrumentation": "^0.57.1",
+ "@opentelemetry/semantic-conventions": "^1.27.0"
+ },
+ "engines": {
+ "node": ">=14"
+ },
+ "peerDependencies": {
+ "@opentelemetry/api": "^1.3.0"
+ }
+ },
+ "node_modules/@opentelemetry/instrumentation-mysql": {
+ "version": "0.45.1",
+ "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-mysql/-/instrumentation-mysql-0.45.1.tgz",
+ "integrity": "sha512-TKp4hQ8iKQsY7vnp/j0yJJ4ZsP109Ht6l4RHTj0lNEG1TfgTrIH5vJMbgmoYXWzNHAqBH2e7fncN12p3BP8LFg==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@opentelemetry/instrumentation": "^0.57.1",
+ "@opentelemetry/semantic-conventions": "^1.27.0",
+ "@types/mysql": "2.15.26"
+ },
+ "engines": {
+ "node": ">=14"
+ },
+ "peerDependencies": {
+ "@opentelemetry/api": "^1.3.0"
+ }
+ },
+ "node_modules/@opentelemetry/instrumentation-mysql2": {
+ "version": "0.45.2",
+ "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-mysql2/-/instrumentation-mysql2-0.45.2.tgz",
+ "integrity": "sha512-h6Ad60FjCYdJZ5DTz1Lk2VmQsShiViKe0G7sYikb0GHI0NVvApp2XQNRHNjEMz87roFttGPLHOYVPlfy+yVIhQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@opentelemetry/instrumentation": "^0.57.1",
+ "@opentelemetry/semantic-conventions": "^1.27.0",
+ "@opentelemetry/sql-common": "^0.40.1"
+ },
+ "engines": {
+ "node": ">=14"
+ },
+ "peerDependencies": {
+ "@opentelemetry/api": "^1.3.0"
+ }
+ },
+ "node_modules/@opentelemetry/instrumentation-pg": {
+ "version": "0.51.1",
+ "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-pg/-/instrumentation-pg-0.51.1.tgz",
+ "integrity": "sha512-QxgjSrxyWZc7Vk+qGSfsejPVFL1AgAJdSBMYZdDUbwg730D09ub3PXScB9d04vIqPriZ+0dqzjmQx0yWKiCi2Q==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@opentelemetry/core": "^1.26.0",
+ "@opentelemetry/instrumentation": "^0.57.1",
+ "@opentelemetry/semantic-conventions": "^1.27.0",
+ "@opentelemetry/sql-common": "^0.40.1",
+ "@types/pg": "8.6.1",
+ "@types/pg-pool": "2.0.6"
+ },
+ "engines": {
+ "node": ">=14"
+ },
+ "peerDependencies": {
+ "@opentelemetry/api": "^1.3.0"
+ }
+ },
+ "node_modules/@opentelemetry/instrumentation-redis-4": {
+ "version": "0.46.1",
+ "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-redis-4/-/instrumentation-redis-4-0.46.1.tgz",
+ "integrity": "sha512-UMqleEoabYMsWoTkqyt9WAzXwZ4BlFZHO40wr3d5ZvtjKCHlD4YXLm+6OLCeIi/HkX7EXvQaz8gtAwkwwSEvcQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@opentelemetry/instrumentation": "^0.57.1",
+ "@opentelemetry/redis-common": "^0.36.2",
+ "@opentelemetry/semantic-conventions": "^1.27.0"
+ },
+ "engines": {
+ "node": ">=14"
+ },
+ "peerDependencies": {
+ "@opentelemetry/api": "^1.3.0"
+ }
+ },
+ "node_modules/@opentelemetry/instrumentation-tedious": {
+ "version": "0.18.1",
+ "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-tedious/-/instrumentation-tedious-0.18.1.tgz",
+ "integrity": "sha512-5Cuy/nj0HBaH+ZJ4leuD7RjgvA844aY2WW+B5uLcWtxGjRZl3MNLuxnNg5DYWZNPO+NafSSnra0q49KWAHsKBg==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@opentelemetry/instrumentation": "^0.57.1",
+ "@opentelemetry/semantic-conventions": "^1.27.0",
+ "@types/tedious": "^4.0.14"
+ },
+ "engines": {
+ "node": ">=14"
+ },
+ "peerDependencies": {
+ "@opentelemetry/api": "^1.3.0"
+ }
+ },
+ "node_modules/@opentelemetry/instrumentation-undici": {
+ "version": "0.10.1",
+ "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-undici/-/instrumentation-undici-0.10.1.tgz",
+ "integrity": "sha512-rkOGikPEyRpMCmNu9AQuV5dtRlDmJp2dK5sw8roVshAGoB6hH/3QjDtRhdwd75SsJwgynWUNRUYe0wAkTo16tQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@opentelemetry/core": "^1.8.0",
+ "@opentelemetry/instrumentation": "^0.57.1"
+ },
+ "engines": {
+ "node": ">=14"
+ },
+ "peerDependencies": {
+ "@opentelemetry/api": "^1.7.0"
+ }
+ },
+ "node_modules/@opentelemetry/instrumentation/node_modules/semver": {
+ "version": "7.7.3",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
+ "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==",
+ "dev": true,
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/@opentelemetry/redis-common": {
+ "version": "0.36.2",
+ "resolved": "https://registry.npmjs.org/@opentelemetry/redis-common/-/redis-common-0.36.2.tgz",
+ "integrity": "sha512-faYX1N0gpLhej/6nyp6bgRjzAKXn5GOEMYY7YhciSfCoITAktLUtQ36d24QEWNA1/WA1y6qQunCe0OhHRkVl9g==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=14"
+ }
+ },
+ "node_modules/@opentelemetry/resources": {
+ "version": "1.30.1",
+ "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-1.30.1.tgz",
+ "integrity": "sha512-5UxZqiAgLYGFjS4s9qm5mBVo433u+dSPUFWVWXmLAD4wB65oMCoXaJP1KJa9DIYYMeHu3z4BZcStG3LC593cWA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "peer": true,
+ "dependencies": {
+ "@opentelemetry/core": "1.30.1",
+ "@opentelemetry/semantic-conventions": "1.28.0"
+ },
+ "engines": {
+ "node": ">=14"
+ },
+ "peerDependencies": {
+ "@opentelemetry/api": ">=1.0.0 <1.10.0"
+ }
+ },
+ "node_modules/@opentelemetry/resources/node_modules/@opentelemetry/semantic-conventions": {
+ "version": "1.28.0",
+ "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.28.0.tgz",
+ "integrity": "sha512-lp4qAiMTD4sNWW4DbKLBkfiMZ4jbAboJIGOQr5DvciMRI494OapieI9qiODpOt0XBr1LjIDy1xAGAnVs5supTA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=14"
+ }
+ },
+ "node_modules/@opentelemetry/sdk-trace-base": {
+ "version": "1.30.1",
+ "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-1.30.1.tgz",
+ "integrity": "sha512-jVPgBbH1gCy2Lb7X0AVQ8XAfgg0pJ4nvl8/IiQA6nxOsPvS+0zMJaFSs2ltXe0J6C8dqjcnpyqINDJmU30+uOg==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "peer": true,
+ "dependencies": {
+ "@opentelemetry/core": "1.30.1",
+ "@opentelemetry/resources": "1.30.1",
+ "@opentelemetry/semantic-conventions": "1.28.0"
+ },
+ "engines": {
+ "node": ">=14"
+ },
+ "peerDependencies": {
+ "@opentelemetry/api": ">=1.0.0 <1.10.0"
+ }
+ },
+ "node_modules/@opentelemetry/sdk-trace-base/node_modules/@opentelemetry/semantic-conventions": {
+ "version": "1.28.0",
+ "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.28.0.tgz",
+ "integrity": "sha512-lp4qAiMTD4sNWW4DbKLBkfiMZ4jbAboJIGOQr5DvciMRI494OapieI9qiODpOt0XBr1LjIDy1xAGAnVs5supTA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=14"
+ }
+ },
+ "node_modules/@opentelemetry/semantic-conventions": {
+ "version": "1.37.0",
+ "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.37.0.tgz",
+ "integrity": "sha512-JD6DerIKdJGmRp4jQyX5FlrQjA4tjOw1cvfsPAZXfOOEErMUHjPcPSICS+6WnM0nB0efSFARh0KAZss+bvExOA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "peer": true,
+ "engines": {
+ "node": ">=14"
+ }
+ },
+ "node_modules/@opentelemetry/sql-common": {
+ "version": "0.40.1",
+ "resolved": "https://registry.npmjs.org/@opentelemetry/sql-common/-/sql-common-0.40.1.tgz",
+ "integrity": "sha512-nSDlnHSqzC3pXn/wZEZVLuAuJ1MYMXPBwtv2qAbCa3847SaHItdE7SzUq/Jtb0KZmh1zfAbNi3AAMjztTT4Ugg==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@opentelemetry/core": "^1.1.0"
+ },
+ "engines": {
+ "node": ">=14"
+ },
+ "peerDependencies": {
+ "@opentelemetry/api": "^1.1.0"
+ }
+ },
"node_modules/@parcel/watcher": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.1.tgz",
@@ -3150,37 +3801,38 @@
}
},
"node_modules/@paulirish/trace_engine": {
- "version": "0.0.39",
- "resolved": "https://registry.npmjs.org/@paulirish/trace_engine/-/trace_engine-0.0.39.tgz",
- "integrity": "sha512-2Y/ejHX5DDi5bjfWY/0c/BLVSfQ61Jw1Hy60Hnh0hfEO632D3FVctkzT4Q/lVAdvIPR0bUaok9JDTr1pu/OziA==",
+ "version": "0.0.59",
+ "resolved": "https://registry.npmjs.org/@paulirish/trace_engine/-/trace_engine-0.0.59.tgz",
+ "integrity": "sha512-439NUzQGmH+9Y017/xCchBP9571J4bzhpcNhrxorf7r37wcyJZkgUfrUsRL3xl+JDcZ6ORhoFCzCw98c6S3YHw==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
+ "legacy-javascript": "latest",
"third-party-web": "latest"
}
},
"node_modules/@pkgr/core": {
- "version": "0.1.1",
- "resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.1.1.tgz",
- "integrity": "sha512-cq8o4cWH0ibXh9VGi5P20Tu9XF/0fFXl9EUinr9QfTM7a7p0oTA4iJRCQWppXR1Pg8dSM0UCItCkPwsk9qWWYA==",
+ "version": "0.2.9",
+ "resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.2.9.tgz",
+ "integrity": "sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^12.20.0 || ^14.18.0 || >=16.0.0"
},
"funding": {
- "url": "https://opencollective.com/unts"
+ "url": "https://opencollective.com/pkgr"
}
},
"node_modules/@playwright/test": {
- "version": "1.50.1",
- "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.50.1.tgz",
- "integrity": "sha512-Jii3aBg+CEDpgnuDxEp/h7BimHcUTDlpEtce89xEumlJ5ef2hqepZ+PWp1DDpYC/VO9fmWVI1IlEaoI5fK9FXQ==",
+ "version": "1.56.1",
+ "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.56.1.tgz",
+ "integrity": "sha512-vSMYtL/zOcFpvJCW71Q/OEGQb7KYBPAdKh35WNSkaZA75JlAO8ED8UN6GUNTm3drWomcbcqRPFqQbLae8yBTdg==",
"dev": true,
"license": "Apache-2.0",
"peer": true,
"dependencies": {
- "playwright": "1.50.1"
+ "playwright": "1.56.1"
},
"bin": {
"playwright": "cli.js"
@@ -3245,6 +3897,19 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/@prisma/instrumentation": {
+ "version": "6.11.1",
+ "resolved": "https://registry.npmjs.org/@prisma/instrumentation/-/instrumentation-6.11.1.tgz",
+ "integrity": "sha512-mrZOev24EDhnefmnZX7WVVT7v+r9LttPRqf54ONvj6re4XMF7wFTpK2tLJi4XHB7fFp/6xhYbgRel8YV7gQiyA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@opentelemetry/instrumentation": "^0.52.0 || ^0.53.0 || ^0.54.0 || ^0.55.0 || ^0.56.0 || ^0.57.0"
+ },
+ "peerDependencies": {
+ "@opentelemetry/api": "^1.8"
+ }
+ },
"node_modules/@puppeteer/browsers": {
"version": "2.6.1",
"resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-2.6.1.tgz",
@@ -3269,9 +3934,9 @@
}
},
"node_modules/@puppeteer/browsers/node_modules/semver": {
- "version": "7.7.1",
- "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.1.tgz",
- "integrity": "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA==",
+ "version": "7.7.3",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
+ "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==",
"dev": true,
"license": "ISC",
"bin": {
@@ -3288,89 +3953,105 @@
"dev": true,
"license": "MIT"
},
- "node_modules/@sentry-internal/tracing": {
- "version": "7.120.3",
- "resolved": "https://registry.npmjs.org/@sentry-internal/tracing/-/tracing-7.120.3.tgz",
- "integrity": "sha512-Ausx+Jw1pAMbIBHStoQ6ZqDZR60PsCByvHdw/jdH9AqPrNE9xlBSf9EwcycvmrzwyKspSLaB52grlje2cRIUMg==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@sentry/core": "7.120.3",
- "@sentry/types": "7.120.3",
- "@sentry/utils": "7.120.3"
- },
- "engines": {
- "node": ">=8"
- }
- },
"node_modules/@sentry/core": {
- "version": "7.120.3",
- "resolved": "https://registry.npmjs.org/@sentry/core/-/core-7.120.3.tgz",
- "integrity": "sha512-vyy11fCGpkGK3qI5DSXOjgIboBZTriw0YDx/0KyX5CjIjDDNgp5AGgpgFkfZyiYiaU2Ww3iFuKo4wHmBusz1uA==",
+ "version": "9.46.0",
+ "resolved": "https://registry.npmjs.org/@sentry/core/-/core-9.46.0.tgz",
+ "integrity": "sha512-it7JMFqxVproAgEtbLgCVBYtQ9fIb+Bu0JD+cEplTN/Ukpe6GaolyYib5geZqslVxhp2sQgT+58aGvfd/k0N8Q==",
"dev": true,
"license": "MIT",
- "dependencies": {
- "@sentry/types": "7.120.3",
- "@sentry/utils": "7.120.3"
- },
"engines": {
- "node": ">=8"
- }
- },
- "node_modules/@sentry/integrations": {
- "version": "7.120.3",
- "resolved": "https://registry.npmjs.org/@sentry/integrations/-/integrations-7.120.3.tgz",
- "integrity": "sha512-6i/lYp0BubHPDTg91/uxHvNui427df9r17SsIEXa2eKDwQ9gW2qRx5IWgvnxs2GV/GfSbwcx4swUB3RfEWrXrQ==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@sentry/core": "7.120.3",
- "@sentry/types": "7.120.3",
- "@sentry/utils": "7.120.3",
- "localforage": "^1.8.1"
- },
- "engines": {
- "node": ">=8"
+ "node": ">=18"
}
},
"node_modules/@sentry/node": {
- "version": "7.120.3",
- "resolved": "https://registry.npmjs.org/@sentry/node/-/node-7.120.3.tgz",
- "integrity": "sha512-t+QtekZedEfiZjbkRAk1QWJPnJlFBH/ti96tQhEq7wmlk3VszDXraZvLWZA0P2vXyglKzbWRGkT31aD3/kX+5Q==",
+ "version": "9.46.0",
+ "resolved": "https://registry.npmjs.org/@sentry/node/-/node-9.46.0.tgz",
+ "integrity": "sha512-pRLqAcd7GTGvN8gex5FtkQR5Mcol8gOy1WlyZZFq4rBbVtMbqKOQRhohwqnb+YrnmtFpj7IZ7KNDo077MvNeOQ==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@sentry-internal/tracing": "7.120.3",
- "@sentry/core": "7.120.3",
- "@sentry/integrations": "7.120.3",
- "@sentry/types": "7.120.3",
- "@sentry/utils": "7.120.3"
+ "@opentelemetry/api": "^1.9.0",
+ "@opentelemetry/context-async-hooks": "^1.30.1",
+ "@opentelemetry/core": "^1.30.1",
+ "@opentelemetry/instrumentation": "^0.57.2",
+ "@opentelemetry/instrumentation-amqplib": "^0.46.1",
+ "@opentelemetry/instrumentation-connect": "0.43.1",
+ "@opentelemetry/instrumentation-dataloader": "0.16.1",
+ "@opentelemetry/instrumentation-express": "0.47.1",
+ "@opentelemetry/instrumentation-fs": "0.19.1",
+ "@opentelemetry/instrumentation-generic-pool": "0.43.1",
+ "@opentelemetry/instrumentation-graphql": "0.47.1",
+ "@opentelemetry/instrumentation-hapi": "0.45.2",
+ "@opentelemetry/instrumentation-http": "0.57.2",
+ "@opentelemetry/instrumentation-ioredis": "0.47.1",
+ "@opentelemetry/instrumentation-kafkajs": "0.7.1",
+ "@opentelemetry/instrumentation-knex": "0.44.1",
+ "@opentelemetry/instrumentation-koa": "0.47.1",
+ "@opentelemetry/instrumentation-lru-memoizer": "0.44.1",
+ "@opentelemetry/instrumentation-mongodb": "0.52.0",
+ "@opentelemetry/instrumentation-mongoose": "0.46.1",
+ "@opentelemetry/instrumentation-mysql": "0.45.1",
+ "@opentelemetry/instrumentation-mysql2": "0.45.2",
+ "@opentelemetry/instrumentation-pg": "0.51.1",
+ "@opentelemetry/instrumentation-redis-4": "0.46.1",
+ "@opentelemetry/instrumentation-tedious": "0.18.1",
+ "@opentelemetry/instrumentation-undici": "0.10.1",
+ "@opentelemetry/resources": "^1.30.1",
+ "@opentelemetry/sdk-trace-base": "^1.30.1",
+ "@opentelemetry/semantic-conventions": "^1.34.0",
+ "@prisma/instrumentation": "6.11.1",
+ "@sentry/core": "9.46.0",
+ "@sentry/node-core": "9.46.0",
+ "@sentry/opentelemetry": "9.46.0",
+ "import-in-the-middle": "^1.14.2",
+ "minimatch": "^9.0.0"
},
"engines": {
- "node": ">=8"
+ "node": ">=18"
}
},
- "node_modules/@sentry/types": {
- "version": "7.120.3",
- "resolved": "https://registry.npmjs.org/@sentry/types/-/types-7.120.3.tgz",
- "integrity": "sha512-C4z+3kGWNFJ303FC+FxAd4KkHvxpNFYAFN8iMIgBwJdpIl25KZ8Q/VdGn0MLLUEHNLvjob0+wvwlcRBBNLXOow==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=8"
- }
- },
- "node_modules/@sentry/utils": {
- "version": "7.120.3",
- "resolved": "https://registry.npmjs.org/@sentry/utils/-/utils-7.120.3.tgz",
- "integrity": "sha512-UDAOQJtJDxZHQ5Nm1olycBIsz2wdGX8SdzyGVHmD8EOQYAeDZQyIlQYohDe9nazdIOQLZCIc3fU0G9gqVLkaGQ==",
+ "node_modules/@sentry/node-core": {
+ "version": "9.46.0",
+ "resolved": "https://registry.npmjs.org/@sentry/node-core/-/node-core-9.46.0.tgz",
+ "integrity": "sha512-XRVu5pqoklZeh4wqhxCLZkz/ipoKhitctgEFXX9Yh1e1BoHM2pIxT52wf+W6hHM676TFmFXW3uKBjsmRM3AjgA==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@sentry/types": "7.120.3"
+ "@sentry/core": "9.46.0",
+ "@sentry/opentelemetry": "9.46.0",
+ "import-in-the-middle": "^1.14.2"
},
"engines": {
- "node": ">=8"
+ "node": ">=18"
+ },
+ "peerDependencies": {
+ "@opentelemetry/api": "^1.9.0",
+ "@opentelemetry/context-async-hooks": "^1.30.1 || ^2.0.0",
+ "@opentelemetry/core": "^1.30.1 || ^2.0.0",
+ "@opentelemetry/instrumentation": ">=0.57.1 <1",
+ "@opentelemetry/resources": "^1.30.1 || ^2.0.0",
+ "@opentelemetry/sdk-trace-base": "^1.30.1 || ^2.0.0",
+ "@opentelemetry/semantic-conventions": "^1.34.0"
+ }
+ },
+ "node_modules/@sentry/opentelemetry": {
+ "version": "9.46.0",
+ "resolved": "https://registry.npmjs.org/@sentry/opentelemetry/-/opentelemetry-9.46.0.tgz",
+ "integrity": "sha512-w2zTxqrdmwRok0cXBoh+ksXdGRUHUZhlpfL/H2kfTodOL+Mk8rW72qUmfqQceXoqgbz8UyK8YgJbyt+XS5H4Qg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@sentry/core": "9.46.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "peerDependencies": {
+ "@opentelemetry/api": "^1.9.0",
+ "@opentelemetry/context-async-hooks": "^1.30.1 || ^2.0.0",
+ "@opentelemetry/core": "^1.30.1 || ^2.0.0",
+ "@opentelemetry/sdk-trace-base": "^1.30.1 || ^2.0.0",
+ "@opentelemetry/semantic-conventions": "^1.34.0"
}
},
"node_modules/@sideway/address": {
@@ -3425,9 +4106,9 @@
}
},
"node_modules/@stylistic/stylelint-plugin": {
- "version": "3.1.1",
- "resolved": "https://registry.npmjs.org/@stylistic/stylelint-plugin/-/stylelint-plugin-3.1.1.tgz",
- "integrity": "sha512-XagAHHIa528EvyGybv8EEYGK5zrVW74cHpsjhtovVATbhDRuJYfE+X4HCaAieW9lCkwbX6L+X0I4CiUG3w/hFw==",
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/@stylistic/stylelint-plugin/-/stylelint-plugin-3.1.3.tgz",
+ "integrity": "sha512-85fsmzgsIVmyG3/GFrjuYj6Cz8rAM7IZiPiXCMiSMfoDOC1lOrzrXPDk24WqviAghnPqGpx8b0caK2PuewWGFg==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -3435,10 +4116,10 @@
"@csstools/css-tokenizer": "^3.0.1",
"@csstools/media-query-list-parser": "^3.0.1",
"is-plain-object": "^5.0.0",
+ "postcss": "^8.4.41",
"postcss-selector-parser": "^6.1.2",
"postcss-value-parser": "^4.2.0",
- "style-search": "^0.1.0",
- "stylelint": "^16.8.2"
+ "style-search": "^0.1.0"
},
"engines": {
"node": "^18.12 || >=20.9"
@@ -3616,6 +4297,7 @@
"integrity": "sha512-8QqtOQT5ACVlmsvKOJNEaWmRPmcojMOzCz4Hs2BGG/toAp/K38LcsMRyLp349glq5AzJbCEeimEoxaX6v/fLrA==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@babel/core": "^7.21.3",
"@svgr/babel-preset": "8.1.0",
@@ -3760,9 +4442,9 @@
}
},
"node_modules/@types/babel__generator": {
- "version": "7.6.8",
- "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.6.8.tgz",
- "integrity": "sha512-ASsj+tpEDsEiFr1arWrlN6V3mdfjRMZt6LtK/Vp/kreFLnr5QH5+DhvD5nINYZXzwJvXeGq+05iUXcAzVrqWtw==",
+ "version": "7.27.0",
+ "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz",
+ "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -3781,13 +4463,13 @@
}
},
"node_modules/@types/babel__traverse": {
- "version": "7.20.6",
- "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.20.6.tgz",
- "integrity": "sha512-r1bzfrm0tomOI8g1SzvCaQHo6Lcv6zu0EA+W2kHrt8dyrHQxGzBBL4kdkzIS+jBMV+EYcMAEAqXqYaLJq5rOZg==",
+ "version": "7.28.0",
+ "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz",
+ "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@babel/types": "^7.20.7"
+ "@babel/types": "^7.28.2"
}
},
"node_modules/@types/body-parser": {
@@ -3838,6 +4520,7 @@
"integrity": "sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@types/estree": "*",
"@types/json-schema": "*"
@@ -3900,17 +4583,6 @@
"@types/send": "*"
}
},
- "node_modules/@types/glob": {
- "version": "7.2.0",
- "resolved": "https://registry.npmjs.org/@types/glob/-/glob-7.2.0.tgz",
- "integrity": "sha512-ZUxbzKl0IfJILTS6t7ip5fQQM/J3TJYubDm3nMbgubNNYS62eXeUpoLUC8/7fJNiFYHTrGPQn7hspDUzIHX3UA==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@types/minimatch": "*",
- "@types/node": "*"
- }
- },
"node_modules/@types/graceful-fs": {
"version": "4.1.9",
"resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.9.tgz",
@@ -3998,13 +4670,6 @@
"dev": true,
"license": "MIT"
},
- "node_modules/@types/minimatch": {
- "version": "5.1.2",
- "resolved": "https://registry.npmjs.org/@types/minimatch/-/minimatch-5.1.2.tgz",
- "integrity": "sha512-K0VQKziLUWkVKiRVrx4a40iPaxTUefQmjtkQofBkYRcoaaL/8rhwDWww9qWbrgicNOgnpIsMxyNIUM4+n6dUIA==",
- "dev": true,
- "license": "MIT"
- },
"node_modules/@types/minimist": {
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/@types/minimist/-/minimist-1.2.5.tgz",
@@ -4012,6 +4677,16 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/@types/mysql": {
+ "version": "2.15.26",
+ "resolved": "https://registry.npmjs.org/@types/mysql/-/mysql-2.15.26.tgz",
+ "integrity": "sha512-DSLCOXhkvfS5WNNPbfn2KdICAmk8lLc+/PNvnPnF7gOdMZCxopXduqv0OQ13y/yA/zXTSikZZqVgybUxOEg6YQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/node": "*"
+ }
+ },
"node_modules/@types/node": {
"version": "22.13.1",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.13.1.tgz",
@@ -4046,6 +4721,28 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/@types/pg": {
+ "version": "8.6.1",
+ "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.6.1.tgz",
+ "integrity": "sha512-1Kc4oAGzAl7uqUStZCDvaLFqZrW9qWSjXOmBfdgyBP5La7Us6Mg4GBvRlSoaZMhQF/zSj1C8CtKMBkoiT8eL8w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/node": "*",
+ "pg-protocol": "*",
+ "pg-types": "^2.2.0"
+ }
+ },
+ "node_modules/@types/pg-pool": {
+ "version": "2.0.6",
+ "resolved": "https://registry.npmjs.org/@types/pg-pool/-/pg-pool-2.0.6.tgz",
+ "integrity": "sha512-TaAUE5rq2VQYxab5Ts7WZhKNmuN78Q6PiFonTDdpbx8a1H0M1vhy3rhiMjl+e2iHmogyMw7jZF4FrE6eJUy5HQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/pg": "*"
+ }
+ },
"node_modules/@types/qs": {
"version": "6.9.18",
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.18.tgz",
@@ -4068,9 +4765,9 @@
"license": "MIT"
},
"node_modules/@types/semver": {
- "version": "7.5.8",
- "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.5.8.tgz",
- "integrity": "sha512-I8EUhyrgfLrcTkzV3TSsGyl1tSuPrEDzr0yd5m90UgNxQkyDXULk3b6MlQqTCpZpNtWe1K0hzclnZkTcLBe2UQ==",
+ "version": "7.7.1",
+ "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.7.1.tgz",
+ "integrity": "sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA==",
"dev": true,
"license": "MIT"
},
@@ -4107,6 +4804,13 @@
"@types/send": "*"
}
},
+ "node_modules/@types/shimmer": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/@types/shimmer/-/shimmer-1.2.0.tgz",
+ "integrity": "sha512-UE7oxhQLLd9gub6JKIAhDq06T0F6FnztwMNRvYgjeQSBeMc1ZG/tA47EwfduvkuQS8apbkM/lpLpWsaCeYsXVg==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/@types/sockjs": {
"version": "0.3.36",
"resolved": "https://registry.npmjs.org/@types/sockjs/-/sockjs-0.3.36.tgz",
@@ -4122,7 +4826,8 @@
"resolved": "https://registry.npmjs.org/@types/source-list-map/-/source-list-map-0.1.6.tgz",
"integrity": "sha512-5JcVt1u5HDmlXkwOD2nslZVllBBc7HDuOICfiZah2Z0is8M8g+ddAEawbmd3VjedfDHBzxCaXLs07QEmb7y54g==",
"dev": true,
- "license": "MIT"
+ "license": "MIT",
+ "optional": true
},
"node_modules/@types/stack-utils": {
"version": "2.0.3",
@@ -4136,7 +4841,18 @@
"resolved": "https://registry.npmjs.org/@types/tapable/-/tapable-1.0.12.tgz",
"integrity": "sha512-bTHG8fcxEqv1M9+TD14P8ok8hjxoOCkfKc8XXLaaD05kI7ohpeI956jtDOD3XHKBQrlyPughUtzm1jtVhHpA5Q==",
"dev": true,
- "license": "MIT"
+ "license": "MIT",
+ "optional": true
+ },
+ "node_modules/@types/tedious": {
+ "version": "4.0.14",
+ "resolved": "https://registry.npmjs.org/@types/tedious/-/tedious-4.0.14.tgz",
+ "integrity": "sha512-KHPsfX/FoVbUGbyYvk1q9MMQHLPeRZhRJZdO45Q4YjvFkv4hMNghCWTvy7rdKessBsmtz4euWCWAB6/tVpI1Iw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/node": "*"
+ }
},
"node_modules/@types/tough-cookie": {
"version": "4.0.5",
@@ -4151,6 +4867,7 @@
"integrity": "sha512-TU+fZFBTBcXj/GpDpDaBmgWk/gn96kMZ+uocaFUlV2f8a6WdMzzI44QBCmGcCiYR0Y6ZlNRiyUyKKt5nl/lbzQ==",
"dev": true,
"license": "MIT",
+ "optional": true,
"dependencies": {
"source-map": "^0.6.1"
}
@@ -4161,6 +4878,7 @@
"integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
"dev": true,
"license": "BSD-3-Clause",
+ "optional": true,
"engines": {
"node": ">=0.10.0"
}
@@ -4171,6 +4889,7 @@
"integrity": "sha512-u6kMFSBM9HcoTpUXnL6mt2HSzftqb3JgYV6oxIgL2dl6sX6aCa5k6SOkzv5DuZjBTPUE/dJltKtwwuqrkZHpfw==",
"dev": true,
"license": "MIT",
+ "optional": true,
"dependencies": {
"@types/node": "*",
"@types/tapable": "^1",
@@ -4186,6 +4905,7 @@
"integrity": "sha512-4nZOdMwSPHZ4pTEZzSp0AsTM4K7Qmu40UKW4tJDiOVs20UzYF9l+qUe4s0ftfN0pin06n+5cWWDJXH+sbhAiDw==",
"dev": true,
"license": "MIT",
+ "optional": true,
"dependencies": {
"@types/node": "*",
"@types/source-list-map": "*",
@@ -4198,6 +4918,7 @@
"integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
"dev": true,
"license": "BSD-3-Clause",
+ "optional": true,
"engines": {
"node": ">=0.10.0"
}
@@ -4277,9 +4998,9 @@
}
},
"node_modules/@typescript-eslint/eslint-plugin/node_modules/semver": {
- "version": "7.7.1",
- "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.1.tgz",
- "integrity": "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA==",
+ "version": "7.7.3",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
+ "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==",
"dev": true,
"license": "ISC",
"bin": {
@@ -4295,6 +5016,7 @@
"integrity": "sha512-tbsV1jPne5CkFQCgPBcDOt30ItF7aJoZL997JSF7MhGQqOeT3svWRYxiqlfA5RUdlHN6Fi+EI9bxqbdyAUZjYQ==",
"dev": true,
"license": "BSD-2-Clause",
+ "peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "6.21.0",
"@typescript-eslint/types": "6.21.0",
@@ -4408,9 +5130,9 @@
}
},
"node_modules/@typescript-eslint/typescript-estree/node_modules/semver": {
- "version": "7.7.1",
- "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.1.tgz",
- "integrity": "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA==",
+ "version": "7.7.3",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
+ "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==",
"dev": true,
"license": "ISC",
"bin": {
@@ -4447,9 +5169,9 @@
}
},
"node_modules/@typescript-eslint/utils/node_modules/semver": {
- "version": "7.7.1",
- "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.1.tgz",
- "integrity": "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA==",
+ "version": "7.7.3",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
+ "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==",
"dev": true,
"license": "ISC",
"bin": {
@@ -4706,20 +5428,20 @@
}
},
"node_modules/@wordpress/babel-preset-default": {
- "version": "8.17.0",
- "resolved": "https://registry.npmjs.org/@wordpress/babel-preset-default/-/babel-preset-default-8.17.0.tgz",
- "integrity": "sha512-+ivwvBI92u6abFf0DlwHem8fH5HujKy5e8a0cwDBOJivEzIJLPKYSYLlnLZL9I0QIstB+KdcJBARuWuR0l58Sw==",
+ "version": "8.33.0",
+ "resolved": "https://registry.npmjs.org/@wordpress/babel-preset-default/-/babel-preset-default-8.33.0.tgz",
+ "integrity": "sha512-zi+TfLm7w8UmC/IE1b6/z+GIRMvv9s6yQ7+2a3XUEFriAiLwVM2cRXTcauaKkcos3BDi35M0V8x0T7980RwTlQ==",
"dev": true,
"license": "GPL-2.0-or-later",
"dependencies": {
"@babel/core": "7.25.7",
+ "@babel/plugin-syntax-import-attributes": "7.26.0",
"@babel/plugin-transform-react-jsx": "7.25.7",
"@babel/plugin-transform-runtime": "7.25.7",
"@babel/preset-env": "7.25.7",
"@babel/preset-typescript": "7.25.7",
- "@babel/runtime": "7.25.7",
- "@wordpress/browserslist-config": "^6.17.0",
- "@wordpress/warning": "^3.17.0",
+ "@wordpress/browserslist-config": "^6.33.0",
+ "@wordpress/warning": "^3.33.0",
"browserslist": "^4.21.10",
"core-js": "^3.31.0",
"react": "^18.3.0"
@@ -4894,9 +5616,9 @@
}
},
"node_modules/@wordpress/base-styles": {
- "version": "5.17.0",
- "resolved": "https://registry.npmjs.org/@wordpress/base-styles/-/base-styles-5.17.0.tgz",
- "integrity": "sha512-9rYupV2CIS6PIlE27vxqBEn98n2hEBdI4YQI7TD7kdbGHYRDfTqocDK7stiAgqKR9ujDoVmq+Yk3T/jzRi6WoA==",
+ "version": "6.9.0",
+ "resolved": "https://registry.npmjs.org/@wordpress/base-styles/-/base-styles-6.9.0.tgz",
+ "integrity": "sha512-z3WCO0EdVWrXkEn6QXlFQZoKyPxplIctOWTqG8KPLtdHa0gqXhF+gaNxwGg6Ao2ac4sqoFSBcKPhXgE/08jK7g==",
"dev": true,
"license": "GPL-2.0-or-later",
"engines": {
@@ -4905,9 +5627,9 @@
}
},
"node_modules/@wordpress/browserslist-config": {
- "version": "6.17.0",
- "resolved": "https://registry.npmjs.org/@wordpress/browserslist-config/-/browserslist-config-6.17.0.tgz",
- "integrity": "sha512-cjMclWLwfam5O03gOHWjD8veeLVnfmC93V9LX1aPt/ZT9aE0cmEZUxBa3VzkDM7NvuZFj7SjSvJr+vuar9Np1A==",
+ "version": "6.33.0",
+ "resolved": "https://registry.npmjs.org/@wordpress/browserslist-config/-/browserslist-config-6.33.0.tgz",
+ "integrity": "sha512-4plw8mLKjcd1beuJzmjT4GNBk+R02qu/og6h/BuGMY8dxfqovfGB0Z2w7C85ILmjY2qnvsU7gelDcSXNgwuwxQ==",
"dev": true,
"license": "GPL-2.0-or-later",
"engines": {
@@ -4916,9 +5638,9 @@
}
},
"node_modules/@wordpress/dependency-extraction-webpack-plugin": {
- "version": "6.17.0",
- "resolved": "https://registry.npmjs.org/@wordpress/dependency-extraction-webpack-plugin/-/dependency-extraction-webpack-plugin-6.17.0.tgz",
- "integrity": "sha512-aRiYH1lcgxnvo0dvhEd5dxjBiWQokRdzSHFSF5flZ4vmHVvDRSgj5V0CQTuCG4fr77PwEJNjPHOm+s1JbmmQJw==",
+ "version": "6.33.0",
+ "resolved": "https://registry.npmjs.org/@wordpress/dependency-extraction-webpack-plugin/-/dependency-extraction-webpack-plugin-6.33.0.tgz",
+ "integrity": "sha512-uGvJrak1wpi6XAfIvSXedXgfxvavpzVlj7ypAedAqQ26eFLHCPzK9S2TRp+jw4BglUE3mR2NXD8/glorbGwq+g==",
"dev": true,
"license": "GPL-2.0-or-later",
"dependencies": {
@@ -4940,9 +5662,9 @@
"license": "BSD"
},
"node_modules/@wordpress/e2e-test-utils-playwright": {
- "version": "1.17.0",
- "resolved": "https://registry.npmjs.org/@wordpress/e2e-test-utils-playwright/-/e2e-test-utils-playwright-1.17.0.tgz",
- "integrity": "sha512-KhS+HyduYVHWbB/uHxQUC1wHMACx2BpP+4euMN8Kimy/rIsyOFrav9ueVGn7fHu9wu++swk8nUWFBip3GdsliA==",
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/@wordpress/e2e-test-utils-playwright/-/e2e-test-utils-playwright-1.33.0.tgz",
+ "integrity": "sha512-OuxF/5TeHh2k58jsKRG2AtFhoRgAFKUrOjcrBLaNew3Y6RepwvLLgSq1LXqUrR1nhJU90AaH6AqFrJ2s+lmFUw==",
"dev": true,
"license": "GPL-2.0-or-later",
"dependencies": {
@@ -4962,17 +5684,17 @@
}
},
"node_modules/@wordpress/eslint-plugin": {
- "version": "22.3.0",
- "resolved": "https://registry.npmjs.org/@wordpress/eslint-plugin/-/eslint-plugin-22.3.0.tgz",
- "integrity": "sha512-EG8PvRceycpn9B5UniHRJSwitTwWwqtsF+gcg+BOT/tU/dmMaDTRqQdXnPOhw10Qg+QKqvBEl6IT+yRwTP5rsA==",
+ "version": "22.19.0",
+ "resolved": "https://registry.npmjs.org/@wordpress/eslint-plugin/-/eslint-plugin-22.19.0.tgz",
+ "integrity": "sha512-J24RZ6U4Ref0ix8uhmc3XJGkJLdi/V+JOQjjRwB0uLpsSHio4+LhAJrBlovkZCf+0HsRKiJHuIdli0EKW5gl3g==",
"dev": true,
"license": "GPL-2.0-or-later",
"dependencies": {
"@babel/eslint-parser": "7.25.7",
"@typescript-eslint/eslint-plugin": "^6.4.1",
"@typescript-eslint/parser": "^6.4.1",
- "@wordpress/babel-preset-default": "^8.17.0",
- "@wordpress/prettier-config": "^4.17.0",
+ "@wordpress/babel-preset-default": "^8.33.0",
+ "@wordpress/prettier-config": "^4.33.0",
"cosmiconfig": "^7.0.0",
"eslint-config-prettier": "^8.3.0",
"eslint-plugin-import": "^2.25.2",
@@ -5052,13 +5774,12 @@
}
},
"node_modules/@wordpress/jest-console": {
- "version": "8.17.0",
- "resolved": "https://registry.npmjs.org/@wordpress/jest-console/-/jest-console-8.17.0.tgz",
- "integrity": "sha512-PksPaHIQN+gHycF+S4b4PcZ35xRef2nRo+sBJXolnAWhKi93IrBENFDHwdyaD7gVe7t8qJlXYd7vaF8A6Tqn2g==",
+ "version": "8.33.0",
+ "resolved": "https://registry.npmjs.org/@wordpress/jest-console/-/jest-console-8.33.0.tgz",
+ "integrity": "sha512-G9mJYPpGokk+G5MCM2xMQzHqmZY2DNTFDxtJnmH4ISHm4+2S2OTsHovTNuOM+n8QqaaB2En4uuBfYykpRQfNlw==",
"dev": true,
"license": "GPL-2.0-or-later",
"dependencies": {
- "@babel/runtime": "7.25.7",
"jest-matcher-utils": "^29.6.2"
},
"engines": {
@@ -5070,13 +5791,13 @@
}
},
"node_modules/@wordpress/jest-preset-default": {
- "version": "12.17.0",
- "resolved": "https://registry.npmjs.org/@wordpress/jest-preset-default/-/jest-preset-default-12.17.0.tgz",
- "integrity": "sha512-T5LWyi2VEiYjW2RQwajRuHeSNeI2cXKX+OJzDb9+RwIhD3316ghcExynGNmpT2Umo9mvNjWBpD57EPwQAOdR1w==",
+ "version": "12.33.0",
+ "resolved": "https://registry.npmjs.org/@wordpress/jest-preset-default/-/jest-preset-default-12.33.0.tgz",
+ "integrity": "sha512-TI3FHvMyWeC36IBz7lGaADLIHrSow9Yj80jwisWZ1uppWkAh1wwnJuGnMUn6dSydUolCGitLcMBjA/kGx3uPLw==",
"dev": true,
"license": "GPL-2.0-or-later",
"dependencies": {
- "@wordpress/jest-console": "^8.17.0",
+ "@wordpress/jest-console": "^8.33.0",
"babel-jest": "29.7.0"
},
"engines": {
@@ -5089,9 +5810,9 @@
}
},
"node_modules/@wordpress/npm-package-json-lint-config": {
- "version": "5.17.0",
- "resolved": "https://registry.npmjs.org/@wordpress/npm-package-json-lint-config/-/npm-package-json-lint-config-5.17.0.tgz",
- "integrity": "sha512-j5G1/baTcd9YYwzPVBSsT6XlFMeKELxwIYsmtrv7p49WiygPlHt6Rz6aLpym6L7BRaJ64mqG2/dY5KcEYdoCTg==",
+ "version": "5.33.0",
+ "resolved": "https://registry.npmjs.org/@wordpress/npm-package-json-lint-config/-/npm-package-json-lint-config-5.33.0.tgz",
+ "integrity": "sha512-XejRL8yPGoBVY44gvfH2A2STzFDUjzT7inxhsqzZWYgpMtDNjgdrRN6fgA1GP1nyQx0iRg28r/vapjFCWCA+5w==",
"dev": true,
"license": "GPL-2.0-or-later",
"engines": {
@@ -5103,14 +5824,15 @@
}
},
"node_modules/@wordpress/postcss-plugins-preset": {
- "version": "5.17.0",
- "resolved": "https://registry.npmjs.org/@wordpress/postcss-plugins-preset/-/postcss-plugins-preset-5.17.0.tgz",
- "integrity": "sha512-mpEPYNOC1PgQMFalIcp4rdlvMf3/Gppvn2NWzxPIoIxA/AYJEbwZ4ctPIbioXIWaubM1UizC6Z8+7S2huLsfUw==",
+ "version": "5.33.0",
+ "resolved": "https://registry.npmjs.org/@wordpress/postcss-plugins-preset/-/postcss-plugins-preset-5.33.0.tgz",
+ "integrity": "sha512-VBmXyBpjq96L58ox5Fmhc2lMKuLZafqkz8im34gQOthjw8PwkHXDCcC/q5ue5SzYXvX07UTZnGGuc7V6ARrHLg==",
"dev": true,
"license": "GPL-2.0-or-later",
"dependencies": {
- "@wordpress/base-styles": "^5.17.0",
- "autoprefixer": "^10.4.20"
+ "@wordpress/base-styles": "^6.9.0",
+ "autoprefixer": "^10.4.20",
+ "postcss-import": "^16.1.1"
},
"engines": {
"node": ">=18.12.0",
@@ -5121,9 +5843,9 @@
}
},
"node_modules/@wordpress/prettier-config": {
- "version": "4.17.0",
- "resolved": "https://registry.npmjs.org/@wordpress/prettier-config/-/prettier-config-4.17.0.tgz",
- "integrity": "sha512-yoNJRCRMX27bvGyLzF2GunbPqksn6NJD1DDbV7a5j8gUvOZezN+5duAFApIDwaa4n3fxfIzf0wdoBxrMdnuBFg==",
+ "version": "4.33.0",
+ "resolved": "https://registry.npmjs.org/@wordpress/prettier-config/-/prettier-config-4.33.0.tgz",
+ "integrity": "sha512-PRNb10ouWjg52yeWHTXlaZqkuHMSHlKq9Risg368f5fWU7akDJgZboiD6jVdtv+iGXdFRlI5oRF31wqArzNykA==",
"dev": true,
"license": "GPL-2.0-or-later",
"engines": {
@@ -5135,32 +5857,31 @@
}
},
"node_modules/@wordpress/scripts": {
- "version": "30.10.0",
- "resolved": "https://registry.npmjs.org/@wordpress/scripts/-/scripts-30.10.0.tgz",
- "integrity": "sha512-Rs5NBN2TSWAYsf4DAchbi0ZnBkOjEfKzDXZGNEbWuO2dpbpPXHn9puZe5tBNo2bOe09mf+dXfOUh0Z1puK7orw==",
+ "version": "30.26.0",
+ "resolved": "https://registry.npmjs.org/@wordpress/scripts/-/scripts-30.26.0.tgz",
+ "integrity": "sha512-RpyF41xHtA4ktOP0JBBb6/MkoB7/H/emqQnO3t+dZFs56jCP/8141MicDl7Ne9PY29D4NaB0LgbcmthK5Msk1Q==",
"dev": true,
"license": "GPL-2.0-or-later",
"dependencies": {
"@babel/core": "7.25.7",
"@pmmmwh/react-refresh-webpack-plugin": "^0.5.11",
"@svgr/webpack": "^8.0.1",
- "@wordpress/babel-preset-default": "^8.17.0",
- "@wordpress/browserslist-config": "^6.17.0",
- "@wordpress/dependency-extraction-webpack-plugin": "^6.17.0",
- "@wordpress/e2e-test-utils-playwright": "^1.17.0",
- "@wordpress/eslint-plugin": "^22.3.0",
- "@wordpress/jest-preset-default": "^12.17.0",
- "@wordpress/npm-package-json-lint-config": "^5.17.0",
- "@wordpress/postcss-plugins-preset": "^5.17.0",
- "@wordpress/prettier-config": "^4.17.0",
- "@wordpress/stylelint-config": "^23.9.0",
+ "@wordpress/babel-preset-default": "^8.33.0",
+ "@wordpress/browserslist-config": "^6.33.0",
+ "@wordpress/dependency-extraction-webpack-plugin": "^6.33.0",
+ "@wordpress/e2e-test-utils-playwright": "^1.33.0",
+ "@wordpress/eslint-plugin": "^22.19.0",
+ "@wordpress/jest-preset-default": "^12.33.0",
+ "@wordpress/npm-package-json-lint-config": "^5.33.0",
+ "@wordpress/postcss-plugins-preset": "^5.33.0",
+ "@wordpress/prettier-config": "^4.33.0",
+ "@wordpress/stylelint-config": "^23.25.0",
"adm-zip": "^0.5.9",
"babel-jest": "29.7.0",
"babel-loader": "9.2.1",
"browserslist": "^4.21.10",
"chalk": "^4.0.0",
"check-node-version": "^4.1.0",
- "clean-webpack-plugin": "^3.0.0",
"copy-webpack-plugin": "^10.2.0",
"cross-spawn": "^7.0.6",
"css-loader": "^6.2.0",
@@ -5210,15 +5931,21 @@
"npm": ">=8.19.2"
},
"peerDependencies": {
- "@playwright/test": "^1.49.1",
+ "@playwright/test": "^1.55.0",
+ "@wordpress/env": "^10.0.0",
"react": "^18.0.0",
"react-dom": "^18.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@wordpress/env": {
+ "optional": true
+ }
}
},
"node_modules/@wordpress/stylelint-config": {
- "version": "23.9.0",
- "resolved": "https://registry.npmjs.org/@wordpress/stylelint-config/-/stylelint-config-23.9.0.tgz",
- "integrity": "sha512-id+dU8JmvLBP/4Od0sIYe6g56nUKh97NO0RI+PNHDRB660Nn7nBJpRAu9Y3vd/3RwoZyaS72JAovmkQrzynGiw==",
+ "version": "23.25.0",
+ "resolved": "https://registry.npmjs.org/@wordpress/stylelint-config/-/stylelint-config-23.25.0.tgz",
+ "integrity": "sha512-GefqayI9kSohIwYW6xkK8jZTF62k71ALdMSVgktMXru567gUDpb1Ci79CIY4iTK3fq/OpJW3uAM4AfXYNH8+3Q==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -5231,13 +5958,14 @@
"npm": ">=8.19.2"
},
"peerDependencies": {
- "stylelint": "^16.8.2"
+ "stylelint": "^16.8.2",
+ "stylelint-scss": "^6.4.0"
}
},
"node_modules/@wordpress/warning": {
- "version": "3.17.0",
- "resolved": "https://registry.npmjs.org/@wordpress/warning/-/warning-3.17.0.tgz",
- "integrity": "sha512-dmEjDbYtfPD8rMRtSrLxoW3g8CLKl+vK5pdXvDvG0lBoRjqwtRPP4cgNBOC8cq8gXRCwh5NDDtM2C8MTjGjVsQ==",
+ "version": "3.33.0",
+ "resolved": "https://registry.npmjs.org/@wordpress/warning/-/warning-3.33.0.tgz",
+ "integrity": "sha512-LzYgKfxgK5YEpTu4zHPCDzw+kH5hYCrKRK/joK8S9booy5ERvzRCPrISMwrmAKTD9esYF82+IEHhW0/qsjxPsw==",
"dev": true,
"license": "GPL-2.0-or-later",
"engines": {
@@ -5297,6 +6025,7 @@
"integrity": "sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA==",
"dev": true,
"license": "MIT",
+ "peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -5315,6 +6044,16 @@
"acorn-walk": "^8.0.2"
}
},
+ "node_modules/acorn-import-attributes": {
+ "version": "1.9.5",
+ "resolved": "https://registry.npmjs.org/acorn-import-attributes/-/acorn-import-attributes-1.9.5.tgz",
+ "integrity": "sha512-n02Vykv5uA3eHGM/Z2dQrcD56kL8TyDb2p1+0P83PClMnC/nc+anbQRhIOWnSq4Ke/KvDPrY3C9hDtC/A3eHnQ==",
+ "dev": true,
+ "license": "MIT",
+ "peerDependencies": {
+ "acorn": "^8"
+ }
+ },
"node_modules/acorn-jsx": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz",
@@ -5367,6 +6106,7 @@
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"fast-deep-equal": "^3.1.1",
"fast-json-stable-stringify": "^2.0.0",
@@ -5597,18 +6337,20 @@
"license": "MIT"
},
"node_modules/array-includes": {
- "version": "3.1.8",
- "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.8.tgz",
- "integrity": "sha512-itaWrbYbqpGXkGhZPGUulwnhVf5Hpy1xiCFsGqyIGglbBxmG5vSjxQen3/WGOjPpNEv1RtBLKxbmVXm8HpJStQ==",
+ "version": "3.1.9",
+ "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.9.tgz",
+ "integrity": "sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ==",
"dev": true,
"license": "MIT",
"dependencies": {
- "call-bind": "^1.0.7",
+ "call-bind": "^1.0.8",
+ "call-bound": "^1.0.4",
"define-properties": "^1.2.1",
- "es-abstract": "^1.23.2",
- "es-object-atoms": "^1.0.0",
- "get-intrinsic": "^1.2.4",
- "is-string": "^1.0.7"
+ "es-abstract": "^1.24.0",
+ "es-object-atoms": "^1.1.1",
+ "get-intrinsic": "^1.3.0",
+ "is-string": "^1.1.1",
+ "math-intrinsics": "^1.1.0"
},
"engines": {
"node": ">= 0.4"
@@ -5627,16 +6369,6 @@
"node": ">=8"
}
},
- "node_modules/array-uniq": {
- "version": "1.0.3",
- "resolved": "https://registry.npmjs.org/array-uniq/-/array-uniq-1.0.3.tgz",
- "integrity": "sha512-MNha4BWQ6JbwhFhj03YK552f7cb3AzoE8SzeljgChvL1dl3IcvggXVz1DilzySZkCja+CXuZbdW7yATchWn8/Q==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=0.10.0"
- }
- },
"node_modules/array.prototype.findlast": {
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/array.prototype.findlast/-/array.prototype.findlast-1.2.5.tgz",
@@ -5659,18 +6391,19 @@
}
},
"node_modules/array.prototype.findlastindex": {
- "version": "1.2.5",
- "resolved": "https://registry.npmjs.org/array.prototype.findlastindex/-/array.prototype.findlastindex-1.2.5.tgz",
- "integrity": "sha512-zfETvRFA8o7EiNn++N5f/kaCw221hrpGsDmcpndVupkPzEc1Wuf3VgC0qby1BbHs7f5DVYjgtEU2LLh5bqeGfQ==",
+ "version": "1.2.6",
+ "resolved": "https://registry.npmjs.org/array.prototype.findlastindex/-/array.prototype.findlastindex-1.2.6.tgz",
+ "integrity": "sha512-F/TKATkzseUExPlfvmwQKGITM3DGTK+vkAsCZoDc5daVygbJBnjEUCbgkAvVFsgfXfX4YIqZ/27G3k3tdXrTxQ==",
"dev": true,
"license": "MIT",
"dependencies": {
- "call-bind": "^1.0.7",
+ "call-bind": "^1.0.8",
+ "call-bound": "^1.0.4",
"define-properties": "^1.2.1",
- "es-abstract": "^1.23.2",
+ "es-abstract": "^1.23.9",
"es-errors": "^1.3.0",
- "es-object-atoms": "^1.0.0",
- "es-shim-unscopables": "^1.0.2"
+ "es-object-atoms": "^1.1.1",
+ "es-shim-unscopables": "^1.1.0"
},
"engines": {
"node": ">= 0.4"
@@ -5813,10 +6546,20 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/atomically": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/atomically/-/atomically-2.0.3.tgz",
+ "integrity": "sha512-kU6FmrwZ3Lx7/7y3hPS5QnbJfaohcIul5fGqf7ok+4KklIEk9tJ0C2IQPdacSbVUWv6zVHXEBWoWd6NrVMT7Cw==",
+ "dev": true,
+ "dependencies": {
+ "stubborn-fs": "^1.2.5",
+ "when-exit": "^2.1.1"
+ }
+ },
"node_modules/autoprefixer": {
- "version": "10.4.20",
- "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.20.tgz",
- "integrity": "sha512-XY25y5xSv/wEoqzDyXXME4AFfkZI0P23z6Fs3YgymDnKJkCGOnkL0iTxCa85UTqaSgfcqyf3UA6+c7wUvx/16g==",
+ "version": "10.4.21",
+ "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.21.tgz",
+ "integrity": "sha512-O+A6LWV5LDHSJD3LjHYoNi4VLsj/Whi7k6zG12xTYaU4cQ8oxQGckXNX8cRHK5yOZ/ppVHe0ZBXGzSV9jXdVbQ==",
"dev": true,
"funding": [
{
@@ -5834,11 +6577,11 @@
],
"license": "MIT",
"dependencies": {
- "browserslist": "^4.23.3",
- "caniuse-lite": "^1.0.30001646",
+ "browserslist": "^4.24.4",
+ "caniuse-lite": "^1.0.30001702",
"fraction.js": "^4.3.7",
"normalize-range": "^0.1.2",
- "picocolors": "^1.0.1",
+ "picocolors": "^1.1.1",
"postcss-value-parser": "^4.2.0"
},
"bin": {
@@ -5868,9 +6611,9 @@
}
},
"node_modules/axe-core": {
- "version": "4.10.2",
- "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.10.2.tgz",
- "integrity": "sha512-RE3mdQ7P3FRSe7eqCWoeQ/Z9QXrtniSjp1wUjt5nRC3WIpz5rSCve6o3fsZ2aCpJtrZjSZgjwXAoTO5k4tEI0w==",
+ "version": "4.11.0",
+ "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.11.0.tgz",
+ "integrity": "sha512-ilYanEU8vxxBexpJd8cWM4ElSQq4QctCLKih0TSfjIfCQTeyH/6zVrmIJfLPrKTKJRbiG+cfnZbQIjAlJmF1jQ==",
"dev": true,
"license": "MPL-2.0",
"engines": {
@@ -5899,13 +6642,6 @@
"node": ">= 0.4"
}
},
- "node_modules/b4a": {
- "version": "1.6.7",
- "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.6.7.tgz",
- "integrity": "sha512-OnAYlL5b7LEkALw87fUVafQw5rVR9RjwGd4KUwNQ6DrrNmaVaUCgLipfVlzrPQ4tWOR9P0IXGNOx50jYCCdSJg==",
- "dev": true,
- "license": "Apache-2.0"
- },
"node_modules/babel-jest": {
"version": "29.7.0",
"resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-29.7.0.tgz",
@@ -6022,9 +6758,9 @@
}
},
"node_modules/babel-preset-current-node-syntax": {
- "version": "1.1.0",
- "resolved": "https://registry.npmjs.org/babel-preset-current-node-syntax/-/babel-preset-current-node-syntax-1.1.0.tgz",
- "integrity": "sha512-ldYss8SbBlWva1bs28q78Ju5Zq1F+8BrqBZZ0VFhLBvhh6lCpC2o3gDJi/5DRLs9FgYZCnmPYIVFU4lRXCkyUw==",
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/babel-preset-current-node-syntax/-/babel-preset-current-node-syntax-1.2.0.tgz",
+ "integrity": "sha512-E/VlAEzRrsLEb2+dv8yp3bo4scof3l9nR4lrld+Iy5NyVqgVYUJnDAmunkhPMisRI32Qc4iRiz425d8vM++2fg==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -6045,7 +6781,7 @@
"@babel/plugin-syntax-top-level-await": "^7.14.5"
},
"peerDependencies": {
- "@babel/core": "^7.0.0"
+ "@babel/core": "^7.0.0 || ^8.0.0-0"
}
},
"node_modules/babel-preset-jest": {
@@ -6073,38 +6809,55 @@
"license": "MIT"
},
"node_modules/bare-events": {
- "version": "2.5.4",
- "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.5.4.tgz",
- "integrity": "sha512-+gFfDkR8pj4/TrWCGUGWmJIkBwuxPS5F+a5yWjOHQt2hHvNZd5YLzadjmDUtFmMM4y429bnKLa8bYBMHcYdnQA==",
+ "version": "2.8.0",
+ "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.8.0.tgz",
+ "integrity": "sha512-AOhh6Bg5QmFIXdViHbMc2tLDsBIRxdkIaIddPslJF9Z5De3APBScuqGP2uThXnIpqFrgoxMNC6km7uXNIMLHXA==",
"dev": true,
"license": "Apache-2.0",
- "optional": true
+ "peerDependencies": {
+ "bare-abort-controller": "*"
+ },
+ "peerDependenciesMeta": {
+ "bare-abort-controller": {
+ "optional": true
+ }
+ }
},
"node_modules/bare-fs": {
- "version": "4.0.1",
- "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.0.1.tgz",
- "integrity": "sha512-ilQs4fm/l9eMfWY2dY0WCIUplSUp7U0CT1vrqMg1MUdeZl4fypu5UP0XcDBK5WBQPJAKP1b7XEodISmekH/CEg==",
+ "version": "4.4.11",
+ "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.4.11.tgz",
+ "integrity": "sha512-Bejmm9zRMvMTRoHS+2adgmXw1ANZnCNx+B5dgZpGwlP1E3x6Yuxea8RToddHUbWtVV0iUMWqsgZr8+jcgUI2SA==",
"dev": true,
"license": "Apache-2.0",
"optional": true,
"dependencies": {
- "bare-events": "^2.0.0",
+ "bare-events": "^2.5.4",
"bare-path": "^3.0.0",
- "bare-stream": "^2.0.0"
+ "bare-stream": "^2.6.4",
+ "bare-url": "^2.2.2",
+ "fast-fifo": "^1.3.2"
},
"engines": {
- "bare": ">=1.7.0"
+ "bare": ">=1.16.0"
+ },
+ "peerDependencies": {
+ "bare-buffer": "*"
+ },
+ "peerDependenciesMeta": {
+ "bare-buffer": {
+ "optional": true
+ }
}
},
"node_modules/bare-os": {
- "version": "3.4.0",
- "resolved": "https://registry.npmjs.org/bare-os/-/bare-os-3.4.0.tgz",
- "integrity": "sha512-9Ous7UlnKbe3fMi7Y+qh0DwAup6A1JkYgPnjvMDNOlmnxNRQvQ/7Nst+OnUQKzk0iAT0m9BisbDVp9gCv8+ETA==",
+ "version": "3.6.2",
+ "resolved": "https://registry.npmjs.org/bare-os/-/bare-os-3.6.2.tgz",
+ "integrity": "sha512-T+V1+1srU2qYNBmJCXZkUY5vQ0B4FSlL3QDROnKQYOqeiQR8UbjNHlPa+TIbM4cuidiN9GaTaOZgSEgsvPbh5A==",
"dev": true,
"license": "Apache-2.0",
"optional": true,
"engines": {
- "bare": ">=1.6.0"
+ "bare": ">=1.14.0"
}
},
"node_modules/bare-path": {
@@ -6119,9 +6872,9 @@
}
},
"node_modules/bare-stream": {
- "version": "2.6.5",
- "resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.6.5.tgz",
- "integrity": "sha512-jSmxKJNJmHySi6hC42zlZnq00rga4jjxcgNZjY9N5WlOe/iOoGRtdwGsHzQv2RlH2KOYMwGUXhf2zXd32BA9RA==",
+ "version": "2.7.0",
+ "resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.7.0.tgz",
+ "integrity": "sha512-oyXQNicV1y8nc2aKffH+BUHFRXmx6VrPzlnaEvMhram0nPBrKcEdcyBg5r08D0i8VxngHFAiVyn1QKXpSG0B8A==",
"dev": true,
"license": "Apache-2.0",
"optional": true,
@@ -6141,6 +6894,17 @@
}
}
},
+ "node_modules/bare-url": {
+ "version": "2.3.1",
+ "resolved": "https://registry.npmjs.org/bare-url/-/bare-url-2.3.1.tgz",
+ "integrity": "sha512-v2yl0TnaZTdEnelkKtXZGnotiV6qATBlnNuUMrHl6v9Lmmrh9mw9RYyImPU7/4RahumSwQS1k2oKXcRfXcbjJw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "dependencies": {
+ "bare-path": "^3.0.0"
+ }
+ },
"node_modules/base64-js": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
@@ -6276,9 +7040,9 @@
"license": "ISC"
},
"node_modules/brace-expansion": {
- "version": "2.0.1",
- "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz",
- "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==",
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
+ "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -6318,6 +7082,7 @@
}
],
"license": "MIT",
+ "peer": true,
"dependencies": {
"caniuse-lite": "^1.0.30001688",
"electron-to-chromium": "^1.5.73",
@@ -6407,24 +7172,28 @@
}
},
"node_modules/cacheable": {
- "version": "1.8.8",
- "resolved": "https://registry.npmjs.org/cacheable/-/cacheable-1.8.8.tgz",
- "integrity": "sha512-OE1/jlarWxROUIpd0qGBSKFLkNsotY8pt4GeiVErUYh/NUeTNrT+SBksUgllQv4m6a0W/VZsLuiHb88maavqEw==",
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/cacheable/-/cacheable-2.1.1.tgz",
+ "integrity": "sha512-LmF4AXiSNdiRbI2UjH8pAp9NIXxeQsTotpEaegPiDcnN0YPygDJDV3l/Urc0mL72JWdATEorKqIHEx55nDlONg==",
"dev": true,
"license": "MIT",
"dependencies": {
- "hookified": "^1.7.0",
- "keyv": "^5.2.3"
+ "@cacheable/memoize": "^2.0.3",
+ "@cacheable/memory": "^2.0.3",
+ "@cacheable/utils": "^2.1.0",
+ "hookified": "^1.12.2",
+ "keyv": "^5.5.3",
+ "qified": "^0.5.0"
}
},
"node_modules/cacheable/node_modules/keyv": {
- "version": "5.2.3",
- "resolved": "https://registry.npmjs.org/keyv/-/keyv-5.2.3.tgz",
- "integrity": "sha512-AGKecUfzrowabUv0bH1RIR5Vf7w+l4S3xtQAypKaUpTdIR1EbrAcTxHCrpo9Q+IWeUlFE2palRtgIQcgm+PQJw==",
+ "version": "5.5.3",
+ "resolved": "https://registry.npmjs.org/keyv/-/keyv-5.5.3.tgz",
+ "integrity": "sha512-h0Un1ieD+HUrzBH6dJXhod3ifSghk5Hw/2Y4/KHBziPlZecrFyE9YOTPU6eOs0V9pYl8gOs86fkr/KN8lUX39A==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@keyv/serialize": "^1.0.2"
+ "@keyv/serialize": "^1.1.1"
}
},
"node_modules/call-bind": {
@@ -6447,9 +7216,9 @@
}
},
"node_modules/call-bind-apply-helpers": {
- "version": "1.0.1",
- "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.1.tgz",
- "integrity": "sha512-BhYE+WDaywFg2TBWYNXAE+8B1ATnThNBqXHP5nQu0jWJdVvY2hvkpyB3qOmtmDePiS5/BDQ8wASEWGMWRG148g==",
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
+ "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -6461,14 +7230,14 @@
}
},
"node_modules/call-bound": {
- "version": "1.0.3",
- "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.3.tgz",
- "integrity": "sha512-YTd+6wGlNlPxSuri7Y6X8tY2dmm12UMH66RpKMhiX6rsk5wXXnYgbUcOt8kiS31/AjfoTOvCsE+w8nZQLQnzHA==",
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
+ "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
"dev": true,
"license": "MIT",
"dependencies": {
- "call-bind-apply-helpers": "^1.0.1",
- "get-intrinsic": "^1.2.6"
+ "call-bind-apply-helpers": "^1.0.2",
+ "get-intrinsic": "^1.3.0"
},
"engines": {
"node": ">= 0.4"
@@ -6685,9 +7454,9 @@
}
},
"node_modules/chrome-launcher": {
- "version": "1.1.2",
- "resolved": "https://registry.npmjs.org/chrome-launcher/-/chrome-launcher-1.1.2.tgz",
- "integrity": "sha512-YclTJey34KUm5jB1aEJCq807bSievi7Nb/TU4Gu504fUYi3jw3KCIaH6L7nFWQhdEgH3V+wCh+kKD1P5cXnfxw==",
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/chrome-launcher/-/chrome-launcher-1.2.1.tgz",
+ "integrity": "sha512-qmFR5PLMzHyuNJHwOloHPAHhbaNglkfeV/xDtt5b7xiFFyU1I+AZZX0PYseMuhenJSSirgxELYIbswcoc+5H4A==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
@@ -6697,7 +7466,7 @@
"lighthouse-logger": "^2.0.1"
},
"bin": {
- "print-chrome-path": "bin/print-chrome-path.js"
+ "print-chrome-path": "bin/print-chrome-path.cjs"
},
"engines": {
"node": ">=12.13.0"
@@ -6750,23 +7519,6 @@
"dev": true,
"license": "MIT"
},
- "node_modules/clean-webpack-plugin": {
- "version": "3.0.0",
- "resolved": "https://registry.npmjs.org/clean-webpack-plugin/-/clean-webpack-plugin-3.0.0.tgz",
- "integrity": "sha512-MciirUH5r+cYLGCOL5JX/ZLzOZbVr1ot3Fw+KcvbhUb6PM+yycqd9ZhIlcigQ5gl+XhppNmw3bEFuaaMNyLj3A==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@types/webpack": "^4.4.31",
- "del": "^4.1.1"
- },
- "engines": {
- "node": ">=8.9.0"
- },
- "peerDependencies": {
- "webpack": "*"
- }
- },
"node_modules/cliui": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz",
@@ -6824,9 +7576,9 @@
}
},
"node_modules/collect-v8-coverage": {
- "version": "1.0.2",
- "resolved": "https://registry.npmjs.org/collect-v8-coverage/-/collect-v8-coverage-1.0.2.tgz",
- "integrity": "sha512-lHl4d5/ONEbLlJvaJNtsF/Lz+WvB07u2ycqTYbdrq7UypDXailES4valYb2eWiJFxZlVmpGekfqoxQhzyFdT4Q==",
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/collect-v8-coverage/-/collect-v8-coverage-1.0.3.tgz",
+ "integrity": "sha512-1L5aqIkwPfiodaMgQunkF1zRhNqifHBmtbbbxcr6yVxxBnliw4TDOW6NxpO8DJLgJ16OT+Y4ztZqP6p/FtXnAw==",
"dev": true,
"license": "MIT"
},
@@ -6961,52 +7713,24 @@
"license": "MIT"
},
"node_modules/configstore": {
- "version": "5.0.1",
- "resolved": "https://registry.npmjs.org/configstore/-/configstore-5.0.1.tgz",
- "integrity": "sha512-aMKprgk5YhBNyH25hj8wGt2+D52Sw1DRRIzqBwLp2Ya9mFmY8KPvvtvmna8SxVR9JMZ4kzMD68N22vlaRpkeFA==",
+ "version": "7.1.0",
+ "resolved": "https://registry.npmjs.org/configstore/-/configstore-7.1.0.tgz",
+ "integrity": "sha512-N4oog6YJWbR9kGyXvS7jEykLDXIE2C0ILYqNBZBp9iwiJpoCBWYsuAdW6PPFn6w06jjnC+3JstVvWHO4cZqvRg==",
"dev": true,
"license": "BSD-2-Clause",
"dependencies": {
- "dot-prop": "^5.2.0",
- "graceful-fs": "^4.1.2",
- "make-dir": "^3.0.0",
- "unique-string": "^2.0.0",
- "write-file-atomic": "^3.0.0",
- "xdg-basedir": "^4.0.0"
+ "atomically": "^2.0.3",
+ "dot-prop": "^9.0.0",
+ "graceful-fs": "^4.2.11",
+ "xdg-basedir": "^5.1.0"
},
"engines": {
- "node": ">=8"
- }
- },
- "node_modules/configstore/node_modules/make-dir": {
- "version": "3.1.0",
- "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz",
- "integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "semver": "^6.0.0"
- },
- "engines": {
- "node": ">=8"
+ "node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
- "node_modules/configstore/node_modules/write-file-atomic": {
- "version": "3.0.3",
- "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-3.0.3.tgz",
- "integrity": "sha512-AvHcyZ5JnSfq3ioSyjrBkH9yW4m7Ayk8/9My/DD9onKeu/94fwrMocemO2QAJFAlnnDN+ZDS+ZjAR5ua1/PV/Q==",
- "dev": true,
- "license": "ISC",
- "dependencies": {
- "imurmurhash": "^0.1.4",
- "is-typedarray": "^1.0.0",
- "signal-exit": "^3.0.2",
- "typedarray-to-buffer": "^3.1.5"
- }
- },
"node_modules/connect-history-api-fallback": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/connect-history-api-fallback/-/connect-history-api-fallback-2.0.0.tgz",
@@ -7149,9 +7873,9 @@
}
},
"node_modules/core-js": {
- "version": "3.40.0",
- "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.40.0.tgz",
- "integrity": "sha512-7vsMc/Lty6AGnn7uFpYT56QesI5D2Y/UkgKounk87OP9Z2H9Z8kj6jzcSGAxFmUtDOS0ntK6lbQz+Nsa0Jj6mQ==",
+ "version": "3.46.0",
+ "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.46.0.tgz",
+ "integrity": "sha512-vDMm9B0xnqqZ8uSBpZ8sNtRtOdmfShrvT6h2TuQGLs0Is+cR0DYbj/KWP6ALVNbWPpqA/qPLoOuppJN07humpA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
@@ -7277,20 +8001,10 @@
"node": ">= 8"
}
},
- "node_modules/crypto-random-string": {
- "version": "2.0.0",
- "resolved": "https://registry.npmjs.org/crypto-random-string/-/crypto-random-string-2.0.0.tgz",
- "integrity": "sha512-v1plID3y9r/lPhviJ1wrXpLeyUIGAZ2SHNYTEapm7/8A9nLPoyvVp3RK/EPFqn5kEznyWgYZNsRtYYIWbuG8KA==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=8"
- }
- },
"node_modules/csp_evaluator": {
- "version": "1.1.1",
- "resolved": "https://registry.npmjs.org/csp_evaluator/-/csp_evaluator-1.1.1.tgz",
- "integrity": "sha512-N3ASg0C4kNPUaNxt1XAvzHIVuzdtr8KLgfk1O8WDyimp1GisPAHESupArO2ieHk9QWbrJ/WkQODyh21Ps/xhxw==",
+ "version": "1.1.5",
+ "resolved": "https://registry.npmjs.org/csp_evaluator/-/csp_evaluator-1.1.5.tgz",
+ "integrity": "sha512-EL/iN9etCTzw/fBnp0/uj0f5BOOGvZut2mzsiiBZ/FdT6gFQCKRO/tmcKOxn5drWZ2Ndm/xBb1SI4zwWbGtmIw==",
"dev": true,
"license": "Apache-2.0"
},
@@ -7673,9 +8387,9 @@
"license": "MIT"
},
"node_modules/debug": {
- "version": "4.4.0",
- "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz",
- "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==",
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -7735,9 +8449,9 @@
"license": "MIT"
},
"node_modules/dedent": {
- "version": "1.5.3",
- "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.5.3.tgz",
- "integrity": "sha512-NHQtfOOW68WD8lgypbLA5oT+Bt0xXJhiYvoR6SmmNXZfpzOGXwdKWmcwG8N7PwVVWV3eF/68nmD9BaJSsTBhyQ==",
+ "version": "1.7.0",
+ "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.0.tgz",
+ "integrity": "sha512-HGFtf8yhuhGhqO07SV79tRp+br4MnbdjeVxotpn1QBl30pcLLCQjX5b2295ll0fv8RKDKsmWYrl05usHM9CewQ==",
"dev": true,
"license": "MIT",
"peerDependencies": {
@@ -7850,65 +8564,6 @@
"node": ">= 14"
}
},
- "node_modules/del": {
- "version": "4.1.1",
- "resolved": "https://registry.npmjs.org/del/-/del-4.1.1.tgz",
- "integrity": "sha512-QwGuEUouP2kVwQenAsOof5Fv8K9t3D8Ca8NxcXKrIpEHjTXK5J2nXLdP+ALI1cgv8wj7KuwBhTwBkOZSJKM5XQ==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@types/glob": "^7.1.1",
- "globby": "^6.1.0",
- "is-path-cwd": "^2.0.0",
- "is-path-in-cwd": "^2.0.0",
- "p-map": "^2.0.0",
- "pify": "^4.0.1",
- "rimraf": "^2.6.3"
- },
- "engines": {
- "node": ">=6"
- }
- },
- "node_modules/del/node_modules/array-union": {
- "version": "1.0.2",
- "resolved": "https://registry.npmjs.org/array-union/-/array-union-1.0.2.tgz",
- "integrity": "sha512-Dxr6QJj/RdU/hCaBjOfxW+q6lyuVE6JFWIrAUpuOOhoJJoQ99cUn3igRaHVB5P9WrgFVN0FfArM3x0cueOU8ng==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "array-uniq": "^1.0.1"
- },
- "engines": {
- "node": ">=0.10.0"
- }
- },
- "node_modules/del/node_modules/globby": {
- "version": "6.1.0",
- "resolved": "https://registry.npmjs.org/globby/-/globby-6.1.0.tgz",
- "integrity": "sha512-KVbFv2TQtbzCoxAnfD6JcHZTYCzyliEaaeM/gH8qQdkKr5s0OP9scEgvdcngyk7AVdY6YVW/TJHd+lQ/Df3Daw==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "array-union": "^1.0.1",
- "glob": "^7.0.3",
- "object-assign": "^4.0.1",
- "pify": "^2.0.0",
- "pinkie-promise": "^2.0.0"
- },
- "engines": {
- "node": ">=0.10.0"
- }
- },
- "node_modules/del/node_modules/globby/node_modules/pify": {
- "version": "2.3.0",
- "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz",
- "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=0.10.0"
- }
- },
"node_modules/delayed-stream": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
@@ -7972,11 +8627,12 @@
"license": "MIT"
},
"node_modules/devtools-protocol": {
- "version": "0.0.1312386",
- "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1312386.tgz",
- "integrity": "sha512-DPnhUXvmvKT2dFA/j7B+riVLUt9Q6RKJlcppojL5CoRywJJKLDYnRlw0gTFKfgDPHP5E04UoB71SxoJlVZy8FA==",
+ "version": "0.0.1507524",
+ "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1507524.tgz",
+ "integrity": "sha512-OjaNE7qpk6GRTXtqQjAE5bGx6+c4F1zZH0YXtpZQLM92HNXx4zMAaqlKhP4T52DosG6hDW8gPMNhGOF8xbwk/w==",
"dev": true,
- "license": "BSD-3-Clause"
+ "license": "BSD-3-Clause",
+ "peer": true
},
"node_modules/diff-sequences": {
"version": "29.6.3",
@@ -8112,16 +8768,32 @@
}
},
"node_modules/dot-prop": {
- "version": "5.3.0",
- "resolved": "https://registry.npmjs.org/dot-prop/-/dot-prop-5.3.0.tgz",
- "integrity": "sha512-QM8q3zDe58hqUqjraQOmzZ1LIH9SWQJTlEKCH4kJ2oQvLZk7RbQXvtDM2XEq3fwkV9CCvvH4LA0AV+ogFsBM2Q==",
+ "version": "9.0.0",
+ "resolved": "https://registry.npmjs.org/dot-prop/-/dot-prop-9.0.0.tgz",
+ "integrity": "sha512-1gxPBJpI/pcjQhKgIU91II6Wkay+dLcN3M6rf2uwP8hRur3HtQXjVrdAK3sjC0piaEuxzMwjXChcETiJl47lAQ==",
"dev": true,
"license": "MIT",
"dependencies": {
- "is-obj": "^2.0.0"
+ "type-fest": "^4.18.2"
},
"engines": {
- "node": ">=8"
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/dot-prop/node_modules/type-fest": {
+ "version": "4.41.0",
+ "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz",
+ "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==",
+ "dev": true,
+ "license": "(MIT OR CC0-1.0)",
+ "engines": {
+ "node": ">=16"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/dunder-proto": {
@@ -8201,9 +8873,9 @@
}
},
"node_modules/end-of-stream": {
- "version": "1.4.4",
- "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz",
- "integrity": "sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==",
+ "version": "1.4.5",
+ "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
+ "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -8295,9 +8967,9 @@
}
},
"node_modules/es-abstract": {
- "version": "1.23.9",
- "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.23.9.tgz",
- "integrity": "sha512-py07lI0wjxAC/DcfK1S6G7iANonniZwTISvdPzk9hzeH0IZIshbuuFxLIU96OyF89Yb9hiqWn8M/bY83KY5vzA==",
+ "version": "1.24.0",
+ "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.0.tgz",
+ "integrity": "sha512-WSzPgsdLtTcQwm4CROfS5ju2Wa1QQcVeT37jFjYzdFz1r9ahadC8B8/a4qxJxM+09F18iumCdRmlr96ZYkQvEg==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -8305,18 +8977,18 @@
"arraybuffer.prototype.slice": "^1.0.4",
"available-typed-arrays": "^1.0.7",
"call-bind": "^1.0.8",
- "call-bound": "^1.0.3",
+ "call-bound": "^1.0.4",
"data-view-buffer": "^1.0.2",
"data-view-byte-length": "^1.0.2",
"data-view-byte-offset": "^1.0.1",
"es-define-property": "^1.0.1",
"es-errors": "^1.3.0",
- "es-object-atoms": "^1.0.0",
+ "es-object-atoms": "^1.1.1",
"es-set-tostringtag": "^2.1.0",
"es-to-primitive": "^1.3.0",
"function.prototype.name": "^1.1.8",
- "get-intrinsic": "^1.2.7",
- "get-proto": "^1.0.0",
+ "get-intrinsic": "^1.3.0",
+ "get-proto": "^1.0.1",
"get-symbol-description": "^1.1.0",
"globalthis": "^1.0.4",
"gopd": "^1.2.0",
@@ -8328,21 +9000,24 @@
"is-array-buffer": "^3.0.5",
"is-callable": "^1.2.7",
"is-data-view": "^1.0.2",
+ "is-negative-zero": "^2.0.3",
"is-regex": "^1.2.1",
+ "is-set": "^2.0.3",
"is-shared-array-buffer": "^1.0.4",
"is-string": "^1.1.1",
"is-typed-array": "^1.1.15",
- "is-weakref": "^1.1.0",
+ "is-weakref": "^1.1.1",
"math-intrinsics": "^1.1.0",
- "object-inspect": "^1.13.3",
+ "object-inspect": "^1.13.4",
"object-keys": "^1.1.1",
"object.assign": "^4.1.7",
"own-keys": "^1.0.1",
- "regexp.prototype.flags": "^1.5.3",
+ "regexp.prototype.flags": "^1.5.4",
"safe-array-concat": "^1.1.3",
"safe-push-apply": "^1.0.0",
"safe-regex-test": "^1.1.0",
"set-proto": "^1.0.0",
+ "stop-iteration-iterator": "^1.1.0",
"string.prototype.trim": "^1.2.10",
"string.prototype.trimend": "^1.0.9",
"string.prototype.trimstart": "^1.0.8",
@@ -8351,7 +9026,7 @@
"typed-array-byte-offset": "^1.0.4",
"typed-array-length": "^1.0.7",
"unbox-primitive": "^1.1.0",
- "which-typed-array": "^1.1.18"
+ "which-typed-array": "^1.1.19"
},
"engines": {
"node": ">= 0.4"
@@ -8445,13 +9120,16 @@
}
},
"node_modules/es-shim-unscopables": {
- "version": "1.0.2",
- "resolved": "https://registry.npmjs.org/es-shim-unscopables/-/es-shim-unscopables-1.0.2.tgz",
- "integrity": "sha512-J3yBRXCzDu4ULnQwxyToo/OjdMx6akgVC7K6few0a7F/0wLtmKKN7I73AH5T2836UuXRqN7Qg+IIUw/+YJksRw==",
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/es-shim-unscopables/-/es-shim-unscopables-1.1.0.tgz",
+ "integrity": "sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw==",
"dev": true,
"license": "MIT",
"dependencies": {
- "hasown": "^2.0.0"
+ "hasown": "^2.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
}
},
"node_modules/es-to-primitive": {
@@ -8542,6 +9220,7 @@
"deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.2.0",
"@eslint-community/regexpp": "^4.6.1",
@@ -8593,11 +9272,12 @@
}
},
"node_modules/eslint-config-prettier": {
- "version": "8.10.0",
- "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-8.10.0.tgz",
- "integrity": "sha512-SM8AMJdeQqRYT9O9zguiruQZaN7+z+E4eAP9oiLNGKMtomwaB1E9dcgUD6ZAn/eQAb52USbvezbiljfZUhbJcg==",
+ "version": "8.10.2",
+ "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-8.10.2.tgz",
+ "integrity": "sha512-/IGJ6+Dka158JnP5n5YFMOszjDWrXggGz1LaK/guZq9vZTmniaKlHcsscvkAhn9y4U+BU3JuUdYvtAMcv30y4A==",
"dev": true,
"license": "MIT",
+ "peer": true,
"bin": {
"eslint-config-prettier": "bin/cli.js"
},
@@ -8628,9 +9308,9 @@
}
},
"node_modules/eslint-module-utils": {
- "version": "2.12.0",
- "resolved": "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.12.0.tgz",
- "integrity": "sha512-wALZ0HFoytlyh/1+4wuZ9FJCD/leWHQzzrxJ8+rebyReSLk7LApMyd3WJaLVoN+D5+WIdJyDK1c6JnE65V4Zyg==",
+ "version": "2.12.1",
+ "resolved": "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.12.1.tgz",
+ "integrity": "sha512-L8jSWTze7K2mTg0vos/RuLRS5soomksDPoJLXIslC7c8Wmut3bx7CPpJijDcBZtxQ5lrbUdM+s0OlNbz0DCDNw==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -8656,30 +9336,30 @@
}
},
"node_modules/eslint-plugin-import": {
- "version": "2.31.0",
- "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.31.0.tgz",
- "integrity": "sha512-ixmkI62Rbc2/w8Vfxyh1jQRTdRTF52VxwRVHl/ykPAmqG+Nb7/kNn+byLP0LxPgI7zWA16Jt82SybJInmMia3A==",
+ "version": "2.32.0",
+ "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.32.0.tgz",
+ "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@rtsao/scc": "^1.1.0",
- "array-includes": "^3.1.8",
- "array.prototype.findlastindex": "^1.2.5",
- "array.prototype.flat": "^1.3.2",
- "array.prototype.flatmap": "^1.3.2",
+ "array-includes": "^3.1.9",
+ "array.prototype.findlastindex": "^1.2.6",
+ "array.prototype.flat": "^1.3.3",
+ "array.prototype.flatmap": "^1.3.3",
"debug": "^3.2.7",
"doctrine": "^2.1.0",
"eslint-import-resolver-node": "^0.3.9",
- "eslint-module-utils": "^2.12.0",
+ "eslint-module-utils": "^2.12.1",
"hasown": "^2.0.2",
- "is-core-module": "^2.15.1",
+ "is-core-module": "^2.16.1",
"is-glob": "^4.0.3",
"minimatch": "^3.1.2",
"object.fromentries": "^2.0.8",
"object.groupby": "^1.0.3",
- "object.values": "^1.2.0",
+ "object.values": "^1.2.1",
"semver": "^6.3.1",
- "string.prototype.trimend": "^1.0.8",
+ "string.prototype.trimend": "^1.0.9",
"tsconfig-paths": "^3.15.0"
},
"engines": {
@@ -8690,9 +9370,9 @@
}
},
"node_modules/eslint-plugin-import/node_modules/brace-expansion": {
- "version": "1.1.11",
- "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
- "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==",
+ "version": "1.1.12",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
+ "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -8881,9 +9561,9 @@
}
},
"node_modules/eslint-plugin-jest/node_modules/semver": {
- "version": "7.7.1",
- "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.1.tgz",
- "integrity": "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA==",
+ "version": "7.7.3",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
+ "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==",
"dev": true,
"license": "ISC",
"bin": {
@@ -8918,9 +9598,9 @@
}
},
"node_modules/eslint-plugin-jsdoc/node_modules/semver": {
- "version": "7.7.1",
- "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.1.tgz",
- "integrity": "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA==",
+ "version": "7.7.3",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
+ "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==",
"dev": true,
"license": "ISC",
"bin": {
@@ -8961,9 +9641,9 @@
}
},
"node_modules/eslint-plugin-jsx-a11y/node_modules/brace-expansion": {
- "version": "1.1.11",
- "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
- "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==",
+ "version": "1.1.12",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
+ "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -9001,14 +9681,14 @@
}
},
"node_modules/eslint-plugin-prettier": {
- "version": "5.2.3",
- "resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-5.2.3.tgz",
- "integrity": "sha512-qJ+y0FfCp/mQYQ/vWQ3s7eUlFEL4PyKfAJxsnYTJ4YT73nsJBWqmEpFryxV9OeUiqmsTsYJ5Y+KDNaeP31wrRw==",
+ "version": "5.5.4",
+ "resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-5.5.4.tgz",
+ "integrity": "sha512-swNtI95SToIz05YINMA6Ox5R057IMAmWZ26GqPxusAp1TZzj+IdY9tXNWWD3vkF/wEqydCONcwjTFpxybBqZsg==",
"dev": true,
"license": "MIT",
"dependencies": {
"prettier-linter-helpers": "^1.0.0",
- "synckit": "^0.9.1"
+ "synckit": "^0.11.7"
},
"engines": {
"node": "^14.18.0 || >=16.0.0"
@@ -9019,7 +9699,7 @@
"peerDependencies": {
"@types/eslint": ">=8.0.0",
"eslint": ">=8.0.0",
- "eslint-config-prettier": "*",
+ "eslint-config-prettier": ">= 7.0.0 <10.0.0 || >=10.1.0",
"prettier": ">=3.0.0"
},
"peerDependenciesMeta": {
@@ -9032,9 +9712,9 @@
}
},
"node_modules/eslint-plugin-react": {
- "version": "7.37.4",
- "resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.37.4.tgz",
- "integrity": "sha512-BGP0jRmfYyvOyvMoRX/uoUeW+GqNj9y16bPQzqAHf3AYII/tDs+jMN0dBVkl88/OZwNGwrVFxE7riHsXVfy/LQ==",
+ "version": "7.37.5",
+ "resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.37.5.tgz",
+ "integrity": "sha512-Qteup0SqU15kdocexFNAJMvCJEfa2xUKNV4CC1xsVMrIIqEy3SQ/rqyxCWNzfrd3/ldy6HMlD2e0JDVpDg2qIA==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -9048,7 +9728,7 @@
"hasown": "^2.0.2",
"jsx-ast-utils": "^2.4.1 || ^3.0.0",
"minimatch": "^3.1.2",
- "object.entries": "^1.1.8",
+ "object.entries": "^1.1.9",
"object.fromentries": "^2.0.8",
"object.values": "^1.2.1",
"prop-types": "^15.8.1",
@@ -9078,9 +9758,9 @@
}
},
"node_modules/eslint-plugin-react/node_modules/brace-expansion": {
- "version": "1.1.11",
- "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
- "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==",
+ "version": "1.1.12",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
+ "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -9174,9 +9854,9 @@
"license": "Python-2.0"
},
"node_modules/eslint/node_modules/brace-expansion": {
- "version": "1.1.11",
- "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
- "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==",
+ "version": "1.1.12",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
+ "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -9436,6 +10116,16 @@
"node": ">=0.8.x"
}
},
+ "node_modules/events-universal": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/events-universal/-/events-universal-1.0.1.tgz",
+ "integrity": "sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "bare-events": "^2.7.0"
+ }
+ },
"node_modules/execa": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz",
@@ -9937,27 +10627,10 @@
"node": "^10.12.0 || >=12.0.0"
}
},
- "node_modules/flat-cache/node_modules/rimraf": {
- "version": "3.0.2",
- "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz",
- "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==",
- "deprecated": "Rimraf versions prior to v4 are no longer supported",
- "dev": true,
- "license": "ISC",
- "dependencies": {
- "glob": "^7.1.3"
- },
- "bin": {
- "rimraf": "bin.js"
- },
- "funding": {
- "url": "https://github.com/sponsors/isaacs"
- }
- },
"node_modules/flatted": {
- "version": "3.3.2",
- "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.2.tgz",
- "integrity": "sha512-AiwGJM8YcNOaobumgtng+6NHuOqC3A7MixFeDafM3X9cIUM+xUXoS5Vfgf+OihAYe20fxqNM9yPBXJzRtZ/4eA==",
+ "version": "3.3.3",
+ "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz",
+ "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==",
"dev": true,
"license": "ISC"
},
@@ -9983,9 +10656,9 @@
}
},
"node_modules/for-each": {
- "version": "0.3.4",
- "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.4.tgz",
- "integrity": "sha512-kKaIINnFpzW6ffJNDjjyjrk21BkDx38c0xa/klsT8VzLCaMEefv4ZTacrcVR4DmgTeBra++jMDAfS/tS799YDw==",
+ "version": "0.3.5",
+ "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz",
+ "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -10046,6 +10719,13 @@
"node": ">= 0.6"
}
},
+ "node_modules/forwarded-parse": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/forwarded-parse/-/forwarded-parse-2.1.2.tgz",
+ "integrity": "sha512-alTFZZQDKMporBH77856pXgzhEzaUVmLCDk+egLgIgHst3Tpndzz8MnKe+GzRJRfvVdn69HhpW7cmXzvtLvJAw==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/fraction.js": {
"version": "4.3.7",
"resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.7.tgz",
@@ -10150,6 +10830,16 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/generator-function": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz",
+ "integrity": "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
"node_modules/gensync": {
"version": "1.0.0-beta.2",
"resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
@@ -10171,18 +10861,18 @@
}
},
"node_modules/get-intrinsic": {
- "version": "1.2.7",
- "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.7.tgz",
- "integrity": "sha512-VW6Pxhsrk0KAOqs3WEd0klDiF/+V7gQOpAvY1jVU/LHmaD/kQO4523aiJuikX/QAKYiW6x8Jh+RJej1almdtCA==",
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
+ "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
"dev": true,
"license": "MIT",
"dependencies": {
- "call-bind-apply-helpers": "^1.0.1",
+ "call-bind-apply-helpers": "^1.0.2",
"es-define-property": "^1.0.1",
"es-errors": "^1.3.0",
- "es-object-atoms": "^1.0.0",
+ "es-object-atoms": "^1.1.1",
"function-bind": "^1.1.2",
- "get-proto": "^1.0.0",
+ "get-proto": "^1.0.1",
"gopd": "^1.2.0",
"has-symbols": "^1.1.0",
"hasown": "^2.0.2",
@@ -10277,9 +10967,9 @@
}
},
"node_modules/get-uri": {
- "version": "6.0.4",
- "resolved": "https://registry.npmjs.org/get-uri/-/get-uri-6.0.4.tgz",
- "integrity": "sha512-E1b1lFFLvLgak2whF2xDBcOy6NLVGZBqqjJjsIhvopKfWWEi64pLVTWWehV8KlLerZkfNTA95sTe2OdJKm1OzQ==",
+ "version": "6.0.5",
+ "resolved": "https://registry.npmjs.org/get-uri/-/get-uri-6.0.5.tgz",
+ "integrity": "sha512-b1O07XYq8eRuVzBNgJLstU6FYc1tS6wnMtF1I1D9lE8LxZSOGZ7LhxN54yPP6mGw5f2CkXY2BQUL9Fx41qvcIg==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -10634,9 +11324,9 @@
}
},
"node_modules/hookified": {
- "version": "1.7.0",
- "resolved": "https://registry.npmjs.org/hookified/-/hookified-1.7.0.tgz",
- "integrity": "sha512-XQdMjqC1AyeOzfs+17cnIk7Wdfu1hh2JtcyNfBf5u9jHrT3iZUlGHxLTntFBuk5lwkqJ6l3+daeQdHK5yByHVA==",
+ "version": "1.12.2",
+ "resolved": "https://registry.npmjs.org/hookified/-/hookified-1.12.2.tgz",
+ "integrity": "sha512-aokUX1VdTpI0DUsndvW+OiwmBpKCu/NgRsSSkuSY0zq8PY6Q6a+lmOfAFDXAAOtBqJELvcWY9L1EVtzjbQcMdg==",
"dev": true,
"license": "MIT"
},
@@ -10997,13 +11687,6 @@
"dev": true,
"license": "MIT"
},
- "node_modules/immediate": {
- "version": "3.0.6",
- "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
- "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==",
- "dev": true,
- "license": "MIT"
- },
"node_modules/immutable": {
"version": "5.0.3",
"resolved": "https://registry.npmjs.org/immutable/-/immutable-5.0.3.tgz",
@@ -11038,6 +11721,19 @@
"node": ">=4"
}
},
+ "node_modules/import-in-the-middle": {
+ "version": "1.15.0",
+ "resolved": "https://registry.npmjs.org/import-in-the-middle/-/import-in-the-middle-1.15.0.tgz",
+ "integrity": "sha512-bpQy+CrsRmYmoPMAE/0G33iwRqwW4ouqdRg8jgbH3aKuCtOc8lxgmYXg2dMM92CRiGP660EtBcymH/eVUpCSaA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "acorn": "^8.14.0",
+ "acorn-import-attributes": "^1.9.5",
+ "cjs-module-lexer": "^1.2.2",
+ "module-details-from-path": "^1.0.3"
+ }
+ },
"node_modules/import-local": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/import-local/-/import-local-3.2.0.tgz",
@@ -11143,39 +11839,28 @@
}
},
"node_modules/intl-messageformat": {
- "version": "10.7.14",
- "resolved": "https://registry.npmjs.org/intl-messageformat/-/intl-messageformat-10.7.14.tgz",
- "integrity": "sha512-mMGnE4E1otdEutV5vLUdCxRJygHB5ozUBxsPB5qhitewssrS/qGruq9bmvIRkkGsNeK5ZWLfYRld18UHGTIifQ==",
+ "version": "10.7.18",
+ "resolved": "https://registry.npmjs.org/intl-messageformat/-/intl-messageformat-10.7.18.tgz",
+ "integrity": "sha512-m3Ofv/X/tV8Y3tHXLohcuVuhWKo7BBq62cqY15etqmLxg2DZ34AGGgQDeR+SCta2+zICb1NX83af0GJmbQ1++g==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
- "@formatjs/ecma402-abstract": "2.3.2",
- "@formatjs/fast-memoize": "2.2.6",
- "@formatjs/icu-messageformat-parser": "2.11.0",
- "tslib": "2"
+ "@formatjs/ecma402-abstract": "2.3.6",
+ "@formatjs/fast-memoize": "2.2.7",
+ "@formatjs/icu-messageformat-parser": "2.11.4",
+ "tslib": "^2.8.0"
}
},
"node_modules/ip-address": {
- "version": "9.0.5",
- "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-9.0.5.tgz",
- "integrity": "sha512-zHtQzGojZXTwZTHQqra+ETKd4Sn3vgi7uBmlPoXVWZqYvuKmtI0l/VZTjqGmJY9x88GGOaZ9+G9ES8hC4T4X8g==",
+ "version": "10.0.1",
+ "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.0.1.tgz",
+ "integrity": "sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA==",
"dev": true,
"license": "MIT",
- "dependencies": {
- "jsbn": "1.1.0",
- "sprintf-js": "^1.1.3"
- },
"engines": {
"node": ">= 12"
}
},
- "node_modules/ip-address/node_modules/sprintf-js": {
- "version": "1.1.3",
- "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz",
- "integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==",
- "dev": true,
- "license": "BSD-3-Clause"
- },
"node_modules/ipaddr.js": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.2.0.tgz",
@@ -11271,13 +11956,13 @@
}
},
"node_modules/is-boolean-object": {
- "version": "1.2.1",
- "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.1.tgz",
- "integrity": "sha512-l9qO6eFlUETHtuihLcYOaLKByJ1f+N4kthcU9YjHy3N+B3hWv0y/2Nd0mu/7lTFnRQHTrSdXF50HQ3bl5fEnng==",
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.2.tgz",
+ "integrity": "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==",
"dev": true,
"license": "MIT",
"dependencies": {
- "call-bound": "^1.0.2",
+ "call-bound": "^1.0.3",
"has-tostringtag": "^1.0.2"
},
"engines": {
@@ -11447,14 +12132,15 @@
}
},
"node_modules/is-generator-function": {
- "version": "1.1.0",
- "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.0.tgz",
- "integrity": "sha512-nPUB5km40q9e8UfN/Zc24eLlzdSf9OfKByBw9CIdw4H1giPMeA0OIJvbchsCu4npfI2QcMVBsGEBHKZ7wLTWmQ==",
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz",
+ "integrity": "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==",
"dev": true,
"license": "MIT",
"dependencies": {
- "call-bound": "^1.0.3",
- "get-proto": "^1.0.0",
+ "call-bound": "^1.0.4",
+ "generator-function": "^2.0.0",
+ "get-proto": "^1.0.1",
"has-tostringtag": "^1.0.2",
"safe-regex-test": "^1.1.0"
},
@@ -11491,6 +12177,19 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/is-negative-zero": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.3.tgz",
+ "integrity": "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
"node_modules/is-number": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
@@ -11518,52 +12217,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
- "node_modules/is-obj": {
- "version": "2.0.0",
- "resolved": "https://registry.npmjs.org/is-obj/-/is-obj-2.0.0.tgz",
- "integrity": "sha512-drqDG3cbczxxEJRoOXcOjtdp1J/lyp1mNn0xaznRs8+muBhgQcrnbspox5X5fOw0HnMnbfDzvnEMEtqDEJEo8w==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=8"
- }
- },
- "node_modules/is-path-cwd": {
- "version": "2.2.0",
- "resolved": "https://registry.npmjs.org/is-path-cwd/-/is-path-cwd-2.2.0.tgz",
- "integrity": "sha512-w942bTcih8fdJPJmQHFzkS76NEP8Kzzvmw92cXsazb8intwLqPibPPdXf4ANdKV3rYMuuQYGIWtvz9JilB3NFQ==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=6"
- }
- },
- "node_modules/is-path-in-cwd": {
- "version": "2.1.0",
- "resolved": "https://registry.npmjs.org/is-path-in-cwd/-/is-path-in-cwd-2.1.0.tgz",
- "integrity": "sha512-rNocXHgipO+rvnP6dk3zI20RpOtrAM/kzbB258Uw5BWr3TpXi861yzjo16Dn4hUox07iw5AyeMLHWsujkjzvRQ==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "is-path-inside": "^2.1.0"
- },
- "engines": {
- "node": ">=6"
- }
- },
- "node_modules/is-path-in-cwd/node_modules/is-path-inside": {
- "version": "2.1.0",
- "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-2.1.0.tgz",
- "integrity": "sha512-wiyhTzfDWsvwAW53OBWF5zuvaOGlZ6PwYxAbPVDhpm+gM09xKQGjBq/8uYN12aDvMxnAnq3dxTyoSoRNmg5YFg==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "path-is-inside": "^1.0.2"
- },
- "engines": {
- "node": ">=6"
- }
- },
"node_modules/is-path-inside": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz",
@@ -11716,13 +12369,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
- "node_modules/is-typedarray": {
- "version": "1.0.0",
- "resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz",
- "integrity": "sha512-cyA56iCMHAh5CdzjJIa4aohJyeO1YbwLi3Jc35MmRU6poroFjIGZzUzupGiRPOjgHg9TLu43xbpwXk523fMxKA==",
- "dev": true,
- "license": "MIT"
- },
"node_modules/is-unicode-supported": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz",
@@ -11897,9 +12543,9 @@
}
},
"node_modules/istanbul-reports": {
- "version": "3.1.7",
- "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.1.7.tgz",
- "integrity": "sha512-BewmUXImeuRk2YY0PVbxgKAysvhRPUQE0h5QRM++nVWyubKGV0l8qQ5op8+B2DOmwSe63Jivj0BjkPQVf8fP5g==",
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz",
+ "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
@@ -11934,6 +12580,7 @@
"integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@jest/core": "^29.7.0",
"@jest/types": "^29.6.3",
@@ -12458,9 +13105,9 @@
}
},
"node_modules/jest-snapshot/node_modules/semver": {
- "version": "7.7.1",
- "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.1.tgz",
- "integrity": "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA==",
+ "version": "7.7.3",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
+ "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==",
"dev": true,
"license": "ISC",
"bin": {
@@ -12610,13 +13257,6 @@
"js-yaml": "bin/js-yaml.js"
}
},
- "node_modules/jsbn": {
- "version": "1.1.0",
- "resolved": "https://registry.npmjs.org/jsbn/-/jsbn-1.1.0.tgz",
- "integrity": "sha512-4bYVV3aAMtDTTu4+xsDYa6sy9GyJ69/amsu9sYF2zqjiEoZA5xJi3BrfX3uY+/IekIu7MwdObdbDWpoZdBv3/A==",
- "dev": true,
- "license": "MIT"
- },
"node_modules/jsdoc-type-pratt-parser": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/jsdoc-type-pratt-parser/-/jsdoc-type-pratt-parser-4.0.0.tgz",
@@ -12801,9 +13441,9 @@
}
},
"node_modules/known-css-properties": {
- "version": "0.35.0",
- "resolved": "https://registry.npmjs.org/known-css-properties/-/known-css-properties-0.35.0.tgz",
- "integrity": "sha512-a/RAk2BfKk+WFGhhOCAYqSiFLc34k8Mt/6NWRI4joER0EYUzXIcFivjjnoD3+XU1DggLn/tZc3DOAgke7l8a4A==",
+ "version": "0.37.0",
+ "resolved": "https://registry.npmjs.org/known-css-properties/-/known-css-properties-0.37.0.tgz",
+ "integrity": "sha512-JCDrsP4Z1Sb9JwG0aJ8Eo2r7k4Ou5MwmThS/6lcIe1ICyb7UBJKGRIUUdqc2ASdE/42lgz6zFUnzAIhtXnBVrQ==",
"dev": true,
"license": "MIT"
},
@@ -12848,6 +13488,13 @@
"node": ">=0.10.0"
}
},
+ "node_modules/legacy-javascript": {
+ "version": "0.0.1",
+ "resolved": "https://registry.npmjs.org/legacy-javascript/-/legacy-javascript-0.0.1.tgz",
+ "integrity": "sha512-lPyntS4/aS7jpuvOlitZDFifBCb4W8L/3QU0PLbUTUj+zYah8rfVjYic88yG7ZKTxhS5h9iz7duT8oUXKszLhg==",
+ "dev": true,
+ "license": "Apache-2.0"
+ },
"node_modules/leven": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz",
@@ -12872,48 +13519,37 @@
"node": ">= 0.8.0"
}
},
- "node_modules/lie": {
- "version": "3.1.1",
- "resolved": "https://registry.npmjs.org/lie/-/lie-3.1.1.tgz",
- "integrity": "sha512-RiNhHysUjhrDQntfYSfY4MU24coXXdEOgw9WGcKHNeEwffDYbF//u87M1EWaMGzuFoSbqW0C9C6lEEhDOAswfw==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "immediate": "~3.0.5"
- }
- },
"node_modules/lighthouse": {
- "version": "12.3.0",
- "resolved": "https://registry.npmjs.org/lighthouse/-/lighthouse-12.3.0.tgz",
- "integrity": "sha512-OaLE8DasnwQkn2CBo2lKtD+IQv42mNP3T+Vaw29I++rAh0Zpgc6SM15usdIYyzhRMR5EWFxze5Fyb+HENJSh2A==",
+ "version": "12.8.2",
+ "resolved": "https://registry.npmjs.org/lighthouse/-/lighthouse-12.8.2.tgz",
+ "integrity": "sha512-+5SKYzVaTFj22MgoYDPNrP9tlD2/Ay7j3SxPSFD9FpPyVxGr4UtOQGKyrdZ7wCmcnBaFk0mCkPfARU3CsE0nvA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
- "@paulirish/trace_engine": "0.0.39",
- "@sentry/node": "^7.0.0",
- "axe-core": "^4.10.2",
- "chrome-launcher": "^1.1.2",
- "configstore": "^5.0.1",
- "csp_evaluator": "1.1.1",
- "devtools-protocol": "0.0.1312386",
+ "@paulirish/trace_engine": "0.0.59",
+ "@sentry/node": "^9.28.1",
+ "axe-core": "^4.10.3",
+ "chrome-launcher": "^1.2.0",
+ "configstore": "^7.0.0",
+ "csp_evaluator": "1.1.5",
+ "devtools-protocol": "0.0.1507524",
"enquirer": "^2.3.6",
"http-link-header": "^1.1.1",
"intl-messageformat": "^10.5.3",
"jpeg-js": "^0.4.4",
"js-library-detector": "^6.7.0",
- "lighthouse-logger": "^2.0.1",
+ "lighthouse-logger": "^2.0.2",
"lighthouse-stack-packs": "1.12.2",
"lodash-es": "^4.17.21",
"lookup-closest-locale": "6.2.0",
"metaviewport-parser": "0.3.0",
"open": "^8.4.0",
"parse-cache-control": "1.0.1",
- "puppeteer-core": "^23.10.4",
+ "puppeteer-core": "^24.17.1",
"robots-parser": "^3.0.1",
- "semver": "^5.3.0",
"speedline-core": "^1.4.3",
- "third-party-web": "^0.26.1",
- "tldts-icann": "^6.1.16",
+ "third-party-web": "^0.27.0",
+ "tldts-icann": "^7.0.12",
"ws": "^7.0.0",
"yargs": "^17.3.1",
"yargs-parser": "^21.0.0"
@@ -12928,33 +13564,16 @@
}
},
"node_modules/lighthouse-logger": {
- "version": "2.0.1",
- "resolved": "https://registry.npmjs.org/lighthouse-logger/-/lighthouse-logger-2.0.1.tgz",
- "integrity": "sha512-ioBrW3s2i97noEmnXxmUq7cjIcVRjT5HBpAYy8zE11CxU9HqlWHHeRxfeN1tn8F7OEMVPIC9x1f8t3Z7US9ehQ==",
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/lighthouse-logger/-/lighthouse-logger-2.0.2.tgz",
+ "integrity": "sha512-vWl2+u5jgOQuZR55Z1WM0XDdrJT6mzMP8zHUct7xTlWhuQs+eV0g+QL0RQdFjT54zVmbhLCP8vIVpy1wGn/gCg==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
- "debug": "^2.6.9",
+ "debug": "^4.4.1",
"marky": "^1.2.2"
}
},
- "node_modules/lighthouse-logger/node_modules/debug": {
- "version": "2.6.9",
- "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
- "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "ms": "2.0.0"
- }
- },
- "node_modules/lighthouse-logger/node_modules/ms": {
- "version": "2.0.0",
- "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
- "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
- "dev": true,
- "license": "MIT"
- },
"node_modules/lighthouse-stack-packs": {
"version": "1.12.2",
"resolved": "https://registry.npmjs.org/lighthouse-stack-packs/-/lighthouse-stack-packs-1.12.2.tgz",
@@ -12962,14 +13581,102 @@
"dev": true,
"license": "Apache-2.0"
},
+ "node_modules/lighthouse/node_modules/@puppeteer/browsers": {
+ "version": "2.10.12",
+ "resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-2.10.12.tgz",
+ "integrity": "sha512-mP9iLFZwH+FapKJLeA7/fLqOlSUwYpMwjR1P5J23qd4e7qGJwecJccJqHYrjw33jmIZYV4dtiTHPD/J+1e7cEw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "debug": "^4.4.3",
+ "extract-zip": "^2.0.1",
+ "progress": "^2.0.3",
+ "proxy-agent": "^6.5.0",
+ "semver": "^7.7.3",
+ "tar-fs": "^3.1.1",
+ "yargs": "^17.7.2"
+ },
+ "bin": {
+ "browsers": "lib/cjs/main-cli.js"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/lighthouse/node_modules/puppeteer-core": {
+ "version": "24.25.0",
+ "resolved": "https://registry.npmjs.org/puppeteer-core/-/puppeteer-core-24.25.0.tgz",
+ "integrity": "sha512-8Xs6q3Ut+C8y7sAaqjIhzv1QykGWG4gc2mEZ2mYE7siZFuRp4xQVehOf8uQKSQAkeL7jXUs3mknEeiqnRqUKvQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@puppeteer/browsers": "2.10.12",
+ "chromium-bidi": "9.1.0",
+ "debug": "^4.4.3",
+ "devtools-protocol": "0.0.1508733",
+ "typed-query-selector": "^2.12.0",
+ "webdriver-bidi-protocol": "0.3.7",
+ "ws": "^8.18.3"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/lighthouse/node_modules/puppeteer-core/node_modules/chromium-bidi": {
+ "version": "9.1.0",
+ "resolved": "https://registry.npmjs.org/chromium-bidi/-/chromium-bidi-9.1.0.tgz",
+ "integrity": "sha512-rlUzQ4WzIAWdIbY/viPShhZU2n21CxDUgazXVbw4Hu1MwaeUSEksSeM6DqPgpRjCLXRk702AVRxJxoOz0dw4OA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "mitt": "^3.0.1",
+ "zod": "^3.24.1"
+ },
+ "peerDependencies": {
+ "devtools-protocol": "*"
+ }
+ },
+ "node_modules/lighthouse/node_modules/puppeteer-core/node_modules/devtools-protocol": {
+ "version": "0.0.1508733",
+ "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1508733.tgz",
+ "integrity": "sha512-QJ1R5gtck6nDcdM+nlsaJXcelPEI7ZxSMw1ujHpO1c4+9l+Nue5qlebi9xO1Z2MGr92bFOQTW7/rrheh5hHxDg==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "peer": true
+ },
+ "node_modules/lighthouse/node_modules/puppeteer-core/node_modules/ws": {
+ "version": "8.18.3",
+ "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz",
+ "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=10.0.0"
+ },
+ "peerDependencies": {
+ "bufferutil": "^4.0.1",
+ "utf-8-validate": ">=5.0.2"
+ },
+ "peerDependenciesMeta": {
+ "bufferutil": {
+ "optional": true
+ },
+ "utf-8-validate": {
+ "optional": true
+ }
+ }
+ },
"node_modules/lighthouse/node_modules/semver": {
- "version": "5.7.2",
- "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz",
- "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==",
+ "version": "7.7.3",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
+ "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==",
"dev": true,
"license": "ISC",
"bin": {
- "semver": "bin/semver"
+ "semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
}
},
"node_modules/lighthouse/node_modules/ws": {
@@ -12994,6 +13701,16 @@
}
}
},
+ "node_modules/lighthouse/node_modules/zod": {
+ "version": "3.25.76",
+ "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
+ "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/colinhacks"
+ }
+ },
"node_modules/lilconfig": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz",
@@ -13049,16 +13766,6 @@
"node": ">=8.9.0"
}
},
- "node_modules/localforage": {
- "version": "1.10.0",
- "resolved": "https://registry.npmjs.org/localforage/-/localforage-1.10.0.tgz",
- "integrity": "sha512-14/H1aX7hzBBmmh7sGPd+AOMkkIrHM3Z1PAyGgZigA1H1p5O5ANnMyWzvpAETtG68/dC4pC0ncy3+PPGzXZHPg==",
- "dev": true,
- "license": "Apache-2.0",
- "dependencies": {
- "lie": "3.1.1"
- }
- },
"node_modules/locate-path": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
@@ -13209,9 +13916,9 @@
}
},
"node_modules/make-dir/node_modules/semver": {
- "version": "7.7.1",
- "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.1.tgz",
- "integrity": "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA==",
+ "version": "7.7.3",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
+ "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==",
"dev": true,
"license": "ISC",
"bin": {
@@ -13395,9 +14102,9 @@
"license": "MIT"
},
"node_modules/marky": {
- "version": "1.2.5",
- "resolved": "https://registry.npmjs.org/marky/-/marky-1.2.5.tgz",
- "integrity": "sha512-q9JtQJKjpsVxCRVgQ+WapguSbKC3SQ5HEzFGPAJMStgh3QjCawp00UKv3MTTAArTmGmmPUvllHZoNbZ3gs0I+Q==",
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/marky/-/marky-1.3.0.tgz",
+ "integrity": "sha512-ocnPZQLNpvbedwTy9kNrQEsknEfgvcLMvOtz3sFeWApDq1MXH1TqkCIx58xlpESsfwQOnuBO9beyQuNGzVvuhQ==",
"dev": true,
"license": "Apache-2.0"
},
@@ -13758,6 +14465,13 @@
"node": ">=0.10.0"
}
},
+ "node_modules/module-details-from-path": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/module-details-from-path/-/module-details-from-path-1.0.4.tgz",
+ "integrity": "sha512-EGWKgxALGMgzvxYF1UyGTy0HXX/2vHLkw6+NvDKW2jypWbHpjQuj4UMcqQWXHERJhVGKikolT06G3bcKe4fi7w==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/mrmime": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.0.tgz",
@@ -13790,9 +14504,9 @@
}
},
"node_modules/nanoid": {
- "version": "3.3.8",
- "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.8.tgz",
- "integrity": "sha512-WNLf5Sd8oZxOm+TzppcYk8gVOgP+l58xNy58D0nbUnOxOWRWvlcCV4kUF7ltmI6PsrLl/BgKEyS4mqsGChFN0w==",
+ "version": "3.3.11",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
+ "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
"dev": true,
"funding": [
{
@@ -13902,9 +14616,9 @@
}
},
"node_modules/normalize-package-data/node_modules/semver": {
- "version": "7.7.1",
- "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.1.tgz",
- "integrity": "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA==",
+ "version": "7.7.3",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
+ "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==",
"dev": true,
"license": "ISC",
"bin": {
@@ -13957,6 +14671,7 @@
"integrity": "sha512-cuXAJJB1Rdqz0UO6w524matlBqDBjcNt7Ru+RDIu4y6RI1gVqiWBnylrK8sPRk81gGBA0X8hJbDXolVOoTc+sA==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"ajv": "^6.12.6",
"ajv-errors": "^1.0.1",
@@ -13992,9 +14707,9 @@
"license": "MIT"
},
"node_modules/npm-package-json-lint/node_modules/semver": {
- "version": "7.7.1",
- "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.1.tgz",
- "integrity": "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA==",
+ "version": "7.7.3",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
+ "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==",
"dev": true,
"license": "ISC",
"bin": {
@@ -14087,9 +14802,9 @@
"license": "MIT"
},
"node_modules/object-inspect": {
- "version": "1.13.3",
- "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.3.tgz",
- "integrity": "sha512-kDCGIbxkDSXE3euJZZXzc6to7fCrKHNI/hSRQnRuQ+BWjFNzZwiFF8fj/6o2t2G9/jTj8PSIYTfCLelLZEeRpA==",
+ "version": "1.13.4",
+ "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
+ "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
"dev": true,
"license": "MIT",
"engines": {
@@ -14131,15 +14846,16 @@
}
},
"node_modules/object.entries": {
- "version": "1.1.8",
- "resolved": "https://registry.npmjs.org/object.entries/-/object.entries-1.1.8.tgz",
- "integrity": "sha512-cmopxi8VwRIAw/fkijJohSfpef5PdN0pMQJN6VC/ZKvn0LIknWD8KtgY6KlQdEc4tIjcQ3HxSMmnvtzIscdaYQ==",
+ "version": "1.1.9",
+ "resolved": "https://registry.npmjs.org/object.entries/-/object.entries-1.1.9.tgz",
+ "integrity": "sha512-8u/hfXFRBD1O0hPUjioLhoWFHRmt6tKA4/vZPyckBr18l1KE9uHrFaFaUi8MDRTpi4uak2goyPTSNJLXX2k2Hw==",
"dev": true,
"license": "MIT",
"dependencies": {
- "call-bind": "^1.0.7",
+ "call-bind": "^1.0.8",
+ "call-bound": "^1.0.4",
"define-properties": "^1.2.1",
- "es-object-atoms": "^1.0.0"
+ "es-object-atoms": "^1.1.1"
},
"engines": {
"node": ">= 0.4"
@@ -14373,16 +15089,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
- "node_modules/p-map": {
- "version": "2.1.0",
- "resolved": "https://registry.npmjs.org/p-map/-/p-map-2.1.0.tgz",
- "integrity": "sha512-y3b8Kpd8OAN444hxfBbFfj1FY/RjtTd8tzYwhUqNYXx0fXx2iX4maP4Qr6qhIKbQXI02wTLAda4fYUbDagTUFw==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=6"
- }
- },
"node_modules/p-retry": {
"version": "4.6.2",
"resolved": "https://registry.npmjs.org/p-retry/-/p-retry-4.6.2.tgz",
@@ -14408,9 +15114,9 @@
}
},
"node_modules/pac-proxy-agent": {
- "version": "7.1.0",
- "resolved": "https://registry.npmjs.org/pac-proxy-agent/-/pac-proxy-agent-7.1.0.tgz",
- "integrity": "sha512-Z5FnLVVZSnX7WjBg0mhDtydeRZ1xMcATZThjySQUHqr+0ksP8kqaw23fNKkaaN/Z8gwLUs/W7xdl0I75eP2Xyw==",
+ "version": "7.2.0",
+ "resolved": "https://registry.npmjs.org/pac-proxy-agent/-/pac-proxy-agent-7.2.0.tgz",
+ "integrity": "sha512-TEB8ESquiLMc0lV8vcd5Ql/JAKAoyzHFXaStwjkzpOpC5Yv+pIzLfHvjTSdf3vpa2bMiUQrg9i6276yn8666aA==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -14428,9 +15134,9 @@
}
},
"node_modules/pac-proxy-agent/node_modules/agent-base": {
- "version": "7.1.3",
- "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.3.tgz",
- "integrity": "sha512-jRR5wdylq8CkOe6hei19GGZnxM6rBGwFl3Bg0YItGDimvjGtAvdZk4Pu6Cl4u4Igsws4a1fd1Vq3ezrhn4KmFw==",
+ "version": "7.1.4",
+ "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
+ "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
"dev": true,
"license": "MIT",
"engines": {
@@ -14603,13 +15309,6 @@
"node": ">=0.10.0"
}
},
- "node_modules/path-is-inside": {
- "version": "1.0.2",
- "resolved": "https://registry.npmjs.org/path-is-inside/-/path-is-inside-1.0.2.tgz",
- "integrity": "sha512-DUWJr3+ULp4zXmol/SZkFf3JGsS9/SIv+Y3Rt93/UjPpDpklB5f1er4O3POIbUuUJ3FXgqte2Q7SrU6zAqwk8w==",
- "dev": true,
- "license": "(WTFPL OR MIT)"
- },
"node_modules/path-key": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
@@ -14651,6 +15350,40 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/pg-int8": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
+ "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",
+ "dev": true,
+ "license": "ISC",
+ "engines": {
+ "node": ">=4.0.0"
+ }
+ },
+ "node_modules/pg-protocol": {
+ "version": "1.10.3",
+ "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.10.3.tgz",
+ "integrity": "sha512-6DIBgBQaTKDJyxnXaLiLR8wBpQQcGWuAESkRBX/t6OwA8YsqP+iVSiond2EDy6Y/dsGk8rh/jtax3js5NeV7JQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/pg-types": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",
+ "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "pg-int8": "1.0.1",
+ "postgres-array": "~2.0.0",
+ "postgres-bytea": "~1.0.0",
+ "postgres-date": "~1.0.4",
+ "postgres-interval": "^1.1.0"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -14672,42 +15405,19 @@
}
},
"node_modules/pify": {
- "version": "4.0.1",
- "resolved": "https://registry.npmjs.org/pify/-/pify-4.0.1.tgz",
- "integrity": "sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==",
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz",
+ "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==",
"dev": true,
"license": "MIT",
"engines": {
- "node": ">=6"
- }
- },
- "node_modules/pinkie": {
- "version": "2.0.4",
- "resolved": "https://registry.npmjs.org/pinkie/-/pinkie-2.0.4.tgz",
- "integrity": "sha512-MnUuEycAemtSaeFSjXKW/aroV7akBbY+Sv+RkyqFjgAe73F+MR0TBWKBRDkmfWq/HiFmdavfZ1G7h4SPZXaCSg==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=0.10.0"
- }
- },
- "node_modules/pinkie-promise": {
- "version": "2.0.1",
- "resolved": "https://registry.npmjs.org/pinkie-promise/-/pinkie-promise-2.0.1.tgz",
- "integrity": "sha512-0Gni6D4UcLTbv9c57DfxDGdr41XfgUjqWZu492f0cIGr16zDU06BWP/RAEvOuo7CQ0CNjHaLlM59YJJFm3NWlw==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "pinkie": "^2.0.0"
- },
- "engines": {
"node": ">=0.10.0"
}
},
"node_modules/pirates": {
- "version": "4.0.6",
- "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.6.tgz",
- "integrity": "sha512-saLsH7WeYYPiD25LDuLRRY/i+6HaPYr6G1OUlN39otzkSTxKnubR9RTxS3/Kk50s1g2JTgFwWQDQyplC5/SHZg==",
+ "version": "4.0.7",
+ "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz",
+ "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==",
"dev": true,
"license": "MIT",
"engines": {
@@ -14819,14 +15529,13 @@
}
},
"node_modules/playwright": {
- "version": "1.50.1",
- "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.50.1.tgz",
- "integrity": "sha512-G8rwsOQJ63XG6BbKj2w5rHeavFjy5zynBA9zsJMMtBoe/Uf757oG12NXz6e6OirF7RCrTVAKFXbLmn1RbL7Qaw==",
+ "version": "1.56.1",
+ "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.56.1.tgz",
+ "integrity": "sha512-aFi5B0WovBHTEvpM3DzXTUaeN6eN0qWnTkKx4NQaH4Wvcmc153PdaY2UBdSYKaGYw+UyWXSVyxDUg5DoPEttjw==",
"dev": true,
"license": "Apache-2.0",
- "peer": true,
"dependencies": {
- "playwright-core": "1.50.1"
+ "playwright-core": "1.56.1"
},
"bin": {
"playwright": "cli.js"
@@ -14839,12 +15548,11 @@
}
},
"node_modules/playwright-core": {
- "version": "1.50.1",
- "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.50.1.tgz",
- "integrity": "sha512-ra9fsNWayuYumt+NiM069M6OkcRb1FZSK8bgi66AtpFoWkg2+y0bJSNmkFrWhMbEBbVKC/EruAHH3g0zmtwGmQ==",
+ "version": "1.56.1",
+ "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.56.1.tgz",
+ "integrity": "sha512-hutraynyn31F+Bifme+Ps9Vq59hKuUCz7H1kDOcBs+2oGguKkWTU50bBWrtz34OUWmIwpBTWDxaRPXrIXkgvmQ==",
"dev": true,
"license": "Apache-2.0",
- "peer": true,
"bin": {
"playwright-core": "cli.js"
},
@@ -14852,6 +15560,21 @@
"node": ">=18"
}
},
+ "node_modules/playwright/node_modules/fsevents": {
+ "version": "2.3.2",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
+ "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
"node_modules/plur": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/plur/-/plur-4.0.0.tgz",
@@ -14869,9 +15592,9 @@
}
},
"node_modules/possible-typed-array-names": {
- "version": "1.0.0",
- "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.0.0.tgz",
- "integrity": "sha512-d7Uw+eZoloe0EHDIYoe+bQ5WXnGMOpmiZFTuMWCwpjzzkL2nTjcKiAk4hh8TjnGye2TwWOk3UXucZ+3rbmBa8Q==",
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",
+ "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==",
"dev": true,
"license": "MIT",
"engines": {
@@ -14879,9 +15602,9 @@
}
},
"node_modules/postcss": {
- "version": "8.5.1",
- "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.1.tgz",
- "integrity": "sha512-6oz2beyjc5VMn/KV1pPw8fliQkhBXrVn1Z3TVyqZxU8kZpzEKhBdmCFqI6ZbmGtamQvQGuU1sgPTk8ZrXDD7jQ==",
+ "version": "8.5.6",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
+ "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==",
"dev": true,
"funding": [
{
@@ -14898,8 +15621,9 @@
}
],
"license": "MIT",
+ "peer": true,
"dependencies": {
- "nanoid": "^3.3.8",
+ "nanoid": "^3.3.11",
"picocolors": "^1.1.1",
"source-map-js": "^1.2.1"
},
@@ -15012,6 +15736,24 @@
"postcss": "^8.4.31"
}
},
+ "node_modules/postcss-import": {
+ "version": "16.1.1",
+ "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-16.1.1.tgz",
+ "integrity": "sha512-2xVS1NCZAfjtVdvXiyegxzJ447GyqCeEI5V7ApgQVOWnros1p5lGNovJNapwPpMombyFBfqDwt7AD3n2l0KOfQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "postcss-value-parser": "^4.0.0",
+ "read-cache": "^1.0.0",
+ "resolve": "^1.1.7"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ },
+ "peerDependencies": {
+ "postcss": "^8.0.0"
+ }
+ },
"node_modules/postcss-loader": {
"version": "6.2.1",
"resolved": "https://registry.npmjs.org/postcss-loader/-/postcss-loader-6.2.1.tgz",
@@ -15574,6 +16316,49 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/postgres-array": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
+ "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/postgres-bytea": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.0.tgz",
+ "integrity": "sha512-xy3pmLuQqRBZBXDULy7KbaitYqLcmxigw14Q5sj8QBVLqEwXfeybIKVWiqAXTlcvdvb0+xkOtDbfQMOf4lST1w==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/postgres-date": {
+ "version": "1.0.7",
+ "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",
+ "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/postgres-interval": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",
+ "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "xtend": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/prelude-ls": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
@@ -15591,6 +16376,7 @@
"integrity": "sha512-X4UlrxDTH8oom9qXlcjnydsjAOD2BmB6yFmvS4Z2zdTzqqpRWb+fbqrH412+l+OUXmbzJlSXjlMFYPgYG12IAA==",
"dev": true,
"license": "MIT",
+ "peer": true,
"bin": {
"prettier": "bin/prettier.cjs"
},
@@ -15737,9 +16523,9 @@
}
},
"node_modules/proxy-agent/node_modules/agent-base": {
- "version": "7.1.3",
- "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.3.tgz",
- "integrity": "sha512-jRR5wdylq8CkOe6hei19GGZnxM6rBGwFl3Bg0YItGDimvjGtAvdZk4Pu6Cl4u4Igsws4a1fd1Vq3ezrhn4KmFw==",
+ "version": "7.1.4",
+ "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
+ "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
"dev": true,
"license": "MIT",
"engines": {
@@ -15805,9 +16591,9 @@
}
},
"node_modules/pump": {
- "version": "3.0.2",
- "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.2.tgz",
- "integrity": "sha512-tUPXtzlGM8FE3P0ZL6DVs/3P58k9nk8/jZeQCurTJylQA8qFYzHFfhBJkuqyE0FifOsQ0uKWekiZ5g8wtr28cw==",
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.3.tgz",
+ "integrity": "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -15867,6 +16653,19 @@
],
"license": "MIT"
},
+ "node_modules/qified": {
+ "version": "0.5.1",
+ "resolved": "https://registry.npmjs.org/qified/-/qified-0.5.1.tgz",
+ "integrity": "sha512-+BtFN3dCP+IaFA6IYNOu/f/uK1B8xD2QWyOeCse0rjtAebBmkzgd2d1OAXi3ikAzJMIBSdzZDNZ3wZKEUDQs5w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "hookified": "^1.12.2"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
"node_modules/qs": {
"version": "6.13.0",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz",
@@ -15976,6 +16775,7 @@
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"loose-envify": "^1.1.0"
},
@@ -16011,10 +16811,21 @@
"integrity": "sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA==",
"dev": true,
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">=0.10.0"
}
},
+ "node_modules/read-cache": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
+ "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "pify": "^2.3.0"
+ }
+ },
"node_modules/read-pkg": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-5.2.0.tgz",
@@ -16307,6 +17118,21 @@
"node": ">=0.10.0"
}
},
+ "node_modules/require-in-the-middle": {
+ "version": "7.5.2",
+ "resolved": "https://registry.npmjs.org/require-in-the-middle/-/require-in-the-middle-7.5.2.tgz",
+ "integrity": "sha512-gAZ+kLqBdHarXB64XpAe2VCjB7rIRv+mU8tfRWziHRJ5umKsIHN2tLLv6EtMw7WCdP19S0ERVMldNvxYCHnhSQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "debug": "^4.3.5",
+ "module-details-from-path": "^1.0.3",
+ "resolve": "^1.22.8"
+ },
+ "engines": {
+ "node": ">=8.6.0"
+ }
+ },
"node_modules/requireindex": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/requireindex/-/requireindex-1.2.0.tgz",
@@ -16424,9 +17250,9 @@
}
},
"node_modules/rimraf": {
- "version": "2.7.1",
- "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz",
- "integrity": "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==",
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz",
+ "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==",
"deprecated": "Rimraf versions prior to v4 are no longer supported",
"dev": true,
"license": "ISC",
@@ -16435,6 +17261,9 @@
},
"bin": {
"rimraf": "bin.js"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/robots-parser": {
@@ -16615,6 +17444,7 @@
"integrity": "sha512-B1bozCeNQiOgDcLd33e2Cs2U60wZwjUUXzh900ZyQF5qUasvMdDZYbQ566LJu7cqR+sAHlAfO6RMkaID5s6qpA==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"chokidar": "^4.0.0",
"immutable": "^5.0.2",
@@ -16690,7 +17520,6 @@
"integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"loose-envify": "^1.1.0"
}
@@ -16721,6 +17550,7 @@
"integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"fast-deep-equal": "^3.1.3",
"fast-uri": "^3.0.1",
@@ -17103,6 +17933,13 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/shimmer": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/shimmer/-/shimmer-1.2.1.tgz",
+ "integrity": "sha512-sQTKC1Re/rM6XyFM6fIAGHRPVGvyXfgzIDvzoq608vM+jeyVD0Tu1E6Np0Kc2zAIFWIj963V2800iF/9LPieQw==",
+ "dev": true,
+ "license": "BSD-2-Clause"
+ },
"node_modules/side-channel": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
@@ -17271,13 +18108,13 @@
}
},
"node_modules/socks": {
- "version": "2.8.3",
- "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.3.tgz",
- "integrity": "sha512-l5x7VUUWbjVFbafGLxPWkYsHIhEvmF85tbIeFZWc8ZPtoMyybuEhL7Jye/ooC4/d48FgOjSJXgsF/AJPYCW8Zw==",
+ "version": "2.8.7",
+ "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.7.tgz",
+ "integrity": "sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A==",
"dev": true,
"license": "MIT",
"dependencies": {
- "ip-address": "^9.0.5",
+ "ip-address": "^10.0.1",
"smart-buffer": "^4.2.0"
},
"engines": {
@@ -17301,9 +18138,9 @@
}
},
"node_modules/socks-proxy-agent/node_modules/agent-base": {
- "version": "7.1.3",
- "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.3.tgz",
- "integrity": "sha512-jRR5wdylq8CkOe6hei19GGZnxM6rBGwFl3Bg0YItGDimvjGtAvdZk4Pu6Cl4u4Igsws4a1fd1Vq3ezrhn4KmFw==",
+ "version": "7.1.4",
+ "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
+ "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
"dev": true,
"license": "MIT",
"engines": {
@@ -17441,9 +18278,9 @@
}
},
"node_modules/spdx-license-ids": {
- "version": "3.0.21",
- "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.21.tgz",
- "integrity": "sha512-Bvg/8F5XephndSK3JffaRqdT+gyhfqIPwDHpX80tJrF8QQRYMo8sNMeaZ2Dp5+jhwKnUmIOyFFQfHRkjJm5nXg==",
+ "version": "3.0.22",
+ "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.22.tgz",
+ "integrity": "sha512-4PRT4nh1EImPbt2jASOKHX7PB7I+e4IWNLvkKFDxNhJlfjbYlleYQh285Z/3mPTHSAK/AvdMmw5BNNuYH8ShgQ==",
"dev": true,
"license": "CC0-1.0"
},
@@ -17541,18 +18378,30 @@
"node": ">= 0.8"
}
},
- "node_modules/streamx": {
- "version": "2.22.0",
- "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.22.0.tgz",
- "integrity": "sha512-sLh1evHOzBy/iWRiR6d1zRcLao4gGZr3C1kzNz4fopCOKJb6xD9ub8Mpi9Mr1R6id5o43S+d93fI48UC5uM9aw==",
+ "node_modules/stop-iteration-iterator": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz",
+ "integrity": "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==",
"dev": true,
"license": "MIT",
"dependencies": {
+ "es-errors": "^1.3.0",
+ "internal-slot": "^1.1.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/streamx": {
+ "version": "2.23.0",
+ "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.23.0.tgz",
+ "integrity": "sha512-kn+e44esVfn2Fa/O0CPFcex27fjIL6MkVae0Mm6q+E6f0hWv578YCERbv+4m02cjxvDsPKLnmxral/rR6lBMAg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "events-universal": "^1.0.0",
"fast-fifo": "^1.3.2",
"text-decoder": "^1.1.0"
- },
- "optionalDependencies": {
- "bare-events": "^2.2.0"
}
},
"node_modules/string_decoder": {
@@ -17796,6 +18645,12 @@
"node": ">=0.8.0"
}
},
+ "node_modules/stubborn-fs": {
+ "version": "1.2.5",
+ "resolved": "https://registry.npmjs.org/stubborn-fs/-/stubborn-fs-1.2.5.tgz",
+ "integrity": "sha512-H2N9c26eXjzL/S/K+i/RHHcFanE74dptvvjM8iwzwbVcWY/zjBbgRqF3K0DY4+OD+uTTASTBvDoxPDaPN02D7g==",
+ "dev": true
+ },
"node_modules/style-search": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/style-search/-/style-search-0.1.0.tgz",
@@ -17821,9 +18676,9 @@
}
},
"node_modules/stylelint": {
- "version": "16.14.1",
- "resolved": "https://registry.npmjs.org/stylelint/-/stylelint-16.14.1.tgz",
- "integrity": "sha512-oqCL7AC3786oTax35T/nuLL8p2C3k/8rHKAooezrPGRvUX0wX+qqs5kMWh5YYT4PHQgVDobHT4tw55WgpYG6Sw==",
+ "version": "16.25.0",
+ "resolved": "https://registry.npmjs.org/stylelint/-/stylelint-16.25.0.tgz",
+ "integrity": "sha512-Li0avYWV4nfv1zPbdnxLYBGq4z8DVZxbRgx4Kn6V+Uftz1rMoF1qiEI3oL4kgWqyYgCgs7gT5maHNZ82Gk03vQ==",
"dev": true,
"funding": [
{
@@ -17836,42 +18691,43 @@
}
],
"license": "MIT",
+ "peer": true,
"dependencies": {
- "@csstools/css-parser-algorithms": "^3.0.4",
- "@csstools/css-tokenizer": "^3.0.3",
- "@csstools/media-query-list-parser": "^4.0.2",
+ "@csstools/css-parser-algorithms": "^3.0.5",
+ "@csstools/css-tokenizer": "^3.0.4",
+ "@csstools/media-query-list-parser": "^4.0.3",
"@csstools/selector-specificity": "^5.0.0",
- "@dual-bundle/import-meta-resolve": "^4.1.0",
+ "@dual-bundle/import-meta-resolve": "^4.2.1",
"balanced-match": "^2.0.0",
"colord": "^2.9.3",
"cosmiconfig": "^9.0.0",
"css-functions-list": "^3.2.3",
"css-tree": "^3.1.0",
- "debug": "^4.3.7",
+ "debug": "^4.4.3",
"fast-glob": "^3.3.3",
"fastest-levenshtein": "^1.0.16",
- "file-entry-cache": "^10.0.5",
+ "file-entry-cache": "^10.1.4",
"global-modules": "^2.0.0",
"globby": "^11.1.0",
"globjoin": "^0.1.4",
"html-tags": "^3.3.1",
- "ignore": "^7.0.3",
+ "ignore": "^7.0.5",
"imurmurhash": "^0.1.4",
"is-plain-object": "^5.0.0",
- "known-css-properties": "^0.35.0",
+ "known-css-properties": "^0.37.0",
"mathml-tag-names": "^2.1.3",
"meow": "^13.2.0",
"micromatch": "^4.0.8",
"normalize-path": "^3.0.0",
"picocolors": "^1.1.1",
- "postcss": "^8.5.1",
+ "postcss": "^8.5.6",
"postcss-resolve-nested-selector": "^0.1.6",
"postcss-safe-parser": "^7.0.1",
- "postcss-selector-parser": "^7.0.0",
+ "postcss-selector-parser": "^7.1.0",
"postcss-value-parser": "^4.2.0",
"resolve-from": "^5.0.0",
"string-width": "^4.2.3",
- "supports-hyperlinks": "^3.1.0",
+ "supports-hyperlinks": "^3.2.0",
"svg-tags": "^1.0.0",
"table": "^6.9.0",
"write-file-atomic": "^5.0.1"
@@ -17931,19 +18787,19 @@
}
},
"node_modules/stylelint-scss": {
- "version": "6.11.0",
- "resolved": "https://registry.npmjs.org/stylelint-scss/-/stylelint-scss-6.11.0.tgz",
- "integrity": "sha512-AvJ6LVzz2iXHxPlPTR9WVy73FC/vmohH54VySNlCKX1NIXNAeuzy/VbIkMJLMyw/xKYqkgY4kAgB+qy5BfCaCg==",
+ "version": "6.12.1",
+ "resolved": "https://registry.npmjs.org/stylelint-scss/-/stylelint-scss-6.12.1.tgz",
+ "integrity": "sha512-UJUfBFIvXfly8WKIgmqfmkGKPilKB4L5j38JfsDd+OCg2GBdU0vGUV08Uw82tsRZzd4TbsUURVVNGeOhJVF7pA==",
"dev": true,
"license": "MIT",
"dependencies": {
"css-tree": "^3.0.1",
"is-plain-object": "^5.0.0",
- "known-css-properties": "^0.35.0",
- "mdn-data": "^2.15.0",
+ "known-css-properties": "^0.36.0",
+ "mdn-data": "^2.21.0",
"postcss-media-query-parser": "^0.2.3",
"postcss-resolve-nested-selector": "^0.1.6",
- "postcss-selector-parser": "^7.0.0",
+ "postcss-selector-parser": "^7.1.0",
"postcss-value-parser": "^4.2.0"
},
"engines": {
@@ -17953,17 +18809,24 @@
"stylelint": "^16.0.2"
}
},
+ "node_modules/stylelint-scss/node_modules/known-css-properties": {
+ "version": "0.36.0",
+ "resolved": "https://registry.npmjs.org/known-css-properties/-/known-css-properties-0.36.0.tgz",
+ "integrity": "sha512-A+9jP+IUmuQsNdsLdcg6Yt7voiMF/D4K83ew0OpJtpu+l34ef7LaohWV0Rc6KNvzw6ZDizkqfyB5JznZnzuKQA==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/stylelint-scss/node_modules/mdn-data": {
- "version": "2.15.0",
- "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.15.0.tgz",
- "integrity": "sha512-KIrS0lFPOqA4DgeO16vI5fkAsy8p++WBlbXtB5P1EQs8ubBgguAInNd1DnrCeTRfGchY0kgThgDOOIPyOLH2dQ==",
+ "version": "2.24.0",
+ "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.24.0.tgz",
+ "integrity": "sha512-i97fklrJl03tL1tdRVw0ZfLLvuDsdb6wxL+TrJ+PKkCbLrp2PCu2+OYdCKychIUm19nSM/35S6qz7pJpnXttoA==",
"dev": true,
"license": "CC0-1.0"
},
"node_modules/stylelint-scss/node_modules/postcss-selector-parser": {
- "version": "7.0.0",
- "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.0.0.tgz",
- "integrity": "sha512-9RbEr1Y7FFfptd/1eEdntyjMwLeghW1bHX9GWjXo19vx4ytPQhANltvVxDggzJl7mnWM+dX28kb6cyS/4iQjlQ==",
+ "version": "7.1.0",
+ "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.0.tgz",
+ "integrity": "sha512-8sLjZwK0R+JlxlYcTuVnyT2v+htpdrjDOKuMcOVdYjt52Lh8hWRYpxBPoKx/Zg+bcjc3wx6fmQevMmUztS/ccA==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -17975,9 +18838,9 @@
}
},
"node_modules/stylelint/node_modules/@csstools/media-query-list-parser": {
- "version": "4.0.2",
- "resolved": "https://registry.npmjs.org/@csstools/media-query-list-parser/-/media-query-list-parser-4.0.2.tgz",
- "integrity": "sha512-EUos465uvVvMJehckATTlNqGj4UJWkTmdWuDMjqvSUkjGpmOyFZBVwb4knxCm/k2GMTXY+c/5RkdndzFYWeX5A==",
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/@csstools/media-query-list-parser/-/media-query-list-parser-4.0.3.tgz",
+ "integrity": "sha512-HAYH7d3TLRHDOUQK4mZKf9k9Ph/m8Akstg66ywKR4SFAigjs3yBiUeZtFxywiTm5moZMAp/5W/ZuFnNXXYLuuQ==",
"dev": true,
"funding": [
{
@@ -17994,8 +18857,8 @@
"node": ">=18"
},
"peerDependencies": {
- "@csstools/css-parser-algorithms": "^3.0.4",
- "@csstools/css-tokenizer": "^3.0.3"
+ "@csstools/css-parser-algorithms": "^3.0.5",
+ "@csstools/css-tokenizer": "^3.0.4"
}
},
"node_modules/stylelint/node_modules/@csstools/selector-specificity": {
@@ -18063,25 +18926,25 @@
}
},
"node_modules/stylelint/node_modules/file-entry-cache": {
- "version": "10.0.6",
- "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-10.0.6.tgz",
- "integrity": "sha512-0wvv16mVo9nN0Md3k7DMjgAPKG/TY4F/gYMBVb/wMThFRJvzrpaqBFqF6km9wf8QfYTN+mNg5aeaBLfy8k35uA==",
+ "version": "10.1.4",
+ "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-10.1.4.tgz",
+ "integrity": "sha512-5XRUFc0WTtUbjfGzEwXc42tiGxQHBmtbUG1h9L2apu4SulCGN3Hqm//9D6FAolf8MYNL7f/YlJl9vy08pj5JuA==",
"dev": true,
"license": "MIT",
"dependencies": {
- "flat-cache": "^6.1.6"
+ "flat-cache": "^6.1.13"
}
},
"node_modules/stylelint/node_modules/flat-cache": {
- "version": "6.1.6",
- "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-6.1.6.tgz",
- "integrity": "sha512-F+CKgSwp0pzLx67u+Zy1aCueVWFAHWbXepvXlZ+bWVTaASbm5SyCnSJ80Fp1ePEmS57wU+Bf6cx6525qtMZ4lQ==",
+ "version": "6.1.18",
+ "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-6.1.18.tgz",
+ "integrity": "sha512-JUPnFgHMuAVmLmoH9/zoZ6RHOt5n9NlUw/sDXsTbROJ2SFoS2DS4s+swAV6UTeTbGH/CAsZIE6M8TaG/3jVxgQ==",
"dev": true,
"license": "MIT",
"dependencies": {
- "cacheable": "^1.8.8",
- "flatted": "^3.3.2",
- "hookified": "^1.7.0"
+ "cacheable": "^2.1.0",
+ "flatted": "^3.3.3",
+ "hookified": "^1.12.0"
}
},
"node_modules/stylelint/node_modules/global-modules": {
@@ -18113,9 +18976,9 @@
}
},
"node_modules/stylelint/node_modules/ignore": {
- "version": "7.0.3",
- "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.3.tgz",
- "integrity": "sha512-bAH5jbK/F3T3Jls4I0SO1hmPR0dKU0a7+SY6n1yzRtG54FLO8d6w/nxLFX2Nb7dBu6cCWXPaAME6cYqFUMmuCA==",
+ "version": "7.0.5",
+ "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz",
+ "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==",
"dev": true,
"license": "MIT",
"engines": {
@@ -18159,11 +19022,12 @@
}
},
"node_modules/stylelint/node_modules/postcss-selector-parser": {
- "version": "7.0.0",
- "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.0.0.tgz",
- "integrity": "sha512-9RbEr1Y7FFfptd/1eEdntyjMwLeghW1bHX9GWjXo19vx4ytPQhANltvVxDggzJl7mnWM+dX28kb6cyS/4iQjlQ==",
+ "version": "7.1.0",
+ "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.0.tgz",
+ "integrity": "sha512-8sLjZwK0R+JlxlYcTuVnyT2v+htpdrjDOKuMcOVdYjt52Lh8hWRYpxBPoKx/Zg+bcjc3wx6fmQevMmUztS/ccA==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"cssesc": "^3.0.0",
"util-deprecate": "^1.0.2"
@@ -18333,20 +19197,19 @@
"license": "MIT"
},
"node_modules/synckit": {
- "version": "0.9.2",
- "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.9.2.tgz",
- "integrity": "sha512-vrozgXDQwYO72vHjUb/HnFbQx1exDjoKzqx23aXEg2a9VIg2TSFZ8FmeZpTjUCFMYw7mpX4BE2SFu8wI7asYsw==",
+ "version": "0.11.11",
+ "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.11.11.tgz",
+ "integrity": "sha512-MeQTA1r0litLUf0Rp/iisCaL8761lKAZHaimlbGK4j0HysC4PLfqygQj9srcs0m2RdtDYnF8UuYyKpbjHYp7Jw==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@pkgr/core": "^0.1.0",
- "tslib": "^2.6.2"
+ "@pkgr/core": "^0.2.9"
},
"engines": {
"node": "^14.18.0 || >=16.0.0"
},
"funding": {
- "url": "https://opencollective.com/unts"
+ "url": "https://opencollective.com/synckit"
}
},
"node_modules/table": {
@@ -18401,9 +19264,9 @@
}
},
"node_modules/tar-fs": {
- "version": "3.0.8",
- "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.0.8.tgz",
- "integrity": "sha512-ZoROL70jptorGAlgAYiLoBLItEKw/fUxg9BSYK/dF/GAGYFJOJJJMvjPAKDJraCXFwadD456FCuvLWgfhMsPwg==",
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.1.1.tgz",
+ "integrity": "sha512-LZA0oaPOc2fVo82Txf3gw+AkEd38szODlptMYejQUhndHMLQ9M059uXR+AfS7DNo0NpINvSqDsvyaCrBVkptWg==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -18427,6 +19290,21 @@
"streamx": "^2.15.0"
}
},
+ "node_modules/tar-stream/node_modules/b4a": {
+ "version": "1.7.3",
+ "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.7.3.tgz",
+ "integrity": "sha512-5Q2mfq2WfGuFp3uS//0s6baOJLMoVduPYVeNmDYxu5OUA1/cBfvr2RIS7vi62LdNj/urk1hfmj867I3qt6uZ7Q==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "peerDependencies": {
+ "react-native-b4a": "*"
+ },
+ "peerDependenciesMeta": {
+ "react-native-b4a": {
+ "optional": true
+ }
+ }
+ },
"node_modules/terser": {
"version": "5.37.0",
"resolved": "https://registry.npmjs.org/terser/-/terser-5.37.0.tgz",
@@ -18556,9 +19434,9 @@
}
},
"node_modules/test-exclude/node_modules/brace-expansion": {
- "version": "1.1.11",
- "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
- "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==",
+ "version": "1.1.12",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
+ "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -18589,6 +19467,21 @@
"b4a": "^1.6.4"
}
},
+ "node_modules/text-decoder/node_modules/b4a": {
+ "version": "1.7.3",
+ "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.7.3.tgz",
+ "integrity": "sha512-5Q2mfq2WfGuFp3uS//0s6baOJLMoVduPYVeNmDYxu5OUA1/cBfvr2RIS7vi62LdNj/urk1hfmj867I3qt6uZ7Q==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "peerDependencies": {
+ "react-native-b4a": "*"
+ },
+ "peerDependenciesMeta": {
+ "react-native-b4a": {
+ "optional": true
+ }
+ }
+ },
"node_modules/text-table": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz",
@@ -18597,9 +19490,9 @@
"license": "MIT"
},
"node_modules/third-party-web": {
- "version": "0.26.2",
- "resolved": "https://registry.npmjs.org/third-party-web/-/third-party-web-0.26.2.tgz",
- "integrity": "sha512-taJ0Us0lKoYBqcbccMuDElSUPOxmBfwlHe1OkHQ3KFf+RwovvBHdXhbFk9XJVQE2vHzxbTwvwg5GFsT9hbDokQ==",
+ "version": "0.27.0",
+ "resolved": "https://registry.npmjs.org/third-party-web/-/third-party-web-0.27.0.tgz",
+ "integrity": "sha512-h0JYX+dO2Zr3abCQpS6/uFjujaOjA1DyDzGQ41+oFn9VW/ARiq9g5ln7qEP9+BTzDpOMyIfsfj4OvfgXAsMUSA==",
"dev": true,
"license": "MIT"
},
@@ -18618,20 +19511,20 @@
"license": "MIT"
},
"node_modules/tldts-core": {
- "version": "6.1.76",
- "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.76.tgz",
- "integrity": "sha512-uzhJ02RaMzgQR3yPoeE65DrcHI6LoM4saUqXOt/b5hmb3+mc4YWpdSeAQqVqRUlQ14q8ZuLRWyBR1ictK1dzzg==",
+ "version": "7.0.17",
+ "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.17.tgz",
+ "integrity": "sha512-DieYoGrP78PWKsrXr8MZwtQ7GLCUeLxihtjC1jZsW1DnvSMdKPitJSe8OSYDM2u5H6g3kWJZpePqkp43TfLh0g==",
"dev": true,
"license": "MIT"
},
"node_modules/tldts-icann": {
- "version": "6.1.76",
- "resolved": "https://registry.npmjs.org/tldts-icann/-/tldts-icann-6.1.76.tgz",
- "integrity": "sha512-RgyLN7i/wCF3dZCWi2Qwmk68lNnPjBanLDnn0aLqAAe0ZLMr7+j3BYIzNJ7+bc5XuqZ6lATnRwlH52gSQiKOXw==",
+ "version": "7.0.17",
+ "resolved": "https://registry.npmjs.org/tldts-icann/-/tldts-icann-7.0.17.tgz",
+ "integrity": "sha512-up4oFDoumyz2RscRxoYRxf+2OvIKUHjh7rUvuGWI0PZ/47k35sadoi2JyKR0AIfTw09qcfix8bUxXFQhY1QZIQ==",
"dev": true,
"license": "MIT",
"dependencies": {
- "tldts-core": "^6.1.76"
+ "tldts-core": "^7.0.17"
}
},
"node_modules/tmpl": {
@@ -18854,6 +19747,7 @@
"integrity": "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==",
"dev": true,
"license": "(MIT OR CC0-1.0)",
+ "peer": true,
"engines": {
"node": ">=10"
},
@@ -18960,16 +19854,6 @@
"dev": true,
"license": "MIT"
},
- "node_modules/typedarray-to-buffer": {
- "version": "3.1.5",
- "resolved": "https://registry.npmjs.org/typedarray-to-buffer/-/typedarray-to-buffer-3.1.5.tgz",
- "integrity": "sha512-zdu8XMNEDepKKR+XYOXAVPtWui0ly0NtohUscw+UmaHiAWT8hrV1rr//H6V+0DvJ3OQ19S979M0laLfX8rm82Q==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "is-typedarray": "^1.0.0"
- }
- },
"node_modules/typescript": {
"version": "5.7.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.7.3.tgz",
@@ -19073,19 +19957,6 @@
"node": ">=4"
}
},
- "node_modules/unique-string": {
- "version": "2.0.0",
- "resolved": "https://registry.npmjs.org/unique-string/-/unique-string-2.0.0.tgz",
- "integrity": "sha512-uNaeirEPvpZWSgzwsPGtU2zVSTrn/8L5q/IexZmH0eH6SA73CmAA5U4GwORTxQAZs95TAXLNqeLoPPNO5gZfWg==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "crypto-random-string": "^2.0.0"
- },
- "engines": {
- "node": ">=8"
- }
- },
"node_modules/universalify": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz",
@@ -19383,6 +20254,13 @@
"dev": true,
"license": "Apache-2.0"
},
+ "node_modules/webdriver-bidi-protocol": {
+ "version": "0.3.7",
+ "resolved": "https://registry.npmjs.org/webdriver-bidi-protocol/-/webdriver-bidi-protocol-0.3.7.tgz",
+ "integrity": "sha512-wIx5Gu/LLTeexxilpk8WxU2cpGAKlfbWRO5h+my6EMD1k5PYqM1qQO1MHUFf4f3KRnhBvpbZU7VkizAgeSEf7g==",
+ "dev": true,
+ "license": "Apache-2.0"
+ },
"node_modules/webidl-conversions": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz",
@@ -19399,6 +20277,7 @@
"integrity": "sha512-EksG6gFY3L1eFMROS/7Wzgrii5mBAFe4rIr3r2BTfo7bcc+DWwFZ4OJ/miOuHJO/A85HwyI4eQ0F6IKXesO7Fg==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@types/eslint-scope": "^3.7.7",
"@types/estree": "^1.0.6",
@@ -19505,6 +20384,7 @@
"integrity": "sha512-pIDJHIEI9LR0yxHXQ+Qh95k2EvXpWzZ5l+d+jIo+RdSm9MiHfzazIxwwni/p7+x4eJZuvG1AJwgC4TNQ7NRgsg==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@discoveryjs/json-ext": "^0.5.0",
"@webpack-cli/configtest": "^2.1.1",
@@ -19585,6 +20465,7 @@
"integrity": "sha512-0XavAZbNJ5sDrCbkpWL8mia0o5WPOd2YGtxrEiZkBK9FjLppIUK2TgxK6qGD2P3hUXTJNNPVibrerKcx5WkR1g==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@types/bonjour": "^3.5.9",
"@types/connect-history-api-fallback": "^1.3.5",
@@ -19690,23 +20571,6 @@
"node": ">=8.10.0"
}
},
- "node_modules/webpack-dev-server/node_modules/rimraf": {
- "version": "3.0.2",
- "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz",
- "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==",
- "deprecated": "Rimraf versions prior to v4 are no longer supported",
- "dev": true,
- "license": "ISC",
- "dependencies": {
- "glob": "^7.1.3"
- },
- "bin": {
- "rimraf": "bin.js"
- },
- "funding": {
- "url": "https://github.com/sponsors/isaacs"
- }
- },
"node_modules/webpack-merge": {
"version": "5.10.0",
"resolved": "https://registry.npmjs.org/webpack-merge/-/webpack-merge-5.10.0.tgz",
@@ -19864,6 +20728,13 @@
"node": ">=12"
}
},
+ "node_modules/when-exit": {
+ "version": "2.1.4",
+ "resolved": "https://registry.npmjs.org/when-exit/-/when-exit-2.1.4.tgz",
+ "integrity": "sha512-4rnvd3A1t16PWzrBUcSDZqcAmsUIy4minDXT/CZ8F2mVDgd65i4Aalimgz1aQkRGU0iH5eT5+6Rx2TK8o443Pg==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
@@ -19948,16 +20819,17 @@
}
},
"node_modules/which-typed-array": {
- "version": "1.1.18",
- "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.18.tgz",
- "integrity": "sha512-qEcY+KJYlWyLH9vNbsr6/5j59AXk5ni5aakf8ldzBvGde6Iz4sxZGkJyWSAueTG7QhOvNRYb1lDdFmL5Td0QKA==",
+ "version": "1.1.19",
+ "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.19.tgz",
+ "integrity": "sha512-rEvr90Bck4WZt9HHFC4DJMsjvu7x+r6bImz0/BrbWb7A2djJ8hnZMrWnHo9F8ssv0OMErasDhftrfROTyqSDrw==",
"dev": true,
"license": "MIT",
"dependencies": {
"available-typed-arrays": "^1.0.7",
"call-bind": "^1.0.8",
- "call-bound": "^1.0.3",
- "for-each": "^0.3.3",
+ "call-bound": "^1.0.4",
+ "for-each": "^0.3.5",
+ "get-proto": "^1.0.1",
"gopd": "^1.2.0",
"has-tostringtag": "^1.0.2"
},
@@ -20047,13 +20919,16 @@
}
},
"node_modules/xdg-basedir": {
- "version": "4.0.0",
- "resolved": "https://registry.npmjs.org/xdg-basedir/-/xdg-basedir-4.0.0.tgz",
- "integrity": "sha512-PSNhEJDejZYV7h50BohL09Er9VaIefr2LMAf3OEmpCkjOi34eYyQYAXUTjEQtZJTKcF0E2UKTh+osDLsgNim9Q==",
+ "version": "5.1.0",
+ "resolved": "https://registry.npmjs.org/xdg-basedir/-/xdg-basedir-5.1.0.tgz",
+ "integrity": "sha512-GCPAHLvrIH13+c0SuacwvRYj2SxJXQ4kaVTT5xgL3kPrz56XxkF21IGhjSE1+W0aw7gpBWRGXLCPnPby6lSpmQ==",
"dev": true,
"license": "MIT",
"engines": {
- "node": ">=8"
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/xml-name-validator": {
@@ -20073,6 +20948,16 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/xtend": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
+ "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.4"
+ }
+ },
"node_modules/y18n": {
"version": "5.0.8",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
diff --git a/package.json b/package.json
index aa34e59..0ccfb57 100644
--- a/package.json
+++ b/package.json
@@ -16,6 +16,6 @@
"start": "wp-scripts start"
},
"devDependencies": {
- "@wordpress/scripts": "^30.10.0"
+ "@wordpress/scripts": "^30.26.0"
}
}
diff --git a/src/faq/block.json b/src/faq/block.json
new file mode 100644
index 0000000..f88d33a
--- /dev/null
+++ b/src/faq/block.json
@@ -0,0 +1,34 @@
+{
+ "$schema": "https://schemas.wp.org/trunk/block.json",
+ "apiVersion": 3,
+ "name": "jvb/faq",
+ "title": "FAQ Block",
+ "category": "jvb",
+ "icon": "info",
+ "description": "Display FAQs organized by sections with customizable ordering",
+ "keywords": ["faq", "questions", "help"],
+ "version": "1.0.0",
+ "textdomain": "jvb",
+ "attributes": {
+ "sectionOrder": {
+ "type": "array",
+ "default": []
+ },
+ "showSectionTitles": {
+ "type": "boolean",
+ "default": true
+ },
+ "collapseByDefault": {
+ "type": "boolean",
+ "default": false
+ }
+ },
+ "supports": {
+ "align": ["wide", "full"],
+ "html": false
+ },
+ "editorScript": "file:./index.js",
+ "editorStyle": "file:./index.css",
+ "style": "file:./style-index.css",
+ "viewScript": "file:./view.js"
+}
diff --git a/src/faq/edit.js b/src/faq/edit.js
new file mode 100644
index 0000000..73e36e4
--- /dev/null
+++ b/src/faq/edit.js
@@ -0,0 +1,145 @@
+import { useBlockProps, InspectorControls } from '@wordpress/block-editor';
+import { PanelBody, ToggleControl, Notice, Button } from '@wordpress/components';
+import { __ } from '@wordpress/i18n';
+import { useState, useEffect } from '@wordpress/element';
+import './editor.scss';
+
+export default function Edit({ attributes, setAttributes }) {
+ const { sectionOrder, showSectionTitles, collapseByDefault } = attributes;
+ const [sections, setSections] = useState([]);
+
+ // Get sections from localized script
+ const allSections = window.jvbFaq?.sections || [];
+
+ // Initialize sections with proper ordering
+ useEffect(() => {
+ if (!allSections.length) return;
+
+ if (sectionOrder.length === 0) {
+ // First time - use default order
+ const orderedSections = allSections.map(section => ({
+ id: section.id,
+ name: section.name,
+ }));
+ setSections(orderedSections);
+ setAttributes({ sectionOrder: orderedSections.map(s => s.id) });
+ } else {
+ // Use saved order, add any new sections at the end
+ const orderedSections = [];
+ const existingIds = new Set(sectionOrder);
+
+ // Add sections in saved order
+ sectionOrder.forEach(id => {
+ const section = allSections.find(s => s.id === id);
+ if (section) {
+ orderedSections.push({ id: section.id, name: section.name });
+ }
+ });
+
+ // Add any new sections that weren't in the saved order
+ allSections.forEach(section => {
+ if (!existingIds.has(section.id)) {
+ orderedSections.push({ id: section.id, name: section.name });
+ }
+ });
+
+ setSections(orderedSections);
+ }
+ }, [allSections, sectionOrder]);
+
+ const moveSection = (index, direction) => {
+ const newSections = [...sections];
+ const newIndex = direction === 'up' ? index - 1 : index + 1;
+
+ if (newIndex < 0 || newIndex >= newSections.length) return;
+
+ // Swap sections
+ [newSections[index], newSections[newIndex]] = [newSections[newIndex], newSections[index]];
+
+ setSections(newSections);
+ setAttributes({ sectionOrder: newSections.map(s => s.id) });
+ };
+
+ const blockProps = useBlockProps({
+ className: 'faq-block-editor',
+ });
+
+ return (
+ <>
+ <InspectorControls>
+ <PanelBody title={__('FAQ Settings', 'jvb')} initialOpen={true}>
+ <ToggleControl
+ label={__('Show Section Titles', 'jvb')}
+ checked={showSectionTitles}
+ onChange={(value) => setAttributes({ showSectionTitles: value })}
+ help={__('Display section names as headings', 'jvb')}
+ />
+ <ToggleControl
+ label={__('Collapse by Default', 'jvb')}
+ checked={collapseByDefault}
+ onChange={(value) => setAttributes({ collapseByDefault: value })}
+ help={__('Questions start collapsed and expand on click', 'jvb')}
+ />
+ </PanelBody>
+
+ <PanelBody title={__('Section Order', 'jvb')} initialOpen={false}>
+ <p className="components-base-control__help">
+ {__('Use the arrow buttons to reorder sections', 'jvb')}
+ </p>
+ {sections.length > 0 ? (
+ <div className="faq-section-list">
+ {sections.map((section, index) => (
+ <div key={section.id} className="faq-section-item">
+ <div className="faq-section-controls">
+ <Button
+ icon="arrow-up-alt2"
+ label={__('Move up', 'jvb')}
+ disabled={index === 0}
+ onClick={() => moveSection(index, 'up')}
+ className="faq-section-button"
+ />
+ <Button
+ icon="arrow-down-alt2"
+ label={__('Move down', 'jvb')}
+ disabled={index === sections.length - 1}
+ onClick={() => moveSection(index, 'down')}
+ className="faq-section-button"
+ />
+ </div>
+ <span className="faq-section-name">{section.name}</span>
+ </div>
+ ))}
+ </div>
+ ) : (
+ <Notice status="info" isDismissible={false}>
+ {__('No sections found. Create sections in the FAQ taxonomy.', 'jvb')}
+ </Notice>
+ )}
+ </PanelBody>
+ </InspectorControls>
+
+ <div {...blockProps}>
+ <div className="faq-block-preview">
+ <h3>{__('FAQ Block', 'jvb')}</h3>
+ <p>
+ {__('This block will display FAQs organized by sections.', 'jvb')}
+ </p>
+ {sections.length > 0 ? (
+ <div className="faq-sections-preview">
+ <strong>{__('Section Order:', 'jvb')}</strong>
+ <ol>
+ {sections.map((section) => (
+ <li key={section.id}>{section.name}</li>
+ ))}
+ </ol>
+ </div>
+ ) : (
+ <Notice status="warning" isDismissible={false}>
+ {__('No sections available. Create sections in the FAQ taxonomy.', 'jvb')}
+ </Notice>
+ )}
+ </div>
+ </div>
+ </>
+ );
+}
diff --git a/src/faq/editor.scss b/src/faq/editor.scss
new file mode 100644
index 0000000..d9955a2
--- /dev/null
+++ b/src/faq/editor.scss
@@ -0,0 +1,99 @@
+/**
+ * FAQ Block - Editor Styles
+ */
+
+.faq-block-editor {
+ padding: 2rem;
+ border: 2px dashed #ccc;
+ border-radius: 8px;
+ background: #f9f9f9;
+
+ .faq-block-preview {
+ text-align: center;
+
+ h3 {
+ margin: 0 0 0.5rem;
+ font-size: 1.25rem;
+ font-weight: 600;
+ }
+
+ > p {
+ margin: 0 0 1.5rem;
+ color: #666;
+ }
+
+ .faq-sections-preview {
+ margin-top: 1.5rem;
+ text-align: left;
+ background: white;
+ padding: 1rem;
+ border-radius: 4px;
+
+ strong {
+ display: block;
+ margin-bottom: 0.5rem;
+ }
+
+ ol {
+ margin: 0;
+ padding-left: 1.5rem;
+
+ li {
+ margin: 0.25rem 0;
+ padding: 0.25rem 0;
+ }
+ }
+ }
+ }
+}
+
+// Inspector Controls
+.faq-section-list {
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
+ margin-top: 0.5rem;
+}
+
+.faq-section-item {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ padding: 0.5rem 0.75rem;
+ background: white;
+ border: 1px solid #ddd;
+ border-radius: 4px;
+ transition: background 150ms ease;
+
+ &:hover {
+ background: #f9f9f9;
+ }
+}
+
+.faq-section-controls {
+ display: flex;
+ gap: 0.25rem;
+ flex-shrink: 0;
+}
+
+.faq-section-button {
+ min-width: 30px !important;
+ padding: 4px !important;
+ height: 30px !important;
+
+ &:disabled {
+ opacity: 0.3;
+ cursor: not-allowed;
+ }
+}
+
+.faq-section-name {
+ flex: 1;
+ font-weight: 500;
+ padding-left: 0.5rem;
+}
+
+// Notice adjustments
+.components-panel__body .components-notice {
+ margin: 1rem 0;
+}
diff --git a/src/faq/index.js b/src/faq/index.js
new file mode 100644
index 0000000..2f2dd15
--- /dev/null
+++ b/src/faq/index.js
@@ -0,0 +1,11 @@
+import { registerBlockType } from '@wordpress/blocks';
+import Edit from './edit';
+import './style.scss';
+import './editor.scss';
+import metadata from './block.json';
+
+registerBlockType(metadata.name, {
+ edit: Edit,
+ // No save function - dynamic block rendered on server
+ save: () => null,
+});
diff --git a/src/faq/index.php b/src/faq/index.php
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/src/faq/index.php
diff --git a/src/faq/render.php b/src/faq/render.php
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/src/faq/render.php
diff --git a/src/faq/style.scss b/src/faq/style.scss
new file mode 100644
index 0000000..4bb9bc6
--- /dev/null
+++ b/src/faq/style.scss
@@ -0,0 +1,70 @@
+nav#faq {
+ --height: fit-content;
+ display: block;
+ background-color: var(--base-100);
+ border-radius: var(--outerRadius);
+ padding: 1.5rem;
+ ol {
+ list-style: decimal-leading-zero;
+ height: fit-content;
+ display: block;
+ counter-reset: faq;
+ li {
+ counter-increment: faq;
+ &::before {
+ content: counter(faq);
+ display: block;
+ font-family: var(--heading);
+ font-weight: var(--hBold);
+ }
+ }
+ }
+ h2 {
+ left: 0;
+ font-size: var(--large);
+ margin: .5rem 0 .5rem;
+ }
+ a {
+ padding: .5rem;
+ }
+}
+
+.faq-block {
+ padding-bottom: 3rem;
+ max-width: none;
+ width: 100%;
+ > * {
+ max-width: var(--alignWide);
+ margin: 1rem auto;
+ }
+ h2 {
+ margin: 5rem 0 1.5rem;
+ }
+ h3 {
+ margin: 0;
+ text-transform: none;
+ }
+ :target {
+ background-color: var(--base);
+ outline: none;
+
+ h2 {
+ background-color: var(--base);
+ padding: 1rem 1.5rem;
+ border-radius: var(--outerRadius);
+ }
+ }
+ details {
+ max-width: var(--maxWidth);
+ margin: 1rem auto;
+ padding: .75rem;
+ }
+ details + details {
+ margin-top: 3rem;
+ }
+ details .button {
+ height: fit-content;
+ display: block;
+ margin-left: auto;
+ }
+}
diff --git a/src/faq/view.js b/src/faq/view.js
new file mode 100644
index 0000000..1de987d
--- /dev/null
+++ b/src/faq/view.js
@@ -0,0 +1,84 @@
+/**
+ * FAQ Block - Frontend Interactions
+ * Handles accordion functionality for FAQ items
+ */
+
+document.addEventListener('DOMContentLoaded', () => {
+ const faqBlocks = document.querySelectorAll('.faq-block');
+
+ faqBlocks.forEach((block) => {
+ const faqItems = block.querySelectorAll('.faq-item');
+
+ faqItems.forEach((item) => {
+ const button = item.querySelector('.faq-item__question');
+ const answer = item.querySelector('.faq-item__answer');
+
+ if (!button || !answer) return;
+
+ button.addEventListener('click', () => {
+ const isExpanded = button.getAttribute('aria-expanded') === 'true';
+
+ // Toggle this item
+ button.setAttribute('aria-expanded', !isExpanded);
+
+ if (isExpanded) {
+ // Collapse
+ answer.style.height = answer.scrollHeight + 'px';
+ // Force reflow
+ answer.offsetHeight;
+ answer.style.height = '0';
+
+ setTimeout(() => {
+ answer.style.display = 'none';
+ answer.style.height = '';
+ }, 300);
+
+ item.classList.remove('faq-item--expanded');
+ } else {
+ // Expand
+ answer.style.display = 'block';
+ answer.style.height = '0';
+ // Force reflow
+ answer.offsetHeight;
+ answer.style.height = answer.scrollHeight + 'px';
+
+ setTimeout(() => {
+ answer.style.height = 'auto';
+ }, 300);
+
+ item.classList.add('faq-item--expanded');
+ }
+ });
+
+ // Handle keyboard navigation
+ button.addEventListener('keydown', (e) => {
+ // Space or Enter triggers the button
+ if (e.key === ' ' || e.key === 'Enter') {
+ e.preventDefault();
+ button.click();
+ }
+ });
+ });
+ });
+
+ // Optional: Add URL hash navigation
+ // If URL has #faq-123, open that specific FAQ
+ if (window.location.hash) {
+ const hash = window.location.hash.substring(1);
+ const targetItem = document.querySelector(`[data-faq-id="${hash}"]`);
+
+ if (targetItem) {
+ const button = targetItem.querySelector('.faq-item__question');
+ const isExpanded = button.getAttribute('aria-expanded') === 'true';
+
+ if (!isExpanded) {
+ button.click();
+ }
+
+ // Scroll to item after a short delay
+ setTimeout(() => {
+ targetItem.scrollIntoView({ behavior: 'smooth', block: 'center' });
+ }, 350);
+ }
+ }
+});
diff --git a/src/glossary/block.json b/src/glossary/block.json
new file mode 100644
index 0000000..2705831
--- /dev/null
+++ b/src/glossary/block.json
@@ -0,0 +1,24 @@
+{
+ "$schema": "https://schemas.wp.org/trunk/block.json",
+ "apiVersion": 3,
+ "name": "jvb/glossary",
+ "version": "0.1.0",
+ "title": "Glossary of Terms",
+ "category": "jvb",
+ "icon": "excerpt-view",
+ "description": "Outputs the terms",
+ "example": {},
+ "supports": {
+ "html": false,
+ "align": ["wide", "full"]
+ },
+ "textdomain": "jvb",
+ "selectors": {
+ "root": ".glossary"
+ },
+ "editorScript": "file:./index.js",
+ "editorStyle": "file:./index.css",
+ "style": "file:./style-index.css",
+ "render": "file:./render.php",
+ "viewScript": "file:./view.js"
+}
diff --git a/src/glossary/edit.js b/src/glossary/edit.js
new file mode 100644
index 0000000..4a115af
--- /dev/null
+++ b/src/glossary/edit.js
@@ -0,0 +1,38 @@
+/**
+ * Retrieves the translation of text.
+ *
+ * @see https://developer.wordpress.org/block-editor/reference-guides/packages/packages-i18n/
+ */
+import { __ } from '@wordpress/i18n';
+
+/**
+ * React hook that is used to mark the block wrapper element.
+ * It provides all the necessary props like the class name.
+ *
+ * @see https://developer.wordpress.org/block-editor/reference-guides/packages/packages-block-editor/#useblockprops
+ */
+import { useBlockProps } from '@wordpress/block-editor';
+
+/**
+ * Lets webpack process CSS, SASS or SCSS files referenced in JavaScript files.
+ * Those files can contain any CSS code that gets applied to the editor.
+ *
+ * @see https://www.npmjs.com/package/@wordpress/scripts#using-css
+ */
+import './editor.scss';
+
+/**
+ * The edit function describes the structure of your block in the context of the
+ * editor. This represents what the editor will render when the block is used.
+ *
+ * @see https://developer.wordpress.org/block-editor/reference-guides/block-api/block-edit-save/#edit
+ *
+ * @return {Element} Element to render.
+ */
+export default function Edit() {
+ return (
+ <p { ...useBlockProps() }>
+ { __( 'Will output the glossary', 'jvb' ) }
+ </p>
+ );
+}
diff --git a/src/glossary/editor.scss b/src/glossary/editor.scss
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/src/glossary/editor.scss
diff --git a/src/glossary/index.js b/src/glossary/index.js
new file mode 100644
index 0000000..d82621b
--- /dev/null
+++ b/src/glossary/index.js
@@ -0,0 +1,33 @@
+/**
+ * Registers a new block provided a unique name and an object defining its behavior.
+ *
+ * @see https://developer.wordpress.org/block-editor/reference-guides/block-api/block-registration/
+ */
+import { registerBlockType } from '@wordpress/blocks';
+
+/**
+ * Lets webpack process CSS, SASS or SCSS files referenced in JavaScript files.
+ * All files containing `style` keyword are bundled together. The code used
+ * gets applied both to the front of your site and to the editor.
+ *
+ * @see https://www.npmjs.com/package/@wordpress/scripts#using-css
+ */
+import './style.scss';
+
+/**
+ * Internal dependencies
+ */
+import Edit from './edit';
+import metadata from './block.json';
+
+/**
+ * Every block starts by registering a new block type definition.
+ *
+ * @see https://developer.wordpress.org/block-editor/reference-guides/block-api/block-registration/
+ */
+registerBlockType( metadata.name, {
+ /**
+ * @see ./edit.js
+ */
+ edit: Edit,
+} );
diff --git a/src/glossary/index.php b/src/glossary/index.php
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/src/glossary/index.php
diff --git a/src/glossary/render.php b/src/glossary/render.php
new file mode 100644
index 0000000..52aded4
--- /dev/null
+++ b/src/glossary/render.php
@@ -0,0 +1,8 @@
+<?php
+/**
+ * @see https://github.com/WordPress/gutenberg/blob/trunk/docs/reference-guides/block-api/block-metadata.md#render
+ */
+?>
+<p <?php echo get_block_wrapper_attributes(); ?>>
+ <?php esc_html_e( 'Menu – hello from a dynamic block!', 'menu' ); ?>
+</p>
diff --git a/src/glossary/style.scss b/src/glossary/style.scss
new file mode 100644
index 0000000..0b9e16c
--- /dev/null
+++ b/src/glossary/style.scss
@@ -0,0 +1,104 @@
+:root {
+ --navWidth: 40vw;
+ @media (min-width: 768px) {
+ --navWidth: 22vw;
+ }
+}
+nav.glossary-index {
+ position: fixed;
+ top: 50%;
+ transform: translateY(-50%);
+ width: var(--navWidth);
+ right: 0;
+ height: 60vh;
+ z-index: var(--z-3);
+
+ > ul {
+ --dir: column;
+ --align: flex-start;
+ touch-action: pan-y;
+ height: 100%;
+ width: 100%;
+ overflow: hidden auto;
+ scroll-behavior: smooth;
+ }
+ li, a {
+ width: 100%;
+ }
+ a {
+ --justify: center;
+ background-color: var(--overlay-heavy);
+ word-wrap: anywhere;
+ white-space: wrap;
+ transition: background-color 0.2s ease;
+ }
+ a:hover,
+ a:focus,
+ a.active {
+ background-color: rgba(var(--action-rgb), var(--rgb-heavy));
+ color: var(--action-contrast);
+ }
+}
+.glossary dd {
+ margin-left: .5rem;
+ width: calc(100% + .75rem);
+}
+.glossary dd,
+.glossary dt {
+ position: relative;
+ left: 0;
+ transition: margin var(--transition-base),
+ left var(--transition-base),
+ color var(--transition-base),
+ width var(--transition-base);
+}
+.glossary dt:target,
+.glossary dt.active {
+ outline: none;
+ left: -1.5rem;
+ padding: 0;
+ color: var(--action-0);
+}
+ .glossary dt:target + dd,
+ .glossary dt.active + dd {
+ left: -1.5rem;
+ }
+
+main header,
+dl.glossary {
+ margin-right:0;
+ margin-left:0;
+ padding: 0 var(--navWidth) 0 2rem;
+ max-width: 100vw;
+ @media (min-width:768px) {
+ margin-left: auto;
+ max-width: var(--maxWidth);
+ margin-right: var(--navWidth);
+ padding-right: var(--height);
+ }
+}
+
+@media (max-width: 768px) {
+ .glossary {
+ h2 {
+ font-size: var(--medium);
+ }
+ p {
+ font-size: var(--small);
+ }
+ }
+ .glossary-index {
+ li,a {
+ height: fit-content;
+ }
+ a {
+ font-size: var(--small);
+ padding: .25rem;
+ min-height: 2em;
+ }
+ }
+
+ body:has(.glossary) h1 {
+ font-size: var(--xxlarge);
+ }
+}
diff --git a/src/glossary/view.js b/src/glossary/view.js
new file mode 100644
index 0000000..0d426b1
--- /dev/null
+++ b/src/glossary/view.js
@@ -0,0 +1,196 @@
+/**
+ * Glossary Navigation Active State Manager
+ * Handles highlighting active terms as they scroll into view
+ * and syncing navigation with scroll position
+ */
+class GlossaryNavigator {
+ constructor(glossarySelector = 'dl.glossary', navSelector = 'nav.glossary-index') {
+ this.glossary = document.querySelector(glossarySelector);
+ this.nav = document.querySelector(navSelector);
+
+ if (!this.glossary || !this.nav) return;
+
+ this.terms = this.glossary.querySelectorAll('dt[id]');
+ this.navList = this.nav.querySelector('ul');
+ this.activeClass = 'active';
+ this.currentActive = null;
+ this.breakpoint = 768; // Adjust this to match your small screen breakpoint
+
+ this.init();
+ this.setupResizeHandler();
+ }
+
+ init() {
+ // Set up Intersection Observer with screen-size appropriate margins
+ const observerOptions = {
+ root: null, // viewport
+ rootMargin: this.getRootMargin(),
+ threshold: 0
+ };
+
+ this.observer = new IntersectionObserver(
+ (entries) => this.handleIntersection(entries),
+ observerOptions
+ );
+
+ // Observe all terms
+ this.terms.forEach(term => this.observer.observe(term));
+
+ // Also handle manual scroll for edge cases
+ this.handleScroll = this.debounce(() => this.checkActiveTerm(), 100);
+ window.addEventListener('scroll', this.handleScroll, { passive: true });
+ }
+
+ getRootMargin() {
+ if (window.innerWidth < this.breakpoint) {
+ // On small screens: 5rem from top and bottom
+ // Convert rem to pixels
+ const remInPixels = parseFloat(getComputedStyle(document.documentElement).fontSize);
+ const margin = Math.round(remInPixels * 5); // 5rem in pixels
+ return `-${margin}px 0px -${margin}px 0px`;
+ }
+ // On larger screens: centered (50% from top and bottom)
+ return '-50% 0px -50% 0px';
+ }
+
+ setupResizeHandler() {
+ let resizeTimer;
+ window.addEventListener('resize', () => {
+ clearTimeout(resizeTimer);
+ resizeTimer = setTimeout(() => {
+ // Reinitialize observer with new margins on resize
+ this.reinitialize();
+ }, 250);
+ });
+ }
+
+ reinitialize() {
+ // Disconnect old observer
+ if (this.observer) {
+ this.observer.disconnect();
+ }
+
+ // Create new observer with updated margins
+ this.init();
+ }
+
+ handleIntersection(entries) {
+ // Find the entry that's intersecting
+ const intersecting = entries.find(entry => entry.isIntersecting);
+
+ if (intersecting) {
+ this.setActive(intersecting.target);
+ }
+ }
+
+ checkActiveTerm() {
+ // Fallback method to find which term is in the trigger zone
+ const remInPixels = parseFloat(getComputedStyle(document.documentElement).fontSize);
+ const margin = remInPixels * 4;
+
+ let closestTerm = null;
+ let closestDistance = Infinity;
+
+ this.terms.forEach(term => {
+ const rect = term.getBoundingClientRect();
+
+ // Check if term is within the trigger zone (4rem from top or bottom on small screens)
+ const isInZone = window.innerWidth < this.breakpoint
+ ? rect.top >= margin && rect.top <= window.innerHeight - margin
+ : rect.top + rect.height / 2 >= 0 && rect.top + rect.height / 2 <= window.innerHeight;
+
+ if (isInZone) {
+ // Find closest to the trigger point
+ const triggerPoint = window.innerWidth < this.breakpoint
+ ? margin // Top edge of zone on small screens
+ : window.innerHeight / 2; // Center on large screens
+
+ const distance = Math.abs(rect.top - triggerPoint);
+
+ if (distance < closestDistance) {
+ closestDistance = distance;
+ closestTerm = term;
+ }
+ }
+ });
+
+ if (closestTerm) {
+ this.setActive(closestTerm);
+ }
+ }
+
+ setActive(term) {
+ if (this.currentActive === term) return;
+
+ // Remove active class from previous term
+ if (this.currentActive) {
+ this.currentActive.classList.remove(this.activeClass);
+ }
+
+ // Add active class to current term
+ term.classList.add(this.activeClass);
+ this.currentActive = term;
+
+ // Update navigation
+ this.updateNavigation(term.id);
+ }
+
+ updateNavigation(termId) {
+ // Remove active from all nav links
+ const navLinks = this.nav.querySelectorAll('a');
+ navLinks.forEach(link => link.classList.remove(this.activeClass));
+
+ // Find and activate corresponding nav link
+ const activeLink = this.nav.querySelector(`a[href="#${termId}"]`);
+
+ if (activeLink) {
+ activeLink.classList.add(this.activeClass);
+
+ // Scroll the nav list to center the active link
+ this.centerNavItem(activeLink);
+ }
+ }
+
+ centerNavItem(link) {
+ const listRect = this.navList.getBoundingClientRect();
+ const linkRect = link.getBoundingClientRect();
+
+ // Calculate position to center the link in the nav container
+ const scrollTop = this.navList.scrollTop;
+ const linkOffset = linkRect.top - listRect.top;
+ const centerOffset = (listRect.height / 2) - (linkRect.height / 2);
+
+ this.navList.scrollTo({
+ top: scrollTop + linkOffset - centerOffset,
+ behavior: 'smooth'
+ });
+ }
+
+ debounce(func, wait) {
+ let timeout;
+ return function executedFunction(...args) {
+ const later = () => {
+ clearTimeout(timeout);
+ func(...args);
+ };
+ clearTimeout(timeout);
+ timeout = setTimeout(later, wait);
+ };
+ }
+
+ destroy() {
+ if (this.observer) {
+ this.observer.disconnect();
+ }
+ window.removeEventListener('scroll', this.handleScroll);
+ }
+}
+
+// Initialize when DOM is ready
+if (document.readyState === 'loading') {
+ document.addEventListener('DOMContentLoaded', () => {
+ new GlossaryNavigator();
+ });
+} else {
+ new GlossaryNavigator();
+}
diff --git a/src/gmbreviews/block.json b/src/gmbreviews/block.json
new file mode 100644
index 0000000..78634fd
--- /dev/null
+++ b/src/gmbreviews/block.json
@@ -0,0 +1,68 @@
+{
+ "$schema": "https://schemas.wp.org/trunk/block.json",
+ "apiVersion": 3,
+ "name": "jvb/gmbreviews",
+ "title": "GMB Reviews",
+ "category": "jvb",
+ "description": "Display top-rated Google My Business reviews with statistics and action buttons",
+ "keywords": ["reviews", "google", "testimonials", "gmb", "ratings"],
+ "textdomain": "jvb",
+ "attributes": {
+ "inheritUser": {
+ "type": "boolean",
+ "default": false
+ },
+ "count": {
+ "type": "number",
+ "default": 5
+ },
+ "showRating": {
+ "type": "boolean",
+ "default": true
+ },
+ "showDate": {
+ "type": "boolean",
+ "default": true
+ },
+ "showReviewLink": {
+ "type": "boolean",
+ "default": true
+ },
+ "showViewAllLink": {
+ "type": "boolean",
+ "default": true
+ },
+ "showStats": {
+ "type": "boolean",
+ "default": true
+ },
+ "minStars": {
+ "type": "number",
+ "default": 4,
+ "minimum": 1,
+ "maximum": 5
+ }
+ },
+ "supports": {
+ "html": false,
+ "align": true,
+ "color": {
+ "text": true,
+ "background": true,
+ "link": true
+ },
+ "spacing": {
+ "margin": true,
+ "padding": true
+ },
+ "typography": {
+ "fontSize": true,
+ "lineHeight": true
+ }
+ },
+ "render": "file:./render.php",
+ "editorScript": "file:./index.js",
+ "editorStyle": "file:./index.css",
+ "style": "file:./style-index.css",
+ "viewScript": "file:./view.js"
+}
diff --git a/src/gmbreviews/edit.js b/src/gmbreviews/edit.js
new file mode 100644
index 0000000..7f7e09f
--- /dev/null
+++ b/src/gmbreviews/edit.js
@@ -0,0 +1,69 @@
+// src/gmbreviews/edit.js
+import { useBlockProps, InspectorControls } from '@wordpress/block-editor';
+import { PanelBody, RangeControl, ToggleControl } from '@wordpress/components';
+import { __ } from '@wordpress/i18n';
+import ServerSideRender from '@wordpress/server-side-render';
+
+export default function Edit({ attributes, setAttributes }) {
+ const blockProps = useBlockProps();
+ const { count, inheritUser, showStats, minStars, showViewAllLink, showRating, showDate, showReviewLink } = attributes;
+
+ return (
+ <>
+ <InspectorControls>
+ <PanelBody title={__('Review Settings', 'jvb')}>
+ <ToggleControl
+ label={__('Inherit User', 'jvb')}
+ checked={inheritUser}
+ onChange={(value) => setAttributes({ inheritUser: value })}
+ />
+ <RangeControl
+ label={__('Number of Reviews', 'jvb')}
+ value={count}
+ onChange={(value) => setAttributes({ count: value })}
+ min={1}
+ max={20}
+ />
+ <ToggleControl
+ label={__('Show Rating', 'jvb')}
+ checked={showRating}
+ onChange={(value) => setAttributes({ showRating: value })}
+ />
+ <ToggleControl
+ label={__('Show Date', 'jvb')}
+ checked={showDate}
+ onChange={(value) => setAttributes({ showDate: value })}
+ />
+ <ToggleControl
+ label={__('Show Review Link', 'jvb')}
+ checked={showReviewLink}
+ onChange={(value) => setAttributes({ showReviewLink: value })}
+ />
+ <ToggleControl
+ label={__('Show Stats', 'jvb')}
+ checked={showStats}
+ onChange={(value) => setAttributes({ showStats: value })}
+ />
+ <ToggleControl
+ label={__('Show All Reviews Link', 'jvb')}
+ checked={showViewAllLink}
+ onChange={(value) => setAttributes({ showViewAllLink: value })}
+ />
+ <RangeControl
+ label={__('Minimum Rating', 'jvb')}
+ value={minStars}
+ onChange={(value) => setAttributes({ minStars: value })}
+ min={1}
+ max={5}
+ />
+ </PanelBody>
+ </InspectorControls>
+ <div {...blockProps}>
+ <ServerSideRender
+ block="jvb/gmbreviews"
+ attributes={attributes}
+ />
+ </div>
+ </>
+ );
+}
diff --git a/src/gmbreviews/editor.scss b/src/gmbreviews/editor.scss
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/src/gmbreviews/editor.scss
diff --git a/src/gmbreviews/index.js b/src/gmbreviews/index.js
new file mode 100644
index 0000000..2f2dd15
--- /dev/null
+++ b/src/gmbreviews/index.js
@@ -0,0 +1,11 @@
+import { registerBlockType } from '@wordpress/blocks';
+import Edit from './edit';
+import './style.scss';
+import './editor.scss';
+import metadata from './block.json';
+
+registerBlockType(metadata.name, {
+ edit: Edit,
+ // No save function - dynamic block rendered on server
+ save: () => null,
+});
diff --git a/src/gmbreviews/index.php b/src/gmbreviews/index.php
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/src/gmbreviews/index.php
diff --git a/src/gmbreviews/render.php b/src/gmbreviews/render.php
new file mode 100644
index 0000000..47ad2b5
--- /dev/null
+++ b/src/gmbreviews/render.php
@@ -0,0 +1,203 @@
+<?php
+/**
+ * GMB Reviews Block - Render Template
+ *
+ * Displays recent Google My Business reviews with a link to leave a review
+ */
+function jvbRenderGMBReviewsBlock(array $attributes): string
+{
+ $count = $attributes['count'] ?? 5;
+ $showRating = $attributes['showRating'] ?? true;
+ $showDate = $attributes['showDate'] ?? true;
+ $showReviewLink = $attributes['showReviewLink'] ?? true;
+ $showViewAllLink = $attributes['showViewAllLink'] ?? true;
+ $showStats = $attributes['showStats'] ?? true;
+ $minStars = $attributes['minStars'] ?? 4; // Only show 4+ star reviews
+ $inheritUser = $attributes['inheritUser']??null;
+ if ($inheritUser) {
+ global $post;
+ $inheritUser = $post->post_author;
+ }
+ try {
+ $gmb = JVB()->connect('gmb', $inheritUser);
+ if (!$gmb->isSetUp()) {
+ error_log('GMB Not set up for: '.(int)$inheritUser);
+ return '';
+ }
+ $gotReviews = $gmb->getReviews();
+ // Get all data
+ $allReviews = $gotReviews['reviews']??[];
+ $reviewUrl = $gmb->getReviewUrl();
+ $viewAllUrl = $gmb->getReviewsViewUrl();
+
+ $average = $gotReviews['averageRating']??null;
+ $total = $gotReviews['totalReviewCount']??null;
+
+ // Filter reviews by minimum stars
+ $reviews = [];
+ if (!empty($allReviews)) {
+ foreach ($allReviews as $review) {
+ $rating = $review['starRating'] ?? 0;
+ if ($rating >= $minStars) {
+ $reviews[] = $review;
+ if (count($reviews) >= $count) {
+ break; // Got enough reviews
+ }
+ }
+ }
+ }
+
+ if (empty($reviews) && empty($reviewUrl) && empty($stats)) {
+ error_log('No reviews to display...');
+ return '';
+ }
+
+ ob_start();
+ ?>
+ <div class="gmb-reviews">
+ <div class="row btw">
+ <?php
+ if ($showStats && !empty($average) && !empty($total)) {
+ ?>
+ <p>
+ <span class="stars" aria-label="<?= $average ?> out of 5 stars">
+ <?php
+ $fullStars = floor($average);
+ $hasHalfStar = ($average - $fullStars) >= 0.5;
+
+ for ($i = 1; $i <= 5; $i++) {
+ if ($i <= $fullStars) {
+ echo jvbIcon('star', ['style' => 'fill']);
+ } elseif ($i == $fullStars + 1 && $hasHalfStar) {
+ echo jvbIcon('star-half', ['style'=> 'fill']);
+ } else {
+ echo jvbIcon('star', ['style' => 'light']);
+ }
+ }
+ ?>
+ </span>
+ <i>Average</i>
+ </p>
+ <?php
+ if ($total > 0) {
+ ?>
+ <p><i>{ <?= number_format($total ) . ' ' . _n('Review', 'Reviews', $total, 'jvb')?> Total }</i></p>
+ <?php
+ }
+ ?>
+ <?php
+ }
+ ?>
+
+ <?php
+ if ($showReviewLink && !empty($reviewUrl)) {
+ ?>
+ <a href="<?=esc_url($reviewUrl)?>"
+ class="button"
+ target="_blank"
+ rel="noopener noreferrer">
+ <?= jvbIcon('star', ['style' => 'fill']) ?>
+ Leave Your Review
+ </a>
+ <?php
+ }
+ ?>
+ </div>
+
+ <ul>
+ <?php
+ foreach ($reviews as $review) {
+ $reviewer = $review['reviewer']['displayName'] ?? 'Anonymous';
+ $profilePhoto = $review['reviewer']['profilePhotoUrl'] ?? '';
+ $rating = $review['starRating'] ?? 0;
+ $rating = match($rating) {
+ 'FIVE' => 5,
+ 'FOUR' => 4,
+ 'THREE' => 3,
+ 'TWO' => 2,
+ 'ONE' => 1,
+ default => $rating
+ };
+ $comment = $review['comment'] ?? '';
+ $date = $review['updateTime'] ?? '';
+ ?>
+ <li>
+ <article class="review">
+ <header class="row btw">
+ <?php if (!empty($profilePhoto)) { ?>
+ <img src="<?=esc_url($profilePhoto)?>"
+ alt="<?=esc_attr($reviewer)?>"
+ 'loading="lazy">
+ <?php } else { ?>
+ <div class="avatar">
+ <?= jvbIcon('user-circle')?>
+ </div>
+ <?php } ?>
+
+ <div class="col end">
+ <h4><?= esc_html($reviewer)?></h4>
+ <?php
+ // Date
+ if ($showDate && !empty($date)) {
+ $formatted_date = human_time_diff(strtotime($date), current_time('timestamp')) . ' ago';
+ ?>
+ <time datetime="<?=esc_attr($date)?>">
+ <?= esc_html($formatted_date) ?>
+ </time>
+ <?php } ?>
+ <?php if ($showRating && $rating > 0) { ?>
+ <div class="stars" aria-label="<?= $rating ?> out of 5 stars">
+ <?php
+ for ($i = 1; $i <= 5; $i++) {
+ echo ($i <= $rating) ? jvbIcon('star', ['style' => 'fill']) : jvbIcon('star', ['style' => 'light']);
+ } ?>
+ </div>
+ <?php } ?>
+ </div>
+
+ </header>
+ <?php
+ // Review text
+ if (!empty($comment)) { ?>
+ <div class="review">
+ <?= apply_filters('the_content', $comment) ?>
+ </div>
+ <?php } ?>
+ </article>
+ </li>
+ <?php
+ }
+ ?>
+ </ul>
+ <?php
+ // Footer with "See All Reviews" button
+ if ($showViewAllLink && !empty($viewAllUrl)) {
+ ?>
+ <div class="footer">
+ <a href=" <?= esc_url($viewAllUrl) ?>"
+ class="button"
+ target="_blank"
+ rel="noopener noreferrer">
+
+ <?php
+ if ($showStats ) {
+ echo 'See All ' . number_format($total) . ' Reviews';
+ } else {
+ echo ' See All Reviews';
+ }
+ ?>
+ <?= jvbIcon('arrow-square-out') ?>
+ </a>
+ </div>
+ <?php
+ }
+ ?>
+ </div>
+ <?php
+ return ob_get_clean();
+
+ } catch (\Exception $e) {
+ error_log('[GMB Reviews Block] Error: ' . $e->getMessage());
+ return '';
+ }
+}
diff --git a/src/gmbreviews/style.scss b/src/gmbreviews/style.scss
new file mode 100644
index 0000000..a2edaf3
--- /dev/null
+++ b/src/gmbreviews/style.scss
@@ -0,0 +1,58 @@
+.gmb-reviews {
+ > .row.btw {
+ .button {
+ width: 100%;
+ height: max-content;
+ }
+ p {
+ width: fit-content;
+ }
+ }
+ .stars {
+ display: inline-block;
+ vertical-align: middle;
+ }
+ ul {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+ li {
+ margin: 2rem 0;
+ position:relative;
+ &:nth-of-type(odd) {
+ left: -2rem;
+ }
+ &:nth-of-type(even) {
+ right: -2rem;
+ }
+ }
+ }
+ article {
+ padding: 1rem;
+ border-radius: var(--outerRadius);
+ background-color: var(--base);
+ header {
+ --align: center;
+ >img {
+ position: relative;
+ left: 0;
+ }
+ }
+ time {
+ font-style: italic;
+ }
+ .review {
+ padding: 1.5rem;
+ }
+
+ h4 {
+ width: max-content;
+ }
+ .icon {
+ color: var(--action-0);
+ }
+ }
+ .footer .button {
+ width: 100%;
+ }
+}
diff --git a/src/gmbreviews/view.js b/src/gmbreviews/view.js
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/src/gmbreviews/view.js
diff --git a/src/summary/render.php b/src/summary/render.php
index 92863f5..9253d73 100644
--- a/src/summary/render.php
+++ b/src/summary/render.php
@@ -1,4 +1,7 @@
<?php
+
+use JVBase\managers\CacheManager;
+
if (!defined('ABSPATH')) {
exit; // Exit if accessed directly
}
@@ -28,7 +31,7 @@
function jvbRenderArtistSummary():string
{
$current = get_queried_object();
- $cache = new JVBase\managers\CacheManager('artists', WEEK_IN_SECONDS);
+ $cache = CacheManager::for('artists', WEEK_IN_SECONDS);
$key = 'artist-bio-'.$current->ID;
$cached = $cache->get($key);
$cached = false;
@@ -155,7 +158,7 @@
{
$current = get_queried_object();
- $cache = new JVBase\managers\CacheManager('shops', WEEK_IN_SECONDS);
+ $cache = CacheManager::for('shops', WEEK_IN_SECONDS);
$key = 'shop-bio-'.$current->term_id;
$cached = $cache->get($key);
$cached = false;
@@ -286,8 +289,8 @@
function jvbRenderTermSummary()
{
$current = get_queried_object();
- $cache = new JVBase\managers\CacheManager($current->taxonomy, WEEK_IN_SECONDS);
- $key = $current->taxonomy.'-'.$current->ID;
+ $cache = CacheManager::for(jvbNoBase($current->taxonomy), WEEK_IN_SECONDS);
+ $key = $current->ID;
$cached = $cache->get($key);
$cached = false;
if ($cached !== false) {
diff --git a/src/video/edit.js b/src/video/edit.js
index 44b6d35..df46c62 100644
--- a/src/video/edit.js
+++ b/src/video/edit.js
@@ -179,6 +179,7 @@
{renderVideoSourceList(videoSources, false)}
<MediaUploadCheck>
<MediaUpload
+ multiple={ true }
onSelect={(media) => onSelectVideo(media, false)}
allowedTypes={ALLOWED_VIDEO_TYPES}
render={({ open }) => (
@@ -200,6 +201,7 @@
{renderVideoSourceList(mobileSources, true)}
<MediaUploadCheck>
<MediaUpload
+ multiple={ true }
onSelect={(media) => onSelectVideo(media, true)}
allowedTypes={ALLOWED_VIDEO_TYPES}
render={({ open }) => (
diff --git a/src/video/index.js b/src/video/index.js
index c0cc9f0..996d2cf 100644
--- a/src/video/index.js
+++ b/src/video/index.js
@@ -1,9 +1,20 @@
import { registerBlockType } from '@wordpress/blocks';
+import { useBlockProps, InnerBlocks } from '@wordpress/block-editor';
import './style.scss';
import Edit from './edit';
import metadata from './block.json';
registerBlockType(metadata.name, {
edit: Edit,
- save: () => null // Server-side rendering
+ save: ({ attributes }) => {
+ const blockProps = useBlockProps.save({
+ className: 'video-cover-wrapper-placeholder'
+ });
+
+ return (
+ <div {...blockProps}>
+ <InnerBlocks.Content />
+ </div>
+ );
+ }
});
diff --git a/src/video/style.scss b/src/video/style.scss
index 223f434..43841af 100644
--- a/src/video/style.scss
+++ b/src/video/style.scss
@@ -1,65 +1,118 @@
-.video-cover-wrapper {
+
+.video-cover {
position: relative;
width: 100%;
- min-height: 400px;
+ min-height: 75vh;
overflow: hidden;
display: flex;
-
- // Video background
- .video-cover-bg {
+ .wrap {
+ background-color: var(--contrast-200);
+ //&::before {
+ // position: absolute;
+ // top: 0;
+ // bottom: 0;
+ // left: 0;
+ // right: 0;
+ // background-color: var(--base);
+ // mix-blend-mode: lighten;
+ // content: '';
+ // z-index: 1;
+ //}
+ }
+ /* Video background */
+ .video-container {
position: absolute;
- top: 50%;
- left: 50%;
+ top: 0;
+ bottom: 0;
+ left: 0;
+ right: 0;
min-width: 100%;
min-height: 100%;
- width: auto;
- height: auto;
- transform: translate(-50%, -50%);
- object-fit: cover;
z-index: 0;
+ display: flex;
+ background-color: var(--action-50);
&.fade {
animation: fadeIn 1s ease-in;
}
+
+ video {
+ pointer-events: none;
+ opacity: .85;
+ mix-blend-mode: multiply;
+ filter: grayscale(100%) contrast(1);
+ flex: 1 0 100%;
+ object-fit: cover;
+ }
}
- // Dark overlay
- .video-cover-overlay {
- position: absolute;
- top: 0;
- left: 0;
- right: 0;
- bottom: 0;
- background: rgba(0, 0, 0, 1);
- z-index: 1;
- }
-
- // Content container
- .video-cover-content {
+ .inner-wrap {
position: relative;
z-index: 2;
width: 100%;
padding: 2rem;
- color: white;
+ color: var(--action-contrast);
- // Better text readability
+ /* Better text readability */
h1, h2, h3, h4, h5, h6 {
- color: white;
+ word-spacing: 100vw;
+ color: var(--action-contrast);
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.5);
+ margin: 2rem 0 0;
}
p {
- color: white;
+ text-transform: uppercase;
+ letter-spacing: 2px;
+ margin: 0;
+ color: var(--action-contrast);
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.5);
}
- // Button styles
+ .media-text {
+ }
+ .media-text figure {
+ max-width: 50%;
+ }
+ @media (min-width: 768px) {
+ .media-text {
+ --align: flex-start;
+ gap: 3rem;
+ max-width: var(--maxWidth);
+ }
+ }
+ .media-text > div {
+ width: fit-content;
+ }
+ .buttons a {
+ font-weight: 500;
+ color: var(--action-contrast);
+ border-color: var(--action-contrast);
+ &:visited {
+ color: var(--action-0);
+ &:hover {
+ color: var(--action-contrast);
+ }
+ }
+ &:hover {
+ background-color: var(--action-0);
+ color: var(--action-contrast);
+ }
+ }
+
+ .outline a {
+ background-color: rgba(var(--base-rgb), var(--overlay-light));
+ }
+ .buttons {
+ margin: 3rem 0;
+ }
+ /* Button styles */
.wp-block-button__link {
text-shadow: none;
}
}
- // Alignment classes
+ /* Alignment classes */
&.align-top-left {
align-items: flex-start;
justify-content: flex-start;
@@ -97,7 +150,7 @@
justify-content: flex-end;
}
- // Full-width alignment
+ /* Full-width alignment */
&.alignfull {
width: 100vw;
max-width: none;
@@ -105,7 +158,7 @@
margin-right: calc(50% - 50vw);
}
- // Wide alignment
+ /* Wide alignment */
&.alignwide {
max-width: 1200px;
}
@@ -120,23 +173,3 @@
}
}
-// Responsive adjustments
-@media (max-width: 768px) {
- .video-cover-wrapper {
- min-height: 300px;
-
- .video-cover-content {
- padding: 1.5rem;
- }
- }
-}
-
-@media (max-width: 480px) {
- .video-cover-wrapper {
- min-height: 250px;
-
- .video-cover-content {
- padding: 1rem;
- }
- }
-}
diff --git a/templates/dashboard/sections/news.php b/templates/dashboard/sections/news.php
index f9c06b0..299b027 100644
--- a/templates/dashboard/sections/news.php
+++ b/templates/dashboard/sections/news.php
@@ -1,4 +1,7 @@
<?php
+
+use JVBase\managers\CacheManager;
+
if (!defined('ABSPATH')) {
exit; // Exit if accessed directly
}
@@ -6,7 +9,7 @@
wp_redirect(get_home_url(null, '/dash'));
exit;
}
-$cache = new JVBase\managers\CacheManager('news', 3600);
+$cache = CacheManager::for('news', 3600);
$check = $cache->get('type-options');
if ($check) {
diff --git a/webpack.jvb.js b/webpack.jvb.js
index 333258d..df580d7 100644
--- a/webpack.jvb.js
+++ b/webpack.jvb.js
@@ -4,49 +4,48 @@
module.exports = {
mode: 'production',
entry: {
- 'cache': './assets/js/concise/SimpleCache.js',
- 'dataStore': './assets/js/concise/DataStore.js',
+ 'a11y': './assets/js/dash/A11yHelper.js',
+ // 'admin': './assets/js/dash/Admin.js',
+ 'bioManager': './assets/js/dash/BioManager.js',
+ 'ContentManager': './assets/js/dash/ContentManager.js',
+ 'hours': './assets/js/dash/CopyHours.js',
'crud': './assets/js/dash/CRUD.js',
- 'queue': './assets/js/concise/Queue.js',
+ 'dataStore': './assets/js/concise/DataStore.js',
+ 'dragHandler': './assets/js/concise/DragHandler.js',
+ 'error': './assets/js/dash/ErrorHandler.js',
+ 'favouritesManager': './assets/js/dash/FavouritesManager.js',
'form': './assets/js/concise/FormController.js',
+ 'favourites': './assets/js/concise/FrontendFavourites.js',
+ 'votes': './assets/js/concise/FrontendVotes.js',
+ 'gallery': './assets/js/Gallery.js',
+ 'maps': './assets/js/dash/GoogleMaps.js',
+ 'handleSelection': './assets/js/concise/HandleSelection.js',
+ 'integrations': './assets/js/dash/Integrations.js',
+ 'loading': './assets/js/dash/LoadingManager.js',
+ 'media': './assets/js/concise/Media.js',
+ 'modal': './assets/js/dash/Modal.js',
+ 'navigation': './assets/js/concise/navigation.js',
+ 'news': './assets/js/dash/NewsManager.js',
+ 'notificationManager': './assets/js/dash/NotificationManager.js',
+ 'notifications': './assets/js/Notifications.js',
+ 'page-nav': './assets/js/on-this-page.js',
'populate': './assets/js/concise/PopulateForm.js',
+ 'popup': './assets/js/concise/Popup.js',
+ 'postSelector': './assets/js/dash/PostSelector.js',
'quill': './assets/js/concise/quill.js',
+ 'queue': './assets/js/concise/Queue.js',
+ 'referral': './assets/js/concise/Referral.js',
+ 'shopManager': './assets/js/dash/ShopManager.js',
+ 'cache': './assets/js/concise/SimpleCache.js',
+ 'square': './assets/js/dash/SquareCheckout.js',
+ 'tabs': './assets/js/dash/Tabs.js',
+ 'creator': './assets/js/dash/TaxonomyCreator.js',
+ 'selector': './assets/js/concise/TaxonomySelector.js',
+ 'ui': './assets/js/ui-handler.js',
+ 'uploader': './assets/js/concise/UploadManager.js',
+ 'settings': './assets/js/concise/UserSettings.js',
+ 'utility': './assets/js/dash/UtilityFunctions.js',
'view': './assets/js/concise/View.js',
- 'media': './assets/js/concise/Media.js',
- 'navigation': './assets/js/concise/navigation.js',
- 'notifications': './assets/js/Notifications.js',
- 'ui': './assets/js/ui-handler.js',
- 'page-nav': './assets/js/on-this-page.js',
- 'a11y': './assets/js/dash/A11yHelper.js',
- 'admin': './assets/js/dash/Admin.js',
- 'uploader': './assets/js/concise/UploadManager.js',
- 'bioManager': './assets/js/dash/BioManager.js',
- 'ContentManager': './assets/js/dash/ContentManager.js',
- // 'DashboardNavigator': './assets/js/dash/DashboardNavigator.js',
- 'error': './assets/js/dash/ErrorHandler.js',
- 'favouritesManager': './assets/js/dash/FavouritesManager.js',
- // 'form': './assets/js/dash/FormHandler.js',
- 'gallery': './assets/js/Gallery.js',
- 'loading': './assets/js/dash/LoadingManager.js',
- 'modal': './assets/js/dash/Modal.js',
- 'news': './assets/js/dash/NewsManager.js',
- 'notificationManager': './assets/js/dash/NotificationManager.js',
- 'postSelector': './assets/js/dash/PostSelector.js',
- 'shopManager': './assets/js/dash/ShopManager.js',
- 'tabs': './assets/js/dash/Tabs.js',
- 'selector': './assets/js/concise/TaxonomySelector.js',
- 'creator': './assets/js/dash/TaxonomyCreator.js',
- 'utility': './assets/js/dash/UtilityFunctions.js',
- 'square': './assets/js/dash/SquareCheckout.js',
- 'integrations': './assets/js/dash/Integrations.js',
- 'maps': './assets/js/dash/GoogleMaps.js',
- 'hours': './assets/js/dash/CopyHours.js',
- 'favourites': './assets/js/concise/FrontendFavourites.js',
- 'votes': './assets/js/concise/FrontendVotes.js',
- 'handleSelection': './assets/js/concise/HandleSelection.js',
- 'dragHandler': './assets/js/concise/DragHandler.js',
- 'referral': './assets/js/concise/Referral.js',
- 'popup': './assets/js/concise/Popup.js',
},
output: {
filename: '[name].min.js',
--
Gitblit v1.10.0