Jake Vanderwerf
2025-10-20 e729f920139f0c65902be2d6b2c32466b08375e8
=Form updates
2 files added
25 files modified
7234 ■■■■ changed files
JVBase.php 8 ●●●● patch | view | raw | blame | history
assets/css/copy-hours.min.css 2 ●●● patch | view | raw | blame | history
assets/css/forms.min.css 2 ●●● patch | view | raw | blame | history
assets/js/concise/FormController.js 1136 ●●●●● patch | view | raw | blame | history
assets/js/dash/SquareCheckout.js 250 ●●●●● patch | view | raw | blame | history
assets/js/dash/UtilityFunctions.js 15 ●●●●● patch | view | raw | blame | history
assets/js/min/form.min.js 2 ●●● patch | view | raw | blame | history
assets/js/min/square.min.js 2 ●●● patch | view | raw | blame | history
assets/js/min/utility.min.js 2 ●●● patch | view | raw | blame | history
build/forms/view.asset.php 2 ●●● patch | view | raw | blame | history
build/forms/view.js 2 ●●● patch | view | raw | blame | history
icons.php 4 ●●● patch | view | raw | blame | history
inc/blocks/FormBlock.php 58 ●●●●● patch | view | raw | blame | history
inc/helpers/renderFields.php 22 ●●●●● patch | view | raw | blame | history
inc/helpers/ui.php 20 ●●●●● patch | view | raw | blame | history
inc/integrations/Integrations.php 89 ●●●●● patch | view | raw | blame | history
inc/integrations/Square.php 442 ●●●●● patch | view | raw | blame | history
inc/managers/FormManager.php 1 ●●●● patch | view | raw | blame | history
inc/managers/ReferralManager.php 80 ●●●●● patch | view | raw | blame | history
inc/meta/MetaForm.php 2637 ●●●●● patch | view | raw | blame | history
inc/meta/MetaFormOld.php 2128 ●●●●● patch | view | raw | blame | history
inc/meta/MetaManager.php 9 ●●●●● patch | view | raw | blame | history
inc/registry/PostTypeRegistrar.php 2 ●●● patch | view | raw | blame | history
inc/rest/_setup.php 4 ●●●● patch | view | raw | blame | history
inc/rest/routes/IntegrationsSquareRoutes.php 239 ●●●●● patch | view | raw | blame | history
jvb.php 5 ●●●●● patch | view | raw | blame | history
src/forms/view.js 71 ●●●● patch | view | raw | blame | history
JVBase.php
@@ -13,6 +13,7 @@
use JVBase\managers\UserTermsManager;
use JVBase\rest\routes\FeedRoutes;
use JVBase\rest\routes\FavouritesRoutes;
use JVBase\rest\routes\IntegrationsSquareRoutes;
use JVBase\rest\routes\NotificationsRoutes;
use JVBase\rest\routes\ContentRoutes;
use JVBase\rest\routes\TermRoutes;
@@ -84,7 +85,6 @@
            'userTerms'     => new UserTermsManager(),
        ];
        add_action('wp_footer', [$this, 'additionalActions']);
        if (Features::forSite()->has('magicLink')) {
            $this->routes['magicLink'] = new MagicLinkRoutes();
@@ -98,6 +98,10 @@
            $this->managers['dash'] = new DashboardManager();
        }
        if (Features::hasIntegration('square')){
            $this->routes['square'] = new IntegrationsSquareRoutes();
        }
        $this->routes = [
            'integrations'  => new IntegrationsRoutes(),
        ];
@@ -148,6 +152,8 @@
        }
        $this->setupIntegrations();
        add_action('wp_footer', [$this, 'additionalActions']);
//        $this->managers['notifications'] = new NotificationManager();
        // Register activation hook
        register_activation_hook(JVB_DIR . '/jvb.php', [$this, 'activate']);
assets/css/copy-hours.min.css
@@ -1 +1 @@
.group-fields{position:relative}.hours-copy-btn:hover{background-color:var(--action-50);transform:scale(1.05)}.hours-copy-btn:active{transform:scale(.95)}.hours-copy-btn .icon{--w:0.875rem}.copy-hours-content h3{margin:0 0 1rem 0;color:var(--contrast);font-size:var(--large)}.copy-hours-source{background-color:var(--base-100);padding:1rem;border-radius:var(--innerRadius);margin-bottom:1.5rem;border:1px solid var(--base-200)}.copy-hours-source h4{margin:0 0 .5rem 0;color:var(--contrast-100);text-transform:uppercase;font-size:var(--small);font-weight:600}.source-info{--gap:.25rem}.source-day{font-weight:600;color:var(--contrast);text-transform:capitalize}.source-hours{--gap:1rem;font-weight:500;color:var(--contrast)}.source-hours.closed{color:var(--contrast-200);font-style:italic}.copy-hours-targets{margin-bottom:2rem}.copy-hours-targets h4{margin:0 0 1rem 0;color:var(--contrast-100);text-transform:uppercase;font-size:var(--small);font-weight:600}.day-checkboxes{display:grid;grid-template-columns:repeat(auto-fit,minmax(120px,1fr));gap:.75rem}.feedback{position:fixed;top:2rem;right:2rem;background-color:var(--action-50);color:var(--action-contrast);padding:1rem 1.5rem;border-radius:var(--innerRadius);box-shadow:var(--shadow);z-index:10000;opacity:0;transform:translateX(100px);transition:all var(--transition-base);display:flex;align-items:center;gap:.5rem}.feedback.show{opacity:1;transform:translateX(0)}.feedback .icon{--w:1.25rem}@media (prefers-color-scheme:dark){.feedback{background-color:var(--success-dark,#1e7e34)}}
.group-fields{position:relative}.hours-copy-btn:hover{background-color:var(--action-50);transform:scale(1.05)}.hours-copy-btn:active{transform:scale(.95)}.hours-copy-btn .icon{--w:0.875rem}.copy-hours-content h3{margin:0 0 1rem 0;color:var(--contrast);font-size:var(--large)}.copy-hours-source{background-color:var(--base-100);padding:1rem;border-radius:var(--innerRadius);margin-bottom:1.5rem;border:1px solid var(--base-200)}.copy-hours-source h4{margin:0 0 .5rem 0;color:var(--contrast-100);text-transform:uppercase;font-size:var(--small);font-weight:600}.source-info{--gap:.25rem}.source-day{font-weight:600;color:var(--contrast);text-transform:capitalize}.source-hours{--gap:1rem;font-weight:500;color:var(--contrast)}.source-hours.closed{color:var(--contrast-200);font-style:italic}.copy-hours-targets{margin-bottom:2rem}.copy-hours-targets h4{margin:0 0 1rem 0;color:var(--contrast-100);text-transform:uppercase;font-size:var(--small);font-weight:600}.day-checkboxes{display:grid;grid-template-columns:repeat(auto-fit,minmax(120px,1fr));gap:.75rem}.feedback{position:fixed;top:2rem;right:2rem;background-color:var(--action-50);color:var(--action-contrast);padding:1rem 1.5rem;border-radius:var(--innerRadius);box-shadow:var(--shadow);z-index:10000;opacity:0;transform:translateX(100px);transition:all var(--transition-base);display:flex;align-items:center;gap:.5rem}.feedback.show{opacity:1;transform:translateX(0)}.feedback .icon{--w:1.25rem}
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:calc(100% - (var(--padding) * 2.5));height:calc(100% - (var(--padding) * 2))}.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 *!*/
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}
assets/js/concise/FormController.js
@@ -28,6 +28,10 @@
        this.specialFields = new Map();
        this.dependencies = new Map();
        // Validation (YOU ARE GREAT!)
        this.validators = this.initValidators();
        this.touchedFields = new Set();
        // Auto-save configuration
        this.autoSaveDefaults = {
            delay: 3000, // 3 seconds
@@ -71,15 +75,26 @@
        switch(event) {
            case 'item-saved':
                if (data.item.status === 'autosave') {
                    this.showFormStatus(data.item.formId, 'autosave');
                    // this.showFormStatus(data.item.formId, 'autosave');
                }
                break;
            case 'data-loaded':
                this.checkPendingForms();
                break;
        }
    }
    async checkPendingForms() {
        let items = await this.store.query('status', 'draft');
        items.forEach(item => {
            let form = this.forms.get(item.formId);
            if (form && form.element) {
                form.element.querySelector('.restore-form').hidden = false;
                new this.populateForm(form.element, item.data);
            }
        });
    }
    /**
     * Check for pending operations from previous session
     */
@@ -160,11 +175,11 @@
    initListeners() {
        // Only add if not already added
        if (!this.globalHandlersAdded) {
            document.addEventListener('submit', this.submitHandler);
            document.addEventListener('click', this.clickHandler);
            document.addEventListener('change', this.changeHandler);
            document.addEventListener('focus', this.focusHandler, true);
            document.addEventListener('blur', this.blurHandler, true);
            document.addEventListener('input', this.inputHandler);
            this.globalHandlersAdded = true;
        }
    }
@@ -176,6 +191,8 @@
        const formId = formElement.dataset.formId || `form_${Date.now()}`;
        formElement.dataset.formId = formId;
        formElement.addEventListener('submit', this.submitHandler);
        const formConfig = {
            element: formElement,
            id: formId,
@@ -199,7 +216,7 @@
        // Check for pending data
        if (this.store && formConfig.options.cache) {
            const cached = this.store.getForm(formId);
            const cached = this.store.get(formId);
            if (cached && cached.formData) {
                this.showPendingNotification(cached);
            }
@@ -231,7 +248,9 @@
        // Initialize tabs if present
        if (window.jvbTabs && form.querySelector('nav.tabs')) {
            new window.jvbTabs(form);
            formConfig.tabs = new window.jvbTabs(form);
            this.forms.set(formConfig.formId, formConfig);
            this.initSteppedForm(formConfig.formId);
        }
        // Scan for existing selector fields
@@ -241,6 +260,120 @@
    }
    /**
     * Initialize stepped form functionality
     */
    initSteppedForm(formId) {
        const formConfig = this.forms.get(formId);
        const form = formConfig.element;
        const tabsInstance = formConfig.tabs;
        const sections = form.querySelectorAll('.tab-content');
        const totalSteps = sections.length;
        const progressBar = form.querySelector('.form-progress .fill');
        const stepText = form.querySelector('.step-text .current');
        const tabButtons = form.querySelectorAll('nav.tabs button');
        // Update progress display
        const updateProgress = (currentStep) => {
            const progress = (currentStep / totalSteps) * 100;
            if (progressBar) {
                progressBar.style.width = progress + '%';
            }
            if (stepText) {
                stepText.textContent = currentStep;
            }
            // Update tab states
            tabButtons.forEach((btn, idx) => {
                const stepNum = idx + 1;
                btn.classList.remove('current', 'completed', 'pending');
                if (stepNum < currentStep) {
                    btn.classList.add('completed');
                } else if (stepNum === currentStep) {
                    btn.classList.add('current');
                } else {
                    btn.classList.add('pending');
                }
            });
        };
        // Next/Previous button handling
        form.addEventListener('click', (e) => {
            const nextBtn = e.target.closest('[data-action="next-step"]');
            const prevBtn = e.target.closest('[data-action="prev-step"]');
            if (nextBtn) {
                e.preventDefault();
                const currentSection = nextBtn.closest('.tab-content');
                const currentStep = parseInt(currentSection.dataset.step);
                const nextSection = form.querySelector(`.tab-content[data-step="${currentStep + 1}"]`);
                if (nextSection && this.validateStep(currentSection)) {
                    const nextTab = nextSection.dataset.tab;
                    tabsInstance.switchTab(nextTab, true);
                    updateProgress(currentStep + 1);
                    // Scroll to top of form
                    form.scrollIntoView({ behavior: 'smooth', block: 'start' });
                }
            }
            if (prevBtn) {
                e.preventDefault();
                const currentSection = prevBtn.closest('.tab-content');
                const currentStep = parseInt(currentSection.dataset.step);
                const prevSection = form.querySelector(`.tab-content[data-step="${currentStep - 1}"]`);
                if (prevSection) {
                    const prevTab = prevSection.dataset.tab;
                    tabsInstance.switchTab(prevTab, true);
                    updateProgress(currentStep - 1);
                    // Scroll to top of form
                    form.scrollIntoView({ behavior: 'smooth', block: 'start' });
                }
            }
        });
        // Update progress when tabs are clicked directly
        const originalSwitchTab = tabsInstance.switchTab.bind(tabsInstance);
        tabsInstance.switchTab = (tab, updateHistory) => {
            originalSwitchTab(tab, updateHistory);
            const activeSection = form.querySelector(`.tab-content[data-tab="${tab}"]`);
            if (activeSection) {
                const step = parseInt(activeSection.dataset.step);
                updateProgress(step);
            }
        };
        // Initialize progress
        updateProgress(1);
    }
    /**
     * Validate current step before allowing progression
     * Can be enhanced with custom validation rules
     */
    validateStep(section) {
        const fields = section.querySelectorAll('.field');
        let allValid = true;
        fields.forEach(fieldWrapper => {
            const input = fieldWrapper.querySelector('input, textarea, select');
            if (input && !input.closest('[hidden]')) {
                const isValid = this.validateField(input, fieldWrapper);
                if (!isValid) {
                    allValid = false;
                }
            }
        });
        return allValid;
    }
    /**
     * Initialize Quill editors
     */
    initQuillEditors(form) {
@@ -522,16 +655,12 @@
        if (this.subscribers.size > 0 ){
            const form = event.target;
            if (!form.dataset.formId) return;
            this.store.delete(form.dataset.formId);
            event.preventDefault();
            const formConfig = this.forms.get(form.dataset.formId);
            if (!formConfig) return;
            const formData = this.collectFormData(form);
            event.preventDefault();
            this.notify('form-submit', {
                formId: formConfig.id,
                data: formData,
@@ -544,8 +673,23 @@
        if (window.targetCheck(e, 'div.quantity')) {
            let container = window.targetCheck(e, 'div.quantity');
            this.handleNumberClick(e, container.querySelector('input'));
        } else if (window.targetCheck(e, '[data-action]')) {
            let action = window.targetCheck(e, '[data-action]');
            action = action.dataset.action;
            switch (action) {
                case 'clear-form':
                    let form = e.target.closest('form');
                    this.store.delete(form.dataset.formId);
                    form?.reset();
                    e.target.closest('.restore-form').hidden = true;
                    break;
                case 'dismiss-restore':
                    e.target.closest('.restore-form').hidden = true;
                    break;
        }
    }
    }
    handleNumberClick(e, input) {
        let change = 0;
@@ -635,12 +779,27 @@
        }
    }
    handleBlur(event) {
        const target = event.target;
    handleBlur(e) {
        const target = e.target;
        const form = target.form || target.closest('form');
        if (!form) return;
        const input = e.target.closest('input, textarea, select');
        if (input) {
            const fieldWrapper = this.findFieldWrapper(input);
            if (fieldWrapper) {
                // Mark as touched and validate
                const fieldName = fieldWrapper.dataset.field;
                if (fieldName) {
                    if (this.shouldDebounce(input)) {
                        window.debouncer.cancel(`validate_${fieldName}`);
                    }
                    this.touchedFields.add(fieldName);
                }
                this.validateField(input, fieldWrapper);
            }
        const formConfig = this.forms?.get(form.dataset.formId);
        if (formConfig && formConfig.options.autoSave && !form.dataset.noautosave) {
            // Shorter delay on blur
@@ -651,7 +810,421 @@
            });
        }
    }
    }
    handleInput(e) {
        const input = e.target.closest('input, textarea, select');
        if (!input) return;
        let form = input.closest('form');
        this.showFormStatus(form.dataset.formId, 'pending');
        const fieldWrapper = this.findFieldWrapper(input);
        if (!fieldWrapper) return;
        const fieldName = fieldWrapper.dataset.field;
        if (fieldName) {
            this.touchedFields.add(fieldName);
        }
        if (this.shouldDebounce(input)){
            window.debouncer.schedule(
                `validate_${fieldName}`,
                (input, fieldWrapper) => this.validateField.bind(this),
                500
            )
        }
    }
    /***************************************************************
     FORM VALIDATION
    ***************************************************************/
    /**
     * Initialize validation rules
     */
    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: (value, fieldWrapper) => {
                    const num = parseFloat(value);
                    if (isNaN(num)) return 'Please enter a valid number';
                    const min = fieldWrapper.dataset.min;
                    const max = fieldWrapper.dataset.max;
                    if (min !== undefined && num < parseFloat(min)) {
                        return `Value must be at least ${min}`;
                    }
                    if (max !== undefined && num > parseFloat(max)) {
                        return `Value must be at most ${max}`;
                    }
                    return true;
                }
            },
            text: {
                test: (value, fieldWrapper) => {
                    const minLength = fieldWrapper.dataset.minlength;
                    const maxLength = fieldWrapper.dataset.maxlength;
                    if (minLength && value.length < parseInt(minLength)) {
                        return `Must be at least ${minLength} characters`;
                    }
                    if (maxLength && value.length > parseInt(maxLength)) {
                        return `Must be no more than ${maxLength} characters`;
                    }
                    return true;
                }
            }
        };
    }
    /**
     * Find the field wrapper (handles both simple and complex fields)
     */
    findFieldWrapper(input) {
        // Try to find the closest .field wrapper
        let wrapper = input.closest('.field');
        // If we're in a repeater row, make sure we get the right field wrapper
        if (!wrapper) {
            wrapper = input.closest('[data-field]');
        }
        return wrapper;
    }
    /**
     * Check if input should be debounced
     */
    shouldDebounce(input) {
        const debounceTypes = ['text', 'email', 'url', 'tel', 'search'];
        return debounceTypes.includes(input.type) || input.tagName === 'TEXTAREA';
    }
    /**
     * Validate a single field
     */
    validateField(input, fieldWrapper) {
        const value = this.getFieldValue(input);
        const fieldName = fieldWrapper.dataset.field;
        // Skip validation if field hasn't been touched yet (unless it's required)
        if (!this.touchedFields.has(fieldName) && !input.required) {
            return true;
        }
        // Skip validation if field is empty and not required
        if (!value && !input.required) {
            this.clearValidation(fieldWrapper);
            return true;
        }
        // Check required
        if (input.required && !value) {
            this.showError(fieldWrapper, 'This field is required');
            return false;
        }
        // Check HTML5 validity first
        if (input.checkValidity && !input.checkValidity()) {
            this.showError(fieldWrapper, input.validationMessage);
            return false;
        }
        // Custom pattern validation from data attribute
        const pattern = fieldWrapper.dataset.pattern;
        if (pattern && value) {
            const regex = new RegExp(pattern);
            if (!regex.test(value)) {
                const message = fieldWrapper.dataset.validationMessage || 'Invalid format';
                this.showError(fieldWrapper, message);
                return false;
            }
        }
        // Type-specific validation
        const validateType = fieldWrapper.dataset.validate || input.type;
        if (validateType && this.validators[validateType]) {
            const validator = this.validators[validateType];
            if (validator.pattern && !validator.pattern.test(value)) {
                this.showError(fieldWrapper, validator.message);
                return false;
            }
            if (validator.test) {
                const result = validator.test(value, fieldWrapper);
                if (result !== true) {
                    this.showError(fieldWrapper, result);
                    return false;
                }
            }
        }
        // All validations passed
        this.showSuccess(fieldWrapper);
        return true;
    }
    /**
     * Get field value (handles different input types)
     */
    getFieldValue(input) {
        if (!input) return '';
        if (input.type === 'checkbox') {
            return input.checked ? input.value || '1' : '';
        } else if (input.type === 'radio') {
            const checked = input.form?.querySelector(`[name="${input.name}"]:checked`);
            return checked ? checked.value : '';
        } else if (input.type === 'select-multiple') {
            return Array.from(input.selectedOptions).map(o => o.value);
        }
        return input.value?.trim() || '';
    }
    /**
     * Show success state (green checkmark)
     */
    showSuccess(fieldWrapper) {
        if (!fieldWrapper) return;
        // Find validation elements (they might be in field-input-wrapper or field-content)
        const success = fieldWrapper.querySelector('.validation-icon.success');
        const error = fieldWrapper.querySelector('.validation-icon.error');
        const message = fieldWrapper.querySelector('.validation-message');
        const input = fieldWrapper.querySelector('input, textarea, select');
        // Remove error state
        fieldWrapper.classList.remove('has-error');
        input?.classList.remove('error');
        // Add success state
        fieldWrapper.classList.add('has-success');
        // Show checkmark (if element exists)
        if (success) {
            success.hidden = false;
        }
        if (error) {
            error.hidden = true;
        }
        // Hide error message
        if (message) {
            message.hidden = true;
            message.textContent = '';
        }
    }
    /**
     * Show error state (red message below field)
     */
    showError(fieldWrapper, errorMessage) {
        if (!fieldWrapper) return;
        const success = fieldWrapper.querySelector('.validation-icon.success');
        const error = fieldWrapper.querySelector('.validation-icon.error');
        const message = fieldWrapper.querySelector('.validation-message');
        const input = fieldWrapper.querySelector('input, textarea, select');
        // Remove success state
        fieldWrapper.classList.remove('has-success');
        // Add error state
        fieldWrapper.classList.add('has-error');
        input?.classList.add('error');
        // Hide checkmark (if element exists)
        if (success) {
            success.hidden = true;
        }
        //show x
        if (error) {
            error.hidden = false;
        }
        // Show error message
        if (message) {
            message.hidden = false;
            message.textContent = errorMessage;
        }
    }
    /**
     * Clear validation state
     */
    clearValidation(fieldWrapper) {
        if (!fieldWrapper) return;
        const icon = fieldWrapper.querySelector('.validation-icon');
        const message = fieldWrapper.querySelector('.validation-message');
        const input = fieldWrapper.querySelector('input, textarea, select');
        fieldWrapper.classList.remove('has-error', 'has-success');
        input?.classList.remove('error');
        if (icon) {
            icon.hidden = true;
        }
        if (message) {
            message.hidden = true;
            message.textContent = '';
        }
    }
    /**
     * Validate all fields in a container (useful for step validation)
     */
    validateAllFields(container) {
        if (!container) return true;
        const fields = container.querySelectorAll('.field:not([hidden])');
        let allValid = true;
        fields.forEach(fieldWrapper => {
            // Skip complex parent wrappers (repeater, group) - validate their children
            if (this.isComplexFieldWrapper(fieldWrapper)) {
                return;
            }
            const input = fieldWrapper.querySelector('input:not([type="hidden"]), textarea, select');
            if (input && !input.closest('[hidden]')) {
                // Mark as touched so validation will run
                const fieldName = fieldWrapper.dataset.field;
                if (fieldName) {
                    this.touchedFields.add(fieldName);
                }
                const isValid = this.validateField(input, fieldWrapper);
                if (!isValid) {
                    allValid = false;
                    // Scroll to first error
                    if (allValid === false) {
                        input.scrollIntoView({ behavior: 'smooth', block: 'center' });
                        input.focus();
                    }
                }
            }
        });
        return allValid;
    }
    /**
     * Check if field wrapper is a complex type (repeater, group, etc.)
     */
    isComplexFieldWrapper(fieldWrapper) {
        return fieldWrapper.classList.contains('repeater') ||
            fieldWrapper.classList.contains('group') ||
            fieldWrapper.classList.contains('upload');
    }
    /**
     * Special validation for repeater fields
     */
    attachRepeaterValidation(form) {
        // When a repeater row is added, attach validation to its fields
        form.addEventListener('click', (e) => {
            if (e.target.closest('.add-repeater-row')) {
                // Wait for the DOM to update
                setTimeout(() => {
                    const repeaterRows = form.querySelectorAll('.repeater-row');
                    repeaterRows.forEach(row => {
                        const inputs = row.querySelectorAll('input, textarea, select');
                        inputs.forEach(input => {
                            const fieldWrapper = this.findFieldWrapper(input);
                            if (fieldWrapper) {
                                // Validation listeners are already attached via event delegation
                                // Just clear any existing validation state for new rows
                                this.clearValidation(fieldWrapper);
                            }
                        });
                    });
                }, 100);
            }
        });
    }
    /**
     * Special validation for group fields
     */
    attachGroupValidation(form) {
        // Group fields might have conditional fields
        // Validate when conditions change
        form.addEventListener('change', (e) => {
            const changedInput = e.target.closest('input, select');
            if (!changedInput) return;
            // Check if this change affects conditional fields
            const fieldName = changedInput.name;
            if (!fieldName) return;
            // Find any conditional fields that depend on this field
            const conditionalFields = form.querySelectorAll(`[data-show-if*="${fieldName}"]`);
            conditionalFields.forEach(conditionalField => {
                // Clear validation for hidden fields
                if (conditionalField.hidden) {
                    this.clearValidation(conditionalField);
                }
            });
        });
    }
    /**
     * Reset validation state for a form
     */
    resetForm(form) {
        if (!form) return;
        // Clear all touched fields
        this.touchedFields.clear();
        // Clear all validation states
        const fields = form.querySelectorAll('.field');
        fields.forEach(fieldWrapper => {
            this.clearValidation(fieldWrapper);
        });
    }
    /**
     * Get validation errors for a form
     */
    getFormErrors(form) {
        const errors = {};
        const fields = form.querySelectorAll('.field.has-error');
        fields.forEach(fieldWrapper => {
            const fieldName = fieldWrapper.dataset.field;
            const message = fieldWrapper.querySelector('.validation-message');
            if (fieldName && message) {
                errors[fieldName] = message.textContent;
            }
        });
        return errors;
    }
    /**
     * Add custom validator
     */
    addValidator(name, validator) {
        this.validators[name] = validator;
    }
    /* ========== Auto-save functionality ========== */
    /**
     * Get appropriate delay based on field type and context
@@ -671,7 +1244,7 @@
        return this.autoSaveDefaults.delay;
    }
    scheduleSave(formConfig, delay = this.autoSaveDefaults.delay) {
        document.addEventListener('input', this.handleInput, {passive: true});
        document.addEventListener('input', this.saveCheck, {passive: true});
        const saveKey = `autosave_${formConfig.id}`;
        this.debouncer.schedule(
@@ -682,24 +1255,27 @@
    }
    //Extend delay if user is currently typing
    handleInput(e) {
    saveCheck(e) {
        let form = e.target.closest('form[data-id]');
        if (!form) {
            return;
        }
        this.scheduleSave(this.forms.get(form.dataset.id));
    }
    async autosave(formConfig) {
        const formData = this.collectFormData(formConfig.element);
        this.showFormStatus(formConfig.id, 'saving');
        await this.store.save({
            formId: formConfig.id,
            data: formData,
            status: 'draft',
            timestamp: Date.now()
        }).then(()=> {
            this.showFormStatus(formConfig.id, 'autosaved');
        });
        this.showFormStatus(formConfig.id, 'saved');
        // Get only changed fields
        const changes = this.getChangedFields(formConfig.data, formData);
@@ -742,30 +1318,48 @@
        return Object.keys(changes).length > 0;
    }
    showFormStatus(form, status) {
    showFormStatus(formID, status) {
        // Remove existing status
        const existingStatus = form.querySelector('.form-status');
        if (existingStatus) {
            existingStatus.remove();
        }
        let form = this.forms.get(formID);
        console.log('Setting status: ', status);
        // Add new status
        const statusElement = document.createElement('div');
        statusElement.className = `form-status status-${status}`;
        const statusWrap = form.element.querySelector('.fstatus');
        statusWrap.hidden = false;
        const statusElement = statusWrap.querySelector('.message');
        statusElement.textContent = '';
        statusWrap.querySelector('.icon')?.remove();
        const messages = {
            'saving': 'Saving changes...',
            'saved': 'Changes saved',
            'error': 'Failed to save 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'
        };
        const icons = {
            'autosaved': 'check',
            'submitted': 'check',
            'error': 'close',
            'offline': 'cloud-slash',
            'pending': 'exclamation-mark'
        }
        let icon = window.getIcon(icons[status]);
        if (icon) {
            statusWrap.prepend(icon);
        }
        console.log(status, messages[status]);
        console.log(status, icons[status]);
        statusElement.textContent = messages[status] || status;
        form.insertBefore(statusElement, form.firstChild);
        statusWrap.classList.toggle('loading', ['uploading', 'saving'].includes(status));
        // Auto-hide success messages
        if (status === 'saved') {
            setTimeout(() => statusElement.remove(), 3000);
        if (status === 'submitted') {
            setTimeout(() => statusWrap.hidden = true, 3000);
        }
    }
@@ -810,7 +1404,7 @@
        if (key.includes('|')) return this.processTableField;
        if (key.includes('::')) return this.processGroupField;
        if (key.includes(':')) return this.processRepeaterField;
        if (key.includes('[')) return this.processLocationField;
        if (/\[[^\]]+\]/.test(key)) return this.processLocationField;
        return this.processRegularField;
    }
@@ -944,6 +1538,7 @@
    processRegularField(key, value, data, repeaterData, postData, form) {
        //handle array values (like checkboxes/selects)
        key = key.replace('[]','');
        if (data[key]) {
            if (!Array.isArray(data[key])) {
                data[key] = [data[key]];
@@ -973,6 +1568,485 @@
        return window.getDifferences?.map(original, current) || {};
    }
    /*******************************************************
     Field Summary
    *******************************************************/
    /**
     * Show a comprehensive summary of form submission
     */
    showSummary(formId, clear = 'form') {
        const formConfig = this.forms.get(formId);
        if (!formConfig) return;
        const form = formConfig.element || document.querySelector(`[data-form-id="${formId}"]`);
        const summary = window.getTemplate('formSummary');
        const [
            title,
            resultWrapper,
            resultTemplate
        ] = [
            summary.querySelector('h2'),
            summary.querySelector('.summary'),
            summary.querySelector('.result')
        ];
        // Fields to skip in summary
        const skipFields = ['sendAll', ...this.ignore];
        // Process each field in the form data
        for (const [key, value] of Object.entries(formConfig.data)) {
            // Skip ignored fields and empty values
            if (skipFields.includes(key) || this.isEmptyValue(value)) {
                continue;
            }
            // Get field info from form
            const fieldInfo = this.getFieldInfo(form, key);
            if (!fieldInfo.label) continue; // Skip if no label found
            // Create result element
            const resultEl = this.createResultElement(
                resultTemplate,
                fieldInfo,
                value,
                form
            );
            if (resultEl) {
                resultWrapper.appendChild(resultEl);
            }
        }
        // Remove template
        resultTemplate.remove();
        // Insert summary and hide form
        clear = (clear !== 'form') ? form.closest(clear)??form : form;
        clear.after(summary);
        window.fade(clear, false);
    }
    /**
     * Check if a value is empty (null, undefined, empty string, empty array, empty object)
     */
    isEmptyValue(value) {
        if (value === null || value === undefined || value === '') {
            return true;
        }
        if (Array.isArray(value) && value.length === 0) {
            return true;
        }
        if (typeof value === 'object' && Object.keys(value).length === 0) {
            return true;
        }
        return false;
    }
    /**
     * Get field information (label, type, etc.) from the form
     * Handles special field name patterns ([], ::, :, etc.)
     */
    getFieldInfo(form, fieldName) {
        // Try to find label by 'for' attribute (exact match)
        let label = form.querySelector(`label[for="${fieldName}"]`);
        let input = null;
        let fieldWrapper = null;
        // Try to find the input field - check multiple patterns
        if (!input) {
            // Try exact match first
            input = form.querySelector(`[name="${fieldName}"]`);
        }
        if (!input) {
            // Try with [] suffix (for checkboxes, multi-selects)
            input = form.querySelector(`[name="${fieldName}[]"]`);
        }
        if (!input) {
            // Try as fieldset legend (for checkbox/radio groups)
            const fieldset = form.querySelector(`fieldset[data-field="${fieldName}"]`);
            if (fieldset) {
                label = fieldset.querySelector('legend');
                input = fieldset.querySelector('input, select, textarea');
            }
        }
        // Get label from input if not found yet
        if (!label && input) {
            // Try closest field wrapper first
            const field = input.closest('.field, fieldset');
            if (field) {
                label = field.querySelector('label, legend');
            }
        }
        // Get field wrapper - always use base name (no special characters)
        fieldWrapper = form.querySelector(`.field[data-field="${fieldName}"], fieldset[data-field="${fieldName}"]`);
        // Determine field type
        let fieldType = 'text';
        if (fieldWrapper?.dataset.type) {
            fieldType = fieldWrapper.dataset.type;
        } else if (input) {
            // Infer from input type
            if (input.type === 'checkbox' && input.name.endsWith('[]')) {
                fieldType = 'checkbox'; // checkbox group
            } else if (input.type === 'checkbox') {
                fieldType = 'true_false'; // single checkbox
            } else if (input.tagName === 'SELECT' && input.multiple) {
                fieldType = 'select'; // multi-select
            } else {
                fieldType = input.type || 'text';
            }
        }
        return {
            label: label?.textContent.replace('*', '').trim() || null,
            type: fieldType,
            wrapper: fieldWrapper,
            input: input
        };
    }
    /**
     * Create a result element for a field
     */
    createResultElement(template, fieldInfo, value, form) {
        const resultEl = template.cloneNode(true);
        const titleEl = resultEl.querySelector('h4');
        const valueEl = resultEl.querySelector('p');
        // Set label
        titleEl.textContent = fieldInfo.label;
        // Format value based on field type
        const formattedValue = this.formatFieldValue(value, fieldInfo.type, form);
        // Determine how to set the value
        if (this.isHtmlContent(formattedValue)) {
            // HTML content - use innerHTML
            valueEl.innerHTML = formattedValue;
        } else {
            // Plain text - use textContent for safety
            valueEl.textContent = formattedValue;
        }
        return resultEl;
    }
    /**
     * Check if content should be treated as HTML
     */
    isHtmlContent(content) {
        return typeof content === 'string' && (
            content.includes('<br>') ||
            content.includes('<p>') ||
            content.includes('<ul>') ||
            content.includes('<ol>') ||
            content.includes('<a ') ||
            content.includes('<strong>') ||
            content.includes('<em>') ||
            content.includes('<div')
        );
    }
    /**
     * Format field value based on type
     */
    formatFieldValue(value, type, form) {
        switch (type) {
            case 'textarea':
            case 'wysiwyg':
                // Handle rich text - check if it's actual HTML content from Quill
                return this.formatTextareaValue(value, type);
            case 'true_false':
                return (value === '1' || value === 1 || value === true) ? 'Yes' : 'No';
            case 'checkbox':
                // Handle both single checkbox and checkbox groups
                if (Array.isArray(value)) {
                    return this.formatArrayValue(value);
                }
                return (value === '1' || value === 1 || value === true) ? 'Yes' : 'No';
            case 'select':
                // Handle both single and multi-select
                if (Array.isArray(value)) {
                    return this.formatArrayValue(value);
                }
                // Get label from select option
                return this.getSelectLabel(value, form, type);
            case 'date':
            case 'datetime':
            case 'time':
                return window.formatDate ? window.formatDate(value) : value;
            case 'radio':
                // Get label from select option or radio label
                return this.getSelectLabel(value, form, type);
            case 'repeater':
                return this.formatRepeaterValue(value);
            case 'group':
                return this.formatGroupValue(value);
            case 'location':
                return this.formatLocationValue(value);
            case 'file':
            case 'image':
                return this.formatFileValue(value);
            case 'number':
                return this.formatNumber(value);
            case 'email':
                return `<a href="mailto:${value}">${value}</a>`;
            case 'url':
                return `<a href="${value}" target="_blank" rel="noopener">${value}</a>`;
            case 'phone':
                return `<a href="tel:${value.replace(/\D/g, '')}">${value}</a>`;
            default:
                // Handle arrays (multi-select, checkbox group)
                if (Array.isArray(value)) {
                    return this.formatArrayValue(value);
                }
                return value;
        }
    }
    /**
     * Format repeater field value
     */
    formatRepeaterValue(rows) {
        if (!Array.isArray(rows) || rows.length === 0) {
            return '<em>No entries</em>';
        }
        let html = '<div class="repeater-summary">';
        rows.forEach((row, index) => {
            html += `<div class="repeater-row">`;
            html += `<strong>Entry ${index + 1}:</strong><ul>`;
            for (const [key, value] of Object.entries(row)) {
                if (!this.isEmptyValue(value)) {
                    const label = key.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase());
                    html += `<li><strong>${label}:</strong> ${value}</li>`;
                }
            }
            html += `</ul></div>`;
        });
        html += '</div>';
        return html;
    }
    /**
     * Format group field value
     */
    formatGroupValue(groupData) {
        if (typeof groupData !== 'object' || Object.keys(groupData).length === 0) {
            return '<em>No data</em>';
        }
        let html = '<div class="group-summary"><ul>';
        for (const [key, value] of Object.entries(groupData)) {
            if (!this.isEmptyValue(value)) {
                const label = key.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase());
                // Handle nested groups
                if (typeof value === 'object' && !Array.isArray(value)) {
                    html += `<li><strong>${label}:</strong> ${this.formatGroupValue(value)}</li>`;
                } else {
                    html += `<li><strong>${label}:</strong> ${value}</li>`;
                }
            }
        }
        html += '</ul></div>';
        return html;
    }
    /**
     * Format location field value
     */
    formatLocationValue(location) {
        if (typeof location !== 'object') return location;
        const parts = [];
        const fields = ['address', 'city', 'state', 'zip', 'country'];
        fields.forEach(field => {
            if (location[field]) {
                parts.push(location[field]);
            }
        });
        return parts.join(', ');
    }
    /**
     * Format file/image value
     */
    formatFileValue(value) {
        if (typeof value === 'string') {
            // Single file - could be URL or filename
            if (value.startsWith('http')) {
                return `<a href="${value}" target="_blank">View file</a>`;
            }
            return value;
        }
        if (Array.isArray(value)) {
            return value.map(file => {
                if (typeof file === 'string') {
                    return `<a href="${file}" target="_blank">View file</a>`;
                }
                return file.name || 'File';
            }).join(', ');
        }
        return 'File uploaded';
    }
    /**
     * Format number with proper locale formatting
     */
    formatNumber(value) {
        const num = parseFloat(value);
        if (isNaN(num)) return value;
        // Check if it's likely currency (has 2 decimal places)
        if (value.toString().includes('.') && value.toString().split('.')[1].length === 2) {
            return new Intl.NumberFormat('en-CA', {
                style: 'currency',
                currency: 'USD'
            }).format(num);
        }
        return new Intl.NumberFormat('en-CA').format(num);
    }
    /**
     * Format array values (checkboxes, multi-select)
     */
    /**
     * Format array values (checkboxes, multi-select)
     */
    formatArrayValue(arr, form = null, fieldInfo = null) {
        if (arr.length === 0) return '<em>None selected</em>';
        // If we have field info, try to get proper labels
        if (form && fieldInfo && fieldInfo.input) {
            const labeled = arr.map(val => {
                return this.getSelectLabel(val, form, fieldInfo.type);
            });
            return '<ul><li>' + labeled.join('</li><li>') + '</li></ul>';
        }
        // Fallback to raw values
        return '<ul><li>' + arr.join('</li><li>') + '</li></ul>';
    }
    /**
     * Get label for select/radio option
     */
    /**
     * Get label for select/radio/checkbox option
     */
    getSelectLabel(value, form, type) {
        if (type === 'select') {
            const option = form.querySelector(`option[value="${value}"]`);
            return option?.textContent || value;
        }
        if (type === 'radio') {
            const radio = form.querySelector(`input[type="radio"][value="${value}"]`);
            const label = radio?.nextElementSibling;
            return label?.textContent || value;
        }
        if (type === 'checkbox') {
            // Try to find the checkbox with this value
            const checkbox = form.querySelector(`input[type="checkbox"][value="${value}"]`);
            if (checkbox) {
                // Look for associated label
                const label = form.querySelector(`label[for="${checkbox.id}"]`);
                if (label) {
                    return label.textContent.trim();
                }
                // Try next sibling
                const nextLabel = checkbox.nextElementSibling;
                if (nextLabel?.tagName === 'LABEL') {
                    return nextLabel.textContent.trim();
                }
            }
        }
        return value;
    }
    /**
     * Format textarea value - handles both rich text and plain text
     */
    formatTextareaValue(value, type) {
        if (!value) return '<em>Empty</em>';
        // If it's explicitly a wysiwyg type or contains HTML tags, use as-is
        if (type === 'wysiwyg' || this.containsHtml(value)) {
            // Quill content already has proper HTML structure
            return value;
        }
        // Plain textarea - preserve formatting
        return this.formatPlainText(value);
    }
    /**
     * Check if string contains HTML content (more reliable than just checking for '<')
     */
    containsHtml(str) {
        // Check for common HTML tags that Quill uses
        const htmlPattern = /<(p|strong|em|u|s|ol|ul|li|blockquote|h[1-6]|a|br|span)\b[^>]*>/i;
        return htmlPattern.test(str);
    }
    /**
     * Format plain text content - preserves whitespace and converts newlines
     */
    formatPlainText(text) {
        if (!text) return '';
        // First, escape any HTML entities that might be in the text
        text = text
            .replace(/&/g, '&amp;')
            .replace(/</g, '&lt;')
            .replace(/>/g, '&gt;');
        // Convert double newlines to paragraphs for better readability
        const paragraphs = text.split(/\n\n+/);
        if (paragraphs.length > 1) {
            // Multiple paragraphs
            return paragraphs
                .map(p => `<p>${p.replace(/\n/g, '<br>')}</p>`)
                .join('');
        }
        // Single paragraph - just convert newlines to breaks
        return text.replace(/\n/g, '<br>');
    }
    /**
     * Convert newlines to <br> tags (kept for backwards compatibility)
     */
    nl2br(text) {
        return this.formatPlainText(text);
    }
    /**
     * Event system
     */
