From e729f920139f0c65902be2d6b2c32466b08375e8 Mon Sep 17 00:00:00 2001
From: Jake Vanderwerf <get@jakevanderwerf.ca>
Date: Mon, 20 Oct 2025 17:54:52 +0000
Subject: [PATCH] =Form updates
---
assets/js/dash/SquareCheckout.js | 258 +
assets/css/copy-hours.min.css | 2
inc/helpers/renderFields.php | 22
assets/css/forms.min.css | 2
inc/managers/FormManager.php | 1
assets/js/dash/UtilityFunctions.js | 15
jvb.php | 5
inc/helpers/ui.php | 20
assets/js/min/square.min.js | 2
inc/managers/ReferralManager.php | 80
assets/js/min/form.min.js | 2
build/forms/view.asset.php | 2
inc/rest/_setup.php | 4
inc/meta/MetaForm.php | 2981 +++++++---------------
inc/meta/MetaFormOld.php | 2128 ++++++++++++++++
assets/js/min/utility.min.js | 2
JVBase.php | 8
inc/rest/routes/IntegrationsSquareRoutes.php | 239 +
inc/integrations/Square.php | 444 +++
inc/meta/MetaManager.php | 9
inc/integrations/Integrations.php | 93
src/forms/view.js | 153
assets/js/concise/FormController.js | 1152 ++++++++
build/forms/view.js | 2
inc/blocks/FormBlock.php | 58
icons.php | 4
inc/registry/PostTypeRegistrar.php | 2
27 files changed, 5,426 insertions(+), 2,264 deletions(-)
diff --git a/JVBase.php b/JVBase.php
index 7191fff..e6fe838 100644
--- a/JVBase.php
+++ b/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']);
diff --git a/assets/css/copy-hours.min.css b/assets/css/copy-hours.min.css
index 16967a5..101a314 100644
--- a/assets/css/copy-hours.min.css
+++ b/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)}}
\ No newline at end of file
+.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}
\ No newline at end of file
diff --git a/assets/css/forms.min.css b/assets/css/forms.min.css
index 4acd93b..188f3b8 100644
--- a/assets/css/forms.min.css
+++ b/assets/css/forms.min.css
@@ -1 +1 @@
-details.uploader .file-upload-container{margin:1rem 0;max-width:100%}@media (min-width:768px){details.uploader .file-upload-container{margin:1rem var(--mr) 1rem var(--ml);max-width:var(--maxWidth)}}.file-upload-wrapper{border:2px dashed var(--action-0);border-radius:4px;padding:2rem;text-align:center;transition:all .3s ease;background:rgba(var(--action-rgb),var(--rgb-subtle));position:relative;cursor:pointer}.file-upload-wrapper h2{margin:0!important;font-size:var(--large)}.dragover,.file-upload-wrapper:hover{background:rgba(var(--action-rgb),var(--rgb-subtle-hover));border-color:var(--action-0)!important}.file-upload-wrapper input[type=file]{position:absolute;left:0;top:0;width:100%;height:100%;opacity:0;cursor:pointer}.file-upload-text{color:var(--contrast);margin:0;font-family:var(--body)}.file-upload-text strong{color:var(--action-0);text-decoration:underline}.field.upload:has(.upload-item) .file-upload-container{display:none}.field.upload{position:relative}.field.upload:not(.uploading) .progress{display:none}.field.upload .actions{position:absolute;top:0;right:0}.item-grid.group,.item-grid.preview,.item-grid.restore{grid-template-columns:repeat(3,1fr)}.item-grid.group .item,.item-grid.preview .item,.item-grid.restore .item{display:block}.item-grid.group button,.item-grid.preview button,.item-grid.restore button{padding:.25rem .5rem}.item-grid.group button .icon,.item-grid.preview button .icon,.item-grid.restore button .icon{--w:1.1em}.item-grid.group .item .preview>input[type=checkbox]:not(.label-button)+label,.item-grid.preview .item .preview>input[type=checkbox]:not(.label-button)+label,.item-grid.restore .item .preview>input[type=checkbox]:not(.label-button)+label{padding-left:0;margin:0}.item-grid.group .item .preview>input[type=checkbox]+label:before,.item-grid.preview .item .preview>input[type=checkbox]+label:before,.item-grid.restore .item .preview>input[type=checkbox]+label:before{transform:unset;top:.5rem;left:.5rem}.item-grid.group .item .preview>input[type=checkbox]+label::after,.item-grid.preview .item .preview>input[type=checkbox]+label::after,.item-grid.restore .item .preview>input[type=checkbox]+label::after{top:.5rem;left:.75rem;transform:translateY(20%) rotate(45deg)}.item-grid.group .item .item-actions,.item-grid.preview .item .item-actions,.item-grid.restore .item .item-actions{position:absolute;top:0;right:0}.item-grid.group summary,.item-grid.preview summary,.item-grid.restore summary{padding:.5rem}.item-grid.group:has([type=checkbox]:checked),.item-grid.preview:has([type=checkbox]:checked),.item-grid.restore:has([type=checkbox]:checked){padding:1rem;background-color:rgba(var(--contrast-rgb),var(--rgb-subtle))}.item-grid.group:has([type=checkbox]:checked) .item,.item-grid.preview:has([type=checkbox]:checked) .item,.item-grid.restore:has([type=checkbox]:checked) .item{padding:.75rem;opacity:.8}.item-grid.group:has([type=checkbox]:checked) .item img,.item-grid.preview:has([type=checkbox]:checked) .item img,.item-grid.restore:has([type=checkbox]:checked) .item img{filter:var(--filter)}.item-grid.group:has([type=checkbox]:checked) details,.item-grid.preview:has([type=checkbox]:checked) details,.item-grid.restore:has([type=checkbox]:checked) details{display:none}.item-grid.group .item:has([type=checkbox]:checked),.item-grid.preview .item:has([type=checkbox]:checked),.item-grid.restore .item:has([type=checkbox]:checked){padding:.5rem;background-color:rgba(var(--action-rgb),var(--rgb-medium));opacity:1}.item-grid.group .item:has([type=checkbox]:checked) img,.item-grid.preview .item:has([type=checkbox]:checked) img,.item-grid.restore .item:has([type=checkbox]:checked) img{filter:none}[type=radio].featured+label .star+.star,[type=radio].featured:checked+label .star{display:none}[type=radio].featured+label .star,[type=radio].featured:checked+label .star+.star{display:inline-block}.restore.item,.upload.item{border-radius:var(--innerRadius);overflow:hidden;background:var(--base);border:1px solid var(--base-200)}.restore.item img,.upload.item img{transition:transform var(--transition-base)}.restore.item:hover img,.upload.item:hover img{transform:scale(1.02);transition:transform var(--transition-base)}.upload-group{background-image:var(--dashed-action);padding:5px;border-radius:var(--innerRadius);background-color:rgba(var(--action-rgb),var(--rgb-subtle))}.submit-uploads{position:fixed;bottom:var(--offHeight);right:var(--offHeight);z-index:var(--z-6);height:var(--height);box-shadow:var(--shadow);border-radius:var(--innerRadius);animation:pulse-color 5s infinite;animation-delay:1s;background-color:var(--action-0);color:var(--action-contrast)}.submit-uploads:hover{background-color:var(--base-200);color:var(--contrast-200)}.empty-group{grid-column:1/-1;padding:20px;background-image:var(--dashed-action);border-radius:var(--innerRadius);margin:10px 0;cursor:pointer;transition:all var(--transition-base);text-align:center;background-color:rgba(var(--action-rgb),var(--rgb-subtle))}.group-display:not([hidden])~.file-upload-container{display:none}.dragging,.upload.item.dragging{opacity:.7;transform:scale(.95) rotate(3deg);z-index:var(--z-top);box-shadow:0 8px 25px rgba(0,0,0,.3)}.dragover{background:rgba(var(--action-rgb),var(--rgb-light))!important;border-color:var(--action-0)!important;transform:scale(1.05);animation:drop-pulse .8s infinite ease-in-out}.drag-preview{position:fixed;z-index:var(--zz-top);width:fit-content;overflow:visible;pointer-events:none;opacity:.9;transform:scale(1.05);transition:transform .2s ease}.drag-preview .drag-items{width:max-content;height:max-content;position:relative}.drag-preview .drag-items .drag-item{width:120px;height:120px;position:absolute;top:0;left:0;background:var(--base);border-radius:var(--outerRadius);box-shadow:var(--shadow)}.drag-preview .drag-items .drag-item:nth-child(1){transform:rotate(-3deg);z-index:3}.drag-preview .drag-items .drag-item:nth-child(2){left:8px;top:-4px;transform:rotate(4deg);z-index:2;transition-delay:30ms}.drag-preview .drag-items .drag-item:nth-child(3){left:-6px;top:-8px;transform:rotate(-5deg);z-index:1;transition-delay:60ms}.drag-preview .drag-items .drag-item:nth-child(4){left:12px;top:-12px;transform:rotate(3deg);z-index:0;transition-delay:90ms}.drag-preview .drag-items .drag-item:nth-child(n+5){left:-10px;top:-16px;transform:rotate(-4deg);z-index:0;opacity:.8}.drag-preview .drag-items img,.drag-preview .drag-items video{width:100%;height:100%;object-fit:cover;display:block}.drag-preview .drag-count{position:absolute;top:-8px;right:-8px;background:var(--base-200);color:var(--contrast);border-radius:50%;width:24px;height:24px;display:flex;align-items:center;justify-content:center;font-size:12px;font-weight:700;box-shadow:var(--shadow);z-index:var(--z-3)}.item.dragging{opacity:.5;transform:scale(.95);filter:grayscale(50%);transition:opacity .2s ease,transform .2s ease,filter .2s ease}@keyframes drop-pulse{0%,100%{background-color:rgba(var(--action-rgb),var(--rgb-light));transform:scale(1.02)}50%{background-color:var(rgba(var(--action-rgb),var(--rgb-medium)));transform:scale(1.04)}}.group-actions{display:flex;gap:.25rem}@media (max-width:767px){body:not(.uploading):has(.group-display:not([hidden])){overflow:hidden}body:not(.uploading):has(.group-display:not([hidden])) .qtoggle{z-index:var(--z-1)}.group-display.group-display{position:fixed;top:var(--height);bottom:var(--height);left:0;right:0;max-height:var(--maxHeight);overflow:hidden;z-index:var(--z-6);width:calc(100% - 1rem);height:calc(100% - 1rem);padding:0 0 3rem;--justify:flex-start;--align:flex-start;--gap:0}.group-display::before{content:'';display:block;z-index:-1;top:-.5rem;bottom:-.5rem;left:-.5rem;right:-.5rem;position:absolute;background-color:rgba(var(--base-rgb),var(--rgb-heavy));filter:blur(5px)}.group-display .preview-wrap,.group-display .sidebar{height:50%;overflow:hidden auto;position:relative;padding:.5rem}.group-display .preview-wrap{top:0}.group-display .preview-wrap .selected{display:flex;justify-content:space-between;align-items:center}.group-display .sidebar{bottom:0;flex-wrap:nowrap;overflow:hidden auto;background-color:var(--contrast-200);color:var(--base)}.group-display .sidebar>.hint{color:var(--contrast)}.group-display .sidebar .header{display:none}.group-display .preview-actions{top:0;flex-shrink:0}.group-display .preview-wrap>.hint,.group-display .sidebar>.hint{bottom:0;margin:0;text-align:center}.group-display .preview-actions,.group-display .preview-wrap>.hint,.group-display .sidebar>.hint{position:absolute;left:0;right:0;background-color:rgba(var(--base-rgb),var(--rgb-heavy));z-index:var(--z-3);box-shadow:var(--shadow)}.group-display .item-grid{height:100%;overflow:hidden auto;grid-template-columns:repeat(3,1fr);padding:2rem 0}.group-display .sidebar>.item-grid{grid-template-columns:repeat(1,1fr);gap:1rem;padding:0}.group-display .sidebar .empty-group{order:0;position:sticky;height:fit-content;top:0;z-index:var(--z-3);background-color:rgba(var(--action-rgb),var(--rgb-heavy))}.group-display .sidebar .upload-group{order:1}.group-display .sidebar .empty-group p{margin:0}.group-display .field,.group-display .field label{margin:0;padding:0}.group-display .sidebar h4{margin:.25rem}.group-display .item{width:100%;height:max-content}.submit-uploads{bottom:var(--height);left:0;right:0;width:100%;height:3rem}body.uploading .group-display.group-display{position:relative;top:unset;bottom:unset;right:unset;left:unset}}@media (min-width:768px){.group-display.group-display{--wrap:nowrap;--dir:row;--gap:1rem;--align:flex-start}.group-display .preview-wrap,.group-display .sidebar{--justify:flex-start;max-height:calc(100vh - var(--doubleHeight));overflow:hidden auto}.group-display .preview-wrap,.group-display .sidebar{width:50%}.preview-actions,.preview-wrap .hint{position:sticky;z-index:var(--z-3);box-shadow:var(--shadow);background-color:var(--base);width:100%}.preview-actions{top:0;left:0;right:0}.preview-actions .field{margin:0}.preview-wrap .hint,.sidebar>.hint{bottom:-1rem;padding-bottom:1rem;margin:0;left:0;right:0;text-align:center}}.restore-uploads{position:fixed;top:var(--offHeight);bottom:var(--offHeight);left:1rem;right:1rem;border-radius:var(--outerRadius);padding:1rem;z-index:var(--z-top);box-shadow:var(--shadow);background-color:var(--base-200);overflow:hidden auto}dialog nav.tabs{position:sticky;top:0;background-color:var(--base-50);z-index:var(--z-6);box-shadow:var(--shadow-down);margin-bottom:2rem}.editor-container .ql-toolbar{display:flex;background-color:var(--base-50);justify-content:flex-start;flex-wrap:wrap;padding:.25rem;gap:.5rem 1rem;border-top-left-radius:var(--innerRadius);border-top-right-radius:var(--innerRadius);border-bottom:4px solid var(--base-50)}.ql-toolbar .ql-formats{display:flex;gap:.25rem}.editor-container .ql-container{--padding:1rem;background-color:var(--base);border-bottom-left-radius:var(--innerRadius);border-bottom-right-radius:var(--innerRadius);height:fit-content;padding:2px;border:1px solid var(--base-200)}.editor-container .ql-container .ql-editor{padding:var(--padding);width: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 *!*/
\ No newline at end of file
+details.uploader .file-upload-container{margin:1rem 0;max-width:100%}@media (min-width:768px){details.uploader .file-upload-container{margin:1rem var(--mr) 1rem var(--ml);max-width:var(--maxWidth)}}.file-upload-wrapper{border:2px dashed var(--action-0);border-radius:4px;padding:2rem;text-align:center;transition:all .3s ease;background:rgba(var(--action-rgb),var(--rgb-subtle));position:relative;cursor:pointer}.file-upload-wrapper h2{margin:0!important;font-size:var(--large)}.dragover,.file-upload-wrapper:hover{background:rgba(var(--action-rgb),var(--rgb-subtle-hover));border-color:var(--action-0)!important}.file-upload-wrapper input[type=file]{position:absolute;left:0;top:0;width:100%;height:100%;opacity:0;cursor:pointer}.file-upload-text{color:var(--contrast);margin:0;font-family:var(--body)}.file-upload-text strong{color:var(--action-0);text-decoration:underline}.field.upload:has(.upload-item) .file-upload-container{display:none}.field.upload{position:relative}.field.upload:not(.uploading) .progress{display:none}.field.upload .actions{position:absolute;top:0;right:0}.item-grid.group,.item-grid.preview,.item-grid.restore{grid-template-columns:repeat(3,1fr)}.item-grid.group .item,.item-grid.preview .item,.item-grid.restore .item{display:block}.item-grid.group button,.item-grid.preview button,.item-grid.restore button{padding:.25rem .5rem}.item-grid.group button .icon,.item-grid.preview button .icon,.item-grid.restore button .icon{--w:1.1em}.item-grid.group .item .preview>input[type=checkbox]:not(.label-button)+label,.item-grid.preview .item .preview>input[type=checkbox]:not(.label-button)+label,.item-grid.restore .item .preview>input[type=checkbox]:not(.label-button)+label{padding-left:0;margin:0}.item-grid.group .item .preview>input[type=checkbox]+label:before,.item-grid.preview .item .preview>input[type=checkbox]+label:before,.item-grid.restore .item .preview>input[type=checkbox]+label:before{transform:unset;top:.5rem;left:.5rem}.item-grid.group .item .preview>input[type=checkbox]+label::after,.item-grid.preview .item .preview>input[type=checkbox]+label::after,.item-grid.restore .item .preview>input[type=checkbox]+label::after{top:.5rem;left:.75rem;transform:translateY(20%) rotate(45deg)}.item-grid.group .item .item-actions,.item-grid.preview .item .item-actions,.item-grid.restore .item .item-actions{position:absolute;top:0;right:0}.item-grid.group summary,.item-grid.preview summary,.item-grid.restore summary{padding:.5rem}.item-grid.group:has([type=checkbox]:checked),.item-grid.preview:has([type=checkbox]:checked),.item-grid.restore:has([type=checkbox]:checked){padding:1rem;background-color:rgba(var(--contrast-rgb),var(--rgb-subtle))}.item-grid.group:has([type=checkbox]:checked) .item,.item-grid.preview:has([type=checkbox]:checked) .item,.item-grid.restore:has([type=checkbox]:checked) .item{padding:.75rem;opacity:.8}.item-grid.group:has([type=checkbox]:checked) .item img,.item-grid.preview:has([type=checkbox]:checked) .item img,.item-grid.restore:has([type=checkbox]:checked) .item img{filter:var(--filter)}.item-grid.group:has([type=checkbox]:checked) details,.item-grid.preview:has([type=checkbox]:checked) details,.item-grid.restore:has([type=checkbox]:checked) details{display:none}.item-grid.group .item:has([type=checkbox]:checked),.item-grid.preview .item:has([type=checkbox]:checked),.item-grid.restore .item:has([type=checkbox]:checked){padding:.5rem;background-color:rgba(var(--action-rgb),var(--rgb-medium));opacity:1}.item-grid.group .item:has([type=checkbox]:checked) img,.item-grid.preview .item:has([type=checkbox]:checked) img,.item-grid.restore .item:has([type=checkbox]:checked) img{filter:none}[type=radio].featured+label .star+.star,[type=radio].featured:checked+label .star{display:none}[type=radio].featured+label .star,[type=radio].featured:checked+label .star+.star{display:inline-block}.restore.item,.upload.item{border-radius:var(--innerRadius);overflow:hidden;background:var(--base);border:1px solid var(--base-200)}.restore.item img,.upload.item img{transition:transform var(--transition-base)}.restore.item:hover img,.upload.item:hover img{transform:scale(1.02);transition:transform var(--transition-base)}.upload-group{background-image:var(--dashed-action);padding:5px;border-radius:var(--innerRadius);background-color:rgba(var(--action-rgb),var(--rgb-subtle))}.submit-uploads{position:fixed;bottom:var(--offHeight);right:var(--offHeight);z-index:var(--z-6);height:var(--height);box-shadow:var(--shadow);border-radius:var(--innerRadius);animation:pulse-color 5s infinite;animation-delay:1s;background-color:var(--action-0);color:var(--action-contrast)}.submit-uploads:hover{background-color:var(--base-200);color:var(--contrast-200)}.empty-group{grid-column:1/-1;padding:20px;background-image:var(--dashed-action);border-radius:var(--innerRadius);margin:10px 0;cursor:pointer;transition:all var(--transition-base);text-align:center;background-color:rgba(var(--action-rgb),var(--rgb-subtle))}.group-display:not([hidden])~.file-upload-container{display:none}.dragging,.upload.item.dragging{opacity:.7;transform:scale(.95) rotate(3deg);z-index:var(--z-top);box-shadow:0 8px 25px rgba(0,0,0,.3)}.dragover{background:rgba(var(--action-rgb),var(--rgb-light))!important;border-color:var(--action-0)!important;transform:scale(1.05);animation:drop-pulse .8s infinite ease-in-out}.drag-preview{position:fixed;z-index:var(--zz-top);width:fit-content;overflow:visible;pointer-events:none;opacity:.9;transform:scale(1.05);transition:transform .2s ease}.drag-preview .drag-items{width:max-content;height:max-content;position:relative}.drag-preview .drag-items .drag-item{width:120px;height:120px;position:absolute;top:0;left:0;background:var(--base);border-radius:var(--outerRadius);box-shadow:var(--shadow)}.drag-preview .drag-items .drag-item:nth-child(1){transform:rotate(-3deg);z-index:3}.drag-preview .drag-items .drag-item:nth-child(2){left:8px;top:-4px;transform:rotate(4deg);z-index:2;transition-delay:30ms}.drag-preview .drag-items .drag-item:nth-child(3){left:-6px;top:-8px;transform:rotate(-5deg);z-index:1;transition-delay:60ms}.drag-preview .drag-items .drag-item:nth-child(4){left:12px;top:-12px;transform:rotate(3deg);z-index:0;transition-delay:90ms}.drag-preview .drag-items .drag-item:nth-child(n+5){left:-10px;top:-16px;transform:rotate(-4deg);z-index:0;opacity:.8}.drag-preview .drag-items img,.drag-preview .drag-items video{width:100%;height:100%;object-fit:cover;display:block}.drag-preview .drag-count{position:absolute;top:-8px;right:-8px;background:var(--base-200);color:var(--contrast);border-radius:50%;width:24px;height:24px;display:flex;align-items:center;justify-content:center;font-size:12px;font-weight:700;box-shadow:var(--shadow);z-index:var(--z-3)}.item.dragging{opacity:.5;transform:scale(.95);filter:grayscale(50%);transition:opacity .2s ease,transform .2s ease,filter .2s ease}@keyframes drop-pulse{0%,100%{background-color:rgba(var(--action-rgb),var(--rgb-light));transform:scale(1.02)}50%{background-color:var(rgba(var(--action-rgb),var(--rgb-medium)));transform:scale(1.04)}}.group-actions{display:flex;gap:.25rem}@media (max-width:767px){body:not(.uploading):has(.group-display:not([hidden])){overflow:hidden}body:not(.uploading):has(.group-display:not([hidden])) .qtoggle{z-index:var(--z-1)}.group-display.group-display{position:fixed;top:var(--height);bottom:var(--height);left:0;right:0;max-height:var(--maxHeight);overflow:hidden;z-index:var(--z-6);width:calc(100% - 1rem);height:calc(100% - 1rem);padding:0 0 3rem;--justify:flex-start;--align:flex-start;--gap:0}.group-display::before{content:'';display:block;z-index:-1;top:-.5rem;bottom:-.5rem;left:-.5rem;right:-.5rem;position:absolute;background-color:rgba(var(--base-rgb),var(--rgb-heavy));filter:blur(5px)}.group-display .preview-wrap,.group-display .sidebar{height:50%;overflow:hidden auto;position:relative;padding:.5rem}.group-display .preview-wrap{top:0}.group-display .preview-wrap .selected{display:flex;justify-content:space-between;align-items:center}.group-display .sidebar{bottom:0;flex-wrap:nowrap;overflow:hidden auto;background-color:var(--contrast-200);color:var(--base)}.group-display .sidebar>.hint{color:var(--contrast)}.group-display .sidebar .header{display:none}.group-display .preview-actions{top:0;flex-shrink:0}.group-display .preview-wrap>.hint,.group-display .sidebar>.hint{bottom:0;margin:0;text-align:center}.group-display .preview-actions,.group-display .preview-wrap>.hint,.group-display .sidebar>.hint{position:absolute;left:0;right:0;background-color:rgba(var(--base-rgb),var(--rgb-heavy));z-index:var(--z-3);box-shadow:var(--shadow)}.group-display .item-grid{height:100%;overflow:hidden auto;grid-template-columns:repeat(3,1fr);padding:2rem 0}.group-display .sidebar>.item-grid{grid-template-columns:repeat(1,1fr);gap:1rem;padding:0}.group-display .sidebar .empty-group{order:0;position:sticky;height:fit-content;top:0;z-index:var(--z-3);background-color:rgba(var(--action-rgb),var(--rgb-heavy))}.group-display .sidebar .upload-group{order:1}.group-display .sidebar .empty-group p{margin:0}.group-display .field,.group-display .field label{margin:0;padding:0}.group-display .sidebar h4{margin:.25rem}.group-display .item{width:100%;height:max-content}.submit-uploads{bottom:var(--height);left:0;right:0;width:100%;height:3rem}body.uploading .group-display.group-display{position:relative;top:unset;bottom:unset;right:unset;left:unset}}@media (min-width:768px){.group-display.group-display{--wrap:nowrap;--dir:row;--gap:1rem;--align:flex-start}.group-display .preview-wrap,.group-display .sidebar{--justify:flex-start;max-height:calc(100vh - var(--doubleHeight));overflow:hidden auto}.group-display .preview-wrap,.group-display .sidebar{width:50%}.preview-actions,.preview-wrap .hint{position:sticky;z-index:var(--z-3);box-shadow:var(--shadow);background-color:var(--base);width:100%}.preview-actions{top:0;left:0;right:0}.preview-actions .field{margin:0}.preview-wrap .hint,.sidebar>.hint{bottom:-1rem;padding-bottom:1rem;margin:0;left:0;right:0;text-align:center}}.restore-uploads{position:fixed;top:var(--offHeight);bottom:var(--offHeight);left:1rem;right:1rem;border-radius:var(--outerRadius);padding:1rem;z-index:var(--z-top);box-shadow:var(--shadow);background-color:var(--base-200);overflow:hidden auto}dialog nav.tabs{position:sticky;top:0;background-color:var(--base-50);z-index:var(--z-6);box-shadow:var(--shadow-down);margin-bottom:2rem}.editor-container .ql-toolbar{display:flex;background-color:var(--base-50);justify-content:flex-start;flex-wrap:wrap;padding:.25rem;gap:.5rem 1rem;border-top-left-radius:var(--innerRadius);border-top-right-radius:var(--innerRadius);border-bottom:4px solid var(--base-50)}.ql-toolbar .ql-formats{display:flex;gap:.25rem}.editor-container .ql-container{--padding:1rem;background-color:var(--base);border-bottom-left-radius:var(--innerRadius);border-bottom-right-radius:var(--innerRadius);height:fit-content;padding:2px;border:1px solid var(--base-200)}.editor-container .ql-container .ql-editor{padding:var(--padding);width:100%;height:100%}.ql-editor img{max-width:50%;height:auto}.ql-clipboard{left:-100000px;height:1px;overflow-y:hidden;position:absolute;top:50%}.ql-hidden{display:none}.ql-tooltip{position:absolute;transform:translateY(10px);background-color:var(--base-100);border:1px solid var(--base);box-shadow:0 0 5px var(--overlay-heavy);color:var(--contrast);padding:5px 12px;white-space:nowrap}[data-type=single] .item-grid{display:flex}.repeater-row details summary::after{margin-left:0}.repeater-row details summary button{margin-left:auto}/*!* Group actions buttons - more visible *!*//*!* Group item grid - distinct from preview grid *!*//*!* Group count hint *!*//*!* ============================================================================*//*!* Base drag preview *!*//*!* Single item drag preview *!*//*!* Multi-item drag preview container *!*//*!* Items being dragged - reduce opacity on originals *!*//*!* Count badge on multi-item preview *!*//*!* ============================================================================*//*!* Ensure progress bar is visible when needed *!*//*!* Progress bar track *!*//*!* Progress bar fill *!*//*!* Progress details - styled for row layout with text and count *!*//*!* Individual item progress - overlay style *!*//*!* Item progress icon and status text *!*//*!* ============================================================================*//*!* Hide uploader when we have uploads *!*//*!* Show group display when we have uploads *!*//*!* ============================================================================*//*!* Selected items - more obvious *!*//*!* Selection checkbox - always visible on hover or when checked *!*//*!* Selection controls - more prominent *!*//*!* ============================================================================*//*!* Smooth dragover animation *!*//*!* ============================================================================*//*!* ============================================================================*//*!* Notification container - fixed overlay *!*//*!* Content card *!*//*!* Message section *!*//*!* Scrollable field list *!*//*!* Item grid for restore preview *!*//*!* Restore item *!*//*!* Checked state *!*//*!* Preview section *!*//*!* Item info *!*//*!* Checkbox controls *!*//*!* Actions section *!*//*!* Selection controls *!*//*!* Action buttons *!*//*!* Restore button - primary action *!*//*!* Scrap cache button - destructive action *!*//*!* Dismiss button - secondary action *!*//*!* Mobile responsive *!*//*!* Animation *!*//*!* Scrollbar styling for restore field list *!*/form{--step-size:2.5rem}.form-progress{padding:0 1rem}.form-progress .progress{background:var(--base-100);border-radius:var(--innerRadius);padding:1rem}.form-progress .bar{height:6px;background:var(--base-200);border-radius:3px;overflow:hidden;margin-bottom:.5rem}.form-progress .fill{height:100%;background:linear-gradient(90deg,var(--action-0),var(--action-200));width:0%;transition:width .4s ease;border-radius:3px}.form-progress .step-text{font-size:var(--small);font-weight:600;color:var(--contrast-200)}form nav.tabs{position:relative;top:0;left:0;right:0;padding:1rem 0;gap:0;z-index:0}form nav.tabs button{position:relative;background:0 0;border:none;padding:.5rem 1rem .5rem 3rem;z-index:1}form nav.tabs .step-number{width:2.5rem;height:100%;border-radius:50% 0 0 50%;position:absolute;left:0;top:0;background:var(--base-200);color:var(--contrast-50);display:flex;align-items:center;justify-content:center;font-weight:700;font-size:var(--small);border:3px solid var(--base)}form nav.tabs button.pending .step-number{background:var(--base-100);color:var(--contrast-200)}form nav.tabs button.active .step-number,form nav.tabs button.current .step-number{background:var(--action-0);color:var(--action-contrast);border-color:var(--action-200)}form nav.tabs button.completed .step-number{background:var(--successBack);color:var(--successBack);border-color:var(--successText)}form nav.tabs button.completed .step-number::before{content:'✓';font-size:1.2rem;color:var(--successText);position:absolute}form nav.tabs button.completed h2{color:var(--contrast-200)}.step-navigation{margin-top:2rem;padding-top:2rem;border-top:1px solid var(--base-200);gap:1rem}.step-navigation .prev-step{background:var(--base-100)}.step-navigation .next-step,.step-navigation button[type=submit]{margin-left:auto}.field input.error,.field select.error,.field textarea.error{border-color:var(--errorBack)}.error-message{color:var(--errorText);font-size:var(--small);margin-top:.25rem;display:block}@media (max-width:768px){form nav.tabs button{min-width:80px;font-size:var(--small)}form nav.tabs button h2{font-size:var(--small)}form{--step-size:2rem}}.field-input-wrapper{position:relative;display:flex;align-items:center;gap:.5rem}.field-input-wrapper input,.field-input-wrapper select,.field-input-wrapper textarea{flex:1}.validation-icon{display:flex;align-items:center;justify-content:center;font-size:1.25rem;animation:scaleIn .3s ease;--w:1.25rem}.validation-icon.error{color:var(--error)}.validation-icon.success{color:var(--success)}@keyframes scaleIn{from{transform:scale(0);opacity:0}to{transform:scale(1);opacity:1}}.validation-message{color:var(--error-0);font-size:var(--small);margin-top:.25rem;display:block;animation:slideDown .2s ease}@keyframes slideDown{from{opacity:0;transform:translateY(-4px)}to{opacity:1;transform:translateY(0)}}.field.has-error input,.field.has-error select,.field.has-error textarea{border-color:var(--error);background-color:var(--errorBack)}.field.has-error input:focus,.field.has-error select:focus,.field.has-error textarea:focus{outline-color:var(--error);box-shadow:0 0 0 3px rgba(var(--error-rgb),.2)}.field.has-success input,.field.has-success select,.field.has-success textarea{border-color:var(--success)}.field label .required{color:var(--error);margin-left:.25rem}.form-summary{padding:2rem;border-radius:8px;margin-top:2rem;border:2px dashed var(--contrast-200)}.form-summary .message{margin-bottom:2rem}.form-summary .result+.result{position:relative;margin-top:1.5rem;padding-top:1.5rem}.form-summary .result+.result::before{position:absolute;top:0;left:16.5%;content:'';width:67%;height:1px;border-bottom:1px solid var(--base-200)}.form-summary h2{margin:1rem 0}.form-summary h4{background-color:var(--base-100);padding:.5rem 2rem;position:relative;left:-2rem;color:var(--contrast-200);font-size:.875rem;text-transform:uppercase;letter-spacing:.05em;margin-bottom:.75rem}.form-summary p{color:var(--text);margin:0}.group-summary,.repeater-summary{background:var(--base-100);padding:1rem;border-radius:4px;margin-top:.5rem}.repeater-row{margin-bottom:1rem}.repeater-row:last-child{margin-bottom:0}
\ No newline at end of file
diff --git a/assets/js/concise/FormController.js b/assets/js/concise/FormController.js
index 6663484..cdae988 100644
--- a/assets/js/concise/FormController.js
+++ b/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,9 +673,24 @@
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,23 +779,452 @@
}
}
- handleBlur(event) {
- const target = event.target;
+ handleBlur(e) {
+ const target = e.target;
const form = target.form || target.closest('form');
if (!form) return;
- const formConfig = this.forms?.get(form.dataset.formId);
- if (formConfig && formConfig.options.autoSave && !form.dataset.noautosave) {
- // Shorter delay on blur
- this.scheduleSave(formConfig, {
- type: 'blur',
- fieldName: target.name,
- delay: 1500
- });
+
+ 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
+ this.scheduleSave(formConfig, {
+ type: 'blur',
+ fieldName: target.name,
+ delay: 1500
+ });
+ }
}
}
+ 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, '&')
+ .replace(/</g, '<')
+ .replace(/>/g, '>');
+
+ // 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();
diff --git a/assets/js/dash/SquareCheckout.js b/assets/js/dash/SquareCheckout.js
index 3fc9a3c..72d033e 100644
--- a/assets/js/dash/SquareCheckout.js
+++ b/assets/js/dash/SquareCheckout.js
@@ -1,50 +1,50 @@
class SquareCheckout {
constructor(config = {}) {
+ this.config = {
+ ...squareConfig,
+ ...config
+ };
- this.checkout = document.querySelector('aside#cart');
- if (!this.checkout) {
- return;
- }
+ this.payments = null;
+ this.card = null;
+ this.isInitialized = false;
+ this.cartItems = new Map();
+ this.checkout = document.querySelector('aside#cart');
- 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.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.cache = new window.jvbCache('cart', {TTL: 8.64e+7});
this.a11y = window.jvbA11y;
-
this.initCart();
+ if (this.checkout) {
+ this.initElements();
+ this.init();
+ this.initListeners();
- this.payments = null;
- this.card = null;
- this.isInitialized = false;
+ if (this.isLoggedIn) {
+ this.loadSavedCards();
+ }
+ }
+ this.stepMultiplier = 1;
- this.clickHandler = this.handleClick.bind(this);
- this.keyHandler = this.handleEscape.bind(this);
- this.changeHandler = this.handleChange.bind(this);
-
-
- this.initElements();
- this.bindEvents();
-
+ //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',
- headers: {
- 'Content-Type': 'application/json',
- 'X-WP-Nonce': this.config.nonce
- },
- body: JSON.stringify({ email })
- });
+ /**
+ * Load saved cards for logged-in user
+ */
+ async loadSavedCards() {
+ try {
+ const response = await fetch(this.config.api_url + 'saved-cards', {
+ method: 'GET',
+ headers: {
+ 'X-WP-Nonce': this.config.nonce
+ }
+ });
- 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 class="saved-card">
+ <input type="radio" name="payment-method" value="new" checked>
+ <span>Use a new card</span>
</label>
- `).join('')}
- <label>
- <input type="radio" name="payment_method" value="new" checked>
- Use new card
- </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', {
diff --git a/assets/js/dash/UtilityFunctions.js b/assets/js/dash/UtilityFunctions.js
index ec64aac..b682acc 100644
--- a/assets/js/dash/UtilityFunctions.js
+++ b/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
diff --git a/assets/js/min/form.min.js b/assets/js/min/form.min.js
index 59ffea1..ff139e4 100644
--- a/assets/js/min/form.min.js
+++ b/assets/js/min/form.min.js
@@ -1 +1 @@
-(()=>{class e{constructor(){this.store=new window.jvbStore({name:"forms",storeName:"forms",keyPath:"formId",indexes:[{name:"status",keyPath:"status"},{name:"operationId",keyPath:"operationId"},{name:"timestamp",keyPath:"timestamp"},{name:"formType",keyPath:"type"}],TTL:6048e5}),this.debouncer=window.debouncer,this.ignore=[],this.populateForm=window.jvbPopulate,this.subscribers=new Set,this.forms=new Map,this.specialFields=new Map,this.dependencies=new Map,this.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}))})();
\ No newline at end of file
+(()=>{class e{constructor(){this.store=new window.jvbStore({name:"forms",storeName:"forms",keyPath:"formId",indexes:[{name:"status",keyPath:"status"},{name:"operationId",keyPath:"operationId"},{name:"timestamp",keyPath:"timestamp"},{name:"formType",keyPath:"type"}],TTL:6048e5}),this.debouncer=window.debouncer,this.ignore=[],this.populateForm=window.jvbPopulate,this.subscribers=new Set,this.forms=new Map,this.specialFields=new Map,this.dependencies=new Map,this.validators=this.initValidators(),this.touchedFields=new Set,this.autoSaveDefaults={delay:3e3,typingDelay:1500,enabled:!0},this.activeRepeaters=new Map,this.repeaterDelays={change:6e3,typing:3e3,blur:1500,add:500,remove:800,reorder:1e3},this.clickHandler=this.handleClick.bind(this),this.changeHandler=this.handleChange.bind(this),this.submitHandler=this.handleSubmit.bind(this),this.inputHandler=this.handleInput.bind(this),this.focusHandler=this.handleFocus.bind(this),this.blurHandler=this.handleBlur.bind(this),this.init()}async init(){await this.checkPendingOperations(),this.store.subscribe(this.handleStoreEvent.bind(this)),this.initListeners()}handleStoreEvent(e,t){switch(e){case"item-saved":t.item.status;break;case"data-loaded":this.checkPendingForms()}}async checkPendingForms(){(await this.store.query("status","draft")).forEach((e=>{let t=this.forms.get(e.formId);t&&t.element&&(t.element.querySelector(".restore-form").hidden=!1,new this.populateForm(t.element,e.data))}))}async checkPendingOperations(){const e=await this.store.query("status","pending");if(0===e.length)return;const t=this.groupPendingForms(e);this.showPendingNotification(t)}showPendingNotification(e){const t=document.querySelector(`[data-form-id="${e.formId}"]`);if(!t)return;const r=document.createElement("div");r.className="pending-changes-notification",r.innerHTML=`\n\t\t\t<p>We noticed unsaved changes from last time. Would you like to restore them?</p>\n\t\t\t<button class="restore-changes" data-form-id="${e.formId}">Restore</button>\n\t\t\t<button class="discard-changes" data-form-id="${e.formId}">Discard</button>\n\t\t`,t.insertBefore(r,t.firstChild),r.querySelector(".restore-changes").addEventListener("click",(()=>{this.restorePendingForm(e),r.remove()})),r.querySelector(".discard-changes").addEventListener("click",(()=>{this.discardPendingForm(e.formId),r.remove()}))}restorePendingForm(e){const t=document.querySelector(`[data-form-id="${e.formId}"]`);t&&(new this.populateForm(t,e.formData),e.status="restored",this.pendingForms.set(e.formId,e),window.jvbA11y&&window.jvbA11y.announce("Previous changes restored"))}async discardPendingForm(e){this.store.delete(e),window.jvbA11y&&window.jvbA11y.announce("Previous changes discarded")}initListeners(){this.globalHandlersAdded||(document.addEventListener("click",this.clickHandler),document.addEventListener("change",this.changeHandler),document.addEventListener("focus",this.focusHandler,!0),document.addEventListener("blur",this.blurHandler,!0),document.addEventListener("input",this.inputHandler),this.globalHandlersAdded=!0)}registerForm(e,t={}){const r=e.dataset.formId||`form_${Date.now()}`;e.dataset.formId=r,e.addEventListener("submit",this.submitHandler);const s={element:e,id:r,options:{autoSave:!0,saveDelay:this.autoSaveDefaults.delay,endpoint:e.dataset.save,cache:!0,...t},dependencies:new Map,data:this.collectFormData(e),isDirty:!1};if(this.initializeFormFields(e,s),this.forms.set(r,s),this.store&&s.options.cache){const e=this.store.get(r);e&&e.formData&&this.showPendingNotification(e)}return s}initializeFormFields(e,t=null){this.initQuillEditors(e),this.initRepeaterFields(e,t),t&&this.initConditionalFields(e,t),this.initCharacterLimits(e),this.initImageUploadFields(e),window.jvbTabs&&e.querySelector("nav.tabs")&&(t.tabs=new window.jvbTabs(e),this.forms.set(t.formId,t),this.initSteppedForm(t.formId)),window.jvbSelector&&window.jvbSelector.scanExistingFields()}initSteppedForm(e){const t=this.forms.get(e),r=t.element,s=t.tabs,a=r.querySelectorAll(".tab-content").length,i=r.querySelector(".form-progress .fill"),n=r.querySelector(".step-text .current"),o=r.querySelectorAll("nav.tabs button"),l=e=>{const t=e/a*100;i&&(i.style.width=t+"%"),n&&(n.textContent=e),o.forEach(((t,r)=>{const s=r+1;t.classList.remove("current","completed","pending"),s<e?t.classList.add("completed"):s===e?t.classList.add("current"):t.classList.add("pending")}))};r.addEventListener("click",(e=>{const t=e.target.closest('[data-action="next-step"]'),a=e.target.closest('[data-action="prev-step"]');if(t){e.preventDefault();const a=t.closest(".tab-content"),i=parseInt(a.dataset.step),n=r.querySelector(`.tab-content[data-step="${i+1}"]`);if(n&&this.validateStep(a)){const e=n.dataset.tab;s.switchTab(e,!0),l(i+1),r.scrollIntoView({behavior:"smooth",block:"start"})}}if(a){e.preventDefault();const t=a.closest(".tab-content"),i=parseInt(t.dataset.step),n=r.querySelector(`.tab-content[data-step="${i-1}"]`);if(n){const e=n.dataset.tab;s.switchTab(e,!0),l(i-1),r.scrollIntoView({behavior:"smooth",block:"start"})}}}));const c=s.switchTab.bind(s);s.switchTab=(e,t)=>{c(e,t);const s=r.querySelector(`.tab-content[data-tab="${e}"]`);if(s){const e=parseInt(s.dataset.step);l(e)}},l(1)}validateStep(e){const t=e.querySelectorAll(".field");let r=!0;return t.forEach((e=>{const t=e.querySelector("input, textarea, select");if(t&&!t.closest("[hidden]")){this.validateField(t,e)||(r=!1)}})),r}initQuillEditors(e){window.jvbQuill(e)}initRepeaterFields(e,t){e.querySelectorAll(".repeater").forEach((e=>{const r=e.querySelector(".add-repeater-row"),s=e.querySelector(".repeater-items"),a=e.querySelector("template");r&&a&&s&&(window.Sortable&&new Sortable(s,{handle:".repeater-row-header",animation:150,onEnd:()=>{this.updateRepeaterOrder(e,t)}}),r.addEventListener("click",(()=>{this.addRepeaterRow(e,t)})),s.addEventListener("click",(e=>{e.target.closest(".remove-row")&&this.removeRepeaterRow(e.target.closest(".repeater-row"),t)})))}))}addRepeaterRow(e,t){const r=e.querySelector(".repeater-items"),s=e.querySelector("template"),a=r.children.length,i=e.dataset.field,n=s.content.cloneNode(!0).firstElementChild;n.dataset.index=a,n.querySelectorAll("input, select, textarea").forEach((e=>{const t=e.name;e.name=`${i}:${a}:${t}`,e.id=`${i}-${a}-${t}`;const r=e.nextElementSibling;r&&"LABEL"===r.tagName&&(r.htmlFor=e.id)})),r.appendChild(n),t&&t.options.autoSave&&this.scheduleSave(t,{type:"repeater",action:"add",fieldName:i,delay:this.repeaterDelays.add}),window.jvbA11y&&window.jvbA11y.announce("Row added")}removeRepeaterRow(e,t){const r=e.closest(".repeater"),s=r.dataset.field;e.remove(),this.updateRepeaterOrder(r,t),t&&t.options.autoSave&&this.scheduleSave(t,{type:"repeater",action:"remove",fieldName:s,delay:this.repeaterDelays.remove}),window.jvbA11y&&window.jvbA11y.announce("Row removed")}updateRepeaterOrder(e,t){const r=e.querySelector(".repeater-items"),s=e.dataset.field;Array.from(r.children).forEach(((e,t)=>{e.dataset.index=t,e.querySelectorAll("input, select, textarea").forEach((e=>{const r=e.name.split(":");if(3===r.length){const a=r[2];e.name=`${s}:${t}:${a}`,e.id=`${s}-${t}-${a}`;const i=e.nextElementSibling;i&&"LABEL"===i.tagName&&(i.htmlFor=e.id)}}))})),t&&t.options.autoSave&&this.scheduleSave(t,{type:"repeater",action:"reorder",fieldName:s,delay:this.repeaterDelays.reorder})}initConditionalFields(e,t){e.querySelectorAll("[data-depends-on]").forEach((r=>{const s=r.dataset.dependsOn,a=r.dataset.dependsValue,i=r.dataset.dependsOperator||"==";t.dependencies.has(s)||t.dependencies.set(s,[]),t.dependencies.get(s).push({field:r,requiredValue:a,operator:i}),this.checkFieldDependency(e,r,s,a,i)}))}checkFieldDependency(e,t,r,s,a){const i=e.querySelector(`[name="${r}"]`);if(!i)return;const n=this.getFieldValue(i),o=this.evaluateCondition(n,s,a);this.toggleFieldVisibility(t,o)}evaluateCondition(e,t,r){const s=String(e||""),a=String(t||"");switch(r){case"==":default:return s==a;case"!=":return s!=a;case">":return parseFloat(s)>parseFloat(a);case"<":return parseFloat(s)<parseFloat(a);case">=":return parseFloat(s)>=parseFloat(a);case"<=":return parseFloat(s)<=parseFloat(a);case"contains":return s.includes(a);case"empty":return""===s;case"not_empty":return""!==s}}toggleFieldVisibility(e,t){const r=e.closest(".field, fieldset");r&&(r.hidden=!t,r.querySelectorAll("input, select, textarea").forEach((e=>{e.disabled=!t,!t&&e.hasAttribute("required")?(e.dataset.wasRequired="true",e.removeAttribute("required")):t&&"true"===e.dataset.wasRequired&&(e.setAttribute("required",""),delete e.dataset.wasRequired)})))}initCharacterLimits(e){e.querySelectorAll("[data-limit]").forEach((e=>{const t=parseInt(e.dataset.limit,10),r=e.closest(".field");let s=r?.querySelector(".char-count");!s&&r&&(s=document.createElement("div"),s.className="char-count",s.innerHTML=`<span class="current">0</span> / <span class="limit">${t}</span>`,r.appendChild(s));const a=()=>{const r=e.value.length;s&&(s.querySelector(".current").textContent=r,s.classList.toggle("exceeded",r>t)),r>t&&(e.value=e.value.substring(0,t),s&&(s.querySelector(".current").textContent=t))};e.addEventListener("input",a),a()}))}initImageUploadFields(e){window.jvbUploads.scanFields(e)}handleSubmit(e){if(this.subscribers.size>0){const t=e.target;if(!t.dataset.formId)return;e.preventDefault();const r=this.forms.get(t.dataset.formId);if(!r)return;const s=this.collectFormData(t);this.notify("form-submit",{formId:r.id,data:s,config:r})}}handleClick(e){if(window.targetCheck(e,"div.quantity")){let t=window.targetCheck(e,"div.quantity");this.handleNumberClick(e,t.querySelector("input"))}else if(window.targetCheck(e,"[data-action]")){let t=window.targetCheck(e,"[data-action]");switch(t=t.dataset.action,t){case"clear-form":let t=e.target.closest("form");this.store.delete(t.dataset.formId),t?.reset(),e.target.closest(".restore-form").hidden=!0;break;case"dismiss-restore":e.target.closest(".restore-form").hidden=!0}}}handleNumberClick(e,t){let r=0;if(e.target.closest(".increase")?r+=1:e.target.closest(".decrease")&&(r-=1),0!==r){let s=parseFloat(t.step);s=Math.max(s,1),e.ctrlKey&&e.shiftKey?s*=50:e.ctrlKey?s*=5:e.shiftKey&&(s*=10);let a=""===t.value?0:parseFloat(t.value);t.value=a+s*r,this.handleNumberLimits(t)}}handleNumberLimits(e){let[t,r,s,a]=[e.min,e.max,e.closest(".quantity")?.querySelector(".increase"),e.closest(".quantity")?.querySelector(".decrease")],i=parseFloat(e.value);i<t?(e.value=t,a.disabled=!0):i>r?(e.value=r,s.disabled=!1):s.disabled?s.disabled=!1:a.disabled&&(a.disabled=!1)}handleChange(e){if(this.subscribers.size>0){const t=e.target,r=t.form||t.closest("form");if(!r)return;const s=this.forms?.get(r.dataset.formId);if(!s)return;const a=s.dependencies.get(t.name);if(a&&a.forEach((e=>{this.checkFieldDependency(r,e.field,t.name,e.requiredValue,e.operator)})),s.options.autoSave&&!r.dataset.noautosave){const e=this.getDelayForField(t);this.scheduleSave(s,e)}}}handleFocus(e){const t=e.target;t.matches("input, textarea, select")&&(this.currentFocus=t)}handleBlur(e){const t=e.target,r=t.form||t.closest("form");if(!r)return;const s=e.target.closest("input, textarea, select");if(s){const e=this.findFieldWrapper(s);if(e){const t=e.dataset.field;t&&(this.shouldDebounce(s)&&window.debouncer.cancel(`validate_${t}`),this.touchedFields.add(t)),this.validateField(s,e)}const a=this.forms?.get(r.dataset.formId);a&&a.options.autoSave&&!r.dataset.noautosave&&this.scheduleSave(a,{type:"blur",fieldName:t.name,delay:1500})}}handleInput(e){const t=e.target.closest("input, textarea, select");if(!t)return;let r=t.closest("form");this.showFormStatus(r.dataset.formId,"pending");const s=this.findFieldWrapper(t);if(!s)return;const a=s.dataset.field;a&&this.touchedFields.add(a),this.shouldDebounce(t)&&window.debouncer.schedule(`validate_${a}`,((e,t)=>this.validateField.bind(this)),500)}initValidators(){return{email:{pattern:/^[^\s@]+@[^\s@]+\.[^\s@]+$/,message:"Please enter a valid email address"},url:{pattern:/^https?:\/\/.+\..+/,message:"Please enter a valid URL starting with http:// or https://"},phone:{pattern:/^[\d\s\-\+\(\)\.]+$/,message:"Please enter a valid phone number"},number:{test:(e,t)=>{const r=parseFloat(e);if(isNaN(r))return"Please enter a valid number";const s=t.dataset.min,a=t.dataset.max;return void 0!==s&&r<parseFloat(s)?`Value must be at least ${s}`:!(void 0!==a&&r>parseFloat(a))||`Value must be at most ${a}`}},text:{test:(e,t)=>{const r=t.dataset.minlength,s=t.dataset.maxlength;return r&&e.length<parseInt(r)?`Must be at least ${r} characters`:!(s&&e.length>parseInt(s))||`Must be no more than ${s} characters`}}}}findFieldWrapper(e){let t=e.closest(".field");return t||(t=e.closest("[data-field]")),t}shouldDebounce(e){return["text","email","url","tel","search"].includes(e.type)||"TEXTAREA"===e.tagName}validateField(e,t){const r=this.getFieldValue(e),s=t.dataset.field;if(!this.touchedFields.has(s)&&!e.required)return!0;if(!r&&!e.required)return this.clearValidation(t),!0;if(e.required&&!r)return this.showError(t,"This field is required"),!1;if(e.checkValidity&&!e.checkValidity())return this.showError(t,e.validationMessage),!1;const a=t.dataset.pattern;if(a&&r){if(!new RegExp(a).test(r)){const e=t.dataset.validationMessage||"Invalid format";return this.showError(t,e),!1}}const i=t.dataset.validate||e.type;if(i&&this.validators[i]){const e=this.validators[i];if(e.pattern&&!e.pattern.test(r))return this.showError(t,e.message),!1;if(e.test){const s=e.test(r,t);if(!0!==s)return this.showError(t,s),!1}}return this.showSuccess(t),!0}getFieldValue(e){if(!e)return"";if("checkbox"===e.type)return e.checked?e.value||"1":"";if("radio"===e.type){const t=e.form?.querySelector(`[name="${e.name}"]:checked`);return t?t.value:""}return"select-multiple"===e.type?Array.from(e.selectedOptions).map((e=>e.value)):e.value?.trim()||""}showSuccess(e){if(!e)return;const t=e.querySelector(".validation-icon.success"),r=e.querySelector(".validation-icon.error"),s=e.querySelector(".validation-message"),a=e.querySelector("input, textarea, select");e.classList.remove("has-error"),a?.classList.remove("error"),e.classList.add("has-success"),t&&(t.hidden=!1),r&&(r.hidden=!0),s&&(s.hidden=!0,s.textContent="")}showError(e,t){if(!e)return;const r=e.querySelector(".validation-icon.success"),s=e.querySelector(".validation-icon.error"),a=e.querySelector(".validation-message"),i=e.querySelector("input, textarea, select");e.classList.remove("has-success"),e.classList.add("has-error"),i?.classList.add("error"),r&&(r.hidden=!0),s&&(s.hidden=!1),a&&(a.hidden=!1,a.textContent=t)}clearValidation(e){if(!e)return;const t=e.querySelector(".validation-icon"),r=e.querySelector(".validation-message"),s=e.querySelector("input, textarea, select");e.classList.remove("has-error","has-success"),s?.classList.remove("error"),t&&(t.hidden=!0),r&&(r.hidden=!0,r.textContent="")}validateAllFields(e){if(!e)return!0;const t=e.querySelectorAll(".field:not([hidden])");let r=!0;return t.forEach((e=>{if(this.isComplexFieldWrapper(e))return;const t=e.querySelector('input:not([type="hidden"]), textarea, select');if(t&&!t.closest("[hidden]")){const s=e.dataset.field;s&&this.touchedFields.add(s);this.validateField(t,e)||(r=!1,!1===r&&(t.scrollIntoView({behavior:"smooth",block:"center"}),t.focus()))}})),r}isComplexFieldWrapper(e){return e.classList.contains("repeater")||e.classList.contains("group")||e.classList.contains("upload")}attachRepeaterValidation(e){e.addEventListener("click",(t=>{t.target.closest(".add-repeater-row")&&setTimeout((()=>{e.querySelectorAll(".repeater-row").forEach((e=>{e.querySelectorAll("input, textarea, select").forEach((e=>{const t=this.findFieldWrapper(e);t&&this.clearValidation(t)}))}))}),100)}))}attachGroupValidation(e){e.addEventListener("change",(t=>{const r=t.target.closest("input, select");if(!r)return;const s=r.name;if(!s)return;e.querySelectorAll(`[data-show-if*="${s}"]`).forEach((e=>{e.hidden&&this.clearValidation(e)}))}))}resetForm(e){if(!e)return;this.touchedFields.clear();e.querySelectorAll(".field").forEach((e=>{this.clearValidation(e)}))}getFormErrors(e){const t={};return e.querySelectorAll(".field.has-error").forEach((e=>{const r=e.dataset.field,s=e.querySelector(".validation-message");r&&s&&(t[r]=s.textContent)})),t}addValidator(e,t){this.validators[e]=t}getDelayForField(e){return"text"===e.type||"textarea"===e.type?this.autoSaveDefaults.typingDelay:["checkbox","radio","select-one","select-multiple"].includes(e.type)?1e3:this.autoSaveDefaults.delay}scheduleSave(e,t=this.autoSaveDefaults.delay){document.addEventListener("input",this.saveCheck,{passive:!0});const r=`autosave_${e.id}`;this.debouncer.schedule(r,(()=>this.autosave(e)),t)}saveCheck(e){let t=e.target.closest("form[data-id]");t&&this.scheduleSave(this.forms.get(t.dataset.id))}async autosave(e){const t=this.collectFormData(e.element);this.showFormStatus(e.id,"saving"),await this.store.save({formId:e.id,data:t,status:"draft",timestamp:Date.now()}).then((()=>{this.showFormStatus(e.id,"autosaved")}));const r=this.getChangedFields(e.data,t);if(0!==Object.keys(r).length){e.data=t,this.forms.set(e.id,e),document.removeEventListener("input",this.handleInput);for(let[e,s]of Object.entries(t))"object"==typeof s&&(r[e]=s);this.notify("form-autosave",{formId:e.id,changes:r,fullData:t,config:e})}}hasUnsavedChanges(e){const t=this.forms.get(e);if(!t)return!1;if(t.operations?.size>0)return!0;const r=this.collectFormData(t.element),s=this.getChangedFields(t.lastSnapshot,r);return Object.keys(s).length>0}showFormStatus(e,t){let r=this.forms.get(e);console.log("Setting status: ",t);const s=r.element.querySelector(".fstatus");s.hidden=!1;const a=s.querySelector(".message");a.textContent="",s.querySelector(".icon")?.remove();const i={saving:"Saving changes...",autosaved:"Changes saved locally. Submit form to send to server.",uploading:"Uploading your form to server",submitted:"Successfully sent to server",pending:"Unsaved changes",error:"Failed to save changes. Refresh and try again?",offline:"Changes will be saved when online"},n={autosaved:"check",submitted:"check",error:"close",offline:"cloud-slash",pending:"exclamation-mark"};let o=window.getIcon(n[t]);o&&s.prepend(o),console.log(t,i[t]),console.log(t,n[t]),a.textContent=i[t]||t,s.classList.toggle("loading",["uploading","saving"].includes(t)),"submitted"===t&&setTimeout((()=>s.hidden=!0),3e3)}cleanupSpecialFields(){this.specialFields.forEach((e=>{if("quill"===e.type&&e.instance){const t=e.instance.container.previousSibling;t?.classList.contains("ql-toolbar")&&t.remove()}})),this.uploader?.destroy(),this.specialFields.clear()}collectFormData(e){const t=new FormData(e);let r={};const s={},a={};for(let[i,n]of t.entries()){if(this.ignore.includes(i)||i.endsWith("_temp"))continue;this.getFieldProcessor(i)(i,n,r,s,a,e)}return window.isEmptyObject(a)?this.mergeRepeaterData(r,s):(r=this.mergeRepeaterData(r,s),this.mergePostData(r,a))}getFieldProcessor(e){return e.includes("|")?this.processTableField:e.includes("::")?this.processGroupField:e.includes(":")?this.processRepeaterField:/\[[^\]]+\]/.test(e)?this.processLocationField:this.processRegularField}mergeRepeaterData(e,t){return Object.keys(t).forEach((r=>{const s={};Object.keys(t[r]).forEach((e=>{const a=t[r][e];Object.keys(a).length>0&&(s[e]=a)})),e[r]=Object.values(s)})),e}mergePostData(e,t){for(let[t,r]in Object.entries(r))e[t]=r;return e}processTableField(e,t,r,s,a,i){let[n,o]=e.split("|");!n in a&&(a[n]={});this.getFieldProcessor(o)(o,t,a,s,a,i)}processRepeaterField(e,t,r,s,a,i){let[n,o,l]=e.split(":");const c=l.endsWith("[]");l=l.replace("[]",""),s[n]||(s[n]={}),s[n][o]||(s[n][o]={}),c||s[n][o][l]?(s[n][o][l]?Array.isArray(s[n][o][l])||(s[n][o][l]=[s[n][o][l]]):s[n][o][l]=[],s[n][o][l].push(t)):s[n][o][l]=t}processGroupField(e,t,r,s,a,i){const n=e.split("::"),o=n[0];r[o]||(r[o]={});let l=r[o];for(let e=1;e<n.length-1;e++){const t=n[e];l[t]||(l[t]={}),l=l[t]}const c=n[n.length-1];void 0!==l[c]?(Array.isArray(l[c])||(l[c]=[l[c]]),l[c].push(t)):l[c]=t}processLocationField(e,t,r,s,a,i){let[n,o]=e.split("[");o=o.replace("]",""),Object.hasOwn(r,n)||(r[n]={},Object.hasOwn(r,"sendAll")?r.sendAll.includes(n)||r.sendAll.push(n):r.sendAll=[n]),r[n][o]=t}processRegularField(e,t,r,s,a,i){r[e=e.replace("[]","")]?(Array.isArray(r[e])||(r[e]=[r[e]]),r[e].push(t)):r[e]=t}getFieldValue(e){if(!e)return"";if("checkbox"===e.type)return e.checked?e.value||"1":"";if("radio"===e.type){const t=e.form.querySelector(`[name="${e.name}"]:checked`);return t?t.value:""}return"select-multiple"===e.type?Array.from(e.selectedOptions).map((e=>e.value)):e.value}getChangedFields(e,t){return window.getDifferences?.map(e,t)||{}}showSummary(e,t="form"){const r=this.forms.get(e);if(!r)return;const s=r.element||document.querySelector(`[data-form-id="${e}"]`),a=window.getTemplate("formSummary"),[i,n,o]=[a.querySelector("h2"),a.querySelector(".summary"),a.querySelector(".result")],l=["sendAll",...this.ignore];for(const[e,t]of Object.entries(r.data)){if(l.includes(e)||this.isEmptyValue(t))continue;const r=this.getFieldInfo(s,e);if(!r.label)continue;const a=this.createResultElement(o,r,t,s);a&&n.appendChild(a)}o.remove(),(t="form"!==t?s.closest(t)??s:s).after(a),window.fade(t,!1)}isEmptyValue(e){return null==e||""===e||(!(!Array.isArray(e)||0!==e.length)||"object"==typeof e&&0===Object.keys(e).length)}getFieldInfo(e,t){let r=e.querySelector(`label[for="${t}"]`),s=null,a=null;if(s||(s=e.querySelector(`[name="${t}"]`)),s||(s=e.querySelector(`[name="${t}[]"]`)),!s){const a=e.querySelector(`fieldset[data-field="${t}"]`);a&&(r=a.querySelector("legend"),s=a.querySelector("input, select, textarea"))}if(!r&&s){const e=s.closest(".field, fieldset");e&&(r=e.querySelector("label, legend"))}a=e.querySelector(`.field[data-field="${t}"], fieldset[data-field="${t}"]`);let i="text";return a?.dataset.type?i=a.dataset.type:s&&(i="checkbox"===s.type&&s.name.endsWith("[]")?"checkbox":"checkbox"===s.type?"true_false":"SELECT"===s.tagName&&s.multiple?"select":s.type||"text"),{label:r?.textContent.replace("*","").trim()||null,type:i,wrapper:a,input:s}}createResultElement(e,t,r,s){const a=e.cloneNode(!0),i=a.querySelector("h4"),n=a.querySelector("p");i.textContent=t.label;const o=this.formatFieldValue(r,t.type,s);return this.isHtmlContent(o)?n.innerHTML=o:n.textContent=o,a}isHtmlContent(e){return"string"==typeof e&&(e.includes("<br>")||e.includes("<p>")||e.includes("<ul>")||e.includes("<ol>")||e.includes("<a ")||e.includes("<strong>")||e.includes("<em>")||e.includes("<div"))}formatFieldValue(e,t,r){switch(t){case"textarea":case"wysiwyg":return this.formatTextareaValue(e,t);case"true_false":return"1"===e||1===e||!0===e?"Yes":"No";case"checkbox":return Array.isArray(e)?this.formatArrayValue(e):"1"===e||1===e||!0===e?"Yes":"No";case"select":return Array.isArray(e)?this.formatArrayValue(e):this.getSelectLabel(e,r,t);case"date":case"datetime":case"time":return window.formatDate?window.formatDate(e):e;case"radio":return this.getSelectLabel(e,r,t);case"repeater":return this.formatRepeaterValue(e);case"group":return this.formatGroupValue(e);case"location":return this.formatLocationValue(e);case"file":case"image":return this.formatFileValue(e);case"number":return this.formatNumber(e);case"email":return`<a href="mailto:${e}">${e}</a>`;case"url":return`<a href="${e}" target="_blank" rel="noopener">${e}</a>`;case"phone":return`<a href="tel:${e.replace(/\D/g,"")}">${e}</a>`;default:return Array.isArray(e)?this.formatArrayValue(e):e}}formatRepeaterValue(e){if(!Array.isArray(e)||0===e.length)return"<em>No entries</em>";let t='<div class="repeater-summary">';return e.forEach(((e,r)=>{t+='<div class="repeater-row">',t+=`<strong>Entry ${r+1}:</strong><ul>`;for(const[r,s]of Object.entries(e))if(!this.isEmptyValue(s)){const e=r.replace(/_/g," ").replace(/\b\w/g,(e=>e.toUpperCase()));t+=`<li><strong>${e}:</strong> ${s}</li>`}t+="</ul></div>"})),t+="</div>",t}formatGroupValue(e){if("object"!=typeof e||0===Object.keys(e).length)return"<em>No data</em>";let t='<div class="group-summary"><ul>';for(const[r,s]of Object.entries(e))if(!this.isEmptyValue(s)){const e=r.replace(/_/g," ").replace(/\b\w/g,(e=>e.toUpperCase()));"object"!=typeof s||Array.isArray(s)?t+=`<li><strong>${e}:</strong> ${s}</li>`:t+=`<li><strong>${e}:</strong> ${this.formatGroupValue(s)}</li>`}return t+="</ul></div>",t}formatLocationValue(e){if("object"!=typeof e)return e;const t=[];return["address","city","state","zip","country"].forEach((r=>{e[r]&&t.push(e[r])})),t.join(", ")}formatFileValue(e){return"string"==typeof e?e.startsWith("http")?`<a href="${e}" target="_blank">View file</a>`:e:Array.isArray(e)?e.map((e=>"string"==typeof e?`<a href="${e}" target="_blank">View file</a>`:e.name||"File")).join(", "):"File uploaded"}formatNumber(e){const t=parseFloat(e);return isNaN(t)?e:e.toString().includes(".")&&2===e.toString().split(".")[1].length?new Intl.NumberFormat("en-CA",{style:"currency",currency:"USD"}).format(t):new Intl.NumberFormat("en-CA").format(t)}formatArrayValue(e,t=null,r=null){if(0===e.length)return"<em>None selected</em>";if(t&&r&&r.input){return"<ul><li>"+e.map((e=>this.getSelectLabel(e,t,r.type))).join("</li><li>")+"</li></ul>"}return"<ul><li>"+e.join("</li><li>")+"</li></ul>"}getSelectLabel(e,t,r){if("select"===r){const r=t.querySelector(`option[value="${e}"]`);return r?.textContent||e}if("radio"===r){const r=t.querySelector(`input[type="radio"][value="${e}"]`),s=r?.nextElementSibling;return s?.textContent||e}if("checkbox"===r){const r=t.querySelector(`input[type="checkbox"][value="${e}"]`);if(r){const e=t.querySelector(`label[for="${r.id}"]`);if(e)return e.textContent.trim();const s=r.nextElementSibling;if("LABEL"===s?.tagName)return s.textContent.trim()}}return e}formatTextareaValue(e,t){return e?"wysiwyg"===t||this.containsHtml(e)?e:this.formatPlainText(e):"<em>Empty</em>"}containsHtml(e){return/<(p|strong|em|u|s|ol|ul|li|blockquote|h[1-6]|a|br|span)\b[^>]*>/i.test(e)}formatPlainText(e){if(!e)return"";const t=(e=e.replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">")).split(/\n\n+/);return t.length>1?t.map((e=>`<p>${e.replace(/\n/g,"<br>")}</p>`)).join(""):e.replace(/\n/g,"<br>")}nl2br(e){return this.formatPlainText(e)}subscribe(e){return this.subscribers.add(e),()=>this.subscribers.delete(e)}notify(e,t){this.subscribers.forEach((r=>r(e,t)))}cleanupForm(e){const t=this.forms.get(e);t&&(this.hasUnsavedChanges(e)&&this.autosave(t),this.cleanupSpecialFields(),this.forms.delete(e))}destroy(){this.globalHandlersAdded&&(document.removeEventListener("change",this.changeHandler),document.removeEventListener("focus",this.focusHandler,!0),document.removeEventListener("blur",this.blurHandler,!0),document.removeEventListener("input",this.inputHandler,!0)),this.forms.forEach((e=>{let t=e.element;t&&t.removeEventListener("submit",this.submitHandler)})),this.specialFields.clear(),this.forms.clear(),this.activeRepeaters.clear(),this.forms&&this.forms.clear()}}document.addEventListener("DOMContentLoaded",(()=>{window.jvbForm=e}))})();
\ No newline at end of file
diff --git a/assets/js/min/square.min.js b/assets/js/min/square.min.js
index 8ea9e23..98ae4bc 100644
--- a/assets/js/min/square.min.js
+++ b/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}))})();
\ No newline at end of file
+(()=>{class e{constructor(e={}){this.config={...squareConfig,...e},this.payments=null,this.card=null,this.isInitialized=!1,this.cartItems=new Map,this.checkout=document.querySelector("aside#cart"),this.isOpen="1"!==this.config.isOpen||!1,this.isOpen=!0,this.isLoggedIn=this.config.is_logged_in||!1,this.userEmail=this.config.user_email||"",this.savedCards=[],this.selectedCardId=null,this.cartId=null,this.cache=new window.jvbCache("cart",{TTL:864e5}),this.a11y=window.jvbA11y,this.initCart(),this.checkout&&(this.initElements(),this.init(),this.initListeners(),this.isLoggedIn&&this.loadSavedCards()),this.stepMultiplier=1,this.popup=new window.jvbPopup({popup:this.checkout,toggle:this.toggle,name:"Cart",onOpen:this.maybeAddEmptyState.bind(this)}),console.log(this.popup)}async initCart(){this.cartItems=await this.cache.get("cart")??new Map,console.log("cart",this.cartItems),this.cartItems.size>0&&this.notifyRestoredCart()}handleClick(e){if(window.targetCheck(e,"button")&&window.targetCheck(e,"div.quantity")){let t=window.targetCheck(e,"div.quantity");this.handleNumberClick(e,t)}else if(window.targetCheck(e,"[data-add-to-cart]")){let t=window.targetCheck(e,"[data-add-to-cart]");this.handleAddToCart(t)}else if(window.targetCheck(e,"[data-remove-from-cart]")){let t=window.targetCheck(e,"[data-remove-from-cart]");this.handleRemoveFromCart(t)}else window.targetCheck(e,"[data-clear-cart]")&&this.clearCart()}handleChange(e,t){console.log("Checkout change");let a=window.targetCheck(e,".quantity-input");if(a){let t=e.target.closest(".quantity"),i=a.value;if(window.targetCheck(e,".cart-items")){let e=document.querySelector(`.menu-section [data-id="${t.dataset.id}"] input`);e&&(e.value=a.value)}i>0?this.handleAddToCart(t):this.handleRemoveFromCart(t)}}handleNumberClick(e,t){console.log(t),e.preventDefault();let a=0;if(e.target.closest(".increase")?a+=1:e.target.closest(".decrease")&&(a-=1),0!==a){let[e,i]=[parseInt(t.dataset.step),t.querySelector("input")],s=""===i.value?0:parseInt(i.value);i.value=s+e*a*this.stepMultiplier,i.dispatchEvent(new Event("change",{bubbles:!0})),this.handleNumberLimits(t)}}handleNumberLimits(e){let[t,a,i,s,r]=[e.dataset.min,e.dataset.max,e.querySelector("input"),e.querySelector(".increase"),e.querySelector(".decrease")],n=parseInt(i.value);n<t?(i.value=t,r.disabled=!0):n>a?(i.value=a,s.disabled=!1):s.disabled?s.disabled=!1:r.disabled&&(r.disabled=!1)}maybeAddEmptyState(){let e=this.itemsList.querySelector(".empty");if(e&&e.remove(),0===this.cartItems.size){this.checkoutPanel.disabled=!0,this.checkoutPanel.title="Add some things to your cart first!";let e=window.getTemplate("emptyCart");this.itemsList.append(e),this.table.closest("table").hidden=!0,this.total.hidden=!0,this.a11y.announce("Nothing in Cart")}else this.checkoutPanel.disabled=!1,this.table.closest("table").hidden=!1,this.total.hidden=!1,this.checkoutPanel.title="Checkout"}handleEscape(e){"Escape"===e.key?this.stepMultiplier=1:e.ctrlKey&&e.shiftKey?this.stepMultiplier=Math.max(100*parseInt(this.stepMultiplier),1e3):e.shiftKey&&(this.stepMultiplier=Math.max(10*parseInt(this.stepMultiplier),1e3))}handleAddToCart(e){let t=e.dataset.id;this.createItemElement(e);let a=parseFloat(e.dataset.price),i=parseInt(e.querySelector(".quantity-input")?.value)??1,s=parseFloat(a*i);this.cartItems.set(t,{post_id:t,name:e.dataset.name,price:a,quantity:i,total:s,square_catalog_id:e.dataset.squareCatalogId}),this.saveCart()}notifyRestoredCart(){let e=window.getTemplate("restoredCart");this.checkout.querySelector(".tab-content[data-tab=cartItems]").insertBefore(e,this.itemsList),this.cartItems.forEach((e=>{console.log(e);let t=window.getTemplate("cartItem"),a=t.querySelector(".quantity"),i=e.price,s=e.quantity;[a.dataset.id,t.querySelector("label").textContent,t.querySelector(".price").textContent,a.dataset.price,a.dataset.squareCatalogId,t.querySelector('[name="quantity"]').value,t.querySelector(".total").textContent]=[e.post_id,e.name,window.formatPrice(i),i,e.square_catalog_id,s,window.formatPrice(s*i)],this.table.append(t)})),this.updateTotal()}handleRemoveFromCart(e){if(confirm("This will remove this item from the cart. Continue?")){e.querySelector("[data-id]")||(e=e.closest(".item")?.querySelector(".quantity.field"));let t=e.dataset.id;this.cartItems.delete(t),this.table.querySelector(`[data-id="${t}"]`)?.closest("tr").remove();let a=document.querySelector(`[data-id="${t}"] input`);a&&(a.value=0),this.maybeAddEmptyState(),this.saveCart()}}clearCart(){this.cartItems.clear(),window.removeChildren(this.table),this.saveCart()}saveCart(){this.updateTotal(),this.cache.set("cart",this.cartItems)}updateTotal(){let e=0;this.cartItems.forEach((t=>{console.log(t),e+=t.total}));let t=.05*e;e=window.formatPrice(e+t),t=window.formatPrice(t),window.eraseText(this.totalTax),window.eraseText(this.grandTotal),window.typeText(this.totalTax,t),window.typeText(this.grandTotal,e),this.totalTax.classList.remove("typeText")}createItemElement(e){let t=this.itemsList.querySelector(`[data-id="${e.dataset.id}"]`),a=!1,i=e.dataset.price,s=e.querySelector('[name="quantity"]')?.value??1;if(t)t=t.closest("tr");else{a=!0,t=window.getTemplate("cartItem");let s=t.querySelector(".quantity");[s.dataset.id,t.querySelector("label").textContent,t.querySelector(".price").textContent,s.dataset.price,s.dataset.squareCatalogId]=[e.dataset.id,e.dataset.name,window.formatPrice(i),i,e.dataset.squareCatalogId]}[t.querySelector('[name="quantity"]').value,t.querySelector(".total").textContent]=[s,window.formatPrice(s*i)],a&&(t.classList.add("adding"),this.table.append(t),setTimeout((()=>{t.classList.remove("adding")}),500))}async init(){if(window.Square)try{this.payments=window.Square.payments(this.config.application_id,this.config.location_id),await this.initializePaymentMethods(),this.isInitialized=!0,document.dispatchEvent(new CustomEvent("squareCheckoutReady",{detail:{checkout:this}}))}catch(e){console.error("Failed to initialize Square payments:",e),this.handleError(e)}else console.error("Square Web Payments SDK not loaded")}initElements(){this.toggle=document.querySelector(".toggle-cart"),this.isOpen||(this.toggle.disabled=!0,this.toggle.title="Currently closed for online ordering"),this.checkoutPanel=this.checkout.querySelector('button[data-tab="checkout"]'),this.itemsList=this.checkout.querySelector(".cart-items"),this.table=this.checkout.querySelector(".cart-items tbody"),this.total=this.checkout.querySelector(".cart-total"),this.totalTax=this.total.querySelector(".tax span"),this.grandTotal=this.total.querySelector(".total span"),this.checkoutForm=this.checkout.querySelector("form"),this.tabs=new window.jvbTabs(this.checkoutForm,{updateURL:!1}),console.log("Initialized Checkout")}initListeners(){this.clickHandler=this.handleClick.bind(this),this.keyHandler=this.handleEscape.bind(this),this.changeHandler=this.handleChange.bind(this),this.checkoutForm.addEventListener("submit",(e=>this.handleFormSubmit(e))),document.addEventListener("click",this.clickHandler),document.addEventListener("change",this.changeHandler)}async initializePaymentMethods(){if(document.getElementById("square-card-container"))try{this.card=await this.payments.card({style:this.getCardStyle()}),await this.card.attach("#square-card-container"),this.card.addEventListener("cardBrandChanged",(e=>{console.log("Card brand:",e.detail.cardBrand)}))}catch(e){throw console.error("Failed to initialize card:",e),e}}getCardStyle(){return{input:{fontSize:"16px",fontFamily:"inherit",color:"#333",backgroundColor:"#fff"},".input-container":{borderColor:"#ccc",borderRadius:"4px"},".input-container.is-focus":{borderColor:"#006AFF",borderWidth:"2px",outline:"2px solid #006AFF",outlineOffset:"2px"},".input-container.is-error":{borderColor:"#d63638"}}}async handleFormSubmit(e){if(!this.isOpen)return;if(e.preventDefault(),!this.isInitialized)return void this.handleError("Checkout not initialized");const t=e.target,a=this.extractOrderData(t);try{window.jvbLoading.showLoading("Processing payment...");const e=await this.processPayment(a);this.handleSuccess(e,t)}catch(e){this.handleError(e)}finally{window.jvbLoading.hideLoading()}}extractOrderData(e){const t=Array.from(this.cartItems.values()).map((e=>({catalog_object_id:e.square_catalog_id,quantity:String(e.quantity),price:e.price,note:e.note||""}))),a=t.reduce(((e,t)=>e+t.price*t.quantity),0);return{total:Math.round(100*a),items:t,customer:{email:this.isLoggedIn?this.userEmail:e.querySelector('[name="email"]')?.value||"",name:e.querySelector('[name="name"]')?.value||"",phone:e.querySelector('[name="phone"]')?.value||""},note:e.querySelector('[name="special_instructions"]')?.value||"",pickup_time:e.querySelector('[name="pickup_time"]')?.value||""}}async processPayment(e){try{let t=null;if(this.selectedCardId)t=this.selectedCardId;else{const a=await this.card.tokenize({verificationDetails:{amount:String(e.total),currencyCode:this.config.currency||"CAD",intent:"CHARGE",customerInitiated:!0,billingContact:{givenName:e.customer.name.split(" ")[0],familyName:e.customer.name.split(" ").slice(1).join(" "),email:e.customer.email,phone:e.customer.phone,addressLines:[form.querySelector('[name="address"]')?.value||""],city:form.querySelector('[name="city"]')?.value||"",state:form.querySelector('[name="state"]')?.value||"",postalCode:form.querySelector('[name="postal_code"]')?.value||"",countryCode:"CA"}}});if("OK"!==a.status){const e=a.errors?.map((e=>e.message)).join(", ")||"Unknown error";throw new Error(`Card tokenization failed: ${e}`)}t=a.token,a.details?.userChallenged&&console.log("3D Secure verification completed")}return await this.submitToServer(t,e,!!this.selectedCardId)}catch(e){throw console.error("Payment processing failed:",e),e}}async submitToServer(e,t,a=!1){if(!this.isOpen)throw new Error("Store is currently closed");const i=await fetch(this.config.api_url+"process-payment",{method:"POST",headers:{"Content-Type":"application/json","X-WP-Nonce":this.config.nonce},body:JSON.stringify({source_id:e,is_saved_card:a,cart_id:this.getCartId(),amount:t.total,items:t.items,customer:{email:this.isLoggedIn?this.userEmail:t.customer.email,name:t.customer.name,phone:t.customer.phone},note:t.note,pickup_time:t.pickup_time})}),s=await i.json();if(!i.ok)throw new Error(s.message||"Payment processing failed");return this.clearCart(),s}getCartId(){return this.cartId||(this.cartId=crypto.randomUUID(),this.cache.set("cart_id",this.cartId)),this.cartId}trackOrder(e){this.orderId=e,this.scheduleOrderCheck(),this.checkout.querySelector("button[data-tab=order]").hidden=!1}scheduleOrderCheck(){window.debouncer.schedule("order",(()=>{this.checkOrderStatus()}),3e4)}async checkOrderStatus(){const e=await fetch(`/wp-json/jvb/v1/square/order-status/${this.orderId}`),t=await e.json();"ready"!==t.status&&this.scheduleOrderCheck(),this.updateOrderStatus(t)}updateOrderStatus(e){this.checkout.querySelectorAll(".status-item").forEach((t=>{t.dataset.status===e.status&&t.classList.add("active")})),this.checkout.querySelector("#eta").textContent=e.eta||"In progress"}async loadSavedCards(){try{const e=await fetch(this.config.api_url+"saved-cards",{method:"GET",headers:{"X-WP-Nonce":this.config.nonce}}),t=await e.json();t.success&&t.cards&&(this.savedCards=t.cards,this.renderSavedCards())}catch(e){console.error("Failed to load saved cards:",e)}}renderSavedCards(){const e=document.getElementById("saved-cards");if(!e||0===this.savedCards.length)return;const t=`\n <div class="saved-cards-section">\n <h4>Saved Payment Methods</h4>\n ${this.savedCards.map((e=>`\n <label class="saved-card">\n <input type="radio" name="payment-method" value="saved" data-card-id="${e.id}">\n <span class="card-info">\n <strong>${e.card_brand}</strong> ending in ${e.last_4}\n <small>Exp: ${e.exp_month}/${e.exp_year}</small>\n </span>\n </label>\n `)).join("")}\n <label class="saved-card">\n <input type="radio" name="payment-method" value="new" checked>\n <span>Use a new card</span>\n </label>\n </div>\n `;e.innerHTML=t,e.querySelectorAll('input[name="payment-method"]').forEach((e=>{e.addEventListener("change",(e=>{const t="new"===e.target.value,a=document.getElementById("square-card-container");a&&(a.style.display=t?"block":"none"),this.selectedCardId=t?null:e.target.dataset.cardId}))}))}handleSuccess(e,t){document.dispatchEvent(new CustomEvent("squareCheckoutSuccess",{detail:{result:e,form:t}}));const a=t.dataset.successUrl||`/order-confirmation/?order=${e.wp_order_id}`;window.location.href=a}handleError(e){console.error("Square checkout error:",e),document.dispatchEvent(new CustomEvent("squareCheckoutError",{detail:{error:e}})),window.jvbNotifications?.show?.(e.message||"Payment failed","error")}}document.addEventListener("DOMContentLoaded",(()=>{window.squareCheckout=new e}))})();
\ No newline at end of file
diff --git a/assets/js/min/utility.min.js b/assets/js/min/utility.min.js
index 978b9cc..8bdeaee 100644
--- a/assets/js/min/utility.min.js
+++ b/assets/js/min/utility.min.js
@@ -1 +1 @@
-(()=>{window.fade=function(t,e=!0){e?t.style.animation="fadeIn var(--transition-base)":(t.style.animation="fadeOut var(--transition-base)",window.debouncer.schedule(`remove-${t.dataset.id??t.id??t.className.replace(" ","-")}`,(()=>{t.remove()}),500))},window.formatTimeAgo=function(t){const e=t instanceof Date?t:new Date(t),n=new Date,i=Math.floor((n-e)/1e3),o=Math.floor(i/60),r=Math.floor(o/60),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,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,""").replace(/'/g,"'")):""},window.truncateText=function(t,e=100){return!t||t.length<=e?t:t.substring(0,e)+"..."},window.removeChildren=function(t){if(0!==t.children.length)for(;t.firstChild;)t.removeChild(t.firstChild)},window.formatDateRange=function(t,e){const n=new Date(t),i=new Date(e);return n.toDateString()===i.toDateString()?n.toLocaleDateString("en-US",{year:"numeric",month:"short",day:"numeric"}):n.getMonth()===i.getMonth()&&n.getFullYear()===i.getFullYear()?`${n.toLocaleDateString("en-US",{month:"short",day:"numeric"})} - ${i.getDate()}, ${i.getFullYear()}`:n.getFullYear()===i.getFullYear()?`${n.toLocaleDateString("en-US",{month:"short",day:"numeric"})} - ${i.toLocaleDateString("en-US",{month:"short",day:"numeric"})}, ${i.getFullYear()}`:`${n.toLocaleDateString("en-US",{month:"short",day:"numeric",year:"numeric"})} - ${i.toLocaleDateString("en-US",{month:"short",day:"numeric",year:"numeric"})}`},window.debounce=function(t,e=300){let n;return function(...i){clearTimeout(n),n=setTimeout((()=>t.apply(this,i)),e)}},window.throttle=function(t,e){let n;return function(){const i=arguments,o=this;n||(t.apply(o,i),n=!0,setTimeout((()=>n=!1),e))}},window.throttle=function(t,e=300){let n;return function(...i){n||(t.apply(this,i),n=!0,setTimeout((()=>n=!1),e))}},window.uppercaseFirst=function(t){return t.charAt(0).toUpperCase()+t.slice(1)},window.sanitizeHtml=function(t){const e=document.createElement("div");return e.textContent=t,e.innerHTML},window.formatDate=function(t){if(!t)return"";const e=new Date(t),n=new Date,i=Math.floor((n-e)/864e5);return i<1?"Today":i<2?"Yesterday":i<7?`${i} days ago`:e.toLocaleDateString()},window.getPluralContent=function(t){return"artwork"===t?"artwork":t+"s"},window.showToast=function(t,e="success",n={}){window.jvbNotifications.showToast(t,e,n)},window.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()}}})();
\ No newline at end of file
+(()=>{window.fade=function(t,e=!0){e?t.style.animation="fadeIn var(--transition-base)":(t.style.animation="fadeOut var(--transition-base)",window.debouncer.schedule(`remove-${t.dataset.id??t.id??t.className.replace(" ","-")}`,(()=>{t.remove()}),500))},window.formatTimeAgo=function(t){const e=t instanceof Date?t:new Date(t),n=new Date,i=Math.floor((n-e)/1e3),o=Math.floor(i/60),r=Math.floor(o/60),a=Math.floor(r/24);return r<24?0===r?0===o?"Just now":`${o} ${1===o?"minute":"minutes"} ago`:`${r} ${1===r?"hour":"hours"} ago`:a<7?`${a} ${1===a?"day":"days"} ago`:e.toLocaleDateString()},window.formatTimeSoon=function(t){const e=t instanceof Date?t:new Date(t),n=new Date;if(e<=n)return"Just now";const i=Math.floor((e-n)/1e3),o=Math.floor(i/60);return i<60?"In a moment":o<5?"In a few minutes":o<20?"Coming up soon":o<60?"In about half an hour":"Later today"},window.uppercaseFirst=function(t){return t.charAt(0).toUpperCase()+t.slice(1)},window.templates=new Map,document.addEventListener("DOMContentLoaded",(()=>{window.loadTemplates()})),window.loadTemplates=function(){document.querySelectorAll("template").forEach((t=>{const e=Array.from(t.classList);if(e.length>0){const n=t.content.cloneNode(!0).firstElementChild;e.forEach((t=>{window.templates.has(t)||window.templates.set(t,n)}))}}))},window.getTemplate=function(t){return 0===window.templates.size&&loadTemplates(),!!window.templates.has(t)&&window.templates.get(t).cloneNode(!0)},window.formatVote=function(t,e){let n=window.getTemplate("voteButton");n.dataset.itemId=t.id,n.dataset.content=t.content;let i=n.querySelector("button.up"),o=n.querySelector("button.down");return"up"===e&&i.classList.add("voted"),"down"===e&&o.classList.add("voted"),t.upvotes>0&&(i.querySelector(".count").textContent=t.upvotes),t.downvotes>0&&(o.querySelector(".count").textContent="-"+t.downvotes),n},window.checkVoteStatus=function(t,e){if(!jvbSettings.currentUser)return"";let n="";return window.userVotes&&window.userVotes[t]?.has(e)&&(n=window.userVotes[t].get(e)),n},window.getIcon=function(t){if(void 0===t)return"";if(window.jvbIcons||(window.jvbIcons=new Map),!window.jvbIcons.has(t)&&jvbSettings.icons[t]){let e=document.createElement("div");e.innerHTML=jvbSettings.icons[t],window.jvbIcons.set(t,e.firstElementChild.cloneNode(!0)),e.remove()}return window.jvbIcons.get(t)?.cloneNode(!0)},window.isEmptyObject=function(t){return 0===Object.keys(t).length},window.formatNumber=function(t){return t.toString().replace(/\B(?=(\d{3})+(?!\d))/g,",")},window.formatPrice=function(t,e="CAD"){return new Intl.NumberFormat("en-CA",{style:"currency",currency:e}).format(t)},window.escapeHtml=function(t){return t?("string"==typeof t||t instanceof String||(t=String(t)),t.replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,""").replace(/'/g,"'")):""},window.truncateText=function(t,e=100){return!t||t.length<=e?t:t.substring(0,e)+"..."},window.removeChildren=function(t){if(0!==t.children.length)for(;t.firstChild;)t.removeChild(t.firstChild)},window.formatDateRange=function(t,e){const n=new Date(t),i=new Date(e);return n.toDateString()===i.toDateString()?n.toLocaleDateString("en-US",{year:"numeric",month:"short",day:"numeric"}):n.getMonth()===i.getMonth()&&n.getFullYear()===i.getFullYear()?`${n.toLocaleDateString("en-US",{month:"short",day:"numeric"})} - ${i.getDate()}, ${i.getFullYear()}`:n.getFullYear()===i.getFullYear()?`${n.toLocaleDateString("en-US",{month:"short",day:"numeric"})} - ${i.toLocaleDateString("en-US",{month:"short",day:"numeric"})}, ${i.getFullYear()}`:`${n.toLocaleDateString("en-US",{month:"short",day:"numeric",year:"numeric"})} - ${i.toLocaleDateString("en-US",{month:"short",day:"numeric",year:"numeric"})}`},window.debounce=function(t,e=300){let n;return function(...i){clearTimeout(n),n=setTimeout((()=>t.apply(this,i)),e)}},window.throttle=function(t,e){let n;return function(){const i=arguments,o=this;n||(t.apply(o,i),n=!0,setTimeout((()=>n=!1),e))}},window.throttle=function(t,e=300){let n;return function(...i){n||(t.apply(this,i),n=!0,setTimeout((()=>n=!1),e))}},window.uppercaseFirst=function(t){return t.charAt(0).toUpperCase()+t.slice(1)},window.sanitizeHtml=function(t){const e=document.createElement("div");return e.textContent=t,e.innerHTML},window.formatDate=function(t){if(!t)return"";const e=new Date(t),n=new Date,i=Math.floor((n-e)/864e5);return i<1?"Today":i<2?"Yesterday":i<7?`${i} days ago`:e.toLocaleDateString()},window.getPluralContent=function(t){return"artwork"===t?"artwork":t+"s"},window.showToast=function(t,e="success",n={}){window.jvbNotifications.showToast(t,e,n)},window.dateFormatter=new Intl.DateTimeFormat("en-CA",{year:"numeric",month:"long",day:"numeric",hour:"2-digit",minute:"2-digit",second:"2-digit",timeZoneName:"short"}),window.formatDate=function(t){return t instanceof Date&&!isNaN(t)||(t=new Date(t)),window.dateFormatter.format(t)},window.typeText=function(t,e,n=50){return t.classList.add("typeText"),new Promise((i=>{let o=0;t.textContent="";const r=setInterval((()=>{o<e.length?(t.textContent+=e.charAt(o),o++):(clearInterval(r),i())}),n)}))},window.eraseText=function(t,e=10){return new Promise((n=>{let i=t.textContent,o=i.length;const r=setInterval((()=>{o>0?(o--,t.textContent=i.substring(0,o)):(clearInterval(r),n())}),e)}))},window.typeLoop=function(t,e,n=50,i=10,o=1e3,r=250){let a=!0;return async function(){for(;a;)await window.typeText(t,e,n),await new Promise((t=>setTimeout(t,o))),await window.eraseText(t,i),await new Promise((t=>setTimeout(t,r)))}(),function(){a=!1}},window.toCamelCase=function(t){return t.replace(/-([a-z])/g,(function(t){return t[1].toUpperCase()}))},window.targetCheck=function(t,e){return"string"==typeof e&&(t.target.closest(e)??!1)},window.getDifferences={VALUE_CREATED:"created",VALUE_UPDATED:"updated",VALUE_DELETED:"deleted",VALUE_UNCHANGED:"unchanged",map:function(t,e){if(this.isFunction(t)||this.isFunction(e))throw"Invalid argument. Function given, object expected.";if(this.isFile(t)||this.isFile(e)){const n=this.compareFiles(t,e);return n===this.VALUE_UNCHANGED?null:{type:n,data:void 0===t?e:t}}if(this.isValue(t)||this.isValue(e)){const n=this.compareValues(t,e);if(n===this.VALUE_UNCHANGED)return null;let i;switch(n){case this.VALUE_CREATED:i=e;break;case this.VALUE_DELETED:i=this.getEmptyValue(t);break;case this.VALUE_UPDATED:default:i=e}return{type:n,data:i}}let n={},i=!1;for(let o in t)if(!this.isFunction(t[o])){let r;e&&void 0!==e[o]&&(r=e[o]);const a=this.map(t[o],r);null!==a&&(a.hasOwnProperty("type")&&a.hasOwnProperty("data")?n[o]=a.data:n[o]=a,i=!0)}if(e)for(let o in e)if(!this.isFunction(e[o])&&(void 0===t||void 0===t[o])){const t=this.map(void 0,e[o]);null!==t&&(t.hasOwnProperty("type")&&t.hasOwnProperty("data")?n[o]=t.data:n[o]=t,i=!0)}return i?n:null},getEmptyValue:function(t){return this.isArray(t)?[]:this.isObject(t)?{}:"number"==typeof t?0:"boolean"!=typeof t&&""},compareValues:function(t,e){return t===e||this.isDate(t)&&this.isDate(e)&&t.getTime()===e.getTime()?this.VALUE_UNCHANGED:void 0===t?this.VALUE_CREATED:void 0===e?this.VALUE_DELETED:this.VALUE_UPDATED},isFunction:function(t){return"[object Function]"===Object.prototype.toString.call(t)},isArray:function(t){return"[object Array]"===Object.prototype.toString.call(t)},isDate:function(t){return"[object Date]"===Object.prototype.toString.call(t)},isObject:function(t){return"[object Object]"===Object.prototype.toString.call(t)},isFile:function(t){return t instanceof File},isValue:function(t){return!this.isObject(t)&&!this.isArray(t)},compareFiles:function(t,e){return!this.isFile(t)&&this.isFile(e)?this.VALUE_CREATED:this.isFile(t)&&!this.isFile(e)?this.VALUE_DELETED:this.isFile(t)&&this.isFile(e)?t.name===e.name&&t.size===e.size&&t.type===e.type&&t.lastModified===e.lastModified?this.VALUE_UNCHANGED:this.VALUE_UPDATED:this.VALUE_UNCHANGED},merge:function(t,e){if(null==t)return e;if(null==e)return t;if(this.isFunction(t)||this.isFunction(e))return e;if(this.isFile(t)||this.isFile(e))return e;if(this.isValue(t)||this.isValue(e)||this.isArray(t)||this.isArray(e))return e;if(this.isObject(t)&&this.isObject(e)){let n={};for(let e in t)this.isFunction(t[e])||(n[e]=t[e]);for(let i in e)this.isFunction(e[i])||(void 0!==t[i]?n[i]=this.merge(t[i],e[i]):n[i]=e[i]);return n}return e}},window.deepMerge=function(t,e){return window.getDifferences.merge(t,e)},window.isInt=function(t){return!isNaN(parseInt(t))&&isFinite(t)},window.isNumeric=function(t){return!isNaN(parseFloat(t))&&isFinite(t)},window.handleListField=function(t,e){if(!Array.isArray(e))return void t.remove();let n=t.querySelector("li");e.forEach((e=>{let i=n.cloneNode(!0);i.textContent=e,t.append(i)})),n.remove()},window.handleTextField=function(t,e){"string"==typeof e?t.textContent=e:t.remove()},window.handleImageField=function(t,e){if(!Array.isArray(e)||0===e)return void t.remove();let n="IMG"===t.tagName?t:t.querySelector("img");n?(n.alt=e.alt,n.src=e.thumbnail,n.dataset.small=e.small,n.dataset.medium=e.medium,n.dataset.large=e.full):t.remove()},window.handleGalleryField=function(t,e){if(!Array.isArray(e))return void t.remove();let n=t.querySelector("img");e.forEach((e=>{let i=n.cloneNode(!0);window.handleImageField(i,e),t.append(i)})),n.remove()},window.uiFromSelectors=function(t,e=null){let n={};for(let[i,o]of Object.entries(t))n[i]="object"==typeof o?window.uiFromSelectors(o,e):e?e.querySelector(o):document.querySelector(o);return n};window.debouncer=new class{constructor(){this.timeouts=new Map,window.addEventListener("beforeunload",(()=>this.cleanup()))}schedule(t,e,n=1e3){this.cancel(t),console.log("Scheduling action: ",t),console.log("With callback",e),this.timeouts.set(t,setTimeout((()=>{e(),this.timeouts.delete(t)}),n))}cancel(t){this.timeouts.has(t)&&(console.log("Cancelling ",t),clearTimeout(this.timeouts.get(t)),this.timeouts.delete(t))}cleanup(){for(let t of this.timeouts.values())console.log("clearing timeout: ",t),clearTimeout(t);this.timeouts.clear()}}})();
\ No newline at end of file
diff --git a/build/forms/view.asset.php b/build/forms/view.asset.php
index 4f95fff..8bb1b2c 100644
--- a/build/forms/view.asset.php
+++ b/build/forms/view.asset.php
@@ -1 +1 @@
-<?php return array('dependencies' => array(), 'version' => '489e47cea4ff0c15a5f0');
+<?php return array('dependencies' => array(), 'version' => '6af2556d0306f0da3d78');
diff --git a/build/forms/view.js b/build/forms/view.js
index eb7164f..73415e6 100644
--- a/build/forms/view.js
+++ b/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)}))}));
\ No newline at end of file
+(()=>{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}))})();
\ No newline at end of file
diff --git a/icons.php b/icons.php
index be6007c..10df568 100644
--- a/icons.php
+++ b/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',
diff --git a/inc/blocks/FormBlock.php b/inc/blocks/FormBlock.php
index 6cec6de..369ce77 100644
--- a/inc/blocks/FormBlock.php
+++ b/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>';
}
/**
diff --git a/inc/helpers/renderFields.php b/inc/helpers/renderFields.php
index 5c806d0..2e31338 100644
--- a/inc/helpers/renderFields.php
+++ b/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">
diff --git a/inc/helpers/ui.php b/inc/helpers/ui.php
index 1831a33..b9bdfd4 100644
--- a/inc/helpers/ui.php
+++ b/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>';
+}
diff --git a/inc/integrations/Integrations.php b/inc/integrations/Integrations.php
index 76f0c56..5f47ff6 100644
--- a/inc/integrations/Integrations.php
+++ b/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()) {
- $this->logDebug('OAuth token expired, attempting refresh');
- if (!$this->refreshOAuthToken()) {
- $this->logError('Failed to refresh OAuth token');
+ 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 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
diff --git a/inc/integrations/Square.php b/inc/integrations/Square.php
index 4eb7b1c..ca3a7aa 100644
--- a/inc/integrations/Square.php
+++ b/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',
@@ -1957,13 +2148,15 @@
'jvb-square-checkout',
'squareConfig',
[
- 'isOpen' => jvbIsOpen(),
+ 'isOpen' => jvbIsOpen(),
'application_id' => $this->credentials['client_id'] ?? '',
'location_id' => $this->locationId,
'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;
+ }
}
diff --git a/inc/managers/FormManager.php b/inc/managers/FormManager.php
index fe90ee0..28288b8 100644
--- a/inc/managers/FormManager.php
+++ b/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();
}
diff --git a/inc/managers/ReferralManager.php b/inc/managers/ReferralManager.php
index 429ea99..9777fa4 100644
--- a/inc/managers/ReferralManager.php
+++ b/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>
diff --git a/inc/meta/MetaForm.php b/inc/meta/MetaForm.php
index d6846c4..91d9980 100644
--- a/inc/meta/MetaForm.php
+++ b/inc/meta/MetaForm.php
@@ -6,7 +6,7 @@
use DateTime;
if (!defined('ABSPATH')) {
- exit; // Exit if accessed directly
+ exit;
}
/**
@@ -14,25 +14,28 @@
*/
class MetaForm
{
- protected int $max_file_size = 5242880;
+ protected int $max_file_size = 5242880;
- //Rendering fields
- public function render(string $name, mixed $value, array $config, bool $showHidden = false, bool $return = false):mixed
- {
+ /* ========== 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;
- }
+ if (jvbCheck('hidden', $config) && !$showHidden) {
+ return $out;
+ }
- if (array_key_exists('display', $config) && $config['display'] === 'hidden'){
- $out = '<input type="hidden" name="'.$name.'" value="'.$value.'" />';
+ 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) {
echo $out;
}
@@ -40,647 +43,961 @@
}
ob_start();
- $type = array_map( 'ucfirst', explode('_', $config['type']));
- $type = implode('', $type);
- $method = 'render' . $type . 'Field';
+
+ // 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)) {
+ $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);
- }
-
-
+ $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();
- }
- }
+ /* ========== HELPER METHODS ========== */
- 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();
- }
- }
+ /**
+ * Prepare common field data
+ */
+ protected function prepareFieldData(string $name, mixed $value, array $field): array
+ {
+ 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),
+ ];
+ }
- 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();
- }
- }
+ /**
+ * Build common HTML attributes for inputs
+ */
+ protected function buildInputAttributes(string $name, array $field): string
+ {
+ $attrs = [];
- 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);
+ // 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];
+ }
+ }
+
+ // Required
+ if (!empty($field['required'])) {
+ $attrs['required'] = true;
+ }
+
+ // 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) . '"';
+ }
+ }
+
+ return $attrString;
+ }
+
+ /**
+ * Build validation data attributes
+ */
+ protected function buildValidationAttributes(array $field): string
+ {
+ $attrs = [];
+
+ if (!empty($field['pattern'])) {
+ $attrs['data-pattern'] = $field['pattern'];
+ }
+
+ 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 <?= 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>
+ <?php endif; ?>
+ </label>
+ <?php
+ }
+
+ /**
+ * 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
+ {
+ $field['validate'] = 'url'; // Auto-add URL validation
+ $this->renderStandardInput($name, $value, $field, 'url');
+ }
+
+ 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"' : '';
$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.'"';
- }
+ foreach ($field['data'] as $key => $v) {
+ $customData .= ($v === '') ? ' data-' . $key : ' data-' . $key . '="' . $v . '"';
}
}
if (empty($value)) {
- $value = $field['default']??0;
+ $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>
+ $autocomplete = (array_key_exists('autocomplete', $field)) ? ' autocomplete="' . $field['autocomplete'] . '"' : '';
- <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>
+ <div class="field <?= esc_attr($field['type']) ?> <?= esc_attr($name) ?> row"
+ <?= $conditional ?>
+ data-field="<?= esc_attr($name) ?>"
+ <?= $validationAttrs ?>>
- <?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 $this->renderLabel($name, $field); ?>
- <?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
- }
+ <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']) ?>">
+ <?= jvbIcon('minus') ?>
+ </button>
- protected function renderPostSelectorField(string $name, mixed $value, array $field):void
- {
- $conditional = $this->handleConditionalField($field);
+ <input type="number"
+ 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' : '' ?>>
- // 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 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>
- <?= $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); } ?>
+ <?php $this->renderHintAndDescription($field, $name); ?>
</div>
<?php
- }
+ }
- protected function renderGroupField(string $name, mixed $value, array $field):void
+ /* ========== SELECT, RADIO, CHECKBOX FIELDS ========== */
+
+ private function renderSelectField(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) : '';
+
+ ?>
+ <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>
+ <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 renderRadioField(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) : '';
+
+ ?>
+ <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
+ {
+ $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 <?= 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="<?= 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($data['name']) ?>-<?= esc_attr($key)?>">
+ <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 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"' : '';
+
+ ?>
+ <div class="field true-false <?= esc_attr($name) ?> row btw"
+ <?= $conditional ?>
+ data-field="<?= esc_attr($name) ?>"
+ <?= $validationAttrs ?>>
+
+ <label class="toggle-switch row" <?= $describedBy ?>>
+ <input
+ type="checkbox"
+ name="<?= esc_attr($data['name']) ?>"
+ value="1"
+ <?= ($value) ? ' checked' : '' ?>
+ <?= !empty($field['required']) ? 'required' : '' ?>
+ >
+ <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
+ {
+ if (array_key_exists('group', $field)) {
+ $name = $field['group'] . '::' . $name;
+ }
+
+ $this->renderComplexFieldWrapper($name, $field, function($name, $data, $field) use ($value) {
+ $values = is_array($value) ? $value : [];
+ $rowLabel = $field['row_label'] ?? '';
+ $rowTitle = $field['new_row'] ?? 'New Item';
+ $addLabel = $field['add_label'] ?? 'Add Item';
+
+ ?>
+ <div class="repeater-items" data-label="<?= esc_attr($rowLabel) ?>">
+ <?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'], [], '', $name, $rowTitle); ?>
+ </template>
+
+ <button type="button" class="add-repeater-row button secondary">
+ <?= jvbIcon('plus', ['title' => 'Add']) ?>
+ <span><?= esc_html($addLabel) ?></span>
+ </button>
+ <?php
+ });
+ }
+
+ private function renderRepeaterRow(array $fields, array $values, int|string $index, string $base_name, string $rowTitle): 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) {
+ $field_name = ($base_name === '') ? $slug : sprintf('%s:%s:%s', $base_name, $index, $slug);
+ $field_value = $values[$slug] ?? '';
+ $this->render($field_name, $field_value, $field);
+ }
+ ?>
+ </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;
+ }
+
+ /* ========== GROUP FIELD ========== */
+
+ 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;
+ $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
+
+ 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="<?= esc_attr($name) ?>"
+ <?= $validationAttrs ?>
+ <?= $describedBy ?>>
+ <legend><?= esc_html($field['label']) ?></legend>
- <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;
+ <?php $this->renderHintAndDescription($field, $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 class="group-fields <?= esc_attr($original) ?>">
+ <?php $this->renderGroupFields($name, $values, $field); ?>
</div>
+
+ <span class="validation-message" hidden role="alert"></span>
+ </fieldset>
<?php
- if (!$hidden) {
- ?>
- </fieldset>
- <?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'] = $groupName;
+
+ // Get the value for this specific field
+ $field_value = $values[$field_name] ?? '';
+
+ // Handle conditional fields within the group
+ if (isset($config['condition'])) {
+ $condition_field = $config['condition']['field'];
+ if (!str_contains($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
+ });
+ }
+
+ /**
+ * 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';
+
+ ?>
+ <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,1411 +1032,144 @@
] : 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>';
- }
+ $this->renderComplexFieldWrapper($name, $field, function($name, $data, $field) use (
+ $stored_data, $street, $address, $lat, $lng, $map_id, $js_config
+ ) {
?>
- <?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-field-wrapper"
+ data-location-field-init="<?= esc_attr(json_encode($js_config)) ?>">
- <div class="location-search-wrapper">
- <div class="autocomplete-wrapper"></div>
+ <?php if (!empty($street)) : ?>
+ <p class="current-location">
+ <strong>Current location:</strong> <?= esc_html($street) ?>
+ </p>
+ <?php endif; ?>
- <!-- Map container -->
- <div class="location-preview">
- <div id="<?= esc_attr($map_id); ?>"
- class="location-map">
- </div>
+ <label for="<?= esc_attr($data['id']) ?>">Address</label>
+ <input type="text"
+ id="<?= esc_attr($data['id']) ?>"
+ name="<?= esc_attr($data['name']) ?>[address]"
+ value="<?= esc_attr($address) ?>"
+ placeholder="Enter an address"
+ class="location-search-input"
+ autocomplete="off"
+ <?= !empty($field['required']) ? 'required' : '' ?>>
- <?php if (!empty($stored_data)):
- jvbLocationLinks($stored_data);
- endif; ?>
- </div>
+ <div id="<?= esc_attr($map_id) ?>" class="location-map" style="height: 300px;"></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; ?>
-
+ <!-- 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>
- </div>
- <?php
+ <?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); } ?>
+ /* ========== HTML FIELD ========== */
- <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
+ protected function renderHtmlField(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);
+ $method_name = $field['content'];
+ $content = '';
- // 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");
+ if (method_exists($this, $method_name)) {
+ $content = $this->$method_name();
+ }
+
+ if ($content === '') {
return;
}
- // Get accepted types
- $acceptedTypes = $this->getAllowedTypes($config);
+ echo sprintf(
+ '<div class="html-field-container" data-field-type="html" data-field="%s">%s</div>',
+ esc_attr($name),
+ $content
+ );
+ }
- // Build accept attribute for input
- $acceptExtensions = $this->getMimeExtensions($acceptedTypes);
- $acceptAttr = implode(',', $acceptExtensions);
+ /* ========== UTILITY METHODS ========== */
- // 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;
+ private function handleConditionalField(array $field):string
+ {
+ if (empty($field['condition'])) {
+ return '';
}
- // Build data attributes
- $conditional = $this->handleConditionalField($field);
- $describedBy = !empty($field['description']) ? ' aria-describedby="' . esc_attr($name) . '-help"' : '';
+ $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'] ?? '==')
+ );
+ }
- 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) . '"' : '');
- }
+ protected function renderHint(string $hint): void
+ {
?>
- <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>
+ <span class="hint"><?= esc_html($hint) ?></span>
<?php
}
-
- protected function getAllowedTypes(array $config):array
+ protected function renderDescription(string $description, string $name): void
{
- $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
+ ?>
+ <p class="description" id="<?= esc_attr($name) ?>-help">
+ <?= wp_kses_post($description) ?>
+ </p>
+ <?php
+ }
+
+ protected function getAllowedTypes(array $config): array
+ {
if (!empty($config['accepted_types'])) {
- return is_array($config['accepted_types'])
- ? $config['accepted_types']
- : [$config['accepted_types']];
+ return $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'],
+ // 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 (isset($extensionMap[$mime])) {
- $extensions = array_merge($extensions, $extensionMap[$mime]);
+ if (str_contains($mime, '*')) {
+ continue; // Skip wildcards
}
+ $ext = str_replace(['image/', 'video/', 'application/'], '', $mime);
+ $extensions[] = '.' . $ext;
}
-
- return array_unique($extensions);
+ return $extensions;
}
- /**
- * Get max file size for subtype
- */
- private function getMaxFileSize(string $subtype): int
+ protected function parseAttachmentIds(mixed $value): array
{
- $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);
+ if (empty($value)) {
+ return [];
}
- $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 (is_array($value)) {
+ return array_filter($value, 'is_numeric');
}
- if (array_key_exists('group', $field)) {
- $name = $field['group'].'::'.$name;
+ if (is_string($value)) {
+ return array_filter(explode(',', $value), 'is_numeric');
}
- ?>
- <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
+ return is_numeric($value) ? [$value] : [];
}
- 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;
- }
}
diff --git a/inc/meta/MetaFormOld.php b/inc/meta/MetaFormOld.php
new file mode 100644
index 0000000..1c29732
--- /dev/null
+++ b/inc/meta/MetaFormOld.php
@@ -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;
+ }
+}
diff --git a/inc/meta/MetaManager.php b/inc/meta/MetaManager.php
index 7ed3bc6..e4644f2 100644
--- a/inc/meta/MetaManager.php
+++ b/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();
diff --git a/inc/registry/PostTypeRegistrar.php b/inc/registry/PostTypeRegistrar.php
index e6f7272..9bd39c6 100644
--- a/inc/registry/PostTypeRegistrar.php
+++ b/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,
diff --git a/inc/rest/_setup.php b/inc/rest/_setup.php
index 813542b..a10f740 100644
--- a/inc/rest/_setup.php
+++ b/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');
diff --git a/inc/rest/routes/IntegrationsSquareRoutes.php b/inc/rest/routes/IntegrationsSquareRoutes.php
new file mode 100644
index 0000000..af16f06
--- /dev/null
+++ b/inc/rest/routes/IntegrationsSquareRoutes.php
@@ -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')
+ ]
+ ];
+ }
+}
diff --git a/jvb.php b/jvb.php
index edc1b76..fb13350 100644
--- a/jvb.php
+++ b/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');
diff --git a/src/forms/view.js b/src/forms/view.js
index 4275888..d8f6948 100644
--- a/src/forms/view.js
+++ b/src/forms/view.js
@@ -2,80 +2,85 @@
* 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.querySelectorAll('.jvb-form-block form').forEach(form => {
+ this.controller.registerForm(form);
+ });
+
+ this.controller.subscribe((event, data) => {
+ if (event === 'form-submit') {
+ this.handleFormSubmission(data);
+ }
+ });
+ }
+
+ 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'] = formId;
+ data['form_id'] = form.id;
+ let block = form.closest('.jvb-form-block');
+ this.controller.showFormStatus(formId, 'uploading');
+ try {
+ const response = await fetch (`${jvbSettings.api}forms`,
+ {
+ method: 'POST',
+ headers,
+ 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');
+
+ this.controller.showSummary(formId, '.jvb-form-block');
+ this.controller.store.delete(formId);
+
+ } catch (error) {
+ throw error;
+ }
+ }
+
+ 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>');
+ } else {
+ item.querySelector('div').innerHTML = value;
+ }
+ }
+ }
+ block.append(summary);
+ }
+}
+
document.addEventListener('DOMContentLoaded', function() {
- let formController = new window.jvbForm();
-
- document.querySelectorAll('.jvb-form-block').forEach(block => {
- const formHandler = formController.registerForm(block.querySelector('form'));
- });
- formController.subscribe((event, data) => {
- if (event === 'form-submit') {
- handleFormSubmission(null, data.data, data.config.element);
- }
- });
+ new FormBlock();
});
-
-async function handleFormSubmission(event, data, form) {
- let headers = {
- 'X-WP-Nonce': jvbSettings.nonce,
- 'Content-Type': 'application/json'
- };
-
- data['form_type'] = form.dataset.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);
- try {
- const response = await fetch (`${jvbSettings.api}forms`,
- {
- method: 'POST',
- headers,
- body: JSON.stringify(data)
- });
- if (!response.ok) {
- const errorData = await response.json().catch(() => ({}));
- throw new Error(errorData.message|| `Request failed with status ${response.status}`);
- }
-
- updateUI(await response.json(), block);
- } 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;
- }
- let summary = window.getTemplate('formSummary');
- summary.querySelector('h2').textContent = 'Success!';
- console.log('Form Response: ', response);
- for (let [key, value] of Object.entries(response)) {
- let item = summary.querySelector(`#${key}`);
-
- let title = item.querySelector('h4');
- if (title.innerText.includes('%s')) {
- title.innerHTML = title.replace('%s', '<b>'+value+'</b>');
- } else {
- item.querySelector('div').innerHTML = value;
- }
- }
- block.append(summary);
-}
-
--
Gitblit v1.10.0