Jake Vanderwerf
2025-11-04 42fa8304ddb811b0f725f245130f70c0f5e86a6c
=Refactored LoginManager to be more extensible and configurable, as well as an AjaxRateLimiter
63 files added
109 files modified
1 files deleted
20339 ■■■■ changed files
JVBase.php 4 ●●●● patch | view | raw | blame | history
assets/css/dash.min.css 2 ●●● patch | view | raw | blame | history
assets/css/forms.min.css 2 ●●● patch | view | raw | blame | history
assets/css/nav.min.css 2 ●●● patch | view | raw | blame | history
assets/js/concise/DataStore.js 375 ●●●● patch | view | raw | blame | history
assets/js/concise/FormController.js 16 ●●●● patch | view | raw | blame | history
assets/js/concise/PopulateForm.js 8 ●●●●● patch | view | raw | blame | history
assets/js/concise/Queue.js 179 ●●●●● patch | view | raw | blame | history
assets/js/concise/SimpleCache.js 415 ●●●● patch | view | raw | blame | history
assets/js/concise/TaxonomySelector.js 474 ●●●●● patch | view | raw | blame | history
assets/js/concise/UploadManager.js 19 ●●●●● patch | view | raw | blame | history
assets/js/concise/UserSettings.js 234 ●●●●● patch | view | raw | blame | history
assets/js/concise/View.js 69 ●●●●● patch | view | raw | blame | history
assets/js/concise/navigation.js 12 ●●●●● patch | view | raw | blame | history
assets/js/dash/CRUD.js 23 ●●●● patch | view | raw | blame | history
assets/js/dash/Integrations.js 164 ●●●● patch | view | raw | blame | history
assets/js/dash/TaxonomyCreator.js 244 ●●●● patch | view | raw | blame | history
assets/js/dash/UtilityFunctions.js 4 ●●●● patch | view | raw | blame | history
assets/js/min/cache.min.js 2 ●●● patch | view | raw | blame | history
assets/js/min/creator.min.js 2 ●●● patch | view | raw | blame | history
assets/js/min/crud.min.js 2 ●●● patch | view | raw | blame | history
assets/js/min/dataStore.min.js 2 ●●● patch | view | raw | blame | history
assets/js/min/form.min.js 2 ●●● patch | view | raw | blame | history
assets/js/min/integrations.min.js 2 ●●● patch | view | raw | blame | history
assets/js/min/navigation.min.js 2 ●●● patch | view | raw | blame | history
assets/js/min/populate.min.js 2 ●●● patch | view | raw | blame | history
assets/js/min/queue.min.js 2 ●●● patch | view | raw | blame | history
assets/js/min/selector.min.js 2 ●●● patch | view | raw | blame | history
assets/js/min/settings.min.js 1 ●●●● patch | view | raw | blame | history
assets/js/min/square.min.js 2 ●●● patch | view | raw | blame | history
assets/js/min/uploader.min.js 2 ●●● patch | view | raw | blame | history
assets/js/min/utility.min.js 2 ●●● patch | view | raw | blame | history
assets/js/min/view.min.js 2 ●●● patch | view | raw | blame | history
build/faq/block.json 41 ●●●●● patch | view | raw | blame | history
build/faq/index-rtl.css 1 ●●●● patch | view | raw | blame | history
build/faq/index.asset.php 1 ●●●● patch | view | raw | blame | history
build/faq/index.css 1 ●●●● patch | view | raw | blame | history
build/faq/index.js 1 ●●●● patch | view | raw | blame | history
build/faq/style-index-rtl.css 1 ●●●● patch | view | raw | blame | history
build/faq/style-index.css 1 ●●●● patch | view | raw | blame | history
build/faq/view.asset.php 1 ●●●● patch | view | raw | blame | history
build/faq/view.js 1 ●●●● patch | view | raw | blame | history
build/glossary/block.json 27 ●●●●● patch | view | raw | blame | history
build/glossary/index-rtl.css 1 ●●●● patch | view | raw | blame | history
build/glossary/index.asset.php 1 ●●●● patch | view | raw | blame | history
build/glossary/index.css 1 ●●●● patch | view | raw | blame | history
build/glossary/index.js 1 ●●●● patch | view | raw | blame | history
build/glossary/render.php 8 ●●●●● patch | view | raw | blame | history
build/glossary/style-index-rtl.css 1 ●●●● patch | view | raw | blame | history
build/glossary/style-index.css 1 ●●●● patch | view | raw | blame | history
build/glossary/view.asset.php 1 ●●●● patch | view | raw | blame | history
build/glossary/view.js 1 ●●●● patch | view | raw | blame | history
build/gmbreviews/block.json 74 ●●●●● patch | view | raw | blame | history
build/gmbreviews/index-rtl.css 1 ●●●● patch | view | raw | blame | history
build/gmbreviews/index.asset.php 1 ●●●● patch | view | raw | blame | history
build/gmbreviews/index.css 1 ●●●● patch | view | raw | blame | history
build/gmbreviews/index.js 1 ●●●● patch | view | raw | blame | history
build/gmbreviews/render.php 203 ●●●●● patch | view | raw | blame | history
build/gmbreviews/style-index-rtl.css 1 ●●●● patch | view | raw | blame | history
build/gmbreviews/style-index.css 1 ●●●● patch | view | raw | blame | history
build/gmbreviews/view.asset.php 1 ●●●● patch | view | raw | blame | history
build/gmbreviews/view.js patch | view | raw | blame | history
build/summary/index.asset.php 2 ●●● patch | view | raw | blame | history
build/summary/index.js 2 ●●● patch | view | raw | blame | history
build/video/index.asset.php 2 ●●● patch | view | raw | blame | history
build/video/index.js 2 ●●● patch | view | raw | blame | history
build/video/style-index-rtl.css 2 ●●● patch | view | raw | blame | history
build/video/style-index.css 2 ●●● patch | view | raw | blame | history
globals.php 5 ●●●● patch | view | raw | blame | history
icons.php 6 ●●●● patch | view | raw | blame | history
inc/blocks/CustomBlocks.php 817 ●●●● patch | view | raw | blame | history
inc/blocks/FAQBlock.php 297 ●●●●● patch | view | raw | blame | history
inc/blocks/FeedBlock.php 2 ●●● patch | view | raw | blame | history
inc/blocks/FormBlock.php 9 ●●●●● patch | view | raw | blame | history
inc/blocks/GlossaryBlock.php 153 ●●●●● patch | view | raw | blame | history
inc/blocks/MenuBlock.php 2 ●●● patch | view | raw | blame | history
inc/blocks/RegisterBlocks.php 14 ●●●●● patch | view | raw | blame | history
inc/blocks/SummaryBlock.php 4 ●●● patch | view | raw | blame | history
inc/blocks/VideoCoverBlock.php 25 ●●●●● patch | view | raw | blame | history
inc/blocks/_setup.php 41 ●●●●● patch | view | raw | blame | history
inc/forms/PostSelector.php 2 ●●● patch | view | raw | blame | history
inc/forms/TaxonomySelector.php 195 ●●●●● patch | view | raw | blame | history
inc/forms/TaxonomySelectorOld.php 2 ●●● patch | view | raw | blame | history
inc/helpers/all.php 2 ●●● patch | view | raw | blame | history
inc/helpers/breadcrumbs.php 4 ●●● patch | view | raw | blame | history
inc/helpers/cache.php 125 ●●●●● patch | view | raw | blame | history
inc/helpers/formatting.php 44 ●●●●● patch | view | raw | blame | history
inc/helpers/legacy.php 112 ●●●● patch | view | raw | blame | history
inc/helpers/media.php 28 ●●●●● patch | view | raw | blame | history
inc/helpers/members.php 125 ●●●● patch | view | raw | blame | history
inc/helpers/renderFields.php 105 ●●●● patch | view | raw | blame | history
inc/helpers/saveFields.php 3 ●●●●● patch | view | raw | blame | history
inc/helpers/time.php 4 ●●● patch | view | raw | blame | history
inc/helpers/ui.php 5 ●●●●● patch | view | raw | blame | history
inc/importers/JaneAppClientImporter.php 386 ●●●●● patch | view | raw | blame | history
inc/importers/JaneAppSalesImporter.php 574 ●●●●● patch | view | raw | blame | history
inc/importers/_setup.php 3 ●●●●● patch | view | raw | blame | history
inc/integrations/GoogleMyBusiness.php 135 ●●●●● patch | view | raw | blame | history
inc/integrations/Integrations.php 66 ●●●● patch | view | raw | blame | history
inc/managers/AdminPages.php 3 ●●●●● patch | view | raw | blame | history
inc/managers/AjaxRateLimiter.php 325 ●●●●● patch | view | raw | blame | history
inc/managers/CRUDManager.php 157 ●●●● patch | view | raw | blame | history
inc/managers/CacheManager.php 747 ●●●●● patch | view | raw | blame | history
inc/managers/CacheManagerOld.php 376 ●●●●● patch | view | raw | blame | history
inc/managers/DashboardManager.php 1120 ●●●●● patch | view | raw | blame | history
inc/managers/DirectoryManager.php 2 ●●● patch | view | raw | blame | history
inc/managers/FormManager.php 3 ●●●● patch | view | raw | blame | history
inc/managers/LoginManager.php 2220 ●●●●● patch | view | raw | blame | history
inc/managers/LoginManagerOld.php 1061 ●●●●● patch | view | raw | blame | history
inc/managers/MagicLinkManager.php 437 ●●●●● patch | view | raw | blame | history
inc/managers/NewsRelationships.php 15 ●●●● patch | view | raw | blame | history
inc/managers/NotificationManager.php 9 ●●●●● patch | view | raw | blame | history
inc/managers/OperationQueue.php 15 ●●●● patch | view | raw | blame | history
inc/managers/ReferralManager.php 366 ●●●●● patch | view | raw | blame | history
inc/managers/RoleManager.php 54 ●●●●● patch | view | raw | blame | history
inc/managers/TaxonomyRelationships.php 10 ●●●● patch | view | raw | blame | history
inc/managers/UmamiMetrics.php 4 ●●●● patch | view | raw | blame | history
inc/managers/UploadManager.php 2 ●●● patch | view | raw | blame | history
inc/managers/UserTermsManager.php 22 ●●●●● patch | view | raw | blame | history
inc/managers/_setup.php 3 ●●●● patch | view | raw | blame | history
inc/meta/MetaForm.php 882 ●●●● patch | view | raw | blame | history
inc/meta/MetaManager.php 5 ●●●●● patch | view | raw | blame | history
inc/registry/CheckCustomTables.php 479 ●●●●● patch | view | raw | blame | history
inc/registry/PostTypeRegistrar.php 141 ●●●● patch | view | raw | blame | history
inc/registry/TaxonomyRegistrar.php 8 ●●●● patch | view | raw | blame | history
inc/rest/RestRouteManager.php 332 ●●●● patch | view | raw | blame | history
inc/rest/routes/BioRoutes.php 6 ●●●●● patch | view | raw | blame | history
inc/rest/routes/ContentRoutes.php 197 ●●●● patch | view | raw | blame | history
inc/rest/routes/FavouritesRoutes.php 52 ●●●● patch | view | raw | blame | history
inc/rest/routes/FeedRoutes.php 20 ●●●●● patch | view | raw | blame | history
inc/rest/routes/FormRoutes.php 4 ●●●● patch | view | raw | blame | history
inc/rest/routes/ImporterRoutes.php 326 ●●●●● patch | view | raw | blame | history
inc/rest/routes/Invitations.php 17 ●●●●● patch | view | raw | blame | history
inc/rest/routes/NotificationsRoutes.php 28 ●●●●● patch | view | raw | blame | history
inc/rest/routes/OptionsRoutes.php 4 ●●●● patch | view | raw | blame | history
inc/rest/routes/SettingsRoutes.php 17 ●●●●● patch | view | raw | blame | history
inc/rest/routes/TermRoutes.php 48 ●●●● patch | view | raw | blame | history
inc/rest/routes/UploadRoutes.php 113 ●●●●● patch | view | raw | blame | history
inc/utility/Features.php 281 ●●●●● patch | view | raw | blame | history
inc/utility/Validator.php 4 ●●●● patch | view | raw | blame | history
jvb.php 61 ●●●● patch | view | raw | blame | history
package-lock.json 2905 ●●●●● patch | view | raw | blame | history
package.json 2 ●●● patch | view | raw | blame | history
src/faq/block.json 34 ●●●●● patch | view | raw | blame | history
src/faq/edit.js 145 ●●●●● patch | view | raw | blame | history
src/faq/editor.scss 99 ●●●●● patch | view | raw | blame | history
src/faq/index.js 11 ●●●●● patch | view | raw | blame | history
src/faq/index.php patch | view | raw | blame | history
src/faq/render.php patch | view | raw | blame | history
src/faq/style.scss 70 ●●●●● patch | view | raw | blame | history
src/faq/view.js 84 ●●●●● patch | view | raw | blame | history
src/glossary/block.json 24 ●●●●● patch | view | raw | blame | history
src/glossary/edit.js 38 ●●●●● patch | view | raw | blame | history
src/glossary/editor.scss patch | view | raw | blame | history
src/glossary/index.js 33 ●●●●● patch | view | raw | blame | history
src/glossary/index.php patch | view | raw | blame | history
src/glossary/render.php 8 ●●●●● patch | view | raw | blame | history
src/glossary/style.scss 104 ●●●●● patch | view | raw | blame | history
src/glossary/view.js 196 ●●●●● patch | view | raw | blame | history
src/gmbreviews/block.json 68 ●●●●● patch | view | raw | blame | history
src/gmbreviews/edit.js 69 ●●●●● patch | view | raw | blame | history
src/gmbreviews/editor.scss patch | view | raw | blame | history
src/gmbreviews/index.js 11 ●●●●● patch | view | raw | blame | history
src/gmbreviews/index.php patch | view | raw | blame | history
src/gmbreviews/render.php 203 ●●●●● patch | view | raw | blame | history
src/gmbreviews/style.scss 58 ●●●●● patch | view | raw | blame | history
src/gmbreviews/view.js patch | view | raw | blame | history
src/summary/render.php 11 ●●●●● patch | view | raw | blame | history
src/video/edit.js 2 ●●●●● patch | view | raw | blame | history
src/video/index.js 13 ●●●●● patch | view | raw | blame | history
src/video/style.scss 137 ●●●●● patch | view | raw | blame | history
templates/dashboard/sections/news.php 5 ●●●● patch | view | raw | blame | history
webpack.jvb.js 75 ●●●● patch | view | raw | blame | history
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);
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}
: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}
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}
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}
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}
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}
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();
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');
});
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;
        }
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);
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
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();
});
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);
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;
//      }
//  });
// });
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;
    }
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) {
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);
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;
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) {
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();
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)))}};
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)))}};
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)}};
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)}};
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}))}))})();
(()=>{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}))}))})();
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")}};
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")}};
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,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;")).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}))})();
(()=>{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,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;")).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")}))})();
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)}};
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)}};
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}))})();
(()=>{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}))})();
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||"")}}};
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||"")}}};
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}))})();
(()=>{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}))})();
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)}))})();
(()=>{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}))})();
assets/js/min/settings.min.js
New file
@@ -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}))})();
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}))})();
(()=>{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}))})();
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}))})();
(()=>{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}))})();
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,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;").replace(/'/g,"&#039;")):""},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()}}})();
(()=>{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,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;").replace(/'/g,"&#039;")):""},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()}}})();
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`}}};
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`}}};
build/faq/block.json
New file
@@ -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"
}
build/faq/index-rtl.css
New file
@@ -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}
build/faq/index.asset.php
New file
@@ -0,0 +1 @@
<?php return array('dependencies' => array('react-jsx-runtime', 'wp-block-editor', 'wp-blocks', 'wp-components', 'wp-element', 'wp-i18n'), 'version' => 'a548f44eb677edc4a936');
build/faq/index.css
New file
@@ -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}
build/faq/index.js
New file
@@ -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)})();
build/faq/style-index-rtl.css
New file
@@ -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}
build/faq/style-index.css
New file
@@ -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}
build/faq/view.asset.php
New file
@@ -0,0 +1 @@
<?php return array('dependencies' => array(), 'version' => '5f3b6f1df013ae48fe01');
build/faq/view.js
New file
@@ -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)}}}));
build/glossary/block.json
New file
@@ -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"
}
build/glossary/index-rtl.css
New file
@@ -0,0 +1 @@
build/glossary/index.asset.php
New file
@@ -0,0 +1 @@
<?php return array('dependencies' => array('react-jsx-runtime', 'wp-block-editor', 'wp-blocks', 'wp-i18n'), 'version' => '39ddb4f9b53613e58ee5');
build/glossary/index.css
New file
@@ -0,0 +1 @@
build/glossary/index.js
New file
@@ -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)})();
build/glossary/render.php
New file
@@ -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>
build/glossary/style-index-rtl.css
New file
@@ -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)}}
build/glossary/style-index.css
New file
@@ -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)}}
build/glossary/view.asset.php
New file
@@ -0,0 +1 @@
<?php return array('dependencies' => array(), 'version' => '90747b206e7cdf47035c');
build/glossary/view.js
New file
@@ -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})();
build/gmbreviews/block.json
New file
@@ -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"
}
build/gmbreviews/index-rtl.css
New file
@@ -0,0 +1 @@
build/gmbreviews/index.asset.php
New file
@@ -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');
build/gmbreviews/index.css
New file
@@ -0,0 +1 @@
build/gmbreviews/index.js
New file
@@ -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)})();
build/gmbreviews/render.php
New file
@@ -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 '';
    }
}
build/gmbreviews/style-index-rtl.css
New file
@@ -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%}
build/gmbreviews/style-index.css
New file
@@ -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%}
build/gmbreviews/view.asset.php
New file
@@ -0,0 +1 @@
<?php return array('dependencies' => array(), 'version' => '31d6cfe0d16ae931b73c');
build/gmbreviews/view.js
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');
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)})();
(()=>{"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)})();
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');
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)})();
(()=>{"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)})();
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}}
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}}
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) {
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
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>—&emsp;'.$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>—&emsp;'.$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();
inc/blocks/FAQBlock.php
New file
@@ -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();
    }
}
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']);
    }
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,
inc/blocks/GlossaryBlock.php
New file
@@ -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(' ·  ·', '&emsp;',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));
    }
}
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' ]);
    }
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',
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();
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);
    }
}
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');
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,
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
    }
}
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'] ?? '';
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');
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;
inc/helpers/cache.php
File was deleted
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;
}
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>';
}
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();
}
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
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;
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;
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);
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';
inc/importers/JaneAppClientImporter.php
New file
@@ -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;
    }
}
inc/importers/JaneAppSalesImporter.php
New file
@@ -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;
    }
}
inc/importers/_setup.php
New file
@@ -0,0 +1,3 @@
<?php
require(JVB_DIR . '/inc/importers/JaneAppClientImporter.php');
require(JVB_DIR . '/inc/importers/JaneAppSalesImporter.php');
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
     */
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':''?>>
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
    }
    /**
inc/managers/AjaxRateLimiter.php
New file
@@ -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;
    }
}
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;
    }
}
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 [];
    }
}
inc/managers/CacheManagerOld.php
New file
@@ -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()));
    }
}
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();
        }
    }
}
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']);
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);
    }
    /**
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();
inc/managers/LoginManagerOld.php
New file
@@ -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');
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;
    }
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) {
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);
    }
    /**
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;
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>';
    }
}
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
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();
    }
}
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();
inc/managers/UploadManager.php
@@ -505,7 +505,7 @@
        return apply_filters(
            'jvb_upload_filename',
            "{$username}-{$base_name}-{$timestamp}",
            "{$base_name}-{$timestamp}",
            $context
        );
    }
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;
    }
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
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'])) {
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);
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);
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;
    }
}
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);
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);
//}
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;
    }
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
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(
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);
    }
    /**
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'] ?? '');
inc/rest/routes/ImporterRoutes.php
New file
@@ -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) . '.';
    }
}
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
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,
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
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'
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);
    }
    /**
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;
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 truncated after the above file
inc/utility/Validator.php jvb.php package-lock.json package.json src/faq/block.json src/faq/edit.js src/faq/editor.scss src/faq/index.js src/faq/index.php src/faq/render.php src/faq/style.scss src/faq/view.js src/glossary/block.json src/glossary/edit.js src/glossary/editor.scss src/glossary/index.js src/glossary/index.php src/glossary/render.php src/glossary/style.scss src/glossary/view.js src/gmbreviews/block.json src/gmbreviews/edit.js src/gmbreviews/editor.scss src/gmbreviews/index.js src/gmbreviews/index.php src/gmbreviews/render.php src/gmbreviews/style.scss src/gmbreviews/view.js src/summary/render.php src/video/edit.js src/video/index.js src/video/style.scss templates/dashboard/sections/news.php webpack.jvb.js