@@ -1010,11 +2084,17 @@
    destroy() {
        // Remove global handlers
        if (this.globalHandlersAdded) {
            document.removeEventListener('submit', this.submitHandler);
            document.removeEventListener('change', this.changeHandler);
            document.removeEventListener('focus', this.focusHandler, true);
            document.removeEventListener('blur', this.blurHandler, true);
            document.removeEventListener('input', this.inputHandler, true);
        }
        this.forms.forEach((formConfig) => {
            let element = formConfig.element;
            if (element) {
                element.removeEventListener('submit', this.submitHandler);
            }
        });
        // Clear maps
        this.specialFields.clear();
assets/js/dash/SquareCheckout.js
@@ -1,50 +1,50 @@
class SquareCheckout {
    constructor(config = {}) {
        this.checkout = document.querySelector('aside#cart');
        if (!this.checkout) {
            return;
        }
        this.config = Object.assign({
            application_id: squareConfig.application_id,
            location_id: squareConfig.location_id,
            api_url: squareConfig.api_url,
            nonce: squareConfig.nonce,
            currency: squareConfig.currency || 'CAD'
        }, config);
        this.stepMultiplier = 1;
        this.cache = new window.jvbCache('cart', {TTL: 8.64e+7});
        this.a11y = window.jvbA11y;
        this.initCart();
        this.config = {
            ...squareConfig,
            ...config
        };
        this.payments = null;
        this.card = null;
        this.isInitialized = false;
        this.cartItems      = new Map();
        this.checkout       = document.querySelector('aside#cart');
        this.isOpen = this.config.isOpen !== '1' || false;
        //User Context
        this.isLoggedIn     = this.config.is_logged_in|| false;
        this.userEmail      = this.config.user_email || '';
        this.savedCards     = [];
        this.selectedCardId = null;
        this.cartId = null;
        this.clickHandler = this.handleClick.bind(this);
        this.keyHandler = this.handleEscape.bind(this);
        this.changeHandler = this.handleChange.bind(this);
        this.cache = new window.jvbCache('cart', {TTL: 8.64e+7});
        this.a11y = window.jvbA11y;
        this.initCart();
        if (this.checkout) {
        this.initElements();
        this.bindEvents();
            this.init();
            this.initListeners();
            if (this.isLoggedIn) {
                this.loadSavedCards();
            }
        }
        this.stepMultiplier = 1;
        //Handle the opening and closing of the checkout window
        this.popup = new window.jvbPopup({
            popup: this.checkout,
            toggle: this.toggle,
            name: 'Cart',
            onOpen: this.maybeAddEmptyState.bind(this),
        });
        this.init();
        this.toggle.hidden = false;
        console.log(this.popup);
        // this.toggle.hidden = false;
    }
    async initCart() {
@@ -54,6 +54,7 @@
            this.notifyRestoredCart();
        }
    }
    handleClick(e) {
        if (window.targetCheck(e, 'button') && window.targetCheck(e, 'div.quantity')) {
            let quantity = window.targetCheck(e, 'div.quantity');
@@ -347,7 +348,7 @@
    initElements() {
        this.toggle = document.querySelector('.toggle-cart');
        if (squareConfig.isOpen !== '1') {
        if (!this.isOpen) {
            this.toggle.disabled = true;
            this.toggle.title = 'Currently closed for online ordering';
        }
@@ -366,9 +367,12 @@
        console.log('Initialized Checkout');
    }
    bindEvents() {
        this.checkoutForm.addEventListener('submit', (e) => this.handleFormSubmit(e));
    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);
    }
@@ -382,12 +386,18 @@
                style: this.getCardStyle()
            });
            await this.card.attach('#square-card-container');
            this.card.addEventListener('cardBrandChanged', (event) => {
                console.log('Card brand:', event.detail.cardBrand);
                // You could show card brand icon here
            });
        } catch (error) {
            console.error('Failed to initialize card:', error);
            throw error;
        }
    }
    getCardStyle() {
        return {
            input: {
@@ -401,7 +411,10 @@
                borderRadius: '4px'
            },
            '.input-container.is-focus': {
                borderColor: '#007cba'
                borderColor: '#006AFF',
                borderWidth: '2px',
                outline: '2px solid #006AFF',
                outlineOffset: '2px'
            },
            '.input-container.is-error': {
                borderColor: '#d63638'
@@ -410,7 +423,7 @@
    }
    async handleFormSubmit(event) {
        if (squareConfig.isOpen !== '1') {
        if (!this.isOpen) {
            return;
        }
        event.preventDefault();
@@ -435,23 +448,23 @@
    }
    extractOrderData(form) {
        // Convert cart items Map to array with proper structure
        const items = Array.from(this.cartItems.values()).map(item => ({
            post_id: item.post_id,
            quantity: item.quantity,
            catalog_object_id: item.square_catalog_id,
            quantity: String(item.quantity),
            price: item.price,
            name: item.name
            note: item.note || ''
        }));
        const total = items.reduce((sum, item) =>
            sum + (item.price * item.quantity), 0
        );
        // Pre-fill customer info if logged in
        return {
            total: total * 100, // Square expects amount in cents
            total: Math.round(total * 100),
            items: items,
            customer: {
                email: form.querySelector('[name="email"]')?.value || '',
                email: this.isLoggedIn ? this.userEmail : (form.querySelector('[name="email"]')?.value || ''),
                name: form.querySelector('[name="name"]')?.value || '',
                phone: form.querySelector('[name="phone"]')?.value || ''
            },
@@ -462,52 +475,101 @@
    async processPayment(orderData) {
        try {
            const result = await this.card.tokenize();
            let sourceToken = null;
            if (result.status === 'OK') {
                return await this.submitToServer(result.token, orderData);
            // Check if using saved card or new card
            if (this.selectedCardId) {
                // Use saved card
                sourceToken = this.selectedCardId;
            } else {
                throw new Error('Card tokenization failed: ' + (result.errors?.join(', ') || 'Unknown error'));
                // Tokenize new card
                const tokenResult = await this.card.tokenize({
                    verificationDetails: {
                        amount: String(orderData.total),
                        currencyCode: this.config.currency || 'CAD',
                        intent: 'CHARGE',
                        customerInitiated: true,
                        billingContact: {
                            givenName: orderData.customer.name.split(' ')[0],
                            familyName: orderData.customer.name.split(' ').slice(1).join(' '),
                            email: orderData.customer.email,
                            phone: orderData.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' // or 'US'
            }
                    }
                });
                if (tokenResult.status !== 'OK') {
                    const errors = tokenResult.errors?.map(e => e.message).join(', ') || 'Unknown error';
                    throw new Error(`Card tokenization failed: ${errors}`);
                }
                sourceToken = tokenResult.token;
                if (tokenResult.details?.userChallenged) {
                    console.log('3D Secure verification completed');
                }
            }
            // Send to server
            return await this.submitToServer(sourceToken, orderData, !!this.selectedCardId);
        } catch (error) {
            console.error('Payment processing failed:', error);
            throw error;
        }
    }
    async submitToServer(token, orderData) {
        if (squareConfig.isOpen !== '1') {
            return;
    async submitToServer(sourceToken, orderData, isSavedCard = false) {
        if (!this.isOpen) {
            throw new Error('Store is currently closed');
        }
        // Square Web Payments SDK handles EVERYTHING
        // We just need to track the order for status updates
        const response = await fetch(this.config.api_url + 'save-order', {
        const response = await fetch(this.config.api_url + 'process-payment', {
            method: 'POST',
            headers: {
                'Content-Type': 'application/json',
                'X-WP-Nonce': this.config.nonce
            },
            body: JSON.stringify({
                order_id: token.orderId,  // From Square SDK response
                payment_id: token.paymentId,  // From Square SDK response
                customer: orderData.customer,
                source_id: sourceToken,
                is_saved_card: isSavedCard,
                cart_id: this.getCartId(),
                amount: orderData.total,
                items: orderData.items,
                action: 'jvb_integration_action',
                service: 'square',
                integration_action: 'save_order'
                customer: {
                    email: this.isLoggedIn ? this.userEmail : orderData.customer.email,
                    name: orderData.customer.name,
                    phone: orderData.customer.phone
                },
                note: orderData.note,
                pickup_time: orderData.pickup_time
            })
        });
        const result = await response.json();
        if (!response.ok) {
            throw new Error(result.message || 'Failed to save order');
            throw new Error(result.message || 'Payment processing failed');
        }
        this.clearCart();
        return result;
    }
    getCartId() {
        // Generate once per cart session
        if (!this.cartId) {
            this.cartId = crypto.randomUUID();
            this.cache.set('cart_id', this.cartId);
        }
        return this.cartId;
    }
    trackOrder(orderNum) {
        this.orderId = orderNum;
        this.scheduleOrderCheck();
@@ -545,43 +607,75 @@
    /**************************************************************
     * Customer Data
    **************************************************************/
    async loadCustomerProfile(email) {
        const response = await fetch('/wp-json/jvb/v1/square/customer', {
            method: 'POST',
    /**
     * Load saved cards for logged-in user
     */
    async loadSavedCards() {
        try {
            const response = await fetch(this.config.api_url + 'saved-cards', {
                method: 'GET',
            headers: {
                'Content-Type': 'application/json',
                'X-WP-Nonce': this.config.nonce
            },
            body: JSON.stringify({ email })
                }
        });
        const profile = await response.json();
            const result = await response.json();
        if (profile) {
            this.displaySavedCards(profile.cards);
            this.fillCustomerInfo(profile.customer);
            if (result.success && result.cards) {
                this.savedCards = result.cards;
                this.renderSavedCards();
            }
        } catch (error) {
            console.error('Failed to load saved cards:', error);
        }
    }
    displaySavedCards(cards) {
    /**
     * Render saved cards in the checkout form
     */
    renderSavedCards() {
        const container = document.getElementById('saved-cards');
        if (!cards.length) return;
        if (!container || this.savedCards.length === 0) {
            return;
        }
        container.innerHTML = `
            <h3>Saved Payment Methods</h3>
            ${cards.map(card => `
                <label>
                    <input type="radio" name="payment_method" value="${card.id}">
                    â€¢â€¢â€¢â€¢ ${card.last_4} (${card.card_brand})
        const html = `
            <div class="saved-cards-section">
                <h4>Saved Payment Methods</h4>
                ${this.savedCards.map(card => `
                    <label class="saved-card">
                        <input type="radio" name="payment-method" value="saved" data-card-id="${card.id}">
                        <span class="card-info">
                            <strong>${card.card_brand}</strong> ending in ${card.last_4}
                            <small>Exp: ${card.exp_month}/${card.exp_year}</small>
                        </span>
                </label>
            `).join('')}
            <label>
                <input type="radio" name="payment_method" value="new" checked>
                Use new card
                <label class="saved-card">
                    <input type="radio" name="payment-method" value="new" checked>
                    <span>Use a new card</span>
            </label>
            </div>
        `;
        container.innerHTML = html;
        // Listen for payment method selection
        container.querySelectorAll('input[name="payment-method"]').forEach(radio => {
            radio.addEventListener('change', (e) => {
                const useNewCard = e.target.value === 'new';
                const cardContainer = document.getElementById('square-card-container');
                if (cardContainer) {
                    cardContainer.style.display = useNewCard ? 'block' : 'none';
    }
                this.selectedCardId = useNewCard ? null : e.target.dataset.cardId;
            });
        });
    }
    handleSuccess(result, form) {
        // Trigger success event
        document.dispatchEvent(new CustomEvent('squareCheckoutSuccess', {
assets/js/dash/UtilityFunctions.js
@@ -420,7 +420,22 @@
window.showToast = function(message, type='success', actions={}){
    window.jvbNotifications.showToast(message, type, actions);
}
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(date) {
    if (!(date instanceof Date && !isNaN(date))) {
        date = new Date(date);
    }
    return window.dateFormatter.format(date);
}
/**
 * Outputs the set text as if a typewriter were writing it
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.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){if("item-saved"===e)"autosave"===t.item.status&&this.showFormStatus(t.item.formId,"autosave")}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 s=document.createElement("div");s.className="pending-changes-notification",s.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(s,t.firstChild),s.querySelector(".restore-changes").addEventListener("click",(()=>{this.restorePendingForm(e),s.remove()})),s.querySelector(".discard-changes").addEventListener("click",(()=>{this.discardPendingForm(e.formId),s.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("submit",this.submitHandler),document.addEventListener("click",this.clickHandler),document.addEventListener("change",this.changeHandler),document.addEventListener("focus",this.focusHandler,!0),document.addEventListener("blur",this.blurHandler,!0),this.globalHandlersAdded=!0)}registerForm(e,t={}){const s=e.dataset.formId||`form_${Date.now()}`;e.dataset.formId=s;const a={element:e,id:s,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,a),this.forms.set(s,a),this.store&&a.options.cache){const e=this.store.getForm(s);e&&e.formData&&this.showPendingNotification(e)}return a}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")&&new window.jvbTabs(e),window.jvbSelector&&window.jvbSelector.scanExistingFields()}initQuillEditors(e){window.jvbQuill(e)}initRepeaterFields(e,t){e.querySelectorAll(".repeater").forEach((e=>{const s=e.querySelector(".add-repeater-row"),a=e.querySelector(".repeater-items"),i=e.querySelector("template");s&&i&&a&&(window.Sortable&&new Sortable(a,{handle:".repeater-row-header",animation:150,onEnd:()=>{this.updateRepeaterOrder(e,t)}}),s.addEventListener("click",(()=>{this.addRepeaterRow(e,t)})),a.addEventListener("click",(e=>{e.target.closest(".remove-row")&&this.removeRepeaterRow(e.target.closest(".repeater-row"),t)})))}))}addRepeaterRow(e,t){const s=e.querySelector(".repeater-items"),a=e.querySelector("template"),i=s.children.length,r=e.dataset.field,n=a.content.cloneNode(!0).firstElementChild;n.dataset.index=i,n.querySelectorAll("input, select, textarea").forEach((e=>{const t=e.name;e.name=`${r}:${i}:${t}`,e.id=`${r}-${i}-${t}`;const s=e.nextElementSibling;s&&"LABEL"===s.tagName&&(s.htmlFor=e.id)})),s.appendChild(n),t&&t.options.autoSave&&this.scheduleSave(t,{type:"repeater",action:"add",fieldName:r,delay:this.repeaterDelays.add}),window.jvbA11y&&window.jvbA11y.announce("Row added")}removeRepeaterRow(e,t){const s=e.closest(".repeater"),a=s.dataset.field;e.remove(),this.updateRepeaterOrder(s,t),t&&t.options.autoSave&&this.scheduleSave(t,{type:"repeater",action:"remove",fieldName:a,delay:this.repeaterDelays.remove}),window.jvbA11y&&window.jvbA11y.announce("Row removed")}updateRepeaterOrder(e,t){const s=e.querySelector(".repeater-items"),a=e.dataset.field;Array.from(s.children).forEach(((e,t)=>{e.dataset.index=t,e.querySelectorAll("input, select, textarea").forEach((e=>{const s=e.name.split(":");if(3===s.length){const i=s[2];e.name=`${a}:${t}:${i}`,e.id=`${a}-${t}-${i}`;const r=e.nextElementSibling;r&&"LABEL"===r.tagName&&(r.htmlFor=e.id)}}))})),t&&t.options.autoSave&&this.scheduleSave(t,{type:"repeater",action:"reorder",fieldName:a,delay:this.repeaterDelays.reorder})}initConditionalFields(e,t){e.querySelectorAll("[data-depends-on]").forEach((s=>{const a=s.dataset.dependsOn,i=s.dataset.dependsValue,r=s.dataset.dependsOperator||"==";t.dependencies.has(a)||t.dependencies.set(a,[]),t.dependencies.get(a).push({field:s,requiredValue:i,operator:r}),this.checkFieldDependency(e,s,a,i,r)}))}checkFieldDependency(e,t,s,a,i){const r=e.querySelector(`[name="${s}"]`);if(!r)return;const n=this.getFieldValue(r),o=this.evaluateCondition(n,a,i);this.toggleFieldVisibility(t,o)}evaluateCondition(e,t,s){const a=String(e||""),i=String(t||"");switch(s){case"==":default:return a==i;case"!=":return a!=i;case">":return parseFloat(a)>parseFloat(i);case"<":return parseFloat(a)<parseFloat(i);case">=":return parseFloat(a)>=parseFloat(i);case"<=":return parseFloat(a)<=parseFloat(i);case"contains":return a.includes(i);case"empty":return""===a;case"not_empty":return""!==a}}toggleFieldVisibility(e,t){const s=e.closest(".field, fieldset");s&&(s.hidden=!t,s.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),s=e.closest(".field");let a=s?.querySelector(".char-count");!a&&s&&(a=document.createElement("div"),a.className="char-count",a.innerHTML=`<span class="current">0</span> / <span class="limit">${t}</span>`,s.appendChild(a));const i=()=>{const s=e.value.length;a&&(a.querySelector(".current").textContent=s,a.classList.toggle("exceeded",s>t)),s>t&&(e.value=e.value.substring(0,t),a&&(a.querySelector(".current").textContent=t))};e.addEventListener("input",i),i()}))}initImageUploadFields(e){window.jvbUploads.scanFields(e)}handleSubmit(e){if(this.subscribers.size>0){const t=e.target;if(!t.dataset.formId)return;this.store.delete(t.dataset.formId),e.preventDefault();const s=this.forms.get(t.dataset.formId);if(!s)return;const a=this.collectFormData(t);e.preventDefault(),this.notify("form-submit",{formId:s.id,data:a,config:s})}}handleClick(e){if(window.targetCheck(e,"div.quantity")){let t=window.targetCheck(e,"div.quantity");this.handleNumberClick(e,t.querySelector("input"))}}handleNumberClick(e,t){let s=0;if(e.target.closest(".increase")?s+=1:e.target.closest(".decrease")&&(s-=1),0!==s){let a=parseFloat(t.step);a=Math.max(a,1),e.ctrlKey&&e.shiftKey?a*=50:e.ctrlKey?a*=5:e.shiftKey&&(a*=10);let i=""===t.value?0:parseFloat(t.value);t.value=i+a*s,this.handleNumberLimits(t)}}handleNumberLimits(e){let[t,s,a,i]=[e.min,e.max,e.closest(".quantity")?.querySelector(".increase"),e.closest(".quantity")?.querySelector(".decrease")],r=parseFloat(e.value);r<t?(e.value=t,i.disabled=!0):r>s?(e.value=s,a.disabled=!1):a.disabled?a.disabled=!1:i.disabled&&(i.disabled=!1)}handleChange(e){if(this.subscribers.size>0){const t=e.target,s=t.form||t.closest("form");if(!s)return;const a=this.forms?.get(s.dataset.formId);if(!a)return;const i=a.dependencies.get(t.name);if(i&&i.forEach((e=>{this.checkFieldDependency(s,e.field,t.name,e.requiredValue,e.operator)})),a.options.autoSave&&!s.dataset.noautosave){const e=this.getDelayForField(t);this.scheduleSave(a,e)}}}handleFocus(e){const t=e.target;t.matches("input, textarea, select")&&(this.currentFocus=t)}handleBlur(e){const t=e.target,s=t.form||t.closest("form");if(!s)return;const a=this.forms?.get(s.dataset.formId);a&&a.options.autoSave&&!s.dataset.noautosave&&this.scheduleSave(a,{type:"blur",fieldName:t.name,delay:1500})}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.handleInput,{passive:!0});const s=`autosave_${e.id}`;this.debouncer.schedule(s,(()=>this.autosave(e)),t)}handleInput(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);await this.store.save({formId:e.id,data:t,status:"draft",timestamp:Date.now()}),this.showFormStatus(e.id,"saved");const s=this.getChangedFields(e.data,t);if(0!==Object.keys(s).length){e.data=t,this.forms.set(e.id,e),document.removeEventListener("input",this.handleInput);for(let[e,a]of Object.entries(t))"object"==typeof a&&(s[e]=a);this.notify("form-autosave",{formId:e.id,changes:s,fullData:t,config:e})}}hasUnsavedChanges(e){const t=this.forms.get(e);if(!t)return!1;if(t.operations?.size>0)return!0;const s=this.collectFormData(t.element),a=this.getChangedFields(t.lastSnapshot,s);return Object.keys(a).length>0}showFormStatus(e,t){const s=e.querySelector(".form-status");s&&s.remove();const a=document.createElement("div");a.className=`form-status status-${t}`;a.textContent={saving:"Saving changes...",saved:"Changes saved",error:"Failed to save changes",offline:"Changes will be saved when online"}[t]||t,e.insertBefore(a,e.firstChild),"saved"===t&&setTimeout((()=>a.remove()),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 s={};const a={},i={};for(let[r,n]of t.entries()){if(this.ignore.includes(r)||r.endsWith("_temp"))continue;this.getFieldProcessor(r)(r,n,s,a,i,e)}return window.isEmptyObject(i)?this.mergeRepeaterData(s,a):(s=this.mergeRepeaterData(s,a),this.mergePostData(s,i))}getFieldProcessor(e){return e.includes("|")?this.processTableField:e.includes("::")?this.processGroupField:e.includes(":")?this.processRepeaterField:e.includes("[")?this.processLocationField:this.processRegularField}mergeRepeaterData(e,t){return Object.keys(t).forEach((s=>{const a={};Object.keys(t[s]).forEach((e=>{const i=t[s][e];Object.keys(i).length>0&&(a[e]=i)})),e[s]=Object.values(a)})),e}mergePostData(e,t){for(let[t,s]in Object.entries(s))e[t]=s;return e}processTableField(e,t,s,a,i,r){let[n,o]=e.split("|");!n in i&&(i[n]={});this.getFieldProcessor(o)(o,t,i,a,i,r)}processRepeaterField(e,t,s,a,i,r){let[n,o,d]=e.split(":");const l=d.endsWith("[]");d=d.replace("[]",""),a[n]||(a[n]={}),a[n][o]||(a[n][o]={}),l||a[n][o][d]?(a[n][o][d]?Array.isArray(a[n][o][d])||(a[n][o][d]=[a[n][o][d]]):a[n][o][d]=[],a[n][o][d].push(t)):a[n][o][d]=t}processGroupField(e,t,s,a,i,r){const n=e.split("::"),o=n[0];s[o]||(s[o]={});let d=s[o];for(let e=1;e<n.length-1;e++){const t=n[e];d[t]||(d[t]={}),d=d[t]}const l=n[n.length-1];void 0!==d[l]?(Array.isArray(d[l])||(d[l]=[d[l]]),d[l].push(t)):d[l]=t}processLocationField(e,t,s,a,i,r){let[n,o]=e.split("[");o=o.replace("]",""),Object.hasOwn(s,n)||(s[n]={},Object.hasOwn(s,"sendAll")?s.sendAll.includes(n)||s.sendAll.push(n):s.sendAll=[n]),s[n][o]=t}processRegularField(e,t,s,a,i,r){s[e]?(Array.isArray(s[e])||(s[e]=[s[e]]),s[e].push(t)):s[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)||{}}subscribe(e){return this.subscribers.add(e),()=>this.subscribers.delete(e)}notify(e,t){this.subscribers.forEach((s=>s(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("submit",this.submitHandler),document.removeEventListener("change",this.changeHandler),document.removeEventListener("focus",this.focusHandler,!0),document.removeEventListener("blur",this.blurHandler,!0)),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.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}))})();
assets/js/min/square.min.js
@@ -1 +1 @@
(()=>{class t{constructor(t={}){this.checkout=document.querySelector("aside#cart"),this.checkout&&(this.config=Object.assign({application_id:squareConfig.application_id,location_id:squareConfig.location_id,api_url:squareConfig.api_url,nonce:squareConfig.nonce,currency:squareConfig.currency||"CAD"},t),this.stepMultiplier=1,this.cache=new window.jvbCache("cart",{TTL:864e5}),this.a11y=window.jvbA11y,this.initCart(),this.payments=null,this.card=null,this.isInitialized=!1,this.clickHandler=this.handleClick.bind(this),this.keyHandler=this.handleEscape.bind(this),this.changeHandler=this.handleChange.bind(this),this.initElements(),this.bindEvents(),this.popup=new window.jvbPopup({popup:this.checkout,toggle:this.toggle,name:"Cart",onOpen:this.maybeAddEmptyState.bind(this)}),this.init(),this.toggle.hidden=!1)}async initCart(){this.cartItems=await this.cache.get("cart")??new Map,console.log("cart",this.cartItems),this.cartItems.size>0&&this.notifyRestoredCart()}handleClick(t){if(window.targetCheck(t,"button")&&window.targetCheck(t,"div.quantity")){let e=window.targetCheck(t,"div.quantity");this.handleNumberClick(t,e)}else if(window.targetCheck(t,"[data-add-to-cart]")){let e=window.targetCheck(t,"[data-add-to-cart]");this.handleAddToCart(e)}else if(window.targetCheck(t,"[data-remove-from-cart]")){let e=window.targetCheck(t,"[data-remove-from-cart]");this.handleRemoveFromCart(e)}else window.targetCheck(t,"[data-clear-cart]")&&this.clearCart()}handleChange(t,e){console.log("Checkout change");let a=window.targetCheck(t,".quantity-input");if(a){let e=t.target.closest(".quantity"),i=a.value;if(window.targetCheck(t,".cart-items")){let t=document.querySelector(`.menu-section [data-id="${e.dataset.id}"] input`);t&&(t.value=a.value)}i>0?this.handleAddToCart(e):this.handleRemoveFromCart(e)}}handleNumberClick(t,e){console.log(e),t.preventDefault();let a=0;if(t.target.closest(".increase")?a+=1:t.target.closest(".decrease")&&(a-=1),0!==a){let[t,i]=[parseInt(e.dataset.step),e.querySelector("input")],r=""===i.value?0:parseInt(i.value);i.value=r+t*a*this.stepMultiplier,i.dispatchEvent(new Event("change",{bubbles:!0})),this.handleNumberLimits(e)}}handleNumberLimits(t){let[e,a,i,r,s]=[t.dataset.min,t.dataset.max,t.querySelector("input"),t.querySelector(".increase"),t.querySelector(".decrease")],o=parseInt(i.value);o<e?(i.value=e,s.disabled=!0):o>a?(i.value=a,r.disabled=!1):r.disabled?r.disabled=!1:s.disabled&&(s.disabled=!1)}maybeAddEmptyState(){let t=this.itemsList.querySelector(".empty");if(t&&t.remove(),0===this.cartItems.size){this.checkoutPanel.disabled=!0,this.checkoutPanel.title="Add some things to your cart first!";let t=window.getTemplate("emptyCart");this.itemsList.append(t),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(t){"Escape"===t.key?this.stepMultiplier=1:t.ctrlKey&&t.shiftKey?this.stepMultiplier=Math.max(100*parseInt(this.stepMultiplier),1e3):t.shiftKey&&(this.stepMultiplier=Math.max(10*parseInt(this.stepMultiplier),1e3))}handleAddToCart(t){let e=t.dataset.id;this.createItemElement(t);let a=parseFloat(t.dataset.price),i=parseInt(t.querySelector(".quantity-input")?.value)??1,r=parseFloat(a*i);this.cartItems.set(e,{post_id:e,name:t.dataset.name,price:a,quantity:i,total:r,square_catalog_id:t.dataset.squareCatalogId}),this.saveCart()}notifyRestoredCart(){let t=window.getTemplate("restoredCart");this.checkout.querySelector(".tab-content[data-tab=cartItems]").insertBefore(t,this.itemsList),this.cartItems.forEach((t=>{console.log(t);let e=window.getTemplate("cartItem"),a=e.querySelector(".quantity"),i=t.price,r=t.quantity;[a.dataset.id,e.querySelector("label").textContent,e.querySelector(".price").textContent,a.dataset.price,a.dataset.squareCatalogId,e.querySelector('[name="quantity"]').value,e.querySelector(".total").textContent]=[t.post_id,t.name,window.formatPrice(i),i,t.square_catalog_id,r,window.formatPrice(r*i)],this.table.append(e)})),this.updateTotal()}handleRemoveFromCart(t){if(confirm("This will remove this item from the cart. Continue?")){t.querySelector("[data-id]")||(t=t.closest(".item")?.querySelector(".quantity.field"));let e=t.dataset.id;this.cartItems.delete(e),this.table.querySelector(`[data-id="${e}"]`)?.closest("tr").remove();let a=document.querySelector(`[data-id="${e}"] 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 t=0;this.cartItems.forEach((e=>{console.log(e),t+=e.total}));let e=.05*t;t=window.formatPrice(t+e),e=window.formatPrice(e),window.eraseText(this.totalTax),window.eraseText(this.grandTotal),window.typeText(this.totalTax,e),window.typeText(this.grandTotal,t),this.totalTax.classList.remove("typeText")}createItemElement(t){let e=this.itemsList.querySelector(`[data-id="${t.dataset.id}"]`),a=!1,i=t.dataset.price,r=t.querySelector('[name="quantity"]')?.value??1;if(e)e=e.closest("tr");else{a=!0,e=window.getTemplate("cartItem");let r=e.querySelector(".quantity");[r.dataset.id,e.querySelector("label").textContent,e.querySelector(".price").textContent,r.dataset.price,r.dataset.squareCatalogId]=[t.dataset.id,t.dataset.name,window.formatPrice(i),i,t.dataset.squareCatalogId]}[e.querySelector('[name="quantity"]').value,e.querySelector(".total").textContent]=[r,window.formatPrice(r*i)],a&&(e.classList.add("adding"),this.table.append(e),setTimeout((()=>{e.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(t){console.error("Failed to initialize Square payments:",t),this.handleError(t)}else console.error("Square Web Payments SDK not loaded")}initElements(){this.toggle=document.querySelector(".toggle-cart"),"1"!==squareConfig.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")}bindEvents(){this.checkoutForm.addEventListener("submit",(t=>this.handleFormSubmit(t))),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")}catch(t){throw console.error("Failed to initialize card:",t),t}}getCardStyle(){return{input:{fontSize:"16px",fontFamily:"inherit",color:"#333",backgroundColor:"#fff"},".input-container":{borderColor:"#ccc",borderRadius:"4px"},".input-container.is-focus":{borderColor:"#007cba"},".input-container.is-error":{borderColor:"#d63638"}}}async handleFormSubmit(t){if("1"!==squareConfig.isOpen)return;if(t.preventDefault(),!this.isInitialized)return void this.handleError("Checkout not initialized");const e=t.target,a=this.extractOrderData(e);try{window.jvbLoading.showLoading("Processing payment...");const t=await this.processPayment(a);this.handleSuccess(t,e)}catch(t){this.handleError(t)}finally{window.jvbLoading.hideLoading()}}extractOrderData(t){const e=Array.from(this.cartItems.values()).map((t=>({post_id:t.post_id,quantity:t.quantity,price:t.price,name:t.name})));return{total:100*e.reduce(((t,e)=>t+e.price*e.quantity),0),items:e,customer:{email:t.querySelector('[name="email"]')?.value||"",name:t.querySelector('[name="name"]')?.value||"",phone:t.querySelector('[name="phone"]')?.value||""},note:t.querySelector('[name="special_instructions"]')?.value||"",pickup_time:t.querySelector('[name="pickup_time"]')?.value||""}}async processPayment(t){try{const e=await this.card.tokenize();if("OK"===e.status)return await this.submitToServer(e.token,t);throw new Error("Card tokenization failed: "+(e.errors?.join(", ")||"Unknown error"))}catch(t){throw console.error("Payment processing failed:",t),t}}async submitToServer(t,e){if("1"!==squareConfig.isOpen)return;const a=await fetch(this.config.api_url+"save-order",{method:"POST",headers:{"Content-Type":"application/json","X-WP-Nonce":this.config.nonce},body:JSON.stringify({order_id:t.orderId,payment_id:t.paymentId,customer:e.customer,items:e.items,action:"jvb_integration_action",service:"square",integration_action:"save_order"})}),i=await a.json();if(!a.ok)throw new Error(i.message||"Failed to save order");return i}trackOrder(t){this.orderId=t,this.scheduleOrderCheck(),this.checkout.querySelector("button[data-tab=order]").hidden=!1}scheduleOrderCheck(){window.debouncer.schedule("order",(()=>{this.checkOrderStatus()}),3e4)}async checkOrderStatus(){const t=await fetch(`/wp-json/jvb/v1/square/order-status/${this.orderId}`),e=await t.json();"ready"!==e.status&&this.scheduleOrderCheck(),this.updateOrderStatus(e)}updateOrderStatus(t){this.checkout.querySelectorAll(".status-item").forEach((e=>{e.dataset.status===t.status&&e.classList.add("active")})),this.checkout.querySelector("#eta").textContent=t.eta||"In progress"}async loadCustomerProfile(t){const e=await fetch("/wp-json/jvb/v1/square/customer",{method:"POST",headers:{"Content-Type":"application/json","X-WP-Nonce":this.config.nonce},body:JSON.stringify({email:t})}),a=await e.json();a&&(this.displaySavedCards(a.cards),this.fillCustomerInfo(a.customer))}displaySavedCards(t){const e=document.getElementById("saved-cards");t.length&&(e.innerHTML=`\n            <h3>Saved Payment Methods</h3>\n            ${t.map((t=>`\n                <label>\n                    <input type="radio" name="payment_method" value="${t.id}">\n                    â€¢â€¢â€¢â€¢ ${t.last_4} (${t.card_brand})\n                </label>\n            `)).join("")}\n            <label>\n                <input type="radio" name="payment_method" value="new" checked>\n                Use new card\n            </label>\n        `)}handleSuccess(t,e){document.dispatchEvent(new CustomEvent("squareCheckoutSuccess",{detail:{result:t,form:e}}));const a=e.dataset.successUrl||`/order-confirmation/?order=${t.wp_order_id}`;window.location.href=a}handleError(t){console.error("Square checkout error:",t),document.dispatchEvent(new CustomEvent("squareCheckoutError",{detail:{error:t}})),window.jvbNotifications?.show?.(t.message||"Payment failed","error")}}document.addEventListener("DOMContentLoaded",(()=>{window.squareCheckout=new t}))})();
(()=>{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}))})();
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),s=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`:s<7?`${s} ${1===s?"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.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 s=!0;return async function(){for(;s;)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(){s=!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 s=this.map(t[o],r);null!==s&&(s.hasOwnProperty("type")&&s.hasOwnProperty("data")?n[o]=s.data:n[o]=s,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),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()}}})();
build/forms/view.asset.php
@@ -1 +1 @@
<?php return array('dependencies' => array(), 'version' => '489e47cea4ff0c15a5f0');
<?php return array('dependencies' => array(), 'version' => '6af2556d0306f0da3d78');
build/forms/view.js
@@ -1 +1 @@
document.addEventListener("DOMContentLoaded",(function(){let e=new window.jvbForm;document.querySelectorAll(".jvb-form-block").forEach((t=>{e.registerForm(t.querySelector("form"))})),e.subscribe(((e,t)=>{"form-submit"===e&&async function(e,t,o){let r={"X-WP-Nonce":jvbSettings.nonce,"Content-Type":"application/json"};t.form_type=o.dataset.formId,t.form_id=o.id;let n=o.closest(".jvb-form-block"),i=window.getTemplate("spinner");o.hidden=!0,n.classList.add("loading"),n.prepend(i);try{const e=await fetch(`${jvbSettings.api}forms`,{method:"POST",headers:r,body:JSON.stringify(t)});if(!e.ok){const t=await e.json().catch((()=>({})));throw new Error(t.message||`Request failed with status ${e.status}`)}!function(e,t){if(!1===e.success){let o=t.querySelector("form");return o.removeAttribute("hidden"),void("required"in e&&e.required.forEach((e=>{let t=o.querySelector(`[name=${e}]`);t.classList.add("required","error"),t.scrollIntoView()})))}let o=window.getTemplate("formSummary");o.querySelector("h2").textContent="Success!",console.log("Form Response: ",e);for(let[t,r]of Object.entries(e)){let e=o.querySelector(`#${t}`),n=e.querySelector("h4");n.innerText.includes("%s")?n.innerHTML=n.replace("%s","<b>"+r+"</b>"):e.querySelector("div").innerHTML=r}t.append(o)}(await e.json(),n)}catch(e){throw e}finally{n.classList.remove("loading")}}(0,t.data,t.config.element)}))}));
(()=>{class e{constructor(){this.controller=new window.jvbForm,document.querySelectorAll(".jvb-form-block form").forEach((e=>{this.controller.registerForm(e)})),this.controller.subscribe(((e,o)=>{"form-submit"===e&&this.handleFormSubmission(o)}))}async handleFormSubmission(e){let[o,t,r]=[e.formId,e.config,e.data],s=t.element,n={"X-WP-Nonce":jvbSettings.nonce,"Content-Type":"application/json"};e.form_type=o,e.form_id=s.id,s.closest(".jvb-form-block"),this.controller.showFormStatus(o,"uploading");try{const e=await fetch(`${jvbSettings.api}forms`,{method:"POST",headers:n,body:JSON.stringify(r)});if(!e.ok){this.controller.showFormStatus(o,"error");const t=await e.json().catch((()=>({})));throw new Error(t.message||`Request failed with status ${e.status}`)}this.controller.showFormStatus(o,"submitted"),this.controller.showSummary(o,".jvb-form-block"),this.controller.store.delete(o)}catch(e){throw e}}updateUI(e,o){let t=window.getTemplate("formSummary");t.querySelector("h2").textContent="Success!",console.log("Form Response: ",e),console.log(t);for(let[o,r]of Object.entries(e)){let e=t.querySelector(`#${o}`);if(e){let o=e.querySelector("h4");o.innerText.includes("%s")?o.innerHTML=o.replace("%s","<b>"+r+"</b>"):e.querySelector("div").innerHTML=r}}o.append(t)}}document.addEventListener("DOMContentLoaded",(function(){new e}))})();
icons.php
@@ -378,6 +378,7 @@
//      $this->cache->invalidateGroup('icons');
        $this->style = JVB_SITE['icons']??'regular';
        $this->used = get_option(BASE.'used_icons', [
            $this->style => [
                'heart',
@@ -475,7 +476,8 @@
                'location',
                'hours',
                'star',
                'star-half'
                'star-half',
                'exclamation-mark'
            ],
            'fill'  => [
                'heart',
inc/blocks/FormBlock.php
@@ -46,7 +46,6 @@
        // Register forms data for the block editor
        add_action('enqueue_block_editor_assets', [$this, 'localizeFormsData']);
        add_action('init', [$this, 'registerBlock']);
    }
@@ -112,7 +111,7 @@
        $cache_key = $this->cache->generateKey($block);
        $cached = $this->cache->get($cache_key);
        $cached = false;
        if ($cached) {
            return $cached;
        }
@@ -285,6 +284,7 @@
        if (!empty($form_config['sections'])) {
            $this->renderSections($type, $meta);
        } else {
            echo jvbFormStatus();
            // Render fields directly
            foreach ($form_config['fields'] as $field_name => $field_config) {
                $meta->render('form', $field_name, $field_config);
@@ -300,8 +300,14 @@
        $form_config = $this->forms[$type];
        $sections = $form_config['sections'];
        $fields = $form_config['fields'];
        $total = count($sections);
        echo '<div class="container">';
        if ($total > 1) {
            echo '<div class="form-progress">';
            jvbRenderProgressBar('<span class="step-text">Step <span class="current">1</span> of ' . $total . '</span>');
            echo '</div>';
        }
        // Render navigation if multiple sections
        if (count($sections) > 1) {
@@ -311,21 +317,24 @@
                $active_class = $i === 1 ? ' active' : '';
                $aria_selected = $i === 1 ? 'true' : 'false';
                echo '<button type="button" class="tab' . $active_class . '" data-tab="' . esc_attr($slug) . '" role="tab" aria-selected="' . $aria_selected . '">';
                echo '<button type="button" class="tab' . $active_class . '" data-tab="' . esc_attr($slug) . '" data-step="'.$i.'" role="tab" aria-selected="' . $aria_selected . '">';
                echo '<span class="step-number">' . $i . '</span>';
                echo '<h2>' . esc_html($section['label'] ?? $section) . '</h2>';
                echo '</button>';
                $i++;
            }
            echo '</nav>';
        }
        echo jvbFormStatus();
        // Render section content
        $i = 0;
        $total = count($sections);
        foreach ($sections as $slug => $section) {
            $active_class = $i === 0 ? ' active' : '';
            $is_last = $i === ($total -1);
            echo '<section id="' . esc_attr($slug) . '" class="tab-content' . $active_class . '" data-tab="' . esc_attr($slug) . '" role="tabpanel">';
            echo '<section id="' . esc_attr($slug) . '" class="tab-content' . $active_class . '" data-tab="' . esc_attr($slug) . '" data-step="'.($i + 1).'" role="tabpanel">';
            if (is_array($section) && !empty($section['title'])) {
                echo '<h2>' . esc_html($section['title']) . '</h2>';
@@ -344,14 +353,30 @@
                $meta->render('form', $field_name, $field_config);
            }
            if ($i === ($total - 1)){
                $submit_text = $form_config['submit'] ?? 'Submit';
                // Submit button
                echo '<div class="form-actions">';
                echo '<button type="submit">' . esc_html($submit_text) . '</button>';
                echo '</div>';
            // Add step navigation buttons
            echo '<div class="step-navigation row btw">';
            if ($i > 0) {
                echo '<button type="button" class="button secondary prev-step" data-action="prev-step">';
                echo jvbIcon('caret-left');
                echo '<span>Previous</span>';
                echo '</button>';
            } else {
                echo '<div></div>'; // Spacer for flex layout
            }
            if ($is_last) {
                $submit_text = $form_config['submit'] ?? 'Submit';
                echo '<button type="submit" class="button primary">' . esc_html($submit_text) . '</button>';
            } else {
                echo '<button type="button" class="button primary next-step" data-action="next-step">';
                echo '<span>Next Step</span>';
                echo jvbIcon('caret-right');
                echo '</button>';
            }
            echo '</div>'; // .step-navigation
            echo '</section>';
            $i++;
        }
@@ -389,19 +414,6 @@
        echo '<input type="hidden" name="timestamp" value="' . time() . '">';
        echo '</form>';
        echo '<template class="formSummary">
        <section class="form-summary">
        <h2></h2>
        <div class="message"><p>You\'ll get an email with this information, too. If you need to make any changes, respond to that email.</p></div>
        <h3>Summary:</h3>';
        foreach($form_config['fields'] as $field=>$config) {
            $label = $config['summaryTitle'] ?? $config['label'];
            echo '<div id="'.$field.'"><h4>'.$label.'</h4><div></div></div>';
        }
        echo '</section></template>';
    }
    /**
inc/helpers/renderFields.php
@@ -324,6 +324,28 @@
add_action('wp_footer', 'jvbOutputImageTemplates');
function jvbOutputImageTemplates() {
    if (wp_script_is('jvb-form')) {
        jvbInlineStyles('forms');
        ?>
        <template class="formSummary">
            <div class="form-summary">
                <h2>Success!</h2>
                <div class="message">
                    <p>We're picking up what you're laying down.</p>
                    <p>You'll get an email with your response. If you can't find it, check your spam.</p>
                    <p>If you need to make any changes, respond to that email.</p>
                    <p>You can see a summary of what you wrote below:</p>
                </div>
                <div class="summary">
                    <div class="result">
                        <h4></h4>
                        <p></p>
                    </div>
                </div>
            </div>
        </template>
        <?php
    }
    if (wp_script_is('jvb-uploader')) {
        ?>
        <template class="emptyGroup">
inc/helpers/ui.php
@@ -434,3 +434,23 @@
    <?php
}
function jvbFormStatus(string $message = '') {
    return '<div class="restore-form col" hidden>
            <h3>Looks like we left things hanging...</h3>
            <p>We\'ve filled in the fields with what you put last time.</p>
            <div class="actions">
                <button type="button" data-action="clear-form">
                    '.jvbIcon('arrows-clockwise').'
                    <span>Start Over</span>
                </button>
                <button type="button" data-action="dismiss-restore">
                    '.jvbIcon('close').'
                    <span>Dismiss</span>
                </button>
            </div>
        </div>
        <div class="fstatus row" hidden>
            <div class="spinner"></div>
            <p class="message">'.$message.'</p>
        </div>';
}
inc/integrations/Integrations.php
@@ -35,6 +35,7 @@
    protected array $apiEndpoints = [];   // Valid endpoint paths for this service
    protected string $apiVersion = '';     // API version string (e.g., 'v2', '2024-01-01')
    protected int $refresh_interval = 0; //seconds before expiry to refresh tokens. 0 to disable
    /**
     * OAuth Configuration
@@ -806,7 +807,9 @@
                // Retry with backoff for server errors
                if ($attempt < $this->maxRetries && !$this->isClientError($e)) {
                    sleep($this->retryDelays[$attempt - 1] ?? 5);
                    $delay = pow(2, $attempt) * 1000000; // 2^attempt seconds in microseconds
                    $jitter = rand(0, $delay * 0.3); // Add 0-30% jitter
                    usleep($delay + $jitter);
                } else {
                    break;
                }
@@ -1341,10 +1344,21 @@
        }
        if (!empty($this->credentials)) {
            if ($this->isOAuthService && $this->hasOAuthCredentials() && !$this->isOAuthValid()) {
            if ($this->isOAuthService && $this->hasOAuthCredentials()) {
                // Check if token is expired first
                if (!$this->isOAuthValid()) {
                $this->logDebug('OAuth token expired, attempting refresh');
                if (!$this->refreshOAuthToken()) {
                    $this->logError('Failed to refresh OAuth token');
                        $this->logError('Failed to refresh expired OAuth token');
                    }
                }
                // Check if we should proactively refresh (before expiry)
                elseif ($this->shouldRefreshToken()) {
                    $this->logDebug('OAuth token should be refreshed proactively');
                    if (!$this->refreshOAuthToken()) {
                        $this->logError('Failed to proactively refresh OAuth token');
                        // Not critical - token is still valid
                    }
                }
            }
            $this->initialize();
@@ -1786,6 +1800,75 @@
    }
    /**
     * Check if token should be proactively refreshed
     * Different from isOAuthValid() which checks if token is actually expired
     */
    protected function shouldRefreshToken(): bool
    {
        if (!$this->isOAuthService || $this->refresh_interval === 0) {
            return false;
        }
        // If no expiry info, we can't proactively refresh
        if (empty($this->credentials['expires_at'])) {
            return false;
        }
        $expires_at = intval($this->credentials['expires_at']);
        $time_until_expiry = $expires_at - time();
        // Refresh if we're within the refresh interval window
        return $time_until_expiry > 0 && $time_until_expiry <= $this->refresh_interval;
    }
    /**
     * Get time until token refresh is recommended
     * Useful for displaying in admin UI
     */
    public function getTimeUntilRefresh(): ?int
    {
        if ($this->refresh_interval === 0 || empty($this->credentials['expires_at'])) {
            return null;
        }
        $expires_at = intval($this->credentials['expires_at']);
        $refresh_at = $expires_at - $this->refresh_interval;
        $time_until_refresh = $refresh_at - time();
        return max(0, $time_until_refresh);
    }
    /**
     * Get token freshness status
     * Returns: 'fresh', 'should_refresh', 'expired', or 'no_expiry_info'
     */
    public function getTokenStatus(): string
    {
        if (!$this->isOAuthService) {
            return 'not_oauth';
        }
        if (empty($this->credentials['access_token'])) {
            return 'no_token';
        }
        if (empty($this->credentials['expires_at'])) {
            return 'no_expiry_info';
        }
        $expires_at = intval($this->credentials['expires_at']);
        $now = time();
        if ($expires_at <= $now) {
            return 'expired';
        }
        if ($this->shouldRefreshToken()) {
            return 'should_refresh';
        }
        return 'fresh';
    }
    /**
     * Refresh OAuth token
     */
    protected function refreshOAuthToken(): bool
inc/integrations/Square.php
@@ -1,8 +1,10 @@
<?php
namespace JVBase\integrations;
use JVBase\meta\MetaForm;
use JVBase\meta\MetaManager;
use Exception;
use JVBase\registry\PostTypeRegistrar;
use WP_Error;
if (!defined('ABSPATH')) {
@@ -71,6 +73,8 @@
        $this->title = 'Square';
        $this->icon = 'square-logo';
        $this->refresh_interval = 7 * DAY_IN_SECONDS;
        // Define credential fields
        $this->fields = [
            'environment'   => [
@@ -171,6 +175,8 @@
                'sync_to_square' => 'Sync Site to Square',
            ]
        );
        add_action('init', [$this, 'registerSquarePostTypes']);
    }
    /**
@@ -207,6 +213,134 @@
    }
    public function getSquarePostConfig(string $post = 'all'):array
    {
        $posts = [
            '_sq_orders' => [
                'singular'  => 'Square Order',
                'plural'    => 'Square Orders',
                'public'    => false,
                'fields' => [
                    'post_title' => [
                        'type' => 'text',
                        'label' => 'Order Number'
                    ],
                    'square_order_id' => [
                        'type' => 'text',
                        'label' => 'Square Order ID',
                        'readonly' => true
                    ],
                    'square_payment_id' => [
                        'type' => 'text',
                        'label' => 'Square Payment ID',
                        'readonly' => true
                    ],
                    'square_customer_id' => [
                        'type' => 'text',
                        'label' => 'Square Customer ID',
                        'readonly' => true
                    ],
                    'amount' => [
                        'type' => 'number',
                        'label' => 'Total Amount (cents)',
                        'readonly' => true
                    ],
                    'status' => [
                        'type' => 'select',
                        'label' => 'Order Status',
                        'options' => [
                            'PROPOSED' => 'Proposed',
                            'RESERVED' => 'Reserved',
                            'PREPARED' => 'Prepared (Ready for Pickup)',
                            'COMPLETED' => 'Completed',
                            'CANCELED' => 'Canceled'
                        ],
                        'readonly' => true
                    ],
                    'fulfillment_status' => [
                        'type' => 'select',
                        'label' => 'Fulfillment Status',
                        'options' => [
                            'PROPOSED' => 'Proposed',
                            'RESERVED' => 'Reserved',
                            'PREPARED' => 'Prepared',
                            'COMPLETED' => 'Completed',
                            'CANCELED' => 'Canceled',
                            'FAILED' => 'Failed'
                        ],
                        'readonly' => true
                    ],
                    'pickup_time' => [
                        'type' => 'datetime',
                        'label' => 'Pickup Time'
                    ],
                    'customer_email' => [
                        'type' => 'email',
                        'label' => 'Customer Email',
                        'readonly' => true
                    ],
                    'customer_name' => [
                        'type' => 'text',
                        'label' => 'Customer Name',
                        'readonly' => true
                    ],
                    'customer_phone' => [
                        'type' => 'tel',
                        'label' => 'Customer Phone',
                        'readonly' => true
                    ],
                    'special_instructions' => [
                        'type' => 'textarea',
                        'label' => 'Special Instructions',
                        'readonly' => true
                    ],
                    'items' => [
                        'type' => 'repeater',
                        'label' => 'Order Items',
                        'readonly' => true,
                        'fields' => [
                            'name' => ['type' => 'text', 'label' => 'Item Name'],
                            'quantity' => ['type' => 'number', 'label' => 'Quantity'],
                            'price' => ['type' => 'number', 'label' => 'Price'],
                            'note' => ['type' => 'text', 'label' => 'Note']
                        ]
                    ],
                    'receipt_url' => [
                        'type' => 'url',
                        'label' => 'Receipt URL',
                        'readonly' => true
                    ],
                    'created_at' => [
                        'type' => 'datetime',
                        'label' => 'Created At',
                        'readonly' => true
                    ],
                    'updated_at' => [
                        'type' => 'datetime',
                        'label' => 'Last Updated',
                        'readonly' => true
                    ]
                ]
            ]
        ];
        if ($post === 'all'){
            return $posts;
        }elseif(array_key_exists($post, $posts)) {
            return $posts[$post];
        }
        return [];
    }
    public function registerSquarePostTypes():void
    {
        $squarePostTypes = $this->getSquarePostConfig();
        foreach ($squarePostTypes as $slug => $config) {
            $registrar = new PostTypeRegistrar($slug, $config);
            $registrar->register();
        }
    }
    /**
     * Get request headers for API calls
     */
@@ -717,10 +851,10 @@
        // User login tracking for security
        add_action('wp_login', [$this, 'trackUserLogin'], 10, 2);
        add_action('jvbAdditionalActions', [$this, 'outputCheckout']);
        // Enqueue checkout scripts
        add_action('wp_enqueue_scripts', [$this, 'enqueueScripts']);
        add_filter('jvbAdditionalActions', [$this, 'outputCheckout']);
    }
@@ -728,7 +862,7 @@
        if (is_singular(BASE.'dash') || is_post_type_archive(BASE.'dash')) {
            return $actions;
        }
        $meta = new MetaForm();
        $form = '<aside id="cart" class="right">
            <form id="checkout" data-form-id="checkout" data-save="checkout">';
@@ -745,12 +879,37 @@
            'description' => 'Securely checkout with your name, email, and payments processed by Square.',
            'content'   => '<div class="checkout-section">
                                <h3>Customer Information</h3>
                                <input type="text" name="name" placeholder="Full Name" required autocomplete="name">
                                <input type="email" name="email" placeholder="Email" required autocomplete="email">
                                <input type="tel" name="phone" placeholder="Phone" required autocomplete="tel"">
                                <h3>Pickup Details</h3>
                                <input type="time" name="pickup_time" min="11:00" max="20:00" required>
                                '.$meta->return('cart_name', null, [
                                    'type'      => 'text',
                                    'label'     => 'Your Name',
                                    'required'  => true,
                                    'autocomplete' => 'name'
                                ]).
                                $meta->return('cart_email', null, [
                                    'type'      => 'email',
                                    'label'     => 'Your Email',
                                    'required'  => true,
                                    'autocomplete'=> 'email',
                                ]).
                                $meta->return('cart_phone', null, [
                                    'type'      => 'tel',
                                    'label'     => 'Your Phone',
                                    'required'  => true,
                                    'autocomplete'=> 'phone'
                                ]).'
                                <h3>Pickup Details</h3>'.
                                $meta->return('pickup_time', null, [
                                    'type'      => 'datetime',
                                    'label'     => 'Pickup Type',
                                    'min'       => '11:00',
                                    'max'       => '20:00',
                                    'required'  => true,
                                ]).
                                $meta->return('special_instructions', null, [
                                    'type'      => 'textarea',
                                    'label'     => 'Special Instructions',
                                    'quill'     => true,
                                ]).'
                                <textarea name="special_instructions" placeholder="Special instructions or dietary notes"></textarea>
                            </div>
@@ -768,7 +927,7 @@
            'content'   => $this->renderOrderStatus()
        ]
                ];
        $form .= jvbRenderTabs($tabs);
        $form .= jvbRenderTabs($tabs, true);
        $form .= '<div class="cart-total row end"><p class="tax">Tax: <span></span></p><p class="total">GRAND TOTAL: <span></span></p></div>
        </form>
@@ -817,7 +976,7 @@
        $actions[] = [
            'button' =>     '<button type="button" class="toggle-cart row" title="Your Cart" data-action="toggle-cart" aria-label="Open Cart" aria-controls="checkout" aria-expanded="false" hidden>
            'button' =>     '<button type="button" class="toggle-cart row" title="Your Cart" data-action="toggle-cart" aria-label="Open Cart" aria-controls="checkout" aria-expanded="false">
                    '.jvbIcon('cart').'<span class="abs"></span><span class="abs count"></span>
                </button>',
            'content' =>    $form
@@ -1836,16 +1995,49 @@
    /**
     * Handle order status webhook
     */
    /**
     * Handle order status webhook - NOW UPDATES POST TYPE
     */
    private function handleOrderWebhook(array $data): bool
    {
        $order_id = $data['object']['order']['id'] ?? '';
        $state = $data['object']['order']['state'] ?? '';
        $fulfillments = $data['object']['order']['fulfillments'] ?? [];
        if (!$order_id) {
            return false;
        }
        // Update cached order status
        // Find the WP post for this order
        $wp_order_id = get_option(BASE . 'square_order_map_' . $order_id);
        if ($wp_order_id) {
            // Update the post meta
            $meta = new MetaManager($wp_order_id, 'post');
            $updates = [
                'status' => $state,
                'updated_at' => current_time('mysql')
            ];
            // Extract fulfillment status and pickup time
            if (!empty($fulfillments[0])) {
                $fulfillment = $fulfillments[0];
                $updates['fulfillment_status'] = $fulfillment['state'] ?? $state;
                if (!empty($fulfillment['pickup_details']['pickup_at'])) {
                    $updates['pickup_time'] = $fulfillment['pickup_details']['pickup_at'];
                }
            }
            $meta->setAll($updates);
            // Trigger notification to customer if order is ready
            if ($state === 'PREPARED') {
                do_action(BASE . 'square_order_ready', $wp_order_id, $order_id);
            }
        }
        // Also update transient cache for quick status checks
        set_transient(BASE . 'square_order_' . $order_id, $state, HOUR_IN_SECONDS);
        // Trigger action for other integrations
@@ -1940,7 +2132,6 @@
                'jvb-a11y',
                'jvb-cache',
                'jvb-tabs',
                'jvb-modal',
                'jvb-popup'
            ],
            '1.0.0',
@@ -1963,7 +2154,9 @@
                'environment' => $this->environment,
                'api_url' => rest_url('jvb/v1/square/'),
                'nonce' => wp_create_nonce('wp_rest'),
                'currency' => get_option(BASE . 'currency', 'CAD')
                'currency' => get_option(BASE . 'currency', 'CAD'),
                'is_logged_in' => is_user_logged_in(),
                'user_email' => is_user_logged_in() ? wp_get_current_user()->user_email : '' // NEW
            ]
        );
    }
@@ -2950,7 +3143,8 @@
                    'name' => $image_title ?: 'Image',
                    'caption' => $alt_text ?: ''
                ]
            ]
            ],
            'object_id' => $supported_image_id
        ];
        $body = $this->buildMultipartBody($file_path, $request_json, $boundary);
@@ -3279,4 +3473,222 @@
            $this->deleteCredentials();
        }
    }
    private function createSquareOrder(array $items, ?string $customer_id, array $data): array|WP_Error
    {
        // Build line items for Square
        $line_items = [];
        foreach ($items as $item) {
            $line_item = [
                'quantity' => (string)$item['quantity'], // MUST be string!
            ];
            // Use catalog_object_id if available (recommended)
            if (!empty($item['catalog_object_id'])) {
                $line_item['catalog_object_id'] = $item['catalog_object_id'];
                $line_item['catalog_version'] = $item['catalog_version'] ?? null;
            } else {
                // Ad-hoc line item (not recommended - no tax/inventory automation)
                $line_item['name'] = $item['name'];
                $line_item['base_price_money'] = [
                    'amount' => (int)$item['price'],
                    'currency' => $this->getCurrency()
                ];
            }
            if (!empty($item['note'])) {
                $line_item['note'] = $item['note'];
            }
            $line_items[] = $line_item;
        }
        $order_data = [
            'idempotency_key' => wp_generate_uuid4(), // Different from payment idempotency key
            'order' => [
                'location_id' => $this->locationId,
                'line_items' => $line_items,
                'state' => 'OPEN'
            ]
        ];
        // Add customer if available
        if ($customer_id) {
            $order_data['order']['customer_id'] = $customer_id;
        }
        // Add metadata
        if (!empty($data['note'])) {
            $order_data['order']['metadata'] = [
                'special_instructions' => $data['note']
            ];
        }
        if (!empty($data['pickup_time'])) {
            $order_data['order']['metadata']['pickup_time'] = $data['pickup_time'];
        }
        return $this->postRequest('orders', $order_data);
    }
    private function createSquarePayment(
        string $source_id,
        string $idempotency_key,
        int $amount_cents,
        string $order_id,
        ?string $customer_id
    ): array|WP_Error
    {
        $payment_data = [
            'idempotency_key' => $idempotency_key,
            'source_id' => $source_id,
            'amount_money' => [
                'amount' => $amount_cents, // Already in cents!
                'currency' => $this->getCurrency()
            ],
            'order_id' => $order_id,
            'location_id' => $this->locationId,
            'autocomplete' => true, // Capture immediately
        ];
        // Add customer if available
        if ($customer_id) {
            $payment_data['customer_id'] = $customer_id;
        }
        // Add reference ID for tracking
        $payment_data['reference_id'] = 'WP_' . time();
        return $this->postRequest('payments', $payment_data);
    }
    private function saveOrderToWordPress(array $order_data): int
    {
        // Extract customer info
        $customer_email = $order_data['customer']['email'] ?? '';
        $customer_name = $order_data['customer']['name'] ?? '';
        // Find or create WP user for logged-in association
        $user_id = 0;
        if ($customer_email) {
            $user = get_user_by('email', $customer_email);
            if ($user) {
                $user_id = $user->ID;
                // Store Square customer ID on user
                if (!empty($order_data['square_customer_id'])) {
                    update_user_meta($user_id, BASE . '_square_customer_id', $order_data['square_customer_id']);
                }
            }
        }
        // Create order post
        $order_post_id = wp_insert_post([
            'post_type' => BASE . '_sq_orders',
            'post_title' => 'Order #' . $order_data['square_order_id'],
            'post_status' => 'publish',
            'post_author' => $user_id // Associate with user if logged in
        ]);
        if (!$order_post_id || is_wp_error($order_post_id)) {
            $this->logError('Failed to create order post', ['order_data' => $order_data]);
            return 0;
        }
        // Save all order meta
        $meta = new MetaManager($order_post_id, 'post');
        $fields = $this->getSquarePostConfig('_sq_orders')['fields'];
        unset($fields['post_title']);
        $meta->setFieldConfig($fields);
        $meta->setAll([
            'square_order_id' => $order_data['square_order_id'],
            'square_payment_id' => $order_data['square_payment_id'] ?? '',
            'square_customer_id' => $order_data['square_customer_id'] ?? '',
            'amount' => $order_data['amount'],
            'status' => $order_data['status'],
            'fulfillment_status' => $order_data['fulfillment_status'] ?? 'PROPOSED',
            'pickup_time' => $order_data['pickup_time'] ?? '',
            'customer_email' => $customer_email,
            'customer_name' => $customer_name,
            'customer_phone' => $order_data['customer']['phone'] ?? '',
            'special_instructions' => $order_data['note'] ?? '',
            'items' => $order_data['items'],
            'receipt_url' => $order_data['receipt_url'] ?? '',
            'created_at' => current_time('mysql'),
            'updated_at' => current_time('mysql')
        ]);
        // Index by Square order ID for quick webhook lookups
        update_option(BASE . 'square_order_map_' . $order_data['square_order_id'], $order_post_id);
        return $order_post_id;
    }
    /**
     * Get currency code
     */
    private function getCurrency(): string
    {
        return get_option(BASE . 'currency', 'CAD');
    }
    /**
     * Get customer with saved cards (2025-compliant)
     */
    public function getUserCards(string $customer_id): array
    {
        $response = $this->getRequest('cards?customer_id=' . $customer_id);
        return $response['cards'] ?? [];
    }
    public function getUserOrders(string $customer_email): array
    {
        // First get Square customer ID
        $customer_response = $this->postRequest('customers/search', [
            'filter' => [
                'email_address' => ['exact' => $customer_email]
            ]
        ]);
        $customer_id = $customer_response['customers'][0]['id'] ?? null;
        if (!$customer_id) {
            return [];
        }
        // Get their orders
        $orders_response = $this->postRequest('orders/search', [
            'filter' => [
                'customer_filter' => [
                    'customer_ids' => [$customer_id]
                ]
            ],
            'sort' => [
                'sort_field' => 'CREATED_AT',
                'sort_order' => 'DESC'
            ],
            'limit' => 50
        ]);
        return $orders_response['orders'] ?? [];
    }
    public function checkOrderStatus(string $order_id): ?string
    {
        // Check transient cache first
        $cached = get_transient(BASE . 'square_order_' . $order_id);
        if ($cached) {
            return $cached;
        }
        // Fetch from Square
        $response = $this->getRequest('orders/' . $order_id);
        if (!is_wp_error($response)) {
            $state = $response['order']['state'] ?? null;
            set_transient(BASE . 'square_order_' . $order_id, $state, HOUR_IN_SECONDS);
            return $state;
        }
        return null;
    }
}
inc/managers/FormManager.php
@@ -146,6 +146,7 @@
        <form id="<?= $id ?>" class="jvb-form" action="<?=esc_url(admin_url('admin-post.php'))?>" method="post">
        <?php
        wp_nonce_field('jvb_form_' . $id);
        echo jvbFormStatus();
    }
inc/managers/ReferralManager.php
@@ -3,6 +3,7 @@
use JVBase\managers\MagicLinkManager;
use JVBase\integrations\Cloudflare;
use JVBase\meta\MetaForm;
use JVBase\utility\Features;
use WP_User;
use WP_Error;
@@ -58,7 +59,7 @@
        add_filter(BASE.'new_user_email_content', [$this, 'addReferralToWelcomeEmail'], 99, 2);
        add_action('jvbAdditionalActions', [$this, 'outputShareWidget']);
        add_filter('jvbAdditionalActions', [$this, 'outputShareWidget']);
        add_action('wp_enqueue_scripts', [$this, 'enqueueScripts']);
        // Schedule cron jobs for reports
@@ -877,14 +878,13 @@
    {
        $user_id = get_current_user_id();
        jvbDump($user_id);
        $content = '<aside class="jvb-referral right">';
        if (!$user_id) {
            $content .= $this->getUnloggedInReferral();
        } else {
            $content .= $this->getLoggedInReferral($user_id);
        }
        $content .= '<button type="button" class="close">'.jvbIcon('close').'</button></aside>';
        $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">
@@ -901,44 +901,30 @@
        ob_start();
        JVB()->connect('cloudflare')->renderTurnstile();
        $turnstile = ob_get_clean();
        $meta = new MetaForm();
        $codeForm = '<form id="referral-code-form">
                    <div class="status" hidden>
                        <div class="spinner"></div>
                        <p class="message"></p>
                    </div>
                    <div class="field text">
                        <label for="referral-name">Your Name</label>
                        <input type="text"
                               id="referral-name"
                               name="name"
                               placeholder="Mister Meeseeks"
                               autocomplete="name"
                               required>
                    </div>
                    <div class="field email">
                        <label for="referral-email">Your Email</label>
                        <input type="email"
                               id="referral-email"
                               name="email"
                               placeholder="look@me.com"
                               autocomplete="email"
                               required>
                    </div>
                    <div class="field text">
                        <label for="referral-code-input">Referral Code</label>
                        <input type="text"
                               id="referral-code-input"
                               name="referral_code"
                               placeholder="e.g., THISISFAKE1234"
                               required
                               pattern="[A-Za-z0-9]+"
                               maxlength="20"
                               autocomplete="off"
                               style="text-transform: uppercase;">
                    </div>
                    '.jvbFormStatus().$meta->return('referral_name', null, [
                        'required'  => true,
                        'type'      => 'text',
                        'label'     => 'Your Name',
                        'placeholder'=> 'Mister Meeseeks',
                        'autocomplete'=>'name'
                    ]).
                    $meta->return('referral_email', null, [
                        'required'  => true,
                        'type'      => 'email',
                        'label'     => 'Your Email',
                        'placeholder'=> 'look@me.com',
                        'autocomplete'=> 'email'
                    ]).
                    $meta->return('referral_code', null, [
                        'required'  => true,
                        'type'      => 'text',
                        'label'     => 'Referral Code',
                        'pattern'   => '[A-Za-z0-9]+',
                        'maxLength' => 20,
                        'autocomplete'=>'off'
                    ]).'
                    <button type="submit">
                        Get Started
                    </button>
@@ -954,14 +940,12 @@
                </div>';
        $loginForm = '<form id ="login-form">
        <div class="status" hidden>
            <div class="spinner"></div>
            <p class="message"></p>
        </div>
        <div class="field email">
            <label for="login-email">Your Email</label>
            <input id="login-email" name="login-email" type="email" autocomplete="email">
        </div>
        '.jvbFormStatus().$meta->return('login_email', null, [
                'required'  => true,
                'type'      => 'email',
                'label'     => 'Your Email',
                'autocomplete'=>'email'
            ]).'
        '.$turnstile.'
        <button type="submit">Login With Magic Link</button>
</form>
inc/meta/MetaForm.php
@@ -6,7 +6,7 @@
use DateTime;
if (!defined('ABSPATH')) {
    exit; // Exit if accessed directly
    exit;
}
/**
@@ -16,21 +16,24 @@
{
    protected int $max_file_size = 5242880;
    //Rendering fields
    /* ========== MAIN RENDER METHOD ========== */
    public function return(string $name, mixed $value, array $config, bool $showHidden = false)
    {
        return $this->render($name, $value, $config, $showHidden, true);
    }
    public function render(string $name, mixed $value, array $config, bool $showHidden = false, bool $return = false):mixed
    {
        $out = '';
        if (jvbCheck('hidden', $config) && !$showHidden) {
            return $out;
        }
        // Get conditional attributes if they exist
        $conditional = array_key_exists('condition', $config) ?
            $this->handleConditionalField($config) : '';
        if (!array_key_exists('type', $config)) {
            return $out;
        }
        // Handle hidden display type
        if (array_key_exists('display', $config) && $config['display'] === 'hidden'){
            $out = '<input type="hidden" name="'.$name.'" value="'.$value.'" />';
            if (!$return) {
@@ -40,12 +43,15 @@
        }
        ob_start();
        // Try custom function overrides first
        $type = array_map( 'ucfirst', explode('_', $config['type']));
        $type = implode('', $type);
        $method = 'render' . $type . 'Field';
        $nameTemp = implode('', array_map('ucfirst', explode('_', $name)));
        $nameMethod = 'render'.$nameTemp.'Field';
        if(function_exists($nameMethod)) {
            call_user_func($nameMethod, $value, $config);
        } elseif (function_exists($method)) {
@@ -54,195 +60,356 @@
            $this->$method($name, $value, $config);
        }
        $out = ob_get_clean();
        do_action('jvbRenderFormField', $name, $config, $value);
        $out = apply_filters('jvbFilterRenderFormField', $out, $name, $config, $value);
        if (!$return) {
            echo $out;
        }
        return $out;
    }
    public function renderTextField(string $name, mixed $value, array $field):void
    /* ========== HELPER METHODS ========== */
    /**
     * Prepare common field data
     */
    protected function prepareFieldData(string $name, mixed $value, array $field): array
    {
        // Use field-specific value if provided, otherwise use the meta value
        $display_value = isset($field['value']) ? $field['value'] : $value;
        $conditional = $this->handleConditionalField($field);
        $describedBy = (!empty($field['description'])) ? ' aria-describedby="'.$name.'-help"' : '';
        if (array_key_exists('group', $field)) {
            $name = $field['group'].'::'.$name;
        return [
            'name' => array_key_exists('group', $field) ? $field['group'] . '::' . $name : $name,
            'value' => isset($field['value']) ? $field['value'] : $value,
            'id' => (array_key_exists('base', $field) ? esc_attr($field['base']) : '') . esc_attr($name),
        ];
        }
        $placeholder = (array_key_exists('placeholder', $field)) ? ' placeholder="'.$field['placeholder'].'"' : '';
        $autocomplete = (array_key_exists('autocomplete', $field)) ? ' autocomplete="'.$field['autocomplete'].'"' : '';
        ?>
         <div class="field <?=$field['type']?> <?=$name?>" <?=$conditional?> data-field="<?=$name?>">
            <label for="<?= esc_attr($name); ?>">
                <?= esc_html($field['label']); ?>
                <?php if (!empty($field['limit'])) : ?>
                    <span class="char-count" data-limit="<?= esc_attr($field['limit']); ?>">
                    <span class="current">0</span>/<?= esc_attr($field['limit']); ?>
                </span>
                <?php endif; ?>
            </label>
            <input
                type="<?= esc_attr($field['subtype']??'text'); ?>"
                id="<?= array_key_exists('base', $field) ? esc_attr($field['base']) : ''?><?= esc_attr($name); ?>"
                name="<?= array_key_exists('base', $field) ? esc_attr($field['base']) : ''?><?= esc_attr($name); ?>"
                value="<?= esc_attr($display_value); ?>"
                <?= $placeholder ?>
                <?= $autocomplete ?>
                <?= !empty($field['required']) ? 'required' : ''; ?>
                <?= $describedBy ?>
            >
             <?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
        if (array_key_exists('limit', $field)) {
            $this->outputCharacterCountJS();
    /**
     * Build common HTML attributes for inputs
     */
    protected function buildInputAttributes(string $name, array $field): string
    {
        $attrs = [];
        // Conditional rendering
        if (array_key_exists('condition', $field)) {
            $attrs['conditional'] = $this->handleConditionalField($field);
        }
        // Accessibility
        if (!empty($field['description'])) {
            $attrs['aria-describedby'] = $name . '-help';
        }
        // Common attributes
        $common = ['placeholder', 'autocomplete', 'pattern', 'minlength', 'maxlength', 'min', 'max', 'step'];
        foreach ($common as $attr) {
            if (array_key_exists($attr, $field)) {
                $attrs[$attr] = $field[$attr];
        }
    }
    private function renderTelField(string $name, mixed $value, array $field):void
        {$describedBy = (!empty($field['description'])) ? ' aria-describedby="'.$name.'-help"' : '';
        // Use field-specific value if provided, otherwise use the meta value
        $display_value = isset($field['value']) ? $field['value'] : $value;
        $conditional = $this->handleConditionalField($field);
        if (array_key_exists('group', $field)) {
            $name = $field['group'].'::'.$name;
        // Required
        if (!empty($field['required'])) {
            $attrs['required'] = true;
        }
            $placeholder = (array_key_exists('placeholder', $field)) ? ' placeholder="'.$field['placeholder'].'"' : '';
        $autocomplete = (array_key_exists('autocomplete', $field)) ? ' autocomplete="'.$field['autocomplete'].'"' : '';
        ?>
         <div class="field <?=$field['type']?> <?=$name?>" <?=$conditional?> data-field="<?=$name?>">
            <label for="<?= esc_attr($name); ?>">
                <?= esc_html($field['label']); ?>
                <?php if (!empty($field['limit'])) : ?>
                    <span class="char-count" data-limit="<?= esc_attr($field['limit']); ?>">
                    <span class="current">0</span>/<?= esc_attr($field['limit']); ?>
                </span>
                <?php endif; ?>
            </label>
            <input
                type="tel"
                id="<?= array_key_exists('base', $field) ? esc_attr($field['base']) : ''?><?= esc_attr($name); ?>"
                name="<?= array_key_exists('base', $field) ? esc_attr($field['base']) : ''?><?= esc_attr($name); ?>"
                value="<?= esc_attr($display_value); ?>"
                <?= $placeholder ?>
                <?= $autocomplete?>
                <?= $describedBy ?>
                <?= !empty($field['required']) ? 'required' : ''; ?>
            >
             <?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
        if (array_key_exists('limit', $field)) {
            $this->outputCharacterCountJS();
        // Build attribute string
        $attrString = '';
        foreach ($attrs as $key => $val) {
            if ($key === 'conditional') {
                $attrString .= ' ' . $val; // Already formatted
            } elseif ($val === true) {
                $attrString .= ' ' . $key;
            } else {
                $attrString .= ' ' . $key . '="' . esc_attr($val) . '"';
        }
    }
    private function renderEmailField(string $name, mixed $value, array $field):void
        return $attrString;
    }
    /**
     * Build validation data attributes
     */
    protected function buildValidationAttributes(array $field): string
    {
        $describedBy = (!empty($field['description'])) ? ' aria-describedby="'.$name.'-help"' : '';
        // Use field-specific value if provided, otherwise use the meta value
        $display_value = isset($field['value']) ? $field['value'] : $value;
        $conditional = $this->handleConditionalField($field);
        if (array_key_exists('group', $field)) {
            $name = $field['group'].'::'.$name;
        $attrs = [];
        if (!empty($field['pattern'])) {
            $attrs['data-pattern'] = $field['pattern'];
        }
        $placeholder = (array_key_exists('placeholder', $field)) ? ' placeholder="'.$field['placeholder'].'"' : '';
        $autocomplete = (array_key_exists('autocomplete', $field)) ? ' autocomplete="'.$field['autocomplete'].'"' : '';
        if (!empty($field['validate'])) {
            $attrs['data-validate'] = $field['validate'];
        }
        if (isset($field['min'])) {
            $attrs['data-min'] = $field['min'];
        }
        if (isset($field['max'])) {
            $attrs['data-max'] = $field['max'];
        }
        if (isset($field['minlength'])) {
            $attrs['data-minlength'] = $field['minlength'];
        }
        if (isset($field['maxlength'])) {
            $attrs['data-maxlength'] = $field['maxlength'];
        }
        if (!empty($field['validation_message'])) {
            $attrs['data-validation-message'] = $field['validation_message'];
        }
        $attrs['data-type'] = $field['type'];
        $attrString = '';
        foreach ($attrs as $key => $val) {
            $attrString .= ' ' . $key . '="' . esc_attr($val) . '"';
        }
        return $attrString;
    }
    /* ========== GENERIC FIELD WRAPPER ========== */
    /**
     * Render a standard input field with validation wrapper
     */
    protected function renderStandardInput(string $name, mixed $value, array $field, string $inputType = 'text'): void
    {
        $data = $this->prepareFieldData($name, $value, $field);
        $inputAttrs = $this->buildInputAttributes($name, $field);
        $validationAttrs = $this->buildValidationAttributes($field);
        $conditional = array_key_exists('condition', $field) ? $this->handleConditionalField($field) : '';
        ?>
         <div class="field <?=$field['type']?> <?=$name?>" <?=$conditional?> data-field="<?=$name?>">
            <label for="<?= esc_attr($name); ?>">
                <?= esc_html($field['label']); ?>
        <div class="field <?= esc_attr($field['type']) ?> <?= esc_attr($name) ?>"
            <?= $conditional ?>
             data-field="<?= esc_attr($name) ?>"
            <?= $validationAttrs ?>>
            <?php $this->renderLabel($name, $field); ?>
            <div class="field-input-wrapper">
                <input
                    type="<?= esc_attr($inputType) ?>"
                    id="<?= esc_attr($data['id']) ?>"
                    name="<?= esc_attr($data['name']) ?>"
                    value="<?= esc_attr($data['value']) ?>"
                    <?= $inputAttrs ?>
                >
                <span class="validation-icon success" hidden aria-hidden="true">
                    <?= jvbIcon('check-circle') ?>
                </span>
                <span class="validation-icon error" hidden aria-hidden="true">
                    <?= jvbIcon('x-circle') ?>
                </span>
            </div>
            <span class="validation-message" hidden role="alert"></span>
            <?php $this->renderHintAndDescription($field, $name); ?>
        </div>
        <?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
     */
    protected function renderLabel(string $name, array $field): void
    {
        ?>
        <label for="<?= esc_attr($name) ?>">
            <?= esc_html($field['label']) ?>
            <?php if (!empty($field['required'])) : ?>
                <span class="required" aria-label="required">*</span>
            <?php endif; ?>
                <?php if (!empty($field['limit'])) : ?>
                    <span class="char-count" data-limit="<?= esc_attr($field['limit']); ?>">
                    <span class="current">0</span>/<?= esc_attr($field['limit']); ?>
                <span class="char-count" data-limit="<?= esc_attr($field['limit']) ?>">
                    <span class="current">0</span>/<?= esc_attr($field['limit']) ?>
                </span>
                <?php endif; ?>
            </label>
            <input
                type="email"
             <?= array_key_exists('base', $field) ? esc_attr($field['base']) : ''?><?= esc_attr($name); ?>"
                name="<?= array_key_exists('base', $field) ? esc_attr($field['base']) : ''?><?= esc_attr($name); ?>"
                value="<?= esc_attr($display_value); ?>"
                <?= $placeholder ?>
                <?= $autocomplete ?>
                <?= $describedBy ?>
                <?= !empty($field['required']) ? 'required' : ''; ?>
            >
             <?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
        if (array_key_exists('limit', $field)) {
            $this->outputCharacterCountJS();
        }
    /**
     * Render hint and description
     */
    protected function renderHintAndDescription(array $field, string $name): void
    {
        if (array_key_exists('hint', $field)) {
            $this->renderHint($field['hint']);
        }
        if (array_key_exists('description', $field)) {
            $this->renderDescription($field['description'], $name);
        }
    }
    /* ========== SIMPLE INPUT FIELD TYPES ========== */
    public function renderTextField(string $name, mixed $value, array $field): void
    {
        $this->renderStandardInput($name, $value, $field, $field['input_type'] ?? 'text');
    }
    public function renderEmailField(string $name, mixed $value, array $field): void
    {
        $field['validate'] = 'email'; // Auto-add email validation
        $this->renderStandardInput($name, $value, $field, 'email');
    }
    private function renderUrlField(string $name, mixed $value, array $field):void
    {
        $describedBy = (!empty($field['description'])) ? ' aria-describedby="'.$name.'-help"' : '';
        // Use field-specific value if provided, otherwise use the meta value
        $display_value = isset($field['value']) ? $field['value'] : $value;
        $conditional = $this->handleConditionalField($field);
        if (array_key_exists('group', $field)) {
            $name = $field['group'].'::'.$name;
        $field['validate'] = 'url'; // Auto-add URL validation
        $this->renderStandardInput($name, $value, $field, 'url');
        }
        $placeholder = (array_key_exists('placeholder', $field)) ? ' placeholder="'.$field['placeholder'].'"' : '';
        $autocomplete = (array_key_exists('autocomplete', $field)) ? ' autocomplete="'.$field['autocomplete'].'"' : '';
        ?>
         <div class="field <?=$field['type']?> <?=$name?>" <?=$conditional?> data-field="<?=$name?>">
            <label for="<?= esc_attr($name); ?>">
                <?= esc_html($field['label']); ?>
                <?php if (!empty($field['limit'])) : ?>
                    <span class="char-count" data-limit="<?= esc_attr($field['limit']); ?>">
                    <span class="current">0</span>/<?= esc_attr($field['limit']); ?>
                </span>
                <?php endif; ?>
            </label>
            <input
                type="url"
                id="<?= array_key_exists('base', $field) ? esc_attr($field['base']) : ''?><?= esc_attr($name); ?>"
                name="<?= array_key_exists('base', $field) ? esc_attr($field['base']) : ''?><?= esc_attr($name); ?>"
                value="<?= esc_attr($display_value); ?>"
                <?= $placeholder ?>
                <?= $describedBy ?>
                <?= $autocomplete ?>
                <?= !empty($field['required']) ? 'required' : ''; ?>
            >
             <?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
        if (array_key_exists('limit', $field)) {
            $this->outputCharacterCountJS();
    private function renderTelField(string $name, mixed $value, array $field): void
    {
        $field['validate'] = 'phone'; // Auto-add phone validation
        $this->renderStandardInput($name, $value, $field, 'tel');
    }
    private function renderDateField(string $name, mixed $value, array $field): void
    {
        $format = !empty($field['format']) ? $field['format'] : 'Y-m-d';
        // Format the date if we have a value
        if (!empty($value)) {
            $date = DateTime::createFromFormat($format, $value);
            if ($date) {
                $value = $date->format('Y-m-d'); // HTML date input requires Y-m-d format
        }
    }
        $this->renderStandardInput($name, $value, $field, 'date');
    }
    private function renderTimeField(string $name, mixed $value, array $field): void
    {
        $this->renderStandardInput($name, $value, $field, 'time');
    }
    private function renderDatetimeField(string $name, mixed $value, array $field): void
    {
        $this->renderStandardInput($name, $value, $field, 'datetime-local');
    }
    /* ========== TEXTAREA FIELD ========== */
    public function renderTextareaField(string $name, mixed $value, array $field): void
    {
        $data = $this->prepareFieldData($name, $value, $field);
        $inputAttrs = $this->buildInputAttributes($name, $field);
        $validationAttrs = $this->buildValidationAttributes($field);
        $conditional = array_key_exists('condition', $field) ? $this->handleConditionalField($field) : '';
        $rows = isset($field['rows']) ? (int)$field['rows'] : 4;
        $quill = (array_key_exists('quill', $field) && $field['quill'] == true) ? ' data-editor="true"' : '';
        if ($quill !== '') {
            $allowImages = array_key_exists('allowImage', $field);
            $quill .= ($allowImages) ? ' data-allowimage="true"' : ' data-allowimage="false"';
        }
        // Handle array values
        if (is_array($value)) {
            $value = implode(', ', $value);
        }
        ?>
        <div class="field <?= esc_attr($field['type']) ?> <?= esc_attr($name) ?>"
            <?= $conditional ?>
             data-field="<?= esc_attr($name) ?>"
            <?= $validationAttrs ?>>
            <?php $this->renderLabel($name, $field); ?>
            <div class="field-input-wrapper">
                <textarea
                    id="<?= esc_attr($data['id']) ?>"
                    name="<?= esc_attr($data['name']) ?>"
                    rows="<?= esc_attr($rows) ?>"
                    <?= $quill ?>
                    <?= $inputAttrs ?>
                ><?= esc_textarea($data['value']) ?></textarea>
                <span class="validation-icon success" hidden aria-hidden="true">
                    <?= jvbIcon('check-circle') ?>
                </span>
                <span class="validation-icon error" hidden aria-hidden="true">
                    <?= jvbIcon('x-circle') ?>
                </span>
            </div>
            <span class="validation-message" hidden role="alert"></span>
            <?php $this->renderHintAndDescription($field, $name); ?>
        </div>
        <?php
    }
    /* ========== NUMBER FIELD ========== */
    private function renderNumberField(string $name, mixed $value, array $field):void
    {
        $data = $this->prepareFieldData($name, $value, $field);
        $conditional = array_key_exists('condition', $field) ? $this->handleConditionalField($field) : '';
        $validationAttrs = $this->buildValidationAttributes($field);
        $describedBy = (!empty($field['description'])) ? ' aria-describedby="'.$name.'-help"' : '';
        $description = '<ul class="list-none"><li>Tip: hold Ctrl/Command to increase 5x</li><li>Shift to increase 10x,</li><li>Or Ctrl/Command + Shift to increase 50x</li></ul>';
        $description .= $field['description']??'';
        $conditional = $this->handleConditionalField($field);
        $min = isset($field['min']) ? (float)$field['min'] : 0;
        $max = isset($field['max']) ? (float)$field['max'] : 100;
        $step = isset($field['step']) ? (float)$field['step'] : 1;
        $data = '';
        // Handle custom data attributes
        $customData = '';
        if (array_key_exists('data', $field) && !empty($field['data'])) {
            foreach($field['data'] as $key => $v) {
                if ($v === '') {
                    $data .= ' data-'.$key;
                } else {
                    $data .= ' data-'.$key.'="'.$v.'"';
                }
                $customData .= ($v === '') ? ' data-' . $key : ' data-' . $key . '="' . $v . '"';
            }
        }
@@ -250,196 +417,230 @@
            $value = $field['default']??0;
        }
        if (array_key_exists('group', $field)) {
            $name = $field['group'].'::'.$name;
        }
        $autocomplete = (array_key_exists('autocomplete', $field)) ? ' autocomplete="'.$field['autocomplete'].'"' : '';
        ?>
         <div class="field <?=$field['type']?> <?=$name?> row" <?=$conditional?> data-field="<?=$name?>">
            <label for="<?= esc_attr($name); ?>">
                <?= esc_html($field['label']); ?>
            </label>
        <div class="field <?= esc_attr($field['type']) ?> <?= esc_attr($name) ?> row"
            <?= $conditional ?>
             data-field="<?= esc_attr($name) ?>"
            <?= $validationAttrs ?>>
             <div class="quantity"
                  <?=$data?>>
            <?php $this->renderLabel($name, $field); ?>
            <div class="quantity" <?= $customData ?>>
                 <button type="button"
                         class="decrease"
                         title="<?= array_key_exists('remove', $field) ? $field['remove'] : 'Decrease amount' ?>"
                         aria-label="Decrease <?= esc_attr($field['label']); ?>">
                        aria-label="Decrease <?= esc_attr($field['label']) ?>">
                     <?= jvbIcon('minus')?>
                 </button>
                 <input type="number"
                        id="<?= array_key_exists('base', $field) ? esc_attr($field['base']) : ''?><?= esc_attr($name); ?>"
                        name="<?= array_key_exists('base', $field) ? esc_attr($field['base']) : ''?><?= esc_attr($name); ?>"
                        value="<?= esc_attr($value); ?>"
                        min="<?= esc_attr($min); ?>"
                        max="<?= esc_attr($max); ?>"
                        step="<?= esc_attr($step); ?>"
                       id="<?= esc_attr($data['id']) ?>"
                       name="<?= esc_attr($data['name']) ?>"
                       value="<?= esc_attr($value) ?>"
                       min="<?= esc_attr($min) ?>"
                       max="<?= esc_attr($max) ?>"
                       step="<?= esc_attr($step) ?>"
                        class="quantity-input"
                        <?= $describedBy ?>
                        <?= $autocomplete ?>
                     <?= !empty($field['required']) ? 'required' : ''; ?>>
                    <?= !empty($field['required']) ? 'required' : '' ?>>
                 <button type="button"
                         class="increase"
                         title="<?= array_key_exists('add', $field) ? $field['add'] : 'Increase amount' ?>"
                         aria-label="Increase <?= esc_attr($field['label']); ?>">
                        aria-label="Increase <?= esc_attr($field['label']) ?>">
                     <?= jvbIcon('add')?>
                 </button>
             </div>
             <?php $this->renderDescription($description, $name);  ?>
            <?php $this->renderHintAndDescription($field, $name); ?>
        </div>
        <?php
    }
    public function renderTextareaField(string $name, mixed $value, array $field):void
    /* ========== SELECT, RADIO, CHECKBOX FIELDS ========== */
    private function renderSelectField(string $name, mixed $value, array $field): void
    {
        $describedBy = (!empty($field['description'])) ? ' aria-describedby="'.$name.'-help"' : '';
        $rows = isset($field['rows']) ? (int)$field['rows'] : 4;
        $conditional = $this->handleConditionalField($field);
        $quill = (array_key_exists('quill', $field) && $field['quill'] == true) ? ' data-editor="true"' : '';
        if ($quill !== '') {
            $allowImages = array_key_exists('allowImage', $field);
            $quill .= ($allowImages) ? ' data-allowimage="true"' : ' data-allowimage="false"';
        }
        // Handle array values
        if (is_array($value)) {
            $value = implode(', ', $value);
        }
        if (array_key_exists('group', $field)) {
            $name = $field['group'].'::'.$name;
        }
        $placeholder = (array_key_exists('placeholder', $field)) ? ' placeholder="'.$field['placeholder'].'"' : '';
        $data = $this->prepareFieldData($name, $value, $field);
        $inputAttrs = $this->buildInputAttributes($name, $field);
        $validationAttrs = $this->buildValidationAttributes($field);
        $conditional = array_key_exists('condition', $field) ? $this->handleConditionalField($field) : '';
        ?>
         <div class="field <?=$field['type']?> <?=$name?>" <?=$conditional?> data-field="<?=$name?>">
            <label for="<?= esc_attr($name ?? ''); ?>">
                <?= esc_html($field['label']); ?>
                <?php if (!empty($field['limit'])) : ?>
                    <span class="char-count" data-limit="<?= esc_attr($field['limit']); ?>">
                    <span class="current">0</span>/<?= esc_attr($field['limit']); ?>
        <div class="field <?= esc_attr($field['type']) ?> <?= esc_attr($name) ?>"
            <?= $conditional ?>
             data-field="<?= esc_attr($name) ?>"
            <?= $validationAttrs ?>>
            <?php $this->renderLabel($name, $field); ?>
            <div class="field-input-wrapper">
                <select
                    id="<?= esc_attr($data['id']) ?>"
                    name="<?= esc_attr($data['name']) ?>"
                    <?= $inputAttrs ?>
                >
                    <?php foreach ($field['options'] as $key => $label) : ?>
                        <option value="<?= esc_attr($key) ?>" <?php selected($value, $key); ?>>
                            <?= esc_html($label) ?>
                        </option>
                    <?php endforeach; ?>
                </select>
                <span class="validation-icon success" hidden aria-hidden="true">
                    <?= jvbIcon('check-circle') ?>
                </span>
                <?php endif; ?>
            </label>
            <textarea
                id="<?= array_key_exists('base', $field) ? esc_attr($field['base']) : ''?><?= esc_attr($name ?? ''); ?>"
                name="<?= array_key_exists('base', $field) ? esc_attr($field['base']) : ''?><?= esc_attr($name ?? ''); ?>"
                    <?= $quill ?>
                    rows="<?= esc_attr($rows); ?>"
                <?= $placeholder ?>
                <?= $describedBy ?>
                <?= !empty($field['required']) ? 'required' : ''; ?>
            <?= !empty($field['limit']) ? 'data-limit="' . esc_attr($field['limit']) . '"' : ''; ?>
        ><?= esc_textarea($value); ?></textarea>
             <?php if (array_key_exists('hint', $field)) { $this->renderHint($field['hint']); }  ?>
             <?php if (array_key_exists('description', $field)) { $this->renderDescription($field['description'], $name); }  ?>
                <span class="validation-icon error" hidden aria-hidden="true">
                    <?= jvbIcon('x-circle') ?>
                </span>
            </div>
            <span class="validation-message" hidden role="alert"></span>
            <?php $this->renderHintAndDescription($field, $name); ?>
        </div>
        <?php
    }
    private function renderSetField(string $name, mixed $value, array $field):void
    private function renderRadioField(string $name, mixed $value, array $field): void
    {
        $this->renderCheckboxField($name, $value, $field);
        $data = $this->prepareFieldData($name, $value, $field);
        $validationAttrs = $this->buildValidationAttributes($field);
        $conditional = array_key_exists('condition', $field) ? $this->handleConditionalField($field) : '';
        ?>
        <div class="field <?= esc_attr($field['type']) ?> <?= esc_attr($name) ?>"
            <?= $conditional ?>
             data-field="<?= esc_attr($name) ?>"
            <?= $validationAttrs ?>>
            <fieldset>
                <legend><?= esc_html($field['label']) ?>
                    <?php if (!empty($field['required'])) : ?>
                        <span class="required" aria-label="required">*</span>
                    <?php endif; ?>
                </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>
                    </label>
                <?php endforeach; ?>
            </fieldset>
            <span class="validation-message" hidden role="alert"></span>
            <?php $this->renderHintAndDescription($field, $name); ?>
        </div>
        <?php
    }
    private function renderCheckboxField(string $name, mixed $value, array $field):void
    {
        $describedBy = (!empty($field['description'])) ? ' aria-describedby="'.$name.'-help"' : '';
        $value = !is_array($value) ? explode(',', $value) : $value;
        $limit = isset($field['limit']) ? (int)$field['limit'] : 0;
        $conditional = $this->handleConditionalField($field);
        if (array_key_exists('group', $field)) {
            $name = $field['group'].'::'.$name;
        $data = $this->prepareFieldData($name, $value, $field);
        $validationAttrs = $this->buildValidationAttributes($field);
        $conditional = array_key_exists('condition', $field) ? $this->handleConditionalField($field) : '';
        if (!is_array($value)) {
            $value = !empty($value) ? [$value] : [];
        }
        ?>
        <div class="field checkbox-group" <?= $limit ? 'data-limit="' . esc_attr($limit) . '"' : ''; ?> <?=$conditional?> data-field="<?=$name?>"<?=$describedBy?>>
            <span class="label"><?= esc_html($field['label']); ?></span>
            <div class="checkbox-options flex">
        <div class="field <?= esc_attr($field['type']) ?> <?= esc_attr($name) ?>"
            <?= $conditional ?>
             data-field="<?= esc_attr($name) ?>"
            <?= $validationAttrs ?>>
            <fieldset>
                <legend><?= esc_html($field['label']) ?>
                    <?php if (!empty($field['required'])) : ?>
                        <span class="required" aria-label="required">*</span>
                    <?php endif; ?>
                </legend>
            <?php foreach ($field['options'] as $key => $label) : ?>
                <input
                    type="checkbox"
                    id="<?= array_key_exists('base', $field) ? esc_attr($field['base']) : ''?><?= esc_attr($name).'-'.esc_attr($key)?>"
                    <?= array_key_exists('base', $field) ? esc_attr($field['base']) : ''?><?= esc_attr($name)?>[]"
                    value="<?= esc_attr($key); ?>"
                    <?= (in_array($key, $value)) ? 'checked' : ''; ?>
                    <?= !empty($field['required']) ? 'required' : ''; ?>
                        id="<?= esc_attr($data['name']) ?>-<?= esc_attr($key)?>"
                        name="<?= esc_attr($data['name']) ?>[]"
                        value="<?= esc_attr($key) ?>"
                        <?php checked(in_array($key, $value)); ?>
                >
                <label class="checkbox-option" for="<?= esc_attr($name).'-'.esc_attr($key) ?>">
                    <?= esc_html($label); ?>
                    <label class="checkbox-option" for="<?= esc_attr($data['name']) ?>-<?= esc_attr($key)?>">
                        <span><?= esc_html($label) ?></span>
                </label>
            <?php endforeach; ?>
        </div>
            <?php if (array_key_exists('hint', $field)) { $this->renderHint($field['hint']); }  ?>
            <?php if (array_key_exists('description', $field)) { $this->renderDescription($field['description'], $name); }  ?>
            </fieldset>
            <span class="validation-message" hidden role="alert"></span>
            <?php $this->renderHintAndDescription($field, $name); ?>
        </div>
        <?php
    }
    private function renderRadioField(string $name, mixed $value, array $field):void
    private function renderTrueFalseField(string $name, mixed $value, array $field): void
    {
        $data = $this->prepareFieldData($name, $value, $field);
        $validationAttrs = $this->buildValidationAttributes($field);
        $conditional = array_key_exists('condition', $field) ? $this->handleConditionalField($field) : '';
        $describedBy = (!empty($field['description'])) ? ' aria-describedby="'.$name.'-help"' : '';
        $value = (array)$value;
        $conditional = $this->handleConditionalField($field);
        if (!array_key_exists('label', $field)) {
            error_log('No label for: '.print_r($name, true));
        }
        if (array_key_exists('group', $field)) {
            $name = $field['group'].'::'.$name;
        }
        ?>
        <div class="field radio-group"<?=$conditional?> data-field="<?=$name?>"<?=$describedBy?>>
            <label><?= esc_html($field['label']); ?></label>
        <div class="field true-false <?= esc_attr($name) ?> row btw"
            <?= $conditional ?>
             data-field="<?= esc_attr($name) ?>"
            <?= $validationAttrs ?>>
            <div class="radio-options row">
                <?php foreach ($field['options'] as $key => $label) : ?>
            <label class="toggle-switch row" <?= $describedBy ?>>
                    <input
                        type="radio"
                        id="<?= array_key_exists('base', $field) ? esc_attr($field['base']) : ''?><?= esc_attr($name).'-'.esc_attr($key) ?>"
                        name="<?= array_key_exists('base', $field) ? esc_attr($field['base']) : ''?><?= esc_attr($name); ?>"
                        value="<?= esc_attr($key); ?>"
                        <?= !empty($field['required']) ? 'required' : ''; ?>
                        <?= (in_array($key, $value)) ? 'checked' : ''; ?>
                    type="checkbox"
                    name="<?= esc_attr($data['name']) ?>"
                    value="1"
                    <?= ($value) ? ' checked' : '' ?>
                    <?= !empty($field['required']) ? 'required' : '' ?>
                    >
                    <label class="radio-option" for="<?= esc_attr($name).'-'.esc_attr($key) ?>">
                        <?= esc_html($label); ?>
                    </label>
                <?php endforeach; ?>
            </div>
            <?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="slider"></div>
                <span class="toggle-label">
                    <?php if (!empty($field['required'])) : ?>
                        <span class="required" aria-label="required">*</span>
                    <?php endif; ?>
                    <?= esc_html($field['label']) ?></span>
            </label>
            <span class="validation-message" hidden role="alert"></span>
            <?php $this->renderHintAndDescription($field, $name); ?>
        </div>
        <?php
    }
    /* ========== REPEATER FIELD ========== */
    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;
        }
        $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));
            }
            ?>
            <h3><?= esc_html($field['label']); ?></h3>
        $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';
            <div class="repeater-items">
            ?>
            <div class="repeater-items" data-label="<?= esc_attr($rowLabel) ?>">
                <?php
                if (!empty($values)) {
                    foreach ($values as $index => $row) {
@@ -450,44 +651,38 @@
            </div>
            <template class="<?=uniqid('repeaterTemplate')?>">
                <?php $this->renderRepeaterRow($field['fields'], array(), '', '', $rowTitle); ?>
                <?php $this->renderRepeaterRow($field['fields'], [], '', $name, $rowTitle); ?>
            </template>
            <button type="button" class="add-repeater-row">
                <?= jvbIcon('plus', ['title'=> 'Add']); ?> <?= (array_key_exists('add_label', $field)) ? $field['add_label'] : 'Add Item'; ?>
            <button type="button" class="add-repeater-row button secondary">
                <?= jvbIcon('plus', ['title' => 'Add']) ?>
                <span><?= esc_html($addLabel) ?></span>
            </button>
            <?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 = 'New Item'):void
    private function renderRepeaterRow(array $fields, array $values, int|string $index, string $base_name, string $rowTitle): 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) :
                        if ($base_name === '') {
                            $field_name = $slug;
                        } else {
                            $field_name = sprintf('%s:%s:%s', $base_name, $index, $slug);
                    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);
                        }
                        $field_value = isset($values[$slug]) ? $values[$slug] : '';
                        $name = $field_name;
                        $this->render($name, $field_value, $field);
                    endforeach;
                    ?>
                </div>
            </details>
@@ -508,122 +703,7 @@
        return $rowTitle;
    }
    private function renderTaxonomyField(string $name, mixed $value, array $field):void
    {
        $conditional = $this->handleConditionalField($field);
        $taxonomy = $field['taxonomy'];
        // Get currently selected terms
        $selected_terms = ($value === '') ? [] : explode(',', $value);
        // Convert selected term IDs to the format expected by single modal
        $processedSelected = [];
        if (!empty($selected_terms)) {
            foreach ($selected_terms as $termId) {
                if (is_numeric($termId)) {
                    $term = get_term($termId, $taxonomy);
                    if ($term && !is_wp_error($term)) {
                        $processedSelected[$term->term_id] = [
                            'name' => $term->name,
                            'path' => TaxonomySelector::getTermPath($term)
                        ];
                    }
                }
            }
        }
        // Create configuration for single modal system
        $config = [
            'taxonomy' => $taxonomy,
            'max' => $field['limit'] ?? 0,
            'search' => $field['search'] ?? true,
            'createNew' => $field['createNew'] ?? false,
            'selected' => $processedSelected,
            'base' => $field['base'] ?? '',
        ];
        $describedBy = (!empty($field['description'])) ? ' aria-describedby="'.$name.'-help"' : '';
        ?>
        <div class="field taxonomy <?=$name?>" <?= $conditional ?> data-field="<?=$name?>">
            <div class="field-group-header">
                <label class="toggle">
                    <?= jvbIcon(str_replace(BASE, '', $taxonomy)) ?>
                    <?= esc_html($field['label']) ?>
                </label>
            </div>
            <?php
            $tax = new TaxonomySelector($name, $taxonomy, $config);
            $extra = '<input type="hidden"
                   name="'.esc_attr($name).'"
                   id="'.esc_attr($name).'"'.$describedBy.'
                   data-taxonomy="'.esc_attr($taxonomy).'"
                   value="'.esc_attr(is_array($selected_terms) ? implode(',', $selected_terms) : $selected_terms).'">';
            echo $tax->render([], $extra);
            ?>
            <?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
    }
    protected function renderPostSelectorField(string $name, mixed $value, array $field):void
    {
        $conditional = $this->handleConditionalField($field);
        // Process selected posts
        $selected_posts = $value;
        if (is_string($selected_posts)) {
            $selected_posts = !empty($selected_posts) ? explode(',', $selected_posts) : [];
        } elseif (!is_array($selected_posts)) {
            $selected_posts = [];
        }
        // Configure the post selector
        $config = [
            'multiple' => $field['multiple'] ?? true,
            'maxSelections' => $field['limit'] ?? 0,
            'search' => true,
            'placeholder' => $field['placeholder'] ?? 'Search posts...',
            'noResults' => 'No posts found',
            'shop_id' => $field['shop_id'] ?? null,
            'onClose' => 'updateMetaFormPost'
        ];
        $postSelector = new PostSelector($field['post_type'], $config);
        $containerId = $name . '-post-selector';
        $describedBy = (!empty($field['description'])) ? ' aria-describedby="'.$name.'-help"' : '';
        ?>
        <div class="field post-selector <?=$name?>" <?= $conditional ?> data-field="<?=$name?>">
            <div class="field-group-header">
                <label class="toggle">
                    <?= jvbIcon($field['post_type'] . '-selector') ?>
                    <?= esc_html($field['label'] ?? ucfirst($field['post_type'])) ?>
                </label>
                <button title="Add <?= esc_attr(ucfirst($field['post_type'])) ?>"
                        class="add-item-btn"
                        type="button">
                    <?= jvbIcon('add', ['title' => "Add " . ucfirst($field['post_type'])]) ?>
                </button>
            </div>
            <?= $postSelector->render($selected_posts, $containerId) ?>
            <!-- Hidden input for form submission -->
            <input type="hidden"
                   name="<?= array_key_exists('base', $field) ? esc_attr($field['base']) : ''?><?= esc_attr($name) ?>"
                   class="post-selector-input"
                   <?= $describedBy ?>
                   data-post-type="<?= esc_attr($field['post_type']) ?>"
                   value="<?= esc_attr(is_array($selected_posts) ? implode(',', $selected_posts) : $selected_posts) ?>">
            <?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
    }
    /* ========== GROUP FIELD ========== */
    protected function renderGroupField(string $name, mixed $value, array $field):void
    {
@@ -631,56 +711,293 @@
            return;
        }
        // Handle conditional fields
        $conditional = $this->handleConditionalField($field);
        // Ensure value is an array
        $values = is_array($value) ? $value : [];
        $original = $name;
        if (array_key_exists('group', $field)) {
            $name = $field['group'].'::'.$name;
        }
        $describedBy = (!empty($field['description'])) ? ' aria-describedby="'.$name.'-help"' : '';
        $hidden = (array_key_exists('mode', $field) && $field['mode'] === 'hidden');
        if (!$hidden) {
        if ($hidden) {
            // Simplified render for hidden groups
            $this->renderGroupFields($name, $values, $field);
            return;
        }
        // Standard fieldset render
        $conditional = $this->handleConditionalField($field);
        $validationAttrs = $this->buildValidationAttributes($field);
        $describedBy = (!empty($field['description'])) ? ' aria-describedby="' . $name . '-help"' : '';
            ?>
            <fieldset class="field group <?= esc_attr($name) ?>" <?= $conditional ?> data-field="<?=$name?>"<?= $describedBy?>>
        <fieldset class="field group <?= esc_attr($name) ?>"
            <?= $conditional ?>
                  data-field="<?= esc_attr($name) ?>"
            <?= $validationAttrs ?>
            <?= $describedBy ?>>
                <legend><?= esc_html($field['label']) ?></legend>
            <?php $this->renderHintAndDescription($field, $name); ?>
            <div class="group-fields <?= esc_attr($original) ?>">
                <?php $this->renderGroupFields($name, $values, $field); ?>
            </div>
            <span class="validation-message" hidden role="alert"></span>
        </fieldset>
            <?php
        }
        ?>
            <div class="group-fields <?=$original?>"<?= ($hidden) ? ' data-field="'.$name.'"' : ''?>"<?= $describedBy ?>>
        <?php if (array_key_exists('hint', $field)) { $this->renderHint($field['hint']); }  ?>
        <?php if (array_key_exists('description', $field)) { $this->renderDescription($field['description'], $name); }  ?>
                <?php
    /**
     * Render individual fields within a group
     * Reusable for both standard and hidden group modes
     */
    private function renderGroupFields(string $groupName, array $values, array $field): void
    {
                foreach ($field['fields'] as $field_name => $config) {
                    // Set the group context for proper field naming
                    $config['group'] = $name;
            $config['group'] = $groupName;
                    // Get the value for this specific field
                    $field_value = $values[$field_name] ?? '';
                    // Handle conditional fields within the group
                    if (isset($config['condition'])) {
                        // Convert condition field reference to group context
                        $condition_field = $config['condition']['field'];
                        if (!str_contains($condition_field, '::')) {
                            $config['condition']['field'] = $name . '::' . $condition_field;
                    $config['condition']['field'] = $groupName . '::' . $condition_field;
                        }
                    }
                    $this->render($field_name, $field_value, $config);
                }
    }
    /* ========== UPLOAD 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,
            'create_thumbnails' => true,
        ], $field);
        // Validate destination config
        if (in_array($config['destination'], ['post', 'post_group']) && empty($config['content'])) {
            error_log("Upload field '{$name}' has destination '{$config['destination']}' but no content defined");
            return;
        }
        if (array_key_exists('group', $field)) {
            $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'];
        }
        $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"
                           accept="<?= esc_attr($acceptAttr) ?>"
                        <?= $config['multiple'] ? 'multiple' : '' ?>>
                    <p class="file-upload-text">
                        <strong>Click to upload</strong> or drag and drop<br>
                        <?= esc_html($this->getUploadInstructions($config)) ?>
                    </p>
                </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' : '' ?>>
            </div>
        <?php
        if (!$hidden) {
        });
    }
    /**
     * Render upload preview items
     */
    private function renderUploadPreviews(array $attachmentIds, array $config): void
    {
        if (empty($attachmentIds)) {
            return;
        }
        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>';
                }
            }
            // Add other subtypes (video, document) as needed
        }
    }
    /**
     * Get upload instruction text based on config
     */
    private function getUploadInstructions(array $config): string
    {
        $extensions = $this->getMimeExtensions($this->getAllowedTypes($config));
        $extList = implode(', ', array_map('strtoupper', $extensions));
        $maxSize = $config['max_size'] ?? $this->max_file_size;
        $maxSizeMB = round($maxSize / 1048576, 1);
        return "{$extList} (max. {$maxSizeMB}MB)";
    }
    /* ========== TAXONOMY/USER SELECTOR FIELDS ========== */
    private function renderTaxonomyField(string $name, mixed $value, array $field): void
    {
        if (array_key_exists('group', $field)) {
            $name = $field['group'] . '::' . $name;
        }
        $this->renderSelectorField($name, $value, $field, 'taxonomy');
    }
    private function renderUserField(string $name, mixed $value, array $field): void
    {
        if (array_key_exists('group', $field)) {
            $name = $field['group'] . '::' . $name;
        }
        $this->renderSelectorField($name, $value, $field, 'post');
    }
    /**
     * Generic selector field renderer
     * Handles both taxonomy and post selectors with consistent structure
     */
    private function renderSelectorField(string $name, mixed $value, array $field, string $type): void
    {
        $conditional = $this->handleConditionalField($field);
        $validationAttrs = $this->buildValidationAttributes($field);
        $describedBy = (!empty($field['description'])) ? ' aria-describedby="' . $name . '-help"' : '';
        // Parse selected values
        $selected = ($value === '') ? [] : (is_array($value) ? $value : explode(',', $value));
        // Create selector instance
        if ($type === 'taxonomy') {
            $taxonomy = $field['taxonomy'];
            $selectorConfig = [
                'multiple' => $field['multiple'] ?? true,
                'placeholder' => $field['placeholder'] ?? 'Search terms...',
                'noResults' => 'No terms found',
                'onClose' => 'updateMetaFormTaxonomy'
            ];
            $selector = new TaxonomySelector($taxonomy, $selectorConfig);
            $icon = $taxonomy;
        } else {
            $postType = $field['post_type'];
            $selectorConfig = [
                'multiple' => $field['multiple'] ?? true,
                'placeholder' => $field['placeholder'] ?? 'Search posts...',
                'noResults' => 'No posts found',
                'shop_id' => $field['shop_id'] ?? null,
                'onClose' => 'updateMetaFormPost'
            ];
            $selector = new PostSelector($postType, $selectorConfig);
            $icon = $postType;
        }
        $containerId = $name . '-' . $type . '-selector';
            ?>
            </fieldset>
        <div class="field <?= esc_attr($type) ?>-selector <?= 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) ?>
            <!-- Hidden input for form submission -->
            <input type="hidden"
                   class="<?= esc_attr($type) ?>-selector-input"
                   name="<?= array_key_exists('base', $field) ? esc_attr($field['base']) : '' ?><?= esc_attr($name) ?>"
                   data-<?= esc_attr($type) ?>="<?= esc_attr($field[$type === 'taxonomy' ? 'taxonomy' : 'post_type']) ?>"
                   value="<?= esc_attr(is_array($selected) ? implode(',', $selected) : $value) ?>"
                <?= !empty($field['required']) ? 'required' : '' ?>>
            <?php $this->renderHintAndDescription($field, $name); ?>
            <span class="validation-message" hidden role="alert"></span>
        </div>
            <?php
        }
    }
    /* ========== LOCATION FIELD ========== */
    protected function renderLocationField(string $name, mixed $value, array $field): void
    {
        $googleMaps = JVB()->connect('maps');
@@ -689,7 +1006,7 @@
            return;
        }
        // Extract stored values
        // Parse stored data
        if (is_string($value)) {
            $value = maybe_unserialize($value);
        }
@@ -698,18 +1015,15 @@
        $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 configuration for JavaScript initialization
        // Prepare JavaScript configuration
        $field_id = esc_attr($name);
        $map_id = $field_id . '_map';
        $js_config = [
            'fieldId' => $field_id,
            'initialCoords' => (!empty($lat) && !empty($lng)) ? [
@@ -718,1376 +1032,68 @@
            ] : null
        ];
        // IMPORTANT: Properly escape the JSON for use in HTML attribute
        $json_config = htmlspecialchars(json_encode($js_config), ENT_QUOTES, 'UTF-8');
        $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)) ?>">
        <div class="field location <?= esc_attr($field_id) ?>"
             data-field="<?= esc_attr($field_id) ?>"
             data-location-field-init="<?= $json_config ?>"<?=$describedBy?>>
                <?php if (!empty($street)) : ?>
                    <p class="current-location">
                        <strong>Current location:</strong> <?= esc_html($street) ?>
                    </p>
                <?php endif; ?>
            <?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
    }
    //TODO: This is more or less handled by PostSelector/TaxonomySelector, no?
    private function renderAssociationField(string $name, mixed $value, array $field):void
    {
        // Ensure value is an array
        if (!is_array($value)) {
            $value = empty($value) ? [] : [$value];
        }
        // Get field configuration
        $limit = isset($field['limit']) ? (int)$field['limit'] : 0;
        $object_types = isset($field['object_types']) ? $field['object_types'] : ['post'];
        $post_types = isset($field['post_types']) ? $field['post_types'] : ['post'];
        $taxonomies = isset($field['taxonomies']) ? $field['taxonomies'] : [];
        $describedBy = (!empty($field['description'])) ? ' aria-describedby="'.$name.'-help"' : '';
        // Create unique ID for this field
        $field_id = 'association-' . esc_attr($name);
        $conditional = $this->handleConditionalField($field);
        if (array_key_exists('group', $field)) {
            $name = $field['group'].'::'.$name;
        }
        ?>
        <div class="field association <?=$name?>" data-field="<?= esc_attr($name); ?>" <?= $conditional; ?>>
            <label><?= esc_html($field['label']); ?></label>
            <?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="association-container"<?=$describedBy?>>
                <div class="association-search">
                <label for="<?= esc_attr($data['id']) ?>">Address</label>
                    <input type="text"
                           id="<?= esc_attr($field_id); ?>-search"
                           class="association-search-input"
                           placeholder="Search items...">
                    <div class="association-filter">
                        <?php if (count($object_types) > 1 || count($post_types) > 1 || count($taxonomies) > 0) : ?>
                            <select class="association-filter-select">
                                <?php if (in_array('post', $object_types)) : ?>
                                    <?php foreach ($post_types as $post_type) : ?>
                                        <?php
                                        $post_type_obj = get_post_type_object($post_type);
                                        $label = $post_type_obj ? $post_type_obj->labels->singular_name : ucfirst($post_type);
                                        ?>
                                        <option value="post:<?= esc_attr($post_type); ?>">
                                            <?= esc_html($label); ?>
                                        </option>
                                    <?php endforeach; ?>
                                <?php endif; ?>
                                <?php if (in_array('term', $object_types)) : ?>
                                    <?php foreach ($taxonomies as $taxonomy) : ?>
                                        <?php
                                        $tax_obj = get_taxonomy($taxonomy);
                                        $label = $tax_obj ? $tax_obj->labels->singular_name : ucfirst($taxonomy);
                                        ?>
                                        <option value="term:<?= esc_attr($taxonomy); ?>">
                                            <?= esc_html($label); ?>
                                        </option>
                                    <?php endforeach; ?>
                                <?php endif; ?>
                            </select>
                        <?php endif; ?>
                        <button type="button" class="search-button">
                            <?= jvbIcon('search', ['title' => 'Search']); ?>
                        </button>
                    </div>
                </div>
                <div class="association-results">
                    <div class="association-available">
                        <h4>Available Items</h4>
                        <ul class="available-items"></ul>
                        <div class="association-loading" hidden>
                            Loading...
                        </div>
                        <div class="association-no-results" hidden>
                            No items found
                        </div>
                        <div class="association-pagination">
                            <button type="button" class="prev-page" disabled>
                                <?= jvbIcon('arrow-left', ['title' => 'Previous']); ?>
                            </button>
                            <span class="page-info">Page <span class="current-page">1</span></span>
                            <button type="button" class="next-page" disabled>
                                <?= jvbIcon('arrow-right', ['title' => 'Next']); ?>
                            </button>
                        </div>
                    </div>
                    <div class="association-actions">
                        <button type="button" class="add-selected" disabled>
                            <?= jvbIcon('arrow-right', ['title' => 'Add selected']); ?>
                        </button>
                        <button type="button" class="remove-selected" disabled>
                            <?= jvbIcon('arrow-left', ['title' => 'Remove selected']); ?>
                        </button>
                    </div>
                    <div class="association-selected">
                        <h4>Selected Items
                            <?php if ($limit) : ?>
                                <span class="limit-info">(<?= esc_html($limit); ?> max)</span>
                            <?php endif; ?>
                        </h4>
                        <ul class="selected-items row">
                            <?php
                            // Display currently selected items
                            foreach ($value as $item_id) {
                                // Try to determine the type and get details
                                $item_type = '';
                                $item_title = '';
                                $item_object = '';
                                // Check if it's a post
                                if (in_array('post', $object_types)) {
                                    $post = get_post($item_id);
                                    if ($post && in_array($post->post_type, $post_types)) {
                                        $item_type = 'post';
                                        $item_title = $post->post_title;
                                        $item_object = $post->post_type;
                                    }
                                }
                                // Check if it's a term
                                if (empty($item_type) && in_array('term', $object_types)) {
                                    foreach ($taxonomies as $taxonomy) {
                                        $term = get_term($item_id, $taxonomy);
                                        if (!is_wp_error($term) && $term) {
                                            $item_type = 'term';
                                            $item_title = $term->name;
                                            $item_object = $term->taxonomy;
                                            break;
                                        }
                                    }
                                }
                                // Only output if we found the item
                                if (!empty($item_type) && !empty($item_title)) {
                                    ?>
                                    <li data-id="<?= esc_attr($item_id); ?>"
                                        data-type="<?= esc_attr($item_type); ?>"
                                        data-object="<?= esc_attr($item_object); ?>">
                                        <span class="item-title"><?= esc_html($item_title); ?></span>
                                        <span class="item-type"><?= esc_html(ucfirst($item_object)); ?></span>
                                        <button type="button" class="remove-item row">
                                            <?= jvbIcon('close', ['title' => 'Remove']); ?>
                                        </button>
                                    </li>
                                    <?php
                                }
                            }
                            ?>
                        </ul>
                    </div>
                </div>
            </div>
            <!-- Hidden input to store selected values -->
            <input type="hidden" name="<?= array_key_exists('base', $field) ? esc_attr($field['base']) : ''?><?= esc_attr($name); ?>" value="<?= esc_attr(implode(',', $value)); ?>">
        </div>
        <script>
            (function() {
                // Initialize association field
                const container = document.querySelector('[data-field="<?= esc_attr($name); ?>"]');
                if (!container) return;
                const searchInput = container.querySelector('.association-search-input');
                const filterSelect = container.querySelector('.association-filter-select');
                const searchButton = container.querySelector('.search-button');
                const availableList = container.querySelector('.available-items');
                const selectedList = container.querySelector('.selected-items');
                const hiddenInput = container.querySelector('input[name="<?= array_key_exists('base', $field) ? esc_attr($field['base']) : ''?><?= esc_attr($name); ?>"]');
                const addButton = container.querySelector('.add-selected');
                const removeButton = container.querySelector('.remove-selected');
                const loadingIndicator = container.querySelector('.association-loading');
                const noResultsMessage = container.querySelector('.association-no-results');
                const prevPageButton = container.querySelector('.prev-page');
                const nextPageButton = container.querySelector('.next-page');
                const currentPageSpan = container.querySelector('.current-page');
                // Configuration
                const config = {
                    limit: <?= $limit ?: 0; ?>,
                    objectTypes: <?= json_encode($object_types); ?>,
                    postTypes: <?= json_encode($post_types); ?>,
                    taxonomies: <?= json_encode($taxonomies); ?>,
                    perPage: 10,
                    currentPage: 1
                };
                // Current state
                let currentSearch = '';
                let currentFilter = filterSelect ? filterSelect.value : (config.objectTypes.includes('post') ? 'post:' + config.postTypes[0] : 'term:' + config.taxonomies[0]);
                let availableItems = [];
                let selectedItems = [];
                // Parse initial selected items
                const initialValue = hiddenInput.value;
                if (initialValue) {
                    selectedItems = initialValue.split(',').map(id => parseInt(id, 10));
                }
                // Event Listeners
                if (searchButton) {
                    searchButton.addEventListener('click', performSearch);
                }
                if (searchInput) {
                    searchInput.addEventListener('keypress', function(e) {
                        if (e.key === 'Enter') {
                            e.preventDefault();
                            performSearch();
                        }
                    });
                }
                if (filterSelect) {
                    filterSelect.addEventListener('change', function() {
                        currentFilter = this.value;
                        config.currentPage = 1;
                        performSearch();
                    });
                }
                if (prevPageButton) {
                    prevPageButton.addEventListener('click', function() {
                        if (config.currentPage > 1) {
                            config.currentPage--;
                            performSearch();
                        }
                    });
                }
                if (nextPageButton) {
                    nextPageButton.addEventListener('click', function() {
                        config.currentPage++;
                        performSearch();
                    });
                }
                // Add items
                addButton.addEventListener('click', function() {
                    const selectedAvailableItems = availableList.querySelectorAll('li.selected');
                    selectedAvailableItems.forEach(item => {
                        const id = parseInt(item.dataset.id, 10);
                        // Check limit
                        if (config.limit && selectedItems.length >= config.limit) {
                            return;
                        }
                        // Skip if already selected
                        if (selectedItems.includes(id)) {
                            return;
                        }
                        // Add to selection
                        selectedItems.push(id);
                        // Clone and modify for selected list
                        const clone = item.cloneNode(true);
                        clone.classList.remove('selected');
                        // Replace checkbox with remove button
                        const checkbox = clone.querySelector('input[type="checkbox"]');
                        if (checkbox) {
                            const removeBtn = document.createElement('button');
                            removeBtn.type = 'button';
                            removeBtn.className = 'remove-item';
                            removeBtn.innerHTML = '<?= jvbIcon('close', ['title' => 'Remove']); ?>';
                            removeBtn.addEventListener('click', function() {
                                removeItem(id, clone);
                            });
                            checkbox.parentNode.replaceChild(removeBtn, checkbox);
                        }
                        selectedList.appendChild(clone);
                    });
                    // Update hidden input
                    updateHiddenInput();
                    // Update UI state
                    updateButtonStates();
                });
                // Remove items
                removeButton.addEventListener('click', function() {
                    const selectedSelectedItems = selectedList.querySelectorAll('li.selected');
                    selectedSelectedItems.forEach(item => {
                        const id = parseInt(item.dataset.id, 10);
                        removeItem(id, item);
                    });
                });
                // Listen for clicks on items in both lists
                availableList.addEventListener('click', function(e) {
                    const item = e.target.closest('li');
                    if (!item) return;
                    // If clicking checkbox, handle separately
                    if (e.target.type === 'checkbox') {
                        updateButtonStates();
                        return;
                    }
                    // Toggle selection
                    if (item.classList.contains('selected')) {
                        item.classList.remove('selected');
                        item.querySelector('input[type="checkbox"]').checked = false;
                    } else {
                        item.classList.add('selected');
                        item.querySelector('input[type="checkbox"]').checked = true;
                    }
                    updateButtonStates();
                });
                selectedList.addEventListener('click', function(e) {
                    const item = e.target.closest('li');
                    if (!item) return;
                    // If clicking remove button, handle it
                    if (e.target.closest('.remove-item')) {
                        const id = parseInt(item.dataset.id, 10);
                        removeItem(id, item);
                        return;
                    }
                    // Toggle selection
                    item.classList.toggle('selected');
                    updateButtonStates();
                });
                // Helper Functions
                function performSearch() {
                    currentSearch = searchInput.value.trim();
                    // Show loading
                    loadingIndicator.hidden = false;
                    noResultsMessage.hidden = true;
                    availableList.innerHTML = '';
                    // Get filter parts
                    const [type, object] = currentFilter.split(':');
                    // Prepare data for AJAX
                    const data = {
                        action: 'jvb_association_search',
                        nonce: jvbSettings.nonce,
                        type: type,
                        object: object,
                        search: currentSearch,
                        page: config.currentPage,
                        per_page: config.perPage,
                        selected: selectedItems
                    };
                    // Make AJAX request to WordPress REST API
                    fetch(jvbSettings.api + 'terms', {
                        method: 'POST',
                        headers: {
                            'Content-Type': 'application/json',
                            'X-WP-Nonce': jvbSettings.nonce
                        },
                        body: JSON.stringify(data)
                    })
                        .then(response => response.json())
                        .then(response => {
                            // Hide loading
                            loadingIndicator.hidden = true;
                            if (response.success && response.items && response.items.length > 0) {
                                // Update available items
                                availableItems = response.items;
                                // Render items
                                renderAvailableItems();
                                // Update pagination
                                updatePagination(response.total, response.pages);
                            } else {
                                // Show no results
                                noResultsMessage.hidden = false;
                                prevPageButton.disabled = true;
                                nextPageButton.disabled = true;
                                currentPageSpan.textContent = '1';
                            }
                        })
                        .catch(error => {
                            console.error('Error searching items:', error);
                            loadingIndicator.hidden = true;
                            noResultsMessage.hidden = false;
                        });
                }
                function renderAvailableItems() {
                    availableList.innerHTML = '';
                    availableItems.forEach(item => {
                        const isSelected = selectedItems.includes(item.id);
                        const li = document.createElement('li');
                        li.dataset.id = item.id;
                        li.dataset.type = item.type;
                        li.dataset.object = item.object;
                        // Create checkbox
                        const checkbox = document.createElement('input');
                        checkbox.type = 'checkbox';
                        checkbox.id = `${name}-item-${item.id}`;
                        // Create label for title
                        const titleSpan = document.createElement('span');
                        titleSpan.className = 'item-title';
                        titleSpan.textContent = item.title;
                        // Create label for type
                        const typeSpan = document.createElement('span');
                        typeSpan.className = 'item-type';
                        typeSpan.textContent = item.object_label;
                        // Append elements
                        li.appendChild(checkbox);
                        li.appendChild(titleSpan);
                        li.appendChild(typeSpan);
                        // Disable if already selected
                        if (isSelected) {
                            li.classList.add('disabled');
                            checkbox.disabled = true;
                            // Add note that item is already selected
                            const note = document.createElement('span');
                            note.className = 'item-note';
                            note.textContent = 'Already selected';
                            li.appendChild(note);
                        }
                        availableList.appendChild(li);
                    });
                }
                function updatePagination(total, pages) {
                    // Update current page display
                    currentPageSpan.textContent = config.currentPage;
                    // Update prev/next buttons
                    prevPageButton.disabled = config.currentPage <= 1;
                    nextPageButton.disabled = config.currentPage >= pages;
                }
                function removeItem(id, element) {
                    // Remove from array
                    selectedItems = selectedItems.filter(itemId => itemId !== id);
                    // Remove from DOM
                    if (element) {
                        element.remove();
                    }
                    // Update hidden input
                    updateHiddenInput();
                    // Update buttons
                    updateButtonStates();
                }
                function updateHiddenInput() {
                    hiddenInput.value = selectedItems.join(',');
                }
                function updateButtonStates() {
                    // Add button is enabled if at least one available item is selected
                    // and we haven't reached the limit
                    const hasSelectedAvailable = availableList.querySelector('li.selected:not(.disabled)') !== null;
                    addButton.disabled = !hasSelectedAvailable ||
                        (config.limit > 0 && selectedItems.length >= config.limit);
                    // Remove button is enabled if at least one selected item is selected
                    const hasSelectedItems = selectedList.querySelector('li.selected') !== null;
                    removeButton.disabled = !hasSelectedItems;
                }
                // Initial search
                performSearch();
            })();
        </script>
        <?php
    }
    private function renderTrueFalseField(string $name, mixed $value, array $field):void
    {
        $conditional = $this->handleConditionalField($field);
        if (array_key_exists('group', $field)) {
            $name = $field['group'].'::'.$name;
        }
        $describedBy = (!empty($field['description'])) ? ' aria-describedby="'.$name.'-help"' : '';
        ?>
        <div class="field true-false <?=$name?> row btw" <?=$conditional?> data-field="<?=$name?>">
            <label class="toggle-switch row"<?=$describedBy?>>
                <input
                    type="checkbox"
                    name="<?= array_key_exists('base', $field) ? esc_attr($field['base']) : ''?><?= esc_attr($name); ?>"
                    value="1"
                    <?= ($value) ? ' checked':'' ?>
                    <?= !empty($field['required']) ? 'required' : ''; ?>
                >
                <div class="slider"></div>
                <span class="toggle-label"><?= esc_html($field['label']); ?></span>
            </label>
            <?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 renderUploadField(string $name, mixed $value, array $field): void
    {
        $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,
        ];
        $config = array_merge($defaultConfig, $field);
        // Validate destination config
        if (in_array($config['destination'], ['post', 'post_group']) && empty($config['content'])) {
            error_log("Upload field '{$name}' has destination '{$config['destination']}' but no content defined");
            return;
        }
        // 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;
        }
        // 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 ?>>
            <div class="file-upload-container">
                <div class="file-upload-wrapper">
                    <input type="file"
                           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) ?>"
                           data-max-size="<?= esc_attr($this->getMaxFileSize($subtype)) ?>"
                        <?= $multiple ? 'multiple' : '' ?>
                       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' : '' ?>>
                    <h2><?= esc_html($field['label']) ?></h2>
                <div id="<?= esc_attr($map_id) ?>" class="location-map" style="height: 300px;"></div>
                    <?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->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>
            <?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 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; ?>
                <!-- 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
        });
    }
    protected function getAllowedTypes(array $config):array
    {
        $typeMap = [
            'image' => [
                'image/jpeg',
                'image/png',
                'image/gif',
                'image/webp'
            ],
            'video' => [
                'video/mp4',
                'video/webm',
                'video/ogg',
                'video/ogv',
                'video/quicktime'
            ],
            'document' => [
                'application/pdf',
                'application/msword',
                'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
                'application/vnd.ms-excel',
                'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
                'text/plain',
                'text/csv'
            ],
            'any' => [] // Will be merged from all types
        ];
        // If specific types are defined, use those
        if (!empty($config['accepted_types'])) {
            return is_array($config['accepted_types'])
                ? $config['accepted_types']
                : [$config['accepted_types']];
        }
        // Otherwise use subtype defaults
        $subtype = $config['subtype'] ?? 'image';
        if ($subtype === 'any') {
            return array_merge(
                $typeMap['image'],
                $typeMap['video'],
                $typeMap['document']
            );
        }
        return $typeMap[$subtype] ?? $typeMap['image'];
    }
    /**
     * Parse attachment IDs from value
     */
    private function parseAttachmentIds(mixed $value): array
    {
        if (empty($value)) return [];
        if (is_array($value)) {
            return array_filter(array_map('absint', $value));
        }
        return array_filter(array_map('absint', explode(',', $value)));
    }
    /**
     * Get file extensions for MIME types
     */
    private function getMimeExtensions(array $mimeTypes): array
    {
        $extensionMap = [
            'image/jpeg' => ['.jpg', '.jpeg'],
            'image/png' => ['.png'],
            'image/gif' => ['.gif'],
            'image/webp' => ['.webp'],
            'video/mp4' => ['.mp4'],
            'video/webm' => ['.webm'],
            'video/ogg' => ['.ogg'],
            'video/ogv' => ['.ogv'],
            'video/quicktime' => ['.mov'],
            'application/pdf' => ['.pdf'],
            'application/msword' => ['.doc'],
            'application/vnd.openxmlformats-officedocument.wordprocessingml.document' => ['.docx'],
            'application/vnd.ms-excel' => ['.xls'],
            'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' => ['.xlsx'],
            'text/plain' => ['.txt'],
            'text/csv' => ['.csv'],
        ];
        $extensions = [];
        foreach ($mimeTypes as $mime) {
            if (isset($extensionMap[$mime])) {
                $extensions = array_merge($extensions, $extensionMap[$mime]);
            }
        }
        return array_unique($extensions);
    }
    /**
     * 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'];
    }
    /**
     * Get human-readable file size label
     */
    private function getMaxFileSizeLabel(string $subtype): string
    {
        $bytes = $this->getMaxFileSize($subtype);
        $mb = round($bytes / 1048576);
        return "{$mb}MB";
    }
    /**
     * 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))));
    }
    /**
     * Render existing attachment
     */
    private function renderExistingAttachment(int $attachmentId, string $subtype): string
    {
        $attachment = get_post($attachmentId);
        if (!$attachment) return '';
        $url = wp_get_attachment_url($attachmentId);
        $thumbUrl = $subtype === 'image'
            ? wp_get_attachment_image_url($attachmentId, 'medium')
            : $url;
        ob_start();
        ?>
        <div class="upload-item existing" data-attachment-id="<?= esc_attr($attachmentId) ?>" data-subtype="<?= esc_attr($subtype) ?>">
            <div class="preview">
                <?php if ($subtype === 'image') : ?>
                    <img src="<?= esc_url($thumbUrl) ?>" alt="<?= esc_attr(get_post_meta($attachmentId, '_wp_attachment_image_alt', true)) ?>">
                <?php elseif ($subtype === 'video') : ?>
                    <video src="<?= esc_url($url) ?>" controls></video>
                <?php else : ?>
                    <div class="document-preview">
                        <?= jvbIcon('document') ?>
                        <span><?= esc_html(basename($url)) ?></span>
                    </div>
                <?php endif; ?>
                <div class="overlay">
                    <div class="actions">
                        <button type="button" class="remove" title="Remove">
                            <span class="screen-reader-text">Remove <?= esc_attr($subtype) ?></span>
                            Ã—
                        </button>
                    </div>
                </div>
            </div>
            <?php if ($subtype === 'image') {
                echo jvbImageMeta();
            } ?>
        </div>
        <?php
        return ob_get_clean();
    }
    private function renderImageField(string $name, mixed $value, array $field):void
    {
        $image_url = $title = $alt = $caption = false;
        if ($value !== 0 || $value !== '') {
            $image_url = wp_get_attachment_image_url((int)$value, 'medium') ?: false;
            $caption = wp_get_attachment_caption((int)$value);
            $alt = get_post_meta((int)$value, '_wp_attachment_image_alt', true);
            $title = get_the_title((int)$value);
        }
        $mode = array_key_exists('mode', $field) ? $field['mode'] : 'direct';
        $multiple = ($mode === 'selection' || isset($field['multiple']));
        if (array_key_exists('group', $field)) {
            $name = $field['group'].'::'.$name;
        }
        $groupable = (array_key_exists('imageType', $field) && $field['imageType'] === 'groupable');
        $singular = (array_key_exists('singular', $field)) ? $field['singular'] : 'post';
        $plural = (array_key_exists('plural', $field)) ? $field['plural'] : 'posts';
        $dataContent = (array_key_exists('content', $field)) ? ' data-content="'.$field['content'].'"' : '';
        $dataType = ($groupable) ? 'groupable' : (($multiple) ? 'gallery' : 'single');
        $describedBy = (!empty($field['description'])) ? ' aria-describedby="'.$name.'-help"' : '';
        ?>
        <div class="field image <?=$name?>"
             data-field="<?= esc_attr($name); ?>"
             data-upload-field
             data-mode="<?= esc_attr($mode); ?>"
             <?=$dataContent?>
            <?= ' data-type="'.$dataType.'"'?>>
            <div class="file-upload-container">
                <div class="file-upload-wrapper">
                    <input type="file"
                           name="<?= array_key_exists('base', $field) ? esc_attr($field['base']) : ''?><?= esc_attr($name); ?>_temp"
                           id="<?= array_key_exists('base', $field) ? esc_attr($field['base']) : ''?><?= esc_attr($name); ?>_temp"
                           accept=".jpg,.jpeg,.png,.gif,.webp"
                           data-max-size="<?= $this->max_file_size; ?>"
                        <?= $multiple ? 'multiple' : ''; ?>>
                    <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>
                        JPG, PNG, GIF, or WEBP (max. 5MB)
                    </p>
                    <?php if ($groupable) { ?>
                    <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 } ?>
                    <?php if (!empty($field['upload_description'])) : ?>
                        <p><?= esc_html($field['upload_description']); ?></p>
                    <?php endif; ?>
                </div>
                <div class="file-error"></div>
            </div>
            <?php if ($groupable) : ?>
            <div class="group-display" hidden>
                <div class="preview-wrap">
                    <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>
                                    With <span class="selection-count">0</span> selected
                                </div>
                            </div>
                            <!-- Selection actions (hidden by default) -->
                            <div class="selection-actions" hidden>
                                <button type="button" class="create-from-selection">
                                    <?= jvbIcon('add') ?>
                                    Create New <?= $singular ?>
                                </button>
                                <button type="button" class="remove-selection">
                                    <?= jvbIcon('delete') ?>
                                    Remove
                                </button>
                            </div>
                        </div>
                        <button type="button" 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 if ($image_url) {
                            echo jvbRenderImageForm((int)$value);
                        } ?>
                    </div>
                    <?php if ($groupable) : ?>
                    <p class="hint"><?= jvbIcon('elbow-left-up') ?>  These will become individual <?= $plural ?>  <?= jvbIcon('elbow-right-up')?></p>
                </div>
                <div class="sidebar">
                    <div class="header">
                        <h4>New <?= $plural?></h4>
                        <p class="hint">Drag images into groups to create separate <?= $plural ?>.</p>
                        <p class="hint">Select multiple images and click "Add to <?= $singular?>" or create new <?= $plural ?>.</p>
                    </div>
                    <button type="button" class="create-group-from-selection">
                        <?= jvbIcon('add') ?>
                        Create New <?= $singular ?>
                    </button>
                    <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 endif; ?>
            <?php if ($mode === 'direct') : ?>
                <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
    }
    protected function renderGalleryField(string $name, string|null|false $value, array $field):void
    {
        $ids = ($value === '' || is_null($value) || !$value) ? [] : explode(',',$value);
        if (!empty($ids)) {
            $ids = array_map('absint', $ids);
        }
        $conditional = $this->handleConditionalField($field);
        if (array_key_exists('group', $field)) {
            $name = $field['group'].'::'.$name;
        }
        //TODO: This can probably just be a wrapper for renderImageField...
        ?>
        <div class="field gallery <?=$name?>"
             data-field="<?= esc_attr($name); ?>"
            <?= $conditional ?>>
            <label><?= esc_html($field['label']); ?></label>
            <!-- Container for existing images -->
            <div class="gallery-preview">
                <?php
                if (!empty($ids)) {
                    foreach ($ids as $id) {
                        $url = wp_get_attachment_image_url($id, 'medium');
                        if ($url) {
                            echo '<div class="preview-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>';
                        }
                    }
                }
                ?>
            </div>
            <!-- Hidden file uploader that will be managed by BatchFileUploader -->
            <div class="file-upload-container">
                <div class="file-upload-wrapper">
                    <input type="file"
                           name="<?= array_key_exists('base', $field) ? esc_attr($field['base']) : ''?><?= esc_attr($name); ?>_temp"
                           accept=".jpg,.jpeg,.png,.gif,.webp"
                           multiple>
                    <p class="file-upload-text">
                        <strong>Click to upload</strong> or drag and drop<br>
                        JPG, PNG, GIF, or WEBP (max. 5MB)
                    </p>
                </div>
                <div class="file-error"></div>
            </div>
            <!-- Hidden input for storing the IDs -->
            <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 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 renderSelectField(string $name, mixed $value, array $field):void
    {
        $describedBy = (!empty($field['description'])) ? ' aria-describedby="'.$name.'-help"' : '';
        $conditional = $this->handleConditionalField($field);
        $default = isset($field['default']) ? $field['default'] : '';
        $value = !empty($value) ? $value : $default;
        if (array_key_exists('group', $field)) {
            $name = $field['group'].'::'.$name;
        }
        ?>
         <div class="field <?=$field['type']?> <?=$name?>" data-field="<?=$name?>" <?=$conditional?>>
            <label for="<?= esc_attr($name); ?>">
                <?= esc_html($field['label']); ?>
            </label>
             <?php if (array_key_exists('hint', $field)) { $this->renderHint($field['hint']); }  ?>
             <?php if (array_key_exists('description', $field)) { $this->renderDescription($field['description'], $name); }  ?>
            <select
                id="<?= array_key_exists('base', $field) ? esc_attr($field['base']) : ''?><?= esc_attr($name); ?>"
                name="<?= array_key_exists('base', $field) ? esc_attr($field['base']) : ''?><?= esc_attr($name); ?>"
                <?=$describedBy?>
                <?= !empty($field['required']) ? 'required' : ''; ?>
            >
                <?php foreach ($field['options'] as $key => $label) : ?>
                    <option value="<?= esc_attr($key); ?>"
                        <?php selected($value, $key); ?>>
                        <?= esc_html($label); ?>
                    </option>
                <?php endforeach; ?>
            </select>
        </div>
        <?php
    }
    /* ========== HTML FIELD ========== */
    protected function renderHtmlField(string $name, mixed $value, array $field):void
    {
        $method_name = $field['content'];
        $content = '';
        if (method_exists($this, $method_name)) {
            $content = $this->$method_name();
        }
        echo ($content == '') ? '' : sprintf(
        if ($content === '') {
            return;
        }
        echo sprintf(
            '<div class="html-field-container" data-field-type="html" data-field="%s">%s</div>',
            esc_attr($name),
            $content
        );
    }
    private function renderDateField(string $name, mixed $value, array $field):void
    {
        $describedBy = (!empty($field['description'])) ? ' aria-describedby="'.$name.'-help"' : '';
        $conditional = $this->handleConditionalField($field);
        $format = !empty($field['format']) ? $field['format'] : 'Y-m-d';
    /* ========== UTILITY METHODS ========== */
        // Format the date if we have a value
        if (!empty($value)) {
            $date = DateTime::createFromFormat($format, $value);
            if ($date) {
                $value = $date->format('Y-m-d'); // HTML date input requires Y-m-d format
            }
        }
        if (array_key_exists('group', $field)) {
            $name = $field['group'].'::'.$name;
        }
        ?>
         <div class="field <?=$field['type']?> <?=$name?>" <?=$conditional?> data-field="<?=$name?>">
            <label for="<?= esc_attr($name); ?>">
                <?= esc_html($field['label']); ?>
            </label>
            <div class="date-wrapper"<?=$describedBy?>>
                <input
                    type="date"
                    id="<?= array_key_exists('base', $field) ? esc_attr($field['base']) : ''?><?= esc_attr($name); ?>"
                    name="<?= array_key_exists('base', $field) ? esc_attr($field['base']) : ''?><?= esc_attr($name); ?>"
                    value="<?= esc_attr($value); ?>"
                    <?= !empty($field['required']) ? 'required' : ''; ?>
                    data-format="<?= esc_attr($format); ?>"
                >
                <?= jvbIcon('event') ?>
            </div>
             <?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
    }
    public function renderTimeField(string $name, mixed $value, array $field):void
    {
        $conditional = $this->handleConditionalField($field);
        $describedBy = (!empty($field['description'])) ? ' aria-describedby="'.$name.'-help"' : '';
        // Convert various time formats to HTML time input format (HH:MM)
        if (!empty($value)) {
            // If it's already in HH:MM format, use as-is
            if (preg_match('/^\d{2}:\d{2}$/', $value)) {
                // Value is already in correct format
            } else {
                // Try to parse and convert
                $timestamp = strtotime($value);
                if ($timestamp !== false) {
                    $value = date('H:i', $timestamp);
                } else {
                    $value = '';
                }
            }
        }
        if (array_key_exists('group', $field)) {
            $name = $field['group'].'::'.$name;
        }
        ?>
         <div class="field <?=$field['type']?> <?=$name?>" <?=$conditional?> data-field="<?=$name?>">
            <label for="<?= esc_attr($name); ?>">
                <?= esc_html($field['label']); ?>
            </label>
            <div class="time-wrapper"<?=$describedBy?>>
                <input
                    type="time"
                    id="<?= array_key_exists('base', $field) ? esc_attr($field['base']) : ''?><?= esc_attr($name); ?>"
                    name="<?= array_key_exists('base', $field) ? esc_attr($field['base']) : ''?><?= esc_attr($name); ?>"
                    value="<?= esc_attr($value); ?>"
                    <?= !empty($field['required']) ? 'required' : ''; ?>
                    <?= !empty($field['min']) ? 'min="' . esc_attr($field['min']) . '"' : ''; ?>
                    <?= !empty($field['max']) ? 'max="' . esc_attr($field['max']) . '"' : ''; ?>
                    <?= !empty($field['step']) ? 'step="' . esc_attr($field['step']) . '"' : ''; ?>
                >
            </div>
             <?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 renderDatetimeField(string $name, mixed $value, array $field):void
    {
        $conditional = $this->handleConditionalField($field);
        $describedBy = (!empty($field['description'])) ? ' aria-describedby="'.$name.'-help"' : '';
        // Convert datetime to HTML datetime-local format (YYYY-MM-DDTHH:MM)
        if (!empty($value)) {
            $date = DateTime::createFromFormat('Y-m-d H:i:s', $value);
            if (!$date) {
                // Try alternative formats
                $formats = ['Y-m-d\TH:i:s', 'Y-m-d\TH:i', 'Y-m-d H:i'];
                foreach ($formats as $format) {
                    $date = DateTime::createFromFormat($format, $value);
                    if ($date) break;
                }
            }
            if ($date) {
                $value = $date->format('Y-m-d\TH:i'); // HTML datetime-local format
            } else {
                $value = '';
            }
        }
        if (array_key_exists('group', $field)) {
            $name = $field['group'].'::'.$name;
        }
        ?>
        <div class="field <?=$field['type']?> <?=$name?>" <?=$conditional?> data-field="<?=$name?>">
            <label for="<?= esc_attr($name); ?>">
                <?= esc_html($field['label']); ?>
            </label>
            <div class="datetime-wrapper"<?=$describedBy?>>
                <input
                    type="datetime-local"
                    id="<?= array_key_exists('base', $field) ? esc_attr($field['base']) : ''?><?= esc_attr($name); ?>"
                    name="<?= array_key_exists('base', $field) ? esc_attr($field['base']) : ''?><?= esc_attr($name); ?>"
                    value="<?= esc_attr($value); ?>"
                    <?= !empty($field['required']) ? 'required' : ''; ?>
                    <?= !empty($field['min']) ? 'min="' . esc_attr($field['min']) . '"' : ''; ?>
                    <?= !empty($field['max']) ? 'max="' . esc_attr($field['max']) . '"' : ''; ?>
                    <?= !empty($field['step']) ? 'step="' . esc_attr($field['step']) . '"' : ''; ?>
                >
                <?= jvbIcon('event') ?>
            </div>
            <?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
    }
    public function outputCharacterCountJS():void
    {
        ?>
        <script>
            document.querySelectorAll('[maxlength]').forEach(field => {
                const counter = field.closest('.field')?.querySelector('.char-count .current');
                if (counter) {
                    const updateCount = () => counter.textContent = field.value.length;
                    field.addEventListener('input', updateCount);
                    updateCount();
                }
            });
        </script>
        <?php
    }
    //Conditional Fields
    private function handleConditionalField(array $field):string
    {
        if (empty($field['condition'])) {
@@ -2103,26 +1109,67 @@
        );
    }
    protected function renderDescription(string $description, string $name):void
    protected function renderHint(string $hint): void
    {
        $id = $name.'-help';
        $out = '<div class="has-tooltip">
        <span class="tt-toggle">'.jvbIcon('help').'</span>
        <div role="tooltip" id="'.$id.'"><p>'.$description.'</p></div>
        </div>';
        echo $out;
        ?>
        <span class="hint"><?= esc_html($hint) ?></span>
        <?php
    }
    protected function renderHint(array|string $hint):void
    protected function renderDescription(string $description, string $name): void
    {
        if (is_array($hint)) {
            $out = '';
            foreach($hint as $h) {
                $out .= '<p class="hint">'.$h.'</p>';
        ?>
        <p class="description" id="<?= esc_attr($name) ?>-help">
            <?= wp_kses_post($description) ?>
        </p>
        <?php
            }
        } else {
            $out = '<p class="hint">'.$hint.'</p>';
    protected function getAllowedTypes(array $config): array
    {
        if (!empty($config['accepted_types'])) {
            return $config['accepted_types'];
        }
        echo $out;
        // Default types based on subtype
        $defaults = [
            'image' => ['image/jpeg', 'image/png', 'image/gif', 'image/webp'],
            'video' => ['video/mp4', 'video/webm', 'video/ogg'],
            'document' => ['application/pdf', 'application/msword', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'],
            'any' => ['image/*', 'video/*', 'application/pdf']
        ];
        return $defaults[$config['subtype']] ?? $defaults['image'];
    }
    protected function getMimeExtensions(array $mimeTypes): array
    {
        $extensions = [];
        foreach ($mimeTypes as $mime) {
            if (str_contains($mime, '*')) {
                continue; // Skip wildcards
            }
            $ext = str_replace(['image/', 'video/', 'application/'], '', $mime);
            $extensions[] = '.' . $ext;
        }
        return $extensions;
    }
    protected function parseAttachmentIds(mixed $value): array
    {
        if (empty($value)) {
            return [];
        }
        if (is_array($value)) {
            return array_filter($value, 'is_numeric');
        }
        if (is_string($value)) {
            return array_filter(explode(',', $value), 'is_numeric');
        }
        return is_numeric($value) ? [$value] : [];
    }
}
inc/meta/MetaFormOld.php
New file
@@ -0,0 +1,2128 @@
<?php
namespace JVBase\inc\meta;
use JVBase\forms\TaxonomySelector;
use JVBase\forms\PostSelector;
use DateTime;
if (!defined('ABSPATH')) {
    exit; // Exit if accessed directly
}
/**
 * Renders the form fields for managing the meta
 */
class MetaFormOld
{
    protected int $max_file_size = 5242880;
    //Rendering fields
    public function render(string $name, mixed $value, array $config, bool $showHidden = false, bool $return = false):mixed
    {
        $out = '';
        if (jvbCheck('hidden', $config) && !$showHidden) {
            return $out;
        }
        // Get conditional attributes if they exist
        $conditional = array_key_exists('condition', $config) ?
            $this->handleConditionalField($config) : '';
        if (!array_key_exists('type', $config)) {
            return $out;
        }
        if (array_key_exists('display', $config) && $config['display'] === 'hidden'){
            $out = '<input type="hidden" name="'.$name.'" value="'.$value.'" />';
            if (!$return) {
                echo $out;
            }
            return $out;
        }
        ob_start();
        $type = array_map( 'ucfirst', explode('_', $config['type']));
        $type = implode('', $type);
        $method = 'render' . $type . 'Field';
        $nameTemp = implode('', array_map('ucfirst', explode('_', $name)));
        $nameMethod = 'render'.$nameTemp.'Field';
        if(function_exists($nameMethod)) {
            call_user_func($nameMethod, $value, $config);
        } elseif (function_exists($method)) {
            call_user_func($method, $value, $config);
        } elseif (method_exists($this, $method)) {
            $this->$method($name, $value, $config);
        }
        $out = ob_get_clean();
        do_action('jvbRenderFormField', $name, $config, $value);
        $out = apply_filters('jvbFilterRenderFormField', $out, $name, $config, $value);
        if (!$return) {
            echo $out;
        }
        return $out;
    }
    public function renderTextField(string $name, mixed $value, array $field):void
    {
        // Use field-specific value if provided, otherwise use the meta value
        $display_value = isset($field['value']) ? $field['value'] : $value;
        $conditional = $this->handleConditionalField($field);
        $describedBy = (!empty($field['description'])) ? ' aria-describedby="'.$name.'-help"' : '';
        if (array_key_exists('group', $field)) {
            $name = $field['group'].'::'.$name;
        }
        $placeholder = (array_key_exists('placeholder', $field)) ? ' placeholder="'.$field['placeholder'].'"' : '';
        $autocomplete = (array_key_exists('autocomplete', $field)) ? ' autocomplete="'.$field['autocomplete'].'"' : '';
        ?>
         <div class="field <?=$field['type']?> <?=$name?>" <?=$conditional?> data-field="<?=$name?>">
            <label for="<?= esc_attr($name); ?>">
                <?= esc_html($field['label']); ?>
                <?php if (!empty($field['limit'])) : ?>
                    <span class="char-count" data-limit="<?= esc_attr($field['limit']); ?>">
                    <span class="current">0</span>/<?= esc_attr($field['limit']); ?>
                </span>
                <?php endif; ?>
            </label>
            <input
                type="<?= esc_attr($field['subtype']??'text'); ?>"
                id="<?= array_key_exists('base', $field) ? esc_attr($field['base']) : ''?><?= esc_attr($name); ?>"
                name="<?= array_key_exists('base', $field) ? esc_attr($field['base']) : ''?><?= esc_attr($name); ?>"
                value="<?= esc_attr($display_value); ?>"
                <?= $placeholder ?>
                <?= $autocomplete ?>
                <?= !empty($field['required']) ? 'required' : ''; ?>
                <?= $describedBy ?>
            >
             <?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
        if (array_key_exists('limit', $field)) {
            $this->outputCharacterCountJS();
        }
    }
    private function renderTelField(string $name, mixed $value, array $field):void
        {$describedBy = (!empty($field['description'])) ? ' aria-describedby="'.$name.'-help"' : '';
        // Use field-specific value if provided, otherwise use the meta value
        $display_value = isset($field['value']) ? $field['value'] : $value;
        $conditional = $this->handleConditionalField($field);
        if (array_key_exists('group', $field)) {
            $name = $field['group'].'::'.$name;
        }
            $placeholder = (array_key_exists('placeholder', $field)) ? ' placeholder="'.$field['placeholder'].'"' : '';
        $autocomplete = (array_key_exists('autocomplete', $field)) ? ' autocomplete="'.$field['autocomplete'].'"' : '';
        ?>
         <div class="field <?=$field['type']?> <?=$name?>" <?=$conditional?> data-field="<?=$name?>">
            <label for="<?= esc_attr($name); ?>">
                <?= esc_html($field['label']); ?>
                <?php if (!empty($field['limit'])) : ?>
                    <span class="char-count" data-limit="<?= esc_attr($field['limit']); ?>">
                    <span class="current">0</span>/<?= esc_attr($field['limit']); ?>
                </span>
                <?php endif; ?>
            </label>
            <input
                type="tel"
                id="<?= array_key_exists('base', $field) ? esc_attr($field['base']) : ''?><?= esc_attr($name); ?>"
                name="<?= array_key_exists('base', $field) ? esc_attr($field['base']) : ''?><?= esc_attr($name); ?>"
                value="<?= esc_attr($display_value); ?>"
                <?= $placeholder ?>
                <?= $autocomplete?>
                <?= $describedBy ?>
                <?= !empty($field['required']) ? 'required' : ''; ?>
            >
             <?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
        if (array_key_exists('limit', $field)) {
            $this->outputCharacterCountJS();
        }
    }
    private function renderEmailField(string $name, mixed $value, array $field):void
    {
        $describedBy = (!empty($field['description'])) ? ' aria-describedby="'.$name.'-help"' : '';
        // Use field-specific value if provided, otherwise use the meta value
        $display_value = isset($field['value']) ? $field['value'] : $value;
        $conditional = $this->handleConditionalField($field);
        if (array_key_exists('group', $field)) {
            $name = $field['group'].'::'.$name;
        }
        $placeholder = (array_key_exists('placeholder', $field)) ? ' placeholder="'.$field['placeholder'].'"' : '';
        $autocomplete = (array_key_exists('autocomplete', $field)) ? ' autocomplete="'.$field['autocomplete'].'"' : '';
        ?>
         <div class="field <?=$field['type']?> <?=$name?>" <?=$conditional?> data-field="<?=$name?>">
            <label for="<?= esc_attr($name); ?>">
                <?= esc_html($field['label']); ?>
                <?php if (!empty($field['limit'])) : ?>
                    <span class="char-count" data-limit="<?= esc_attr($field['limit']); ?>">
                    <span class="current">0</span>/<?= esc_attr($field['limit']); ?>
                </span>
                <?php endif; ?>
            </label>
            <input
                type="email"
             <?= array_key_exists('base', $field) ? esc_attr($field['base']) : ''?><?= esc_attr($name); ?>"
                name="<?= array_key_exists('base', $field) ? esc_attr($field['base']) : ''?><?= esc_attr($name); ?>"
                value="<?= esc_attr($display_value); ?>"
                <?= $placeholder ?>
                <?= $autocomplete ?>
                <?= $describedBy ?>
                <?= !empty($field['required']) ? 'required' : ''; ?>
            >
             <?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
        if (array_key_exists('limit', $field)) {
            $this->outputCharacterCountJS();
        }
    }
    private function renderUrlField(string $name, mixed $value, array $field):void
    {
        $describedBy = (!empty($field['description'])) ? ' aria-describedby="'.$name.'-help"' : '';
        // Use field-specific value if provided, otherwise use the meta value
        $display_value = isset($field['value']) ? $field['value'] : $value;
        $conditional = $this->handleConditionalField($field);
        if (array_key_exists('group', $field)) {
            $name = $field['group'].'::'.$name;
        }
        $placeholder = (array_key_exists('placeholder', $field)) ? ' placeholder="'.$field['placeholder'].'"' : '';
        $autocomplete = (array_key_exists('autocomplete', $field)) ? ' autocomplete="'.$field['autocomplete'].'"' : '';
        ?>
         <div class="field <?=$field['type']?> <?=$name?>" <?=$conditional?> data-field="<?=$name?>">
            <label for="<?= esc_attr($name); ?>">
                <?= esc_html($field['label']); ?>
                <?php if (!empty($field['limit'])) : ?>
                    <span class="char-count" data-limit="<?= esc_attr($field['limit']); ?>">
                    <span class="current">0</span>/<?= esc_attr($field['limit']); ?>
                </span>
                <?php endif; ?>
            </label>
            <input
                type="url"
                id="<?= array_key_exists('base', $field) ? esc_attr($field['base']) : ''?><?= esc_attr($name); ?>"
                name="<?= array_key_exists('base', $field) ? esc_attr($field['base']) : ''?><?= esc_attr($name); ?>"
                value="<?= esc_attr($display_value); ?>"
                <?= $placeholder ?>
                <?= $describedBy ?>
                <?= $autocomplete ?>
                <?= !empty($field['required']) ? 'required' : ''; ?>
            >
             <?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
        if (array_key_exists('limit', $field)) {
            $this->outputCharacterCountJS();
        }
    }
    private function renderNumberField(string $name, mixed $value, array $field):void
    {
        $describedBy = (!empty($field['description'])) ? ' aria-describedby="'.$name.'-help"' : '';
        $description = '<ul class="list-none"><li>Tip: hold Ctrl/Command to increase 5x</li><li>Shift to increase 10x,</li><li>Or Ctrl/Command + Shift to increase 50x</li></ul>';
        $description .= $field['description']??'';
        $conditional = $this->handleConditionalField($field);
        $min = isset($field['min']) ? (float)$field['min'] : 0;
        $max = isset($field['max']) ? (float)$field['max'] : 100;
        $step = isset($field['step']) ? (float)$field['step'] : 1;
        $data = '';
        if (array_key_exists('data', $field) && !empty($field['data'])) {
            foreach($field['data'] as $key => $v) {
                if ($v === '') {
                    $data .= ' data-'.$key;
                } else {
                    $data .= ' data-'.$key.'="'.$v.'"';
                }
            }
        }
        if (empty($value)) {
            $value = $field['default']??0;
        }
        if (array_key_exists('group', $field)) {
            $name = $field['group'].'::'.$name;
        }
        $autocomplete = (array_key_exists('autocomplete', $field)) ? ' autocomplete="'.$field['autocomplete'].'"' : '';
        ?>
         <div class="field <?=$field['type']?> <?=$name?> row" <?=$conditional?> data-field="<?=$name?>">
            <label for="<?= esc_attr($name); ?>">
                <?= esc_html($field['label']); ?>
            </label>
             <div class="quantity"
                  <?=$data?>>
                 <button type="button"
                         class="decrease"
                         title="<?= array_key_exists('remove', $field) ? $field['remove'] : 'Decrease amount' ?>"
                         aria-label="Decrease <?= esc_attr($field['label']); ?>">
                     <?= jvbIcon('minus')?>
                 </button>
                 <input type="number"
                        id="<?= array_key_exists('base', $field) ? esc_attr($field['base']) : ''?><?= esc_attr($name); ?>"
                        name="<?= array_key_exists('base', $field) ? esc_attr($field['base']) : ''?><?= esc_attr($name); ?>"
                        value="<?= esc_attr($value); ?>"
                        min="<?= esc_attr($min); ?>"
                        max="<?= esc_attr($max); ?>"
                        step="<?= esc_attr($step); ?>"
                        class="quantity-input"
                        <?= $describedBy ?>
                        <?= $autocomplete ?>
                     <?= !empty($field['required']) ? 'required' : ''; ?>>
                 <button type="button"
                         class="increase"
                         title="<?= array_key_exists('add', $field) ? $field['add'] : 'Increase amount' ?>"
                         aria-label="Increase <?= esc_attr($field['label']); ?>">
                     <?= jvbIcon('add')?>
                 </button>
             </div>
             <?php $this->renderDescription($description, $name);  ?>
        </div>
        <?php
    }
    public function renderTextareaField(string $name, mixed $value, array $field):void
    {
        $describedBy = (!empty($field['description'])) ? ' aria-describedby="'.$name.'-help"' : '';
        $rows = isset($field['rows']) ? (int)$field['rows'] : 4;
        $conditional = $this->handleConditionalField($field);
        $quill = (array_key_exists('quill', $field) && $field['quill'] == true) ? ' data-editor="true"' : '';
        if ($quill !== '') {
            $allowImages = array_key_exists('allowImage', $field);
            $quill .= ($allowImages) ? ' data-allowimage="true"' : ' data-allowimage="false"';
        }
        // Handle array values
        if (is_array($value)) {
            $value = implode(', ', $value);
        }
        if (array_key_exists('group', $field)) {
            $name = $field['group'].'::'.$name;
        }
        $placeholder = (array_key_exists('placeholder', $field)) ? ' placeholder="'.$field['placeholder'].'"' : '';
        ?>
         <div class="field <?=$field['type']?> <?=$name?>" <?=$conditional?> data-field="<?=$name?>">
            <label for="<?= esc_attr($name ?? ''); ?>">
                <?= esc_html($field['label']); ?>
                <?php if (!empty($field['limit'])) : ?>
                    <span class="char-count" data-limit="<?= esc_attr($field['limit']); ?>">
                    <span class="current">0</span>/<?= esc_attr($field['limit']); ?>
                </span>
                <?php endif; ?>
            </label>
            <textarea
                id="<?= array_key_exists('base', $field) ? esc_attr($field['base']) : ''?><?= esc_attr($name ?? ''); ?>"
                name="<?= array_key_exists('base', $field) ? esc_attr($field['base']) : ''?><?= esc_attr($name ?? ''); ?>"
                    <?= $quill ?>
                    rows="<?= esc_attr($rows); ?>"
                <?= $placeholder ?>
                <?= $describedBy ?>
                <?= !empty($field['required']) ? 'required' : ''; ?>
            <?= !empty($field['limit']) ? 'data-limit="' . esc_attr($field['limit']) . '"' : ''; ?>
        ><?= esc_textarea($value); ?></textarea>
             <?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 renderSetField(string $name, mixed $value, array $field):void
    {
        $this->renderCheckboxField($name, $value, $field);
    }
    private function renderCheckboxField(string $name, mixed $value, array $field):void
    {
        $describedBy = (!empty($field['description'])) ? ' aria-describedby="'.$name.'-help"' : '';
        $value = !is_array($value) ? explode(',', $value) : $value;
        $limit = isset($field['limit']) ? (int)$field['limit'] : 0;
        $conditional = $this->handleConditionalField($field);
        if (array_key_exists('group', $field)) {
            $name = $field['group'].'::'.$name;
        }
        ?>
        <div class="field checkbox-group" <?= $limit ? 'data-limit="' . esc_attr($limit) . '"' : ''; ?> <?=$conditional?> data-field="<?=$name?>"<?=$describedBy?>>
            <span class="label"><?= esc_html($field['label']); ?></span>
            <div class="checkbox-options flex">
            <?php foreach ($field['options'] as $key => $label) : ?>
                <input
                    type="checkbox"
                    id="<?= array_key_exists('base', $field) ? esc_attr($field['base']) : ''?><?= esc_attr($name).'-'.esc_attr($key)?>"
                    <?= array_key_exists('base', $field) ? esc_attr($field['base']) : ''?><?= esc_attr($name)?>[]"
                    value="<?= esc_attr($key); ?>"
                    <?= (in_array($key, $value)) ? 'checked' : ''; ?>
                    <?= !empty($field['required']) ? 'required' : ''; ?>
                >
                <label class="checkbox-option" for="<?= esc_attr($name).'-'.esc_attr($key) ?>">
                    <?= esc_html($label); ?>
                </label>
            <?php endforeach; ?>
        </div>
            <?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 renderRadioField(string $name, mixed $value, array $field):void
    {
        $describedBy = (!empty($field['description'])) ? ' aria-describedby="'.$name.'-help"' : '';
        $value = (array)$value;
        $conditional = $this->handleConditionalField($field);
        if (!array_key_exists('label', $field)) {
            error_log('No label for: '.print_r($name, true));
        }
        if (array_key_exists('group', $field)) {
            $name = $field['group'].'::'.$name;
        }
        ?>
        <div class="field radio-group"<?=$conditional?> data-field="<?=$name?>"<?=$describedBy?>>
            <label><?= esc_html($field['label']); ?></label>
            <div class="radio-options row">
                <?php foreach ($field['options'] as $key => $label) : ?>
                    <input
                        type="radio"
                        id="<?= array_key_exists('base', $field) ? esc_attr($field['base']) : ''?><?= esc_attr($name).'-'.esc_attr($key) ?>"
                        name="<?= array_key_exists('base', $field) ? esc_attr($field['base']) : ''?><?= esc_attr($name); ?>"
                        value="<?= esc_attr($key); ?>"
                        <?= !empty($field['required']) ? 'required' : ''; ?>
                        <?= (in_array($key, $value)) ? 'checked' : ''; ?>
                    >
                    <label class="radio-option" for="<?= esc_attr($name).'-'.esc_attr($key) ?>">
                        <?= esc_html($label); ?>
                    </label>
                <?php endforeach; ?>
            </div>
            <?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 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;
        }
        $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));
            }
            ?>
            <h3><?= esc_html($field['label']); ?></h3>
            <div class="repeater-items">
                <?php
                if (!empty($values)) {
                    foreach ($values as $index => $row) {
                        $this->renderRepeaterRow($field['fields'], $row, $index, $name, $rowTitle);
                    }
                }
                ?>
            </div>
            <template class="<?=uniqid('repeaterTemplate')?>">
                <?php $this->renderRepeaterRow($field['fields'], array(), '', '', $rowTitle); ?>
            </template>
            <button type="button" class="add-repeater-row">
                <?= jvbIcon('plus', ['title'=> 'Add']); ?> <?= (array_key_exists('add_label', $field)) ? $field['add_label'] : 'Add Item'; ?>
            </button>
            <?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 = 'New Item'):void
    {
        $display_number = (is_string($index)) ? $index : ($index + 1);
        ?>
        <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>
                    <button type="button" class="remove-row" title="Remove">
                        <?= jvbIcon('delete', ['title'=>'Remove']); ?>
                    </button>
                </summary>
                <div class="repeater-row-content">
                    <?php
                    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>
        </div>
        <?php
    }
    private function getRowTitle(array $fields, array $values, string $rowTitle):string
    {
        // Try to find the first text field or textarea value to use as title
        foreach ($fields as $slug => $field) {
            if (in_array($field['type'], ['text', 'textarea']) &&
                isset($values[$slug]) &&
                !empty($values[$slug])) {
                return $values[$slug];
            }
        }
        return $rowTitle;
    }
    private function renderTaxonomyField(string $name, mixed $value, array $field):void
    {
        $conditional = $this->handleConditionalField($field);
        $taxonomy = $field['taxonomy'];
        // Get currently selected terms
        $selected_terms = ($value === '') ? [] : explode(',', $value);
        // Convert selected term IDs to the format expected by single modal
        $processedSelected = [];
        if (!empty($selected_terms)) {
            foreach ($selected_terms as $termId) {
                if (is_numeric($termId)) {
                    $term = get_term($termId, $taxonomy);
                    if ($term && !is_wp_error($term)) {
                        $processedSelected[$term->term_id] = [
                            'name' => $term->name,
                            'path' => TaxonomySelector::getTermPath($term)
                        ];
                    }
                }
            }
        }
        // Create configuration for single modal system
        $config = [
            'taxonomy' => $taxonomy,
            'max' => $field['limit'] ?? 0,
            'search' => $field['search'] ?? true,
            'createNew' => $field['createNew'] ?? false,
            'selected' => $processedSelected,
            'base' => $field['base'] ?? '',
        ];
        $describedBy = (!empty($field['description'])) ? ' aria-describedby="'.$name.'-help"' : '';
        ?>
        <div class="field taxonomy <?=$name?>" <?= $conditional ?> data-field="<?=$name?>">
            <div class="field-group-header">
                <label class="toggle">
                    <?= jvbIcon(str_replace(BASE, '', $taxonomy)) ?>
                    <?= esc_html($field['label']) ?>
                </label>
            </div>
            <?php
            $tax = new TaxonomySelector($name, $taxonomy, $config);
            $extra = '<input type="hidden"
                   name="'.esc_attr($name).'"
                   id="'.esc_attr($name).'"'.$describedBy.'
                   data-taxonomy="'.esc_attr($taxonomy).'"
                   value="'.esc_attr(is_array($selected_terms) ? implode(',', $selected_terms) : $selected_terms).'">';
            echo $tax->render([], $extra);
            ?>
            <?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
    }
    protected function renderPostSelectorField(string $name, mixed $value, array $field):void
    {
        $conditional = $this->handleConditionalField($field);
        // Process selected posts
        $selected_posts = $value;
        if (is_string($selected_posts)) {
            $selected_posts = !empty($selected_posts) ? explode(',', $selected_posts) : [];
        } elseif (!is_array($selected_posts)) {
            $selected_posts = [];
        }
        // Configure the post selector
        $config = [
            'multiple' => $field['multiple'] ?? true,
            'maxSelections' => $field['limit'] ?? 0,
            'search' => true,
            'placeholder' => $field['placeholder'] ?? 'Search posts...',
            'noResults' => 'No posts found',
            'shop_id' => $field['shop_id'] ?? null,
            'onClose' => 'updateMetaFormPost'
        ];
        $postSelector = new PostSelector($field['post_type'], $config);
        $containerId = $name . '-post-selector';
        $describedBy = (!empty($field['description'])) ? ' aria-describedby="'.$name.'-help"' : '';
        ?>
        <div class="field post-selector <?=$name?>" <?= $conditional ?> data-field="<?=$name?>">
            <div class="field-group-header">
                <label class="toggle">
                    <?= jvbIcon($field['post_type'] . '-selector') ?>
                    <?= esc_html($field['label'] ?? ucfirst($field['post_type'])) ?>
                </label>
                <button title="Add <?= esc_attr(ucfirst($field['post_type'])) ?>"
                        class="add-item-btn"
                        type="button">
                    <?= jvbIcon('add', ['title' => "Add " . ucfirst($field['post_type'])]) ?>
                </button>
            </div>
            <?= $postSelector->render($selected_posts, $containerId) ?>
            <!-- Hidden input for form submission -->
            <input type="hidden"
                   name="<?= array_key_exists('base', $field) ? esc_attr($field['base']) : ''?><?= esc_attr($name) ?>"
                   class="post-selector-input"
                   <?= $describedBy ?>
                   data-post-type="<?= esc_attr($field['post_type']) ?>"
                   value="<?= esc_attr(is_array($selected_posts) ? implode(',', $selected_posts) : $selected_posts) ?>">
            <?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
    }
    protected function renderGroupField(string $name, mixed $value, array $field):void
    {
        if (!array_key_exists('fields', $field) || empty($field['fields'])) {
            return;
        }
        // Handle conditional fields
        $conditional = $this->handleConditionalField($field);
        // Ensure value is an array
        $values = is_array($value) ? $value : [];
        $original = $name;
        if (array_key_exists('group', $field)) {
            $name = $field['group'].'::'.$name;
        }
        $describedBy = (!empty($field['description'])) ? ' aria-describedby="'.$name.'-help"' : '';
        $hidden = (array_key_exists('mode', $field) && $field['mode'] === 'hidden');
        if (!$hidden) {
            ?>
            <fieldset class="field group <?= esc_attr($name) ?>" <?= $conditional ?> data-field="<?=$name?>"<?= $describedBy?>>
                <legend><?= esc_html($field['label']) ?></legend>
            <?php
        }
        ?>
            <div class="group-fields <?=$original?>"<?= ($hidden) ? ' data-field="'.$name.'"' : ''?>"<?= $describedBy ?>>
        <?php if (array_key_exists('hint', $field)) { $this->renderHint($field['hint']); }  ?>
        <?php if (array_key_exists('description', $field)) { $this->renderDescription($field['description'], $name); }  ?>
                <?php
                foreach ($field['fields'] as $field_name => $config) {
                    // Set the group context for proper field naming
                    $config['group'] = $name;
                    // Get the value for this specific field
                    $field_value = $values[$field_name] ?? '';
                    // Handle conditional fields within the group
                    if (isset($config['condition'])) {
                        // Convert condition field reference to group context
                        $condition_field = $config['condition']['field'];
                        if (!str_contains($condition_field, '::')) {
                            $config['condition']['field'] = $name . '::' . $condition_field;
                        }
                    }
                    $this->render($field_name, $field_value, $config);
                }
                ?>
            </div>
        <?php
        if (!$hidden) {
            ?>
            </fieldset>
            <?php
        }
    }
    protected function renderLocationField(string $name, mixed $value, array $field): void
    {
        $googleMaps = JVB()->connect('maps');
        if (!$googleMaps->isSetUp()) {
            echo '<div class="notice notice-warning"><p>Google Maps not configured. Please configure in Integrations settings.</p></div>';
            return;
        }
        // Extract stored values
        if (is_string($value)) {
            $value = maybe_unserialize($value);
        }
        $stored_data = is_array($value) ? $value : [];
        $address = $stored_data['address'] ?? '';
        $lat = $stored_data['lat'] ?? '';
        $lng = $stored_data['lng'] ?? '';
        // 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 configuration for JavaScript initialization
        $js_config = [
            'fieldId' => $field_id,
            'initialCoords' => (!empty($lat) && !empty($lng)) ? [
                'lat' => (float)$lat,
                'lng' => (float)$lng
            ] : null
        ];
        // IMPORTANT: Properly escape the JSON for use in HTML attribute
        $json_config = htmlspecialchars(json_encode($js_config), ENT_QUOTES, 'UTF-8');
        ?>
        <div class="field location <?= esc_attr($field_id) ?>"
             data-field="<?= esc_attr($field_id) ?>"
             data-location-field-init="<?= $json_config ?>"<?=$describedBy?>>
            <?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
    }
    //TODO: This is more or less handled by PostSelector/TaxonomySelector, no?
    private function renderAssociationField(string $name, mixed $value, array $field):void
    {
        // Ensure value is an array
        if (!is_array($value)) {
            $value = empty($value) ? [] : [$value];
        }
        // Get field configuration
        $limit = isset($field['limit']) ? (int)$field['limit'] : 0;
        $object_types = isset($field['object_types']) ? $field['object_types'] : ['post'];
        $post_types = isset($field['post_types']) ? $field['post_types'] : ['post'];
        $taxonomies = isset($field['taxonomies']) ? $field['taxonomies'] : [];
        $describedBy = (!empty($field['description'])) ? ' aria-describedby="'.$name.'-help"' : '';
        // Create unique ID for this field
        $field_id = 'association-' . esc_attr($name);
        $conditional = $this->handleConditionalField($field);
        if (array_key_exists('group', $field)) {
            $name = $field['group'].'::'.$name;
        }
        ?>
        <div class="field association <?=$name?>" data-field="<?= esc_attr($name); ?>" <?= $conditional; ?>>
            <label><?= esc_html($field['label']); ?></label>
            <?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="association-container"<?=$describedBy?>>
                <div class="association-search">
                    <input type="text"
                           id="<?= esc_attr($field_id); ?>-search"
                           class="association-search-input"
                           placeholder="Search items...">
                    <div class="association-filter">
                        <?php if (count($object_types) > 1 || count($post_types) > 1 || count($taxonomies) > 0) : ?>
                            <select class="association-filter-select">
                                <?php if (in_array('post', $object_types)) : ?>
                                    <?php foreach ($post_types as $post_type) : ?>
                                        <?php
                                        $post_type_obj = get_post_type_object($post_type);
                                        $label = $post_type_obj ? $post_type_obj->labels->singular_name : ucfirst($post_type);
                                        ?>
                                        <option value="post:<?= esc_attr($post_type); ?>">
                                            <?= esc_html($label); ?>
                                        </option>
                                    <?php endforeach; ?>
                                <?php endif; ?>
                                <?php if (in_array('term', $object_types)) : ?>
                                    <?php foreach ($taxonomies as $taxonomy) : ?>
                                        <?php
                                        $tax_obj = get_taxonomy($taxonomy);
                                        $label = $tax_obj ? $tax_obj->labels->singular_name : ucfirst($taxonomy);
                                        ?>
                                        <option value="term:<?= esc_attr($taxonomy); ?>">
                                            <?= esc_html($label); ?>
                                        </option>
                                    <?php endforeach; ?>
                                <?php endif; ?>
                            </select>
                        <?php endif; ?>
                        <button type="button" class="search-button">
                            <?= jvbIcon('search', ['title' => 'Search']); ?>
                        </button>
                    </div>
                </div>
                <div class="association-results">
                    <div class="association-available">
                        <h4>Available Items</h4>
                        <ul class="available-items"></ul>
                        <div class="association-loading" hidden>
                            Loading...
                        </div>
                        <div class="association-no-results" hidden>
                            No items found
                        </div>
                        <div class="association-pagination">
                            <button type="button" class="prev-page" disabled>
                                <?= jvbIcon('arrow-left', ['title' => 'Previous']); ?>
                            </button>
                            <span class="page-info">Page <span class="current-page">1</span></span>
                            <button type="button" class="next-page" disabled>
                                <?= jvbIcon('arrow-right', ['title' => 'Next']); ?>
                            </button>
                        </div>
                    </div>
                    <div class="association-actions">
                        <button type="button" class="add-selected" disabled>
                            <?= jvbIcon('arrow-right', ['title' => 'Add selected']); ?>
                        </button>
                        <button type="button" class="remove-selected" disabled>
                            <?= jvbIcon('arrow-left', ['title' => 'Remove selected']); ?>
                        </button>
                    </div>
                    <div class="association-selected">
                        <h4>Selected Items
                            <?php if ($limit) : ?>
                                <span class="limit-info">(<?= esc_html($limit); ?> max)</span>
                            <?php endif; ?>
                        </h4>
                        <ul class="selected-items row">
                            <?php
                            // Display currently selected items
                            foreach ($value as $item_id) {
                                // Try to determine the type and get details
                                $item_type = '';
                                $item_title = '';
                                $item_object = '';
                                // Check if it's a post
                                if (in_array('post', $object_types)) {
                                    $post = get_post($item_id);
                                    if ($post && in_array($post->post_type, $post_types)) {
                                        $item_type = 'post';
                                        $item_title = $post->post_title;
                                        $item_object = $post->post_type;
                                    }
                                }
                                // Check if it's a term
                                if (empty($item_type) && in_array('term', $object_types)) {
                                    foreach ($taxonomies as $taxonomy) {
                                        $term = get_term($item_id, $taxonomy);
                                        if (!is_wp_error($term) && $term) {
                                            $item_type = 'term';
                                            $item_title = $term->name;
                                            $item_object = $term->taxonomy;
                                            break;
                                        }
                                    }
                                }
                                // Only output if we found the item
                                if (!empty($item_type) && !empty($item_title)) {
                                    ?>
                                    <li data-id="<?= esc_attr($item_id); ?>"
                                        data-type="<?= esc_attr($item_type); ?>"
                                        data-object="<?= esc_attr($item_object); ?>">
                                        <span class="item-title"><?= esc_html($item_title); ?></span>
                                        <span class="item-type"><?= esc_html(ucfirst($item_object)); ?></span>
                                        <button type="button" class="remove-item row">
                                            <?= jvbIcon('close', ['title' => 'Remove']); ?>
                                        </button>
                                    </li>
                                    <?php
                                }
                            }
                            ?>
                        </ul>
                    </div>
                </div>
            </div>
            <!-- Hidden input to store selected values -->
            <input type="hidden" name="<?= array_key_exists('base', $field) ? esc_attr($field['base']) : ''?><?= esc_attr($name); ?>" value="<?= esc_attr(implode(',', $value)); ?>">
        </div>
        <script>
            (function() {
                // Initialize association field
                const container = document.querySelector('[data-field="<?= esc_attr($name); ?>"]');
                if (!container) return;
                const searchInput = container.querySelector('.association-search-input');
                const filterSelect = container.querySelector('.association-filter-select');
                const searchButton = container.querySelector('.search-button');
                const availableList = container.querySelector('.available-items');
                const selectedList = container.querySelector('.selected-items');
                const hiddenInput = container.querySelector('input[name="<?= array_key_exists('base', $field) ? esc_attr($field['base']) : ''?><?= esc_attr($name); ?>"]');
                const addButton = container.querySelector('.add-selected');
                const removeButton = container.querySelector('.remove-selected');
                const loadingIndicator = container.querySelector('.association-loading');
                const noResultsMessage = container.querySelector('.association-no-results');
                const prevPageButton = container.querySelector('.prev-page');
                const nextPageButton = container.querySelector('.next-page');
                const currentPageSpan = container.querySelector('.current-page');
                // Configuration
                const config = {
                    limit: <?= $limit ?: 0; ?>,
                    objectTypes: <?= json_encode($object_types); ?>,
                    postTypes: <?= json_encode($post_types); ?>,
                    taxonomies: <?= json_encode($taxonomies); ?>,
                    perPage: 10,
                    currentPage: 1
                };
                // Current state
                let currentSearch = '';
                let currentFilter = filterSelect ? filterSelect.value : (config.objectTypes.includes('post') ? 'post:' + config.postTypes[0] : 'term:' + config.taxonomies[0]);
                let availableItems = [];
                let selectedItems = [];
                // Parse initial selected items
                const initialValue = hiddenInput.value;
                if (initialValue) {
                    selectedItems = initialValue.split(',').map(id => parseInt(id, 10));
                }
                // Event Listeners
                if (searchButton) {
                    searchButton.addEventListener('click', performSearch);
                }
                if (searchInput) {
                    searchInput.addEventListener('keypress', function(e) {
                        if (e.key === 'Enter') {
                            e.preventDefault();
                            performSearch();
                        }
                    });
                }
                if (filterSelect) {
                    filterSelect.addEventListener('change', function() {
                        currentFilter = this.value;
                        config.currentPage = 1;
                        performSearch();
                    });
                }
                if (prevPageButton) {
                    prevPageButton.addEventListener('click', function() {
                        if (config.currentPage > 1) {
                            config.currentPage--;
                            performSearch();
                        }
                    });
                }
                if (nextPageButton) {
                    nextPageButton.addEventListener('click', function() {
                        config.currentPage++;
                        performSearch();
                    });
                }
                // Add items
                addButton.addEventListener('click', function() {
                    const selectedAvailableItems = availableList.querySelectorAll('li.selected');
                    selectedAvailableItems.forEach(item => {
                        const id = parseInt(item.dataset.id, 10);
                        // Check limit
                        if (config.limit && selectedItems.length >= config.limit) {
                            return;
                        }
                        // Skip if already selected
                        if (selectedItems.includes(id)) {
                            return;
                        }
                        // Add to selection
                        selectedItems.push(id);
                        // Clone and modify for selected list
                        const clone = item.cloneNode(true);
                        clone.classList.remove('selected');
                        // Replace checkbox with remove button
                        const checkbox = clone.querySelector('input[type="checkbox"]');
                        if (checkbox) {
                            const removeBtn = document.createElement('button');
                            removeBtn.type = 'button';
                            removeBtn.className = 'remove-item';
                            removeBtn.innerHTML = '<?= jvbIcon('close', ['title' => 'Remove']); ?>';
                            removeBtn.addEventListener('click', function() {
                                removeItem(id, clone);
                            });
                            checkbox.parentNode.replaceChild(removeBtn, checkbox);
                        }
                        selectedList.appendChild(clone);
                    });
                    // Update hidden input
                    updateHiddenInput();
                    // Update UI state
                    updateButtonStates();
                });
                // Remove items
                removeButton.addEventListener('click', function() {
                    const selectedSelectedItems = selectedList.querySelectorAll('li.selected');
                    selectedSelectedItems.forEach(item => {
                        const id = parseInt(item.dataset.id, 10);
                        removeItem(id, item);
                    });
                });
                // Listen for clicks on items in both lists
                availableList.addEventListener('click', function(e) {
                    const item = e.target.closest('li');
                    if (!item) return;
                    // If clicking checkbox, handle separately
                    if (e.target.type === 'checkbox') {
                        updateButtonStates();
                        return;
                    }
                    // Toggle selection
                    if (item.classList.contains('selected')) {
                        item.classList.remove('selected');
                        item.querySelector('input[type="checkbox"]').checked = false;
                    } else {
                        item.classList.add('selected');
                        item.querySelector('input[type="checkbox"]').checked = true;
                    }
                    updateButtonStates();
                });
                selectedList.addEventListener('click', function(e) {
                    const item = e.target.closest('li');
                    if (!item) return;
                    // If clicking remove button, handle it
                    if (e.target.closest('.remove-item')) {
                        const id = parseInt(item.dataset.id, 10);
                        removeItem(id, item);
                        return;
                    }
                    // Toggle selection
                    item.classList.toggle('selected');
                    updateButtonStates();
                });
                // Helper Functions
                function performSearch() {
                    currentSearch = searchInput.value.trim();
                    // Show loading
                    loadingIndicator.hidden = false;
                    noResultsMessage.hidden = true;
                    availableList.innerHTML = '';
                    // Get filter parts
                    const [type, object] = currentFilter.split(':');
                    // Prepare data for AJAX
                    const data = {
                        action: 'jvb_association_search',
                        nonce: jvbSettings.nonce,
                        type: type,
                        object: object,
                        search: currentSearch,
                        page: config.currentPage,
                        per_page: config.perPage,
                        selected: selectedItems
                    };
                    // Make AJAX request to WordPress REST API
                    fetch(jvbSettings.api + 'terms', {
                        method: 'POST',
                        headers: {
                            'Content-Type': 'application/json',
                            'X-WP-Nonce': jvbSettings.nonce
                        },
                        body: JSON.stringify(data)
                    })
                        .then(response => response.json())
                        .then(response => {
                            // Hide loading
                            loadingIndicator.hidden = true;
                            if (response.success && response.items && response.items.length > 0) {
                                // Update available items
                                availableItems = response.items;
                                // Render items
                                renderAvailableItems();
                                // Update pagination
                                updatePagination(response.total, response.pages);
                            } else {
                                // Show no results
                                noResultsMessage.hidden = false;
                                prevPageButton.disabled = true;
                                nextPageButton.disabled = true;
                                currentPageSpan.textContent = '1';
                            }
                        })
                        .catch(error => {
                            console.error('Error searching items:', error);
                            loadingIndicator.hidden = true;
                            noResultsMessage.hidden = false;
                        });
                }
                function renderAvailableItems() {
                    availableList.innerHTML = '';
                    availableItems.forEach(item => {
                        const isSelected = selectedItems.includes(item.id);
                        const li = document.createElement('li');
                        li.dataset.id = item.id;
                        li.dataset.type = item.type;
                        li.dataset.object = item.object;
                        // Create checkbox
                        const checkbox = document.createElement('input');
                        checkbox.type = 'checkbox';
                        checkbox.id = `${name}-item-${item.id}`;
                        // Create label for title
                        const titleSpan = document.createElement('span');
                        titleSpan.className = 'item-title';
                        titleSpan.textContent = item.title;
                        // Create label for type
                        const typeSpan = document.createElement('span');
                        typeSpan.className = 'item-type';
                        typeSpan.textContent = item.object_label;
                        // Append elements
                        li.appendChild(checkbox);
                        li.appendChild(titleSpan);
                        li.appendChild(typeSpan);
                        // Disable if already selected
                        if (isSelected) {
                            li.classList.add('disabled');
                            checkbox.disabled = true;
                            // Add note that item is already selected
                            const note = document.createElement('span');
                            note.className = 'item-note';
                            note.textContent = 'Already selected';
                            li.appendChild(note);
                        }
                        availableList.appendChild(li);
                    });
                }
                function updatePagination(total, pages) {
                    // Update current page display
                    currentPageSpan.textContent = config.currentPage;
                    // Update prev/next buttons
                    prevPageButton.disabled = config.currentPage <= 1;
                    nextPageButton.disabled = config.currentPage >= pages;
                }
                function removeItem(id, element) {
                    // Remove from array
                    selectedItems = selectedItems.filter(itemId => itemId !== id);
                    // Remove from DOM
                    if (element) {
                        element.remove();
                    }
                    // Update hidden input
                    updateHiddenInput();
                    // Update buttons
                    updateButtonStates();
                }
                function updateHiddenInput() {
                    hiddenInput.value = selectedItems.join(',');
                }
                function updateButtonStates() {
                    // Add button is enabled if at least one available item is selected
                    // and we haven't reached the limit
                    const hasSelectedAvailable = availableList.querySelector('li.selected:not(.disabled)') !== null;
                    addButton.disabled = !hasSelectedAvailable ||
                        (config.limit > 0 && selectedItems.length >= config.limit);
                    // Remove button is enabled if at least one selected item is selected
                    const hasSelectedItems = selectedList.querySelector('li.selected') !== null;
                    removeButton.disabled = !hasSelectedItems;
                }
                // Initial search
                performSearch();
            })();
        </script>
        <?php
    }
    private function renderTrueFalseField(string $name, mixed $value, array $field):void
    {
        $conditional = $this->handleConditionalField($field);
        if (array_key_exists('group', $field)) {
            $name = $field['group'].'::'.$name;
        }
        $describedBy = (!empty($field['description'])) ? ' aria-describedby="'.$name.'-help"' : '';
        ?>
        <div class="field true-false <?=$name?> row btw" <?=$conditional?> data-field="<?=$name?>">
            <label class="toggle-switch row"<?=$describedBy?>>
                <input
                    type="checkbox"
                    name="<?= array_key_exists('base', $field) ? esc_attr($field['base']) : ''?><?= esc_attr($name); ?>"
                    value="1"
                    <?= ($value) ? ' checked':'' ?>
                    <?= !empty($field['required']) ? 'required' : ''; ?>
                >
                <div class="slider"></div>
                <span class="toggle-label"><?= esc_html($field['label']); ?></span>
            </label>
            <?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 renderUploadField(string $name, mixed $value, array $field): void
    {
        $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,
        ];
        $config = array_merge($defaultConfig, $field);
        // Validate destination config
        if (in_array($config['destination'], ['post', 'post_group']) && empty($config['content'])) {
            error_log("Upload field '{$name}' has destination '{$config['destination']}' but no content defined");
            return;
        }
        // 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;
        }
        // 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 ?>>
            <div class="file-upload-container">
                <div class="file-upload-wrapper">
                    <input type="file"
                           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) ?>"
                           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->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>
            <?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 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
    }
    protected function getAllowedTypes(array $config):array
    {
        $typeMap = [
            'image' => [
                'image/jpeg',
                'image/png',
                'image/gif',
                'image/webp'
            ],
            'video' => [
                'video/mp4',
                'video/webm',
                'video/ogg',
                'video/ogv',
                'video/quicktime'
            ],
            'document' => [
                'application/pdf',
                'application/msword',
                'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
                'application/vnd.ms-excel',
                'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
                'text/plain',
                'text/csv'
            ],
            'any' => [] // Will be merged from all types
        ];
        // If specific types are defined, use those
        if (!empty($config['accepted_types'])) {
            return is_array($config['accepted_types'])
                ? $config['accepted_types']
                : [$config['accepted_types']];
        }
        // Otherwise use subtype defaults
        $subtype = $config['subtype'] ?? 'image';
        if ($subtype === 'any') {
            return array_merge(
                $typeMap['image'],
                $typeMap['video'],
                $typeMap['document']
            );
        }
        return $typeMap[$subtype] ?? $typeMap['image'];
    }
    /**
     * Parse attachment IDs from value
     */
    private function parseAttachmentIds(mixed $value): array
    {
        if (empty($value)) return [];
        if (is_array($value)) {
            return array_filter(array_map('absint', $value));
        }
        return array_filter(array_map('absint', explode(',', $value)));
    }
    /**
     * Get file extensions for MIME types
     */
    private function getMimeExtensions(array $mimeTypes): array
    {
        $extensionMap = [
            'image/jpeg' => ['.jpg', '.jpeg'],
            'image/png' => ['.png'],
            'image/gif' => ['.gif'],
            'image/webp' => ['.webp'],
            'video/mp4' => ['.mp4'],
            'video/webm' => ['.webm'],
            'video/ogg' => ['.ogg'],
            'video/ogv' => ['.ogv'],
            'video/quicktime' => ['.mov'],
            'application/pdf' => ['.pdf'],
            'application/msword' => ['.doc'],
            'application/vnd.openxmlformats-officedocument.wordprocessingml.document' => ['.docx'],
            'application/vnd.ms-excel' => ['.xls'],
            'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' => ['.xlsx'],
            'text/plain' => ['.txt'],
            'text/csv' => ['.csv'],
        ];
        $extensions = [];
        foreach ($mimeTypes as $mime) {
            if (isset($extensionMap[$mime])) {
                $extensions = array_merge($extensions, $extensionMap[$mime]);
            }
        }
        return array_unique($extensions);
    }
    /**
     * 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'];
    }
    /**
     * Get human-readable file size label
     */
    private function getMaxFileSizeLabel(string $subtype): string
    {
        $bytes = $this->getMaxFileSize($subtype);
        $mb = round($bytes / 1048576);
        return "{$mb}MB";
    }
    /**
     * 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))));
    }
    /**
     * Render existing attachment
     */
    private function renderExistingAttachment(int $attachmentId, string $subtype): string
    {
        $attachment = get_post($attachmentId);
        if (!$attachment) return '';
        $url = wp_get_attachment_url($attachmentId);
        $thumbUrl = $subtype === 'image'
            ? wp_get_attachment_image_url($attachmentId, 'medium')
            : $url;
        ob_start();
        ?>
        <div class="upload-item existing" data-attachment-id="<?= esc_attr($attachmentId) ?>" data-subtype="<?= esc_attr($subtype) ?>">
            <div class="preview">
                <?php if ($subtype === 'image') : ?>
                    <img src="<?= esc_url($thumbUrl) ?>" alt="<?= esc_attr(get_post_meta($attachmentId, '_wp_attachment_image_alt', true)) ?>">
                <?php elseif ($subtype === 'video') : ?>
                    <video src="<?= esc_url($url) ?>" controls></video>
                <?php else : ?>
                    <div class="document-preview">
                        <?= jvbIcon('document') ?>
                        <span><?= esc_html(basename($url)) ?></span>
                    </div>
                <?php endif; ?>
                <div class="overlay">
                    <div class="actions">
                        <button type="button" class="remove" title="Remove">
                            <span class="screen-reader-text">Remove <?= esc_attr($subtype) ?></span>
                            Ã—
                        </button>
                    </div>
                </div>
            </div>
            <?php if ($subtype === 'image') {
                echo jvbImageMeta();
            } ?>
        </div>
        <?php
        return ob_get_clean();
    }
    private function renderImageField(string $name, mixed $value, array $field):void
    {
        $image_url = $title = $alt = $caption = false;
        if ($value !== 0 || $value !== '') {
            $image_url = wp_get_attachment_image_url((int)$value, 'medium') ?: false;
            $caption = wp_get_attachment_caption((int)$value);
            $alt = get_post_meta((int)$value, '_wp_attachment_image_alt', true);
            $title = get_the_title((int)$value);
        }
        $mode = array_key_exists('mode', $field) ? $field['mode'] : 'direct';
        $multiple = ($mode === 'selection' || isset($field['multiple']));
        if (array_key_exists('group', $field)) {
            $name = $field['group'].'::'.$name;
        }
        $groupable = (array_key_exists('imageType', $field) && $field['imageType'] === 'groupable');
        $singular = (array_key_exists('singular', $field)) ? $field['singular'] : 'post';
        $plural = (array_key_exists('plural', $field)) ? $field['plural'] : 'posts';
        $dataContent = (array_key_exists('content', $field)) ? ' data-content="'.$field['content'].'"' : '';
        $dataType = ($groupable) ? 'groupable' : (($multiple) ? 'gallery' : 'single');
        $describedBy = (!empty($field['description'])) ? ' aria-describedby="'.$name.'-help"' : '';
        ?>
        <div class="field image <?=$name?>"
             data-field="<?= esc_attr($name); ?>"
             data-upload-field
             data-mode="<?= esc_attr($mode); ?>"
             <?=$dataContent?>
            <?= ' data-type="'.$dataType.'"'?>>
            <div class="file-upload-container">
                <div class="file-upload-wrapper">
                    <input type="file"
                           name="<?= array_key_exists('base', $field) ? esc_attr($field['base']) : ''?><?= esc_attr($name); ?>_temp"
                           id="<?= array_key_exists('base', $field) ? esc_attr($field['base']) : ''?><?= esc_attr($name); ?>_temp"
                           accept=".jpg,.jpeg,.png,.gif,.webp"
                           data-max-size="<?= $this->max_file_size; ?>"
                        <?= $multiple ? 'multiple' : ''; ?>>
                    <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>
                        JPG, PNG, GIF, or WEBP (max. 5MB)
                    </p>
                    <?php if ($groupable) { ?>
                    <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 } ?>
                    <?php if (!empty($field['upload_description'])) : ?>
                        <p><?= esc_html($field['upload_description']); ?></p>
                    <?php endif; ?>
                </div>
                <div class="file-error"></div>
            </div>
            <?php if ($groupable) : ?>
            <div class="group-display" hidden>
                <div class="preview-wrap">
                    <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>
                                    With <span class="selection-count">0</span> selected
                                </div>
                            </div>
                            <!-- Selection actions (hidden by default) -->
                            <div class="selection-actions" hidden>
                                <button type="button" class="create-from-selection">
                                    <?= jvbIcon('add') ?>
                                    Create New <?= $singular ?>
                                </button>
                                <button type="button" class="remove-selection">
                                    <?= jvbIcon('delete') ?>
                                    Remove
                                </button>
                            </div>
                        </div>
                        <button type="button" 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 if ($image_url) {
                            echo jvbRenderImageForm((int)$value);
                        } ?>
                    </div>
                    <?php if ($groupable) : ?>
                    <p class="hint"><?= jvbIcon('elbow-left-up') ?>  These will become individual <?= $plural ?>  <?= jvbIcon('elbow-right-up')?></p>
                </div>
                <div class="sidebar">
                    <div class="header">
                        <h4>New <?= $plural?></h4>
                        <p class="hint">Drag images into groups to create separate <?= $plural ?>.</p>
                        <p class="hint">Select multiple images and click "Add to <?= $singular?>" or create new <?= $plural ?>.</p>
                    </div>
                    <button type="button" class="create-group-from-selection">
                        <?= jvbIcon('add') ?>
                        Create New <?= $singular ?>
                    </button>
                    <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 endif; ?>
            <?php if ($mode === 'direct') : ?>
                <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
    }
    protected function renderGalleryField(string $name, string|null|false $value, array $field):void
    {
        $ids = ($value === '' || is_null($value) || !$value) ? [] : explode(',',$value);
        if (!empty($ids)) {
            $ids = array_map('absint', $ids);
        }
        $conditional = $this->handleConditionalField($field);
        if (array_key_exists('group', $field)) {
            $name = $field['group'].'::'.$name;
        }
        //TODO: This can probably just be a wrapper for renderImageField...
        ?>
        <div class="field gallery <?=$name?>"
             data-field="<?= esc_attr($name); ?>"
            <?= $conditional ?>>
            <label><?= esc_html($field['label']); ?></label>
            <!-- Container for existing images -->
            <div class="gallery-preview">
                <?php
                if (!empty($ids)) {
                    foreach ($ids as $id) {
                        $url = wp_get_attachment_image_url($id, 'medium');
                        if ($url) {
                            echo '<div class="preview-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>';
                        }
                    }
                }
                ?>
            </div>
            <!-- Hidden file uploader that will be managed by BatchFileUploader -->
            <div class="file-upload-container">
                <div class="file-upload-wrapper">
                    <input type="file"
                           name="<?= array_key_exists('base', $field) ? esc_attr($field['base']) : ''?><?= esc_attr($name); ?>_temp"
                           accept=".jpg,.jpeg,.png,.gif,.webp"
                           multiple>
                    <p class="file-upload-text">
                        <strong>Click to upload</strong> or drag and drop<br>
                        JPG, PNG, GIF, or WEBP (max. 5MB)
                    </p>
                </div>
                <div class="file-error"></div>
            </div>
            <!-- Hidden input for storing the IDs -->
            <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 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 renderSelectField(string $name, mixed $value, array $field):void
    {
        $describedBy = (!empty($field['description'])) ? ' aria-describedby="'.$name.'-help"' : '';
        $conditional = $this->handleConditionalField($field);
        $default = isset($field['default']) ? $field['default'] : '';
        $value = !empty($value) ? $value : $default;
        if (array_key_exists('group', $field)) {
            $name = $field['group'].'::'.$name;
        }
        ?>
         <div class="field <?=$field['type']?> <?=$name?>" data-field="<?=$name?>" <?=$conditional?>>
            <label for="<?= esc_attr($name); ?>">
                <?= esc_html($field['label']); ?>
            </label>
             <?php if (array_key_exists('hint', $field)) { $this->renderHint($field['hint']); }  ?>
             <?php if (array_key_exists('description', $field)) { $this->renderDescription($field['description'], $name); }  ?>
            <select
                id="<?= array_key_exists('base', $field) ? esc_attr($field['base']) : ''?><?= esc_attr($name); ?>"
                name="<?= array_key_exists('base', $field) ? esc_attr($field['base']) : ''?><?= esc_attr($name); ?>"
                <?=$describedBy?>
                <?= !empty($field['required']) ? 'required' : ''; ?>
            >
                <?php foreach ($field['options'] as $key => $label) : ?>
                    <option value="<?= esc_attr($key); ?>"
                        <?php selected($value, $key); ?>>
                        <?= esc_html($label); ?>
                    </option>
                <?php endforeach; ?>
            </select>
        </div>
        <?php
    }
    protected function renderHtmlField(string $name, mixed $value, array $field):void
    {
        $method_name = $field['content'];
        $content = '';
        if (method_exists($this, $method_name)) {
            $content = $this->$method_name();
        }
        echo ($content == '') ? '' : sprintf(
            '<div class="html-field-container" data-field-type="html" data-field="%s">%s</div>',
            esc_attr($name),
            $content
        );
    }
    private function renderDateField(string $name, mixed $value, array $field):void
    {
        $describedBy = (!empty($field['description'])) ? ' aria-describedby="'.$name.'-help"' : '';
        $conditional = $this->handleConditionalField($field);
        $format = !empty($field['format']) ? $field['format'] : 'Y-m-d';
        // Format the date if we have a value
        if (!empty($value)) {
            $date = DateTime::createFromFormat($format, $value);
            if ($date) {
                $value = $date->format('Y-m-d'); // HTML date input requires Y-m-d format
            }
        }
        if (array_key_exists('group', $field)) {
            $name = $field['group'].'::'.$name;
        }
        ?>
         <div class="field <?=$field['type']?> <?=$name?>" <?=$conditional?> data-field="<?=$name?>">
            <label for="<?= esc_attr($name); ?>">
                <?= esc_html($field['label']); ?>
            </label>
            <div class="date-wrapper"<?=$describedBy?>>
                <input
                    type="date"
                    id="<?= array_key_exists('base', $field) ? esc_attr($field['base']) : ''?><?= esc_attr($name); ?>"
                    name="<?= array_key_exists('base', $field) ? esc_attr($field['base']) : ''?><?= esc_attr($name); ?>"
                    value="<?= esc_attr($value); ?>"
                    <?= !empty($field['required']) ? 'required' : ''; ?>
                    data-format="<?= esc_attr($format); ?>"
                >
                <?= jvbIcon('event') ?>
            </div>
             <?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
    }
    public function renderTimeField(string $name, mixed $value, array $field):void
    {
        $conditional = $this->handleConditionalField($field);
        $describedBy = (!empty($field['description'])) ? ' aria-describedby="'.$name.'-help"' : '';
        // Convert various time formats to HTML time input format (HH:MM)
        if (!empty($value)) {
            // If it's already in HH:MM format, use as-is
            if (preg_match('/^\d{2}:\d{2}$/', $value)) {
                // Value is already in correct format
            } else {
                // Try to parse and convert
                $timestamp = strtotime($value);
                if ($timestamp !== false) {
                    $value = date('H:i', $timestamp);
                } else {
                    $value = '';
                }
            }
        }
        if (array_key_exists('group', $field)) {
            $name = $field['group'].'::'.$name;
        }
        ?>
         <div class="field <?=$field['type']?> <?=$name?>" <?=$conditional?> data-field="<?=$name?>">
            <label for="<?= esc_attr($name); ?>">
                <?= esc_html($field['label']); ?>
            </label>
            <div class="time-wrapper"<?=$describedBy?>>
                <input
                    type="time"
                    id="<?= array_key_exists('base', $field) ? esc_attr($field['base']) : ''?><?= esc_attr($name); ?>"
                    name="<?= array_key_exists('base', $field) ? esc_attr($field['base']) : ''?><?= esc_attr($name); ?>"
                    value="<?= esc_attr($value); ?>"
                    <?= !empty($field['required']) ? 'required' : ''; ?>
                    <?= !empty($field['min']) ? 'min="' . esc_attr($field['min']) . '"' : ''; ?>
                    <?= !empty($field['max']) ? 'max="' . esc_attr($field['max']) . '"' : ''; ?>
                    <?= !empty($field['step']) ? 'step="' . esc_attr($field['step']) . '"' : ''; ?>
                >
            </div>
             <?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 renderDatetimeField(string $name, mixed $value, array $field):void
    {
        $conditional = $this->handleConditionalField($field);
        $describedBy = (!empty($field['description'])) ? ' aria-describedby="'.$name.'-help"' : '';
        // Convert datetime to HTML datetime-local format (YYYY-MM-DDTHH:MM)
        if (!empty($value)) {
            $date = DateTime::createFromFormat('Y-m-d H:i:s', $value);
            if (!$date) {
                // Try alternative formats
                $formats = ['Y-m-d\TH:i:s', 'Y-m-d\TH:i', 'Y-m-d H:i'];
                foreach ($formats as $format) {
                    $date = DateTime::createFromFormat($format, $value);
                    if ($date) break;
                }
            }
            if ($date) {
                $value = $date->format('Y-m-d\TH:i'); // HTML datetime-local format
            } else {
                $value = '';
            }
        }
        if (array_key_exists('group', $field)) {
            $name = $field['group'].'::'.$name;
        }
        ?>
        <div class="field <?=$field['type']?> <?=$name?>" <?=$conditional?> data-field="<?=$name?>">
            <label for="<?= esc_attr($name); ?>">
                <?= esc_html($field['label']); ?>
            </label>
            <div class="datetime-wrapper"<?=$describedBy?>>
                <input
                    type="datetime-local"
                    id="<?= array_key_exists('base', $field) ? esc_attr($field['base']) : ''?><?= esc_attr($name); ?>"
                    name="<?= array_key_exists('base', $field) ? esc_attr($field['base']) : ''?><?= esc_attr($name); ?>"
                    value="<?= esc_attr($value); ?>"
                    <?= !empty($field['required']) ? 'required' : ''; ?>
                    <?= !empty($field['min']) ? 'min="' . esc_attr($field['min']) . '"' : ''; ?>
                    <?= !empty($field['max']) ? 'max="' . esc_attr($field['max']) . '"' : ''; ?>
                    <?= !empty($field['step']) ? 'step="' . esc_attr($field['step']) . '"' : ''; ?>
                >
                <?= jvbIcon('event') ?>
            </div>
            <?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
    }
    public function outputCharacterCountJS():void
    {
        ?>
        <script>
            document.querySelectorAll('[maxlength]').forEach(field => {
                const counter = field.closest('.field')?.querySelector('.char-count .current');
                if (counter) {
                    const updateCount = () => counter.textContent = field.value.length;
                    field.addEventListener('input', updateCount);
                    updateCount();
                }
            });
        </script>
        <?php
    }
    //Conditional Fields
    private function handleConditionalField(array $field):string
    {
        if (empty($field['condition'])) {
            return '';
        }
        $condition = $field['condition'];
        return sprintf(
            'data-depends-on="%s" data-depends-value="%s" data-depends-operator="%s"',
            esc_attr($field['condition']['field']),
            esc_attr($field['condition']['value']),
            esc_attr($field['condition']['operator'] ?? '==')
        );
    }
    protected function renderDescription(string $description, string $name):void
    {
        $id = $name.'-help';
        $out = '<div class="has-tooltip">
        <span class="tt-toggle">'.jvbIcon('help').'</span>
        <div role="tooltip" id="'.$id.'"><p>'.$description.'</p></div>
        </div>';
        echo $out;
    }
    protected function renderHint(array|string $hint):void
    {
        if (is_array($hint)) {
            $out = '';
            foreach($hint as $h) {
                $out .= '<p class="hint">'.$h.'</p>';
            }
        } else {
            $out = '<p class="hint">'.$hint.'</p>';
        }
        echo $out;
    }
}
inc/meta/MetaManager.php
@@ -19,6 +19,7 @@
    public MetaForm $form;
    protected int|null $object_id;
    public object|null $data;
    protected array $fields =[];
    protected string $field;
    protected mixed $value;
    protected string|null $object_type;
@@ -502,6 +503,9 @@
    protected function getFields(): array
    {
        if (!empty($this->fields)) {
            return $this->fields;
        }
        switch ($this->object_type) {
            case 'post':
                $type = get_post_type((int)$this->object_id);
@@ -601,6 +605,11 @@
        }
    }
    public function setFieldConfig(array $fields):void
    {
        $this->fields = $fields;
    }
    protected function getFieldConfig(string $name):array|false
    {
        $fields = $this->getFields();
inc/registry/PostTypeRegistrar.php
@@ -34,7 +34,7 @@
        $args = [
            'labels'              => $this->buildLabels($singular, $plural),
            'public'              => $this->config['public'] ?? true,
            'publicly_queryable'  => $this->config['publicly_queryable'] ?? true,
            'publicly_queryable'  => $this->config['publicly_queryable'] ?? $this->config['public'] ?? true,
            'show_ui'             => $this->config['show_ui'] ?? true,
//          'show_in_menu'        => false,
            'show_in_menu'        => $this->config['show_in_menu'] ?? true,
inc/rest/_setup.php
@@ -54,6 +54,10 @@
    require(JVB_DIR . '/inc/rest/routes/ApprovalRoutes.php');
}
if (Features::hasIntegration('square')) {
    require(JVB_DIR . '/inc/rest/routes/IntegrationsSquareRoutes.php');
}
require(JVB_DIR .'/inc/rest/routes/OptionsRoutes.php');
require(JVB_DIR .'/inc/rest/routes/FormRoutes.php');
require(JVB_DIR .'/inc/rest/routes/IntegrationsRoutes.php');
inc/rest/routes/IntegrationsSquareRoutes.php
New file
@@ -0,0 +1,239 @@
<?php
namespace JVBase\rest\routes;
use JVBase\rest\RestRouteManager;
use WP_REST_Request;
use WP_REST_Response;
use Exception;
if (!defined('ABSPATH')) {
    exit; // Exit if accessed directly
}
class IntegrationsSquareRoutes extends RestRouteManager
{
    public function registerRoutes():void
    {
        register_rest_route('jvb/v1/square', '/process-payment', [
            'methods' => 'POST',
            'callback' => [$this, 'handlePaymentProcessing'],
            'permission_callback' => '__return_true' // Adjust based on your auth
        ]);
        register_rest_route('jvb/v1/square', '/saved-cards', [
            'methods' => 'GET',
            'callback' => [$this, 'getSavedCards'],
            'permission_callback' => 'is_user_logged_in'
        ]);
        register_rest_route('jvb/v1/square', '/order-history', [
            'methods' => 'GET',
            'callback' => [$this, 'getOrderHistory'],
            'permission_callback' => 'is_user_logged_in'
        ]);
        register_rest_route('jvb/v1/square', '/order-status/(?P<order_id>[a-zA-Z0-9_-]+)', [
            'methods' => 'GET',
            'callback' => [$this, 'getOrderStatus'],
            'permission_callback' => '__return_true' // Allow guests with order ID
        ]);
    }
    public function handlePaymentProcessing($request): array
    {
        $data = $request->get_json_params();
        // Generate idempotency key from cart_id + timestamp
        // This ensures retries use SAME key
        $cart_id = $data['cart_id'] ?? '';
        if (!$cart_id) {
            return ['success' => false, 'message' => 'Missing cart ID'];
        }
        // Check if we already processed this cart
        $existing_order = get_transient(BASE . 'cart_order_' . $cart_id);
        if ($existing_order) {
            // Return cached result - prevents double charge
            return $existing_order;
        }
        // Generate idempotency key tied to this specific cart
        $idempotency_key = 'cart_' . $cart_id . '_' . time();
        // Store key to prevent reprocessing
        set_transient(BASE . 'cart_idempotency_' . $cart_id, $idempotency_key, HOUR_IN_SECONDS);
        // Validate required fields
        $required = ['source_id', 'amount', 'items', 'customer'];
        foreach ($required as $field) {
            if (empty($data[$field])) {
                return [
                    'success' => false,
                    'message' => "Missing required field: {$field}"
                ];
            }
        }
        try {
            $square = JVB()->connect('square');
            // Step 1: Get or create Square customer
            $customer_id = $square->getOrCreateSquareCustomer($data['customer']);
            // Step 2: Create Order in Square
            $order_response = $square->createSquareOrder($data['items'], $customer_id, $data);
            if (is_wp_error($order_response)) {
                throw new Exception($order_response->get_error_message());
            }
            $order_id = $order_response['order']['id'] ?? null;
            if (!$order_id) {
                throw new Exception('Failed to create Square order');
            }
            // Step 3: Create Payment in Square
            $payment_response = $square->createSquarePayment(
                $data['source_id'],
                $data['idempotency_key'],
                $data['amount'],
                $order_id,
                $customer_id
            );
            if (is_wp_error($payment_response)) {
                throw new Exception($payment_response->get_error_message());
            }
            $payment = $payment_response['payment'] ?? null;
            if (!$payment) {
                throw new Exception('Failed to create Square payment');
            }
            // Step 4: Save order reference in WordPress
            $wp_order_id = $square->saveOrderToWordPress([
                'square_order_id' => $order_id,
                'square_payment_id' => $payment['id'],
                'customer' => $data['customer'],
                'items' => $data['items'],
                'amount' => $data['amount'],
                'status' => $payment['status']
            ]);
            $result = [
                'success' => true,
                'order_id' => $order_id,
                'payment_id' => $payment['id'],
                'wp_order_id' => $wp_order_id,
                'status' => $payment['status'],
                'receipt_url' => $payment['receipt_url'] ?? null
            ];
            set_transient(BASE . 'cart_order_' . $cart_id, $result, HOUR_IN_SECONDS);
            return $result;
        } catch (Exception $e) {
            $this->logError('Payment processing failed', [
                'error' => $e->getMessage(),
                'idempotency_key' => $data['idempotency_key']
            ]);
            return [
                'success' => false,
                'message' => $e->getMessage()
            ];
        }
    }
    public function getSavedCards($request): array
    {
        $user_id = get_current_user_id();
        if (!$user_id) {
            return ['success' => false, 'message' => 'Not logged in'];
        }
        $square = JVB()->connect('square');
        // Get Square customer ID for this user
        $square_customer_id = get_user_meta($user_id, BASE . '_square_customer_id', true);
        if (!$square_customer_id) {
            return ['success' => true, 'cards' => []];
        }
        // Fetch cards from Square (2025-compliant - separate endpoint)
        $cards_response = $square->getRequest('cards?customer_id=' . $square_customer_id);
        if (is_wp_error($cards_response)) {
            return ['success' => false, 'message' => 'Failed to fetch cards'];
        }
        return [
            'success' => true,
            'cards' => $cards_response['cards'] ?? []
        ];
    }
    public function getOrderHistory($request): array
    {
        $user_id = get_current_user_id();
        if (!$user_id) {
            return ['success' => false, 'message' => 'Not logged in'];
        }
        // Get orders from custom post type
        $orders = get_posts([
            'post_type' => BASE . '_sq_orders',
            'author' => $user_id,
            'posts_per_page' => 50,
            'orderby' => 'date',
            'order' => 'DESC'
        ]);
        $order_data = [];
        foreach ($orders as $order) {
            $meta = new \JVBase\meta\MetaManager($order->ID, 'post');
            $order_data[] = [
                'wp_order_id' => $order->ID,
                'square_order_id' => $meta->getValue('square_order_id'),
                'status' => $meta->getValue('status'),
                'amount' => $meta->getValue('amount'),
                'items' => $meta->getValue('items'),
                'created_at' => $meta->getValue('created_at'),
                'pickup_time' => $meta->getValue('pickup_time')
            ];
        }
        return [
            'success' => true,
            'orders' => $order_data
        ];
    }
    public function getOrderStatus($request): array
    {
        $order_id = $request->get_param('order_id');
        // Find WP post by Square order ID
        $wp_order_id = get_option(BASE . 'square_order_map_' . $order_id);
        if (!$wp_order_id) {
            return ['success' => false, 'message' => 'Order not found'];
        }
        $meta = new \JVBase\meta\MetaManager($wp_order_id, 'post');
        return [
            'success' => true,
            'order' => [
                'status' => $meta->getValue('status'),
                'fulfillment_status' => $meta->getValue('fulfillment_status'),
                'pickup_time' => $meta->getValue('pickup_time'),
                'items' => $meta->getValue('items')
            ]
        ];
    }
}
jvb.php
@@ -593,7 +593,8 @@
            'jvb-tabs',
            'jvb-selector',
            'jvb-uploader',
            'sortable-js'
            'sortable-js',
            'jvb-populate-form'
        ],
        '1.0.0',
        [
@@ -775,8 +776,6 @@
        JVB_URL.'assets/js/min/navigation.min.js',
    );
    add_action('wp_head', 'jvbInlineNavStyles');
    wp_enqueue_script('jvb-queue');
src/forms/view.js
@@ -2,73 +2,73 @@
 * Frontend JavaScript for the Form Block
 * Handles form validation and submission
 */
class FormBlock {
    constructor() {
        this.controller = new window.jvbForm();
// Wait for DOM to be ready
document.addEventListener('DOMContentLoaded', function() {
    let formController = new window.jvbForm();
    document.querySelectorAll('.jvb-form-block').forEach(block => {
        const formHandler = formController.registerForm(block.querySelector('form'));
        document.querySelectorAll('.jvb-form-block form').forEach(form => {
            this.controller.registerForm(form);
    });
    formController.subscribe((event, data) => {
        this.controller.subscribe((event, data) => {
        if (event === 'form-submit') {
            handleFormSubmission(null, data.data, data.config.element);
                this.handleFormSubmission(data);
        }
    });
});
    }
async function handleFormSubmission(event, data, form) {
    async handleFormSubmission(data) {
        let [
            formId,
            formConfig,
            formData
        ] = [
            data.formId,
            data.config,
            data.data
        ];
        let form = formConfig.element;
    let headers = {
        'X-WP-Nonce': jvbSettings.nonce,
        'Content-Type': 'application/json'
    };
    data['form_type'] = form.dataset.formId;
        data['form_type'] = formId;
    data['form_id'] = form.id;
    let block = form.closest('.jvb-form-block');
    let loading = window.getTemplate('spinner');
    form.hidden = true;
    block.classList.add('loading');
    block.prepend(loading);
        this.controller.showFormStatus(formId, 'uploading');
    try {
        const response = await fetch (`${jvbSettings.api}forms`,
            {
                method: 'POST',
                headers,
                body: JSON.stringify(data)
                    body: JSON.stringify(formData)
            });
        if (!response.ok) {
                this.controller.showFormStatus(formId, 'error');
            const errorData = await response.json().catch(() => ({}));
            throw new Error(errorData.message|| `Request failed with status ${response.status}`);
        }
            this.controller.showFormStatus(formId, 'submitted');
        updateUI(await response.json(), block);
            this.controller.showSummary(formId, '.jvb-form-block');
            this.controller.store.delete(formId);
    } catch (error) {
        throw error;
    } finally {
        block.classList.remove('loading');
    }
}
function updateUI(response, block) {
    if (response.success === false) {
        let form = block.querySelector('form');
        form.removeAttribute('hidden');
        if ('required' in response) {
            response.required.forEach(required => {
                let item = form.querySelector(`[name=${required}]`);
                item.classList.add('required', 'error');
                item.scrollIntoView();
            });
        }
        return;
    }
    updateUI(response, block) {
    let summary = window.getTemplate('formSummary');
    summary.querySelector('h2').textContent = 'Success!';
    console.log('Form Response: ', response);
        console.log(summary);
    for (let [key, value] of Object.entries(response)) {
        let item = summary.querySelector(`#${key}`);
            if (item) {
        let title = item.querySelector('h4');
        if (title.innerText.includes('%s')) {
            title.innerHTML = title.replace('%s', '<b>'+value+'</b>');
@@ -76,6 +76,11 @@
            item.querySelector('div').innerHTML = value;
        }
    }
        }
    block.append(summary);
}
}
document.addEventListener('DOMContentLoaded', function() {
    new FormBlock();
});