25 files modified
2 files added
| | |
| | | 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; |
| | |
| | | 'userTerms' => new UserTermsManager(), |
| | | ]; |
| | | |
| | | add_action('wp_footer', [$this, 'additionalActions']); |
| | | |
| | | if (Features::forSite()->has('magicLink')) { |
| | | $this->routes['magicLink'] = new MagicLinkRoutes(); |
| | |
| | | $this->managers['dash'] = new DashboardManager(); |
| | | } |
| | | |
| | | if (Features::hasIntegration('square')){ |
| | | $this->routes['square'] = new IntegrationsSquareRoutes(); |
| | | } |
| | | |
| | | $this->routes = [ |
| | | 'integrations' => new IntegrationsRoutes(), |
| | | ]; |
| | |
| | | } |
| | | |
| | | $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']); |
| | |
| | | .group-fields{position:relative}.hours-copy-btn:hover{background-color:var(--action-50);transform:scale(1.05)}.hours-copy-btn:active{transform:scale(.95)}.hours-copy-btn .icon{--w:0.875rem}.copy-hours-content h3{margin:0 0 1rem 0;color:var(--contrast);font-size:var(--large)}.copy-hours-source{background-color:var(--base-100);padding:1rem;border-radius:var(--innerRadius);margin-bottom:1.5rem;border:1px solid var(--base-200)}.copy-hours-source h4{margin:0 0 .5rem 0;color:var(--contrast-100);text-transform:uppercase;font-size:var(--small);font-weight:600}.source-info{--gap:.25rem}.source-day{font-weight:600;color:var(--contrast);text-transform:capitalize}.source-hours{--gap:1rem;font-weight:500;color:var(--contrast)}.source-hours.closed{color:var(--contrast-200);font-style:italic}.copy-hours-targets{margin-bottom:2rem}.copy-hours-targets h4{margin:0 0 1rem 0;color:var(--contrast-100);text-transform:uppercase;font-size:var(--small);font-weight:600}.day-checkboxes{display:grid;grid-template-columns:repeat(auto-fit,minmax(120px,1fr));gap:.75rem}.feedback{position:fixed;top:2rem;right:2rem;background-color:var(--action-50);color:var(--action-contrast);padding:1rem 1.5rem;border-radius:var(--innerRadius);box-shadow:var(--shadow);z-index:10000;opacity:0;transform:translateX(100px);transition:all var(--transition-base);display:flex;align-items:center;gap:.5rem}.feedback.show{opacity:1;transform:translateX(0)}.feedback .icon{--w:1.25rem}@media (prefers-color-scheme:dark){.feedback{background-color:var(--success-dark,#1e7e34)}} |
| | | .group-fields{position:relative}.hours-copy-btn:hover{background-color:var(--action-50);transform:scale(1.05)}.hours-copy-btn:active{transform:scale(.95)}.hours-copy-btn .icon{--w:0.875rem}.copy-hours-content h3{margin:0 0 1rem 0;color:var(--contrast);font-size:var(--large)}.copy-hours-source{background-color:var(--base-100);padding:1rem;border-radius:var(--innerRadius);margin-bottom:1.5rem;border:1px solid var(--base-200)}.copy-hours-source h4{margin:0 0 .5rem 0;color:var(--contrast-100);text-transform:uppercase;font-size:var(--small);font-weight:600}.source-info{--gap:.25rem}.source-day{font-weight:600;color:var(--contrast);text-transform:capitalize}.source-hours{--gap:1rem;font-weight:500;color:var(--contrast)}.source-hours.closed{color:var(--contrast-200);font-style:italic}.copy-hours-targets{margin-bottom:2rem}.copy-hours-targets h4{margin:0 0 1rem 0;color:var(--contrast-100);text-transform:uppercase;font-size:var(--small);font-weight:600}.day-checkboxes{display:grid;grid-template-columns:repeat(auto-fit,minmax(120px,1fr));gap:.75rem}.feedback{position:fixed;top:2rem;right:2rem;background-color:var(--action-50);color:var(--action-contrast);padding:1rem 1.5rem;border-radius:var(--innerRadius);box-shadow:var(--shadow);z-index:10000;opacity:0;transform:translateX(100px);transition:all var(--transition-base);display:flex;align-items:center;gap:.5rem}.feedback.show{opacity:1;transform:translateX(0)}.feedback .icon{--w:1.25rem} |
| | |
| | | details.uploader .file-upload-container{margin:1rem 0;max-width:100%}@media (min-width:768px){details.uploader .file-upload-container{margin:1rem var(--mr) 1rem var(--ml);max-width:var(--maxWidth)}}.file-upload-wrapper{border:2px dashed var(--action-0);border-radius:4px;padding:2rem;text-align:center;transition:all .3s ease;background:rgba(var(--action-rgb),var(--rgb-subtle));position:relative;cursor:pointer}.file-upload-wrapper h2{margin:0!important;font-size:var(--large)}.dragover,.file-upload-wrapper:hover{background:rgba(var(--action-rgb),var(--rgb-subtle-hover));border-color:var(--action-0)!important}.file-upload-wrapper input[type=file]{position:absolute;left:0;top:0;width:100%;height:100%;opacity:0;cursor:pointer}.file-upload-text{color:var(--contrast);margin:0;font-family:var(--body)}.file-upload-text strong{color:var(--action-0);text-decoration:underline}.field.upload:has(.upload-item) .file-upload-container{display:none}.field.upload{position:relative}.field.upload:not(.uploading) .progress{display:none}.field.upload .actions{position:absolute;top:0;right:0}.item-grid.group,.item-grid.preview,.item-grid.restore{grid-template-columns:repeat(3,1fr)}.item-grid.group .item,.item-grid.preview .item,.item-grid.restore .item{display:block}.item-grid.group button,.item-grid.preview button,.item-grid.restore button{padding:.25rem .5rem}.item-grid.group button .icon,.item-grid.preview button .icon,.item-grid.restore button .icon{--w:1.1em}.item-grid.group .item .preview>input[type=checkbox]:not(.label-button)+label,.item-grid.preview .item .preview>input[type=checkbox]:not(.label-button)+label,.item-grid.restore .item .preview>input[type=checkbox]:not(.label-button)+label{padding-left:0;margin:0}.item-grid.group .item .preview>input[type=checkbox]+label:before,.item-grid.preview .item .preview>input[type=checkbox]+label:before,.item-grid.restore .item .preview>input[type=checkbox]+label:before{transform:unset;top:.5rem;left:.5rem}.item-grid.group .item .preview>input[type=checkbox]+label::after,.item-grid.preview .item .preview>input[type=checkbox]+label::after,.item-grid.restore .item .preview>input[type=checkbox]+label::after{top:.5rem;left:.75rem;transform:translateY(20%) rotate(45deg)}.item-grid.group .item .item-actions,.item-grid.preview .item .item-actions,.item-grid.restore .item .item-actions{position:absolute;top:0;right:0}.item-grid.group summary,.item-grid.preview summary,.item-grid.restore summary{padding:.5rem}.item-grid.group:has([type=checkbox]:checked),.item-grid.preview:has([type=checkbox]:checked),.item-grid.restore:has([type=checkbox]:checked){padding:1rem;background-color:rgba(var(--contrast-rgb),var(--rgb-subtle))}.item-grid.group:has([type=checkbox]:checked) .item,.item-grid.preview:has([type=checkbox]:checked) .item,.item-grid.restore:has([type=checkbox]:checked) .item{padding:.75rem;opacity:.8}.item-grid.group:has([type=checkbox]:checked) .item img,.item-grid.preview:has([type=checkbox]:checked) .item img,.item-grid.restore:has([type=checkbox]:checked) .item img{filter:var(--filter)}.item-grid.group:has([type=checkbox]:checked) details,.item-grid.preview:has([type=checkbox]:checked) details,.item-grid.restore:has([type=checkbox]:checked) details{display:none}.item-grid.group .item:has([type=checkbox]:checked),.item-grid.preview .item:has([type=checkbox]:checked),.item-grid.restore .item:has([type=checkbox]:checked){padding:.5rem;background-color:rgba(var(--action-rgb),var(--rgb-medium));opacity:1}.item-grid.group .item:has([type=checkbox]:checked) img,.item-grid.preview .item:has([type=checkbox]:checked) img,.item-grid.restore .item:has([type=checkbox]:checked) img{filter:none}[type=radio].featured+label .star+.star,[type=radio].featured:checked+label .star{display:none}[type=radio].featured+label .star,[type=radio].featured:checked+label .star+.star{display:inline-block}.restore.item,.upload.item{border-radius:var(--innerRadius);overflow:hidden;background:var(--base);border:1px solid var(--base-200)}.restore.item img,.upload.item img{transition:transform var(--transition-base)}.restore.item:hover img,.upload.item:hover img{transform:scale(1.02);transition:transform var(--transition-base)}.upload-group{background-image:var(--dashed-action);padding:5px;border-radius:var(--innerRadius);background-color:rgba(var(--action-rgb),var(--rgb-subtle))}.submit-uploads{position:fixed;bottom:var(--offHeight);right:var(--offHeight);z-index:var(--z-6);height:var(--height);box-shadow:var(--shadow);border-radius:var(--innerRadius);animation:pulse-color 5s infinite;animation-delay:1s;background-color:var(--action-0);color:var(--action-contrast)}.submit-uploads:hover{background-color:var(--base-200);color:var(--contrast-200)}.empty-group{grid-column:1/-1;padding:20px;background-image:var(--dashed-action);border-radius:var(--innerRadius);margin:10px 0;cursor:pointer;transition:all var(--transition-base);text-align:center;background-color:rgba(var(--action-rgb),var(--rgb-subtle))}.group-display:not([hidden])~.file-upload-container{display:none}.dragging,.upload.item.dragging{opacity:.7;transform:scale(.95) rotate(3deg);z-index:var(--z-top);box-shadow:0 8px 25px rgba(0,0,0,.3)}.dragover{background:rgba(var(--action-rgb),var(--rgb-light))!important;border-color:var(--action-0)!important;transform:scale(1.05);animation:drop-pulse .8s infinite ease-in-out}.drag-preview{position:fixed;z-index:var(--zz-top);width:fit-content;overflow:visible;pointer-events:none;opacity:.9;transform:scale(1.05);transition:transform .2s ease}.drag-preview .drag-items{width:max-content;height:max-content;position:relative}.drag-preview .drag-items .drag-item{width:120px;height:120px;position:absolute;top:0;left:0;background:var(--base);border-radius:var(--outerRadius);box-shadow:var(--shadow)}.drag-preview .drag-items .drag-item:nth-child(1){transform:rotate(-3deg);z-index:3}.drag-preview .drag-items .drag-item:nth-child(2){left:8px;top:-4px;transform:rotate(4deg);z-index:2;transition-delay:30ms}.drag-preview .drag-items .drag-item:nth-child(3){left:-6px;top:-8px;transform:rotate(-5deg);z-index:1;transition-delay:60ms}.drag-preview .drag-items .drag-item:nth-child(4){left:12px;top:-12px;transform:rotate(3deg);z-index:0;transition-delay:90ms}.drag-preview .drag-items .drag-item:nth-child(n+5){left:-10px;top:-16px;transform:rotate(-4deg);z-index:0;opacity:.8}.drag-preview .drag-items img,.drag-preview .drag-items video{width:100%;height:100%;object-fit:cover;display:block}.drag-preview .drag-count{position:absolute;top:-8px;right:-8px;background:var(--base-200);color:var(--contrast);border-radius:50%;width:24px;height:24px;display:flex;align-items:center;justify-content:center;font-size:12px;font-weight:700;box-shadow:var(--shadow);z-index:var(--z-3)}.item.dragging{opacity:.5;transform:scale(.95);filter:grayscale(50%);transition:opacity .2s ease,transform .2s ease,filter .2s ease}@keyframes drop-pulse{0%,100%{background-color:rgba(var(--action-rgb),var(--rgb-light));transform:scale(1.02)}50%{background-color:var(rgba(var(--action-rgb),var(--rgb-medium)));transform:scale(1.04)}}.group-actions{display:flex;gap:.25rem}@media (max-width:767px){body:not(.uploading):has(.group-display:not([hidden])){overflow:hidden}body:not(.uploading):has(.group-display:not([hidden])) .qtoggle{z-index:var(--z-1)}.group-display.group-display{position:fixed;top:var(--height);bottom:var(--height);left:0;right:0;max-height:var(--maxHeight);overflow:hidden;z-index:var(--z-6);width:calc(100% - 1rem);height:calc(100% - 1rem);padding:0 0 3rem;--justify:flex-start;--align:flex-start;--gap:0}.group-display::before{content:'';display:block;z-index:-1;top:-.5rem;bottom:-.5rem;left:-.5rem;right:-.5rem;position:absolute;background-color:rgba(var(--base-rgb),var(--rgb-heavy));filter:blur(5px)}.group-display .preview-wrap,.group-display .sidebar{height:50%;overflow:hidden auto;position:relative;padding:.5rem}.group-display .preview-wrap{top:0}.group-display .preview-wrap .selected{display:flex;justify-content:space-between;align-items:center}.group-display .sidebar{bottom:0;flex-wrap:nowrap;overflow:hidden auto;background-color:var(--contrast-200);color:var(--base)}.group-display .sidebar>.hint{color:var(--contrast)}.group-display .sidebar .header{display:none}.group-display .preview-actions{top:0;flex-shrink:0}.group-display .preview-wrap>.hint,.group-display .sidebar>.hint{bottom:0;margin:0;text-align:center}.group-display .preview-actions,.group-display .preview-wrap>.hint,.group-display .sidebar>.hint{position:absolute;left:0;right:0;background-color:rgba(var(--base-rgb),var(--rgb-heavy));z-index:var(--z-3);box-shadow:var(--shadow)}.group-display .item-grid{height:100%;overflow:hidden auto;grid-template-columns:repeat(3,1fr);padding:2rem 0}.group-display .sidebar>.item-grid{grid-template-columns:repeat(1,1fr);gap:1rem;padding:0}.group-display .sidebar .empty-group{order:0;position:sticky;height:fit-content;top:0;z-index:var(--z-3);background-color:rgba(var(--action-rgb),var(--rgb-heavy))}.group-display .sidebar .upload-group{order:1}.group-display .sidebar .empty-group p{margin:0}.group-display .field,.group-display .field label{margin:0;padding:0}.group-display .sidebar h4{margin:.25rem}.group-display .item{width:100%;height:max-content}.submit-uploads{bottom:var(--height);left:0;right:0;width:100%;height:3rem}body.uploading .group-display.group-display{position:relative;top:unset;bottom:unset;right:unset;left:unset}}@media (min-width:768px){.group-display.group-display{--wrap:nowrap;--dir:row;--gap:1rem;--align:flex-start}.group-display .preview-wrap,.group-display .sidebar{--justify:flex-start;max-height:calc(100vh - var(--doubleHeight));overflow:hidden auto}.group-display .preview-wrap,.group-display .sidebar{width:50%}.preview-actions,.preview-wrap .hint{position:sticky;z-index:var(--z-3);box-shadow:var(--shadow);background-color:var(--base);width:100%}.preview-actions{top:0;left:0;right:0}.preview-actions .field{margin:0}.preview-wrap .hint,.sidebar>.hint{bottom:-1rem;padding-bottom:1rem;margin:0;left:0;right:0;text-align:center}}.restore-uploads{position:fixed;top:var(--offHeight);bottom:var(--offHeight);left:1rem;right:1rem;border-radius:var(--outerRadius);padding:1rem;z-index:var(--z-top);box-shadow:var(--shadow);background-color:var(--base-200);overflow:hidden auto}dialog nav.tabs{position:sticky;top:0;background-color:var(--base-50);z-index:var(--z-6);box-shadow:var(--shadow-down);margin-bottom:2rem}.editor-container .ql-toolbar{display:flex;background-color:var(--base-50);justify-content:flex-start;flex-wrap:wrap;padding:.25rem;gap:.5rem 1rem;border-top-left-radius:var(--innerRadius);border-top-right-radius:var(--innerRadius);border-bottom:4px solid var(--base-50)}.ql-toolbar .ql-formats{display:flex;gap:.25rem}.editor-container .ql-container{--padding:1rem;background-color:var(--base);border-bottom-left-radius:var(--innerRadius);border-bottom-right-radius:var(--innerRadius);height:fit-content;padding:2px;border:1px solid var(--base-200)}.editor-container .ql-container .ql-editor{padding:var(--padding);width:calc(100% - (var(--padding) * 2.5));height:calc(100% - (var(--padding) * 2))}.ql-editor img{max-width:50%;height:auto}.ql-clipboard{left:-100000px;height:1px;overflow-y:hidden;position:absolute;top:50%}.ql-hidden{display:none}.ql-tooltip{position:absolute;transform:translateY(10px);background-color:var(--base-100);border:1px solid var(--base);box-shadow:0 0 5px var(--overlay-heavy);color:var(--contrast);padding:5px 12px;white-space:nowrap}[data-type=single] .item-grid{display:flex}.repeater-row details summary::after{margin-left:0}.repeater-row details summary button{margin-left:auto}/*!* Group actions buttons - more visible *!*//*!* Group item grid - distinct from preview grid *!*//*!* Group count hint *!*//*!* ============================================================================*//*!* Base drag preview *!*//*!* Single item drag preview *!*//*!* Multi-item drag preview container *!*//*!* Items being dragged - reduce opacity on originals *!*//*!* Count badge on multi-item preview *!*//*!* ============================================================================*//*!* Ensure progress bar is visible when needed *!*//*!* Progress bar track *!*//*!* Progress bar fill *!*//*!* Progress details - styled for row layout with text and count *!*//*!* Individual item progress - overlay style *!*//*!* Item progress icon and status text *!*//*!* ============================================================================*//*!* Hide uploader when we have uploads *!*//*!* Show group display when we have uploads *!*//*!* ============================================================================*//*!* Selected items - more obvious *!*//*!* Selection checkbox - always visible on hover or when checked *!*//*!* Selection controls - more prominent *!*//*!* ============================================================================*//*!* Smooth dragover animation *!*//*!* ============================================================================*//*!* ============================================================================*//*!* Notification container - fixed overlay *!*//*!* Content card *!*//*!* Message section *!*//*!* Scrollable field list *!*//*!* Item grid for restore preview *!*//*!* Restore item *!*//*!* Checked state *!*//*!* Preview section *!*//*!* Item info *!*//*!* Checkbox controls *!*//*!* Actions section *!*//*!* Selection controls *!*//*!* Action buttons *!*//*!* Restore button - primary action *!*//*!* Scrap cache button - destructive action *!*//*!* Dismiss button - secondary action *!*//*!* Mobile responsive *!*//*!* Animation *!*//*!* Scrollbar styling for restore field list *!*/ |
| | | details.uploader .file-upload-container{margin:1rem 0;max-width:100%}@media (min-width:768px){details.uploader .file-upload-container{margin:1rem var(--mr) 1rem var(--ml);max-width:var(--maxWidth)}}.file-upload-wrapper{border:2px dashed var(--action-0);border-radius:4px;padding:2rem;text-align:center;transition:all .3s ease;background:rgba(var(--action-rgb),var(--rgb-subtle));position:relative;cursor:pointer}.file-upload-wrapper h2{margin:0!important;font-size:var(--large)}.dragover,.file-upload-wrapper:hover{background:rgba(var(--action-rgb),var(--rgb-subtle-hover));border-color:var(--action-0)!important}.file-upload-wrapper input[type=file]{position:absolute;left:0;top:0;width:100%;height:100%;opacity:0;cursor:pointer}.file-upload-text{color:var(--contrast);margin:0;font-family:var(--body)}.file-upload-text strong{color:var(--action-0);text-decoration:underline}.field.upload:has(.upload-item) .file-upload-container{display:none}.field.upload{position:relative}.field.upload:not(.uploading) .progress{display:none}.field.upload .actions{position:absolute;top:0;right:0}.item-grid.group,.item-grid.preview,.item-grid.restore{grid-template-columns:repeat(3,1fr)}.item-grid.group .item,.item-grid.preview .item,.item-grid.restore .item{display:block}.item-grid.group button,.item-grid.preview button,.item-grid.restore button{padding:.25rem .5rem}.item-grid.group button .icon,.item-grid.preview button .icon,.item-grid.restore button .icon{--w:1.1em}.item-grid.group .item .preview>input[type=checkbox]:not(.label-button)+label,.item-grid.preview .item .preview>input[type=checkbox]:not(.label-button)+label,.item-grid.restore .item .preview>input[type=checkbox]:not(.label-button)+label{padding-left:0;margin:0}.item-grid.group .item .preview>input[type=checkbox]+label:before,.item-grid.preview .item .preview>input[type=checkbox]+label:before,.item-grid.restore .item .preview>input[type=checkbox]+label:before{transform:unset;top:.5rem;left:.5rem}.item-grid.group .item .preview>input[type=checkbox]+label::after,.item-grid.preview .item .preview>input[type=checkbox]+label::after,.item-grid.restore .item .preview>input[type=checkbox]+label::after{top:.5rem;left:.75rem;transform:translateY(20%) rotate(45deg)}.item-grid.group .item .item-actions,.item-grid.preview .item .item-actions,.item-grid.restore .item .item-actions{position:absolute;top:0;right:0}.item-grid.group summary,.item-grid.preview summary,.item-grid.restore summary{padding:.5rem}.item-grid.group:has([type=checkbox]:checked),.item-grid.preview:has([type=checkbox]:checked),.item-grid.restore:has([type=checkbox]:checked){padding:1rem;background-color:rgba(var(--contrast-rgb),var(--rgb-subtle))}.item-grid.group:has([type=checkbox]:checked) .item,.item-grid.preview:has([type=checkbox]:checked) .item,.item-grid.restore:has([type=checkbox]:checked) .item{padding:.75rem;opacity:.8}.item-grid.group:has([type=checkbox]:checked) .item img,.item-grid.preview:has([type=checkbox]:checked) .item img,.item-grid.restore:has([type=checkbox]:checked) .item img{filter:var(--filter)}.item-grid.group:has([type=checkbox]:checked) details,.item-grid.preview:has([type=checkbox]:checked) details,.item-grid.restore:has([type=checkbox]:checked) details{display:none}.item-grid.group .item:has([type=checkbox]:checked),.item-grid.preview .item:has([type=checkbox]:checked),.item-grid.restore .item:has([type=checkbox]:checked){padding:.5rem;background-color:rgba(var(--action-rgb),var(--rgb-medium));opacity:1}.item-grid.group .item:has([type=checkbox]:checked) img,.item-grid.preview .item:has([type=checkbox]:checked) img,.item-grid.restore .item:has([type=checkbox]:checked) img{filter:none}[type=radio].featured+label .star+.star,[type=radio].featured:checked+label .star{display:none}[type=radio].featured+label .star,[type=radio].featured:checked+label .star+.star{display:inline-block}.restore.item,.upload.item{border-radius:var(--innerRadius);overflow:hidden;background:var(--base);border:1px solid var(--base-200)}.restore.item img,.upload.item img{transition:transform var(--transition-base)}.restore.item:hover img,.upload.item:hover img{transform:scale(1.02);transition:transform var(--transition-base)}.upload-group{background-image:var(--dashed-action);padding:5px;border-radius:var(--innerRadius);background-color:rgba(var(--action-rgb),var(--rgb-subtle))}.submit-uploads{position:fixed;bottom:var(--offHeight);right:var(--offHeight);z-index:var(--z-6);height:var(--height);box-shadow:var(--shadow);border-radius:var(--innerRadius);animation:pulse-color 5s infinite;animation-delay:1s;background-color:var(--action-0);color:var(--action-contrast)}.submit-uploads:hover{background-color:var(--base-200);color:var(--contrast-200)}.empty-group{grid-column:1/-1;padding:20px;background-image:var(--dashed-action);border-radius:var(--innerRadius);margin:10px 0;cursor:pointer;transition:all var(--transition-base);text-align:center;background-color:rgba(var(--action-rgb),var(--rgb-subtle))}.group-display:not([hidden])~.file-upload-container{display:none}.dragging,.upload.item.dragging{opacity:.7;transform:scale(.95) rotate(3deg);z-index:var(--z-top);box-shadow:0 8px 25px rgba(0,0,0,.3)}.dragover{background:rgba(var(--action-rgb),var(--rgb-light))!important;border-color:var(--action-0)!important;transform:scale(1.05);animation:drop-pulse .8s infinite ease-in-out}.drag-preview{position:fixed;z-index:var(--zz-top);width:fit-content;overflow:visible;pointer-events:none;opacity:.9;transform:scale(1.05);transition:transform .2s ease}.drag-preview .drag-items{width:max-content;height:max-content;position:relative}.drag-preview .drag-items .drag-item{width:120px;height:120px;position:absolute;top:0;left:0;background:var(--base);border-radius:var(--outerRadius);box-shadow:var(--shadow)}.drag-preview .drag-items .drag-item:nth-child(1){transform:rotate(-3deg);z-index:3}.drag-preview .drag-items .drag-item:nth-child(2){left:8px;top:-4px;transform:rotate(4deg);z-index:2;transition-delay:30ms}.drag-preview .drag-items .drag-item:nth-child(3){left:-6px;top:-8px;transform:rotate(-5deg);z-index:1;transition-delay:60ms}.drag-preview .drag-items .drag-item:nth-child(4){left:12px;top:-12px;transform:rotate(3deg);z-index:0;transition-delay:90ms}.drag-preview .drag-items .drag-item:nth-child(n+5){left:-10px;top:-16px;transform:rotate(-4deg);z-index:0;opacity:.8}.drag-preview .drag-items img,.drag-preview .drag-items video{width:100%;height:100%;object-fit:cover;display:block}.drag-preview .drag-count{position:absolute;top:-8px;right:-8px;background:var(--base-200);color:var(--contrast);border-radius:50%;width:24px;height:24px;display:flex;align-items:center;justify-content:center;font-size:12px;font-weight:700;box-shadow:var(--shadow);z-index:var(--z-3)}.item.dragging{opacity:.5;transform:scale(.95);filter:grayscale(50%);transition:opacity .2s ease,transform .2s ease,filter .2s ease}@keyframes drop-pulse{0%,100%{background-color:rgba(var(--action-rgb),var(--rgb-light));transform:scale(1.02)}50%{background-color:var(rgba(var(--action-rgb),var(--rgb-medium)));transform:scale(1.04)}}.group-actions{display:flex;gap:.25rem}@media (max-width:767px){body:not(.uploading):has(.group-display:not([hidden])){overflow:hidden}body:not(.uploading):has(.group-display:not([hidden])) .qtoggle{z-index:var(--z-1)}.group-display.group-display{position:fixed;top:var(--height);bottom:var(--height);left:0;right:0;max-height:var(--maxHeight);overflow:hidden;z-index:var(--z-6);width:calc(100% - 1rem);height:calc(100% - 1rem);padding:0 0 3rem;--justify:flex-start;--align:flex-start;--gap:0}.group-display::before{content:'';display:block;z-index:-1;top:-.5rem;bottom:-.5rem;left:-.5rem;right:-.5rem;position:absolute;background-color:rgba(var(--base-rgb),var(--rgb-heavy));filter:blur(5px)}.group-display .preview-wrap,.group-display .sidebar{height:50%;overflow:hidden auto;position:relative;padding:.5rem}.group-display .preview-wrap{top:0}.group-display .preview-wrap .selected{display:flex;justify-content:space-between;align-items:center}.group-display .sidebar{bottom:0;flex-wrap:nowrap;overflow:hidden auto;background-color:var(--contrast-200);color:var(--base)}.group-display .sidebar>.hint{color:var(--contrast)}.group-display .sidebar .header{display:none}.group-display .preview-actions{top:0;flex-shrink:0}.group-display .preview-wrap>.hint,.group-display .sidebar>.hint{bottom:0;margin:0;text-align:center}.group-display .preview-actions,.group-display .preview-wrap>.hint,.group-display .sidebar>.hint{position:absolute;left:0;right:0;background-color:rgba(var(--base-rgb),var(--rgb-heavy));z-index:var(--z-3);box-shadow:var(--shadow)}.group-display .item-grid{height:100%;overflow:hidden auto;grid-template-columns:repeat(3,1fr);padding:2rem 0}.group-display .sidebar>.item-grid{grid-template-columns:repeat(1,1fr);gap:1rem;padding:0}.group-display .sidebar .empty-group{order:0;position:sticky;height:fit-content;top:0;z-index:var(--z-3);background-color:rgba(var(--action-rgb),var(--rgb-heavy))}.group-display .sidebar .upload-group{order:1}.group-display .sidebar .empty-group p{margin:0}.group-display .field,.group-display .field label{margin:0;padding:0}.group-display .sidebar h4{margin:.25rem}.group-display .item{width:100%;height:max-content}.submit-uploads{bottom:var(--height);left:0;right:0;width:100%;height:3rem}body.uploading .group-display.group-display{position:relative;top:unset;bottom:unset;right:unset;left:unset}}@media (min-width:768px){.group-display.group-display{--wrap:nowrap;--dir:row;--gap:1rem;--align:flex-start}.group-display .preview-wrap,.group-display .sidebar{--justify:flex-start;max-height:calc(100vh - var(--doubleHeight));overflow:hidden auto}.group-display .preview-wrap,.group-display .sidebar{width:50%}.preview-actions,.preview-wrap .hint{position:sticky;z-index:var(--z-3);box-shadow:var(--shadow);background-color:var(--base);width:100%}.preview-actions{top:0;left:0;right:0}.preview-actions .field{margin:0}.preview-wrap .hint,.sidebar>.hint{bottom:-1rem;padding-bottom:1rem;margin:0;left:0;right:0;text-align:center}}.restore-uploads{position:fixed;top:var(--offHeight);bottom:var(--offHeight);left:1rem;right:1rem;border-radius:var(--outerRadius);padding:1rem;z-index:var(--z-top);box-shadow:var(--shadow);background-color:var(--base-200);overflow:hidden auto}dialog nav.tabs{position:sticky;top:0;background-color:var(--base-50);z-index:var(--z-6);box-shadow:var(--shadow-down);margin-bottom:2rem}.editor-container .ql-toolbar{display:flex;background-color:var(--base-50);justify-content:flex-start;flex-wrap:wrap;padding:.25rem;gap:.5rem 1rem;border-top-left-radius:var(--innerRadius);border-top-right-radius:var(--innerRadius);border-bottom:4px solid var(--base-50)}.ql-toolbar .ql-formats{display:flex;gap:.25rem}.editor-container .ql-container{--padding:1rem;background-color:var(--base);border-bottom-left-radius:var(--innerRadius);border-bottom-right-radius:var(--innerRadius);height:fit-content;padding:2px;border:1px solid var(--base-200)}.editor-container .ql-container .ql-editor{padding:var(--padding);width:100%;height:100%}.ql-editor img{max-width:50%;height:auto}.ql-clipboard{left:-100000px;height:1px;overflow-y:hidden;position:absolute;top:50%}.ql-hidden{display:none}.ql-tooltip{position:absolute;transform:translateY(10px);background-color:var(--base-100);border:1px solid var(--base);box-shadow:0 0 5px var(--overlay-heavy);color:var(--contrast);padding:5px 12px;white-space:nowrap}[data-type=single] .item-grid{display:flex}.repeater-row details summary::after{margin-left:0}.repeater-row details summary button{margin-left:auto}/*!* Group actions buttons - more visible *!*//*!* Group item grid - distinct from preview grid *!*//*!* Group count hint *!*//*!* ============================================================================*//*!* Base drag preview *!*//*!* Single item drag preview *!*//*!* Multi-item drag preview container *!*//*!* Items being dragged - reduce opacity on originals *!*//*!* Count badge on multi-item preview *!*//*!* ============================================================================*//*!* Ensure progress bar is visible when needed *!*//*!* Progress bar track *!*//*!* Progress bar fill *!*//*!* Progress details - styled for row layout with text and count *!*//*!* Individual item progress - overlay style *!*//*!* Item progress icon and status text *!*//*!* ============================================================================*//*!* Hide uploader when we have uploads *!*//*!* Show group display when we have uploads *!*//*!* ============================================================================*//*!* Selected items - more obvious *!*//*!* Selection checkbox - always visible on hover or when checked *!*//*!* Selection controls - more prominent *!*//*!* ============================================================================*//*!* Smooth dragover animation *!*//*!* ============================================================================*//*!* ============================================================================*//*!* Notification container - fixed overlay *!*//*!* Content card *!*//*!* Message section *!*//*!* Scrollable field list *!*//*!* Item grid for restore preview *!*//*!* Restore item *!*//*!* Checked state *!*//*!* Preview section *!*//*!* Item info *!*//*!* Checkbox controls *!*//*!* Actions section *!*//*!* Selection controls *!*//*!* Action buttons *!*//*!* Restore button - primary action *!*//*!* Scrap cache button - destructive action *!*//*!* Dismiss button - secondary action *!*//*!* Mobile responsive *!*//*!* Animation *!*//*!* Scrollbar styling for restore field list *!*/form{--step-size:2.5rem}.form-progress{padding:0 1rem}.form-progress .progress{background:var(--base-100);border-radius:var(--innerRadius);padding:1rem}.form-progress .bar{height:6px;background:var(--base-200);border-radius:3px;overflow:hidden;margin-bottom:.5rem}.form-progress .fill{height:100%;background:linear-gradient(90deg,var(--action-0),var(--action-200));width:0%;transition:width .4s ease;border-radius:3px}.form-progress .step-text{font-size:var(--small);font-weight:600;color:var(--contrast-200)}form nav.tabs{position:relative;top:0;left:0;right:0;padding:1rem 0;gap:0;z-index:0}form nav.tabs button{position:relative;background:0 0;border:none;padding:.5rem 1rem .5rem 3rem;z-index:1}form nav.tabs .step-number{width:2.5rem;height:100%;border-radius:50% 0 0 50%;position:absolute;left:0;top:0;background:var(--base-200);color:var(--contrast-50);display:flex;align-items:center;justify-content:center;font-weight:700;font-size:var(--small);border:3px solid var(--base)}form nav.tabs button.pending .step-number{background:var(--base-100);color:var(--contrast-200)}form nav.tabs button.active .step-number,form nav.tabs button.current .step-number{background:var(--action-0);color:var(--action-contrast);border-color:var(--action-200)}form nav.tabs button.completed .step-number{background:var(--successBack);color:var(--successBack);border-color:var(--successText)}form nav.tabs button.completed .step-number::before{content:'✓';font-size:1.2rem;color:var(--successText);position:absolute}form nav.tabs button.completed h2{color:var(--contrast-200)}.step-navigation{margin-top:2rem;padding-top:2rem;border-top:1px solid var(--base-200);gap:1rem}.step-navigation .prev-step{background:var(--base-100)}.step-navigation .next-step,.step-navigation button[type=submit]{margin-left:auto}.field input.error,.field select.error,.field textarea.error{border-color:var(--errorBack)}.error-message{color:var(--errorText);font-size:var(--small);margin-top:.25rem;display:block}@media (max-width:768px){form nav.tabs button{min-width:80px;font-size:var(--small)}form nav.tabs button h2{font-size:var(--small)}form{--step-size:2rem}}.field-input-wrapper{position:relative;display:flex;align-items:center;gap:.5rem}.field-input-wrapper input,.field-input-wrapper select,.field-input-wrapper textarea{flex:1}.validation-icon{display:flex;align-items:center;justify-content:center;font-size:1.25rem;animation:scaleIn .3s ease;--w:1.25rem}.validation-icon.error{color:var(--error)}.validation-icon.success{color:var(--success)}@keyframes scaleIn{from{transform:scale(0);opacity:0}to{transform:scale(1);opacity:1}}.validation-message{color:var(--error-0);font-size:var(--small);margin-top:.25rem;display:block;animation:slideDown .2s ease}@keyframes slideDown{from{opacity:0;transform:translateY(-4px)}to{opacity:1;transform:translateY(0)}}.field.has-error input,.field.has-error select,.field.has-error textarea{border-color:var(--error);background-color:var(--errorBack)}.field.has-error input:focus,.field.has-error select:focus,.field.has-error textarea:focus{outline-color:var(--error);box-shadow:0 0 0 3px rgba(var(--error-rgb),.2)}.field.has-success input,.field.has-success select,.field.has-success textarea{border-color:var(--success)}.field label .required{color:var(--error);margin-left:.25rem}.form-summary{padding:2rem;border-radius:8px;margin-top:2rem;border:2px dashed var(--contrast-200)}.form-summary .message{margin-bottom:2rem}.form-summary .result+.result{position:relative;margin-top:1.5rem;padding-top:1.5rem}.form-summary .result+.result::before{position:absolute;top:0;left:16.5%;content:'';width:67%;height:1px;border-bottom:1px solid var(--base-200)}.form-summary h2{margin:1rem 0}.form-summary h4{background-color:var(--base-100);padding:.5rem 2rem;position:relative;left:-2rem;color:var(--contrast-200);font-size:.875rem;text-transform:uppercase;letter-spacing:.05em;margin-bottom:.75rem}.form-summary p{color:var(--text);margin:0}.group-summary,.repeater-summary{background:var(--base-100);padding:1rem;border-radius:4px;margin-top:.5rem}.repeater-row{margin-bottom:1rem}.repeater-row:last-child{margin-bottom:0} |
| | |
| | | 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 |
| | |
| | | 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 |
| | | */ |
| | |
| | | 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; |
| | | } |
| | | } |
| | |
| | | const formId = formElement.dataset.formId || `form_${Date.now()}`; |
| | | formElement.dataset.formId = formId; |
| | | |
| | | formElement.addEventListener('submit', this.submitHandler); |
| | | |
| | | const formConfig = { |
| | | element: formElement, |
| | | id: formId, |
| | |
| | | |
| | | // 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); |
| | | } |
| | |
| | | |
| | | // 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 |
| | |
| | | } |
| | | |
| | | /** |
| | | * 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) { |
| | |
| | | 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, |
| | |
| | | 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; |
| | | |
| | |
| | | } |
| | | } |
| | | |
| | | 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 |
| | |
| | | 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( |
| | |
| | | } |
| | | |
| | | //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); |
| | |
| | | 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); |
| | | } |
| | | } |
| | | |
| | |
| | | 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; |
| | | } |
| | | |
| | |
| | | |
| | | 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]]; |
| | |
| | | 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 |
| | | */ |
| | |
| | | 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(); |
| | |
| | | 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() { |
| | |
| | | this.notifyRestoredCart(); |
| | | } |
| | | } |
| | | |
| | | handleClick(e) { |
| | | if (window.targetCheck(e, 'button') && window.targetCheck(e, 'div.quantity')) { |
| | | let quantity = window.targetCheck(e, 'div.quantity'); |
| | |
| | | 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'; |
| | | } |
| | |
| | | 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); |
| | | } |
| | |
| | | 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: { |
| | |
| | | borderRadius: '4px' |
| | | }, |
| | | '.input-container.is-focus': { |
| | | borderColor: '#007cba' |
| | | borderColor: '#006AFF', |
| | | borderWidth: '2px', |
| | | outline: '2px solid #006AFF', |
| | | outlineOffset: '2px' |
| | | }, |
| | | '.input-container.is-error': { |
| | | borderColor: '#d63638' |
| | |
| | | } |
| | | |
| | | async handleFormSubmit(event) { |
| | | if (squareConfig.isOpen !== '1') { |
| | | if (!this.isOpen) { |
| | | return; |
| | | } |
| | | event.preventDefault(); |
| | |
| | | } |
| | | |
| | | 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 || '' |
| | | }, |
| | |
| | | |
| | | 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(); |
| | |
| | | /************************************************************** |
| | | * 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', { |
| | |
| | | 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 |
| | |
| | | (()=>{class e{constructor(){this.store=new window.jvbStore({name:"forms",storeName:"forms",keyPath:"formId",indexes:[{name:"status",keyPath:"status"},{name:"operationId",keyPath:"operationId"},{name:"timestamp",keyPath:"timestamp"},{name:"formType",keyPath:"type"}],TTL:6048e5}),this.debouncer=window.debouncer,this.ignore=[],this.populateForm=window.jvbPopulate,this.subscribers=new Set,this.forms=new Map,this.specialFields=new Map,this.dependencies=new Map,this.autoSaveDefaults={delay:3e3,typingDelay:1500,enabled:!0},this.activeRepeaters=new Map,this.repeaterDelays={change:6e3,typing:3e3,blur:1500,add:500,remove:800,reorder:1e3},this.clickHandler=this.handleClick.bind(this),this.changeHandler=this.handleChange.bind(this),this.submitHandler=this.handleSubmit.bind(this),this.inputHandler=this.handleInput.bind(this),this.focusHandler=this.handleFocus.bind(this),this.blurHandler=this.handleBlur.bind(this),this.init()}async init(){await this.checkPendingOperations(),this.store.subscribe(this.handleStoreEvent.bind(this)),this.initListeners()}handleStoreEvent(e,t){if("item-saved"===e)"autosave"===t.item.status&&this.showFormStatus(t.item.formId,"autosave")}async checkPendingOperations(){const e=await this.store.query("status","pending");if(0===e.length)return;const t=this.groupPendingForms(e);this.showPendingNotification(t)}showPendingNotification(e){const t=document.querySelector(`[data-form-id="${e.formId}"]`);if(!t)return;const s=document.createElement("div");s.className="pending-changes-notification",s.innerHTML=`\n\t\t\t<p>We noticed unsaved changes from last time. Would you like to restore them?</p>\n\t\t\t<button class="restore-changes" data-form-id="${e.formId}">Restore</button>\n\t\t\t<button class="discard-changes" data-form-id="${e.formId}">Discard</button>\n\t\t`,t.insertBefore(s,t.firstChild),s.querySelector(".restore-changes").addEventListener("click",(()=>{this.restorePendingForm(e),s.remove()})),s.querySelector(".discard-changes").addEventListener("click",(()=>{this.discardPendingForm(e.formId),s.remove()}))}restorePendingForm(e){const t=document.querySelector(`[data-form-id="${e.formId}"]`);t&&(new this.populateForm(t,e.formData),e.status="restored",this.pendingForms.set(e.formId,e),window.jvbA11y&&window.jvbA11y.announce("Previous changes restored"))}async discardPendingForm(e){this.store.delete(e),window.jvbA11y&&window.jvbA11y.announce("Previous changes discarded")}initListeners(){this.globalHandlersAdded||(document.addEventListener("submit",this.submitHandler),document.addEventListener("click",this.clickHandler),document.addEventListener("change",this.changeHandler),document.addEventListener("focus",this.focusHandler,!0),document.addEventListener("blur",this.blurHandler,!0),this.globalHandlersAdded=!0)}registerForm(e,t={}){const s=e.dataset.formId||`form_${Date.now()}`;e.dataset.formId=s;const a={element:e,id:s,options:{autoSave:!0,saveDelay:this.autoSaveDefaults.delay,endpoint:e.dataset.save,cache:!0,...t},dependencies:new Map,data:this.collectFormData(e),isDirty:!1};if(this.initializeFormFields(e,a),this.forms.set(s,a),this.store&&a.options.cache){const e=this.store.getForm(s);e&&e.formData&&this.showPendingNotification(e)}return a}initializeFormFields(e,t=null){this.initQuillEditors(e),this.initRepeaterFields(e,t),t&&this.initConditionalFields(e,t),this.initCharacterLimits(e),this.initImageUploadFields(e),window.jvbTabs&&e.querySelector("nav.tabs")&&new window.jvbTabs(e),window.jvbSelector&&window.jvbSelector.scanExistingFields()}initQuillEditors(e){window.jvbQuill(e)}initRepeaterFields(e,t){e.querySelectorAll(".repeater").forEach((e=>{const s=e.querySelector(".add-repeater-row"),a=e.querySelector(".repeater-items"),i=e.querySelector("template");s&&i&&a&&(window.Sortable&&new Sortable(a,{handle:".repeater-row-header",animation:150,onEnd:()=>{this.updateRepeaterOrder(e,t)}}),s.addEventListener("click",(()=>{this.addRepeaterRow(e,t)})),a.addEventListener("click",(e=>{e.target.closest(".remove-row")&&this.removeRepeaterRow(e.target.closest(".repeater-row"),t)})))}))}addRepeaterRow(e,t){const s=e.querySelector(".repeater-items"),a=e.querySelector("template"),i=s.children.length,r=e.dataset.field,n=a.content.cloneNode(!0).firstElementChild;n.dataset.index=i,n.querySelectorAll("input, select, textarea").forEach((e=>{const t=e.name;e.name=`${r}:${i}:${t}`,e.id=`${r}-${i}-${t}`;const s=e.nextElementSibling;s&&"LABEL"===s.tagName&&(s.htmlFor=e.id)})),s.appendChild(n),t&&t.options.autoSave&&this.scheduleSave(t,{type:"repeater",action:"add",fieldName:r,delay:this.repeaterDelays.add}),window.jvbA11y&&window.jvbA11y.announce("Row added")}removeRepeaterRow(e,t){const s=e.closest(".repeater"),a=s.dataset.field;e.remove(),this.updateRepeaterOrder(s,t),t&&t.options.autoSave&&this.scheduleSave(t,{type:"repeater",action:"remove",fieldName:a,delay:this.repeaterDelays.remove}),window.jvbA11y&&window.jvbA11y.announce("Row removed")}updateRepeaterOrder(e,t){const s=e.querySelector(".repeater-items"),a=e.dataset.field;Array.from(s.children).forEach(((e,t)=>{e.dataset.index=t,e.querySelectorAll("input, select, textarea").forEach((e=>{const s=e.name.split(":");if(3===s.length){const i=s[2];e.name=`${a}:${t}:${i}`,e.id=`${a}-${t}-${i}`;const r=e.nextElementSibling;r&&"LABEL"===r.tagName&&(r.htmlFor=e.id)}}))})),t&&t.options.autoSave&&this.scheduleSave(t,{type:"repeater",action:"reorder",fieldName:a,delay:this.repeaterDelays.reorder})}initConditionalFields(e,t){e.querySelectorAll("[data-depends-on]").forEach((s=>{const a=s.dataset.dependsOn,i=s.dataset.dependsValue,r=s.dataset.dependsOperator||"==";t.dependencies.has(a)||t.dependencies.set(a,[]),t.dependencies.get(a).push({field:s,requiredValue:i,operator:r}),this.checkFieldDependency(e,s,a,i,r)}))}checkFieldDependency(e,t,s,a,i){const r=e.querySelector(`[name="${s}"]`);if(!r)return;const n=this.getFieldValue(r),o=this.evaluateCondition(n,a,i);this.toggleFieldVisibility(t,o)}evaluateCondition(e,t,s){const a=String(e||""),i=String(t||"");switch(s){case"==":default:return a==i;case"!=":return a!=i;case">":return parseFloat(a)>parseFloat(i);case"<":return parseFloat(a)<parseFloat(i);case">=":return parseFloat(a)>=parseFloat(i);case"<=":return parseFloat(a)<=parseFloat(i);case"contains":return a.includes(i);case"empty":return""===a;case"not_empty":return""!==a}}toggleFieldVisibility(e,t){const s=e.closest(".field, fieldset");s&&(s.hidden=!t,s.querySelectorAll("input, select, textarea").forEach((e=>{e.disabled=!t,!t&&e.hasAttribute("required")?(e.dataset.wasRequired="true",e.removeAttribute("required")):t&&"true"===e.dataset.wasRequired&&(e.setAttribute("required",""),delete e.dataset.wasRequired)})))}initCharacterLimits(e){e.querySelectorAll("[data-limit]").forEach((e=>{const t=parseInt(e.dataset.limit,10),s=e.closest(".field");let a=s?.querySelector(".char-count");!a&&s&&(a=document.createElement("div"),a.className="char-count",a.innerHTML=`<span class="current">0</span> / <span class="limit">${t}</span>`,s.appendChild(a));const i=()=>{const s=e.value.length;a&&(a.querySelector(".current").textContent=s,a.classList.toggle("exceeded",s>t)),s>t&&(e.value=e.value.substring(0,t),a&&(a.querySelector(".current").textContent=t))};e.addEventListener("input",i),i()}))}initImageUploadFields(e){window.jvbUploads.scanFields(e)}handleSubmit(e){if(this.subscribers.size>0){const t=e.target;if(!t.dataset.formId)return;this.store.delete(t.dataset.formId),e.preventDefault();const s=this.forms.get(t.dataset.formId);if(!s)return;const a=this.collectFormData(t);e.preventDefault(),this.notify("form-submit",{formId:s.id,data:a,config:s})}}handleClick(e){if(window.targetCheck(e,"div.quantity")){let t=window.targetCheck(e,"div.quantity");this.handleNumberClick(e,t.querySelector("input"))}}handleNumberClick(e,t){let s=0;if(e.target.closest(".increase")?s+=1:e.target.closest(".decrease")&&(s-=1),0!==s){let a=parseFloat(t.step);a=Math.max(a,1),e.ctrlKey&&e.shiftKey?a*=50:e.ctrlKey?a*=5:e.shiftKey&&(a*=10);let i=""===t.value?0:parseFloat(t.value);t.value=i+a*s,this.handleNumberLimits(t)}}handleNumberLimits(e){let[t,s,a,i]=[e.min,e.max,e.closest(".quantity")?.querySelector(".increase"),e.closest(".quantity")?.querySelector(".decrease")],r=parseFloat(e.value);r<t?(e.value=t,i.disabled=!0):r>s?(e.value=s,a.disabled=!1):a.disabled?a.disabled=!1:i.disabled&&(i.disabled=!1)}handleChange(e){if(this.subscribers.size>0){const t=e.target,s=t.form||t.closest("form");if(!s)return;const a=this.forms?.get(s.dataset.formId);if(!a)return;const i=a.dependencies.get(t.name);if(i&&i.forEach((e=>{this.checkFieldDependency(s,e.field,t.name,e.requiredValue,e.operator)})),a.options.autoSave&&!s.dataset.noautosave){const e=this.getDelayForField(t);this.scheduleSave(a,e)}}}handleFocus(e){const t=e.target;t.matches("input, textarea, select")&&(this.currentFocus=t)}handleBlur(e){const t=e.target,s=t.form||t.closest("form");if(!s)return;const a=this.forms?.get(s.dataset.formId);a&&a.options.autoSave&&!s.dataset.noautosave&&this.scheduleSave(a,{type:"blur",fieldName:t.name,delay:1500})}getDelayForField(e){return"text"===e.type||"textarea"===e.type?this.autoSaveDefaults.typingDelay:["checkbox","radio","select-one","select-multiple"].includes(e.type)?1e3:this.autoSaveDefaults.delay}scheduleSave(e,t=this.autoSaveDefaults.delay){document.addEventListener("input",this.handleInput,{passive:!0});const s=`autosave_${e.id}`;this.debouncer.schedule(s,(()=>this.autosave(e)),t)}handleInput(e){let t=e.target.closest("form[data-id]");t&&this.scheduleSave(this.forms.get(t.dataset.id))}async autosave(e){const t=this.collectFormData(e.element);await this.store.save({formId:e.id,data:t,status:"draft",timestamp:Date.now()}),this.showFormStatus(e.id,"saved");const s=this.getChangedFields(e.data,t);if(0!==Object.keys(s).length){e.data=t,this.forms.set(e.id,e),document.removeEventListener("input",this.handleInput);for(let[e,a]of Object.entries(t))"object"==typeof a&&(s[e]=a);this.notify("form-autosave",{formId:e.id,changes:s,fullData:t,config:e})}}hasUnsavedChanges(e){const t=this.forms.get(e);if(!t)return!1;if(t.operations?.size>0)return!0;const s=this.collectFormData(t.element),a=this.getChangedFields(t.lastSnapshot,s);return Object.keys(a).length>0}showFormStatus(e,t){const s=e.querySelector(".form-status");s&&s.remove();const a=document.createElement("div");a.className=`form-status status-${t}`;a.textContent={saving:"Saving changes...",saved:"Changes saved",error:"Failed to save changes",offline:"Changes will be saved when online"}[t]||t,e.insertBefore(a,e.firstChild),"saved"===t&&setTimeout((()=>a.remove()),3e3)}cleanupSpecialFields(){this.specialFields.forEach((e=>{if("quill"===e.type&&e.instance){const t=e.instance.container.previousSibling;t?.classList.contains("ql-toolbar")&&t.remove()}})),this.uploader?.destroy(),this.specialFields.clear()}collectFormData(e){const t=new FormData(e);let s={};const a={},i={};for(let[r,n]of t.entries()){if(this.ignore.includes(r)||r.endsWith("_temp"))continue;this.getFieldProcessor(r)(r,n,s,a,i,e)}return window.isEmptyObject(i)?this.mergeRepeaterData(s,a):(s=this.mergeRepeaterData(s,a),this.mergePostData(s,i))}getFieldProcessor(e){return e.includes("|")?this.processTableField:e.includes("::")?this.processGroupField:e.includes(":")?this.processRepeaterField:e.includes("[")?this.processLocationField:this.processRegularField}mergeRepeaterData(e,t){return Object.keys(t).forEach((s=>{const a={};Object.keys(t[s]).forEach((e=>{const i=t[s][e];Object.keys(i).length>0&&(a[e]=i)})),e[s]=Object.values(a)})),e}mergePostData(e,t){for(let[t,s]in Object.entries(s))e[t]=s;return e}processTableField(e,t,s,a,i,r){let[n,o]=e.split("|");!n in i&&(i[n]={});this.getFieldProcessor(o)(o,t,i,a,i,r)}processRepeaterField(e,t,s,a,i,r){let[n,o,d]=e.split(":");const l=d.endsWith("[]");d=d.replace("[]",""),a[n]||(a[n]={}),a[n][o]||(a[n][o]={}),l||a[n][o][d]?(a[n][o][d]?Array.isArray(a[n][o][d])||(a[n][o][d]=[a[n][o][d]]):a[n][o][d]=[],a[n][o][d].push(t)):a[n][o][d]=t}processGroupField(e,t,s,a,i,r){const n=e.split("::"),o=n[0];s[o]||(s[o]={});let d=s[o];for(let e=1;e<n.length-1;e++){const t=n[e];d[t]||(d[t]={}),d=d[t]}const l=n[n.length-1];void 0!==d[l]?(Array.isArray(d[l])||(d[l]=[d[l]]),d[l].push(t)):d[l]=t}processLocationField(e,t,s,a,i,r){let[n,o]=e.split("[");o=o.replace("]",""),Object.hasOwn(s,n)||(s[n]={},Object.hasOwn(s,"sendAll")?s.sendAll.includes(n)||s.sendAll.push(n):s.sendAll=[n]),s[n][o]=t}processRegularField(e,t,s,a,i,r){s[e]?(Array.isArray(s[e])||(s[e]=[s[e]]),s[e].push(t)):s[e]=t}getFieldValue(e){if(!e)return"";if("checkbox"===e.type)return e.checked?e.value||"1":"";if("radio"===e.type){const t=e.form.querySelector(`[name="${e.name}"]:checked`);return t?t.value:""}return"select-multiple"===e.type?Array.from(e.selectedOptions).map((e=>e.value)):e.value}getChangedFields(e,t){return window.getDifferences?.map(e,t)||{}}subscribe(e){return this.subscribers.add(e),()=>this.subscribers.delete(e)}notify(e,t){this.subscribers.forEach((s=>s(e,t)))}cleanupForm(e){const t=this.forms.get(e);t&&(this.hasUnsavedChanges(e)&&this.autosave(t),this.cleanupSpecialFields(),this.forms.delete(e))}destroy(){this.globalHandlersAdded&&(document.removeEventListener("submit",this.submitHandler),document.removeEventListener("change",this.changeHandler),document.removeEventListener("focus",this.focusHandler,!0),document.removeEventListener("blur",this.blurHandler,!0)),this.specialFields.clear(),this.forms.clear(),this.activeRepeaters.clear(),this.forms&&this.forms.clear()}}document.addEventListener("DOMContentLoaded",(()=>{window.jvbForm=e}))})(); |
| | | (()=>{class e{constructor(){this.store=new window.jvbStore({name:"forms",storeName:"forms",keyPath:"formId",indexes:[{name:"status",keyPath:"status"},{name:"operationId",keyPath:"operationId"},{name:"timestamp",keyPath:"timestamp"},{name:"formType",keyPath:"type"}],TTL:6048e5}),this.debouncer=window.debouncer,this.ignore=[],this.populateForm=window.jvbPopulate,this.subscribers=new Set,this.forms=new Map,this.specialFields=new Map,this.dependencies=new Map,this.validators=this.initValidators(),this.touchedFields=new Set,this.autoSaveDefaults={delay:3e3,typingDelay:1500,enabled:!0},this.activeRepeaters=new Map,this.repeaterDelays={change:6e3,typing:3e3,blur:1500,add:500,remove:800,reorder:1e3},this.clickHandler=this.handleClick.bind(this),this.changeHandler=this.handleChange.bind(this),this.submitHandler=this.handleSubmit.bind(this),this.inputHandler=this.handleInput.bind(this),this.focusHandler=this.handleFocus.bind(this),this.blurHandler=this.handleBlur.bind(this),this.init()}async init(){await this.checkPendingOperations(),this.store.subscribe(this.handleStoreEvent.bind(this)),this.initListeners()}handleStoreEvent(e,t){switch(e){case"item-saved":t.item.status;break;case"data-loaded":this.checkPendingForms()}}async checkPendingForms(){(await this.store.query("status","draft")).forEach((e=>{let t=this.forms.get(e.formId);t&&t.element&&(t.element.querySelector(".restore-form").hidden=!1,new this.populateForm(t.element,e.data))}))}async checkPendingOperations(){const e=await this.store.query("status","pending");if(0===e.length)return;const t=this.groupPendingForms(e);this.showPendingNotification(t)}showPendingNotification(e){const t=document.querySelector(`[data-form-id="${e.formId}"]`);if(!t)return;const r=document.createElement("div");r.className="pending-changes-notification",r.innerHTML=`\n\t\t\t<p>We noticed unsaved changes from last time. Would you like to restore them?</p>\n\t\t\t<button class="restore-changes" data-form-id="${e.formId}">Restore</button>\n\t\t\t<button class="discard-changes" data-form-id="${e.formId}">Discard</button>\n\t\t`,t.insertBefore(r,t.firstChild),r.querySelector(".restore-changes").addEventListener("click",(()=>{this.restorePendingForm(e),r.remove()})),r.querySelector(".discard-changes").addEventListener("click",(()=>{this.discardPendingForm(e.formId),r.remove()}))}restorePendingForm(e){const t=document.querySelector(`[data-form-id="${e.formId}"]`);t&&(new this.populateForm(t,e.formData),e.status="restored",this.pendingForms.set(e.formId,e),window.jvbA11y&&window.jvbA11y.announce("Previous changes restored"))}async discardPendingForm(e){this.store.delete(e),window.jvbA11y&&window.jvbA11y.announce("Previous changes discarded")}initListeners(){this.globalHandlersAdded||(document.addEventListener("click",this.clickHandler),document.addEventListener("change",this.changeHandler),document.addEventListener("focus",this.focusHandler,!0),document.addEventListener("blur",this.blurHandler,!0),document.addEventListener("input",this.inputHandler),this.globalHandlersAdded=!0)}registerForm(e,t={}){const r=e.dataset.formId||`form_${Date.now()}`;e.dataset.formId=r,e.addEventListener("submit",this.submitHandler);const s={element:e,id:r,options:{autoSave:!0,saveDelay:this.autoSaveDefaults.delay,endpoint:e.dataset.save,cache:!0,...t},dependencies:new Map,data:this.collectFormData(e),isDirty:!1};if(this.initializeFormFields(e,s),this.forms.set(r,s),this.store&&s.options.cache){const e=this.store.get(r);e&&e.formData&&this.showPendingNotification(e)}return s}initializeFormFields(e,t=null){this.initQuillEditors(e),this.initRepeaterFields(e,t),t&&this.initConditionalFields(e,t),this.initCharacterLimits(e),this.initImageUploadFields(e),window.jvbTabs&&e.querySelector("nav.tabs")&&(t.tabs=new window.jvbTabs(e),this.forms.set(t.formId,t),this.initSteppedForm(t.formId)),window.jvbSelector&&window.jvbSelector.scanExistingFields()}initSteppedForm(e){const t=this.forms.get(e),r=t.element,s=t.tabs,a=r.querySelectorAll(".tab-content").length,i=r.querySelector(".form-progress .fill"),n=r.querySelector(".step-text .current"),o=r.querySelectorAll("nav.tabs button"),l=e=>{const t=e/a*100;i&&(i.style.width=t+"%"),n&&(n.textContent=e),o.forEach(((t,r)=>{const s=r+1;t.classList.remove("current","completed","pending"),s<e?t.classList.add("completed"):s===e?t.classList.add("current"):t.classList.add("pending")}))};r.addEventListener("click",(e=>{const t=e.target.closest('[data-action="next-step"]'),a=e.target.closest('[data-action="prev-step"]');if(t){e.preventDefault();const a=t.closest(".tab-content"),i=parseInt(a.dataset.step),n=r.querySelector(`.tab-content[data-step="${i+1}"]`);if(n&&this.validateStep(a)){const e=n.dataset.tab;s.switchTab(e,!0),l(i+1),r.scrollIntoView({behavior:"smooth",block:"start"})}}if(a){e.preventDefault();const t=a.closest(".tab-content"),i=parseInt(t.dataset.step),n=r.querySelector(`.tab-content[data-step="${i-1}"]`);if(n){const e=n.dataset.tab;s.switchTab(e,!0),l(i-1),r.scrollIntoView({behavior:"smooth",block:"start"})}}}));const c=s.switchTab.bind(s);s.switchTab=(e,t)=>{c(e,t);const s=r.querySelector(`.tab-content[data-tab="${e}"]`);if(s){const e=parseInt(s.dataset.step);l(e)}},l(1)}validateStep(e){const t=e.querySelectorAll(".field");let r=!0;return t.forEach((e=>{const t=e.querySelector("input, textarea, select");if(t&&!t.closest("[hidden]")){this.validateField(t,e)||(r=!1)}})),r}initQuillEditors(e){window.jvbQuill(e)}initRepeaterFields(e,t){e.querySelectorAll(".repeater").forEach((e=>{const r=e.querySelector(".add-repeater-row"),s=e.querySelector(".repeater-items"),a=e.querySelector("template");r&&a&&s&&(window.Sortable&&new Sortable(s,{handle:".repeater-row-header",animation:150,onEnd:()=>{this.updateRepeaterOrder(e,t)}}),r.addEventListener("click",(()=>{this.addRepeaterRow(e,t)})),s.addEventListener("click",(e=>{e.target.closest(".remove-row")&&this.removeRepeaterRow(e.target.closest(".repeater-row"),t)})))}))}addRepeaterRow(e,t){const r=e.querySelector(".repeater-items"),s=e.querySelector("template"),a=r.children.length,i=e.dataset.field,n=s.content.cloneNode(!0).firstElementChild;n.dataset.index=a,n.querySelectorAll("input, select, textarea").forEach((e=>{const t=e.name;e.name=`${i}:${a}:${t}`,e.id=`${i}-${a}-${t}`;const r=e.nextElementSibling;r&&"LABEL"===r.tagName&&(r.htmlFor=e.id)})),r.appendChild(n),t&&t.options.autoSave&&this.scheduleSave(t,{type:"repeater",action:"add",fieldName:i,delay:this.repeaterDelays.add}),window.jvbA11y&&window.jvbA11y.announce("Row added")}removeRepeaterRow(e,t){const r=e.closest(".repeater"),s=r.dataset.field;e.remove(),this.updateRepeaterOrder(r,t),t&&t.options.autoSave&&this.scheduleSave(t,{type:"repeater",action:"remove",fieldName:s,delay:this.repeaterDelays.remove}),window.jvbA11y&&window.jvbA11y.announce("Row removed")}updateRepeaterOrder(e,t){const r=e.querySelector(".repeater-items"),s=e.dataset.field;Array.from(r.children).forEach(((e,t)=>{e.dataset.index=t,e.querySelectorAll("input, select, textarea").forEach((e=>{const r=e.name.split(":");if(3===r.length){const a=r[2];e.name=`${s}:${t}:${a}`,e.id=`${s}-${t}-${a}`;const i=e.nextElementSibling;i&&"LABEL"===i.tagName&&(i.htmlFor=e.id)}}))})),t&&t.options.autoSave&&this.scheduleSave(t,{type:"repeater",action:"reorder",fieldName:s,delay:this.repeaterDelays.reorder})}initConditionalFields(e,t){e.querySelectorAll("[data-depends-on]").forEach((r=>{const s=r.dataset.dependsOn,a=r.dataset.dependsValue,i=r.dataset.dependsOperator||"==";t.dependencies.has(s)||t.dependencies.set(s,[]),t.dependencies.get(s).push({field:r,requiredValue:a,operator:i}),this.checkFieldDependency(e,r,s,a,i)}))}checkFieldDependency(e,t,r,s,a){const i=e.querySelector(`[name="${r}"]`);if(!i)return;const n=this.getFieldValue(i),o=this.evaluateCondition(n,s,a);this.toggleFieldVisibility(t,o)}evaluateCondition(e,t,r){const s=String(e||""),a=String(t||"");switch(r){case"==":default:return s==a;case"!=":return s!=a;case">":return parseFloat(s)>parseFloat(a);case"<":return parseFloat(s)<parseFloat(a);case">=":return parseFloat(s)>=parseFloat(a);case"<=":return parseFloat(s)<=parseFloat(a);case"contains":return s.includes(a);case"empty":return""===s;case"not_empty":return""!==s}}toggleFieldVisibility(e,t){const r=e.closest(".field, fieldset");r&&(r.hidden=!t,r.querySelectorAll("input, select, textarea").forEach((e=>{e.disabled=!t,!t&&e.hasAttribute("required")?(e.dataset.wasRequired="true",e.removeAttribute("required")):t&&"true"===e.dataset.wasRequired&&(e.setAttribute("required",""),delete e.dataset.wasRequired)})))}initCharacterLimits(e){e.querySelectorAll("[data-limit]").forEach((e=>{const t=parseInt(e.dataset.limit,10),r=e.closest(".field");let s=r?.querySelector(".char-count");!s&&r&&(s=document.createElement("div"),s.className="char-count",s.innerHTML=`<span class="current">0</span> / <span class="limit">${t}</span>`,r.appendChild(s));const a=()=>{const r=e.value.length;s&&(s.querySelector(".current").textContent=r,s.classList.toggle("exceeded",r>t)),r>t&&(e.value=e.value.substring(0,t),s&&(s.querySelector(".current").textContent=t))};e.addEventListener("input",a),a()}))}initImageUploadFields(e){window.jvbUploads.scanFields(e)}handleSubmit(e){if(this.subscribers.size>0){const t=e.target;if(!t.dataset.formId)return;e.preventDefault();const r=this.forms.get(t.dataset.formId);if(!r)return;const s=this.collectFormData(t);this.notify("form-submit",{formId:r.id,data:s,config:r})}}handleClick(e){if(window.targetCheck(e,"div.quantity")){let t=window.targetCheck(e,"div.quantity");this.handleNumberClick(e,t.querySelector("input"))}else if(window.targetCheck(e,"[data-action]")){let t=window.targetCheck(e,"[data-action]");switch(t=t.dataset.action,t){case"clear-form":let t=e.target.closest("form");this.store.delete(t.dataset.formId),t?.reset(),e.target.closest(".restore-form").hidden=!0;break;case"dismiss-restore":e.target.closest(".restore-form").hidden=!0}}}handleNumberClick(e,t){let r=0;if(e.target.closest(".increase")?r+=1:e.target.closest(".decrease")&&(r-=1),0!==r){let s=parseFloat(t.step);s=Math.max(s,1),e.ctrlKey&&e.shiftKey?s*=50:e.ctrlKey?s*=5:e.shiftKey&&(s*=10);let a=""===t.value?0:parseFloat(t.value);t.value=a+s*r,this.handleNumberLimits(t)}}handleNumberLimits(e){let[t,r,s,a]=[e.min,e.max,e.closest(".quantity")?.querySelector(".increase"),e.closest(".quantity")?.querySelector(".decrease")],i=parseFloat(e.value);i<t?(e.value=t,a.disabled=!0):i>r?(e.value=r,s.disabled=!1):s.disabled?s.disabled=!1:a.disabled&&(a.disabled=!1)}handleChange(e){if(this.subscribers.size>0){const t=e.target,r=t.form||t.closest("form");if(!r)return;const s=this.forms?.get(r.dataset.formId);if(!s)return;const a=s.dependencies.get(t.name);if(a&&a.forEach((e=>{this.checkFieldDependency(r,e.field,t.name,e.requiredValue,e.operator)})),s.options.autoSave&&!r.dataset.noautosave){const e=this.getDelayForField(t);this.scheduleSave(s,e)}}}handleFocus(e){const t=e.target;t.matches("input, textarea, select")&&(this.currentFocus=t)}handleBlur(e){const t=e.target,r=t.form||t.closest("form");if(!r)return;const s=e.target.closest("input, textarea, select");if(s){const e=this.findFieldWrapper(s);if(e){const t=e.dataset.field;t&&(this.shouldDebounce(s)&&window.debouncer.cancel(`validate_${t}`),this.touchedFields.add(t)),this.validateField(s,e)}const a=this.forms?.get(r.dataset.formId);a&&a.options.autoSave&&!r.dataset.noautosave&&this.scheduleSave(a,{type:"blur",fieldName:t.name,delay:1500})}}handleInput(e){const t=e.target.closest("input, textarea, select");if(!t)return;let r=t.closest("form");this.showFormStatus(r.dataset.formId,"pending");const s=this.findFieldWrapper(t);if(!s)return;const a=s.dataset.field;a&&this.touchedFields.add(a),this.shouldDebounce(t)&&window.debouncer.schedule(`validate_${a}`,((e,t)=>this.validateField.bind(this)),500)}initValidators(){return{email:{pattern:/^[^\s@]+@[^\s@]+\.[^\s@]+$/,message:"Please enter a valid email address"},url:{pattern:/^https?:\/\/.+\..+/,message:"Please enter a valid URL starting with http:// or https://"},phone:{pattern:/^[\d\s\-\+\(\)\.]+$/,message:"Please enter a valid phone number"},number:{test:(e,t)=>{const r=parseFloat(e);if(isNaN(r))return"Please enter a valid number";const s=t.dataset.min,a=t.dataset.max;return void 0!==s&&r<parseFloat(s)?`Value must be at least ${s}`:!(void 0!==a&&r>parseFloat(a))||`Value must be at most ${a}`}},text:{test:(e,t)=>{const r=t.dataset.minlength,s=t.dataset.maxlength;return r&&e.length<parseInt(r)?`Must be at least ${r} characters`:!(s&&e.length>parseInt(s))||`Must be no more than ${s} characters`}}}}findFieldWrapper(e){let t=e.closest(".field");return t||(t=e.closest("[data-field]")),t}shouldDebounce(e){return["text","email","url","tel","search"].includes(e.type)||"TEXTAREA"===e.tagName}validateField(e,t){const r=this.getFieldValue(e),s=t.dataset.field;if(!this.touchedFields.has(s)&&!e.required)return!0;if(!r&&!e.required)return this.clearValidation(t),!0;if(e.required&&!r)return this.showError(t,"This field is required"),!1;if(e.checkValidity&&!e.checkValidity())return this.showError(t,e.validationMessage),!1;const a=t.dataset.pattern;if(a&&r){if(!new RegExp(a).test(r)){const e=t.dataset.validationMessage||"Invalid format";return this.showError(t,e),!1}}const i=t.dataset.validate||e.type;if(i&&this.validators[i]){const e=this.validators[i];if(e.pattern&&!e.pattern.test(r))return this.showError(t,e.message),!1;if(e.test){const s=e.test(r,t);if(!0!==s)return this.showError(t,s),!1}}return this.showSuccess(t),!0}getFieldValue(e){if(!e)return"";if("checkbox"===e.type)return e.checked?e.value||"1":"";if("radio"===e.type){const t=e.form?.querySelector(`[name="${e.name}"]:checked`);return t?t.value:""}return"select-multiple"===e.type?Array.from(e.selectedOptions).map((e=>e.value)):e.value?.trim()||""}showSuccess(e){if(!e)return;const t=e.querySelector(".validation-icon.success"),r=e.querySelector(".validation-icon.error"),s=e.querySelector(".validation-message"),a=e.querySelector("input, textarea, select");e.classList.remove("has-error"),a?.classList.remove("error"),e.classList.add("has-success"),t&&(t.hidden=!1),r&&(r.hidden=!0),s&&(s.hidden=!0,s.textContent="")}showError(e,t){if(!e)return;const r=e.querySelector(".validation-icon.success"),s=e.querySelector(".validation-icon.error"),a=e.querySelector(".validation-message"),i=e.querySelector("input, textarea, select");e.classList.remove("has-success"),e.classList.add("has-error"),i?.classList.add("error"),r&&(r.hidden=!0),s&&(s.hidden=!1),a&&(a.hidden=!1,a.textContent=t)}clearValidation(e){if(!e)return;const t=e.querySelector(".validation-icon"),r=e.querySelector(".validation-message"),s=e.querySelector("input, textarea, select");e.classList.remove("has-error","has-success"),s?.classList.remove("error"),t&&(t.hidden=!0),r&&(r.hidden=!0,r.textContent="")}validateAllFields(e){if(!e)return!0;const t=e.querySelectorAll(".field:not([hidden])");let r=!0;return t.forEach((e=>{if(this.isComplexFieldWrapper(e))return;const t=e.querySelector('input:not([type="hidden"]), textarea, select');if(t&&!t.closest("[hidden]")){const s=e.dataset.field;s&&this.touchedFields.add(s);this.validateField(t,e)||(r=!1,!1===r&&(t.scrollIntoView({behavior:"smooth",block:"center"}),t.focus()))}})),r}isComplexFieldWrapper(e){return e.classList.contains("repeater")||e.classList.contains("group")||e.classList.contains("upload")}attachRepeaterValidation(e){e.addEventListener("click",(t=>{t.target.closest(".add-repeater-row")&&setTimeout((()=>{e.querySelectorAll(".repeater-row").forEach((e=>{e.querySelectorAll("input, textarea, select").forEach((e=>{const t=this.findFieldWrapper(e);t&&this.clearValidation(t)}))}))}),100)}))}attachGroupValidation(e){e.addEventListener("change",(t=>{const r=t.target.closest("input, select");if(!r)return;const s=r.name;if(!s)return;e.querySelectorAll(`[data-show-if*="${s}"]`).forEach((e=>{e.hidden&&this.clearValidation(e)}))}))}resetForm(e){if(!e)return;this.touchedFields.clear();e.querySelectorAll(".field").forEach((e=>{this.clearValidation(e)}))}getFormErrors(e){const t={};return e.querySelectorAll(".field.has-error").forEach((e=>{const r=e.dataset.field,s=e.querySelector(".validation-message");r&&s&&(t[r]=s.textContent)})),t}addValidator(e,t){this.validators[e]=t}getDelayForField(e){return"text"===e.type||"textarea"===e.type?this.autoSaveDefaults.typingDelay:["checkbox","radio","select-one","select-multiple"].includes(e.type)?1e3:this.autoSaveDefaults.delay}scheduleSave(e,t=this.autoSaveDefaults.delay){document.addEventListener("input",this.saveCheck,{passive:!0});const r=`autosave_${e.id}`;this.debouncer.schedule(r,(()=>this.autosave(e)),t)}saveCheck(e){let t=e.target.closest("form[data-id]");t&&this.scheduleSave(this.forms.get(t.dataset.id))}async autosave(e){const t=this.collectFormData(e.element);this.showFormStatus(e.id,"saving"),await this.store.save({formId:e.id,data:t,status:"draft",timestamp:Date.now()}).then((()=>{this.showFormStatus(e.id,"autosaved")}));const r=this.getChangedFields(e.data,t);if(0!==Object.keys(r).length){e.data=t,this.forms.set(e.id,e),document.removeEventListener("input",this.handleInput);for(let[e,s]of Object.entries(t))"object"==typeof s&&(r[e]=s);this.notify("form-autosave",{formId:e.id,changes:r,fullData:t,config:e})}}hasUnsavedChanges(e){const t=this.forms.get(e);if(!t)return!1;if(t.operations?.size>0)return!0;const r=this.collectFormData(t.element),s=this.getChangedFields(t.lastSnapshot,r);return Object.keys(s).length>0}showFormStatus(e,t){let r=this.forms.get(e);console.log("Setting status: ",t);const s=r.element.querySelector(".fstatus");s.hidden=!1;const a=s.querySelector(".message");a.textContent="",s.querySelector(".icon")?.remove();const i={saving:"Saving changes...",autosaved:"Changes saved locally. Submit form to send to server.",uploading:"Uploading your form to server",submitted:"Successfully sent to server",pending:"Unsaved changes",error:"Failed to save changes. Refresh and try again?",offline:"Changes will be saved when online"},n={autosaved:"check",submitted:"check",error:"close",offline:"cloud-slash",pending:"exclamation-mark"};let o=window.getIcon(n[t]);o&&s.prepend(o),console.log(t,i[t]),console.log(t,n[t]),a.textContent=i[t]||t,s.classList.toggle("loading",["uploading","saving"].includes(t)),"submitted"===t&&setTimeout((()=>s.hidden=!0),3e3)}cleanupSpecialFields(){this.specialFields.forEach((e=>{if("quill"===e.type&&e.instance){const t=e.instance.container.previousSibling;t?.classList.contains("ql-toolbar")&&t.remove()}})),this.uploader?.destroy(),this.specialFields.clear()}collectFormData(e){const t=new FormData(e);let r={};const s={},a={};for(let[i,n]of t.entries()){if(this.ignore.includes(i)||i.endsWith("_temp"))continue;this.getFieldProcessor(i)(i,n,r,s,a,e)}return window.isEmptyObject(a)?this.mergeRepeaterData(r,s):(r=this.mergeRepeaterData(r,s),this.mergePostData(r,a))}getFieldProcessor(e){return e.includes("|")?this.processTableField:e.includes("::")?this.processGroupField:e.includes(":")?this.processRepeaterField:/\[[^\]]+\]/.test(e)?this.processLocationField:this.processRegularField}mergeRepeaterData(e,t){return Object.keys(t).forEach((r=>{const s={};Object.keys(t[r]).forEach((e=>{const a=t[r][e];Object.keys(a).length>0&&(s[e]=a)})),e[r]=Object.values(s)})),e}mergePostData(e,t){for(let[t,r]in Object.entries(r))e[t]=r;return e}processTableField(e,t,r,s,a,i){let[n,o]=e.split("|");!n in a&&(a[n]={});this.getFieldProcessor(o)(o,t,a,s,a,i)}processRepeaterField(e,t,r,s,a,i){let[n,o,l]=e.split(":");const c=l.endsWith("[]");l=l.replace("[]",""),s[n]||(s[n]={}),s[n][o]||(s[n][o]={}),c||s[n][o][l]?(s[n][o][l]?Array.isArray(s[n][o][l])||(s[n][o][l]=[s[n][o][l]]):s[n][o][l]=[],s[n][o][l].push(t)):s[n][o][l]=t}processGroupField(e,t,r,s,a,i){const n=e.split("::"),o=n[0];r[o]||(r[o]={});let l=r[o];for(let e=1;e<n.length-1;e++){const t=n[e];l[t]||(l[t]={}),l=l[t]}const c=n[n.length-1];void 0!==l[c]?(Array.isArray(l[c])||(l[c]=[l[c]]),l[c].push(t)):l[c]=t}processLocationField(e,t,r,s,a,i){let[n,o]=e.split("[");o=o.replace("]",""),Object.hasOwn(r,n)||(r[n]={},Object.hasOwn(r,"sendAll")?r.sendAll.includes(n)||r.sendAll.push(n):r.sendAll=[n]),r[n][o]=t}processRegularField(e,t,r,s,a,i){r[e=e.replace("[]","")]?(Array.isArray(r[e])||(r[e]=[r[e]]),r[e].push(t)):r[e]=t}getFieldValue(e){if(!e)return"";if("checkbox"===e.type)return e.checked?e.value||"1":"";if("radio"===e.type){const t=e.form.querySelector(`[name="${e.name}"]:checked`);return t?t.value:""}return"select-multiple"===e.type?Array.from(e.selectedOptions).map((e=>e.value)):e.value}getChangedFields(e,t){return window.getDifferences?.map(e,t)||{}}showSummary(e,t="form"){const r=this.forms.get(e);if(!r)return;const s=r.element||document.querySelector(`[data-form-id="${e}"]`),a=window.getTemplate("formSummary"),[i,n,o]=[a.querySelector("h2"),a.querySelector(".summary"),a.querySelector(".result")],l=["sendAll",...this.ignore];for(const[e,t]of Object.entries(r.data)){if(l.includes(e)||this.isEmptyValue(t))continue;const r=this.getFieldInfo(s,e);if(!r.label)continue;const a=this.createResultElement(o,r,t,s);a&&n.appendChild(a)}o.remove(),(t="form"!==t?s.closest(t)??s:s).after(a),window.fade(t,!1)}isEmptyValue(e){return null==e||""===e||(!(!Array.isArray(e)||0!==e.length)||"object"==typeof e&&0===Object.keys(e).length)}getFieldInfo(e,t){let r=e.querySelector(`label[for="${t}"]`),s=null,a=null;if(s||(s=e.querySelector(`[name="${t}"]`)),s||(s=e.querySelector(`[name="${t}[]"]`)),!s){const a=e.querySelector(`fieldset[data-field="${t}"]`);a&&(r=a.querySelector("legend"),s=a.querySelector("input, select, textarea"))}if(!r&&s){const e=s.closest(".field, fieldset");e&&(r=e.querySelector("label, legend"))}a=e.querySelector(`.field[data-field="${t}"], fieldset[data-field="${t}"]`);let i="text";return a?.dataset.type?i=a.dataset.type:s&&(i="checkbox"===s.type&&s.name.endsWith("[]")?"checkbox":"checkbox"===s.type?"true_false":"SELECT"===s.tagName&&s.multiple?"select":s.type||"text"),{label:r?.textContent.replace("*","").trim()||null,type:i,wrapper:a,input:s}}createResultElement(e,t,r,s){const a=e.cloneNode(!0),i=a.querySelector("h4"),n=a.querySelector("p");i.textContent=t.label;const o=this.formatFieldValue(r,t.type,s);return this.isHtmlContent(o)?n.innerHTML=o:n.textContent=o,a}isHtmlContent(e){return"string"==typeof e&&(e.includes("<br>")||e.includes("<p>")||e.includes("<ul>")||e.includes("<ol>")||e.includes("<a ")||e.includes("<strong>")||e.includes("<em>")||e.includes("<div"))}formatFieldValue(e,t,r){switch(t){case"textarea":case"wysiwyg":return this.formatTextareaValue(e,t);case"true_false":return"1"===e||1===e||!0===e?"Yes":"No";case"checkbox":return Array.isArray(e)?this.formatArrayValue(e):"1"===e||1===e||!0===e?"Yes":"No";case"select":return Array.isArray(e)?this.formatArrayValue(e):this.getSelectLabel(e,r,t);case"date":case"datetime":case"time":return window.formatDate?window.formatDate(e):e;case"radio":return this.getSelectLabel(e,r,t);case"repeater":return this.formatRepeaterValue(e);case"group":return this.formatGroupValue(e);case"location":return this.formatLocationValue(e);case"file":case"image":return this.formatFileValue(e);case"number":return this.formatNumber(e);case"email":return`<a href="mailto:${e}">${e}</a>`;case"url":return`<a href="${e}" target="_blank" rel="noopener">${e}</a>`;case"phone":return`<a href="tel:${e.replace(/\D/g,"")}">${e}</a>`;default:return Array.isArray(e)?this.formatArrayValue(e):e}}formatRepeaterValue(e){if(!Array.isArray(e)||0===e.length)return"<em>No entries</em>";let t='<div class="repeater-summary">';return e.forEach(((e,r)=>{t+='<div class="repeater-row">',t+=`<strong>Entry ${r+1}:</strong><ul>`;for(const[r,s]of Object.entries(e))if(!this.isEmptyValue(s)){const e=r.replace(/_/g," ").replace(/\b\w/g,(e=>e.toUpperCase()));t+=`<li><strong>${e}:</strong> ${s}</li>`}t+="</ul></div>"})),t+="</div>",t}formatGroupValue(e){if("object"!=typeof e||0===Object.keys(e).length)return"<em>No data</em>";let t='<div class="group-summary"><ul>';for(const[r,s]of Object.entries(e))if(!this.isEmptyValue(s)){const e=r.replace(/_/g," ").replace(/\b\w/g,(e=>e.toUpperCase()));"object"!=typeof s||Array.isArray(s)?t+=`<li><strong>${e}:</strong> ${s}</li>`:t+=`<li><strong>${e}:</strong> ${this.formatGroupValue(s)}</li>`}return t+="</ul></div>",t}formatLocationValue(e){if("object"!=typeof e)return e;const t=[];return["address","city","state","zip","country"].forEach((r=>{e[r]&&t.push(e[r])})),t.join(", ")}formatFileValue(e){return"string"==typeof e?e.startsWith("http")?`<a href="${e}" target="_blank">View file</a>`:e:Array.isArray(e)?e.map((e=>"string"==typeof e?`<a href="${e}" target="_blank">View file</a>`:e.name||"File")).join(", "):"File uploaded"}formatNumber(e){const t=parseFloat(e);return isNaN(t)?e:e.toString().includes(".")&&2===e.toString().split(".")[1].length?new Intl.NumberFormat("en-CA",{style:"currency",currency:"USD"}).format(t):new Intl.NumberFormat("en-CA").format(t)}formatArrayValue(e,t=null,r=null){if(0===e.length)return"<em>None selected</em>";if(t&&r&&r.input){return"<ul><li>"+e.map((e=>this.getSelectLabel(e,t,r.type))).join("</li><li>")+"</li></ul>"}return"<ul><li>"+e.join("</li><li>")+"</li></ul>"}getSelectLabel(e,t,r){if("select"===r){const r=t.querySelector(`option[value="${e}"]`);return r?.textContent||e}if("radio"===r){const r=t.querySelector(`input[type="radio"][value="${e}"]`),s=r?.nextElementSibling;return s?.textContent||e}if("checkbox"===r){const r=t.querySelector(`input[type="checkbox"][value="${e}"]`);if(r){const e=t.querySelector(`label[for="${r.id}"]`);if(e)return e.textContent.trim();const s=r.nextElementSibling;if("LABEL"===s?.tagName)return s.textContent.trim()}}return e}formatTextareaValue(e,t){return e?"wysiwyg"===t||this.containsHtml(e)?e:this.formatPlainText(e):"<em>Empty</em>"}containsHtml(e){return/<(p|strong|em|u|s|ol|ul|li|blockquote|h[1-6]|a|br|span)\b[^>]*>/i.test(e)}formatPlainText(e){if(!e)return"";const t=(e=e.replace(/&/g,"&").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}))})(); |
| | |
| | | (()=>{class t{constructor(t={}){this.checkout=document.querySelector("aside#cart"),this.checkout&&(this.config=Object.assign({application_id:squareConfig.application_id,location_id:squareConfig.location_id,api_url:squareConfig.api_url,nonce:squareConfig.nonce,currency:squareConfig.currency||"CAD"},t),this.stepMultiplier=1,this.cache=new window.jvbCache("cart",{TTL:864e5}),this.a11y=window.jvbA11y,this.initCart(),this.payments=null,this.card=null,this.isInitialized=!1,this.clickHandler=this.handleClick.bind(this),this.keyHandler=this.handleEscape.bind(this),this.changeHandler=this.handleChange.bind(this),this.initElements(),this.bindEvents(),this.popup=new window.jvbPopup({popup:this.checkout,toggle:this.toggle,name:"Cart",onOpen:this.maybeAddEmptyState.bind(this)}),this.init(),this.toggle.hidden=!1)}async initCart(){this.cartItems=await this.cache.get("cart")??new Map,console.log("cart",this.cartItems),this.cartItems.size>0&&this.notifyRestoredCart()}handleClick(t){if(window.targetCheck(t,"button")&&window.targetCheck(t,"div.quantity")){let e=window.targetCheck(t,"div.quantity");this.handleNumberClick(t,e)}else if(window.targetCheck(t,"[data-add-to-cart]")){let e=window.targetCheck(t,"[data-add-to-cart]");this.handleAddToCart(e)}else if(window.targetCheck(t,"[data-remove-from-cart]")){let e=window.targetCheck(t,"[data-remove-from-cart]");this.handleRemoveFromCart(e)}else window.targetCheck(t,"[data-clear-cart]")&&this.clearCart()}handleChange(t,e){console.log("Checkout change");let a=window.targetCheck(t,".quantity-input");if(a){let e=t.target.closest(".quantity"),i=a.value;if(window.targetCheck(t,".cart-items")){let t=document.querySelector(`.menu-section [data-id="${e.dataset.id}"] input`);t&&(t.value=a.value)}i>0?this.handleAddToCart(e):this.handleRemoveFromCart(e)}}handleNumberClick(t,e){console.log(e),t.preventDefault();let a=0;if(t.target.closest(".increase")?a+=1:t.target.closest(".decrease")&&(a-=1),0!==a){let[t,i]=[parseInt(e.dataset.step),e.querySelector("input")],r=""===i.value?0:parseInt(i.value);i.value=r+t*a*this.stepMultiplier,i.dispatchEvent(new Event("change",{bubbles:!0})),this.handleNumberLimits(e)}}handleNumberLimits(t){let[e,a,i,r,s]=[t.dataset.min,t.dataset.max,t.querySelector("input"),t.querySelector(".increase"),t.querySelector(".decrease")],o=parseInt(i.value);o<e?(i.value=e,s.disabled=!0):o>a?(i.value=a,r.disabled=!1):r.disabled?r.disabled=!1:s.disabled&&(s.disabled=!1)}maybeAddEmptyState(){let t=this.itemsList.querySelector(".empty");if(t&&t.remove(),0===this.cartItems.size){this.checkoutPanel.disabled=!0,this.checkoutPanel.title="Add some things to your cart first!";let t=window.getTemplate("emptyCart");this.itemsList.append(t),this.table.closest("table").hidden=!0,this.total.hidden=!0,this.a11y.announce("Nothing in Cart")}else this.checkoutPanel.disabled=!1,this.table.closest("table").hidden=!1,this.total.hidden=!1,this.checkoutPanel.title="Checkout"}handleEscape(t){"Escape"===t.key?this.stepMultiplier=1:t.ctrlKey&&t.shiftKey?this.stepMultiplier=Math.max(100*parseInt(this.stepMultiplier),1e3):t.shiftKey&&(this.stepMultiplier=Math.max(10*parseInt(this.stepMultiplier),1e3))}handleAddToCart(t){let e=t.dataset.id;this.createItemElement(t);let a=parseFloat(t.dataset.price),i=parseInt(t.querySelector(".quantity-input")?.value)??1,r=parseFloat(a*i);this.cartItems.set(e,{post_id:e,name:t.dataset.name,price:a,quantity:i,total:r,square_catalog_id:t.dataset.squareCatalogId}),this.saveCart()}notifyRestoredCart(){let t=window.getTemplate("restoredCart");this.checkout.querySelector(".tab-content[data-tab=cartItems]").insertBefore(t,this.itemsList),this.cartItems.forEach((t=>{console.log(t);let e=window.getTemplate("cartItem"),a=e.querySelector(".quantity"),i=t.price,r=t.quantity;[a.dataset.id,e.querySelector("label").textContent,e.querySelector(".price").textContent,a.dataset.price,a.dataset.squareCatalogId,e.querySelector('[name="quantity"]').value,e.querySelector(".total").textContent]=[t.post_id,t.name,window.formatPrice(i),i,t.square_catalog_id,r,window.formatPrice(r*i)],this.table.append(e)})),this.updateTotal()}handleRemoveFromCart(t){if(confirm("This will remove this item from the cart. Continue?")){t.querySelector("[data-id]")||(t=t.closest(".item")?.querySelector(".quantity.field"));let e=t.dataset.id;this.cartItems.delete(e),this.table.querySelector(`[data-id="${e}"]`)?.closest("tr").remove();let a=document.querySelector(`[data-id="${e}"] input`);a&&(a.value=0),this.maybeAddEmptyState(),this.saveCart()}}clearCart(){this.cartItems.clear(),window.removeChildren(this.table),this.saveCart()}saveCart(){this.updateTotal(),this.cache.set("cart",this.cartItems)}updateTotal(){let t=0;this.cartItems.forEach((e=>{console.log(e),t+=e.total}));let e=.05*t;t=window.formatPrice(t+e),e=window.formatPrice(e),window.eraseText(this.totalTax),window.eraseText(this.grandTotal),window.typeText(this.totalTax,e),window.typeText(this.grandTotal,t),this.totalTax.classList.remove("typeText")}createItemElement(t){let e=this.itemsList.querySelector(`[data-id="${t.dataset.id}"]`),a=!1,i=t.dataset.price,r=t.querySelector('[name="quantity"]')?.value??1;if(e)e=e.closest("tr");else{a=!0,e=window.getTemplate("cartItem");let r=e.querySelector(".quantity");[r.dataset.id,e.querySelector("label").textContent,e.querySelector(".price").textContent,r.dataset.price,r.dataset.squareCatalogId]=[t.dataset.id,t.dataset.name,window.formatPrice(i),i,t.dataset.squareCatalogId]}[e.querySelector('[name="quantity"]').value,e.querySelector(".total").textContent]=[r,window.formatPrice(r*i)],a&&(e.classList.add("adding"),this.table.append(e),setTimeout((()=>{e.classList.remove("adding")}),500))}async init(){if(window.Square)try{this.payments=window.Square.payments(this.config.application_id,this.config.location_id),await this.initializePaymentMethods(),this.isInitialized=!0,document.dispatchEvent(new CustomEvent("squareCheckoutReady",{detail:{checkout:this}}))}catch(t){console.error("Failed to initialize Square payments:",t),this.handleError(t)}else console.error("Square Web Payments SDK not loaded")}initElements(){this.toggle=document.querySelector(".toggle-cart"),"1"!==squareConfig.isOpen&&(this.toggle.disabled=!0,this.toggle.title="Currently closed for online ordering"),this.checkoutPanel=this.checkout.querySelector('button[data-tab="checkout"]'),this.itemsList=this.checkout.querySelector(".cart-items"),this.table=this.checkout.querySelector(".cart-items tbody"),this.total=this.checkout.querySelector(".cart-total"),this.totalTax=this.total.querySelector(".tax span"),this.grandTotal=this.total.querySelector(".total span"),this.checkoutForm=this.checkout.querySelector("form"),this.tabs=new window.jvbTabs(this.checkoutForm,{updateURL:!1}),console.log("Initialized Checkout")}bindEvents(){this.checkoutForm.addEventListener("submit",(t=>this.handleFormSubmit(t))),document.addEventListener("click",this.clickHandler),document.addEventListener("change",this.changeHandler)}async initializePaymentMethods(){if(document.getElementById("square-card-container"))try{this.card=await this.payments.card({style:this.getCardStyle()}),await this.card.attach("#square-card-container")}catch(t){throw console.error("Failed to initialize card:",t),t}}getCardStyle(){return{input:{fontSize:"16px",fontFamily:"inherit",color:"#333",backgroundColor:"#fff"},".input-container":{borderColor:"#ccc",borderRadius:"4px"},".input-container.is-focus":{borderColor:"#007cba"},".input-container.is-error":{borderColor:"#d63638"}}}async handleFormSubmit(t){if("1"!==squareConfig.isOpen)return;if(t.preventDefault(),!this.isInitialized)return void this.handleError("Checkout not initialized");const e=t.target,a=this.extractOrderData(e);try{window.jvbLoading.showLoading("Processing payment...");const t=await this.processPayment(a);this.handleSuccess(t,e)}catch(t){this.handleError(t)}finally{window.jvbLoading.hideLoading()}}extractOrderData(t){const e=Array.from(this.cartItems.values()).map((t=>({post_id:t.post_id,quantity:t.quantity,price:t.price,name:t.name})));return{total:100*e.reduce(((t,e)=>t+e.price*e.quantity),0),items:e,customer:{email:t.querySelector('[name="email"]')?.value||"",name:t.querySelector('[name="name"]')?.value||"",phone:t.querySelector('[name="phone"]')?.value||""},note:t.querySelector('[name="special_instructions"]')?.value||"",pickup_time:t.querySelector('[name="pickup_time"]')?.value||""}}async processPayment(t){try{const e=await this.card.tokenize();if("OK"===e.status)return await this.submitToServer(e.token,t);throw new Error("Card tokenization failed: "+(e.errors?.join(", ")||"Unknown error"))}catch(t){throw console.error("Payment processing failed:",t),t}}async submitToServer(t,e){if("1"!==squareConfig.isOpen)return;const a=await fetch(this.config.api_url+"save-order",{method:"POST",headers:{"Content-Type":"application/json","X-WP-Nonce":this.config.nonce},body:JSON.stringify({order_id:t.orderId,payment_id:t.paymentId,customer:e.customer,items:e.items,action:"jvb_integration_action",service:"square",integration_action:"save_order"})}),i=await a.json();if(!a.ok)throw new Error(i.message||"Failed to save order");return i}trackOrder(t){this.orderId=t,this.scheduleOrderCheck(),this.checkout.querySelector("button[data-tab=order]").hidden=!1}scheduleOrderCheck(){window.debouncer.schedule("order",(()=>{this.checkOrderStatus()}),3e4)}async checkOrderStatus(){const t=await fetch(`/wp-json/jvb/v1/square/order-status/${this.orderId}`),e=await t.json();"ready"!==e.status&&this.scheduleOrderCheck(),this.updateOrderStatus(e)}updateOrderStatus(t){this.checkout.querySelectorAll(".status-item").forEach((e=>{e.dataset.status===t.status&&e.classList.add("active")})),this.checkout.querySelector("#eta").textContent=t.eta||"In progress"}async loadCustomerProfile(t){const e=await fetch("/wp-json/jvb/v1/square/customer",{method:"POST",headers:{"Content-Type":"application/json","X-WP-Nonce":this.config.nonce},body:JSON.stringify({email:t})}),a=await e.json();a&&(this.displaySavedCards(a.cards),this.fillCustomerInfo(a.customer))}displaySavedCards(t){const e=document.getElementById("saved-cards");t.length&&(e.innerHTML=`\n <h3>Saved Payment Methods</h3>\n ${t.map((t=>`\n <label>\n <input type="radio" name="payment_method" value="${t.id}">\n •••• ${t.last_4} (${t.card_brand})\n </label>\n `)).join("")}\n <label>\n <input type="radio" name="payment_method" value="new" checked>\n Use new card\n </label>\n `)}handleSuccess(t,e){document.dispatchEvent(new CustomEvent("squareCheckoutSuccess",{detail:{result:t,form:e}}));const a=e.dataset.successUrl||`/order-confirmation/?order=${t.wp_order_id}`;window.location.href=a}handleError(t){console.error("Square checkout error:",t),document.dispatchEvent(new CustomEvent("squareCheckoutError",{detail:{error:t}})),window.jvbNotifications?.show?.(t.message||"Payment failed","error")}}document.addEventListener("DOMContentLoaded",(()=>{window.squareCheckout=new t}))})(); |
| | | (()=>{class e{constructor(e={}){this.config={...squareConfig,...e},this.payments=null,this.card=null,this.isInitialized=!1,this.cartItems=new Map,this.checkout=document.querySelector("aside#cart"),this.isOpen="1"!==this.config.isOpen||!1,this.isOpen=!0,this.isLoggedIn=this.config.is_logged_in||!1,this.userEmail=this.config.user_email||"",this.savedCards=[],this.selectedCardId=null,this.cartId=null,this.cache=new window.jvbCache("cart",{TTL:864e5}),this.a11y=window.jvbA11y,this.initCart(),this.checkout&&(this.initElements(),this.init(),this.initListeners(),this.isLoggedIn&&this.loadSavedCards()),this.stepMultiplier=1,this.popup=new window.jvbPopup({popup:this.checkout,toggle:this.toggle,name:"Cart",onOpen:this.maybeAddEmptyState.bind(this)}),console.log(this.popup)}async initCart(){this.cartItems=await this.cache.get("cart")??new Map,console.log("cart",this.cartItems),this.cartItems.size>0&&this.notifyRestoredCart()}handleClick(e){if(window.targetCheck(e,"button")&&window.targetCheck(e,"div.quantity")){let t=window.targetCheck(e,"div.quantity");this.handleNumberClick(e,t)}else if(window.targetCheck(e,"[data-add-to-cart]")){let t=window.targetCheck(e,"[data-add-to-cart]");this.handleAddToCart(t)}else if(window.targetCheck(e,"[data-remove-from-cart]")){let t=window.targetCheck(e,"[data-remove-from-cart]");this.handleRemoveFromCart(t)}else window.targetCheck(e,"[data-clear-cart]")&&this.clearCart()}handleChange(e,t){console.log("Checkout change");let a=window.targetCheck(e,".quantity-input");if(a){let t=e.target.closest(".quantity"),i=a.value;if(window.targetCheck(e,".cart-items")){let e=document.querySelector(`.menu-section [data-id="${t.dataset.id}"] input`);e&&(e.value=a.value)}i>0?this.handleAddToCart(t):this.handleRemoveFromCart(t)}}handleNumberClick(e,t){console.log(t),e.preventDefault();let a=0;if(e.target.closest(".increase")?a+=1:e.target.closest(".decrease")&&(a-=1),0!==a){let[e,i]=[parseInt(t.dataset.step),t.querySelector("input")],s=""===i.value?0:parseInt(i.value);i.value=s+e*a*this.stepMultiplier,i.dispatchEvent(new Event("change",{bubbles:!0})),this.handleNumberLimits(t)}}handleNumberLimits(e){let[t,a,i,s,r]=[e.dataset.min,e.dataset.max,e.querySelector("input"),e.querySelector(".increase"),e.querySelector(".decrease")],n=parseInt(i.value);n<t?(i.value=t,r.disabled=!0):n>a?(i.value=a,s.disabled=!1):s.disabled?s.disabled=!1:r.disabled&&(r.disabled=!1)}maybeAddEmptyState(){let e=this.itemsList.querySelector(".empty");if(e&&e.remove(),0===this.cartItems.size){this.checkoutPanel.disabled=!0,this.checkoutPanel.title="Add some things to your cart first!";let e=window.getTemplate("emptyCart");this.itemsList.append(e),this.table.closest("table").hidden=!0,this.total.hidden=!0,this.a11y.announce("Nothing in Cart")}else this.checkoutPanel.disabled=!1,this.table.closest("table").hidden=!1,this.total.hidden=!1,this.checkoutPanel.title="Checkout"}handleEscape(e){"Escape"===e.key?this.stepMultiplier=1:e.ctrlKey&&e.shiftKey?this.stepMultiplier=Math.max(100*parseInt(this.stepMultiplier),1e3):e.shiftKey&&(this.stepMultiplier=Math.max(10*parseInt(this.stepMultiplier),1e3))}handleAddToCart(e){let t=e.dataset.id;this.createItemElement(e);let a=parseFloat(e.dataset.price),i=parseInt(e.querySelector(".quantity-input")?.value)??1,s=parseFloat(a*i);this.cartItems.set(t,{post_id:t,name:e.dataset.name,price:a,quantity:i,total:s,square_catalog_id:e.dataset.squareCatalogId}),this.saveCart()}notifyRestoredCart(){let e=window.getTemplate("restoredCart");this.checkout.querySelector(".tab-content[data-tab=cartItems]").insertBefore(e,this.itemsList),this.cartItems.forEach((e=>{console.log(e);let t=window.getTemplate("cartItem"),a=t.querySelector(".quantity"),i=e.price,s=e.quantity;[a.dataset.id,t.querySelector("label").textContent,t.querySelector(".price").textContent,a.dataset.price,a.dataset.squareCatalogId,t.querySelector('[name="quantity"]').value,t.querySelector(".total").textContent]=[e.post_id,e.name,window.formatPrice(i),i,e.square_catalog_id,s,window.formatPrice(s*i)],this.table.append(t)})),this.updateTotal()}handleRemoveFromCart(e){if(confirm("This will remove this item from the cart. Continue?")){e.querySelector("[data-id]")||(e=e.closest(".item")?.querySelector(".quantity.field"));let t=e.dataset.id;this.cartItems.delete(t),this.table.querySelector(`[data-id="${t}"]`)?.closest("tr").remove();let a=document.querySelector(`[data-id="${t}"] input`);a&&(a.value=0),this.maybeAddEmptyState(),this.saveCart()}}clearCart(){this.cartItems.clear(),window.removeChildren(this.table),this.saveCart()}saveCart(){this.updateTotal(),this.cache.set("cart",this.cartItems)}updateTotal(){let e=0;this.cartItems.forEach((t=>{console.log(t),e+=t.total}));let t=.05*e;e=window.formatPrice(e+t),t=window.formatPrice(t),window.eraseText(this.totalTax),window.eraseText(this.grandTotal),window.typeText(this.totalTax,t),window.typeText(this.grandTotal,e),this.totalTax.classList.remove("typeText")}createItemElement(e){let t=this.itemsList.querySelector(`[data-id="${e.dataset.id}"]`),a=!1,i=e.dataset.price,s=e.querySelector('[name="quantity"]')?.value??1;if(t)t=t.closest("tr");else{a=!0,t=window.getTemplate("cartItem");let s=t.querySelector(".quantity");[s.dataset.id,t.querySelector("label").textContent,t.querySelector(".price").textContent,s.dataset.price,s.dataset.squareCatalogId]=[e.dataset.id,e.dataset.name,window.formatPrice(i),i,e.dataset.squareCatalogId]}[t.querySelector('[name="quantity"]').value,t.querySelector(".total").textContent]=[s,window.formatPrice(s*i)],a&&(t.classList.add("adding"),this.table.append(t),setTimeout((()=>{t.classList.remove("adding")}),500))}async init(){if(window.Square)try{this.payments=window.Square.payments(this.config.application_id,this.config.location_id),await this.initializePaymentMethods(),this.isInitialized=!0,document.dispatchEvent(new CustomEvent("squareCheckoutReady",{detail:{checkout:this}}))}catch(e){console.error("Failed to initialize Square payments:",e),this.handleError(e)}else console.error("Square Web Payments SDK not loaded")}initElements(){this.toggle=document.querySelector(".toggle-cart"),this.isOpen||(this.toggle.disabled=!0,this.toggle.title="Currently closed for online ordering"),this.checkoutPanel=this.checkout.querySelector('button[data-tab="checkout"]'),this.itemsList=this.checkout.querySelector(".cart-items"),this.table=this.checkout.querySelector(".cart-items tbody"),this.total=this.checkout.querySelector(".cart-total"),this.totalTax=this.total.querySelector(".tax span"),this.grandTotal=this.total.querySelector(".total span"),this.checkoutForm=this.checkout.querySelector("form"),this.tabs=new window.jvbTabs(this.checkoutForm,{updateURL:!1}),console.log("Initialized Checkout")}initListeners(){this.clickHandler=this.handleClick.bind(this),this.keyHandler=this.handleEscape.bind(this),this.changeHandler=this.handleChange.bind(this),this.checkoutForm.addEventListener("submit",(e=>this.handleFormSubmit(e))),document.addEventListener("click",this.clickHandler),document.addEventListener("change",this.changeHandler)}async initializePaymentMethods(){if(document.getElementById("square-card-container"))try{this.card=await this.payments.card({style:this.getCardStyle()}),await this.card.attach("#square-card-container"),this.card.addEventListener("cardBrandChanged",(e=>{console.log("Card brand:",e.detail.cardBrand)}))}catch(e){throw console.error("Failed to initialize card:",e),e}}getCardStyle(){return{input:{fontSize:"16px",fontFamily:"inherit",color:"#333",backgroundColor:"#fff"},".input-container":{borderColor:"#ccc",borderRadius:"4px"},".input-container.is-focus":{borderColor:"#006AFF",borderWidth:"2px",outline:"2px solid #006AFF",outlineOffset:"2px"},".input-container.is-error":{borderColor:"#d63638"}}}async handleFormSubmit(e){if(!this.isOpen)return;if(e.preventDefault(),!this.isInitialized)return void this.handleError("Checkout not initialized");const t=e.target,a=this.extractOrderData(t);try{window.jvbLoading.showLoading("Processing payment...");const e=await this.processPayment(a);this.handleSuccess(e,t)}catch(e){this.handleError(e)}finally{window.jvbLoading.hideLoading()}}extractOrderData(e){const t=Array.from(this.cartItems.values()).map((e=>({catalog_object_id:e.square_catalog_id,quantity:String(e.quantity),price:e.price,note:e.note||""}))),a=t.reduce(((e,t)=>e+t.price*t.quantity),0);return{total:Math.round(100*a),items:t,customer:{email:this.isLoggedIn?this.userEmail:e.querySelector('[name="email"]')?.value||"",name:e.querySelector('[name="name"]')?.value||"",phone:e.querySelector('[name="phone"]')?.value||""},note:e.querySelector('[name="special_instructions"]')?.value||"",pickup_time:e.querySelector('[name="pickup_time"]')?.value||""}}async processPayment(e){try{let t=null;if(this.selectedCardId)t=this.selectedCardId;else{const a=await this.card.tokenize({verificationDetails:{amount:String(e.total),currencyCode:this.config.currency||"CAD",intent:"CHARGE",customerInitiated:!0,billingContact:{givenName:e.customer.name.split(" ")[0],familyName:e.customer.name.split(" ").slice(1).join(" "),email:e.customer.email,phone:e.customer.phone,addressLines:[form.querySelector('[name="address"]')?.value||""],city:form.querySelector('[name="city"]')?.value||"",state:form.querySelector('[name="state"]')?.value||"",postalCode:form.querySelector('[name="postal_code"]')?.value||"",countryCode:"CA"}}});if("OK"!==a.status){const e=a.errors?.map((e=>e.message)).join(", ")||"Unknown error";throw new Error(`Card tokenization failed: ${e}`)}t=a.token,a.details?.userChallenged&&console.log("3D Secure verification completed")}return await this.submitToServer(t,e,!!this.selectedCardId)}catch(e){throw console.error("Payment processing failed:",e),e}}async submitToServer(e,t,a=!1){if(!this.isOpen)throw new Error("Store is currently closed");const i=await fetch(this.config.api_url+"process-payment",{method:"POST",headers:{"Content-Type":"application/json","X-WP-Nonce":this.config.nonce},body:JSON.stringify({source_id:e,is_saved_card:a,cart_id:this.getCartId(),amount:t.total,items:t.items,customer:{email:this.isLoggedIn?this.userEmail:t.customer.email,name:t.customer.name,phone:t.customer.phone},note:t.note,pickup_time:t.pickup_time})}),s=await i.json();if(!i.ok)throw new Error(s.message||"Payment processing failed");return this.clearCart(),s}getCartId(){return this.cartId||(this.cartId=crypto.randomUUID(),this.cache.set("cart_id",this.cartId)),this.cartId}trackOrder(e){this.orderId=e,this.scheduleOrderCheck(),this.checkout.querySelector("button[data-tab=order]").hidden=!1}scheduleOrderCheck(){window.debouncer.schedule("order",(()=>{this.checkOrderStatus()}),3e4)}async checkOrderStatus(){const e=await fetch(`/wp-json/jvb/v1/square/order-status/${this.orderId}`),t=await e.json();"ready"!==t.status&&this.scheduleOrderCheck(),this.updateOrderStatus(t)}updateOrderStatus(e){this.checkout.querySelectorAll(".status-item").forEach((t=>{t.dataset.status===e.status&&t.classList.add("active")})),this.checkout.querySelector("#eta").textContent=e.eta||"In progress"}async loadSavedCards(){try{const e=await fetch(this.config.api_url+"saved-cards",{method:"GET",headers:{"X-WP-Nonce":this.config.nonce}}),t=await e.json();t.success&&t.cards&&(this.savedCards=t.cards,this.renderSavedCards())}catch(e){console.error("Failed to load saved cards:",e)}}renderSavedCards(){const e=document.getElementById("saved-cards");if(!e||0===this.savedCards.length)return;const t=`\n <div class="saved-cards-section">\n <h4>Saved Payment Methods</h4>\n ${this.savedCards.map((e=>`\n <label class="saved-card">\n <input type="radio" name="payment-method" value="saved" data-card-id="${e.id}">\n <span class="card-info">\n <strong>${e.card_brand}</strong> ending in ${e.last_4}\n <small>Exp: ${e.exp_month}/${e.exp_year}</small>\n </span>\n </label>\n `)).join("")}\n <label class="saved-card">\n <input type="radio" name="payment-method" value="new" checked>\n <span>Use a new card</span>\n </label>\n </div>\n `;e.innerHTML=t,e.querySelectorAll('input[name="payment-method"]').forEach((e=>{e.addEventListener("change",(e=>{const t="new"===e.target.value,a=document.getElementById("square-card-container");a&&(a.style.display=t?"block":"none"),this.selectedCardId=t?null:e.target.dataset.cardId}))}))}handleSuccess(e,t){document.dispatchEvent(new CustomEvent("squareCheckoutSuccess",{detail:{result:e,form:t}}));const a=t.dataset.successUrl||`/order-confirmation/?order=${e.wp_order_id}`;window.location.href=a}handleError(e){console.error("Square checkout error:",e),document.dispatchEvent(new CustomEvent("squareCheckoutError",{detail:{error:e}})),window.jvbNotifications?.show?.(e.message||"Payment failed","error")}}document.addEventListener("DOMContentLoaded",(()=>{window.squareCheckout=new e}))})(); |
| | |
| | | (()=>{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()}}})(); |
| | | (()=>{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()}}})(); |
| | |
| | | <?php return array('dependencies' => array(), 'version' => '489e47cea4ff0c15a5f0'); |
| | | <?php return array('dependencies' => array(), 'version' => '6af2556d0306f0da3d78'); |
| | |
| | | document.addEventListener("DOMContentLoaded",(function(){let e=new window.jvbForm;document.querySelectorAll(".jvb-form-block").forEach((t=>{e.registerForm(t.querySelector("form"))})),e.subscribe(((e,t)=>{"form-submit"===e&&async function(e,t,o){let r={"X-WP-Nonce":jvbSettings.nonce,"Content-Type":"application/json"};t.form_type=o.dataset.formId,t.form_id=o.id;let n=o.closest(".jvb-form-block"),i=window.getTemplate("spinner");o.hidden=!0,n.classList.add("loading"),n.prepend(i);try{const e=await fetch(`${jvbSettings.api}forms`,{method:"POST",headers:r,body:JSON.stringify(t)});if(!e.ok){const t=await e.json().catch((()=>({})));throw new Error(t.message||`Request failed with status ${e.status}`)}!function(e,t){if(!1===e.success){let o=t.querySelector("form");return o.removeAttribute("hidden"),void("required"in e&&e.required.forEach((e=>{let t=o.querySelector(`[name=${e}]`);t.classList.add("required","error"),t.scrollIntoView()})))}let o=window.getTemplate("formSummary");o.querySelector("h2").textContent="Success!",console.log("Form Response: ",e);for(let[t,r]of Object.entries(e)){let e=o.querySelector(`#${t}`),n=e.querySelector("h4");n.innerText.includes("%s")?n.innerHTML=n.replace("%s","<b>"+r+"</b>"):e.querySelector("div").innerHTML=r}t.append(o)}(await e.json(),n)}catch(e){throw e}finally{n.classList.remove("loading")}}(0,t.data,t.config.element)}))})); |
| | | (()=>{class e{constructor(){this.controller=new window.jvbForm,document.querySelectorAll(".jvb-form-block form").forEach((e=>{this.controller.registerForm(e)})),this.controller.subscribe(((e,o)=>{"form-submit"===e&&this.handleFormSubmission(o)}))}async handleFormSubmission(e){let[o,t,r]=[e.formId,e.config,e.data],s=t.element,n={"X-WP-Nonce":jvbSettings.nonce,"Content-Type":"application/json"};e.form_type=o,e.form_id=s.id,s.closest(".jvb-form-block"),this.controller.showFormStatus(o,"uploading");try{const e=await fetch(`${jvbSettings.api}forms`,{method:"POST",headers:n,body:JSON.stringify(r)});if(!e.ok){this.controller.showFormStatus(o,"error");const t=await e.json().catch((()=>({})));throw new Error(t.message||`Request failed with status ${e.status}`)}this.controller.showFormStatus(o,"submitted"),this.controller.showSummary(o,".jvb-form-block"),this.controller.store.delete(o)}catch(e){throw e}}updateUI(e,o){let t=window.getTemplate("formSummary");t.querySelector("h2").textContent="Success!",console.log("Form Response: ",e),console.log(t);for(let[o,r]of Object.entries(e)){let e=t.querySelector(`#${o}`);if(e){let o=e.querySelector("h4");o.innerText.includes("%s")?o.innerHTML=o.replace("%s","<b>"+r+"</b>"):e.querySelector("div").innerHTML=r}}o.append(t)}}document.addEventListener("DOMContentLoaded",(function(){new e}))})(); |
| | |
| | | // $this->cache->invalidateGroup('icons'); |
| | | $this->style = JVB_SITE['icons']??'regular'; |
| | | |
| | | |
| | | $this->used = get_option(BASE.'used_icons', [ |
| | | $this->style => [ |
| | | 'heart', |
| | |
| | | 'location', |
| | | 'hours', |
| | | 'star', |
| | | 'star-half' |
| | | 'star-half', |
| | | 'exclamation-mark' |
| | | ], |
| | | 'fill' => [ |
| | | 'heart', |
| | |
| | | |
| | | // Register forms data for the block editor |
| | | add_action('enqueue_block_editor_assets', [$this, 'localizeFormsData']); |
| | | |
| | | add_action('init', [$this, 'registerBlock']); |
| | | } |
| | | |
| | |
| | | |
| | | $cache_key = $this->cache->generateKey($block); |
| | | $cached = $this->cache->get($cache_key); |
| | | |
| | | $cached = false; |
| | | if ($cached) { |
| | | return $cached; |
| | | } |
| | |
| | | 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); |
| | |
| | | $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) { |
| | |
| | | $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>'; |
| | |
| | | $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++; |
| | | } |
| | |
| | | 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>'; |
| | | } |
| | | |
| | | /** |
| | |
| | | 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"> |
| | |
| | | <?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>'; |
| | | } |
| | |
| | | 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 |
| | |
| | | |
| | | // 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; |
| | | } |
| | |
| | | } |
| | | |
| | | 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(); |
| | |
| | | } |
| | | |
| | | /** |
| | | * 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 |
| | |
| | | <?php |
| | | namespace JVBase\integrations; |
| | | |
| | | use JVBase\meta\MetaForm; |
| | | use JVBase\meta\MetaManager; |
| | | use Exception; |
| | | use JVBase\registry\PostTypeRegistrar; |
| | | use WP_Error; |
| | | |
| | | if (!defined('ABSPATH')) { |
| | |
| | | $this->title = 'Square'; |
| | | $this->icon = 'square-logo'; |
| | | |
| | | $this->refresh_interval = 7 * DAY_IN_SECONDS; |
| | | |
| | | // Define credential fields |
| | | $this->fields = [ |
| | | 'environment' => [ |
| | |
| | | 'sync_to_square' => 'Sync Site to Square', |
| | | ] |
| | | ); |
| | | |
| | | add_action('init', [$this, 'registerSquarePostTypes']); |
| | | } |
| | | |
| | | /** |
| | |
| | | |
| | | } |
| | | |
| | | 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 |
| | | */ |
| | |
| | | // 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']); |
| | | } |
| | | |
| | | |
| | |
| | | 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">'; |
| | | |
| | |
| | | '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> |
| | | |
| | |
| | | '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> |
| | |
| | | |
| | | |
| | | $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 |
| | |
| | | /** |
| | | * 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 |
| | |
| | | 'jvb-a11y', |
| | | 'jvb-cache', |
| | | 'jvb-tabs', |
| | | 'jvb-modal', |
| | | 'jvb-popup' |
| | | ], |
| | | '1.0.0', |
| | |
| | | '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 |
| | | ] |
| | | ); |
| | | } |
| | |
| | | 'name' => $image_title ?: 'Image', |
| | | 'caption' => $alt_text ?: '' |
| | | ] |
| | | ] |
| | | ], |
| | | 'object_id' => $supported_image_id |
| | | ]; |
| | | |
| | | $body = $this->buildMultipartBody($file_path, $request_json, $boundary); |
| | |
| | | $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; |
| | | } |
| | | } |
| | |
| | | <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(); |
| | | } |
| | | |
| | | |
| | |
| | | |
| | | use JVBase\managers\MagicLinkManager; |
| | | use JVBase\integrations\Cloudflare; |
| | | use JVBase\meta\MetaForm; |
| | | use JVBase\utility\Features; |
| | | use WP_User; |
| | | use WP_Error; |
| | |
| | | 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 |
| | |
| | | { |
| | | |
| | | $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"> |
| | |
| | | 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> |
| | |
| | | </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> |
| | |
| | | use DateTime; |
| | | |
| | | if (!defined('ABSPATH')) { |
| | | exit; // Exit if accessed directly |
| | | exit; |
| | | } |
| | | |
| | | /** |
| | |
| | | */ |
| | | 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; |
| | | } |
| | |
| | | } |
| | | |
| | | 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'); |
| | |
| | | return; |
| | | } |
| | | |
| | | // Extract stored values |
| | | // Parse stored data |
| | | if (is_string($value)) { |
| | | $value = maybe_unserialize($value); |
| | | } |
| | |
| | | $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)) ? [ |
| | |
| | | ] : 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; |
| | | } |
| | | } |
| New file |
| | |
| | | <?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; |
| | | } |
| | | } |
| | |
| | | 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; |
| | |
| | | |
| | | 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); |
| | |
| | | } |
| | | } |
| | | |
| | | public function setFieldConfig(array $fields):void |
| | | { |
| | | $this->fields = $fields; |
| | | } |
| | | |
| | | protected function getFieldConfig(string $name):array|false |
| | | { |
| | | $fields = $this->getFields(); |
| | |
| | | $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, |
| | |
| | | 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'); |
| New file |
| | |
| | | <?php |
| | | namespace JVBase\rest\routes; |
| | | |
| | | |
| | | use JVBase\rest\RestRouteManager; |
| | | use WP_REST_Request; |
| | | use WP_REST_Response; |
| | | use Exception; |
| | | |
| | | if (!defined('ABSPATH')) { |
| | | exit; // Exit if accessed directly |
| | | } |
| | | |
| | | class IntegrationsSquareRoutes extends RestRouteManager |
| | | { |
| | | public function registerRoutes():void |
| | | { |
| | | register_rest_route('jvb/v1/square', '/process-payment', [ |
| | | 'methods' => 'POST', |
| | | 'callback' => [$this, 'handlePaymentProcessing'], |
| | | 'permission_callback' => '__return_true' // Adjust based on your auth |
| | | ]); |
| | | |
| | | register_rest_route('jvb/v1/square', '/saved-cards', [ |
| | | 'methods' => 'GET', |
| | | 'callback' => [$this, 'getSavedCards'], |
| | | 'permission_callback' => 'is_user_logged_in' |
| | | ]); |
| | | |
| | | |
| | | register_rest_route('jvb/v1/square', '/order-history', [ |
| | | 'methods' => 'GET', |
| | | 'callback' => [$this, 'getOrderHistory'], |
| | | 'permission_callback' => 'is_user_logged_in' |
| | | ]); |
| | | |
| | | |
| | | register_rest_route('jvb/v1/square', '/order-status/(?P<order_id>[a-zA-Z0-9_-]+)', [ |
| | | 'methods' => 'GET', |
| | | 'callback' => [$this, 'getOrderStatus'], |
| | | 'permission_callback' => '__return_true' // Allow guests with order ID |
| | | ]); |
| | | } |
| | | |
| | | public function handlePaymentProcessing($request): array |
| | | { |
| | | $data = $request->get_json_params(); |
| | | |
| | | // Generate idempotency key from cart_id + timestamp |
| | | // This ensures retries use SAME key |
| | | $cart_id = $data['cart_id'] ?? ''; |
| | | if (!$cart_id) { |
| | | return ['success' => false, 'message' => 'Missing cart ID']; |
| | | } |
| | | |
| | | // Check if we already processed this cart |
| | | $existing_order = get_transient(BASE . 'cart_order_' . $cart_id); |
| | | if ($existing_order) { |
| | | // Return cached result - prevents double charge |
| | | return $existing_order; |
| | | } |
| | | |
| | | // Generate idempotency key tied to this specific cart |
| | | $idempotency_key = 'cart_' . $cart_id . '_' . time(); |
| | | |
| | | // Store key to prevent reprocessing |
| | | set_transient(BASE . 'cart_idempotency_' . $cart_id, $idempotency_key, HOUR_IN_SECONDS); |
| | | |
| | | // Validate required fields |
| | | $required = ['source_id', 'amount', 'items', 'customer']; |
| | | foreach ($required as $field) { |
| | | if (empty($data[$field])) { |
| | | return [ |
| | | 'success' => false, |
| | | 'message' => "Missing required field: {$field}" |
| | | ]; |
| | | } |
| | | } |
| | | |
| | | try { |
| | | $square = JVB()->connect('square'); |
| | | // Step 1: Get or create Square customer |
| | | $customer_id = $square->getOrCreateSquareCustomer($data['customer']); |
| | | |
| | | // Step 2: Create Order in Square |
| | | $order_response = $square->createSquareOrder($data['items'], $customer_id, $data); |
| | | |
| | | if (is_wp_error($order_response)) { |
| | | throw new Exception($order_response->get_error_message()); |
| | | } |
| | | |
| | | $order_id = $order_response['order']['id'] ?? null; |
| | | if (!$order_id) { |
| | | throw new Exception('Failed to create Square order'); |
| | | } |
| | | |
| | | // Step 3: Create Payment in Square |
| | | $payment_response = $square->createSquarePayment( |
| | | $data['source_id'], |
| | | $data['idempotency_key'], |
| | | $data['amount'], |
| | | $order_id, |
| | | $customer_id |
| | | ); |
| | | |
| | | if (is_wp_error($payment_response)) { |
| | | throw new Exception($payment_response->get_error_message()); |
| | | } |
| | | |
| | | $payment = $payment_response['payment'] ?? null; |
| | | if (!$payment) { |
| | | throw new Exception('Failed to create Square payment'); |
| | | } |
| | | |
| | | // Step 4: Save order reference in WordPress |
| | | $wp_order_id = $square->saveOrderToWordPress([ |
| | | 'square_order_id' => $order_id, |
| | | 'square_payment_id' => $payment['id'], |
| | | 'customer' => $data['customer'], |
| | | 'items' => $data['items'], |
| | | 'amount' => $data['amount'], |
| | | 'status' => $payment['status'] |
| | | ]); |
| | | |
| | | $result = [ |
| | | 'success' => true, |
| | | 'order_id' => $order_id, |
| | | 'payment_id' => $payment['id'], |
| | | 'wp_order_id' => $wp_order_id, |
| | | 'status' => $payment['status'], |
| | | 'receipt_url' => $payment['receipt_url'] ?? null |
| | | ]; |
| | | |
| | | set_transient(BASE . 'cart_order_' . $cart_id, $result, HOUR_IN_SECONDS); |
| | | |
| | | return $result; |
| | | |
| | | } catch (Exception $e) { |
| | | $this->logError('Payment processing failed', [ |
| | | 'error' => $e->getMessage(), |
| | | 'idempotency_key' => $data['idempotency_key'] |
| | | ]); |
| | | |
| | | return [ |
| | | 'success' => false, |
| | | 'message' => $e->getMessage() |
| | | ]; |
| | | } |
| | | } |
| | | |
| | | public function getSavedCards($request): array |
| | | { |
| | | $user_id = get_current_user_id(); |
| | | if (!$user_id) { |
| | | return ['success' => false, 'message' => 'Not logged in']; |
| | | } |
| | | |
| | | $square = JVB()->connect('square'); |
| | | |
| | | // Get Square customer ID for this user |
| | | $square_customer_id = get_user_meta($user_id, BASE . '_square_customer_id', true); |
| | | |
| | | if (!$square_customer_id) { |
| | | return ['success' => true, 'cards' => []]; |
| | | } |
| | | |
| | | // Fetch cards from Square (2025-compliant - separate endpoint) |
| | | $cards_response = $square->getRequest('cards?customer_id=' . $square_customer_id); |
| | | |
| | | if (is_wp_error($cards_response)) { |
| | | return ['success' => false, 'message' => 'Failed to fetch cards']; |
| | | } |
| | | |
| | | return [ |
| | | 'success' => true, |
| | | 'cards' => $cards_response['cards'] ?? [] |
| | | ]; |
| | | } |
| | | |
| | | public function getOrderHistory($request): array |
| | | { |
| | | $user_id = get_current_user_id(); |
| | | if (!$user_id) { |
| | | return ['success' => false, 'message' => 'Not logged in']; |
| | | } |
| | | |
| | | // Get orders from custom post type |
| | | $orders = get_posts([ |
| | | 'post_type' => BASE . '_sq_orders', |
| | | 'author' => $user_id, |
| | | 'posts_per_page' => 50, |
| | | 'orderby' => 'date', |
| | | 'order' => 'DESC' |
| | | ]); |
| | | |
| | | $order_data = []; |
| | | foreach ($orders as $order) { |
| | | $meta = new \JVBase\meta\MetaManager($order->ID, 'post'); |
| | | $order_data[] = [ |
| | | 'wp_order_id' => $order->ID, |
| | | 'square_order_id' => $meta->getValue('square_order_id'), |
| | | 'status' => $meta->getValue('status'), |
| | | 'amount' => $meta->getValue('amount'), |
| | | 'items' => $meta->getValue('items'), |
| | | 'created_at' => $meta->getValue('created_at'), |
| | | 'pickup_time' => $meta->getValue('pickup_time') |
| | | ]; |
| | | } |
| | | |
| | | return [ |
| | | 'success' => true, |
| | | 'orders' => $order_data |
| | | ]; |
| | | } |
| | | |
| | | public function getOrderStatus($request): array |
| | | { |
| | | $order_id = $request->get_param('order_id'); |
| | | |
| | | // Find WP post by Square order ID |
| | | $wp_order_id = get_option(BASE . 'square_order_map_' . $order_id); |
| | | |
| | | if (!$wp_order_id) { |
| | | return ['success' => false, 'message' => 'Order not found']; |
| | | } |
| | | |
| | | $meta = new \JVBase\meta\MetaManager($wp_order_id, 'post'); |
| | | |
| | | return [ |
| | | 'success' => true, |
| | | 'order' => [ |
| | | 'status' => $meta->getValue('status'), |
| | | 'fulfillment_status' => $meta->getValue('fulfillment_status'), |
| | | 'pickup_time' => $meta->getValue('pickup_time'), |
| | | 'items' => $meta->getValue('items') |
| | | ] |
| | | ]; |
| | | } |
| | | } |
| | |
| | | 'jvb-tabs', |
| | | 'jvb-selector', |
| | | 'jvb-uploader', |
| | | 'sortable-js' |
| | | 'sortable-js', |
| | | 'jvb-populate-form' |
| | | ], |
| | | '1.0.0', |
| | | [ |
| | |
| | | JVB_URL.'assets/js/min/navigation.min.js', |
| | | ); |
| | | |
| | | |
| | | |
| | | add_action('wp_head', 'jvbInlineNavStyles'); |
| | | |
| | | wp_enqueue_script('jvb-queue'); |
| | |
| | | * 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); |
| | | } |
| | | |