=Feed block mostly good! Referrals look good to go. Ready for Madi and Heidi to approve
| | |
| | | <?php |
| | | |
| | | use JVBase\integrations\Umami; |
| | | use JVBase\managers\ReferralManager; |
| | | use JVBase\utility\Features; |
| | | |
| | | if (!defined('ABSPATH')) { |
| | | exit; // Exit if accessed directly |
| | |
| | | remove_role('editor'); |
| | | error_log('Roles removed!'); |
| | | error_log('New Roles done!'); |
| | | jvbRegisterAdminPages(); |
| | | if (array_key_exists('integrations', JVB_SITE) && array_key_exists('umami', JVB_SITE['integrations']) && JVB_SITE['integrations']['umami']=== true) { |
| | | error_log('Adding Umami tables'); |
| | | Umami::createTables(); |
| | |
| | | do_action('jvbDailyReset'); |
| | | error_log('Daily options reset completed at ' . current_time('Y-m-d H:i:s')); |
| | | } |
| | | |
| | | |
| | | function jvbRegisterAdminPages():void |
| | | { |
| | | if (Features::forSite()->has('referrals')){ |
| | | ReferralManager::addSubpage(); |
| | | } |
| | | } |
| | |
| | | :target{outline:0!important;padding:0!important}.dashboard>header{justify-content:flex-end}.dashboard>header img{width:var(--height)}.dashboard h1:first-of-type{margin-top:4rem!important}main>footer{max-width:100%!important;position:fixed;z-index:var(--z-top);bottom:0;left:0;right:0;width:100%;margin:4rem 0 0 0!important;height:var(--height);padding:0!important;background-color:var(--base);box-shadow:var(--shadow)}main>*{max-width:min(768px,90vw)!important;margin:0 auto!important}main h1{margin:0!important;font-size:var(--large)}.item-grid .item{position:relative}img{width:100%;height:auto;aspect-ratio:1;object-fit:cover}.replace{margin-bottom:var(--offHeight)!important}.item-grid{margin-bottom:4rem}.item-grid:has(.select-item:checked) .item{padding:.75rem;opacity:.8;filter:var(--filter)}.item-grid .item:has(.select-item:checked){padding:.5rem;filter:none;opacity:1;background-color:var(--action-0)}.grid-view .item>input[type=checkbox]:not(.label-button)+label{padding-left:0;margin:0}.grid-view .item>input[type=checkbox]+label::before{transform:unset;top:.5rem;left:.5rem}.grid-view .item>input[type=checkbox]+label::after{top:.5rem;left:.75rem;transform:translateY(20%) rotate(45deg)}.grid-view .item .item-actions{position:absolute;bottom:0;right:0}.list-view h3,.list-view p{margin:0!important}@media (min-width:768px){.grid-view{grid-template-columns:repeat(auto-fill,minmax(200px,1fr))}.grid-view .item .item-actions{bottom:unset;top:0}}.bulk-controls{margin:1rem 0}.bulk-controls .selected-count{font-weight:400;font-size:var(--small);text-transform:none;font-style:italic;display:flex;gap:.25rem;margin-left:2rem}.selected-count::before{content:'{'}.selected-count::after{content:'}'}.bulk-edit-form .selected{display:grid;grid-template-columns:repeat(auto-fill,minmax(100px,1fr));gap:4px}.selected label{padding:.5rem;opacity:.6;filter:var(--filter);border:2px solid transparent;transition:filter var(--transition-base),opacity var(--transition-base),border var(--transition-base),padding var(--transition-base)}.selected label:has(:checked){border-color:var(--action-0);padding:0;opacity:1;filter:none;transition:filter var(--transition-base),opacity var(--transition-base),border var(--transition-base),padding var(--transition-base)}form.table img,form.table label.select-item{width:6rem;height:6rem}form.table .item-grid.preview{margin:0}.timeline-point.is-dragging{opacity:.4;position:relative}.timeline-point.drop-above{position:relative}.timeline-point.drop-above::before{content:'';position:absolute;top:-4px;left:0;right:0;height:8px;background:var(--primary-color,#06c);border-radius:4px;z-index:10;animation:pulse .6s ease-in-out infinite}.timeline-point.drop-below{position:relative}.timeline-point.drop-below::after{content:'';position:absolute;bottom:-4px;left:0;right:0;height:8px;background:var(--primary-color,#06c);border-radius:4px;z-index:10;animation:pulse .6s ease-in-out infinite}@keyframes pulse{0%,100%{opacity:.6;transform:scaleY(1)}50%{opacity:1;transform:scaleY(1.2)}}.timeline-point.drop-above{margin-top:8px;transition:margin-top .2s ease}.timeline-point.drop-below{margin-bottom:8px;transition:margin-bottom .2s ease}.drag-handle{cursor:grab;padding:.5rem;background:0 0;border:none;opacity:.6;transition:opacity .2s ease}.drag-handle:hover{opacity:1}.drag-handle:active,.is-dragging .drag-handle{cursor:grabbing}.drag-preview .drag-handle{pointer-events:none}.all-filters{margin:2rem 0;padding:1rem 0;border-top:1px solid var(--base-200);border-bottom:1px solid var(--base-200)}details.uploader+.items-list .all-filters{border-top:none}.all-filters .filters{width:100%}.controls .radio-options,.filters.row.start{--align:center;--justify:flex-start;--gap:.5rem}.all-filters span.label{text-transform:uppercase;font-size:var(--small);font-weight:900;width:15vw;display:inline-flex;align-items:center;padding-right:2rem}.controls .icon{--w:1.4rem}.all-filters .btn+label,.all-filters button{height:fit-content;padding:.5rem!important;min-width:0;min-height:0}.all-filters .btn+label:focus,.all-filters .btn+label:hover,.all-filters button:focus,.all-filters button:hover{background-color:transparent;color:var(--action-0);border-color:var(--action-0)}.search-container:not(.open) .clear-search,.search-container:not(.open) input[type=search]{transform:scaleX(0);transform-origin:left;width:0;padding:0;transition:transform var(--transition-base),width var(--transition-base),padding var(--transition-base)}.search-container button{padding:.5rem}.search-container .icon{--w:1.5rem}.search-container.open .clear-search,.search-container.open input[type=search]{transform:scaleX(1);transform-origin:left;transition:transform var(--transition-base),width var(--transition-base),padding var(--transition-base)}.all-filters>.search,.search-container,input[type=search]{width:100%}form.table textarea{width:250px;padding:.5rem}.multi-select summary{--gap:2rem;padding-right:2.5rem}dialog.bulk-edit[open],dialog.create[open],dialog.edit[open]{height:85vh;top:5vh}.tab-content h2{display:none}.group-fields.hours .group-fields,.group-fields.hours .group-fields .field{display:flex;justify-content:space-between;align-items:center}.group-fields.hours .group-fields{padding:1rem .5rem;gap:1rem}.group-fields.hours .group-fields:nth-of-type(2n+1){background-color:var(--base)}.group-fields.hours .group-fields .field{margin:0}.group-fields.hours .true-false{flex:1}.group-fields.hours .time{position:relative}.group-fields.hours .time label{margin:0;font-size:var(--small);position:absolute;top:-1rem;left:0;color:var(--contrast-200)}.today_hours{width:min(500px,90vw)}.today_hours .group-fields{width:100%;padding:0;display:flex;justify-content:center;gap:.5rem}@media (min-width:768px){.today_hours .group-fields{padding:2rem}}.today_hours .field{margin:0}.dash .true-false{margin:0}.dash [type=submit]{width:90%}.dashboard.dash h2{text-transform:none;font-size:var(--large)}.dashboard.dash .replace>ul{display:flex;list-style:none;align-items:flex-start;justify-content:flex-start;flex-wrap:wrap;gap:.5rem}.dashboard.settings nav.tabs{--height:3.5rem;--x:var(--offHeight);position:fixed;bottom:var(--height);left:var(--x);right:var(--x);z-index:99;width:calc(100% - var(--x) - var(--x));background-color:var(--base)}nav.integrations,nav.integrations a,nav.integrations li,nav.integrations ul{height:auto}.replace{overflow:hidden}body.dash form#options{display:flex;flex-flow:column nowrap;justify-content:center;align-items:center}.item-grid.integrations{grid-template-columns:repeat(2,1fr);gap:2rem}.integration{background:var(--base);border:2px solid var(--base-200);border-radius:var(--outerRadius);padding:1rem;position:relative;transition:all var(--transition-base);box-shadow:var(--shadow)}.integration.connected{border-color:var(--success)}.integration.disconnected,.integration.error{border-color:var(--error)}.integration.hasChanges{border-color:var(--warning)}.integration .header{margin-bottom:.75rem;padding-bottom:.75rem;border-bottom:2px solid var(--base-200)}.integration h3{letter-spacing:1px;font-size:var(--medium);margin:0}.integration .meta{margin-bottom:1rem;text-align:right;color:var(--contrast-200);font-size:var(--small)}.integration .setup{font-size:var(--small);font-weight:700;text-transform:uppercase}.integration .setup .indicator{font-size:var(--medium)}.integration .connected .indicator,.integration .setup .connected{color:var(--success)}.integration .disconnected .indicator,.integration .setup .disconnected{color:var(--error)}.integration.hasChanges .disconnected{color:var(--warning)}.connection-status.connected{background-color:var(--successBack);color:var(--successText)}.connection-status.disconnected{background-color:var(--errorBack);color:var(--errorText)}.integration code{display:inline-block;width:90%;margin:0 .5rem;user-select:all;padding:.75rem;border:2px solid var(--base);background-color:var(--base-200);word-break:break-all}.integration details+details{margin-top:1rem}.integration .actions{margin-top:1rem}.hint{line-height:1.2;font-style:italic;font-size:var(--small)}.hasChanges button[data-action=save_credentials]{border-color:var(--warning);animation:pulse-color 1s infinite;animation-delay:1s}.flash{animation:flash .5s}.flash.connected{--b:var(--success)}.flash.disconnected{--b:var(--error)}.flash.syncing{--b:var(--success)}.flash.error,.flash.hasChanges{--b:var(--warning)}@keyframes flash{0%,100%{border-color:inherit}50%{border-color:var(--b)}}.location.field{width:80vw}.location.field>p{text-align:center}.location.field>p+p{margin:0 .5rem 0 0}.location.field .location-map{height:20vh}.location.field .location-links{padding:.5rem 0;display:flex;justify-content:space-evenly}.field.upload [data-upload-id],.item-grid .item{touch-action:none}.empty-state{grid-column:1/-1;padding:1rem 10vw;margin:0 10vw;border-radius:var(--outerRadius);background-color:var(--base-100)}.jvb-oauth-connect{position:relative;transition:opacity .2s}.jvb-oauth-connect.loading{opacity:.6;pointer-events:none}.jvb-oauth-connect.loading::after{content:'';position:absolute;right:-30px;top:50%;transform:translateY(-50%);width:16px;height:16px;border:2px solid #ccc;border-top-color:#0073aa;border-radius:50%;animation:oauth-spin .8s linear infinite}@keyframes oauth-spin{to{transform:translateY(-50%) rotate(360deg)}}.integration-status-message{padding:12px 16px;margin:16px 0;border-radius:4px;display:none;font-size:14px;line-height:1.5}.integration-status-message.success{display:block;background:#d4edda;color:#155724;border-left:4px solid #28a745}.integration-status-message.error{display:block;background:#f8d7da;color:#721c24;border-left:4px solid #dc3545}.integration-status-message.info{display:block;background:#d1ecf1;color:#0c5460;border-left:4px solid #17a2b8}.connection-status{display:inline-flex;align-items:center;gap:8px;padding:6px 12px;border-radius:4px;font-size:13px;font-weight:500}.connection-status.connected{background:#d4edda;color:#155724}.connection-status.disconnected{background:#f8d7da;color:#721c24}.status-indicator{font-size:10px;line-height:1}.connection-status.connected .status-indicator{color:#28a745}.connection-status.disconnected .status-indicator{color:#dc3545} |
| | | :target{outline:0!important;padding:0!important}.dashboard>header{justify-content:flex-end}.dashboard>header img{width:var(--height)}.dashboard h1:first-of-type{margin-top:4rem!important}main>footer{max-width:100%!important;position:fixed;z-index:var(--z-top);bottom:0;left:0;right:0;width:100%;margin:4rem 0 0 0!important;height:var(--height);padding:0!important;background-color:var(--base);box-shadow:var(--shadow)}main>*{max-width:min(768px,90vw)!important;margin:0 auto!important}main h1{margin:0!important;font-size:var(--large)}.item-grid .item{position:relative}img{width:100%;height:auto;aspect-ratio:1;object-fit:cover}.replace{margin-bottom:var(--offHeight)!important}.item-grid{margin-bottom:4rem}.item-grid:has(.select-item:checked) .item{padding:.75rem;opacity:.8;filter:var(--filter)}.item-grid .item:has(.select-item:checked){padding:.5rem;filter:none;opacity:1;background-color:var(--action-0)}.grid-view .item>input[type=checkbox]:not(.label-button)+label{padding-left:0;margin:0}.grid-view .item>input[type=checkbox]+label::before{transform:unset;top:.5rem;left:.5rem}.grid-view .item>input[type=checkbox]+label::after{top:.5rem;left:.75rem;transform:translateY(20%) rotate(45deg)}.grid-view .item .item-actions{position:absolute;bottom:0;right:0}.list-view h3,.list-view p{margin:0!important}@media (min-width:768px){.grid-view{grid-template-columns:repeat(auto-fill,minmax(200px,1fr))}.grid-view .item .item-actions{bottom:unset;top:0}}.bulk-controls{margin:1rem 0}.bulk-controls .selected-count{font-weight:400;font-size:var(--small);text-transform:none;font-style:italic;display:flex;gap:.25rem;margin-left:2rem}.selected-count::before{content:'{'}.selected-count::after{content:'}'}.bulk-edit-form .selected{display:grid;grid-template-columns:repeat(auto-fill,minmax(100px,1fr));gap:4px}.selected label{padding:.5rem;opacity:.6;filter:var(--filter);border:2px solid transparent;transition:filter var(--transition-base),opacity var(--transition-base),border var(--transition-base),padding var(--transition-base)}.selected label:has(:checked){border-color:var(--action-0);padding:0;opacity:1;filter:none;transition:filter var(--transition-base),opacity var(--transition-base),border var(--transition-base),padding var(--transition-base)}form.table img,form.table label.select-item{width:6rem;height:6rem}form.table .item-grid.preview{margin:0}.timeline-point.is-dragging{opacity:.4;position:relative}.timeline-point.drop-above{position:relative}.timeline-point.drop-above::before{content:'';position:absolute;top:-4px;left:0;right:0;height:8px;background:var(--primary-color,#06c);border-radius:4px;z-index:10;animation:pulse .6s ease-in-out infinite}.timeline-point.drop-below{position:relative}.timeline-point.drop-below::after{content:'';position:absolute;bottom:-4px;left:0;right:0;height:8px;background:var(--primary-color,#06c);border-radius:4px;z-index:10;animation:pulse .6s ease-in-out infinite}@keyframes pulse{0%,100%{opacity:.6;transform:scaleY(1)}50%{opacity:1;transform:scaleY(1.2)}}.timeline-point.drop-above{margin-top:8px;transition:margin-top .2s ease}.timeline-point.drop-below{margin-bottom:8px;transition:margin-bottom .2s ease}.drag-handle{cursor:grab;padding:.5rem;background:0 0;border:none;opacity:.6;transition:opacity .2s ease}.drag-handle:hover{opacity:1}.drag-handle:active,.is-dragging .drag-handle{cursor:grabbing}.drag-preview .drag-handle{pointer-events:none}.all-filters{margin:2rem 0;padding:1rem 0;border-top:1px solid var(--base-200);border-bottom:1px solid var(--base-200)}details.uploader+.items-list .all-filters{border-top:none}.all-filters .filters{width:100%}.controls .radio-options,.filters.row.start{--align:center;--justify:flex-start;--gap:.5rem}.all-filters span.label{text-transform:uppercase;font-size:var(--small);font-weight:900;width:15vw;display:inline-flex;align-items:center;padding-right:2rem}.controls .icon{--w:1.4rem}.all-filters .btn+label,.all-filters button{height:fit-content;padding:.5rem!important;min-width:0;min-height:0}.all-filters .btn+label:focus,.all-filters .btn+label:hover,.all-filters button:focus,.all-filters button:hover{background-color:transparent;color:var(--action-0);border-color:var(--action-0)}.search-container:not(.open) .clear-search,.search-container:not(.open) input[type=search]{transform:scaleX(0);transform-origin:left;width:0;padding:0;transition:transform var(--transition-base),width var(--transition-base),padding var(--transition-base)}.search-container button{padding:.5rem}.search-container .icon{--w:1.5rem}.search-container.open .clear-search,.search-container.open input[type=search]{transform:scaleX(1);transform-origin:left;transition:transform var(--transition-base),width var(--transition-base),padding var(--transition-base)}.all-filters>.search,.search-container,input[type=search]{width:100%}form.table textarea{width:250px;padding:.5rem}.multi-select summary{--gap:2rem;padding-right:2.5rem}dialog.bulk-edit[open],dialog.create[open],dialog.edit[open]{height:85vh;top:5vh}.tab-content h2{display:none}.group-fields.hours .group-fields,.group-fields.hours .group-fields .field{display:flex;justify-content:space-between;align-items:center}.group-fields.hours .group-fields{padding:1rem .5rem;gap:1rem}.group-fields.hours .group-fields:nth-of-type(2n+1){background-color:var(--base)}.group-fields.hours .group-fields .field{margin:0}.group-fields.hours .true-false{flex:1}.group-fields.hours .time{position:relative}.group-fields.hours .time label{margin:0;font-size:var(--small);position:absolute;top:-1rem;left:0;color:var(--contrast-200)}.today_hours{width:min(500px,90vw)}.today_hours .group-fields{width:100%;padding:0;display:flex;justify-content:center;gap:.5rem}@media (min-width:768px){.today_hours .group-fields{padding:2rem}}.today_hours .field{margin:0}.dash .true-false{margin:0}.dash [type=submit]{width:90%}.dashboard.dash h2{text-transform:none;font-size:var(--large)}.dashboard.dash .replace>ul{display:flex;list-style:none;align-items:flex-start;justify-content:flex-start;flex-wrap:wrap;gap:.5rem}.dashboard.settings nav.tabs{--height:3.5rem;--x:var(--offHeight);position:fixed;bottom:var(--height);left:var(--x);right:var(--x);z-index:99;width:calc(100% - var(--x) - var(--x));background-color:var(--base)}nav.integrations,nav.integrations a,nav.integrations li,nav.integrations ul{height:auto}.replace{overflow:hidden}body.dash form#options{display:flex;flex-flow:column nowrap;justify-content:center;align-items:center}.item-grid.integrations{grid-template-columns:repeat(2,1fr);gap:2rem}.integration{background:var(--base);border:2px solid var(--base-200);border-radius:var(--outerRadius);padding:1rem;position:relative;transition:all var(--transition-base);box-shadow:var(--shadow)}.integration.connected{border-color:var(--success)}.integration.disconnected,.integration.error{border-color:var(--error)}.integration.hasChanges{border-color:var(--warning)}.integration .header{margin-bottom:.75rem;padding-bottom:.75rem;border-bottom:2px solid var(--base-200)}.integration h3{letter-spacing:1px;font-size:var(--medium);margin:0}.integration .meta{margin-bottom:1rem;text-align:right;color:var(--contrast-200);font-size:var(--small)}.integration .setup{font-size:var(--small);font-weight:700;text-transform:uppercase}.integration .setup .indicator{font-size:var(--medium)}.integration .connected .indicator,.integration .setup .connected{color:var(--success)}.integration .disconnected .indicator,.integration .setup .disconnected{color:var(--error)}.integration.hasChanges .disconnected{color:var(--warning)}.connection-status.connected{background-color:var(--successBack);color:var(--successText)}.connection-status.disconnected{background-color:var(--errorBack);color:var(--errorText)}.integration code{display:inline-block;width:90%;margin:0 .5rem;user-select:all;padding:.75rem;border:2px solid var(--base);background-color:var(--base-200);word-break:break-all}.integration details+details{margin-top:1rem}.integration .actions{margin-top:1rem}.hint{line-height:1.2;font-style:italic;font-size:var(--small)}.hasChanges button[data-action=save_credentials]{border-color:var(--warning);animation:pulse-color 1s infinite;animation-delay:1s}.flash{animation:flash .5s}.flash.connected{--b:var(--success)}.flash.disconnected{--b:var(--error)}.flash.syncing{--b:var(--success)}.flash.error,.flash.hasChanges{--b:var(--warning)}@keyframes flash{0%,100%{border-color:inherit}50%{border-color:var(--b)}}.location.field{width:80vw}.location.field>p{text-align:center}.location.field>p+p{margin:0 .5rem 0 0}.location.field .location-map{height:20vh}.location.field .location-links{padding:.5rem 0;display:flex;justify-content:space-evenly}.field.upload [data-upload-id],.item-grid .item{touch-action:none}.empty-state{grid-column:1/-1;padding:1rem 10vw;margin:0 10vw;border-radius:var(--outerRadius);background-color:var(--base-100)}.jvb-oauth-connect{position:relative;transition:opacity .2s}.jvb-oauth-connect.loading{opacity:.6;pointer-events:none}.jvb-oauth-connect.loading::after{content:'';position:absolute;right:-30px;top:50%;transform:translateY(-50%);width:16px;height:16px;border:2px solid #ccc;border-top-color:#0073aa;border-radius:50%;animation:oauth-spin .8s linear infinite}@keyframes oauth-spin{to{transform:translateY(-50%) rotate(360deg)}}.integration-status-message{padding:12px 16px;margin:16px 0;border-radius:4px;display:none;font-size:14px;line-height:1.5}.integration-status-message.success{display:block;background:#d4edda;color:#155724;border-left:4px solid #28a745}.integration-status-message.error{display:block;background:#f8d7da;color:#721c24;border-left:4px solid #dc3545}.integration-status-message.info{display:block;background:#d1ecf1;color:#0c5460;border-left:4px solid #17a2b8}.connection-status{display:inline-flex;align-items:center;gap:8px;padding:6px 12px;border-radius:4px;font-size:13px;font-weight:500}.connection-status.connected{background:#d4edda;color:#155724}.connection-status.disconnected{background:#f8d7da;color:#721c24}.status-indicator{font-size:10px;line-height:1}.connection-status.connected .status-indicator{color:#28a745}.connection-status.disconnected .status-indicator{color:#dc3545}.referral-dashboard{max-width:1200px;margin:0 auto}.referral-header{text-align:center;margin-bottom:30px}.referral-code-card{background:var(--base-100);padding:30px;border-radius:8px;text-align:center;margin-bottom:30px}.code-display{display:flex;align-items:center;justify-content:center;gap:15px;margin:20px 0}.code-display .code{font-size:32px;font-weight:700;letter-spacing:2px;color:var(--action-0);user-select:all}.stats-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:20px;margin-bottom:30px}.stat-card{background:#fff;padding:25px;border-radius:8px;border:1px solid #ddd;text-align:center}.stat-card.highlight{background:#d4edda;border-color:#c3e6cb}.stat-card h4{margin:0 0 10px 0;color:#666;font-size:14px;font-weight:600;text-transform:uppercase}.stat-number{font-size:36px;font-weight:700;color:#2271b1}.referrals-list-card{background:#fff;padding:25px;border-radius:8px;border:1px solid #ddd}.referrals-table{width:100%;border-collapse:collapse;margin-top:15px}.referrals-table td,.referrals-table th{padding:12px;text-align:left;border-bottom:1px solid #eee}.referrals-table th{background:#f5f5f5;font-weight:600}.status-badge{padding:4px 12px;border-radius:12px;font-size:12px;font-weight:500}.status-badge.pending{background:#fff3cd;color:#856404}.status-badge.consulted{background:#d1ecf1;color:#0c5460}.status-badge.treated{background:#d4edda;color:#155724} |
| | |
| | | nav{--py:.25rem;--px:1rem;max-width:100%;font-family:var(--heading)}nav,nav a,nav li,nav ol,nav ul{height:var(--height);display:flex;justify-content:var(--justify);align-items:var(--align);gap:var(--gap);flex-wrap:var(--wrap);flex-direction:var(--dir)}ul.socials{--w:1.2em;--height:fit-content;gap:.5rem;display:flex;justify-content:center;align-items:center;flex-wrap:nowrap;flex-direction:row;overflow:auto hidden;touch-action:pan-x;list-style:none}nav:not(:has(>ul)),nav>ul{--justify:flex-start;--align:center;--wrap:nowrap;--w:1em;--dir:row;position:relative;overflow:auto hidden;touch-action:pan-x}nav a{padding:0 var(--px);white-space:nowrap;text-transform:uppercase}nav .current a,nav a.current,nav a:focus,nav a:focus:visited,nav a:hover,nav a:hover:visited{background-color:var(--action-0);color:var(--action-contrast);transition:background-color var(--transition-base),color var(--transition-base)}nav ol,nav ul{list-style:none;margin:0;padding:0}.has-submenu button:hover,nav a:hover{background-color:var(--action-0);color:var(--action-contrast)}.has-submenu button{height:var(--height);width:var(--height);padding:0;border:2px solid var(--base);color:var(--contrast);border-radius:0}.toggle .icon{transform:rotate(0);transition:transform var(--timing) var(--function);transition-property:transform,background-color,color}.has-submenu.open>button:not(.notifications,.quick-help) .icon,.has-submenu:hover>button:not(.notifications,.quick-help) .icon{transform:rotate(900deg)}ul.submenu{--dir:column;--wrap:nowrap;--gap:0;position:absolute;top:100%;left:0;max-height:0;transform:scaleY(0);transform-origin:top;width:max-content;min-width:100%;background-color:var(--overlay-light);border:2px solid var(--overlay-light);transition:all var(--timing) var(--function);box-shadow:var(--shadow-none)}.always ul.submenu{position:relative;top:0;left:0}.submenu li{background-color:var(--overlay-heavy);border:1px solid var(--base-50)}.submenu li:hover{--c:var(--action-rgb);background-color:var(--overlay-heavy)}.submenu a:hover{background-color:transparent}.wp-site-blocks>header ul.submenu{right:0;left:auto}.has-submenu.open>ul.submenu,.has-submenu:hover>ul.submenu{transform:scaleY(1);max-height:1000%;box-shadow:var(--shadow)}nav.fixed.bottom,nav.on-this-page{--dir:row;--gap:0;width:calc(100% - var(--height));left:0;bottom:0;position:fixed;box-shadow:var(--shadow);z-index:var(--zz-top)}nav.fixed.bottom ul{width:100%;--justify:space-between;background-color:var(--base);padding:0 .25rem}nav.fixed a,nav.fixed li{--justify:center;width:100%}nav.fixed.bottom a,nav.fixed.bottom a:visited{color:var(--contrast);font-size:var(--small);padding:0}@media (min-width:768px){nav.fixed.bottom a,nav.fixed.bottom a:visited{font-size:var(--medium)}}nav.fixed.bottom a:focus,nav.fixed.bottom a:focus:visited,nav.fixed.bottom a:hover,nav.fixed.bottom a:hover:visited{color:var(--action-contrast)}.fixed.bottom li{flex:1}nav.always a{padding:0;--justify:center}nav.always .socials{width:100%}nav.always .socials li{width:100%}nav.always li{gap:0;--justify:flex-start}nav.always>ul>li>a{width:80%}nav.always .submenu{width:80%;min-width:80%;box-shadow:none!important;border:2px solid var(--action-0);background-color:rgba(var(--contrast-rgb),var(--rgb-subtle))}nav.always .submenu li{background-color:var(--overlay-light)}nav.always .has-submenu>a,nav.fixed .has-submenu>a{width:80%}.has-submenu>button{width:20%}nav#breadcrumbs{--height:1.5em;--w:20px;width:fit-content;max-width:var(--full);position:absolute;background-color:var(--overlay-medium);font-size:var(--small);padding:.125em;overflow:visible;--gap:0}nav#breadcrumbs li+li::before{content:'/';color:var(--contrast-200)}nav#breadcrumbs li:last-of-type{margin-right:.5em}nav#breadcrumbs a,nav#breadcrumbs span{padding:0 .125rem;white-space:nowrap;height:2em;color:var(--contrast);text-transform:none;width:max-content}nav#breadcrumbs span{display:flex;align-items:center;padding-left:.5em}nav#breadcrumbs a:focus,nav#breadcrumbs a:focus:visited,nav#breadcrumbs a:hover,nav#breadcrumbs a:hover:visited{background-color:transparent;color:var(--action-0)}nav#breadcrumbs a:has(.icon){width:2rem}nav.always{z-index:var(--z-above);position:fixed;width:var(--height);bottom:0;right:0}nav.always.open{width:100vw;height:100vh;padding-bottom:var(--offHeight);background-color:var(--overlay-heavy);backdrop-filter:blur(5px);justify-content:flex-end;flex-direction:column;z-index:var(--z-above)}nav.always>ul{--dir:column;--wrap:nowrap;--justify:flex-start;--align:center;--gap:0;position:relative;right:-300vw;padding:1rem 0 0;width:100vw;height:fit-content;max-height:100%;overflow:hidden auto;transition:right var(--transition-base)}nav.always.open>ul{right:0;transition:right var(--transition-base)}nav.always li{max-inline-size:none;width:100%;height:fit-content;--dir:row;--wrap:wrap}nav.always a{--py:1rem;width:100%;min-height:var(--height)}nav.always>button{position:fixed;bottom:0;right:0;width:var(--height);height:var(--height);border-radius:0;background-color:var(--base);color:var(--contrast);transition:width var(--timing) var(--function);transition-property:width,background-color;box-shadow:var(--shadow)}nav.always>button:hover{background-color:var(--action-0);color:var(--action-contrast)}nav.always.open>button{--c:var(--action-rgb);z-index:1000000;width:100%;background-color:var(--overlay-heavy);color:var(--contrast);backdrop-filter:blur(5px)}nav.always.open>button:focus,nav.always.open>button:hover{background-color:var(--action-0);color:var(--action-contrast)}nav.always.open>button .icon-list,nav.always>button .icon-x{transform:scale(0);height:0;width:0;position:absolute}nav.always.open>button .icon-x,nav.always>button .icon-list{transform:scale(1);height:32px;width:32px}nav.always .has-submenu.open>.submenu,nav.always .has-submenu:hover>.submenu{height:max-content}nav.always .has-submenu:hover>a,nav.always .submenu>li>a:focus,nav.always .submenu>li>a:hover{background-color:var(--action-0);color:var(--action-contrast)}@media (min-width:768px){nav.always>ul{padding:var(--height) 0 0}}nav.on-this-page{--justify:space-between;max-width:none;z-index:var(--z-6);margin:0;padding:0 .5rem;background-color:var(--overlay-medium);color:var(--base-200)}body:has(nav.fixed) nav.on-this-page{bottom:var(--offHeight)}.on-this-page ul{--justify:flex-start;gap:0;width:100%}.on-this-page li:not(.has){padding:0}nav.letters li{width:100%;max-width:calc(7.69% - 2px)}.on-this-page .active a{--c:var(--action-rgb);background-color:var(--overlay-heavy);color:var(--action-contrast)}@media (min-width:768px){nav.letters li{max-width:none;width:fit-content}nav.letters a,nav.letters li:not(.has){padding:.25rem .66rem}}nav.index{--justify:flex-start;--px:0;background-color:var(--overlay-heavy)}.index ul{--justify:flex-start;width:fit-content}.index li{flex-shrink:0;transform:scaleX(0);transform-origin:right;max-width:0;overflow:hidden;transition:transform var(--timing) var(--function)}.index li.active{transform:scaleX(1);transform-origin:left;width:100%;flex-shrink:1;max-width:fit-content}@media (min-width:768px){.index li.adj{transform:scaleX(1);transform-origin:left;width:100%;flex-shrink:1;max-width:fit-content}}.index a{border-bottom:4px solid transparent}.index .active a{border-color:var(--action-0);color:var(--contrast)}.index .active a:hover,.index a:hover{background-color:var(--action-0);color:var(--action-contrast)}.index label{display:flex;color:var(--contrast);align-items:center;margin:0}.index label button{margin-left:1em}.index.open{--dir:column-reverse;height:calc(100% - 8rem);z-index:var(--z-above);width:100%;background-color:var(--overlay-heavy);backdrop-filter:blur(5px);align-items:flex-end}.index.open label{max-width:90%;margin-top:1rem;margin-right:2rem}.index.open .toggle .icon{transform:rotate(45deg)}.index.open ul{--dir:column;--justify:flex-end;height:100%;max-width:100%;width:100%}.index.open li{background-color:transparent;max-width:100%!important;width:100%;height:var(--height);transform:scaleX(1);flex-shrink:1;overflow:visible}.index.open a{--justify:flex-end;background-color:transparent;padding:0 2rem 0 0}.condensed{--dir:row;--wrap:wrap;--height:1.2em;--py:.25rem;--px:.25rem;height:fit-content}.condensed>ul{--wrap:wrap;height:fit-content}.condensed ul{--justify:center;--gap:0}.condensed li{width:fit-content}.condensed li+li::before{content:'·';display:block;padding:0 .25em}nav.condensed a{text-transform:none;white-space:nowrap;border-bottom:2px solid transparent}.condensed a:focus,.condensed a:focus:visited,.condensed a:hover,.condensed a:hover:visited{border-color:var(--action-0)}.dashboard-nav{width:100%;--dir:row;--justify:flex-start;--wrap:nowrap}.wp-site-blocks>header,body>header{--dir:row;position:sticky;top:0;left:0;right:0;height:var(--height);width:100vw;display:flex;justify-content:space-between;align-items:center;padding:0 .5rem;background-color:var(--base);z-index:var(--zz-top);box-shadow:var(--shadow);border-bottom:1px solid var(--action-0)}.wp-site-blocks>header img{width:var(--height)}body>header{justify-content:space-between}header .title{--w:5em;margin:0;position:absolute;width:100%;height:100%;display:flex;justify-content:center;align-items:flex-start;max-inline-size:none}.current-hours{position:sticky;top:var(--height);bottom:unset;width:unset;z-index:100;background-color:var(--action-0);color:var(--action-contrast);box-shadow:var(--shadow);padding:.25rem 1rem;display:flex;justify-content:space-between}.current-hours p{margin:0;display:flex;flex-wrap:wrap;flex:1}.current-hours p+p{justify-content:flex-end}.current-hours a{color:var(--action-contrast)}.current-hours a:hover{color:var(--action-200)}.current-hours b{margin-right:.25rem}.find-us{display:flex;align-items:center;gap:0 .5rem}.find-us a{display:flex;padding:.25rem 1rem;border:1px solid var(--action-contrast);border-radius:var(--innerRadius)}.find-us a:hover{background-color:var(--base);color:var(--contrast);border-color:var(--contrast)}nav.menu{--justify:flex-start}nav.menu a{padding:.5rem .66rem}nav.tabs{--gap:0;--wrap:nowrap;padding-bottom:2px;z-index:var(--z-6);position:fixed;bottom:var(--height);left:var(--doubleHeight);right:var(--doubleHeight)}nav.term-navigation:has([hidden]){display:none}ul.socials a{padding:.5rem}ul.socials a .icon{margin:0} |
| | | nav{--py:.25rem;--px:1rem;max-width:100%;font-family:var(--heading)}nav,nav a,nav li,nav ol,nav ul{height:var(--height);display:flex;justify-content:var(--justify);align-items:var(--align);gap:var(--gap);flex-wrap:var(--wrap);flex-direction:var(--dir)}ul.socials{--w:1.2em;--height:fit-content;gap:.5rem;display:flex;justify-content:center;align-items:center;flex-wrap:nowrap;flex-direction:row;overflow:auto hidden;touch-action:pan-x;list-style:none}nav:not(:has(>ul)),nav>ul{--justify:flex-start;--align:center;--wrap:nowrap;--w:1em;--dir:row;position:relative;overflow:auto hidden;touch-action:pan-x}nav a{padding:0 var(--px);white-space:nowrap;text-transform:uppercase}nav .current a,nav a.current,nav a:focus,nav a:focus:visited,nav a:hover,nav a:hover:visited{background-color:var(--action-0);color:var(--action-contrast);transition:background-color var(--transition-base),color var(--transition-base)}nav ol,nav ul{list-style:none;margin:0;padding:0}.has-submenu button:hover,nav a:hover{background-color:var(--action-0);color:var(--action-contrast)}.has-submenu button{height:var(--height);width:var(--height);padding:0;border:2px solid var(--base);color:var(--contrast);border-radius:0}.toggle .icon{transform:rotate(0);transition:transform var(--timing) var(--function);transition-property:transform,background-color,color}.has-submenu.open>button:not(.notifications,.quick-help) .icon,.has-submenu:hover>button:not(.notifications,.quick-help) .icon{transform:rotate(900deg)}ul.submenu{--dir:column;--wrap:nowrap;--gap:0;position:absolute;top:100%;left:0;max-height:0;transform:scaleY(0);transform-origin:top;width:max-content;min-width:100%;background-color:var(--overlay-light);border:2px solid var(--overlay-light);transition:all var(--timing) var(--function);box-shadow:var(--shadow-none)}.always ul.submenu{position:relative;top:0;left:0}.submenu li{background-color:var(--overlay-heavy);border:1px solid var(--base-50)}.submenu li:hover{--c:var(--action-rgb);background-color:var(--overlay-heavy)}.submenu a:hover{background-color:transparent}.wp-site-blocks>header ul.submenu{right:0;left:auto}.has-submenu.open>ul.submenu,.has-submenu:hover>ul.submenu{transform:scaleY(1);max-height:1000%;box-shadow:var(--shadow)}nav.fixed.bottom,nav.on-this-page{--dir:row;--gap:0;width:calc(100% - var(--height));left:0;bottom:0;position:fixed;box-shadow:var(--shadow);z-index:var(--zz-top)}nav.fixed.bottom ul{width:100%;--justify:space-between;background-color:var(--base);padding:0 .25rem}nav.fixed a,nav.fixed li{--justify:center;width:100%}nav.fixed.bottom a,nav.fixed.bottom a:visited{color:var(--contrast);font-size:var(--small);padding:0}@media (min-width:768px){nav.fixed.bottom a,nav.fixed.bottom a:visited{font-size:var(--medium)}}nav.fixed.bottom a:focus,nav.fixed.bottom a:focus:visited,nav.fixed.bottom a:hover,nav.fixed.bottom a:hover:visited{color:var(--action-contrast)}.fixed.bottom li{flex:1}nav.always a{padding:0;--justify:center}nav.always .socials{width:100%}nav.always .socials li{width:100%}nav.always li{gap:0;--justify:flex-start}nav.always>ul>li>a{width:80%}nav.always .submenu{width:80%;min-width:80%;box-shadow:none!important;border:2px solid var(--action-0);background-color:rgba(var(--contrast-rgb),var(--rgb-subtle))}nav.always .submenu li{background-color:var(--overlay-light)}nav.always .has-submenu>a,nav.fixed .has-submenu>a{width:80%}.has-submenu>button{width:20%}nav#breadcrumbs{--height:1.5em;--w:20px;width:fit-content;max-width:var(--full);position:absolute;background-color:var(--overlay-medium);font-size:var(--small);padding:.125em;overflow:visible;--gap:0}nav#breadcrumbs li+li::before{content:'/';color:var(--contrast-200)}nav#breadcrumbs li:last-of-type{margin-right:.5em}nav#breadcrumbs a,nav#breadcrumbs span{padding:0 .125rem;white-space:nowrap;height:2em;color:var(--contrast);text-transform:none;width:max-content}nav#breadcrumbs span{display:flex;align-items:center;padding-left:.5em}nav#breadcrumbs a:focus,nav#breadcrumbs a:focus:visited,nav#breadcrumbs a:hover,nav#breadcrumbs a:hover:visited{background-color:transparent;color:var(--action-0)}nav#breadcrumbs a:has(.icon){width:2rem}nav.always{z-index:var(--z-above);position:fixed;width:var(--height);bottom:0;right:0}nav.always.open{width:100vw;height:100vh;padding-bottom:var(--offHeight);background-color:var(--overlay-heavy);backdrop-filter:blur(5px);justify-content:flex-end;flex-direction:column;z-index:var(--z-above)}nav.always>ul{--dir:column;--wrap:nowrap;--justify:flex-start;--align:center;--gap:0;position:relative;right:-300vw;padding:1rem 0 0;width:100vw;height:fit-content;max-height:100%;overflow:hidden auto;transition:right var(--transition-base)}nav.always.open>ul{right:0;transition:right var(--transition-base)}nav.always li{max-inline-size:none;width:100%;height:fit-content;--dir:row;--wrap:wrap}nav.always a{--py:1rem;width:100%;min-height:var(--height)}nav.always>button{position:fixed;bottom:0;right:0;width:var(--height);height:var(--height);border-radius:0;background-color:var(--base);color:var(--contrast);transition:width var(--timing) var(--function);transition-property:width,background-color;box-shadow:var(--shadow)}nav.always>button:hover{background-color:var(--action-0);color:var(--action-contrast)}nav.always.open>button{--c:var(--action-rgb);z-index:1000000;width:100%;background-color:var(--overlay-heavy);color:var(--contrast);backdrop-filter:blur(5px)}nav.always.open>button:focus,nav.always.open>button:hover{background-color:var(--action-0);color:var(--action-contrast)}nav.always.open>button .icon-list,nav.always>button .icon-x{transform:scale(0);height:0;width:0;position:absolute}nav.always.open>button .icon-x,nav.always>button .icon-list{transform:scale(1);height:32px;width:32px}nav.always .has-submenu.open>.submenu,nav.always .has-submenu:hover>.submenu{height:max-content}nav.always .has-submenu:hover>a,nav.always .submenu>li>a:focus,nav.always .submenu>li>a:hover{background-color:var(--action-0);color:var(--action-contrast)}@media (min-width:768px){nav.always>ul{padding:var(--height) 0 0}}nav.on-this-page{--justify:space-between;max-width:none;z-index:var(--z-6);margin:0;padding:0 .5rem;background-color:var(--overlay-medium);color:var(--base-200)}body:has(nav.fixed) nav.on-this-page{bottom:var(--offHeight)}.on-this-page ul{--justify:flex-start;gap:0;width:100%}.on-this-page li:not(.has){padding:0}nav.letters li{width:100%;max-width:calc(7.69% - 2px)}.on-this-page .active a{--c:var(--action-rgb);background-color:var(--overlay-heavy);color:var(--action-contrast)}@media (min-width:768px){nav.letters li{max-width:none;width:fit-content}nav.letters a,nav.letters li:not(.has){padding:.25rem .66rem}}nav.index{--justify:flex-start;--px:0;background-color:var(--overlay-heavy)}.index ul{--justify:flex-start;width:fit-content}.index li{flex-shrink:0;transform:scaleX(0);transform-origin:right;max-width:0;overflow:hidden;transition:transform var(--timing) var(--function)}.index li.active{transform:scaleX(1);transform-origin:left;width:100%;flex-shrink:1;max-width:fit-content}@media (min-width:768px){.index li.adj{transform:scaleX(1);transform-origin:left;width:100%;flex-shrink:1;max-width:fit-content}}.index a{border-bottom:4px solid transparent}.index .active a{border-color:var(--action-0);color:var(--contrast)}.index .active a:hover,.index a:hover{background-color:var(--action-0);color:var(--action-contrast)}.index label{display:flex;color:var(--contrast);align-items:center;margin:0}.index label button{margin-left:1em}.index.open{--dir:column-reverse;height:calc(100% - 8rem);z-index:var(--z-above);width:100%;background-color:var(--overlay-heavy);backdrop-filter:blur(5px);align-items:flex-end}.index.open label{max-width:90%;margin-top:1rem;margin-right:2rem}.index.open .toggle .icon{transform:rotate(45deg)}.index.open ul{--dir:column;--justify:flex-end;height:100%;max-width:100%;width:100%}.index.open li{background-color:transparent;max-width:100%!important;width:100%;height:var(--height);transform:scaleX(1);flex-shrink:1;overflow:visible}.index.open a{--justify:flex-end;background-color:transparent;padding:0 2rem 0 0}.condensed{--dir:row;--wrap:wrap;--height:1.2em;--py:.25rem;--px:.25rem;height:fit-content}.condensed>ul{--wrap:wrap;height:fit-content}.condensed ul{--justify:center;--gap:0}.condensed li{width:fit-content}.condensed li+li::before{content:'·';display:block;padding:0 .25em}nav.condensed a{text-transform:none;white-space:nowrap;border-bottom:2px solid transparent}.condensed a:focus,.condensed a:focus:visited,.condensed a:hover,.condensed a:hover:visited{border-color:var(--action-0)}.dashboard-nav{width:100%;--dir:row;--justify:flex-start;--wrap:nowrap}.wp-site-blocks>header,body>header{--dir:row;position:sticky;top:0;left:0;right:0;height:var(--height);width:100vw;display:flex;justify-content:space-between;align-items:center;padding:0 .5rem;background-color:var(--base);z-index:var(--zz-top);box-shadow:var(--shadow);border-bottom:1px solid var(--action-0)}.wp-site-blocks>header img{width:var(--height)}body>header{justify-content:space-between}header .title{--w:5em;margin:0;position:absolute;width:100%;height:100%;display:flex;justify-content:center;align-items:flex-start;max-inline-size:none}.current-hours{position:sticky;top:var(--height);bottom:unset;width:unset;z-index:100;background-color:var(--action-0);color:var(--action-contrast);box-shadow:var(--shadow);padding:.25rem 1rem;display:flex;justify-content:space-between}.current-hours p{margin:0;display:flex;flex-wrap:wrap;flex:1}.current-hours p+p{justify-content:flex-end}.current-hours a{color:var(--action-contrast)}.current-hours a:hover{color:var(--action-200)}.current-hours b{margin-right:.25rem}.find-us{display:flex;align-items:center;gap:0 .5rem}.find-us a{display:flex;padding:.25rem 1rem;border:1px solid var(--action-contrast);border-radius:var(--innerRadius)}.find-us a:hover{background-color:var(--base);color:var(--contrast);border-color:var(--contrast)}nav.menu{--justify:flex-start}nav.menu a{padding:.5rem .66rem}nav.tabs{--gap:0;--wrap:nowrap;padding-bottom:2px;z-index:var(--z-6);position:fixed;bottom:var(--height);left:var(--doubleHeight);right:var(--doubleHeight)}nav.term-navigation:has([hidden]){display:none}ul.socials a{padding:.5rem}ul.socials a .icon{margin:0}nav.share{height:max-content;margin:1rem 0;--align:center}nav.share ul{overflow:visible}nav.share h4{display:inline-block;width:max-content;margin:.25rem .5rem .25rem 0;font-size:var(--small)}nav.share .icon{margin-right:0}nav.share .button{position:relative;transition:top var(--transition-base),box-shadow var(--transition-base);top:0;box-shadow:var(--shadow-none)}nav.share .button:hover{top:-4px;box-shadow:var(--shadow-down)} |
| | |
| | | DataStore.instance = this; |
| | | |
| | | // Shared resources |
| | | this.dbConfig = new Map(); // Definitions for the databases |
| | | this.databases = new Map(); // Shared IndexedDB connections |
| | | this.stores = new Map(); // Registered store namespaces |
| | | this.subscribers = new Map(); // Per-store event subscribers |
| | |
| | | |
| | | this.init(); |
| | | |
| | | window.addEventListener('beforeunload', () => this.destroy()); |
| | | // window.addEventListener('beforeunload', () => this.destroy()); |
| | | } |
| | | |
| | | async init() { |
| | |
| | | |
| | | /** |
| | | * Register a new store namespace |
| | | * @param {string} name Database Name |
| | | * @param {object|array} configs An object defining the store, or an array of objects defining the stores |
| | | * @param {number} version the database version |
| | | */ |
| | | register(name, config = {}) { |
| | | if (this.stores.has(name)) { |
| | | console.warn(`Store "${name}" already registered`); |
| | | return this.getStoreAPI(name); |
| | | register(name, configs = [], version = 1.1) { |
| | | if (!Array.isArray(configs)) configs = [configs]; |
| | | if (configs.length === 0) return; |
| | | |
| | | if (!this.dbConfig.has(name)) { |
| | | this.dbConfig.set(name, { |
| | | dbName: `jvb_${name}`, |
| | | version: version, |
| | | stores: {}, |
| | | _initialized: false |
| | | }); |
| | | } |
| | | |
| | | if (!config.keyPath) { |
| | | throw new Error(`Store "${name}" requires a keyPath`); |
| | | let dbEntry = this.dbConfig.get(name); |
| | | |
| | | configs.forEach(config => { |
| | | if (!config.storeName) { |
| | | throw new Error(`Store config for "${name}" missing storeName`); |
| | | } |
| | | if (!config.keyPath) { |
| | | throw new Error(`Store "${config.storeName}" requires keyPath`); |
| | | } |
| | | |
| | | |
| | | const storeKey = `${name}_${config.storeName}`; |
| | | |
| | | const store = { |
| | | name, |
| | | config: { |
| | | // Storage |
| | | dbName: `jvb_${name}_db`, |
| | | version: 1, |
| | | dbName: dbEntry.dbName, |
| | | storeName: 'items', |
| | | keyPath: 'id', |
| | | indexes: [], |
| | |
| | | showLoading: false, |
| | | delayFetch: true, |
| | | validateData: true, // Validate data is serializable |
| | | |
| | | ...config |
| | | }, |
| | | |
| | | // State |
| | | db: null, |
| | | dbKey: name, |
| | | storeKey: storeKey, |
| | | data: new Map(), |
| | | cache: new Map(), |
| | | httpHeaders: new Map(), |
| | | filters: { ...config.filters }, |
| | | subscribers: new Map(), |
| | | filters: {...(config.filters || {}) }, |
| | | isFetching: false, |
| | | currentRequest: null, |
| | | lastResponse: null, |
| | |
| | | ...store.config.headers |
| | | }; |
| | | |
| | | this.stores.set(name, store); |
| | | this.subscribers.set(name, new Set()); |
| | | dbEntry.stores[config.storeName] = storeKey; |
| | | |
| | | this.stores.set(storeKey, store); |
| | | if (!this.subscribers.has(storeKey)) { |
| | | this.subscribers.set(storeKey, new Set()); |
| | | } |
| | | }); |
| | | |
| | | |
| | | // Initialize database asynchronously |
| | | this.initStoreDB(name).catch(error => { |
| | | this.initDB(name).catch(error => { |
| | | console.error(`Failed to initialize store "${name}":`, error); |
| | | }); |
| | | |
| | | return this.getStoreAPI(name); |
| | | const apis = {}; |
| | | for (const [storeName, storeKey] of Object.entries(dbEntry.stores)) { |
| | | apis[storeName] = this.getStoreAPI(storeKey); |
| | | } |
| | | return apis; |
| | | } |
| | | |
| | | /** |
| | |
| | | return Object.fromEntries(obj); |
| | | } |
| | | |
| | | // Preserve ArrayBuffer and TypedArrays (needed for blob storage) |
| | | if (obj instanceof ArrayBuffer || ArrayBuffer.isView(obj)) { |
| | | return obj; |
| | | } |
| | | |
| | | // Preserve Date objects |
| | | if (obj instanceof Date) { |
| | | return obj; |
| | | } |
| | | |
| | | // Handle Arrays |
| | | if (Array.isArray(obj)) { |
| | | return obj.map(item => this.normalizeForStorage(item)); |
| | |
| | | } |
| | | |
| | | /** |
| | | * Convert FormData to plain object for storage |
| | | */ |
| | | formDataToObject(formData) { |
| | | const obj = { |
| | | _isFormData: true, |
| | | entries: {} |
| | | }; |
| | | |
| | | for (const [key, value] of formData.entries()) { |
| | | // Skip File/Blob objects - they're stored separately in UploadManager |
| | | if (value instanceof File || value instanceof Blob) { |
| | | continue; |
| | | } |
| | | |
| | | // Handle multiple values for same key |
| | | if (obj.entries[key]) { |
| | | if (!Array.isArray(obj.entries[key])) { |
| | | obj.entries[key] = [obj.entries[key]]; |
| | | } |
| | | obj.entries[key].push(value); |
| | | } else { |
| | | obj.entries[key] = value; |
| | | } |
| | | } |
| | | |
| | | return obj; |
| | | } |
| | | |
| | | /** |
| | | * Convert stored object back to FormData |
| | | */ |
| | | async objectToFormData(obj) { |
| | | if (!obj._isFormData) return obj; |
| | | |
| | | const formData = new FormData(); |
| | | |
| | | // Restore text entries |
| | | for (const [key, value] of Object.entries(obj.entries)) { |
| | | if (Array.isArray(value)) { |
| | | value.forEach(v => formData.append(key, v)); |
| | | } else { |
| | | formData.append(key, value); |
| | | } |
| | | } |
| | | |
| | | if (window.jvbUploads && obj.entries.upload_ids) { |
| | | const uploadIds = JSON.parse(obj.entries.upload_ids); |
| | | |
| | | for (const uploadId of uploadIds) { |
| | | const file = await window.jvbUploads.getBlobData(uploadId); |
| | | if (file) { |
| | | formData.append('files[]', file); |
| | | } |
| | | } |
| | | } |
| | | |
| | | return formData; |
| | | } |
| | | |
| | | /** |
| | | * Strip DOM references from object |
| | | */ |
| | | stripDOMReferences(obj, visited = new WeakSet()) { |
| | |
| | | return null; |
| | | } |
| | | |
| | | // ✅ PRESERVE ArrayBuffer and TypedArrays (needed for blob storage) |
| | | if (obj instanceof ArrayBuffer || |
| | | ArrayBuffer.isView(obj)) { |
| | | return obj; |
| | | } |
| | | |
| | | // Handle Date |
| | | if (obj instanceof Date) { |
| | | return obj; |
| | |
| | | /** |
| | | * Initialize database for a specific store |
| | | */ |
| | | async initStoreDB(name) { |
| | | const store = this.stores.get(name); |
| | | if (!store || store._initialized) return; |
| | | async initDB(name) { |
| | | const db = this.dbConfig.get(name); |
| | | if (!db || db._initialized) return; |
| | | |
| | | if (this.pendingInits.has(name)) { |
| | | return this.pendingInits.get(name); |
| | | } |
| | | |
| | | const initPromise = this._performStoreInit(name); |
| | | const initPromise = this._performDBInit(name); |
| | | this.pendingInits.set(name, initPromise); |
| | | |
| | | try { |
| | | await initPromise; |
| | | store._initialized = true; |
| | | db._initialized = true; |
| | | } finally { |
| | | this.pendingInits.delete(name); |
| | | } |
| | | } |
| | | |
| | | async _performStoreInit(name) { |
| | | const store = this.stores.get(name); |
| | | const { dbName, version } = store.config; |
| | | async _performDBInit(name) { |
| | | const database = this.dbConfig.get(name); |
| | | const { dbName, version } = database; |
| | | const stores = Object.values(database.stores); |
| | | |
| | | try { |
| | | if (!this.databases.has(dbName)) { |
| | | const db = await this.openDatabase(dbName, version, (db) => { |
| | | this.setupStores(db, store.config); |
| | | stores.forEach(store => { |
| | | let storeObj = this.stores.get(store); |
| | | if (storeObj) { |
| | | this.setupStores(db, storeObj.config); |
| | | } |
| | | }); |
| | | }); |
| | | this.databases.set(dbName, db); |
| | | } |
| | | |
| | | stores.forEach(storeName => { |
| | | let store = this.stores.get(storeName); |
| | | if (store) { |
| | | store.db = this.databases.get(dbName); |
| | | this.loadStoreDataInBackground(name); |
| | | this.notify(name, 'db-init'); |
| | | store._initialized = true; |
| | | this.loadStoreDataInBackground(storeName); |
| | | this.notify(storeName, 'db-init'); |
| | | } |
| | | }) |
| | | |
| | | } catch (error) { |
| | | console.error(`Failed to initialize database for store "${name}":`, error); |
| | |
| | | } |
| | | |
| | | if (!store._initialized) { |
| | | await this.initStoreDB(name); |
| | | await this.initDB(store.dbKey); |
| | | } |
| | | } |
| | | |
| | |
| | | |
| | | // Auto-normalize Sets/Maps |
| | | let processed = this.normalizeForStorage(item); |
| | | |
| | | if (processed.data instanceof FormData) { |
| | | processed = { |
| | | ...processed, |
| | | data: this.formDataToObject(processed.data) |
| | | }; |
| | | } |
| | | |
| | | processed = this.stripDOMReferences(processed); |
| | | |
| | | // Validate data is serializable |
| | |
| | | |
| | | const key = this.getItemKey(processed, store.config.keyPath); |
| | | |
| | | // Store in memory |
| | | // Store the original in memory (with original data intact) |
| | | store.data.set(key, item); |
| | | |
| | | // Store in IndexedDB |
| | | // Store processed in IndexedDB |
| | | if (store.db) { |
| | | const tx = store.db.transaction([store.config.storeName], 'readwrite'); |
| | | const objectStore = tx.objectStore(store.config.storeName); |
| | |
| | | return { valid: true }; |
| | | } |
| | | |
| | | if (obj instanceof ArrayBuffer || ArrayBuffer.isView(obj)) { |
| | | return { valid: true }; |
| | | } |
| | | |
| | | // Reject DOM elements |
| | | if (obj instanceof HTMLElement || |
| | | obj instanceof NodeList || |
| | |
| | | } else { |
| | | store.filters[key] = value; |
| | | } |
| | | |
| | | this.notify(name, 'filters-changed', { |
| | | filters: store.filters, |
| | | changed: { key, oldValue, newValue: value } |
| | |
| | | } |
| | | |
| | | subscribe(name, callback) { |
| | | if (!this.subscribers.has(name)) { |
| | | this.subscribers.set(name, new Set()); |
| | | } |
| | | const subscribers = this.subscribers.get(name); |
| | | subscribers.add(callback); |
| | | return () => subscribers.delete(callback); |
| | |
| | | collectFormData: false, |
| | | ... config |
| | | } |
| | | this.store = window.jvbStore.register( |
| | | const store = window.jvbStore.register( |
| | | 'forms', |
| | | { |
| | | storeName: 'forms', |
| | |
| | | validateData: true, |
| | | delayFetch: true |
| | | }); |
| | | this.store = store.forms; |
| | | |
| | | this.debouncer = window.debouncer; |
| | | |
| | |
| | | this.errors = window.jvbError; |
| | | |
| | | // Initialize DataStore for queue persistence |
| | | this.store = window.jvbStore.register('queue', { |
| | | const store = window.jvbStore.register('queue', { |
| | | storeName: 'queue', |
| | | keyPath: 'id', |
| | | endpoint: this.config.endpoint, |
| | | TTL: Infinity, |
| | |
| | | showLoading: false, |
| | | delayFetch: false, // Queue should fetch immediately |
| | | }); |
| | | this.store = store.queue; |
| | | |
| | | this.classes = [ |
| | | 'offline', |
| | |
| | | |
| | | async processOperation(operation) { |
| | | try { |
| | | //update to uploading |
| | | this.updateOperationStatus(operation.id, 'uploading'); |
| | | |
| | | // Get fresh copy from store to restore FormData |
| | | operation = this.getQueue(operation.id); |
| | | if (operation.data?._isFormData) { |
| | | operation.data = await this.store.objectToFormData(operation.data); |
| | | } |
| | | |
| | | //build request |
| | | const url = `${this.config.apiBase}${operation.endpoint}`; |
| | | let requestBody; |
| | | console.log(operation.data); |
| | | |
| | | if (operation.data instanceof FormData) { |
| | | operation.data.append('id', operation.id); |
| | | operation.data.append('user', this.user); |
| | | requestBody = operation.data; |
| | | // console.log('Sending formData: '); |
| | | // for (const pair of requestBody.entries()) { |
| | | // console.log(pair[0], pair[1]); |
| | | // } |
| | | |
| | | } else { |
| | | requestBody = JSON.stringify({ |
| | | ...operation.data, |
| | | id: operation.id, |
| | | user: this.user |
| | | }); |
| | | // console.log('Sending data: ', { |
| | | // ...operation.data, |
| | | // id: operation.id, |
| | | // user: this.user |
| | | // }); |
| | | operation.headers['Content-Type'] = 'application/json'; |
| | | } |
| | | |
| | | |
| | | |
| | | const response = await fetch(url, { |
| | | method: operation.method, |
| | | headers: operation.headers, |
| | |
| | | /** |
| | | * Referral Widget Manager |
| | | * Handles both logged-in share widget and public code validation widget |
| | | * |
| | | */ |
| | | |
| | | class Referral { |
| | |
| | | } |
| | | |
| | | this.a11y = window.jvbA11y; |
| | | |
| | | this.toggle = document.querySelector('button[data-action="toggle-referral"]'); |
| | | |
| | | this.initElements(); |
| | | this.initListeners(); |
| | | this.checkForReferral(); |
| | | |
| | | // Load additional data for logged-in users |
| | | if (this.isLoggedIn()) { |
| | | this.loadStats(); |
| | | this.loadRecentReferrals(); |
| | | } |
| | | } |
| | | |
| | | initElements() |
| | | { |
| | | initElements() { |
| | | this.selectors = { |
| | | copy: 'button.copy', |
| | | login: '.login', |
| | | copyBtn: '.copy-btn', |
| | | checkCode: '.check-code-btn', |
| | | submit: '[type=submit]', |
| | | }; |
| | | |
| | |
| | | popup: this.container, |
| | | name: 'Referral Box', |
| | | onOpen: () => { |
| | | this.forms.forEach(form => { |
| | | form.addEventListener('submit', this.submitHandler); |
| | | }); |
| | | this.container.addEventListener('click', this.clickHandler); |
| | | this.container.addEventListener('input', this.inputHandler); |
| | | this.bindEventListeners(true); |
| | | }, |
| | | onClose: () => { |
| | | this.forms.forEach(form => { |
| | | form.removeEventListener('submit', this.submitHandler); |
| | | }); |
| | | this.container.removeEventListener('click', this.clickHandler); |
| | | this.container.removeEventListener('input', this.inputHandler); |
| | | this.bindEventListeners(false); |
| | | } |
| | | }); |
| | | |
| | |
| | | this.clickHandler = this.handleClick.bind(this); |
| | | this.inputHandler = this.handleInput.bind(this); |
| | | this.submitHandler = this.handleFormSubmit.bind(this); |
| | | this.changeHandler = this.handleChange.bind(this); |
| | | } |
| | | |
| | | bindEventListeners(bind) { |
| | | const method = bind ? 'addEventListener' : 'removeEventListener'; |
| | | |
| | | this.forms.forEach(form => { |
| | | form[method]('submit', this.submitHandler); |
| | | }); |
| | | |
| | | this.container[method]('click', this.clickHandler); |
| | | this.container[method]('input', this.inputHandler); |
| | | } |
| | | |
| | | isLoggedIn() { |
| | | return Boolean(jvbSettings.currentUser); |
| | | } |
| | | |
| | | handleClick(e) { |
| | | if (e.target.classList.contains('.copy')) { |
| | | let target = e.target.dataset.target; |
| | | let value = this.container.querySelector(`#${target}`); |
| | | value = (value) ? value.textContent : false; |
| | | if (value) { |
| | | this.handleCopy(e.target, value); |
| | | } |
| | | const target = e.target.closest('.copy-btn, .check-code-btn, .attn'); |
| | | if (!target) return; |
| | | |
| | | if (target.classList.contains('copy-btn')) { |
| | | this.handleCopyClick(target); |
| | | } else if (target.classList.contains('check-code-btn')) { |
| | | this.handleCheckCode(e); |
| | | } else if (target.classList.contains('attn')) { |
| | | target.classList.remove('attn'); |
| | | } |
| | | } |
| | | |
| | | handleChange(e) { |
| | | if (e.target.id === 'referral-code') { |
| | | window.debouncer.schedule( |
| | | 'check-referral', |
| | | ()=> this.makeRequest('referrals/check-code', {code: e.target.value})), |
| | | 150 |
| | | /** |
| | | * Handle copy button click with fallback |
| | | */ |
| | | handleCopyClick(button) { |
| | | const targetId = button.dataset.target; |
| | | const codeElement = this.container.querySelector(`#${targetId}`); |
| | | |
| | | if (!codeElement) return; |
| | | |
| | | const text = codeElement.textContent.trim(); |
| | | |
| | | // Try clipboard API first |
| | | if (navigator.clipboard && navigator.clipboard.writeText) { |
| | | navigator.clipboard.writeText(text).then(() => { |
| | | this.showCopySuccess(button); |
| | | }).catch(() => { |
| | | // Fallback to selection |
| | | this.selectText(codeElement); |
| | | this.showCopyFallback(button); |
| | | }); |
| | | } else { |
| | | // Fallback to selection |
| | | this.selectText(codeElement); |
| | | this.showCopyFallback(button); |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * Select text in element |
| | | */ |
| | | selectText(element) { |
| | | if (window.getSelection && document.createRange) { |
| | | const selection = window.getSelection(); |
| | | const range = document.createRange(); |
| | | range.selectNodeContents(element); |
| | | selection.removeAllRanges(); |
| | | selection.addRange(range); |
| | | } else if (document.body.createTextRange) { |
| | | // IE fallback |
| | | const range = document.body.createTextRange(); |
| | | range.moveToElementText(element); |
| | | range.select(); |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * Show copy success feedback |
| | | */ |
| | | showCopySuccess(button) { |
| | | const originalHTML = button.innerHTML; |
| | | button.innerHTML = window.jvbIcon('check', {size: 16}) + ' Copied!'; |
| | | button.classList.add('success'); |
| | | |
| | | setTimeout(() => { |
| | | button.innerHTML = originalHTML; |
| | | button.classList.remove('success'); |
| | | }, 2000); |
| | | } |
| | | |
| | | /** |
| | | * Show fallback message |
| | | */ |
| | | showCopyFallback(button) { |
| | | const originalHTML = button.innerHTML; |
| | | button.innerHTML = '✓ Selected - Press Ctrl+C'; |
| | | button.classList.add('selected'); |
| | | |
| | | setTimeout(() => { |
| | | button.innerHTML = originalHTML; |
| | | button.classList.remove('selected'); |
| | | }, 3000); |
| | | } |
| | | |
| | | handleInput(e) { |
| | | if (e.target.id === 'referral-code') { |
| | | if (e.target.id === 'referral_code' || e.target.name === 'referral_code') { |
| | | e.target.value = e.target.value.toUpperCase(); |
| | | } |
| | | } |
| | | |
| | | // ========================================== |
| | | // SHARE WIDGET (Logged-In Users) |
| | | // ========================================== |
| | | /** |
| | | * Handle code verification |
| | | */ |
| | | async handleCheckCode(e) { |
| | | e.preventDefault(); |
| | | |
| | | initShareWidget() { |
| | | this.initCopyButton(); |
| | | this.loadStats(); |
| | | const form = e.target.closest('form'); |
| | | const codeInput = form.querySelector('[name="referral_code"]'); |
| | | const statusDiv = form.querySelector('.code-status'); |
| | | |
| | | if (!codeInput || !statusDiv) return; |
| | | |
| | | const code = codeInput.value.trim(); |
| | | |
| | | if (!code) { |
| | | this.showCodeStatus(statusDiv, 'Please enter a code', 'error'); |
| | | return; |
| | | } |
| | | |
| | | // Show loading |
| | | statusDiv.hidden = false; |
| | | statusDiv.className = 'code-status loading'; |
| | | statusDiv.innerHTML = '<span class="spinner"></span> Checking...'; |
| | | |
| | | try { |
| | | const result = await this.validateCodeOnly(code); |
| | | |
| | | if (result.success) { |
| | | this.showCodeStatus( |
| | | statusDiv, |
| | | `✓ Valid! Referred by ${result.referrer_name}`, |
| | | 'success' |
| | | ); |
| | | } else { |
| | | this.showCodeStatus(statusDiv, result.message || 'Invalid code', 'error'); |
| | | } |
| | | } catch (error) { |
| | | console.error('Error checking code:', error); |
| | | this.showCodeStatus(statusDiv, 'Error checking code', 'error'); |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * Show code verification status |
| | | */ |
| | | showCodeStatus(statusDiv, message, type) { |
| | | statusDiv.hidden = false; |
| | | statusDiv.className = `code-status ${type}`; |
| | | statusDiv.textContent = message; |
| | | |
| | | if (type === 'error') { |
| | | setTimeout(() => { |
| | | statusDiv.hidden = true; |
| | | }, 5000); |
| | | } |
| | | } |
| | | |
| | | /** |
| | |
| | | async checkForReferral() { |
| | | const isLoggedIn = this.getUrlParameter('seeReferral'); |
| | | const refCode = this.getUrlParameter('ref'); |
| | | |
| | | if (!isLoggedIn && !refCode) { |
| | | return; |
| | | } |
| | | |
| | | if (!refCode) { |
| | | this.popup.openPopup(); |
| | | return; |
| | | } |
| | | |
| | | const codeInput = this.container.querySelector('#referral-code-input'); |
| | | const codeInput = this.container.querySelector('[name="referral_code"]'); |
| | | if (!codeInput) return; |
| | | |
| | | // Convert to uppercase |
| | |
| | | |
| | | // Pre-fill the code input |
| | | codeInput.value = code; |
| | | codeInput.readOnly = true; // Make it read-only since it came from link |
| | | codeInput.readOnly = true; |
| | | |
| | | this.popup.togglePopup(); |
| | | |
| | | // Validate the code immediately to show referrer info |
| | | // Validate the code immediately |
| | | try { |
| | | const referrer = await this.validateCodeOnly(code); |
| | | |
| | | if (referrer.success) { |
| | | // Show referrer info banner |
| | | this.showReferrerBanner(referrer.referrer_name, code); |
| | | const statusDiv = codeInput.closest('form').querySelector('.code-status'); |
| | | if (statusDiv) { |
| | | this.showCodeStatus( |
| | | statusDiv, |
| | | `✓ ${referrer.referrer_name} invited you!`, |
| | | 'success' |
| | | ); |
| | | } |
| | | |
| | | // Focus on name input (first empty field) |
| | | const nameInput = this.container.querySelector('#referral-name'); |
| | | // Focus on name input |
| | | const nameInput = this.container.querySelector('[name="referral_name"]'); |
| | | if (nameInput) { |
| | | nameInput.focus(); |
| | | } |
| | | } else { |
| | | // Invalid code - make input editable and show error |
| | | codeInput.readOnly = false; |
| | | this.showMessage('This referral link is invalid. Please enter a valid code.', 'error'); |
| | | } |
| | |
| | | codeInput.readOnly = false; |
| | | } |
| | | |
| | | // Clean up URL (remove ?ref parameter) |
| | | // Clean up URL |
| | | this.removeUrlParameter('ref'); |
| | | } |
| | | |
| | | /** |
| | | * Get URL parameter value |
| | | */ |
| | | getUrlParameter(name) { |
| | | const urlParams = new URLSearchParams(window.location.search); |
| | | return urlParams.get(name); |
| | | } |
| | | |
| | | /** |
| | | * Remove URL parameter (clean URL) |
| | | */ |
| | | removeUrlParameter(name) { |
| | | const url = new URL(window.location); |
| | | url.searchParams.delete(name); |
| | |
| | | } |
| | | |
| | | /** |
| | | * Validate code without registering (just check if valid) |
| | | * Validate code without registering |
| | | */ |
| | | async validateCodeOnly(code) { |
| | | const response = await fetch(`${jvbSettings.api}/referrals/check-code`, { |
| | | const response = await fetch(`${jvbSettings.api}referrals/check-code`, { |
| | | method: 'POST', |
| | | headers: { |
| | | 'Content-Type': 'application/json' |
| | | 'Content-Type': 'application/json', |
| | | 'X-WP-Nonce': jvbSettings.nonce |
| | | }, |
| | | body: JSON.stringify({ code: code }) |
| | | }); |
| | |
| | | } |
| | | |
| | | /** |
| | | * Show banner with referrer info |
| | | * Load user stats |
| | | */ |
| | | showReferrerBanner(referrerName, code) { |
| | | const header = this.container.querySelector('.referral-header'); |
| | | if (!header) return; |
| | | |
| | | // Create banner |
| | | const banner = document.createElement('div'); |
| | | banner.className = 'referrer-banner'; |
| | | banner.innerHTML = ` |
| | | <div class="banner-icon">🎉</div> |
| | | <div class="banner-content"> |
| | | <strong>${window.escapeHtml(referrerName)}</strong> referred you! |
| | | <div class="banner-code">Code: <code>${window.escapeHtml(code)}</code></div> |
| | | </div> |
| | | `; |
| | | |
| | | // Insert after header |
| | | header.parentNode.insertBefore(banner, header.nextSibling); |
| | | |
| | | // Update header text |
| | | const headerTitle = header.querySelector('h3'); |
| | | if (headerTitle) { |
| | | headerTitle.textContent = 'Complete Your Registration'; |
| | | } |
| | | |
| | | const headerDesc = header.querySelector('p'); |
| | | if (headerDesc) { |
| | | headerDesc.textContent = 'Enter your details below to claim your welcome reward!'; |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * Copy referral link to clipboard |
| | | */ |
| | | handleCopy(button, text = '') { |
| | | if (text === '' || typeof text !== 'string') { |
| | | return; |
| | | } |
| | | let originalText = button.textContent; |
| | | if (navigator.clipboard || navigator.clipboard.writeText) { |
| | | navigator.clipboard.writeText(text).then(() => { |
| | | button.textContent = 'Copied!'; |
| | | button.style.background = '#00a32a'; |
| | | |
| | | setTimeout(() => { |
| | | button.textContent = originalText; |
| | | button.style.background = ''; |
| | | }, 2000); |
| | | }) |
| | | } |
| | | } |
| | | |
| | | async loadStats() { |
| | | const statsContainer = this.container.querySelector('.referral-stats'); |
| | | const statsContainer = this.container.querySelector('.stats-summary'); |
| | | if (!statsContainer) return; |
| | | |
| | | try { |
| | | const response = await fetch(`${jvbSettings.api}/referrals/stats`, { |
| | | const response = await fetch(`${jvbSettings.api}referrals/my-stats?user=${jvbSettings.currentUser}`, { |
| | | headers: { 'X-WP-Nonce': jvbSettings.nonce } |
| | | }); |
| | | |
| | |
| | | } |
| | | |
| | | /** |
| | | * Load recent referrals (last 5) |
| | | */ |
| | | async loadRecentReferrals() { |
| | | const container = this.container.querySelector('.recent-referrals-list'); |
| | | if (!container) return; |
| | | |
| | | try { |
| | | const response = await fetch(`${jvbSettings.api}referrals/my-referrals?limit=5&user=${jvbSettings.currentUser}`, { |
| | | headers: { 'X-WP-Nonce': jvbSettings.nonce } |
| | | }); |
| | | |
| | | const data = await response.json(); |
| | | if (data.success && data.referrals) { |
| | | this.renderRecentReferrals(container, data.referrals); |
| | | } else { |
| | | container.innerHTML = '<p class="no-referrals">No referrals yet</p>'; |
| | | } |
| | | } catch (error) { |
| | | console.error('Error loading referrals:', error); |
| | | container.innerHTML = '<p class="error">Failed to load referrals</p>'; |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * Render recent referrals list |
| | | */ |
| | | renderRecentReferrals(container, referrals) { |
| | | if (!referrals || referrals.length === 0) { |
| | | container.innerHTML = '<p class="no-referrals">Share your code to get started!</p>'; |
| | | return; |
| | | } |
| | | |
| | | const html = referrals.map(ref => ` |
| | | <div class="referral-item"> |
| | | <div class="referral-info"> |
| | | <strong>${window.escapeHtml(ref.referee_name)}</strong> |
| | | <span class="status-badge ${ref.status}">${ref.status}</span> |
| | | </div> |
| | | <div class="referral-date">${this.formatDate(ref.referred_at)}</div> |
| | | </div> |
| | | `).join(''); |
| | | |
| | | container.innerHTML = html; |
| | | } |
| | | |
| | | /** |
| | | * Format date nicely |
| | | */ |
| | | formatDate(dateString) { |
| | | const date = new Date(dateString); |
| | | const now = new Date(); |
| | | const diffTime = Math.abs(now - date); |
| | | const diffDays = Math.floor(diffTime / (1000 * 60 * 60 * 24)); |
| | | |
| | | if (diffDays === 0) return 'Today'; |
| | | if (diffDays === 1) return 'Yesterday'; |
| | | if (diffDays < 7) return `${diffDays} days ago`; |
| | | |
| | | return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); |
| | | } |
| | | |
| | | /** |
| | | * Handle form submission |
| | | */ |
| | | async handleFormSubmit(event) { |
| | | window.debouncer.cancel('check-referral'); |
| | | event.preventDefault(); |
| | | |
| | | const form = event.target; |
| | | |
| | | // Get form data |
| | | const formData = new FormData(form); |
| | | |
| | | let data = {}; |
| | | |
| | | // Disable form |
| | | this.setFormLoading(true, form); |
| | | |
| | | try { |
| | | |
| | | let result = {success: false, message: ''}; |
| | | |
| | | if (form.id === 'referral-code-form') { |
| | | if (!formData.get('name')) { |
| | | result.message += 'We need your name to know who you are.'; |
| | | } |
| | | if (!formData.get('email')) { |
| | | result.message += 'We need your email to confirm you have access to it.'; |
| | | } |
| | | if (!formData.get('referral_code')) { |
| | | result.message += 'We need the referral code to know who sent you.'; |
| | | } |
| | | if (formData.get('name') && formData.get('email') && formData.get('referral_code')) { |
| | | data.name = formData.get('name'); |
| | | data.email = formData.get('email'); |
| | | data.code = formData.get('referral_code'); |
| | | const data = { |
| | | name: formData.get('referral_name'), |
| | | email: formData.get('referral_email'), |
| | | code: formData.get('referral_code') |
| | | }; |
| | | |
| | | if (!data.name || !data.email || !data.code) { |
| | | result.message = 'Please fill in all fields'; |
| | | } else { |
| | | result = await this.makeRequest('referrals/register', data); |
| | | } |
| | | } else if (form.id === 'login-form' && formData.get('login-email')) { |
| | | data.type = 'login'; |
| | | data.email = formData.get('login-email'); |
| | | data.context = {}; |
| | | data.context['redirect_to'] = window.location.href+'?seeReferral=1'; |
| | | console.log('Making Request with: ', data); |
| | | } else if (form.id === 'login-form') { |
| | | const data = { |
| | | type: 'login', |
| | | email: formData.get('login_email'), |
| | | context: { |
| | | redirect_to: window.location.href + '?seeReferral=1' |
| | | } |
| | | }; |
| | | result = await this.makeRequest('magic', data); |
| | | } |
| | | |
| | | |
| | | if (result.success) { |
| | | this.handleSuccess(result); |
| | | this.handleSuccess(form, result); |
| | | } else { |
| | | this.showMessage(result.message || 'Something went wrong. Please try again.', 'error'); |
| | | this.setFormLoading(false, form); |
| | | this.showFormMessage(form, result.message || 'Something went wrong. Please try again.', 'error'); |
| | | } |
| | | } catch (error) { |
| | | console.error('Error registering:', error); |
| | | this.showMessage('Something went wrong. Please try again.', 'error'); |
| | | this.setFormLoading(false, form); |
| | | console.error('Error submitting form:', error); |
| | | this.showFormMessage(form, 'Something went wrong. Please try again.', 'error'); |
| | | } finally { |
| | | this.setFormLoading(false, form); |
| | | } |
| | | } |
| | | |
| | | async makeRequest(endpoint, data) { |
| | | if (![ |
| | | const validEndpoints = [ |
| | | 'magic', |
| | | 'referrals/register', |
| | | 'referrals/check-code' |
| | | ].includes(endpoint)) { |
| | | return {success:false, message: 'Something went wrong (Invalid endpoint).'} |
| | | ]; |
| | | |
| | | if (!validEndpoints.includes(endpoint)) { |
| | | return { success: false, message: 'Invalid endpoint' }; |
| | | } |
| | | console.log('Endpoint: ', endpoint); |
| | | console.log('Data: ', data); |
| | | |
| | | const response = await fetch(`${jvbSettings.api}${endpoint}`, { |
| | | method: 'POST', |
| | | headers: { |
| | |
| | | /** |
| | | * Show success state |
| | | */ |
| | | handleSuccess(result) { |
| | | //Hide forms |
| | | this.container.querySelectorAll('form').forEach(form => { |
| | | window.fade(form, false); |
| | | }); |
| | | |
| | | const successState = this.container.querySelector('.success-message'); |
| | | if (!successState) return; |
| | | handleSuccess(form, result) { |
| | | // Hide form |
| | | form.style.display = 'none'; |
| | | |
| | | // Show success message |
| | | successState.hidden = false; |
| | | const successDiv = form.nextElementSibling; |
| | | if (successDiv && successDiv.classList.contains('success-content')) { |
| | | successDiv.hidden = false; |
| | | |
| | | // Scroll to message |
| | | successState.scrollIntoView({ |
| | | successDiv.scrollIntoView({ |
| | | behavior: 'smooth', |
| | | block: 'center' |
| | | }); |
| | | } |
| | | |
| | | // Fire custom event |
| | | this.dispatchEvent('emailSent', { |
| | |
| | | } |
| | | |
| | | /** |
| | | * Set form loading state |
| | | * Show message in form status area |
| | | */ |
| | | setFormLoading(loading, form) { |
| | | const inputs = form.querySelectorAll('input'); |
| | | showFormMessage(form, text, type = 'error') { |
| | | const status = form.querySelector('.status'); |
| | | if (!status) return; |
| | | |
| | | inputs.forEach(input => input.disabled = loading); |
| | | let status = form.querySelector('.status'); |
| | | let message = status.querySelector('.message'); |
| | | status.hidden = loading; |
| | | status.classList.toggle('loading', loading); |
| | | if (loading) { |
| | | message.textContent = 'Checking with server...'; |
| | | } else { |
| | | message.textContent = ''; |
| | | const message = status.querySelector('.message'); |
| | | if (message) { |
| | | message.textContent = text; |
| | | } |
| | | |
| | | status.hidden = false; |
| | | status.className = `status ${type}`; |
| | | |
| | | if (type === 'error') { |
| | | setTimeout(() => { |
| | | status.hidden = true; |
| | | }, 5000); |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * Show message |
| | | * Set form loading state |
| | | */ |
| | | showMessage(text, type = 'success') { |
| | | const messageDiv = this.container.querySelector('#referral-message'); |
| | | if (!messageDiv) return; |
| | | setFormLoading(loading, form) { |
| | | const inputs = form.querySelectorAll('input, button'); |
| | | inputs.forEach(input => input.disabled = loading); |
| | | |
| | | messageDiv.textContent = text; |
| | | messageDiv.className = 'message ' + type; |
| | | messageDiv.style.display = 'block'; |
| | | const status = form.querySelector('.status'); |
| | | if (status) { |
| | | status.classList.toggle('loading', loading); |
| | | |
| | | if (type === 'error') { |
| | | setTimeout(() => { |
| | | messageDiv.style.display = 'none'; |
| | | }, 5000); |
| | | if (loading) { |
| | | status.hidden = false; |
| | | const message = status.querySelector('.message'); |
| | | if (message) { |
| | | message.textContent = 'Sending...'; |
| | | } |
| | | } |
| | | } |
| | | } |
| | | |
| | |
| | | } |
| | | } |
| | | |
| | | |
| | | document.addEventListener('DOMContentLoaded', () => { |
| | | window.jvbReferral = new Referral(); |
| | | }); |
| | | |
| | |
| | | |
| | | this.subscribers = new Set(); |
| | | |
| | | this.store = window.jvbStore.register( |
| | | const store = window.jvbStore.register( |
| | | 'taxonomies', |
| | | { |
| | | storeName: `terms`, |
| | |
| | | required: 'taxonomy', |
| | | delayFetch: true, |
| | | }); |
| | | this.store = store.terms; |
| | | |
| | | // Central field management |
| | | this.fields = new Map(); |
| | |
| | | this.queue = window.jvbQueue; |
| | | this.a11y = window.jvbA11y; |
| | | this.error = window.jvbError; |
| | | |
| | | // RECOVERY STORES - Cleared after successful upload |
| | | this.fieldStore = window.jvbStore.register( |
| | | 'upload_fields', |
| | | this.fieldStoreReady = false; |
| | | this.uploadStoreReady = false; |
| | | this.hasCheckedForUploads = false; |
| | | const {fields, uploads} = window.jvbStore.register( |
| | | 'uploads', |
| | | [ |
| | | { |
| | | storeName: 'fieldStates', |
| | | storeName: 'fields', |
| | | keyPath: 'id', |
| | | version: 2, |
| | | indexes: [ |
| | | { name: 'fieldId', keyPath: 'fieldId' }, |
| | | { name: 'timestamp', keyPath: 'timestamp' }, |
| | |
| | | { name: 'itemId', keyPath: 'itemId' }, |
| | | { name: 'status', keyPath: 'status' } |
| | | ], |
| | | TTL: 86400000 * 7, // 1 week |
| | | TTL: 7 * 24 * 60 * 60 * 1000, // 1 week |
| | | delayFetch: true |
| | | }); |
| | | |
| | | this.uploadStore = window.jvbStore.register( |
| | | 'uploads', |
| | | }, |
| | | { |
| | | name: 'uploads', |
| | | storeName: 'uploads', |
| | | keyPath: 'id', |
| | | storeBlobs: true, |
| | |
| | | { name: 'attachmentId', keyPath: 'attachmentId' } |
| | | ], |
| | | delayFetch: true |
| | | }); |
| | | } |
| | | ] |
| | | ); |
| | | this.fieldStore = fields; |
| | | this.uploadStore = uploads; |
| | | |
| | | window.jvbUploadBlobs = this.uploadStore; |
| | | |
| | |
| | | // Notification subscribers |
| | | this.subscribers = new Set(); |
| | | |
| | | // Controllers |
| | | this.dragController = null; |
| | | |
| | | // Selectors |
| | | this.selectors = { |
| | | field: { |
| | |
| | | 'failed_permanent': 'Upload failed permanently' |
| | | }; |
| | | |
| | | // Sortable configuration |
| | | this.sortableConfig = { |
| | | animation: 150, |
| | | draggable: '.item', |
| | | handle: '.select-item, img', |
| | | ghostClass: 'sortable-ghost', |
| | | chosenClass: 'sortable-chosen', |
| | | dragClass: 'sortable-drag', |
| | | onEnd: (evt) => this.handleReorder(evt) |
| | | }; |
| | | |
| | | this.init(); |
| | | } |
| | | |
| | |
| | | /******************************************************************************* |
| | | * FIELD MANAGEMENT |
| | | *******************************************************************************/ |
| | | |
| | | initializeFields() { |
| | | const fields = document.querySelectorAll(this.selectors.field.field); |
| | | fields.forEach(uploader => this.registerUploader(uploader)); |
| | |
| | | uploader.dataset.uploader = fieldId; |
| | | this.addFieldSelectionHandler(fieldId); |
| | | |
| | | if (config.destination === 'post_group' && !this.dragController) { |
| | | this.initGroupFeatures(); |
| | | } |
| | | |
| | | if (config.type !== 'single') { |
| | | this.initSortable(fieldId); |
| | | } |
| | |
| | | /******************************************************************************* |
| | | * SORTABLE INITIALIZATION |
| | | *******************************************************************************/ |
| | | |
| | | initSortable(fieldId) { |
| | | if (!window.Sortable) return; |
| | | |
| | | // Mount MultiDrag plugin once |
| | | if (!Sortable._multiDragMounted && Sortable.MultiDrag) { |
| | | Sortable.mount(new Sortable.MultiDrag()); |
| | | Sortable._multiDragMounted = true; |
| | | } |
| | | |
| | | const fieldEl = this.fieldElements.get(fieldId); |
| | | if (!fieldEl) return; |
| | | |
| | | // Find all sortable grids (preview + group grids, but not restore grids) |
| | | // Initialize sortable on all existing grids |
| | | const grids = fieldEl.element.querySelectorAll('.item-grid.preview, .item-grid.group'); |
| | | grids.forEach(grid => { |
| | | const groupId = grid.classList.contains('group') |
| | | ? grid.closest('.upload-group')?.dataset.groupId |
| | | : null; |
| | | this.createSortableForGrid(grid, fieldId, groupId); |
| | | }); |
| | | |
| | | grids.forEach((grid) => { |
| | | // Skip if already initialized |
| | | if (grid.sortableInstance) return; |
| | | // Special handler for empty-group |
| | | const emptyGroup = fieldEl.element.querySelector('.empty-group'); |
| | | if (emptyGroup && !emptyGroup.sortableInstance) { |
| | | emptyGroup.sortableInstance = new Sortable(emptyGroup, { |
| | | animation: 150, |
| | | draggable: '.item', |
| | | multiDrag: true, |
| | | selectedClass: 'selected-for-drag', |
| | | avoidImplicitDeselect: true, |
| | | group: { name: fieldId, pull: false, put: true }, |
| | | ghostClass: 'sortable-ghost', |
| | | chosenClass: 'sortable-chosen', |
| | | dragClass: 'sortable-drag', |
| | | onEnd: (evt) => this.handleDrop(evt, fieldId) |
| | | }); |
| | | } |
| | | } |
| | | |
| | | const isGroupGrid = grid.classList.contains('group'); |
| | | const gridId = isGroupGrid |
| | | ? `${fieldId}-group-${grid.closest('.upload-group')?.dataset.groupId}` |
| | | : `${fieldId}-preview`; |
| | | syncSortableSelection(fieldId, selectedItems) { |
| | | // Update Sortable's selection state to match checkboxes |
| | | this.sortableInstances.forEach((instance, key) => { |
| | | if (key.startsWith(fieldId)) { |
| | | const grid = instance.el; |
| | | const items = grid.querySelectorAll('.item'); |
| | | |
| | | const sortableInstance = new Sortable(grid, { |
| | | ...this.sortableConfig, |
| | | group: { |
| | | name: fieldId, |
| | | pull: true, |
| | | put: true |
| | | }, |
| | | items.forEach(item => { |
| | | const uploadId = item.dataset.uploadId; |
| | | const shouldBeSelected = selectedItems.has(uploadId); |
| | | |
| | | onAdd: (evt) => { |
| | | // Re-enable when items added |
| | | this.updateSortableState(evt.to); |
| | | }, |
| | | onRemove: (evt) => { |
| | | // Disable source if now empty |
| | | this.updateSortableState(evt.from); |
| | | if (shouldBeSelected) { |
| | | Sortable.utils.select(item); |
| | | } else { |
| | | Sortable.utils.deselect(item); |
| | | } |
| | | }); |
| | | |
| | | // Store reference on element for easy access |
| | | grid.sortableInstance = sortableInstance; |
| | | this.sortableInstances.set(gridId, sortableInstance); |
| | | } |
| | | }); |
| | | } |
| | | |
| | | handleDrop(evt, fieldId) { |
| | | const dropTarget = evt.to; |
| | | const sourceTarget = evt.from; |
| | | const items = evt.items?.length > 0 ? evt.items : [evt.item]; |
| | | const uploadIds = items.map(item => item.dataset.uploadId); |
| | | |
| | | // Determine drop target type |
| | | const targetType = this.getDropTargetType(dropTarget); |
| | | |
| | | switch (targetType) { |
| | | case 'empty-group': |
| | | this.handleDropToEmptyGroup(items, uploadIds, fieldId); |
| | | break; |
| | | |
| | | case 'preview': |
| | | this.handleDropToPreview(items, uploadIds, fieldId); |
| | | break; |
| | | |
| | | case 'group': |
| | | this.handleDropToGroup(items, uploadIds, dropTarget, sourceTarget, fieldId); |
| | | break; |
| | | default: |
| | | // Fallback: return to preview |
| | | this.handleDropToPreview(items, uploadIds, fieldId); |
| | | break; |
| | | } |
| | | |
| | | // Update UI |
| | | this.updateSortableState(dropTarget); |
| | | if (sourceTarget !== dropTarget) { |
| | | this.updateSortableState(sourceTarget); |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * Determine what type of drop target this is |
| | | */ |
| | | getDropTargetType(target) { |
| | | if (target.classList.contains('empty-group')) { |
| | | return 'empty-group'; |
| | | } |
| | | |
| | | if (target.classList.contains('preview')) { |
| | | return 'preview'; |
| | | } |
| | | |
| | | if (target.classList.contains('group')) { |
| | | return 'group'; |
| | | } |
| | | |
| | | return 'unknown'; |
| | | } |
| | | |
| | | /** |
| | | * Handle drop to group: add to existing group |
| | | */ |
| | | handleDropToGroup(items, uploadIds, dropTarget, sourceTarget, fieldId) { |
| | | try { |
| | | // If same container, it's just a reorder |
| | | if (dropTarget === sourceTarget) { |
| | | this.handleReorder({ to: dropTarget, items: items }); |
| | | return; |
| | | } |
| | | |
| | | // Moving to different group |
| | | uploadIds.forEach(uploadId => { |
| | | this.addToGroup(uploadId, dropTarget, false); |
| | | }); |
| | | |
| | | this.schedulePersistance(fieldId); |
| | | |
| | | const message = items.length > 1 |
| | | ? `Moved ${items.length} items to group` |
| | | : 'Moved item to group'; |
| | | this.a11y.announce(message); |
| | | |
| | | // Clear selection |
| | | const handler = this.selectionHandlers.get(fieldId); |
| | | handler?.clearSelection(); |
| | | } catch (error) { |
| | | this.handleDropError(items, fieldId, error); |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * Handle drop to preview: remove from groups |
| | | */ |
| | | handleDropToPreview(items, uploadIds, fieldId) { |
| | | try { |
| | | uploadIds.forEach(uploadId => { |
| | | this.removeFromGroup(uploadId); |
| | | }); |
| | | |
| | | this.schedulePersistance(fieldId); |
| | | |
| | | const message = items.length > 1 |
| | | ? `Moved ${items.length} items to preview` |
| | | : 'Moved item to preview'; |
| | | this.a11y.announce(message); |
| | | |
| | | // Clear selection |
| | | const handler = this.selectionHandlers.get(fieldId); |
| | | handler?.clearSelection(); |
| | | } catch (error) { |
| | | this.handleDropError(items, fieldId, error); |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * Handle drop errors consistently |
| | | */ |
| | | handleDropError(items, fieldId, error, message = 'An error occurred') { |
| | | console.error('Drop error:', error); |
| | | |
| | | // Return items to preview as fallback |
| | | const fieldEl = this.fieldElements.get(fieldId); |
| | | if (fieldEl?.ui?.preview) { |
| | | items.forEach(item => fieldEl.ui.preview.appendChild(item)); |
| | | } |
| | | |
| | | this.a11y.announce(`${message}. Items returned to preview.`); |
| | | } |
| | | |
| | | /** |
| | | * Handle drop to group: add to existing group |
| | | */ |
| | | handleDropToEmptyGroup(items, uploadIds, fieldId) { |
| | | try { |
| | | const group = this.createGroup(fieldId); |
| | | if (!group) { |
| | | this.handleDropError(items, fieldId, new Error('Group creation failed'), 'Failed to create group'); |
| | | return; |
| | | } |
| | | |
| | | // Move items to new group |
| | | items.forEach((item, index) => { |
| | | group.grid.appendChild(item); |
| | | this.addToGroup(uploadIds[index], group.grid, false); |
| | | }); |
| | | |
| | | this.schedulePersistance(fieldId); |
| | | |
| | | const message = items.length > 1 |
| | | ? `Created group with ${items.length} items` |
| | | : 'Created group with item'; |
| | | this.a11y.announce(message); |
| | | |
| | | // Clear selection after move |
| | | const handler = this.selectionHandlers.get(fieldId); |
| | | handler?.clearSelection(); |
| | | } catch (error) { |
| | | this.handleDropError(items, fieldId, error); |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * Update sortable enabled/disabled state based on item count |
| | | */ |
| | |
| | | const sortable = grid?.sortableInstance; |
| | | if (!sortable) return; |
| | | |
| | | const hasItems = grid.querySelectorAll('.item').length > 0; |
| | | sortable.option('disabled', !hasItems); |
| | | // const hasItems = grid.querySelectorAll('.item').length > 0; |
| | | sortable.option('disabled', false); |
| | | } |
| | | |
| | | /** |
| | |
| | | const fieldWrapper = grid.closest('.field, .upload'); |
| | | if (!fieldWrapper) return; |
| | | |
| | | let hiddenInput = fieldWrapper.querySelector('input[type="hidden"]'); |
| | | let items = Array.from(fieldWrapper.querySelectorAll('.item')).map(upload => upload.dataset.id); |
| | | console.log(items); |
| | | hiddenInput.value = items.join(','); |
| | | const movedItems = evt.items && evt.items.length > 0 ? evt.items : [evt.item]; |
| | | |
| | | if (window.jvbA11y) { |
| | | window.jvbA11y.announce('Item reordered'); |
| | | // Get current order from DOM |
| | | let items = Array.from(grid.querySelectorAll('.item:not(.sortable-ghost):not(.sortable-clone)')) |
| | | .map(upload => upload.dataset.uploadId) |
| | | .filter(id => id); |
| | | |
| | | console.log('Reordered items:', items); |
| | | |
| | | // Update hidden input (for form submission) |
| | | let hiddenInput = fieldWrapper.querySelector('input[type="hidden"]'); |
| | | if (hiddenInput && items.length > 0) { |
| | | hiddenInput.value = items.join(','); |
| | | } |
| | | |
| | | // ✅ Update fieldState with new order |
| | | const fieldId = this.getFieldIdFromElement(grid); |
| | | if (fieldId) { |
| | | const fieldData = this.getFieldData(fieldId); |
| | | |
| | | // If reordering within a group, update that group's uploads array |
| | | if (grid.classList.contains('group')) { |
| | | const groupId = grid.dataset.groupId; |
| | | const group = fieldData?.groups?.find(g => g.id === groupId); |
| | | if (group) { |
| | | group.uploads = items; // Update order |
| | | } |
| | | } |
| | | // If reordering in preview, the order is implicit by DOM position |
| | | // (we don't store preview order separately) |
| | | |
| | | this.schedulePersistance(fieldId); // ✅ Persist changes |
| | | } |
| | | |
| | | this.a11y.announce('Item reordered'); |
| | | |
| | | fieldWrapper.dispatchEvent(new CustomEvent('jvb-items-reordered', { |
| | | detail: { from: evt.from, to: evt.to, oldIndex: evt.oldIndex, newIndex: evt.newIndex }, |
| | | detail: { |
| | | from: evt.from, |
| | | to: evt.to, |
| | | oldIndex: evt.oldIndex, |
| | | newIndex: evt.newIndex, |
| | | items: items |
| | | }, |
| | | bubbles: true |
| | | })); |
| | | } |
| | | |
| | | /******************************************************************************* |
| | | * DRAG & DROP INITIALIZATION |
| | | *******************************************************************************/ |
| | | |
| | | initGroupFeatures() { |
| | | this.dragController = new window.jvbDragHandler({ |
| | | draggableSelector: this.selectors.items.item, |
| | | dropTargetSelector: `${this.selectors.field.preview}, ${this.selectors.groups.grid}, .empty-group`, |
| | | ignoreSelector: 'input:not(.upload-select), button, select, textarea, details, summary, a', |
| | | previewElement: 'img, video, .icon', |
| | | getItemId: (element) => element.dataset.uploadId, |
| | | getSelectedItems: (element) => { |
| | | const fieldId = this.getFieldIdFromElement(element); |
| | | const uploadId = element.dataset.uploadId; |
| | | const selected = this.getCurrentSelection(fieldId); |
| | | return (selected && selected.includes(uploadId)) ? selected : [uploadId]; |
| | | }, |
| | | validateDrop: (itemIds, targetElement) => { |
| | | const targetFieldId = this.getFieldIdFromElement(targetElement); |
| | | const itemElement = document.querySelector(`[data-upload-id="${itemIds[0]}"]`); |
| | | const itemFieldId = this.getFieldIdFromElement(itemElement); |
| | | return targetFieldId === itemFieldId; |
| | | }, |
| | | onDrop: (itemIds, targetElement) => { |
| | | this.handleItemDrop(itemIds, targetElement); |
| | | targetElement.scrollIntoView({behavior:'smooth', block:'center'}); |
| | | }, |
| | | onDragEnd: (itemIds, success) => { |
| | | if (success) { |
| | | const itemElement = document.querySelector(`[data-upload-id="${itemIds[0]}"]`); |
| | | const fieldId = this.getFieldIdFromElement(itemElement); |
| | | const handler = this.selectionHandlers.get(fieldId); |
| | | handler?.clearSelection(); |
| | | } |
| | | }, |
| | | previewOptions: { |
| | | multiOffset: { x: -60, y: -80 }, |
| | | singleOffset: { x: -50, y: -60 }, |
| | | showCount: true |
| | | } |
| | | }); |
| | | } |
| | | |
| | | /******************************************************************************* |
| | | * FILE DROP HANDLERS |
| | | *******************************************************************************/ |
| | | |
| | |
| | | } |
| | | |
| | | /******************************************************************************* |
| | | * ITEM DROP HANDLER (for rearranging) |
| | | *******************************************************************************/ |
| | | |
| | | handleItemDrop(itemIds, targetElement) { |
| | | const isPreviewDrop = targetElement.classList.contains('preview'); |
| | | let actualTarget = targetElement; |
| | | |
| | | // Handle drop on empty group placeholder |
| | | if (targetElement.classList.contains('empty-group')) { |
| | | const fieldId = this.getFieldIdFromElement(targetElement); |
| | | const group = this.createGroup(fieldId); |
| | | if (!group) return; |
| | | actualTarget = group.grid; |
| | | } |
| | | |
| | | // Move each item |
| | | itemIds.forEach(uploadId => { |
| | | if (isPreviewDrop) { |
| | | this.removeFromGroup(uploadId); |
| | | } else { |
| | | this.addToGroup(uploadId, actualTarget); |
| | | } |
| | | }); |
| | | |
| | | const fieldId = this.getFieldIdFromElement(targetElement); |
| | | this.schedulePersistance(fieldId); |
| | | |
| | | const message = itemIds.length > 1 ? `Moved ${itemIds.length} items` : 'Moved item'; |
| | | this.a11y.announce(message); |
| | | } |
| | | |
| | | /******************************************************************************* |
| | | * CLICK & CHANGE HANDLERS |
| | | *******************************************************************************/ |
| | | |
| | |
| | | try { |
| | | const uploadId = `upload_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; |
| | | |
| | | // Create upload data (without blob initially) |
| | | // Create initial upload data |
| | | const uploadData = { |
| | | id: uploadId, |
| | | attachmentId: null, |
| | |
| | | } |
| | | }; |
| | | |
| | | // Save initial data |
| | | await this.uploadStore.save(uploadData); |
| | | |
| | | // Process file |
| | | const preview = this.createPreviewUrl(file); |
| | | const processedFile = file.type.startsWith('image/') |
| | | ? await this.processImage(file, fieldData.config.subtype) |
| | | : file; |
| | | |
| | | // Store blob data as ArrayBuffer |
| | | // Show progress |
| | | this.showUploadProgress(uploadId, true); |
| | | this.updateUploadItemProgress(uploadId, 50, 'local_processing'); |
| | | |
| | | // Store blob data (this updates the existing uploadData) |
| | | await this.saveBlobData(uploadId, processedFile || file); |
| | | |
| | | // Create DOM element |
| | |
| | | subtype: subtype |
| | | }, fieldData.config.destination === 'post_group'); |
| | | |
| | | // Show progress |
| | | this.showUploadProgress(uploadId, true); |
| | | this.updateUploadItemProgress(uploadId, 50, 'local_processing'); |
| | | |
| | | // Add to preview grid |
| | | if (fieldEl.ui.preview) { |
| | | fieldEl.ui.preview.appendChild(element); |
| | |
| | | }); |
| | | } |
| | | |
| | | // Store persistent data |
| | | uploadData.status = 'processed'; |
| | | await this.uploadStore.save(uploadData); |
| | | // Update status (gets existing data with blobData intact) |
| | | const storedUpload = this.uploadStore.get(uploadId); |
| | | if (storedUpload) { |
| | | storedUpload.status = 'processed'; |
| | | await this.uploadStore.save(storedUpload); |
| | | } |
| | | |
| | | // Add to field |
| | | fieldData.uploads.add(uploadId); |
| | |
| | | } |
| | | |
| | | /******************************************************************************* |
| | | * IMAGE PROCESSING (simplified - keeping existing logic) |
| | | * IMAGE PROCESSING |
| | | *******************************************************************************/ |
| | | |
| | | async processImage(file, uploadId) { |
| | |
| | | * Handle operation cancellation |
| | | */ |
| | | async handleOperationCancelled(fieldId) { |
| | | const fieldData = this.getFieldData(fieldId); // ✅ |
| | | const fieldData = this.getFieldData(fieldId); |
| | | if (!fieldData) return; |
| | | |
| | | const uploadsArray = fieldData.uploads instanceof Set |
| | |
| | | } |
| | | |
| | | for (const groupData of groups) { |
| | | // Create group using existing method which handles all initialization |
| | | const group = this.createGroup(fieldId, groupData.id); |
| | | |
| | | if (!group) { |
| | | console.warn('Failed to create group:', groupData.id); |
| | | continue; |
| | | } |
| | | |
| | | // Find the group in fieldData (createGroup already added it) |
| | | const storedGroup = fieldData.groups?.find(g => g.id === groupData.id); |
| | | |
| | | if (storedGroup) { |
| | | // Restore metadata |
| | | if (groupData.changes) { |
| | | storedGroup.changes = { ...groupData.changes }; |
| | | } |
| | | |
| | | // ✅ Preserve upload order |
| | | if (groupData.uploads) { |
| | | storedGroup.uploads = [...groupData.uploads]; |
| | | } |
| | | |
| | | // Restore form field values if they exist |
| | | // Restore form field values |
| | | if (groupData.changes) { |
| | | const titleInput = group.element.querySelector('[name*="post_title"]'); |
| | | const excerptInput = group.element.querySelector('[name*="post_excerpt"]'); |
| | |
| | | if (titleInput && groupData.changes.post_title) { |
| | | titleInput.value = groupData.changes.post_title; |
| | | } |
| | | |
| | | if (excerptInput && groupData.changes.post_excerpt) { |
| | | excerptInput.value = groupData.changes.post_excerpt; |
| | | } |
| | |
| | | } |
| | | } |
| | | |
| | | // Save updated field data |
| | | await this.saveFieldData(fieldData); |
| | | } |
| | | |
| | |
| | | const fieldData = this.getFieldData(fieldId); |
| | | if (!fieldEl?.ui?.dropZone || !fieldData) return; |
| | | |
| | | if (fieldData.config.destination === 'post_group') return; |
| | | |
| | | const uploadCount = fieldData.uploads?.size || 0; |
| | | const maxFiles = fieldData.config?.maxFiles || 999; |
| | | |
| | | // For groupable uploads, set max to 20 |
| | | const maxFiles = fieldData.config.destination === 'post_group' |
| | | ? 20 |
| | | : (fieldData.config?.maxFiles || 999); |
| | | |
| | | fieldEl.ui.dropZone.hidden = uploadCount >= maxFiles; |
| | | fieldEl.element.classList.toggle('at-max-uploads', uploadCount >= maxFiles); |
| | | |
| | | // Show helpful message for groupable uploads |
| | | if (fieldData.config.destination === 'post_group' && uploadCount >= maxFiles) { |
| | | this.a11y.announce('Maximum of 20 uploads reached. Please submit current uploads before adding more.'); |
| | | } |
| | | } |
| | | |
| | | /******************************************************************************* |
| | | * GROUP MANAGEMENT |
| | | *******************************************************************************/ |
| | | /** |
| | | * Create sortable instance for a grid |
| | | */ |
| | | createSortableForGrid(grid, fieldId, groupId = null) { |
| | | if (!grid || grid.sortableInstance) return; |
| | | |
| | | const sortableInstance = new Sortable(grid, { |
| | | animation: 150, |
| | | draggable: '.item', |
| | | multiDrag: true, |
| | | selectedClass: 'selected-for-drag', |
| | | avoidImplicitDeselect: true, |
| | | group: { name: fieldId, pull: true, put: true }, |
| | | ghostClass: 'sortable-ghost', |
| | | chosenClass: 'sortable-chosen', |
| | | dragClass: 'sortable-drag', |
| | | |
| | | // Centralized drop handler |
| | | onEnd: (evt) => this.handleDrop(evt, fieldId), |
| | | |
| | | // Selection sync |
| | | onSelect: (evt) => { |
| | | const checkbox = evt.item.querySelector('[name*="select-item"]'); |
| | | if (checkbox && !checkbox.checked) { |
| | | checkbox.checked = true; |
| | | checkbox.dispatchEvent(new Event('change', { bubbles: true })); |
| | | } |
| | | }, |
| | | |
| | | onDeselect: (evt) => { |
| | | const checkbox = evt.item.querySelector('[name*="select-item"]'); |
| | | if (checkbox && checkbox.checked) { |
| | | checkbox.checked = false; |
| | | checkbox.dispatchEvent(new Event('change', { bubbles: true })); |
| | | } |
| | | }, |
| | | |
| | | onAdd: (evt) => this.updateSortableState(evt.to), |
| | | onRemove: (evt) => this.updateSortableState(evt.from) |
| | | }); |
| | | |
| | | grid.sortableInstance = sortableInstance; |
| | | |
| | | const gridId = groupId |
| | | ? `${fieldId}-group-${groupId}` |
| | | : `${fieldId}-preview`; |
| | | |
| | | this.sortableInstances.set(gridId, sortableInstance); |
| | | |
| | | return sortableInstance; |
| | | } |
| | | createGroup(fieldId, groupId = null) { |
| | | const fieldData = this.getFieldData(fieldId); |
| | | const fieldEl = this.fieldElements.get(fieldId); |
| | |
| | | |
| | | // Add to field groups |
| | | if (!fieldData.groups) fieldData.groups = []; |
| | | const existingGroup = fieldData.groups.find(g => g.id === groupId); |
| | | if (!existingGroup) { |
| | | fieldData.groups.push({ |
| | | id: groupId, |
| | | uploads: [], |
| | | changes: {} |
| | | }); |
| | | this.saveFieldData(fieldData); |
| | | } |
| | | |
| | | // Initialize selection handler and sortable |
| | | // Initialize selection handler |
| | | this.addGroupSelectionHandler(fieldId, groupId); |
| | | |
| | | // Initialize sortable for this new group grid |
| | | if (grid) { |
| | | const sortableInstance = new Sortable(grid, { |
| | | ...this.sortableConfig, |
| | | group: { name: fieldId, pull: true, put: true }, |
| | | disabled: true, // Empty initially |
| | | onAdd: (evt) => this.updateSortableState(evt.to), |
| | | onRemove: (evt) => this.updateSortableState(evt.from) |
| | | }); |
| | | grid.sortableInstance = sortableInstance; |
| | | this.sortableInstances.set(`${fieldId}-group-${groupId}`, sortableInstance); |
| | | this.createSortableForGrid(grid, fieldId, groupId); |
| | | } |
| | | |
| | | return { id: groupId, element: groupElement, grid: grid }; |
| | | } |
| | | |
| | | createGroupElement(groupId, fieldId) { |
| | | let groupElement = window.getTemplate('imageGroup'); |
| | | if (!groupElement) return; |
| | |
| | | case 'restore': |
| | | this.handleRestoreUploads().then(() => {}); |
| | | break; |
| | | case 'restore-all': |
| | | this.handleRestoreAll().then(() => {}); |
| | | break; |
| | | case 'clear-cache': |
| | | if (!confirm('Save these uploads for later?')) { |
| | | this.cleanupStoredUploads(); |
| | |
| | | handler.subscribe((event, data) => { |
| | | switch(event) { |
| | | case 'item-selected': |
| | | // Sync with Sortable |
| | | this.syncSortableSelection(fieldId, data.selectedItems); |
| | | this.selected.set(fieldId, data.selectedItems); |
| | | break; |
| | | case 'item-deselected': |
| | | this.syncSortableSelection(fieldId, data.selectedItems); |
| | | this.selected.set(fieldId, data.selectedItems); |
| | | break; |
| | | case 'range-selected': |
| | | this.syncSortableSelection(fieldId, data.selectedItems); |
| | | this.selected.set(fieldId, data.selectedItems); |
| | | break; |
| | | case 'select-all': |
| | |
| | | * Save field data to store, converting Sets to Arrays |
| | | */ |
| | | async saveFieldData(fieldData) { |
| | | console.log('💾 Saving:', fieldData.id, { |
| | | uploads: fieldData.uploads?.size, |
| | | groups: fieldData.groups?.length |
| | | }); |
| | | |
| | | await this.fieldStore.save({ |
| | | ...fieldData, |
| | | timestamp: Date.now() |
| | |
| | | window.debouncer.schedule( |
| | | key, |
| | | () => this.persistFieldState(fieldId), |
| | | 1000 |
| | | 250 |
| | | ); |
| | | } |
| | | |
| | |
| | | handleFieldStoreEvent(event, data) { |
| | | switch(event) { |
| | | case 'data-loaded': |
| | | // Check for pending uploads to restore |
| | | this.checkForStoredUploads(); |
| | | this.fieldStoreReady = true; |
| | | this.checkIfBothStoresReady(); |
| | | break; |
| | | } |
| | | } |
| | |
| | | handleUploadStoreEvent(event, data) { |
| | | switch(event) { |
| | | case 'data-loaded': |
| | | this.uploadStoreReady = true; |
| | | this.checkIfBothStoresReady(); |
| | | break; |
| | | case 'item-saved': |
| | | this.showSaveIndicator(data.key); |
| | |
| | | } |
| | | } |
| | | |
| | | checkIfBothStoresReady() { |
| | | if (this.fieldStoreReady && this.uploadStoreReady && !this.hasCheckedForUploads) { |
| | | this.hasCheckedForUploads = true; |
| | | this.checkForStoredUploads(); |
| | | } |
| | | } |
| | | |
| | | async checkForStoredUploads() { |
| | | const allFieldStates = this.fieldStore.getAll(); |
| | | |
| | |
| | | fieldStates: allFieldStates.length, |
| | | uploadStoreSize: this.uploadStore.data.size |
| | | }); |
| | | console.log(this.uploadStore.getAll()); |
| | | console.log(this.fieldStore.getAll()); |
| | | const pendingFields = allFieldStates.filter(field => { |
| | | if (!field.uploads) return false; |
| | | |
| | |
| | | const itemGrid = fieldTemplate.querySelector('.item-grid.restore'); |
| | | |
| | | // Process each upload |
| | | for (const upload of field.uploads) { |
| | | |
| | | for (let uploadId of field.uploads) { |
| | | const upload = this.uploadStore.get(uploadId); |
| | | let uploadItem = window.getTemplate('uploadItem'); |
| | | if (!uploadItem) continue; |
| | | // |
| | |
| | | this.cleanupRestore(); |
| | | } |
| | | |
| | | async handleRestoreAll() { |
| | | let notification = document.querySelector('dialog.restore-uploads'); |
| | | if (!notification) { |
| | | return; |
| | | } |
| | | // Gets ALL uploads from notification without checking selection |
| | | const allUploads = []; |
| | | notification.querySelectorAll('.item.upload').forEach(item => { |
| | | let uploadId = item.dataset.uploadId; |
| | | let fieldId = item.dataset.fieldId; |
| | | allUploads.push({ uploadId, fieldId }); |
| | | }); |
| | | |
| | | await this.restoreSelectedUploads(allUploads); |
| | | this.cleanupRestore(); |
| | | } |
| | | |
| | | showSaveIndicator(key) { |
| | | // Optional: show user that state is being saved |
| | | } |
| | |
| | | this.updateBulkOptions(); |
| | | |
| | | // Initialize components |
| | | this.store = window.jvbStore.register( |
| | | const store = window.jvbStore.register( |
| | | this.content, |
| | | { |
| | | storeName: this.content, |
| | | keyPath: 'id', |
| | | endpoint: 'content', |
| | | headers: { |
| | |
| | | order: 'desc' |
| | | }, |
| | | TTL: 30 * 60 * 1000, //30 minutes cache |
| | | showLoading: true, |
| | | }); |
| | | this.store = store[this.content]; |
| | | |
| | | this.status = 'all'; |
| | | this.filterTimeout = null; |
| | |
| | | break; |
| | | } |
| | | } |
| | | if (window.targetCheck(e, 'select[data-filter]')) { |
| | | this.handleFilterChange(e); |
| | | } |
| | | } |
| | | handleTableChange(e) { |
| | | const row = e.target.closest('tr[data-id]'); |
| | |
| | | let filter = target.dataset.filter; |
| | | if (filter === 'taxonomies') { |
| | | let taxonomy = target.dataset.taxonomy; |
| | | this.store.setFilter(`tax_${taxonomy}`, filter.value); |
| | | this.store.setFilter(`tax_${taxonomy}`, target.value); |
| | | } else { |
| | | this[target.dataset.filter] = target.value; |
| | | this.store.setFilter(target.dataset.filter, target.value); |
| | |
| | | } |
| | | |
| | | setupFilters() { |
| | | document.querySelectorAll('[data-filter]').forEach(filter => { |
| | | this.settings.addSetting(filter) |
| | | filter.addEventListener('change', (e) => { |
| | | if (this.filterTimeout) { |
| | | clearTimeout(this.filterTimeout); |
| | | } |
| | | this.filterTimeout = setTimeout(() => { |
| | | this.filterHandler(e); |
| | | }, 300); |
| | | }); |
| | | }); |
| | | |
| | | // Search |
| | | const searchInput = document.querySelector('input[type="search"]'); |
| | | if (searchInput) { |
| | |
| | | } |
| | | } |
| | | window.debouncer = new DebouncedActions(); |
| | | |
| | |
| | | (()=>{class e{constructor(e){this.queue=window.jvbQueue,this.config=e,this.content=e.content||!1,this.settings=window.jvbUserSettings,this.content&&(this.isTimeline=!1,this.currentItemID=null,this.initElements(),this.updateBulkOptions(),this.store=window.jvbStore.register(this.content,{keyPath:"id",endpoint:"content",headers:{action_nonce:jvbSettings.dash},indexes:[{name:"id",keyPath:"id"},{name:"status",keyPath:"status"},{name:"date",keyPath:"date"},{name:"modified",keyPath:"modified"},{name:"title",keyPath:"title"}],filters:{content:this.content,user:jvbSettings.currentUser,page:1,status:"all",orderby:"modified",order:"desc"},TTL:18e5}),this.status="all",this.filterTimeout=null,this.viewController=new window.jvbViews(this.ui.container,this.store),this.tableForm=null,this.tableChanges=new Map,this.formController=this.isTimeline?new window.jvbForm({collectFormData:()=>this.collectTimelineData.bind(this)}):new window.jvbForm,this.viewController.subscribe(((e,t)=>{if("table-view"!==e||this.tableForm){if("not-table-view"===e)this.tableForm;else if("order-changed"===e){let e=this.store.get(t);if(!e)return;let i={};i[t]=e,this.savePosts(i,"Updating progression order")}}else this.tableForm||(this.tableForm=this.formController.registerForm(t,{autosave:!1,formStatus:!1,isTable:!0}))})),this.formController.subscribe(((e,t)=>{switch(e){case"form-submit":case"form-autosave":this.handleFormChange(e,t)}})),this.queue.subscribe(((e,t)=>{Object.hasOwn(t,"endpoint")&&"content"===t.endpoint&&(console.log("Queue Subscription in CRUD.js: ",t),"operation-completed"===e?this.handleQueueSuccess(e,t):"operation-failed-permanent"===e&&this.handleQueueFailure(e,t))})),this.initialized=!1,this.init())}handleFormChange(e,t){let i=t.fullData.post_title,s=Object.hasOwn(t,"changes")?t.changes:t.fullData,l={};if(this.isTimeline)return l[this.currentItemID]=s,void this.savePosts(l,i);let o=[];switch(!0){case t.config.element===this.ui.forms.edit:l[this.currentItemID]=s,i=`Saving ${i} Changes`,s.post_status&&this.shouldRemoveItem(s.post_status)&&o.push(this.currentItemID);break;case t.config.element===this.ui.forms.bulkEdit:let a=t.config.element.querySelectorAll(".selected input:checked");a.forEach((e=>{l[e.value]=s,s.post_status&&this.shouldRemoveItem(s.post_status)&&o.push(e.value)})),i=`Updating ${a.length} ${this.config.plural??"posts"} Changes`;break;case t.config.element===this.ui.forms.create:"form-submit"===e&&(l[t.config.data["form-id"]]=s,i=`Saving ${i} Changes`)}if(o.length>0){let e=0;o.forEach((t=>{setTimeout((()=>{const e=document.querySelector(`.item[data-id="${t}"]`);e&&window.fade(e,!1)}),e),e+=50})),t.config.element===this.ui.forms.bulkEdit&&setTimeout((()=>{this.viewController.clearSelection()}),e+100)}window.isEmptyObject(l)||this.savePosts(l,i)}shouldRemoveItem(e){return"all"===this.status&&!["publish","draft"].includes(e)||e!==this.status}savePosts(e,t){if(window.isEmptyObject(e))return;for(let t in e)e[t].content||(e[t].content=this.content);let i={endpoint:"content",headers:{action_nonce:jvbSettings.dash},data:{posts:e},popup:"Saving changes",title:t};this.queue.addToQueue(i)}async handleQueueSuccess(e,t){this.store.clearCache(),this.store.clearHttpHeaders(),this.store.fetch()}handleQueueFailure(e,t){console.error("Operation failed permanently:",t),this.a11y?.announce(`Operation failed: ${t.error_message||"Unknown error"}`)}initElements(){this.elements={modals:{create:"dialog.create",edit:"dialog.edit",bulkEdit:"dialog.bulkEdit"},container:".crud[data-content]",grid:".item-grid",bulkSelectActions:".bulk-action-select",forms:{create:"dialog.create form",edit:"dialog.edit form",bulkEdit:"dialog.bulkEdit form"},uploader:"details.uploader"},this.ui=window.uiFromSelectors(this.elements),this.isTimeline=!!document.querySelector("[data-timeline]")}init(){this.settings.addSetting(this.ui.uploader,"open"),this.ui.uploader.addEventListener("toggle",(e=>{this.settings.saveSetting("open",this.ui.uploader.open?"on":"off")})),this.filterHandler=this.handleFilterChange.bind(this),this.changeHandler=this.handleChange.bind(this),this.modals={};for(let[e,t]of Object.entries(this.ui.modals))this.modals[e]=new window.jvbModal(t),this.modals[e].subscribe(((t,i)=>{if("modal-close"===t)this.currentItemID=null,this.formController.cleanupForm(this.modals[e].modal.querySelector("form").dataset.formId)}));this.setupEventDelegation(),this.setupFilters(),this.initialized=!0}setupEventDelegation(){document.addEventListener("change",this.changeHandler),document.addEventListener("click",(e=>{const t=e.target.closest("[data-action]");if(t){e.preventDefault();const i=t.dataset.action,s=t.dataset.id;switch(i){case"edit":this.populateEditForm(s),this.modals.edit.handleOpen();break;case"delete":if(confirm("Delete this item?")){let e={};e[t.dataset.id]={post_status:"delete",content:this.content},window.fade(t.closest(".item"),!1),this.savePosts(e,`Sending ${this.singular} to trash...`),this.store.delete(s)}break;case"trash":let e={};e[t.dataset.id]={post_status:"trash",content:this.content},window.fade(t.closest(".item"),!1),this.savePosts(e,`Sending ${this.singular} to trash...`);break;case"create":this.modals.create.dataset.itemID="new",this.modals.create.dataset.content=this.content,this.modals.create.handleOpen();break;case"bulk-edit":Array.from(this.viewController.selectedItems).length>0&&this.modals.bulkEdit.handleOpen();break;case"bulk-delete":const i=Array.from(this.viewController.selectedItems);i.length>0&&confirm(`Delete ${i.length} items?`)&&(i.forEach((e=>this.store.delete(e))),this.viewController.clearSelection());break;case"sync":break;case"refresh":this.store.fetch()}}e.target.closest(".create-item")&&(this.formController.registerForm(this.ui.forms.create),this.modals.create.handleOpen()),e.target.closest(".cancel-bulk")&&this.viewController.selectAll(!1)})),document.addEventListener("keydown",(e=>{(e.ctrlKey||e.metaKey)&&"a"===e.key&&this.ui.container&&this.ui.container.contains(document.activeElement)&&(e.preventDefault(),this.viewController.selectAll()),"Escape"===e.key&&this.viewController?.selectedItems.size>0&&0===window.jvbModal.getAllModals().length&&this.viewController.clearSelection()}))}handleChange(e){if(e.target.closest("[data-id]"))this.isTimeline?this.handleTimelineTableChange(e):this.handleTableChange(e);else if(e.target.classList.contains("bulk-action-select")){if(e.target.value.startsWith("tax-")){const t=e.target.value.replace("tax-","");return this.openTaxonomyModal(t),void(e.target.value="")}switch(e.target.value){case"edit":this.populateBulkEdit(),this.modals.bulkEdit.handleOpen();break;case"publish":this.setBulkStatus("publish");break;case"draft":case"restore":this.setBulkStatus("draft");break;case"trash":this.setBulkStatus("trash");break;case"delete":this.setBulkStatus("delete")}}}handleTableChange(e){const t=e.target.closest("tr[data-id]");if(!t)return;const i=e.target,s=parseInt(t.dataset.id),l=i.closest(["data-field"])?.dataset.field;if(!l)return;const o=this.store.get(s);if(!o)return;o.fields[l]=this.getInputValue(i),this.store.save(o);let a={};a[s]=o.fields,this.savePosts(a,`Saving changes to ${this.content}`)}handleTimelineTableChange(e){const t=e.target.closest("tbody[data-id]");if(!t)return;const i=e.target,s=i.closest("[data-field]")?.dataset.field;if(!s)return;const l=parseInt(t.dataset.id),o=i.closest("tr.timeline-point"),a=this.store.get(l);if(!a)return;const n=this.getInputValue(i);if(o){const e=o.dataset.imageId;a.fields.timeline||(a.fields.timeline={}),a.fields.timeline[e]||(a.fields.timeline[e]={}),a.fields.timeline[e][s]=n}else a.fields[s]=n;this.store.save(a);let r={};r[l]=a.fields,this.savePosts(r,"Updating progress post")}getInputValue(e){return"checkbox"===e.type?e.checked?e.value||"1":"":"radio"===e.type?e.checked?e.value:null:e.value}openTaxonomyModal(e){window.jvbSelector?window.jvbSelector.openForFilter(e,((e,t)=>this.handleBulkTaxonomy(e,t))):console.error("TaxonomySelector not initialized")}handleBulkTaxonomy(e,t){if(e.length>0){e=e.join(",");let i={},s=Array.from(this.viewController.selectedItems);s.forEach((s=>{i[s]={content:this.content},i[s][t]=e}));let l=`Adding ${s.length} ${this.config.plural??"posts"} to ${e.length} ${jvbSettings.labels[t].plural}`;this.viewController.clearSelection(),this.savePosts(i,l)}}setBulkStatus(e){if(!["publish","draft","trash","delete"].includes(e))return;let t,i={};for(let t of this.viewController.selectedItems)i[t]={post_status:e,content:this.content};if("delete"===e)t="Deleting";else t=window.uppercaseFirst(e)+"ing";if("all"===this.status&&!["publish","draft"].includes(e)||e!==this.status){let e=0;for(let t of this.viewController.selectedItems)setTimeout((()=>{const e=document.querySelector(`.item[data-id="${t}"]`);e&&window.fade(e,!1)}),e),e+=50}this.viewController.clearSelection(),window.isEmptyObject(i)||this.savePosts(i,`${t} ${this.viewController.selectedItems.size} ${this.plural}...`)}handleFilterChange(e){let t=e.target,i=t.dataset.filter;if("taxonomies"===i){let e=t.dataset.taxonomy;this.store.setFilter(`tax_${e}`,i.value)}else this[t.dataset.filter]=t.value,this.store.setFilter(t.dataset.filter,t.value),"status"===t.dataset.filter&&this.updateBulkOptions(t.value)}updateBulkOptions(e="all"){if("trash"===e){if(this.ui.bulkSelectActions.querySelector('[value="edit"]')){window.removeChildren(this.ui.bulkSelectActions),window.getTemplate("trashOptions").querySelectorAll("option").forEach(((e,t)=>{0===t&&(e.checked=!0),this.ui.bulkSelectActions.append(e)}))}}else if(!this.ui.bulkSelectActions.querySelector('[value="edit"]')){window.removeChildren(this.ui.bulkSelectActions),window.getTemplate("notTrashOptions").querySelectorAll("option").forEach(((e,t)=>{this.ui.bulkSelectActions.append(e)}))}this.ui.bulkSelectActions.value=""}populateBulkEdit(){const e=this.modals.bulkEdit.modal.querySelector("form .selected");if(!e)return;window.removeChildren(e);for(let t of this.viewController.selectedItems){let i=this.store.get(t);const s=window.getTemplate("bulkItem");if(!s)return;const l=s.querySelector("input[type=checkbox]"),o=s.querySelector("img");l&&(l.id=`bulk_${i.id}`,l.value=i.id,l.checked=!0),o&&i.thumbnail&&(o.src=i.thumbnail,o.alt=i.alt||""),e.append(s)}let t=this.modals.bulkEdit.modal;[t.querySelector("h2 span").textContent]=[this.viewController.selectedItems.size],this.formController.registerForm(this.ui.forms.bulkEdit)}populateEditForm(e){this.currentItemID=e;let t=this.store.get(parseInt(e));if(t){this.ui.modals.edit.dataset.itemID=e,this.ui.modals.edit.dataset.content=this.content;let i=this.ui.modals.edit.querySelector("form");[this.ui.modals.edit.querySelector("h2").textContent]=[`Editing ${t.fields.post_title}`],i.dataset.formId=`edit-${e}`,new window.jvbPopulate(i,t.fields,t.images),this.formController.registerForm(this.ui.forms.edit)}}setupFilters(){document.querySelectorAll("[data-filter]").forEach((e=>{this.settings.addSetting(e),e.addEventListener("change",(e=>{this.filterTimeout&&clearTimeout(this.filterTimeout),this.filterTimeout=setTimeout((()=>{this.filterHandler(e)}),300)}))}));const e=document.querySelector('input[type="search"]');if(e){let t;e.addEventListener("input",(()=>{e.value.length>3?(clearTimeout(t),t=setTimeout((()=>{this.store.setFilter("search",e.value)}),300)):0===e.value.length&&this.store.removeFilter("search")}))}}destroy(){document.querySelectorAll("[data-filter]").forEach((e=>{e.removeEventListener("change",this.filterHandler)}))}}document.addEventListener("DOMContentLoaded",(()=>{let t=document.querySelector("[data-content]");t&&(window.crudManager=new e({content:t.dataset.content}))}))})(); |
| | | (()=>{class e{constructor(e){if(this.queue=window.jvbQueue,this.config=e,this.content=e.content||!1,this.settings=window.jvbUserSettings,!this.content)return;this.isTimeline=!1,this.currentItemID=null,this.initElements(),this.updateBulkOptions();const t=window.jvbStore.register(this.content,{storeName:this.content,keyPath:"id",endpoint:"content",headers:{action_nonce:jvbSettings.dash},indexes:[{name:"id",keyPath:"id"},{name:"status",keyPath:"status"},{name:"date",keyPath:"date"},{name:"modified",keyPath:"modified"},{name:"title",keyPath:"title"}],filters:{content:this.content,user:jvbSettings.currentUser,page:1,status:"all",orderby:"modified",order:"desc"},TTL:18e5,showLoading:!0});this.store=t[this.content],this.status="all",this.filterTimeout=null,this.viewController=new window.jvbViews(this.ui.container,this.store),this.tableForm=null,this.tableChanges=new Map,this.formController=this.isTimeline?new window.jvbForm({collectFormData:()=>this.collectTimelineData.bind(this)}):new window.jvbForm,this.viewController.subscribe(((e,t)=>{if("table-view"!==e||this.tableForm){if("not-table-view"===e)this.tableForm;else if("order-changed"===e){let e=this.store.get(t);if(!e)return;let s={};s[t]=e,this.savePosts(s,"Updating progression order")}}else this.tableForm||(this.tableForm=this.formController.registerForm(t,{autosave:!1,formStatus:!1,isTable:!0}))})),this.formController.subscribe(((e,t)=>{switch(e){case"form-submit":case"form-autosave":this.handleFormChange(e,t)}})),this.queue.subscribe(((e,t)=>{Object.hasOwn(t,"endpoint")&&"content"===t.endpoint&&(console.log("Queue Subscription in CRUD.js: ",t),"operation-completed"===e?this.handleQueueSuccess(e,t):"operation-failed-permanent"===e&&this.handleQueueFailure(e,t))})),this.initialized=!1,this.init()}handleFormChange(e,t){let s=t.fullData.post_title,i=Object.hasOwn(t,"changes")?t.changes:t.fullData,l={};if(this.isTimeline)return l[this.currentItemID]=i,void this.savePosts(l,s);let o=[];switch(!0){case t.config.element===this.ui.forms.edit:l[this.currentItemID]=i,s=`Saving ${s} Changes`,i.post_status&&this.shouldRemoveItem(i.post_status)&&o.push(this.currentItemID);break;case t.config.element===this.ui.forms.bulkEdit:let a=t.config.element.querySelectorAll(".selected input:checked");a.forEach((e=>{l[e.value]=i,i.post_status&&this.shouldRemoveItem(i.post_status)&&o.push(e.value)})),s=`Updating ${a.length} ${this.config.plural??"posts"} Changes`;break;case t.config.element===this.ui.forms.create:"form-submit"===e&&(l[t.config.data["form-id"]]=i,s=`Saving ${s} Changes`)}if(o.length>0){let e=0;o.forEach((t=>{setTimeout((()=>{const e=document.querySelector(`.item[data-id="${t}"]`);e&&window.fade(e,!1)}),e),e+=50})),t.config.element===this.ui.forms.bulkEdit&&setTimeout((()=>{this.viewController.clearSelection()}),e+100)}window.isEmptyObject(l)||this.savePosts(l,s)}shouldRemoveItem(e){return"all"===this.status&&!["publish","draft"].includes(e)||e!==this.status}savePosts(e,t){if(window.isEmptyObject(e))return;for(let t in e)e[t].content||(e[t].content=this.content);let s={endpoint:"content",headers:{action_nonce:jvbSettings.dash},data:{posts:e},popup:"Saving changes",title:t};this.queue.addToQueue(s)}async handleQueueSuccess(e,t){this.store.clearCache(),this.store.clearHttpHeaders(),this.store.fetch()}handleQueueFailure(e,t){console.error("Operation failed permanently:",t),this.a11y?.announce(`Operation failed: ${t.error_message||"Unknown error"}`)}initElements(){this.elements={modals:{create:"dialog.create",edit:"dialog.edit",bulkEdit:"dialog.bulkEdit"},container:".crud[data-content]",grid:".item-grid",bulkSelectActions:".bulk-action-select",forms:{create:"dialog.create form",edit:"dialog.edit form",bulkEdit:"dialog.bulkEdit form"},uploader:"details.uploader"},this.ui=window.uiFromSelectors(this.elements),this.isTimeline=!!document.querySelector("[data-timeline]")}init(){this.settings.addSetting(this.ui.uploader,"open"),this.ui.uploader.addEventListener("toggle",(e=>{this.settings.saveSetting("open",this.ui.uploader.open?"on":"off")})),this.filterHandler=this.handleFilterChange.bind(this),this.changeHandler=this.handleChange.bind(this),this.modals={};for(let[e,t]of Object.entries(this.ui.modals))this.modals[e]=new window.jvbModal(t),this.modals[e].subscribe(((t,s)=>{if("modal-close"===t)this.currentItemID=null,this.formController.cleanupForm(this.modals[e].modal.querySelector("form").dataset.formId)}));this.setupEventDelegation(),this.setupFilters(),this.initialized=!0}setupEventDelegation(){document.addEventListener("change",this.changeHandler),document.addEventListener("click",(e=>{const t=e.target.closest("[data-action]");if(t){e.preventDefault();const s=t.dataset.action,i=t.dataset.id;switch(s){case"edit":this.populateEditForm(i),this.modals.edit.handleOpen();break;case"delete":if(confirm("Delete this item?")){let e={};e[t.dataset.id]={post_status:"delete",content:this.content},window.fade(t.closest(".item"),!1),this.savePosts(e,`Sending ${this.singular} to trash...`),this.store.delete(i)}break;case"trash":let e={};e[t.dataset.id]={post_status:"trash",content:this.content},window.fade(t.closest(".item"),!1),this.savePosts(e,`Sending ${this.singular} to trash...`);break;case"create":this.modals.create.dataset.itemID="new",this.modals.create.dataset.content=this.content,this.modals.create.handleOpen();break;case"bulk-edit":Array.from(this.viewController.selectedItems).length>0&&this.modals.bulkEdit.handleOpen();break;case"bulk-delete":const s=Array.from(this.viewController.selectedItems);s.length>0&&confirm(`Delete ${s.length} items?`)&&(s.forEach((e=>this.store.delete(e))),this.viewController.clearSelection());break;case"sync":break;case"refresh":this.store.fetch()}}e.target.closest(".create-item")&&(this.formController.registerForm(this.ui.forms.create),this.modals.create.handleOpen()),e.target.closest(".cancel-bulk")&&this.viewController.selectAll(!1)})),document.addEventListener("keydown",(e=>{(e.ctrlKey||e.metaKey)&&"a"===e.key&&this.ui.container&&this.ui.container.contains(document.activeElement)&&(e.preventDefault(),this.viewController.selectAll()),"Escape"===e.key&&this.viewController?.selectedItems.size>0&&0===window.jvbModal.getAllModals().length&&this.viewController.clearSelection()}))}handleChange(e){if(e.target.closest("[data-id]"))this.isTimeline?this.handleTimelineTableChange(e):this.handleTableChange(e);else{if(e.target.classList.contains("bulk-action-select")){if(e.target.value.startsWith("tax-")){const t=e.target.value.replace("tax-","");return this.openTaxonomyModal(t),void(e.target.value="")}switch(e.target.value){case"edit":this.populateBulkEdit(),this.modals.bulkEdit.handleOpen();break;case"publish":this.setBulkStatus("publish");break;case"draft":case"restore":this.setBulkStatus("draft");break;case"trash":this.setBulkStatus("trash");break;case"delete":this.setBulkStatus("delete")}}window.targetCheck(e,"select[data-filter]")&&this.handleFilterChange(e)}}handleTableChange(e){const t=e.target.closest("tr[data-id]");if(!t)return;const s=e.target,i=parseInt(t.dataset.id),l=s.closest(["data-field"])?.dataset.field;if(!l)return;const o=this.store.get(i);if(!o)return;o.fields[l]=this.getInputValue(s),this.store.save(o);let a={};a[i]=o.fields,this.savePosts(a,`Saving changes to ${this.content}`)}handleTimelineTableChange(e){const t=e.target.closest("tbody[data-id]");if(!t)return;const s=e.target,i=s.closest("[data-field]")?.dataset.field;if(!i)return;const l=parseInt(t.dataset.id),o=s.closest("tr.timeline-point"),a=this.store.get(l);if(!a)return;const n=this.getInputValue(s);if(o){const e=o.dataset.imageId;a.fields.timeline||(a.fields.timeline={}),a.fields.timeline[e]||(a.fields.timeline[e]={}),a.fields.timeline[e][i]=n}else a.fields[i]=n;this.store.save(a);let r={};r[l]=a.fields,this.savePosts(r,"Updating progress post")}getInputValue(e){return"checkbox"===e.type?e.checked?e.value||"1":"":"radio"===e.type?e.checked?e.value:null:e.value}openTaxonomyModal(e){window.jvbSelector?window.jvbSelector.openForFilter(e,((e,t)=>this.handleBulkTaxonomy(e,t))):console.error("TaxonomySelector not initialized")}handleBulkTaxonomy(e,t){if(e.length>0){e=e.join(",");let s={},i=Array.from(this.viewController.selectedItems);i.forEach((i=>{s[i]={content:this.content},s[i][t]=e}));let l=`Adding ${i.length} ${this.config.plural??"posts"} to ${e.length} ${jvbSettings.labels[t].plural}`;this.viewController.clearSelection(),this.savePosts(s,l)}}setBulkStatus(e){if(!["publish","draft","trash","delete"].includes(e))return;let t,s={};for(let t of this.viewController.selectedItems)s[t]={post_status:e,content:this.content};if("delete"===e)t="Deleting";else t=window.uppercaseFirst(e)+"ing";if("all"===this.status&&!["publish","draft"].includes(e)||e!==this.status){let e=0;for(let t of this.viewController.selectedItems)setTimeout((()=>{const e=document.querySelector(`.item[data-id="${t}"]`);e&&window.fade(e,!1)}),e),e+=50}this.viewController.clearSelection(),window.isEmptyObject(s)||this.savePosts(s,`${t} ${this.viewController.selectedItems.size} ${this.plural}...`)}handleFilterChange(e){let t=e.target;if("taxonomies"===t.dataset.filter){let e=t.dataset.taxonomy;this.store.setFilter(`tax_${e}`,t.value)}else this[t.dataset.filter]=t.value,this.store.setFilter(t.dataset.filter,t.value),"status"===t.dataset.filter&&this.updateBulkOptions(t.value)}updateBulkOptions(e="all"){if("trash"===e){if(this.ui.bulkSelectActions.querySelector('[value="edit"]')){window.removeChildren(this.ui.bulkSelectActions),window.getTemplate("trashOptions").querySelectorAll("option").forEach(((e,t)=>{0===t&&(e.checked=!0),this.ui.bulkSelectActions.append(e)}))}}else if(!this.ui.bulkSelectActions.querySelector('[value="edit"]')){window.removeChildren(this.ui.bulkSelectActions),window.getTemplate("notTrashOptions").querySelectorAll("option").forEach(((e,t)=>{this.ui.bulkSelectActions.append(e)}))}this.ui.bulkSelectActions.value=""}populateBulkEdit(){const e=this.modals.bulkEdit.modal.querySelector("form .selected");if(!e)return;window.removeChildren(e);for(let t of this.viewController.selectedItems){let s=this.store.get(t);const i=window.getTemplate("bulkItem");if(!i)return;const l=i.querySelector("input[type=checkbox]"),o=i.querySelector("img");l&&(l.id=`bulk_${s.id}`,l.value=s.id,l.checked=!0),o&&s.thumbnail&&(o.src=s.thumbnail,o.alt=s.alt||""),e.append(i)}let t=this.modals.bulkEdit.modal;[t.querySelector("h2 span").textContent]=[this.viewController.selectedItems.size],this.formController.registerForm(this.ui.forms.bulkEdit)}populateEditForm(e){this.currentItemID=e;let t=this.store.get(parseInt(e));if(t){this.ui.modals.edit.dataset.itemID=e,this.ui.modals.edit.dataset.content=this.content;let s=this.ui.modals.edit.querySelector("form");[this.ui.modals.edit.querySelector("h2").textContent]=[`Editing ${t.fields.post_title}`],s.dataset.formId=`edit-${e}`,new window.jvbPopulate(s,t.fields,t.images),this.formController.registerForm(this.ui.forms.edit)}}setupFilters(){const e=document.querySelector('input[type="search"]');if(e){let t;e.addEventListener("input",(()=>{e.value.length>3?(clearTimeout(t),t=setTimeout((()=>{this.store.setFilter("search",e.value)}),300)):0===e.value.length&&this.store.removeFilter("search")}))}}destroy(){document.querySelectorAll("[data-filter]").forEach((e=>{e.removeEventListener("change",this.filterHandler)}))}}document.addEventListener("DOMContentLoaded",(()=>{let t=document.querySelector("[data-content]");t&&(window.crudManager=new e({content:t.dataset.content}))}))})(); |
| | |
| | | (()=>{class e{constructor(){if(e.instance)return e.instance;e.instance=this,this.databases=new Map,this.stores=new Map,this.subscribers=new Map,this.pendingInits=new Map,this.fetchQueue=[],this._initialized=!1,this.body=document.body,this.loading=document.querySelector("dialog.loading"),this.init(),window.addEventListener("beforeunload",(()=>this.destroy()))}async init(){this._initialized||(this._initialized=!0,"indexedDB"in window||console.warn("IndexedDB not supported"))}register(e,t={}){if(this.stores.has(e))return console.warn(`Store "${e}" already registered`),this.getStoreAPI(e);if(!t.keyPath)throw new Error(`Store "${e}" requires a keyPath`);const s={name:e,config:{dbName:`jvb_${e}_db`,version:1,storeName:"items",keyPath:"id",indexes:[],endpoint:null,apiBase:jvbSettings.api,filters:{},required:null,TTL:36e5,useHttpCaching:!0,showLoading:!1,delayFetch:!0,validateData:!0,...t},db:null,data:new Map,cache:new Map,httpHeaders:new Map,filters:{...t.filters},isFetching:!1,currentRequest:null,lastResponse:null,_initialized:!1};return s.config.headers={"X-WP-Nonce":jvbSettings?.nonce,...s.config.headers},this.stores.set(e,s),this.subscribers.set(e,new Set),this.initStoreDB(e).catch((t=>{console.error(`Failed to initialize store "${e}":`,t)})),this.getStoreAPI(e)}getStoreAPI(e){const t={fetch:()=>this.fetch(e),save:t=>this.save(e,t),delete:t=>this.delete(e,t),get:t=>this.get(e,t),getAll:()=>this.getAll(e),getFiltered:()=>this.getFiltered(e),clear:()=>this.clear(e),setFilter:(t,s)=>this.setFilter(e,t,s),setFilters:t=>this.setFilters(e,t),removeFilter:t=>this.removeFilter(e,t),clearFilters:()=>this.clearFilters(e),clearCache:()=>this.clearCache(e),clearHttpHeaders:t=>this.clearHttpHeaders(e,t),subscribe:t=>this.subscribe(e,t),ensureInitialized:()=>this.ensureStoreInitialized(e),get filters(){return{...t.getStore().filters}},get lastResponse(){return t.getStore().lastResponse},get data(){return t.getStore().data},getStore:()=>this.stores.get(e)};return t}normalizeForStorage(e){if(null==e)return e;if(e instanceof Set)return Array.from(e);if(e instanceof Map)return Object.fromEntries(e);if(Array.isArray(e))return e.map((e=>this.normalizeForStorage(e)));if("object"==typeof e){const t={};for(const[s,r]of Object.entries(e))t[s]=this.normalizeForStorage(r);return t}return e}stripDOMReferences(e,t=new WeakSet){if(null==e)return e;const s=typeof e;if("string"===s||"number"===s||"boolean"===s)return e;if("object"===s&&t.has(e))return"[Circular]";if(e instanceof HTMLElement||e instanceof NodeList||e instanceof HTMLCollection||void 0!==e.nodeType)return null;if(e instanceof Date)return e;if(Array.isArray(e))return t.add(e),e.map((e=>this.stripDOMReferences(e,t))).filter((e=>null!==e));if("object"===s){t.add(e);const s={};for(const[r,i]of Object.entries(e)){const e=this.stripDOMReferences(i,t);null!==e&&(s[r]=e)}return s}return e}async initStoreDB(e){const t=this.stores.get(e);if(!t||t._initialized)return;if(this.pendingInits.has(e))return this.pendingInits.get(e);const s=this._performStoreInit(e);this.pendingInits.set(e,s);try{await s,t._initialized=!0}finally{this.pendingInits.delete(e)}}async _performStoreInit(e){const t=this.stores.get(e),{dbName:s,version:r}=t.config;try{if(!this.databases.has(s)){const e=await this.openDatabase(s,r,(e=>{this.setupStores(e,t.config)}));this.databases.set(s,e)}t.db=this.databases.get(s),this.loadStoreDataInBackground(e),this.notify(e,"db-init")}catch(t){throw console.error(`Failed to initialize database for store "${e}":`,t),t}}openDatabase(e,t,s){return new Promise(((r,i)=>{const a=indexedDB.open(e,t);a.onupgradeneeded=e=>{s&&s(e.target.result,e.oldVersion,e.newVersion)},a.onsuccess=e=>r(e.target.result),a.onerror=e=>i(e.target.error),a.onblocked=()=>{console.warn(`Database ${e} blocked. Close other tabs.`)}}))}setupStores(e,t){if(!e.objectStoreNames.contains(t.storeName)){const s=e.createObjectStore(t.storeName,{keyPath:t.keyPath});t.indexes.forEach((e=>{s.createIndex(e.name,e.keyPath||e.name,{unique:e.unique||!1})}))}if(t.endpoint&&!e.objectStoreNames.contains("cache")){e.createObjectStore("cache",{keyPath:"key"}).createIndex("timestamp","timestamp",{unique:!1})}t.useHttpCaching&&!e.objectStoreNames.contains("headers")&&e.createObjectStore("headers",{keyPath:"key"})}loadStoreDataInBackground(e){const t=this.stores.get(e);if(!t?.db)return;const s=[this.loadStoreData(e),this.loadStoreCache(e),this.loadStoreHeaders(e)];Promise.all(s).then((()=>{this.notify(e,"data-ready"),t.config.endpoint&&t.config.delayFetch?(this.fetchQueue.push(e),1===this.fetchQueue.length&&this.processFetchQueue()):t.config.endpoint&&!t.config.delayFetch&&("requestIdleCallback"in window?requestIdleCallback((()=>this.fetch(e)),{timeout:2e3}):setTimeout((()=>this.fetch(e)),100))})).catch((t=>{console.error(`Background load error for store "${e}":`,t)}))}async processFetchQueue(){if(0===this.fetchQueue.length)return;const e=this.fetchQueue.shift();if(!this.stores.get(e))return this.processFetchQueue();try{await this.fetch(e)}catch(t){console.error(`Queue fetch error for "${e}":`,t)}this.fetchQueue.length>0&&("requestIdleCallback"in window?requestIdleCallback((()=>this.processFetchQueue()),{timeout:2e3}):setTimeout((()=>this.processFetchQueue()),50))}async loadStoreData(e){const t=this.stores.get(e);if(t?.db)return new Promise((s=>{const r=t.db.transaction([t.config.storeName],"readonly").objectStore(t.config.storeName).getAll();r.onsuccess=r=>{const i=r.target.result||[];i.forEach((e=>{const s=this.getItemKey(e,t.config.keyPath);t.data.set(s,e)})),this.notify(e,"data-loaded",{count:i.length}),s(i)},r.onerror=()=>s([])}))}async loadStoreCache(e){const t=this.stores.get(e);if(t?.db&&t.db.objectStoreNames.contains("cache"))return new Promise((e=>{const s=t.db.transaction(["cache"],"readonly").objectStore("cache").getAll();s.onsuccess=s=>{(s.target.result||[]).forEach((e=>{this.isCacheValid(e,t.config.TTL)&&t.cache.set(e.key,e)})),e()},s.onerror=()=>e()}))}async loadStoreHeaders(e){const t=this.stores.get(e);if(t?.db&&t.db.objectStoreNames.contains("headers"))return new Promise((e=>{const s=t.db.transaction(["headers"],"readonly").objectStore("headers").getAll();s.onsuccess=s=>{(s.target.result||[]).forEach((e=>{t.httpHeaders.set(e.key,e)})),e()},s.onerror=()=>e()}))}async ensureStoreInitialized(e){const t=this.stores.get(e);if(!t)throw new Error(`Store "${e}" not registered`);t._initialized||await this.initStoreDB(e)}async fetch(e){await this.ensureStoreInitialized(e);const t=this.stores.get(e);if(!t.isFetching){if(t.config.required){if((Array.isArray(t.config.required)?t.config.required:[t.config.required]).some((e=>!t.filters[e]||""===t.filters[e])))return}t.isFetching=!0;try{const s=this.generateCacheKey(t.filters),r=t.cache.get(s);if(r&&this.isCacheValid(r,t.config.TTL))return this.notify(e,"data-loaded",{cached:!0,items:r.items||[]}),r;t.config.showLoading&&this.setLoading(!0);const i=this.buildFetchUrl(e),a={...t.config.headers},o=t.httpHeaders.get(s);t.config.useHttpCaching&&o&&(o.etag&&(a["If-None-Match"]=o.etag),o.lastModified&&(a["If-Modified-Since"]=o.lastModified));const n=new AbortController;t.currentRequest=n;const c=await fetch(i,{method:"GET",headers:a,signal:n.signal});if(304===c.status&&r)return this.notify(e,"data-loaded",{cached:!0,notModified:!0,items:r.items||[]}),r;if(!c.ok)throw new Error(`HTTP ${c.status}: ${c.statusText}`);const d=await c.json();return t.config.useHttpCaching&&this.storeResponseHeaders(e,s,c),await this.processFetchedData(e,d,s),this.notify(e,"data-loaded",{cached:!1,items:d.items||[]}),d}catch(t){throw"AbortError"!==t.name&&(console.error(`Fetch error for store "${e}":`,t),this.notify(e,"fetch-error",{error:t})),t}finally{t.isFetching=!1,t.currentRequest=null,t.config.showLoading&&this.setLoading(!1)}}}buildFetchUrl(e){const t=this.stores.get(e),s=new URLSearchParams;Object.entries(t.filters).forEach((([e,t])=>{null!=t&&""!==t&&("object"==typeof t?s.set(e,JSON.stringify(t)):s.set(e,t))}));const r=t.config.apiBase+t.config.endpoint;return s.toString()?`${r}?${s}`:r}async processFetchedData(e,t,s){const r=this.stores.get(e),i=t.items||[];for(const t of i)await this.save(e,t);const a={key:s,items:i.map((e=>this.getItemKey(e,r.config.keyPath))),timestamp:Date.now(),endpoint:r.config.endpoint,filters:{...r.filters}};r.cache.set(s,a),await this.saveToCache(e,s,a),r.lastResponse={has_more:t.has_more||!1,total:t.total||i.length,pages:t.pages||1}}async save(e,t){const s=this.stores.get(e);let r=this.normalizeForStorage(t);if(r=this.stripDOMReferences(r),s.config.validateData){const t=this.validateSerializable(r);if(!t.valid)throw console.error(`Cannot save non-serializable data to store "${e}":`,t.error),new Error(`Non-serializable data: ${t.error}`)}const i=this.getItemKey(r,s.config.keyPath);if(s.data.set(i,t),s.db){const e=s.db.transaction([s.config.storeName],"readwrite").objectStore(s.config.storeName);await e.put(r)}return this.notify(e,"item-saved",{item:t,key:i}),i}validateSerializable(e,t="root"){if(null==e)return{valid:!0};const s=typeof e;if("string"===s||"number"===s||"boolean"===s)return{valid:!0};if("function"===s)return{valid:!1,error:`Function at ${t}`};if(e instanceof Date)return{valid:!0};if(e instanceof HTMLElement||e instanceof NodeList||e instanceof HTMLCollection||void 0!==e.nodeType)return{valid:!1,error:`DOM element at ${t}`};if(e instanceof FormData)return{valid:!1,error:`FormData at ${t}. Convert to object first.`};if(e instanceof Blob||e instanceof File)return{valid:!1,error:`Blob/File at ${t}. Handle file uploads separately.`};if(Array.isArray(e)){for(let s=0;s<e.length;s++){const r=this.validateSerializable(e[s],`${t}[${s}]`);if(!r.valid)return r}return{valid:!0}}if("object"===s){if(e instanceof Set)return{valid:!1,error:`Set at ${t}. Convert to Array first: Array.from(set)`};if(e instanceof Map)return{valid:!1,error:`Map at ${t}. Convert to Object first: Object.fromEntries(map)`};for(const[s,r]of Object.entries(e)){const e=this.validateSerializable(r,`${t}.${s}`);if(!e.valid)return e}return{valid:!0}}return{valid:!1,error:`Unknown type at ${t}: ${s}`}}async delete(e,t){const s=this.stores.get(e);if(s.data.delete(t),s.db){const e=s.db.transaction([s.config.storeName],"readwrite").objectStore(s.config.storeName);await e.delete(t)}this.notify(e,"item-deleted",{id:t})}get(e,t){return this.stores.get(e).data.get(t)}getAll(e){const t=this.stores.get(e);return Array.from(t.data.values())}getFiltered(e){const t=this.stores.get(e),s=this.generateCacheKey(t.filters),r=t.cache.get(s);return r&&r.items?r.items.reduce(((e,s)=>{const r=t.data.get(s);return r&&e.push(r),e}),[]):this.getAll(e)}async clear(e){const t=this.stores.get(e);if(t.data.clear(),t.cache.clear(),t.db){const e=t.db.transaction([t.config.storeName],"readwrite").objectStore(t.config.storeName);await e.clear()}this.notify(e,"data-cleared")}setFilter(e,t,s){const r=this.stores.get(e),i=r.filters[t];null==s||""===s?delete r.filters[t]:r.filters[t]=s,this.notify(e,"filters-changed",{filters:r.filters,changed:{key:t,oldValue:i,newValue:s}}),r.config.endpoint&&this.fetch(e)}async setFilters(e,t){const s=this.stores.get(e);Object.keys(t).some((e=>s.filters[e]!==t[e]))&&(s.filters={...s.filters,...t},this.notify(e,"filters-changed",{filters:s.filters,changed:t}),s.config.endpoint&&await this.fetch(e))}removeFilter(e,t){const s=this.stores.get(e),r=s.filters[t];void 0!==r&&(delete s.filters[t],this.notify(e,"filters-changed",{filters:s.filters,removed:{key:t,oldValue:r}}),s.config.endpoint&&this.fetch(e))}clearFilters(e){const t=this.stores.get(e),s={...t.filters};t.filters={...t.config.filters},this.notify(e,"filters-cleared",{oldFilters:s,filters:t.filters}),t.config.endpoint&&this.fetch(e)}clearCache(e){const t=this.stores.get(e);if(t.cache.clear(),t.db&&t.db.objectStoreNames.contains("cache")){t.db.transaction(["cache"],"readwrite").objectStore("cache").clear()}this.notify(e,"cache-cleared")}clearHttpHeaders(e,t=null){const s=this.stores.get(e);if(t){if(s.httpHeaders.delete(t),s.db&&s.db.objectStoreNames.contains("headers")){s.db.transaction(["headers"],"readwrite").objectStore("headers").delete(t)}}else if(s.httpHeaders.clear(),s.db&&s.db.objectStoreNames.contains("headers")){s.db.transaction(["headers"],"readwrite").objectStore("headers").clear()}}subscribe(e,t){const s=this.subscribers.get(e);return s.add(t),()=>s.delete(t)}notify(e,t,s={}){const r=this.subscribers.get(e);r&&r.forEach((r=>{try{r(t,s)}catch(t){console.error(`Subscriber error for store "${e}":`,t)}}))}storeResponseHeaders(e,t,s){const r=this.stores.get(e),i={key:t,etag:s.headers.get("ETag"),lastModified:s.headers.get("Last-Modified"),timestamp:Date.now()};if(r.httpHeaders.set(t,i),r.db&&r.db.objectStoreNames.contains("headers")){r.db.transaction(["headers"],"readwrite").objectStore("headers").put(i)}}async saveToCache(e,t,s){const r=this.stores.get(e);if(!r.db||!r.db.objectStoreNames.contains("cache"))return;const i=r.db.transaction(["cache"],"readwrite").objectStore("cache");await i.put(s)}generateCacheKey(e){const t=Object.keys(e).sort().reduce(((t,s)=>(t[s]=e[s],t)),{});return JSON.stringify(t)}isCacheValid(e,t){if(!e||!e.timestamp)return!1;return Date.now()-e.timestamp<t}getItemKey(e,t){if("function"==typeof t)return t(e);const s=t.split(".");let r=e;for(const e of s)r=r?.[e];return r}setLoading(e){this.body.classList.toggle("loading",e),e?this.loading?.showModal():this.loading?.close()}destroy(){this.stores.forEach((e=>{e.currentRequest&&e.currentRequest.abort()})),this.databases.forEach((e=>e.close())),this.stores.clear(),this.subscribers.clear(),this.databases.clear(),this.pendingInits.clear()}}document.addEventListener("DOMContentLoaded",(function(){window.jvbStore=new e}))})(); |
| | | (()=>{class e{constructor(){if(e.instance)return e.instance;e.instance=this,this.dbConfig=new Map,this.databases=new Map,this.stores=new Map,this.subscribers=new Map,this.pendingInits=new Map,this.fetchQueue=[],this._initialized=!1,this.body=document.body,this.loading=document.querySelector("dialog.loading"),this.init()}async init(){this._initialized||(this._initialized=!0,"indexedDB"in window||console.warn("IndexedDB not supported"))}register(e,t=[],s=1.1){if(Array.isArray(t)||(t=[t]),0===t.length)return;this.dbConfig.has(e)||this.dbConfig.set(e,{dbName:`jvb_${e}`,version:s,stores:{},_initialized:!1});let r=this.dbConfig.get(e);t.forEach((t=>{if(!t.storeName)throw new Error(`Store config for "${e}" missing storeName`);if(!t.keyPath)throw new Error(`Store "${t.storeName}" requires keyPath`);const s=`${e}_${t.storeName}`,i={config:{dbName:r.dbName,storeName:"items",keyPath:"id",indexes:[],endpoint:null,apiBase:jvbSettings.api,filters:{},required:null,TTL:36e5,useHttpCaching:!0,showLoading:!1,delayFetch:!0,validateData:!0,...t},dbKey:e,storeKey:s,data:new Map,cache:new Map,httpHeaders:new Map,subscribers:new Map,filters:{...t.filters||{}},isFetching:!1,currentRequest:null,lastResponse:null,_initialized:!1};i.config.headers={"X-WP-Nonce":jvbSettings?.nonce,...i.config.headers},r.stores[t.storeName]=s,this.stores.set(s,i),this.subscribers.has(s)||this.subscribers.set(s,new Set)})),this.initDB(e).catch((t=>{console.error(`Failed to initialize store "${e}":`,t)}));const i={};for(const[e,t]of Object.entries(r.stores))i[e]=this.getStoreAPI(t);return i}getStoreAPI(e){const t={fetch:()=>this.fetch(e),save:t=>this.save(e,t),delete:t=>this.delete(e,t),get:t=>this.get(e,t),getAll:()=>this.getAll(e),getFiltered:()=>this.getFiltered(e),clear:()=>this.clear(e),setFilter:(t,s)=>this.setFilter(e,t,s),setFilters:t=>this.setFilters(e,t),removeFilter:t=>this.removeFilter(e,t),clearFilters:()=>this.clearFilters(e),clearCache:()=>this.clearCache(e),clearHttpHeaders:t=>this.clearHttpHeaders(e,t),subscribe:t=>this.subscribe(e,t),ensureInitialized:()=>this.ensureStoreInitialized(e),get filters(){return{...t.getStore().filters}},get lastResponse(){return t.getStore().lastResponse},get data(){return t.getStore().data},getStore:()=>this.stores.get(e)};return t}normalizeForStorage(e){if(null==e)return e;if(e instanceof Set)return Array.from(e);if(e instanceof Map)return Object.fromEntries(e);if(e instanceof ArrayBuffer||ArrayBuffer.isView(e))return e;if(e instanceof Date)return e;if(Array.isArray(e))return e.map((e=>this.normalizeForStorage(e)));if("object"==typeof e){const t={};for(const[s,r]of Object.entries(e))t[s]=this.normalizeForStorage(r);return t}return e}formDataToObject(e){const t={_isFormData:!0,entries:{}};for(const[s,r]of e.entries())r instanceof File||r instanceof Blob||(t.entries[s]?(Array.isArray(t.entries[s])||(t.entries[s]=[t.entries[s]]),t.entries[s].push(r)):t.entries[s]=r);return t}async objectToFormData(e){if(!e._isFormData)return e;const t=new FormData;for(const[s,r]of Object.entries(e.entries))Array.isArray(r)?r.forEach((e=>t.append(s,e))):t.append(s,r);if(window.jvbUploads&&e.entries.upload_ids){const s=JSON.parse(e.entries.upload_ids);for(const e of s){const s=await window.jvbUploads.getBlobData(e);s&&t.append("files[]",s)}}return t}stripDOMReferences(e,t=new WeakSet){if(null==e)return e;const s=typeof e;if("string"===s||"number"===s||"boolean"===s)return e;if("object"===s&&t.has(e))return"[Circular]";if(e instanceof HTMLElement||e instanceof NodeList||e instanceof HTMLCollection||void 0!==e.nodeType)return null;if(e instanceof ArrayBuffer||ArrayBuffer.isView(e))return e;if(e instanceof Date)return e;if(Array.isArray(e))return t.add(e),e.map((e=>this.stripDOMReferences(e,t))).filter((e=>null!==e));if("object"===s){t.add(e);const s={};for(const[r,i]of Object.entries(e)){const e=this.stripDOMReferences(i,t);null!==e&&(s[r]=e)}return s}return e}async initDB(e){const t=this.dbConfig.get(e);if(!t||t._initialized)return;if(this.pendingInits.has(e))return this.pendingInits.get(e);const s=this._performDBInit(e);this.pendingInits.set(e,s);try{await s,t._initialized=!0}finally{this.pendingInits.delete(e)}}async _performDBInit(e){const t=this.dbConfig.get(e),{dbName:s,version:r}=t,i=Object.values(t.stores);try{if(!this.databases.has(s)){const e=await this.openDatabase(s,r,(e=>{i.forEach((t=>{let s=this.stores.get(t);s&&this.setupStores(e,s.config)}))}));this.databases.set(s,e)}i.forEach((e=>{let t=this.stores.get(e);t&&(t.db=this.databases.get(s),t._initialized=!0,this.loadStoreDataInBackground(e),this.notify(e,"db-init"))}))}catch(t){throw console.error(`Failed to initialize database for store "${e}":`,t),t}}openDatabase(e,t,s){return new Promise(((r,i)=>{const a=indexedDB.open(e,t);a.onupgradeneeded=e=>{s&&s(e.target.result,e.oldVersion,e.newVersion)},a.onsuccess=e=>r(e.target.result),a.onerror=e=>i(e.target.error),a.onblocked=()=>{console.warn(`Database ${e} blocked. Close other tabs.`)}}))}setupStores(e,t){if(!e.objectStoreNames.contains(t.storeName)){const s=e.createObjectStore(t.storeName,{keyPath:t.keyPath});t.indexes.forEach((e=>{s.createIndex(e.name,e.keyPath||e.name,{unique:e.unique||!1})}))}if(t.endpoint&&!e.objectStoreNames.contains("cache")){e.createObjectStore("cache",{keyPath:"key"}).createIndex("timestamp","timestamp",{unique:!1})}t.useHttpCaching&&!e.objectStoreNames.contains("headers")&&e.createObjectStore("headers",{keyPath:"key"})}loadStoreDataInBackground(e){const t=this.stores.get(e);if(!t?.db)return;const s=[this.loadStoreData(e),this.loadStoreCache(e),this.loadStoreHeaders(e)];Promise.all(s).then((()=>{this.notify(e,"data-ready"),t.config.endpoint&&t.config.delayFetch?(this.fetchQueue.push(e),1===this.fetchQueue.length&&this.processFetchQueue()):t.config.endpoint&&!t.config.delayFetch&&("requestIdleCallback"in window?requestIdleCallback((()=>this.fetch(e)),{timeout:2e3}):setTimeout((()=>this.fetch(e)),100))})).catch((t=>{console.error(`Background load error for store "${e}":`,t)}))}async processFetchQueue(){if(0===this.fetchQueue.length)return;const e=this.fetchQueue.shift();if(!this.stores.get(e))return this.processFetchQueue();try{await this.fetch(e)}catch(t){console.error(`Queue fetch error for "${e}":`,t)}this.fetchQueue.length>0&&("requestIdleCallback"in window?requestIdleCallback((()=>this.processFetchQueue()),{timeout:2e3}):setTimeout((()=>this.processFetchQueue()),50))}async loadStoreData(e){const t=this.stores.get(e);if(t?.db)return new Promise((s=>{const r=t.db.transaction([t.config.storeName],"readonly").objectStore(t.config.storeName).getAll();r.onsuccess=r=>{const i=r.target.result||[];i.forEach((e=>{const s=this.getItemKey(e,t.config.keyPath);t.data.set(s,e)})),this.notify(e,"data-loaded",{count:i.length}),s(i)},r.onerror=()=>s([])}))}async loadStoreCache(e){const t=this.stores.get(e);if(t?.db&&t.db.objectStoreNames.contains("cache"))return new Promise((e=>{const s=t.db.transaction(["cache"],"readonly").objectStore("cache").getAll();s.onsuccess=s=>{(s.target.result||[]).forEach((e=>{this.isCacheValid(e,t.config.TTL)&&t.cache.set(e.key,e)})),e()},s.onerror=()=>e()}))}async loadStoreHeaders(e){const t=this.stores.get(e);if(t?.db&&t.db.objectStoreNames.contains("headers"))return new Promise((e=>{const s=t.db.transaction(["headers"],"readonly").objectStore("headers").getAll();s.onsuccess=s=>{(s.target.result||[]).forEach((e=>{t.httpHeaders.set(e.key,e)})),e()},s.onerror=()=>e()}))}async ensureStoreInitialized(e){const t=this.stores.get(e);if(!t)throw new Error(`Store "${e}" not registered`);t._initialized||await this.initDB(t.dbKey)}async fetch(e){await this.ensureStoreInitialized(e);const t=this.stores.get(e);if(!t.isFetching){if(t.config.required){if((Array.isArray(t.config.required)?t.config.required:[t.config.required]).some((e=>!t.filters[e]||""===t.filters[e])))return}t.isFetching=!0;try{const s=this.generateCacheKey(t.filters),r=t.cache.get(s);if(r&&this.isCacheValid(r,t.config.TTL))return this.notify(e,"data-loaded",{cached:!0,items:r.items||[]}),r;t.config.showLoading&&this.setLoading(!0);const i=this.buildFetchUrl(e),a={...t.config.headers},o=t.httpHeaders.get(s);t.config.useHttpCaching&&o&&(o.etag&&(a["If-None-Match"]=o.etag),o.lastModified&&(a["If-Modified-Since"]=o.lastModified));const n=new AbortController;t.currentRequest=n;const c=await fetch(i,{method:"GET",headers:a,signal:n.signal});if(304===c.status&&r)return this.notify(e,"data-loaded",{cached:!0,notModified:!0,items:r.items||[]}),r;if(!c.ok)throw new Error(`HTTP ${c.status}: ${c.statusText}`);const d=await c.json();return t.config.useHttpCaching&&this.storeResponseHeaders(e,s,c),await this.processFetchedData(e,d,s),this.notify(e,"data-loaded",{cached:!1,items:d.items||[]}),d}catch(t){throw"AbortError"!==t.name&&(console.error(`Fetch error for store "${e}":`,t),this.notify(e,"fetch-error",{error:t})),t}finally{t.isFetching=!1,t.currentRequest=null,t.config.showLoading&&this.setLoading(!1)}}}buildFetchUrl(e){const t=this.stores.get(e),s=new URLSearchParams;Object.entries(t.filters).forEach((([e,t])=>{null!=t&&""!==t&&("object"==typeof t?s.set(e,JSON.stringify(t)):s.set(e,t))}));const r=t.config.apiBase+t.config.endpoint;return s.toString()?`${r}?${s}`:r}async processFetchedData(e,t,s){const r=this.stores.get(e),i=t.items||[];for(const t of i)await this.save(e,t);const a={key:s,items:i.map((e=>this.getItemKey(e,r.config.keyPath))),timestamp:Date.now(),endpoint:r.config.endpoint,filters:{...r.filters}};r.cache.set(s,a),await this.saveToCache(e,s,a),r.lastResponse={has_more:t.has_more||!1,total:t.total||i.length,pages:t.pages||1}}async save(e,t){const s=this.stores.get(e);let r=this.normalizeForStorage(t);if(r.data instanceof FormData&&(r={...r,data:this.formDataToObject(r.data)}),r=this.stripDOMReferences(r),s.config.validateData){const t=this.validateSerializable(r);if(!t.valid)throw console.error(`Cannot save non-serializable data to store "${e}":`,t.error),new Error(`Non-serializable data: ${t.error}`)}const i=this.getItemKey(r,s.config.keyPath);if(s.data.set(i,t),s.db){const e=s.db.transaction([s.config.storeName],"readwrite").objectStore(s.config.storeName);await e.put(r)}return this.notify(e,"item-saved",{item:t,key:i}),i}validateSerializable(e,t="root"){if(null==e)return{valid:!0};const s=typeof e;if("string"===s||"number"===s||"boolean"===s)return{valid:!0};if("function"===s)return{valid:!1,error:`Function at ${t}`};if(e instanceof Date)return{valid:!0};if(e instanceof ArrayBuffer||ArrayBuffer.isView(e))return{valid:!0};if(e instanceof HTMLElement||e instanceof NodeList||e instanceof HTMLCollection||void 0!==e.nodeType)return{valid:!1,error:`DOM element at ${t}`};if(e instanceof FormData)return{valid:!1,error:`FormData at ${t}. Convert to object first.`};if(e instanceof Blob||e instanceof File)return{valid:!1,error:`Blob/File at ${t}. Handle file uploads separately.`};if(Array.isArray(e)){for(let s=0;s<e.length;s++){const r=this.validateSerializable(e[s],`${t}[${s}]`);if(!r.valid)return r}return{valid:!0}}if("object"===s){if(e instanceof Set)return{valid:!1,error:`Set at ${t}. Convert to Array first: Array.from(set)`};if(e instanceof Map)return{valid:!1,error:`Map at ${t}. Convert to Object first: Object.fromEntries(map)`};for(const[s,r]of Object.entries(e)){const e=this.validateSerializable(r,`${t}.${s}`);if(!e.valid)return e}return{valid:!0}}return{valid:!1,error:`Unknown type at ${t}: ${s}`}}async delete(e,t){const s=this.stores.get(e);if(s.data.delete(t),s.db){const e=s.db.transaction([s.config.storeName],"readwrite").objectStore(s.config.storeName);await e.delete(t)}this.notify(e,"item-deleted",{id:t})}get(e,t){return this.stores.get(e).data.get(t)}getAll(e){const t=this.stores.get(e);return Array.from(t.data.values())}getFiltered(e){const t=this.stores.get(e),s=this.generateCacheKey(t.filters),r=t.cache.get(s);return r&&r.items?r.items.reduce(((e,s)=>{const r=t.data.get(s);return r&&e.push(r),e}),[]):this.getAll(e)}async clear(e){const t=this.stores.get(e);if(t.data.clear(),t.cache.clear(),t.db){const e=t.db.transaction([t.config.storeName],"readwrite").objectStore(t.config.storeName);await e.clear()}this.notify(e,"data-cleared")}setFilter(e,t,s){const r=this.stores.get(e),i=r.filters[t];null==s||""===s?delete r.filters[t]:r.filters[t]=s,this.notify(e,"filters-changed",{filters:r.filters,changed:{key:t,oldValue:i,newValue:s}}),r.config.endpoint&&this.fetch(e)}async setFilters(e,t){const s=this.stores.get(e);Object.keys(t).some((e=>s.filters[e]!==t[e]))&&(s.filters={...s.filters,...t},this.notify(e,"filters-changed",{filters:s.filters,changed:t}),s.config.endpoint&&await this.fetch(e))}removeFilter(e,t){const s=this.stores.get(e),r=s.filters[t];void 0!==r&&(delete s.filters[t],this.notify(e,"filters-changed",{filters:s.filters,removed:{key:t,oldValue:r}}),s.config.endpoint&&this.fetch(e))}clearFilters(e){const t=this.stores.get(e),s={...t.filters};t.filters={...t.config.filters},this.notify(e,"filters-cleared",{oldFilters:s,filters:t.filters}),t.config.endpoint&&this.fetch(e)}clearCache(e){const t=this.stores.get(e);if(t.cache.clear(),t.db&&t.db.objectStoreNames.contains("cache")){t.db.transaction(["cache"],"readwrite").objectStore("cache").clear()}this.notify(e,"cache-cleared")}clearHttpHeaders(e,t=null){const s=this.stores.get(e);if(t){if(s.httpHeaders.delete(t),s.db&&s.db.objectStoreNames.contains("headers")){s.db.transaction(["headers"],"readwrite").objectStore("headers").delete(t)}}else if(s.httpHeaders.clear(),s.db&&s.db.objectStoreNames.contains("headers")){s.db.transaction(["headers"],"readwrite").objectStore("headers").clear()}}subscribe(e,t){this.subscribers.has(e)||this.subscribers.set(e,new Set);const s=this.subscribers.get(e);return s.add(t),()=>s.delete(t)}notify(e,t,s={}){const r=this.subscribers.get(e);r&&r.forEach((r=>{try{r(t,s)}catch(t){console.error(`Subscriber error for store "${e}":`,t)}}))}storeResponseHeaders(e,t,s){const r=this.stores.get(e),i={key:t,etag:s.headers.get("ETag"),lastModified:s.headers.get("Last-Modified"),timestamp:Date.now()};if(r.httpHeaders.set(t,i),r.db&&r.db.objectStoreNames.contains("headers")){r.db.transaction(["headers"],"readwrite").objectStore("headers").put(i)}}async saveToCache(e,t,s){const r=this.stores.get(e);if(!r.db||!r.db.objectStoreNames.contains("cache"))return;const i=r.db.transaction(["cache"],"readwrite").objectStore("cache");await i.put(s)}generateCacheKey(e){const t=Object.keys(e).sort().reduce(((t,s)=>(t[s]=e[s],t)),{});return JSON.stringify(t)}isCacheValid(e,t){if(!e||!e.timestamp)return!1;return Date.now()-e.timestamp<t}getItemKey(e,t){if("function"==typeof t)return t(e);const s=t.split(".");let r=e;for(const e of s)r=r?.[e];return r}setLoading(e){this.body.classList.toggle("loading",e),e?this.loading?.showModal():this.loading?.close()}destroy(){this.stores.forEach((e=>{e.currentRequest&&e.currentRequest.abort()})),this.databases.forEach((e=>e.close())),this.stores.clear(),this.subscribers.clear(),this.databases.clear(),this.pendingInits.clear()}}document.addEventListener("DOMContentLoaded",(function(){window.jvbStore=new e}))})(); |
| | |
| | | (()=>{class e{constructor(e={}){this.config={collectFormData:!1,...e},this.store=window.jvbStore.register("forms",{storeName:"forms",keyPath:"formId",indexes:[{name:"status",keyPath:"status"},{name:"operationId",keyPath:"operationId"},{name:"timestamp",keyPath:"timestamp"},{name:"formType",keyPath:"type"}],TTL:1008e4,validateData:!0,delayFetch:!0}),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.isTimeline=!1,window.crudManager&&window.crudManager.isTimeline&&(this.isTimeline=!0),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.processRepeaterField=this.processRepeaterField.bind(this),this.processGroupField=this.processGroupField.bind(this),this.processLocationField=this.processLocationField.bind(this),this.processRegularField=this.processRegularField.bind(this),this.init()}async init(){this.store.subscribe(this.handleStoreEvent.bind(this)),this.initListeners(),window.jvbQueue&&window.jvbQueue.subscribe(((e,t)=>{"operation-completed"===e&&"form"===t.type&&this.handleOperationComplete(t)}))}async handleOperationComplete(e){if(e.formId)try{await this.store.delete(e.formId)}catch(e){console.warn("Failed to clear form cache:",e)}const t=this.forms.get(e.formId);t&&(t.isDirty=!1,t.lastSaved=Date.now(),t.data={})}handleStoreEvent(e,t){switch(e){case"item-saved":t.item.status;break;case"data-loaded":this.checkPendingForms()}}checkPendingForms(){this.store.getAll().filter((e=>"draft"===e.status)).forEach((e=>{const t=this.forms.get(e.formId);if(t?.element){const s=t.element.querySelector(".restore-form");s&&(s.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,t){const s=document.querySelector(`[data-form-id="${e}"]`);if(!s)return;const r=document.createElement("div");r.className="pending-changes-notification",r.innerHTML=`\n <p>We noticed unsaved changes from last time. Would you like to restore them?</p>\n <button class="restore-changes" data-form-id="${e}">Restore</button>\n <button class="discard-changes" data-form-id="${e}">Discard</button>\n `,s.insertBefore(r,s.firstChild),r.querySelector(".restore-changes").addEventListener("click",(async()=>{await this.restorePendingForm(e,t),r.remove()})),r.querySelector(".discard-changes").addEventListener("click",(async()=>{await this.discardPendingForm(e),r.remove()}))}async restorePendingForm(e,t){const s=document.querySelector(`[data-form-id="${e}"]`);s&&(new this.populateForm(s,t),await this.store.save({formId:e,data:t,status:"restored",timestamp:Date.now()}),window.jvbA11y&&window.jvbA11y.announce("Previous changes restored"))}async discardPendingForm(e){try{await this.store.delete(e),window.jvbA11y&&window.jvbA11y.announce("Previous changes discarded")}catch(e){console.error("Failed to discard pending form:",e)}}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={}){if(!e)return;const s=e.dataset.formId||`form_${Date.now()}`;e.dataset.formId=s,e.addEventListener("submit",this.submitHandler);const r={element:e,id:s,status:"",options:{autosave:"autosave"in e.dataset,saveDelay:this.autoSaveDefaults.delay,endpoint:e.dataset.save??"",formStatus:!0,cache:!0,...t},dependencies:new Map,data:this.collectFormData(e,!0)};if(this.initializeFormFields(e,r),this.forms.set(s,r),this.store&&r.options.cache){const e=this.store.get(s);e&&e.formData&&this.showPendingNotification(e)}return r}initializeFormFields(e,t=null){this.initQuillEditors(e),this.initRepeaterFields(e,t),t&&this.initConditionalFields(e,t),this.initCharacterLimits(e),this.initImageUploadFields(e),window.jvbTabs&&e.querySelector("nav.tabs")&&(t.tabs=new window.jvbTabs(e),this.forms.set(t.formId,t),this.initSteppedForm(t.formId)),window.jvbSelector&&window.jvbSelector.scanExistingFields(e)}initSteppedForm(e){const t=this.forms.get(e),s=t.element,r=t.tabs,a=s.querySelectorAll(".tab-content").length,i=s.querySelector(".form-progress .fill"),n=s.querySelector(".step-text .current"),o=s.querySelectorAll("nav.tabs button"),l=e=>{const t=e/a*100;i&&(i.style.width=t+"%"),n&&(n.textContent=e),o.forEach(((t,s)=>{const r=s+1;t.classList.remove("current","completed","pending"),r<e?t.classList.add("completed"):r===e?t.classList.add("current"):t.classList.add("pending")}))};s.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=s.querySelector(`.tab-content[data-step="${i+1}"]`);if(n&&this.validateStep(a)){const e=n.dataset.tab;r.switchTab(e,!0),l(i+1),s.scrollIntoView({behavior:"smooth",block:"start"})}}if(a){e.preventDefault();const t=a.closest(".tab-content"),i=parseInt(t.dataset.step),n=s.querySelector(`.tab-content[data-step="${i-1}"]`);if(n){const e=n.dataset.tab;r.switchTab(e,!0),l(i-1),s.scrollIntoView({behavior:"smooth",block:"start"})}}}));const c=r.switchTab.bind(r);r.switchTab=(e,t)=>{c(e,t);const r=s.querySelector(`.tab-content[data-tab="${e}"]`);if(r){const e=parseInt(r.dataset.step);l(e)}},l(1)}validateStep(e){const t=e.querySelectorAll(".field");let s=!0;return t.forEach((e=>{const t=e.querySelector("input, textarea, select");if(t&&!t.closest("[hidden]")){this.validateField(t,e)||(s=!1)}})),s}initQuillEditors(e){window.jvbQuill(e)}initRepeaterFields(e,t){e.querySelectorAll(".repeater").forEach((e=>{const s=e.querySelector(".add-repeater-row"),r=e.querySelector(".repeater-items"),a=e.querySelector("template");s&&a&&r&&(window.Sortable&&new Sortable(r,{handle:".repeater-row-header",animation:150,onEnd:()=>{this.updateRepeaterOrder(e,t)}}),s.addEventListener("click",(()=>{this.addRepeaterRow(e,t)})),r.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"),r=e.querySelector("template"),a=s.children.length,i=e.dataset.field,n=r.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 s=e.nextElementSibling;s&&"LABEL"===s.tagName&&(s.htmlFor=e.id)})),s.appendChild(n),t&&this.scheduleSave(t,{type:"repeater",action:"add",fieldName:i,delay:this.repeaterDelays.add}),window.jvbA11y&&window.jvbA11y.announce("Row added")}removeRepeaterRow(e,t){const s=e.closest(".repeater"),r=s.dataset.field;e.remove(),this.updateRepeaterOrder(s,t),t&&this.scheduleSave(t,{type:"repeater",action:"remove",fieldName:r,delay:this.repeaterDelays.remove}),window.jvbA11y&&window.jvbA11y.announce("Row removed")}updateRepeaterOrder(e,t){const s=e.querySelector(".repeater-items"),r=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 a=s[2];e.name=`${r}:${t}:${a}`,e.id=`${r}-${t}-${a}`;const i=e.nextElementSibling;i&&"LABEL"===i.tagName&&(i.htmlFor=e.id)}}))})),t&&this.scheduleSave(t,{type:"repeater",action:"reorder",fieldName:r,delay:this.repeaterDelays.reorder})}initConditionalFields(e,t){e.querySelectorAll("[data-depends-on]").forEach((s=>{const r=s.dataset.dependsOn,a=s.dataset.dependsValue,i=s.dataset.dependsOperator||"==";t.dependencies.has(r)||t.dependencies.set(r,[]),t.dependencies.get(r).push({field:s,requiredValue:a,operator:i}),this.checkFieldDependency(e,s,r,a,i)}))}checkFieldDependency(e,t,s,r,a){const i=e.querySelector(`[name="${s}"]`);if(!i)return;const n=this.getFieldValue(i),o=this.evaluateCondition(n,r,a);this.toggleFieldVisibility(t,o)}evaluateCondition(e,t,s){const r=String(e||""),a=String(t||"");switch(s){case"==":default:return r==a;case"!=":return r!=a;case">":return parseFloat(r)>parseFloat(a);case"<":return parseFloat(r)<parseFloat(a);case">=":return parseFloat(r)>=parseFloat(a);case"<=":return parseFloat(r)<=parseFloat(a);case"contains":return r.includes(a);case"empty":return""===r;case"not_empty":return""!==r}}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 r=s?.querySelector(".char-count");!r&&s&&(r=document.createElement("div"),r.className="char-count",r.innerHTML=`<span class="current">0</span> / <span class="limit">${t}</span>`,s.appendChild(r));const a=()=>{const s=e.value.length;r&&(r.querySelector(".current").textContent=s,r.classList.toggle("exceeded",s>t)),s>t&&(e.value=e.value.substring(0,t),r&&(r.querySelector(".current").textContent=t))};e.addEventListener("input",a),a()}))}initImageUploadFields(e){window.jvbUploads.scanFields(e)}async handleSubmit(e){const t=e.target;if(!t.dataset.formId)return;const s=this.forms.get(t.dataset.formId);if(this.subscribers.size>0){e.preventDefault();const r=this.collectFormData(t);this.notify("form-submit",{formId:t.dataset.formId,fullData:r,config:s})}else;}handleFormSuccess(e,t){if(e.querySelectorAll(".error-message").forEach((e=>e.remove())),e.querySelectorAll(".field-error").forEach((e=>e.classList.remove("field-error"))),e.classList.add("form-success"),t.message){const s=document.createElement("div");s.className="form-success-message success-message",s.textContent=t.message,e.insertBefore(s,e.firstChild);const r=window.getIcon?.("check-circle");r&&(r.classList.add("success-icon"),s.prepend(r))}if(t.title||t.description){const s=document.createElement("div");if(s.className="success-box",t.title){const e=document.createElement("h3");e.textContent=t.title,s.appendChild(e)}if(t.description){(Array.isArray(t.description)?t.description:[t.description]).forEach((e=>{const t=document.createElement("p");t.textContent=e,s.appendChild(t)}))}e.insertBefore(s,e.firstChild)}if(e.dataset.formId){this.store.delete(e.dataset.formId).catch((e=>{console.warn("Failed to clear form cache:",e)}));const t=this.forms.get(e.dataset.formId);t&&(t.isDirty=!1,t.lastSaved=Date.now(),t.data={})}window.jvbA11y&&window.jvbA11y.announce(t.message||"Form submitted successfully"),e.dispatchEvent(new CustomEvent("jvb-form-success",{detail:t}))}handleFormError(e,t){if(e.querySelectorAll(".error-message").forEach((e=>e.remove())),e.querySelectorAll(".field-error, .has-error").forEach((e=>{e.classList.remove("field-error","has-error")})),e.querySelectorAll(".field").forEach((e=>{this.clearValidation(e)})),t.field){const s=e.querySelector(`[data-field="${t.field}"]`);if(s){this.showError(s,t.message),this.touchedFields.add(t.field),s.scrollIntoView({behavior:"smooth",block:"center"});const e=s.querySelector("input, textarea, select");e&&e.focus()}}else{const s=document.createElement("div");s.className="form-error error-message",s.textContent=t.message;const r=window.getIcon?.("close-circle");r&&(r.classList.add("error-icon"),s.prepend(r)),e.insertBefore(s,e.firstChild),e.scrollIntoView({behavior:"smooth",block:"start"})}if(window.jvbA11y){const e=t.field?`Error in ${t.field}: ${t.message}`:`Form error: ${t.message}`;window.jvbA11y.announce(e)}e.dispatchEvent(new CustomEvent("jvb-form-error",{detail:t}))}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 s=0;if(e.target.closest(".increase")?s+=1:e.target.closest(".decrease")&&(s-=1),0!==s){let r=parseFloat(t.step);r=Math.max(r,1),e.ctrlKey&&e.shiftKey?r*=50:e.ctrlKey?r*=5:e.shiftKey&&(r*=10);let a=""===t.value?0:parseFloat(t.value);t.value=a+r*s,this.handleNumberLimits(t)}}handleNumberLimits(e){let[t,s,r,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>s?(e.value=s,r.disabled=!1):r.disabled?r.disabled=!1:a.disabled&&(a.disabled=!1)}handleChange(e){if(e.target.closest("[data-ignore]"))return;const t=e.target,s=t.form||t.closest("form");if(!s)return;const r=this.forms?.get(s.dataset.formId);if(r&&(r.options.autosave||this.subscribers.size>0)){const e=r.dependencies.get(t.name);e&&e.forEach((e=>{this.checkFieldDependency(s,e.field,t.name,e.requiredValue,e.operator)}));const a=this.getDelayForField(t);this.scheduleSave(r,a)}}handleFocus(e){const t=e.target;t.matches("input, textarea, select")&&(this.currentFocus=t)}handleBlur(e){if(e.target.closest("[data-ignore]"))return;const t=e.target,s=t.form||t.closest("form");if(!s)return;const r=e.target.closest("input, textarea, select");if(r){const e=this.findFieldWrapper(r);if(e){const t=e.dataset.field;t&&(this.shouldDebounce(r)&&window.debouncer.cancel(`validate_${t}`),this.touchedFields.add(t)),this.validateField(r,e)}const a=this.forms?.get(s.dataset.formId);a&&this.scheduleSave(a,{type:"blur",fieldName:t.name,delay:1500})}}handleInput(e){if(e.target.closest("[data-ignore]")||!e.target.closest("form"))return;const t=e.target.closest("input, textarea, select");if(!t)return;let s=t.closest("form");this.showFormStatus(s.dataset.formId,"pending");const r=this.findFieldWrapper(t);if(!r)return;const a=r.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 s=parseFloat(e);if(isNaN(s))return"Please enter a valid number";const r=t.dataset.min,a=t.dataset.max;return void 0!==r&&s<parseFloat(r)?`Value must be at least ${r}`:!(void 0!==a&&s>parseFloat(a))||`Value must be at most ${a}`}},text:{test:(e,t)=>{const s=t.dataset.minlength,r=t.dataset.maxlength;return s&&e.length<parseInt(s)?`Must be at least ${s} characters`:!(r&&e.length>parseInt(r))||`Must be no more than ${r} 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 s=this.getFieldValue(e),r=t.dataset.field;if(!this.touchedFields.has(r)&&!e.required)return!0;if(!s&&!e.required)return this.clearValidation(t),!0;if(e.required&&!s)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&&s){if(!new RegExp(a).test(s)){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(s))return this.showError(t,e.message),!1;if(e.test){const r=e.test(s,t);if(!0!==r)return this.showError(t,r),!1}}return this.showSuccess(t),this.notify("field-validated",e),!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,t=""){if(!e)return;const s=e.querySelector(".validation-icon.success"),r=e.querySelector(".validation-icon.error"),a=e.querySelector(".validation-message"),i=e.querySelector("input, textarea, select");e.classList.remove("has-error"),i?.classList.remove("error"),e.classList.add("has-success"),s&&(s.hidden=!1),r&&(r.hidden=!0),a&&(""===t?(a.hidden=!0,a.textContent=""):(a.hidden=!1,a.textContent=t))}showError(e,t){if(!e)return;const s=e.querySelector(".validation-icon.success"),r=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"),s&&(s.hidden=!0),r&&(r.hidden=!1),a&&(a.hidden=!1,a.textContent=t)}clearValidation(e){if(!e)return;const t=e.querySelector(".validation-icon"),s=e.querySelector(".validation-message"),r=e.querySelector("input, textarea, select");e.classList.remove("has-error","has-success"),r?.classList.remove("error"),t&&(t.hidden=!0),s&&(s.hidden=!0,s.textContent="")}validateAllFields(e){if(!e)return!0;const t=e.querySelectorAll(".field:not([hidden])");let s=!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 r=e.dataset.field;r&&this.touchedFields.add(r);this.validateField(t,e)||(s=!1,!1===s&&(t.scrollIntoView({behavior:"smooth",block:"center"}),t.focus()))}})),s}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 s=t.target.closest("input, select");if(!s)return;const r=s.name;if(!r)return;e.querySelectorAll(`[data-show-if*="${r}"]`).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 s=e.dataset.field,r=e.querySelector(".validation-message");s&&r&&(t[s]=r.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){if(!e.options.autosave)return;document.addEventListener("input",this.saveCheck,{passive:!0});const s=`autosave_${e.id}`;this.debouncer.schedule(s,(()=>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")})).catch((t=>{console.error("Autosave failed:",t),this.showFormStatus(e.id,"error","Failed to save changes")}));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,r]of Object.entries(t))"object"==typeof r&&(s[e]=r);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),r=this.getChangedFields(t.data,s);return Object.keys(r).length>0}showFormStatus(e,t,s=""){let r=this.forms.get(e);if(!r.options.formStatus)return;if(r.status===t)return;r.status=t;const a=r.element.querySelector(".fstatus");a.hidden=!1;const i=a.querySelector(".message");i.textContent="",a.querySelector(".icon")?.remove();const n={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"};let o=window.getIcon({autosaved:"check-circle",submitted:"check-circle",error:"close-circle",offline:"cloud-slash",pending:"exclamation-mark"}[t]);o&&a.prepend(o),""===s&&(s=n[t]||t),i.textContent=s,a.classList.toggle("loading",["uploading","saving"].includes(t)),"submitted"===t&&setTimeout((()=>a.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,t=!1){if(Object.hasOwn(e.dataset,"timeline"))return this.collectTimeline(e);if(e.classList.contains("table")&&"FORM"===e.tagName)return{};const s=new FormData(e);let r={};const a={},i={};for(let[t,n]of s.entries()){if(this.ignore.includes(t)||t.endsWith("_temp"))continue;this.getFieldProcessor(t)(t,n,r,a,i,e)}return window.isEmptyObject(i)?this.mergeRepeaterData(r,a):(r=this.mergeRepeaterData(r,a),this.mergePostData(r,i))}collectTimeline(e){let t={},s={},r=[],a=new FormData(e);for(const[i,n]of a.entries()){if(this.ignore.includes(i)||i.endsWith("_temp"))continue;const a=i.match(/^\[(\d+)\](.+)$/);if(a){const[,t,o]=a;if(s[t]||(s[t]={id:parseInt(t)},r.push(t)),"post_thumbnail"===o)s[t].post_thumbnail=parseInt(e.querySelector(`[name="${i}"]`).closest(".item")?.dataset.id);else{this.getFieldProcessor(o)(o,n,s[t],{},{},e)}}else{this.getFieldProcessor(i)(i,n,t,{},{},e)}}return t.timeline=r.map((e=>s[e])),delete t["form-id"],delete t.sendAll,delete t.timeline_temp,delete t[""],t}getFieldProcessor(e){return e.includes("::")?this.processGroupField:e.includes(":")?this.processRepeaterField:/\[[^\]]+\]/.test(e)?this.processLocationField:this.processRegularField}mergeRepeaterData(e,t){return Object.keys(t).forEach((s=>{const r={};Object.keys(t[s]).forEach((e=>{const a=t[s][e];Object.keys(a).length>0&&(r[e]=a)})),e[s]=Object.values(r)})),e}mergePostData(e,t){for(let[s,r]of Object.entries(t))e[s]=r;return e}processRepeaterField(e,t,s,r,a,i){let[n,o,l]=e.split(":");const c=l.endsWith("[]");l=l.replace("[]",""),r[n]||(r[n]={}),r[n][o]||(r[n][o]={}),c||r[n][o][l]?(r[n][o][l]?Array.isArray(r[n][o][l])||(r[n][o][l]=[r[n][o][l]]):r[n][o][l]=[],r[n][o][l].push(t)):r[n][o][l]=t}processGroupField(e,t,s,r,a,i){const n=e.split("::"),o=n[0];s[o]||(s[o]={});let l=s[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,s,r,a,i){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,r,a,i){s[e=e.replace("[]","")]?(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)||{}}showSummary(e,t="form"){const s=this.forms.get(e);if(!s)return;const r=s.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(s.data)){if(l.includes(e)||this.isEmptyValue(t))continue;const s=this.getFieldInfo(r,e);if(!s.label)continue;const a=this.createResultElement(o,s,t,r);a&&n.appendChild(a)}o.remove(),(t="form"!==t?r.closest(t)??r:r).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 s=e.querySelector(`label[for="${t}"]`),r=null,a=null;if(r||(r=e.querySelector(`[name="${t}"]`)),r||(r=e.querySelector(`[name="${t}[]"]`)),!r){const a=e.querySelector(`fieldset[data-field="${t}"]`);a&&(s=a.querySelector("legend"),r=a.querySelector("input, select, textarea"))}if(!s&&r){const e=r.closest(".field, fieldset");e&&(s=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:r&&(i="checkbox"===r.type&&r.name.endsWith("[]")?"checkbox":"checkbox"===r.type?"true_false":"SELECT"===r.tagName&&r.multiple?"select":r.type||"text"),{label:s?.textContent.replace("*","").trim()||null,type:i,wrapper:a,input:r}}createResultElement(e,t,s,r){const a=e.cloneNode(!0),i=a.querySelector("h4"),n=a.querySelector("p");i.textContent=t.label;const o=this.formatFieldValue(s,t.type,r);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,s){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,s,t);case"date":case"datetime":case"time":return window.formatDate?window.formatDate(e):e;case"radio":return this.getSelectLabel(e,s,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,s)=>{t+='<div class="repeater-row">',t+=`<strong>Entry ${s+1}:</strong><ul>`;for(const[s,r]of Object.entries(e))if(!this.isEmptyValue(r)){const e=s.replace(/_/g," ").replace(/\b\w/g,(e=>e.toUpperCase()));t+=`<li><strong>${e}:</strong> ${r}</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[s,r]of Object.entries(e))if(!this.isEmptyValue(r)){const e=s.replace(/_/g," ").replace(/\b\w/g,(e=>e.toUpperCase()));"object"!=typeof r||Array.isArray(r)?t+=`<li><strong>${e}:</strong> ${r}</li>`:t+=`<li><strong>${e}:</strong> ${this.formatGroupValue(r)}</li>`}return t+="</ul></div>",t}formatLocationValue(e){if("object"!=typeof e)return e;const t=[];return["address","city","state","zip","country"].forEach((s=>{e[s]&&t.push(e[s])})),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,s=null){if(0===e.length)return"<em>None selected</em>";if(t&&s&&s.input){return"<ul><li>"+e.map((e=>this.getSelectLabel(e,t,s.type))).join("</li><li>")+"</li></ul>"}return"<ul><li>"+e.join("</li><li>")+"</li></ul>"}getSelectLabel(e,t,s){if("select"===s){const s=t.querySelector(`option[value="${e}"]`);return s?.textContent||e}if("radio"===s){const s=t.querySelector(`input[type="radio"][value="${e}"]`),r=s?.nextElementSibling;return r?.textContent||e}if("checkbox"===s){const s=t.querySelector(`input[type="checkbox"][value="${e}"]`);if(s){const e=t.querySelector(`label[for="${s.id}"]`);if(e)return e.textContent.trim();const r=s.nextElementSibling;if("LABEL"===r?.tagName)return r.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((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("change",this.changeHandler),document.removeEventListener("focus",this.focusHandler,!0),document.removeEventListener("blur",this.blurHandler,!0),document.removeEventListener("input",this.inputHandler,!0)),this.forms.forEach((e=>{let t=e.element;t&&t.removeEventListener("submit",this.submitHandler)})),this.specialFields.clear(),this.forms.clear(),this.activeRepeaters.clear(),this.forms&&this.forms.clear()}}document.addEventListener("DOMContentLoaded",(()=>{window.jvbForm=e}))})(); |
| | | (()=>{class e{constructor(e={}){this.config={collectFormData:!1,...e};const t=window.jvbStore.register("forms",{storeName:"forms",keyPath:"formId",indexes:[{name:"status",keyPath:"status"},{name:"operationId",keyPath:"operationId"},{name:"timestamp",keyPath:"timestamp"},{name:"formType",keyPath:"type"}],TTL:1008e4,validateData:!0,delayFetch:!0});this.store=t.forms,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.isTimeline=!1,window.crudManager&&window.crudManager.isTimeline&&(this.isTimeline=!0),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.processRepeaterField=this.processRepeaterField.bind(this),this.processGroupField=this.processGroupField.bind(this),this.processLocationField=this.processLocationField.bind(this),this.processRegularField=this.processRegularField.bind(this),this.init()}async init(){this.store.subscribe(this.handleStoreEvent.bind(this)),this.initListeners(),window.jvbQueue&&window.jvbQueue.subscribe(((e,t)=>{"operation-completed"===e&&"form"===t.type&&this.handleOperationComplete(t)}))}async handleOperationComplete(e){if(e.formId)try{await this.store.delete(e.formId)}catch(e){console.warn("Failed to clear form cache:",e)}const t=this.forms.get(e.formId);t&&(t.isDirty=!1,t.lastSaved=Date.now(),t.data={})}handleStoreEvent(e,t){switch(e){case"item-saved":t.item.status;break;case"data-loaded":this.checkPendingForms()}}checkPendingForms(){this.store.getAll().filter((e=>"draft"===e.status)).forEach((e=>{const t=this.forms.get(e.formId);if(t?.element){const s=t.element.querySelector(".restore-form");s&&(s.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,t){const s=document.querySelector(`[data-form-id="${e}"]`);if(!s)return;const r=document.createElement("div");r.className="pending-changes-notification",r.innerHTML=`\n <p>We noticed unsaved changes from last time. Would you like to restore them?</p>\n <button class="restore-changes" data-form-id="${e}">Restore</button>\n <button class="discard-changes" data-form-id="${e}">Discard</button>\n `,s.insertBefore(r,s.firstChild),r.querySelector(".restore-changes").addEventListener("click",(async()=>{await this.restorePendingForm(e,t),r.remove()})),r.querySelector(".discard-changes").addEventListener("click",(async()=>{await this.discardPendingForm(e),r.remove()}))}async restorePendingForm(e,t){const s=document.querySelector(`[data-form-id="${e}"]`);s&&(new this.populateForm(s,t),await this.store.save({formId:e,data:t,status:"restored",timestamp:Date.now()}),window.jvbA11y&&window.jvbA11y.announce("Previous changes restored"))}async discardPendingForm(e){try{await this.store.delete(e),window.jvbA11y&&window.jvbA11y.announce("Previous changes discarded")}catch(e){console.error("Failed to discard pending form:",e)}}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={}){if(!e)return;const s=e.dataset.formId||`form_${Date.now()}`;e.dataset.formId=s,e.addEventListener("submit",this.submitHandler);const r={element:e,id:s,status:"",options:{autosave:"autosave"in e.dataset,saveDelay:this.autoSaveDefaults.delay,endpoint:e.dataset.save??"",formStatus:!0,cache:!0,...t},dependencies:new Map,data:this.collectFormData(e,!0)};if(this.initializeFormFields(e,r),this.forms.set(s,r),this.store&&r.options.cache){const e=this.store.get(s);e&&e.formData&&this.showPendingNotification(e)}return r}initializeFormFields(e,t=null){this.initQuillEditors(e),this.initRepeaterFields(e,t),t&&this.initConditionalFields(e,t),this.initCharacterLimits(e),this.initImageUploadFields(e),window.jvbTabs&&e.querySelector("nav.tabs")&&(t.tabs=new window.jvbTabs(e),this.forms.set(t.formId,t),this.initSteppedForm(t.formId)),window.jvbSelector&&window.jvbSelector.scanExistingFields(e)}initSteppedForm(e){const t=this.forms.get(e),s=t.element,r=t.tabs,a=s.querySelectorAll(".tab-content").length,i=s.querySelector(".form-progress .fill"),n=s.querySelector(".step-text .current"),o=s.querySelectorAll("nav.tabs button"),l=e=>{const t=e/a*100;i&&(i.style.width=t+"%"),n&&(n.textContent=e),o.forEach(((t,s)=>{const r=s+1;t.classList.remove("current","completed","pending"),r<e?t.classList.add("completed"):r===e?t.classList.add("current"):t.classList.add("pending")}))};s.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=s.querySelector(`.tab-content[data-step="${i+1}"]`);if(n&&this.validateStep(a)){const e=n.dataset.tab;r.switchTab(e,!0),l(i+1),s.scrollIntoView({behavior:"smooth",block:"start"})}}if(a){e.preventDefault();const t=a.closest(".tab-content"),i=parseInt(t.dataset.step),n=s.querySelector(`.tab-content[data-step="${i-1}"]`);if(n){const e=n.dataset.tab;r.switchTab(e,!0),l(i-1),s.scrollIntoView({behavior:"smooth",block:"start"})}}}));const c=r.switchTab.bind(r);r.switchTab=(e,t)=>{c(e,t);const r=s.querySelector(`.tab-content[data-tab="${e}"]`);if(r){const e=parseInt(r.dataset.step);l(e)}},l(1)}validateStep(e){const t=e.querySelectorAll(".field");let s=!0;return t.forEach((e=>{const t=e.querySelector("input, textarea, select");if(t&&!t.closest("[hidden]")){this.validateField(t,e)||(s=!1)}})),s}initQuillEditors(e){window.jvbQuill(e)}initRepeaterFields(e,t){e.querySelectorAll(".repeater").forEach((e=>{const s=e.querySelector(".add-repeater-row"),r=e.querySelector(".repeater-items"),a=e.querySelector("template");s&&a&&r&&(window.Sortable&&new Sortable(r,{handle:".repeater-row-header",animation:150,onEnd:()=>{this.updateRepeaterOrder(e,t)}}),s.addEventListener("click",(()=>{this.addRepeaterRow(e,t)})),r.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"),r=e.querySelector("template"),a=s.children.length,i=e.dataset.field,n=r.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 s=e.nextElementSibling;s&&"LABEL"===s.tagName&&(s.htmlFor=e.id)})),s.appendChild(n),t&&this.scheduleSave(t,{type:"repeater",action:"add",fieldName:i,delay:this.repeaterDelays.add}),window.jvbA11y&&window.jvbA11y.announce("Row added")}removeRepeaterRow(e,t){const s=e.closest(".repeater"),r=s.dataset.field;e.remove(),this.updateRepeaterOrder(s,t),t&&this.scheduleSave(t,{type:"repeater",action:"remove",fieldName:r,delay:this.repeaterDelays.remove}),window.jvbA11y&&window.jvbA11y.announce("Row removed")}updateRepeaterOrder(e,t){const s=e.querySelector(".repeater-items"),r=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 a=s[2];e.name=`${r}:${t}:${a}`,e.id=`${r}-${t}-${a}`;const i=e.nextElementSibling;i&&"LABEL"===i.tagName&&(i.htmlFor=e.id)}}))})),t&&this.scheduleSave(t,{type:"repeater",action:"reorder",fieldName:r,delay:this.repeaterDelays.reorder})}initConditionalFields(e,t){e.querySelectorAll("[data-depends-on]").forEach((s=>{const r=s.dataset.dependsOn,a=s.dataset.dependsValue,i=s.dataset.dependsOperator||"==";t.dependencies.has(r)||t.dependencies.set(r,[]),t.dependencies.get(r).push({field:s,requiredValue:a,operator:i}),this.checkFieldDependency(e,s,r,a,i)}))}checkFieldDependency(e,t,s,r,a){const i=e.querySelector(`[name="${s}"]`);if(!i)return;const n=this.getFieldValue(i),o=this.evaluateCondition(n,r,a);this.toggleFieldVisibility(t,o)}evaluateCondition(e,t,s){const r=String(e||""),a=String(t||"");switch(s){case"==":default:return r==a;case"!=":return r!=a;case">":return parseFloat(r)>parseFloat(a);case"<":return parseFloat(r)<parseFloat(a);case">=":return parseFloat(r)>=parseFloat(a);case"<=":return parseFloat(r)<=parseFloat(a);case"contains":return r.includes(a);case"empty":return""===r;case"not_empty":return""!==r}}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 r=s?.querySelector(".char-count");!r&&s&&(r=document.createElement("div"),r.className="char-count",r.innerHTML=`<span class="current">0</span> / <span class="limit">${t}</span>`,s.appendChild(r));const a=()=>{const s=e.value.length;r&&(r.querySelector(".current").textContent=s,r.classList.toggle("exceeded",s>t)),s>t&&(e.value=e.value.substring(0,t),r&&(r.querySelector(".current").textContent=t))};e.addEventListener("input",a),a()}))}initImageUploadFields(e){window.jvbUploads.scanFields(e)}async handleSubmit(e){const t=e.target;if(!t.dataset.formId)return;const s=this.forms.get(t.dataset.formId);if(this.subscribers.size>0){e.preventDefault();const r=this.collectFormData(t);this.notify("form-submit",{formId:t.dataset.formId,fullData:r,config:s})}else;}handleFormSuccess(e,t){if(e.querySelectorAll(".error-message").forEach((e=>e.remove())),e.querySelectorAll(".field-error").forEach((e=>e.classList.remove("field-error"))),e.classList.add("form-success"),t.message){const s=document.createElement("div");s.className="form-success-message success-message",s.textContent=t.message,e.insertBefore(s,e.firstChild);const r=window.getIcon?.("check-circle");r&&(r.classList.add("success-icon"),s.prepend(r))}if(t.title||t.description){const s=document.createElement("div");if(s.className="success-box",t.title){const e=document.createElement("h3");e.textContent=t.title,s.appendChild(e)}if(t.description){(Array.isArray(t.description)?t.description:[t.description]).forEach((e=>{const t=document.createElement("p");t.textContent=e,s.appendChild(t)}))}e.insertBefore(s,e.firstChild)}if(e.dataset.formId){this.store.delete(e.dataset.formId).catch((e=>{console.warn("Failed to clear form cache:",e)}));const t=this.forms.get(e.dataset.formId);t&&(t.isDirty=!1,t.lastSaved=Date.now(),t.data={})}window.jvbA11y&&window.jvbA11y.announce(t.message||"Form submitted successfully"),e.dispatchEvent(new CustomEvent("jvb-form-success",{detail:t}))}handleFormError(e,t){if(e.querySelectorAll(".error-message").forEach((e=>e.remove())),e.querySelectorAll(".field-error, .has-error").forEach((e=>{e.classList.remove("field-error","has-error")})),e.querySelectorAll(".field").forEach((e=>{this.clearValidation(e)})),t.field){const s=e.querySelector(`[data-field="${t.field}"]`);if(s){this.showError(s,t.message),this.touchedFields.add(t.field),s.scrollIntoView({behavior:"smooth",block:"center"});const e=s.querySelector("input, textarea, select");e&&e.focus()}}else{const s=document.createElement("div");s.className="form-error error-message",s.textContent=t.message;const r=window.getIcon?.("close-circle");r&&(r.classList.add("error-icon"),s.prepend(r)),e.insertBefore(s,e.firstChild),e.scrollIntoView({behavior:"smooth",block:"start"})}if(window.jvbA11y){const e=t.field?`Error in ${t.field}: ${t.message}`:`Form error: ${t.message}`;window.jvbA11y.announce(e)}e.dispatchEvent(new CustomEvent("jvb-form-error",{detail:t}))}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 s=0;if(e.target.closest(".increase")?s+=1:e.target.closest(".decrease")&&(s-=1),0!==s){let r=parseFloat(t.step);r=Math.max(r,1),e.ctrlKey&&e.shiftKey?r*=50:e.ctrlKey?r*=5:e.shiftKey&&(r*=10);let a=""===t.value?0:parseFloat(t.value);t.value=a+r*s,this.handleNumberLimits(t)}}handleNumberLimits(e){let[t,s,r,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>s?(e.value=s,r.disabled=!1):r.disabled?r.disabled=!1:a.disabled&&(a.disabled=!1)}handleChange(e){if(e.target.closest("[data-ignore]"))return;const t=e.target,s=t.form||t.closest("form");if(!s)return;const r=this.forms?.get(s.dataset.formId);if(r&&(r.options.autosave||this.subscribers.size>0)){const e=r.dependencies.get(t.name);e&&e.forEach((e=>{this.checkFieldDependency(s,e.field,t.name,e.requiredValue,e.operator)}));const a=this.getDelayForField(t);this.scheduleSave(r,a)}}handleFocus(e){const t=e.target;t.matches("input, textarea, select")&&(this.currentFocus=t)}handleBlur(e){if(e.target.closest("[data-ignore]"))return;const t=e.target,s=t.form||t.closest("form");if(!s)return;const r=e.target.closest("input, textarea, select");if(r){const e=this.findFieldWrapper(r);if(e){const t=e.dataset.field;t&&(this.shouldDebounce(r)&&window.debouncer.cancel(`validate_${t}`),this.touchedFields.add(t)),this.validateField(r,e)}const a=this.forms?.get(s.dataset.formId);a&&this.scheduleSave(a,{type:"blur",fieldName:t.name,delay:1500})}}handleInput(e){if(e.target.closest("[data-ignore]")||!e.target.closest("form"))return;const t=e.target.closest("input, textarea, select");if(!t)return;let s=t.closest("form");this.showFormStatus(s.dataset.formId,"pending");const r=this.findFieldWrapper(t);if(!r)return;const a=r.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 s=parseFloat(e);if(isNaN(s))return"Please enter a valid number";const r=t.dataset.min,a=t.dataset.max;return void 0!==r&&s<parseFloat(r)?`Value must be at least ${r}`:!(void 0!==a&&s>parseFloat(a))||`Value must be at most ${a}`}},text:{test:(e,t)=>{const s=t.dataset.minlength,r=t.dataset.maxlength;return s&&e.length<parseInt(s)?`Must be at least ${s} characters`:!(r&&e.length>parseInt(r))||`Must be no more than ${r} 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 s=this.getFieldValue(e),r=t.dataset.field;if(!this.touchedFields.has(r)&&!e.required)return!0;if(!s&&!e.required)return this.clearValidation(t),!0;if(e.required&&!s)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&&s){if(!new RegExp(a).test(s)){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(s))return this.showError(t,e.message),!1;if(e.test){const r=e.test(s,t);if(!0!==r)return this.showError(t,r),!1}}return this.showSuccess(t),this.notify("field-validated",e),!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,t=""){if(!e)return;const s=e.querySelector(".validation-icon.success"),r=e.querySelector(".validation-icon.error"),a=e.querySelector(".validation-message"),i=e.querySelector("input, textarea, select");e.classList.remove("has-error"),i?.classList.remove("error"),e.classList.add("has-success"),s&&(s.hidden=!1),r&&(r.hidden=!0),a&&(""===t?(a.hidden=!0,a.textContent=""):(a.hidden=!1,a.textContent=t))}showError(e,t){if(!e)return;const s=e.querySelector(".validation-icon.success"),r=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"),s&&(s.hidden=!0),r&&(r.hidden=!1),a&&(a.hidden=!1,a.textContent=t)}clearValidation(e){if(!e)return;const t=e.querySelector(".validation-icon"),s=e.querySelector(".validation-message"),r=e.querySelector("input, textarea, select");e.classList.remove("has-error","has-success"),r?.classList.remove("error"),t&&(t.hidden=!0),s&&(s.hidden=!0,s.textContent="")}validateAllFields(e){if(!e)return!0;const t=e.querySelectorAll(".field:not([hidden])");let s=!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 r=e.dataset.field;r&&this.touchedFields.add(r);this.validateField(t,e)||(s=!1,!1===s&&(t.scrollIntoView({behavior:"smooth",block:"center"}),t.focus()))}})),s}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 s=t.target.closest("input, select");if(!s)return;const r=s.name;if(!r)return;e.querySelectorAll(`[data-show-if*="${r}"]`).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 s=e.dataset.field,r=e.querySelector(".validation-message");s&&r&&(t[s]=r.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){if(!e.options.autosave)return;document.addEventListener("input",this.saveCheck,{passive:!0});const s=`autosave_${e.id}`;this.debouncer.schedule(s,(()=>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")})).catch((t=>{console.error("Autosave failed:",t),this.showFormStatus(e.id,"error","Failed to save changes")}));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,r]of Object.entries(t))"object"==typeof r&&(s[e]=r);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),r=this.getChangedFields(t.data,s);return Object.keys(r).length>0}showFormStatus(e,t,s=""){let r=this.forms.get(e);if(!r.options.formStatus)return;if(r.status===t)return;r.status=t;const a=r.element.querySelector(".fstatus");a.hidden=!1;const i=a.querySelector(".message");i.textContent="",a.querySelector(".icon")?.remove();const n={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"};let o=window.getIcon({autosaved:"check-circle",submitted:"check-circle",error:"close-circle",offline:"cloud-slash",pending:"exclamation-mark"}[t]);o&&a.prepend(o),""===s&&(s=n[t]||t),i.textContent=s,a.classList.toggle("loading",["uploading","saving"].includes(t)),"submitted"===t&&setTimeout((()=>a.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,t=!1){if(Object.hasOwn(e.dataset,"timeline"))return this.collectTimeline(e);if(e.classList.contains("table")&&"FORM"===e.tagName)return{};const s=new FormData(e);let r={};const a={},i={};for(let[t,n]of s.entries()){if(this.ignore.includes(t)||t.endsWith("_temp"))continue;this.getFieldProcessor(t)(t,n,r,a,i,e)}return window.isEmptyObject(i)?this.mergeRepeaterData(r,a):(r=this.mergeRepeaterData(r,a),this.mergePostData(r,i))}collectTimeline(e){let t={},s={},r=[],a=new FormData(e);for(const[i,n]of a.entries()){if(this.ignore.includes(i)||i.endsWith("_temp"))continue;const a=i.match(/^\[(\d+)\](.+)$/);if(a){const[,t,o]=a;if(s[t]||(s[t]={id:parseInt(t)},r.push(t)),"post_thumbnail"===o)s[t].post_thumbnail=parseInt(e.querySelector(`[name="${i}"]`).closest(".item")?.dataset.id);else{this.getFieldProcessor(o)(o,n,s[t],{},{},e)}}else{this.getFieldProcessor(i)(i,n,t,{},{},e)}}return t.timeline=r.map((e=>s[e])),delete t["form-id"],delete t.sendAll,delete t.timeline_temp,delete t[""],t}getFieldProcessor(e){return e.includes("::")?this.processGroupField:e.includes(":")?this.processRepeaterField:/\[[^\]]+\]/.test(e)?this.processLocationField:this.processRegularField}mergeRepeaterData(e,t){return Object.keys(t).forEach((s=>{const r={};Object.keys(t[s]).forEach((e=>{const a=t[s][e];Object.keys(a).length>0&&(r[e]=a)})),e[s]=Object.values(r)})),e}mergePostData(e,t){for(let[s,r]of Object.entries(t))e[s]=r;return e}processRepeaterField(e,t,s,r,a,i){let[n,o,l]=e.split(":");const c=l.endsWith("[]");l=l.replace("[]",""),r[n]||(r[n]={}),r[n][o]||(r[n][o]={}),c||r[n][o][l]?(r[n][o][l]?Array.isArray(r[n][o][l])||(r[n][o][l]=[r[n][o][l]]):r[n][o][l]=[],r[n][o][l].push(t)):r[n][o][l]=t}processGroupField(e,t,s,r,a,i){const n=e.split("::"),o=n[0];s[o]||(s[o]={});let l=s[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,s,r,a,i){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,r,a,i){s[e=e.replace("[]","")]?(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)||{}}showSummary(e,t="form"){const s=this.forms.get(e);if(!s)return;const r=s.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(s.data)){if(l.includes(e)||this.isEmptyValue(t))continue;const s=this.getFieldInfo(r,e);if(!s.label)continue;const a=this.createResultElement(o,s,t,r);a&&n.appendChild(a)}o.remove(),(t="form"!==t?r.closest(t)??r:r).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 s=e.querySelector(`label[for="${t}"]`),r=null,a=null;if(r||(r=e.querySelector(`[name="${t}"]`)),r||(r=e.querySelector(`[name="${t}[]"]`)),!r){const a=e.querySelector(`fieldset[data-field="${t}"]`);a&&(s=a.querySelector("legend"),r=a.querySelector("input, select, textarea"))}if(!s&&r){const e=r.closest(".field, fieldset");e&&(s=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:r&&(i="checkbox"===r.type&&r.name.endsWith("[]")?"checkbox":"checkbox"===r.type?"true_false":"SELECT"===r.tagName&&r.multiple?"select":r.type||"text"),{label:s?.textContent.replace("*","").trim()||null,type:i,wrapper:a,input:r}}createResultElement(e,t,s,r){const a=e.cloneNode(!0),i=a.querySelector("h4"),n=a.querySelector("p");i.textContent=t.label;const o=this.formatFieldValue(s,t.type,r);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,s){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,s,t);case"date":case"datetime":case"time":return window.formatDate?window.formatDate(e):e;case"radio":return this.getSelectLabel(e,s,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,s)=>{t+='<div class="repeater-row">',t+=`<strong>Entry ${s+1}:</strong><ul>`;for(const[s,r]of Object.entries(e))if(!this.isEmptyValue(r)){const e=s.replace(/_/g," ").replace(/\b\w/g,(e=>e.toUpperCase()));t+=`<li><strong>${e}:</strong> ${r}</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[s,r]of Object.entries(e))if(!this.isEmptyValue(r)){const e=s.replace(/_/g," ").replace(/\b\w/g,(e=>e.toUpperCase()));"object"!=typeof r||Array.isArray(r)?t+=`<li><strong>${e}:</strong> ${r}</li>`:t+=`<li><strong>${e}:</strong> ${this.formatGroupValue(r)}</li>`}return t+="</ul></div>",t}formatLocationValue(e){if("object"!=typeof e)return e;const t=[];return["address","city","state","zip","country"].forEach((s=>{e[s]&&t.push(e[s])})),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,s=null){if(0===e.length)return"<em>None selected</em>";if(t&&s&&s.input){return"<ul><li>"+e.map((e=>this.getSelectLabel(e,t,s.type))).join("</li><li>")+"</li></ul>"}return"<ul><li>"+e.join("</li><li>")+"</li></ul>"}getSelectLabel(e,t,s){if("select"===s){const s=t.querySelector(`option[value="${e}"]`);return s?.textContent||e}if("radio"===s){const s=t.querySelector(`input[type="radio"][value="${e}"]`),r=s?.nextElementSibling;return r?.textContent||e}if("checkbox"===s){const s=t.querySelector(`input[type="checkbox"][value="${e}"]`);if(s){const e=t.querySelector(`label[for="${s.id}"]`);if(e)return e.textContent.trim();const r=s.nextElementSibling;if("LABEL"===r?.tagName)return r.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((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("change",this.changeHandler),document.removeEventListener("focus",this.focusHandler,!0),document.removeEventListener("blur",this.blurHandler,!0),document.removeEventListener("input",this.inputHandler,!0)),this.forms.forEach((e=>{let t=e.element;t&&t.removeEventListener("submit",this.submitHandler)})),this.specialFields.clear(),this.forms.clear(),this.activeRepeaters.clear(),this.forms&&this.forms.clear()}}document.addEventListener("DOMContentLoaded",(()=>{window.jvbForm=e}))})(); |
| | |
| | | (()=>{class e{constructor(e={}){this.canUpdateUI=!0,this.config={apiBase:jvbSettings.api,maxRetries:3,pollInterval:5e3,activityDelay:2e3,autosync:!0,endpoint:"queue",...e},this.user=jvbSettings.currentUser,this.headers={"X-WP-Nonce":jvbSettings.nonce,...e.headers},this.a11y=window.jvbA11y,this.errors=window.jvbError,this.store=window.jvbStore.register("queue",{keyPath:"id",endpoint:this.config.endpoint,TTL:1/0,indexes:[{name:"status",keyPath:"status"},{name:"type",keyPath:"type"}],showLoading:!1,delayFetch:!1}),this.classes=["offline","synced","pending"],this.isProcessing=!1,this.isPolling=!1,this.subscribers=new Set,this.statuses=["queued","localProcessing","uploading","pending","processing","completed","failed","failed_permanent"],this.initUI(),this.initListeners(),this.ui.panel&&(this.popup=new window.jvbPopup({popup:this.ui.panel,toggle:this.ui.toggle,name:"Queue Panel"})),this.initQueue(),this.user&&(this.ui.toggle.hidden=!1,this.ui.panel.hidden=!1)}async initQueue(){const e=this.getOperationsByStatus(["completed","failed_permanent"],!1);e.length>0?this.startPolling():this.updateStatusPanel("synced"),this.store.subscribe(((e,t)=>{switch(e){case"data-loaded":case"items-saved":this.getOperationsByStatus(["completed","failed_permanent"],!1).length>0&&this.startPolling(),this.updateUI();break;case"item-saved":if(t.item){const e=this.store.data.get(t.item.id);e&&e.status!==t.item.status&&this.handleOperationStatusChange(t.item,e.status)}this.hasQueuedOperations()&&this.startPolling();break;default:this.updateUI()}})),this.notify("queue-initialized",{operations:e})}handleOperationStatusChange(e,t){if(e&&t!==e.status)switch(e.status){case"completed":this.notify("operation-completed",e);break;case"failed":this.notify("operation-failed",e);break;case"failed_permanent":this.notify("operation-failed-permanent",e)}}addToQueue(e){const t={id:`u${this.user}_${Date.now()}_${Math.random().toString(36).substring(2,9)}`,endpoint:null,method:"POST",headers:{},data:{},canMerge:!0,popup:"Saving changes...",title:"Operation",status:"queued",timestamp:Date.now(),retries:0,user:this.user,...e};if(t.headers={...this.headers,...t.headers},!t.endpoint||!t.data)return console.error("Invalid operation queued: missing endpoint or data"),null;const s=Array.from(this.store.data.values()).filter((e=>"queued"===e.status&&e.endpoint===t.endpoint&&e.canMerge));if(s.length>0){const e=s[0];return e.data=window.deepMerge(e.data,t.data),e.timestamp=Date.now(),this.updateOperationStatus(e.id,e.status),this.updateUI(),this.startActivityTracking(),e.id}return console.log("Added to Queue: ",t),this.store.clearCache(),this.setQueue(t),this.updateOperationStatus(t.id,t.status),this.updateUI(),this.startActivityTracking(),t.id}setQueue(e){this.store.save(e)}updateOperationStatus(e,t){let s=this.store.get(e);s&&(s.status=t,this.notify("operation-status",s),this.updateOperationUI(s))}getQueue(e){return this.store.get(e)}clearQueue(e){this.store.delete(e)}startActivityTracking(){if(!this.activityListeners){const e=["mousedown","mousemove","keypress","scroll","touchstart"];this.activityListeners=e.map((e=>{const t=()=>this.resetActivityTimer();return document.addEventListener(e,t,{passive:!0}),{event:e,handler:t}}))}this.resetActivityTimer()}resetActivityTimer(){this.lastActivity=Date.now(),this.activityTimer&&clearTimeout(this.activityTimer),this.activityTimer=setTimeout((()=>{this.processQueue()}),this.config.activityDelay)}stopActivityTracking(){this.activityTimer&&(clearTimeout(this.activityTimer),this.activityTimer=null),this.activityListeners&&(this.activityListeners.forEach((({event:e,handler:t})=>{document.removeEventListener(e,t)})),this.activityListeners=null)}setProcessing(e){this.isProcessing=e,this.ui.toggle.classList.toggle("saving",e)}async processQueue(){if(this.isProcessing)return;const e=this.getOperationsByStatus("queued");if(0===e.length)return void this.stopActivityTracking();this.setProcessing(!0);for(const t of e)await this.processOperation(t);this.setProcessing(!1),this.stopActivityTracking();this.getOperationsByStatus(["queued","completed","failed_permanent"],!1).length>0&&this.startPolling()}async processOperation(e){try{this.updateOperationStatus(e.id,"uploading"),e=this.getQueue(e.id);const t=`${this.config.apiBase}${e.endpoint}`;let s;console.log(e.data),e.data instanceof FormData?(e.data.append("id",e.id),e.data.append("user",this.user),s=e.data):(s=JSON.stringify({...e.data,id:e.id,user:this.user}),e.headers["Content-Type"]="application/json");const i=await fetch(t,{method:e.method,headers:e.headers,body:s}),a=await i.json();if(!i.ok||!1===a.success)throw new Error(a.message||`HTTP ${i.status}`);if(a.id&&e.id!==a.id){const t=this.getQueue(a.id);t?(t.data=window.deepMerge(t.data,e.data),t.status="pending",t.serverData=a,this.updateOperationStatus(t.id,t.status),this.setQueue(t),this.removeOperationFromUI(e.id),e=t):(this.clearQueue(e.id),e.id=a.id,e.status="pending",e.serverData=a,this.updateOperationStatus(e.id,e.status),this.setQueue(e))}else e.status="pending",e.serverData=a,this.updateOperationStatus(e.id,"pending"),this.setQueue(e);this.a11y.announce(`${e.title} sent to server for processing.`)}catch(t){console.error("Operation failed:",t),e.retries++,e.lastError=t.message,e.retries>=this.config.maxRetries?e.status="failed_permanent":(e.status="failed",e.nextRetry=Date.now()+1e3*Math.pow(2,e.retries)),this.updateOperationStatus(e.id,e.status),this.setQueue(e)}}startPolling(){this.isPolling||(this.isPolling=!0,this.updateStatusPanel("pending"),this.pollTimer=setInterval((async()=>{try{this.store.clearCache(),await this.store.fetch();0===this.getOperationsByStatus(["completed","failed_permanent"],!1).length&&(this.stopPolling(),this.updateStatusPanel("synced"))}catch(e){console.error("Polling error:",e)}}),this.config.pollInterval))}stopPolling(){this.isPolling&&(this.isPolling=!1,this.pollTimer&&(clearInterval(this.pollTimer),this.pollTimer=null),this.countdownTimer&&(clearInterval(this.countdownTimer),this.countdownTimer=null))}async updateServerOperations(e,t){if(0!==(e=(e=Array.isArray(e)?e:e.includes(",")?e.split(","):[e]).filter((e=>{let s=this.getQueue(e);return this.getAllowedActions(s.status).includes(t)}))).length){["cancel","dismiss"].includes(t)&&e.forEach((e=>{this.removeOperationFromUI(e)}));try{const s=`${this.config.apiBase}${this.config.endpoint}`,i=await fetch(s,{method:"POST",headers:{"Content-Type":"application/json",...this.headers},body:JSON.stringify({ids:e,action:t,user:jvbSettings.currentUser})});if(!i.ok){const e=await i.json().catch((()=>{}));throw new Error(e.message||`${t} failed: ${i.status}`)}const a=await i.json();if(!a.success)throw new Error(a.message||`${t} operation failed`);return["cancel","dismiss"].includes(t)?e.forEach((e=>{let s=this.getQueue(e);this.notify(`${t}-operation`,s),this.clearQueue(e)})):(e.forEach((e=>{let s=this.getQueue(e);this.notify(`${t}-operation`,s),s.status="queued",s.retries=0,this.setQueue(s),this.updateOperationStatus(s.id,s.status)})),this.startActivityTracking()),this.updateUI(),a}catch(s){const i=await window.jvbError.log(s,{component:"QueueManager",operation:"performQueueAction",action:t,operationIds:e,itemCount:e.length},(()=>this.updateServerOperations(e,t)));if(i.retried)return i;throw s}}}getAllowedActions(e){return{queued:["cancel"],localProcessing:["cancel"],pending:["cancel"],processing:[],completed:["dismiss"],failed:["retry","dismiss"],failed_permanent:["dismiss"]}[e]||[]}initListeners(){this.clickHandler=this.handleClick.bind(this),this.changeHandler=this.handleChange.bind(this),document.addEventListener("click",this.clickHandler),this.ui.panel?.addEventListener("change",this.changeHandler),this.handleOnline=()=>{this.updateStatusPanel(),this.hasQueuedOperations()&&this.processQueue()},this.handleOffline=()=>this.updateStatusPanel("offline"),this.handleBeforeUnload=e=>{if(this.getOperationsByStatus(["queued","uploading"]).length>0)return e.preventDefault(),"You have unsaved changes in the queue."},window.addEventListener("online",this.handleOnline),window.addEventListener("offline",this.handleOffline),window.addEventListener("beforeunload",this.handleBeforeUnload)}handleClick(e){if(e.target.closest(this.selectors.panel,this.selectors.toggle))if(e.target.closest(this.selectors.refreshButton))this.store.clearCache(),this.store.clearHttpHeaders(),this.store.fetch();else if(e.target.closest(this.selectors.clearButton)){const e=this.getOperationsByStatus("completed");if(e.length>0){const t=e.map((e=>e.id));this.updateServerOperations(t,"dismiss")}}else if(e.target.closest(this.selectors.retryButton)){const e=this.getOperationsByStatus("failed");if(e.length>0){const t=e.map((e=>e.id));this.updateServerOperations(t,"retry")}}else if(e.target.closest("[data-action]")){const t=e.target.closest("[data-action]"),s=t.closest("[data-id]")?.dataset.id;s&&this.updateServerOperations(s,t.dataset.action)}else if(e.target.closest(".filters [data-filter]")){const t=e.target.closest("[data-filter]").dataset.filter;this.setFilter(t)}}handleChange(e){}initUI(){if(this.icons={queued:"arrows-clockwise",localProcessing:"arrows-clockwise",uploading:"syncing",pending:"cloud",processing:"syncing",completed:"cloud-check",failed:"cloud-warning",failed_permanent:"cloud-warning"},this.selectors={panel:"aside#queue",toggle:"button.qtoggle",refreshButton:"button.refreshNow",countdown:".countdown",indicator:".qtoggle .indicator",count:".qtoggle .count",popup:".popup",itemsContainer:".qitems",clearButton:".dismiss-all",retryButton:".retry-all",filters:{all:'.filters [data-filter="all"]',received:'.filters [data-filter="queued"]',localProcessing:'.filters [data-filter="localProcessing"]',uploading:'.filters [data-filter="uploading"]',pending:'.filters [data-filter="pending"]',processing:'.filters [data-filter="processing"]',completed:'.filters [data-filter="completed"]',failed:'.filters [data-filter="failed"]'}},this.ui={panel:document.querySelector(this.selectors.panel),toggle:document.querySelector(this.selectors.toggle),count:document.querySelector(this.selectors.count),indicator:document.querySelector(this.selectors.indicator)},this.ui.panel){for(let[e,t]of Object.entries(this.selectors))if(!["panel","toggle","count","indicator"].includes(e))if("object"==typeof t){this.ui[e]={};for(let[s,i]of Object.entries(t))this.ui[e][s]=this.ui.panel.querySelector(i)}else this.ui[e]=this.ui.panel.querySelector(t)}else this.canUpdateUI=!1}updateUI(){if(!this.canUpdateUI)return;const e=Array.from(this.store.data.values()),t=this.store.lastResponse?.queue_stats||{queued:0,localProcessing:0,uploading:0,pending:0,processing:0,completed:0,failed:0,failed_permanent:0};if(this.ui.count){const s=e.length-t.completed;this.ui.count.textContent=s>0?s:"",this.ui.count.style.display=s>0?"":"none"}if(this.ui.indicator){const e=t.queued>0||t.uploading>0||t.pending>0||t.processing>0;this.ui.indicator.classList.toggle("active",e)}this.ui.clearButton.disabled=0===this.getOperationsByStatus("completed").length,this.ui.retryButton.disabled=0===this.getOperationsByStatus("failed").length&&0===this.getOperationsByStatus("failed_permanent").length,Object.entries(this.ui.filters).forEach((([s,i])=>{const a="all"===s?e.length:t[s]||0,n=i.querySelector(".count");n&&(n.textContent=a>0?a:""),i.setAttribute("data-count",a)})),this.renderOperations()}getStatusLabel(e){return{queued:"Queued",localProcessing:"Processing locally",uploading:"Uploading",pending:"Waiting on server",processing:"Processing",completed:"Completed",failed:"Failed (will retry)",failed_permanent:"Failed permanently"}[e]||e}getItemMessage(e){if(e.message)return e.message;if(e.error_message)return e.error_message;switch(e.status){case"queued":return"Waiting to send...";case"uploading":return"Sending to server...";case"pending":return e.position?`Position ${e.position} in queue`:"In server queue";case"processing":return e.progress?`${e.progress}% complete`:"Processing...";case"completed":return"Successfully completed";case"failed":return`Failed: ${e.lastError||"Unknown error"} (Retry ${e.retries}/${this.config.maxRetries})`;case"failed_permanent":return`Failed: ${e.lastError||"Unknown error"}`;default:return""}}calculateProgress(e){if(e.progress)return e.progress;return{queued:10,uploading:25,pending:40,processing:70,completed:100,failed:0,failed_permanent:0}[e.status]||0}renderOperations(){if(!this.ui.itemsContainer)return;const e=this.store.getFiltered();if(window.removeChildren(this.ui.itemsContainer),0===e.length){let e=window.getTemplate("emptyQueue");this.ui.itemsContainer.append(e),this.a11y.announce("Nothing queued.")}else e.forEach((e=>{const t=this.createOperationUI(e);this.ui.itemsContainer.append(t)}))}createOperationUI(e){const t=window.getTemplate("queueItem");return t.dataset.id=e.id,this.updateOperationUI(e,t),t}updateOperationUI(e,t=null){t||(t=this.ui.itemsContainer?.querySelector(`[data-id="${e.id}"]`)),t||(t=this.createOperationUI(e)),this.statuses.forEach((e=>t.classList.remove(e))),t.classList.add(e.status);let s="";e.updated_at?s=window.formatTimeAgo(new Date(e.updated_at)):e.created_at&&(s=window.formatTimeAgo(new Date(e.created_at)));const i=this.calculateProgress(e),a=t.querySelector(".type"),n=t.querySelector(".status"),r=t.querySelector(".info .details"),o=t.querySelector(".info .time"),l=t.querySelector(".progress .fill");if(a&&(a.textContent=e.title),n){n.querySelector(".icon")?.remove();let t=this.getStatusLabel(e.status);n.title=t,n.prepend(window.getIcon(this.icons[e.status])),n.querySelector("span").textContent=t}r&&(r.textContent=this.getItemMessage(e)),o&&(o.textContent=s),l&&(l.style.width=`${i}%`);const d=t.querySelector(".actions");d&&this.updateActionButtons(e,d)}updateActionButtons(e,t){switch(window.removeChildren(t),e.status){case"queued":case"localProcessing":case"pending":const s=window.getTemplate("button");s.classList.add("cancel"),s.dataset.action="cancel",s.textContent="Cancel",t.appendChild(s);break;case"failed":case"failed_permanent":const i=window.getTemplate("button"),a=window.getTemplate("button");i.classList.add("retry"),i.textContent="Retry",i.disabled=e.retries>=this.maxRetries,i.dataset.action="retry",a.classList.add("dismiss"),a.textContent="Dismiss",a.dataset.action="dismiss",t.appendChild(i),t.appendChild(a);break;case"completed":const n=window.getTemplate("button");n.dataset.action="dismiss",n.classList.add("dismiss"),n.textContent="Dismiss",t.appendChild(n)}}removeOperationFromUI(e){const t=this.ui.itemsContainer?.querySelector(`[data-id="${e}"]`);t&&(t.style.opacity="0",t.style.transform="scale(0.9)",setTimeout((()=>t.remove()),300))}updateCountdown(){if(!this.ui.countdown||!this.isPolling)return;let e=this.config.pollInterval/1e3;this.countdownTimer=setInterval((()=>{e--,this.ui.countdown.textContent=e,e<=0&&(clearInterval(this.countdownTimer),this.isPolling&&setTimeout((()=>this.updateCountdown()),100))}),1e3)}updateStatusPanel(e){this.ui.panel?.classList.remove(...this.classes),this.classes.includes(e)&&this.ui.panel?.classList.add(e)}setFilter(e){Object.values(this.ui.filters).forEach((t=>{t&&t.classList.toggle("active",t.dataset.filter===e)})),"all"===e?this.store.clearFilters():this.store.setFilter("status",e)}showPopup(e,t="success"){if(!this.ui.popup)return;const s=this.ui.popup.querySelector("span");s&&(s.textContent=e),this.ui.popup.className=`popup ${t} show`,setTimeout((()=>{this.ui.popup.classList.remove("show")}),3e3)}getOperationsByStatus(e,t=!0){return Array.isArray(e)||"string"!=typeof e||(e=[e]),t?Array.from(this.store.data.values()).filter((t=>e.includes(t.status))):Array.from(this.store.data.values()).filter((t=>!e.includes(t.status)))}hasQueuedOperations(){return this.getOperationsByStatus("queued").length>0}subscribe(e){return this.subscribers.add(e),()=>this.subscribers.delete(e)}notify(e,t){this.subscribers.forEach((s=>s(e,t)))}destroy(){this.stopPolling(),this.stopActivityTracking(),this.clickHandler&&document.removeEventListener("click",this.clickHandler),this.keyHandler&&document.removeEventListener("keydown",this.keyHandler),this.subscribers.clear()}}document.addEventListener("DOMContentLoaded",(function(){window.jvbQueue=new e}))})(); |
| | | (()=>{class t{constructor(t={}){this.canUpdateUI=!0,this.config={apiBase:jvbSettings.api,maxRetries:3,pollInterval:5e3,activityDelay:2e3,autosync:!0,endpoint:"queue",...t},this.user=jvbSettings.currentUser,this.headers={"X-WP-Nonce":jvbSettings.nonce,...t.headers},this.a11y=window.jvbA11y,this.errors=window.jvbError;const e=window.jvbStore.register("queue",{storeName:"queue",keyPath:"id",endpoint:this.config.endpoint,TTL:1/0,indexes:[{name:"status",keyPath:"status"},{name:"type",keyPath:"type"}],showLoading:!1,delayFetch:!1});this.store=e.queue,this.classes=["offline","synced","pending"],this.isProcessing=!1,this.isPolling=!1,this.subscribers=new Set,this.statuses=["queued","localProcessing","uploading","pending","processing","completed","failed","failed_permanent"],this.initUI(),this.initListeners(),this.ui.panel&&(this.popup=new window.jvbPopup({popup:this.ui.panel,toggle:this.ui.toggle,name:"Queue Panel"})),this.initQueue(),this.user&&(this.ui.toggle.hidden=!1,this.ui.panel.hidden=!1)}async initQueue(){const t=this.getOperationsByStatus(["completed","failed_permanent"],!1);t.length>0?this.startPolling():this.updateStatusPanel("synced"),this.store.subscribe(((t,e)=>{switch(t){case"data-loaded":case"items-saved":this.getOperationsByStatus(["completed","failed_permanent"],!1).length>0&&this.startPolling(),this.updateUI();break;case"item-saved":if(e.item){const t=this.store.data.get(e.item.id);t&&t.status!==e.item.status&&this.handleOperationStatusChange(e.item,t.status)}this.hasQueuedOperations()&&this.startPolling();break;default:this.updateUI()}})),this.notify("queue-initialized",{operations:t})}handleOperationStatusChange(t,e){if(t&&e!==t.status)switch(t.status){case"completed":this.notify("operation-completed",t);break;case"failed":this.notify("operation-failed",t);break;case"failed_permanent":this.notify("operation-failed-permanent",t)}}addToQueue(t){const e={id:`u${this.user}_${Date.now()}_${Math.random().toString(36).substring(2,9)}`,endpoint:null,method:"POST",headers:{},data:{},canMerge:!0,popup:"Saving changes...",title:"Operation",status:"queued",timestamp:Date.now(),retries:0,user:this.user,...t};if(e.headers={...this.headers,...e.headers},!e.endpoint||!e.data)return console.error("Invalid operation queued: missing endpoint or data"),null;const s=Array.from(this.store.data.values()).filter((t=>"queued"===t.status&&t.endpoint===e.endpoint&&t.canMerge));if(s.length>0){const t=s[0];return t.data=window.deepMerge(t.data,e.data),t.timestamp=Date.now(),this.updateOperationStatus(t.id,t.status),this.updateUI(),this.startActivityTracking(),t.id}return console.log("Added to Queue: ",e),this.store.clearCache(),this.setQueue(e),this.updateOperationStatus(e.id,e.status),this.updateUI(),this.startActivityTracking(),e.id}setQueue(t){this.store.save(t)}updateOperationStatus(t,e){let s=this.store.get(t);s&&(s.status=e,this.notify("operation-status",s),this.updateOperationUI(s))}getQueue(t){return this.store.get(t)}clearQueue(t){this.store.delete(t)}startActivityTracking(){if(!this.activityListeners){const t=["mousedown","mousemove","keypress","scroll","touchstart"];this.activityListeners=t.map((t=>{const e=()=>this.resetActivityTimer();return document.addEventListener(t,e,{passive:!0}),{event:t,handler:e}}))}this.resetActivityTimer()}resetActivityTimer(){this.lastActivity=Date.now(),this.activityTimer&&clearTimeout(this.activityTimer),this.activityTimer=setTimeout((()=>{this.processQueue()}),this.config.activityDelay)}stopActivityTracking(){this.activityTimer&&(clearTimeout(this.activityTimer),this.activityTimer=null),this.activityListeners&&(this.activityListeners.forEach((({event:t,handler:e})=>{document.removeEventListener(t,e)})),this.activityListeners=null)}setProcessing(t){this.isProcessing=t,this.ui.toggle.classList.toggle("saving",t)}async processQueue(){if(this.isProcessing)return;const t=this.getOperationsByStatus("queued");if(0===t.length)return void this.stopActivityTracking();this.setProcessing(!0);for(const e of t)await this.processOperation(e);this.setProcessing(!1),this.stopActivityTracking();this.getOperationsByStatus(["queued","completed","failed_permanent"],!1).length>0&&this.startPolling()}async processOperation(t){try{this.updateOperationStatus(t.id,"uploading"),t.data?._isFormData&&(t.data=await this.store.objectToFormData(t.data));const e=`${this.config.apiBase}${t.endpoint}`;let s;t.data instanceof FormData?(t.data.append("id",t.id),t.data.append("user",this.user),s=t.data):(s=JSON.stringify({...t.data,id:t.id,user:this.user}),t.headers["Content-Type"]="application/json");const i=await fetch(e,{method:t.method,headers:t.headers,body:s}),a=await i.json();if(!i.ok||!1===a.success)throw new Error(a.message||`HTTP ${i.status}`);if(a.id&&t.id!==a.id){const e=this.getQueue(a.id);e?(e.data=window.deepMerge(e.data,t.data),e.status="pending",e.serverData=a,this.updateOperationStatus(e.id,e.status),this.setQueue(e),this.removeOperationFromUI(t.id),t=e):(this.clearQueue(t.id),t.id=a.id,t.status="pending",t.serverData=a,this.updateOperationStatus(t.id,t.status),this.setQueue(t))}else t.status="pending",t.serverData=a,this.updateOperationStatus(t.id,"pending"),this.setQueue(t);this.a11y.announce(`${t.title} sent to server for processing.`)}catch(e){console.error("Operation failed:",e),t.retries++,t.lastError=e.message,t.retries>=this.config.maxRetries?t.status="failed_permanent":(t.status="failed",t.nextRetry=Date.now()+1e3*Math.pow(2,t.retries)),this.updateOperationStatus(t.id,t.status),this.setQueue(t)}}startPolling(){this.isPolling||(this.isPolling=!0,this.updateStatusPanel("pending"),this.pollTimer=setInterval((async()=>{try{this.store.clearCache(),await this.store.fetch();0===this.getOperationsByStatus(["completed","failed_permanent"],!1).length&&(this.stopPolling(),this.updateStatusPanel("synced"))}catch(t){console.error("Polling error:",t)}}),this.config.pollInterval))}stopPolling(){this.isPolling&&(this.isPolling=!1,this.pollTimer&&(clearInterval(this.pollTimer),this.pollTimer=null),this.countdownTimer&&(clearInterval(this.countdownTimer),this.countdownTimer=null))}async updateServerOperations(t,e){if(0!==(t=(t=Array.isArray(t)?t:t.includes(",")?t.split(","):[t]).filter((t=>{let s=this.getQueue(t);return this.getAllowedActions(s.status).includes(e)}))).length){["cancel","dismiss"].includes(e)&&t.forEach((t=>{this.removeOperationFromUI(t)}));try{const s=`${this.config.apiBase}${this.config.endpoint}`,i=await fetch(s,{method:"POST",headers:{"Content-Type":"application/json",...this.headers},body:JSON.stringify({ids:t,action:e,user:jvbSettings.currentUser})});if(!i.ok){const t=await i.json().catch((()=>{}));throw new Error(t.message||`${e} failed: ${i.status}`)}const a=await i.json();if(!a.success)throw new Error(a.message||`${e} operation failed`);return["cancel","dismiss"].includes(e)?t.forEach((t=>{let s=this.getQueue(t);this.notify(`${e}-operation`,s),this.clearQueue(t)})):(t.forEach((t=>{let s=this.getQueue(t);this.notify(`${e}-operation`,s),s.status="queued",s.retries=0,this.setQueue(s),this.updateOperationStatus(s.id,s.status)})),this.startActivityTracking()),this.updateUI(),a}catch(s){const i=await window.jvbError.log(s,{component:"QueueManager",operation:"performQueueAction",action:e,operationIds:t,itemCount:t.length},(()=>this.updateServerOperations(t,e)));if(i.retried)return i;throw s}}}getAllowedActions(t){return{queued:["cancel"],localProcessing:["cancel"],pending:["cancel"],processing:[],completed:["dismiss"],failed:["retry","dismiss"],failed_permanent:["dismiss"]}[t]||[]}initListeners(){this.clickHandler=this.handleClick.bind(this),this.changeHandler=this.handleChange.bind(this),document.addEventListener("click",this.clickHandler),this.ui.panel?.addEventListener("change",this.changeHandler),this.handleOnline=()=>{this.updateStatusPanel(),this.hasQueuedOperations()&&this.processQueue()},this.handleOffline=()=>this.updateStatusPanel("offline"),this.handleBeforeUnload=t=>{if(this.getOperationsByStatus(["queued","uploading"]).length>0)return t.preventDefault(),"You have unsaved changes in the queue."},window.addEventListener("online",this.handleOnline),window.addEventListener("offline",this.handleOffline),window.addEventListener("beforeunload",this.handleBeforeUnload)}handleClick(t){if(t.target.closest(this.selectors.panel,this.selectors.toggle))if(t.target.closest(this.selectors.refreshButton))this.store.clearCache(),this.store.clearHttpHeaders(),this.store.fetch();else if(t.target.closest(this.selectors.clearButton)){const t=this.getOperationsByStatus("completed");if(t.length>0){const e=t.map((t=>t.id));this.updateServerOperations(e,"dismiss")}}else if(t.target.closest(this.selectors.retryButton)){const t=this.getOperationsByStatus("failed");if(t.length>0){const e=t.map((t=>t.id));this.updateServerOperations(e,"retry")}}else if(t.target.closest("[data-action]")){const e=t.target.closest("[data-action]"),s=e.closest("[data-id]")?.dataset.id;s&&this.updateServerOperations(s,e.dataset.action)}else if(t.target.closest(".filters [data-filter]")){const e=t.target.closest("[data-filter]").dataset.filter;this.setFilter(e)}}handleChange(t){}initUI(){if(this.icons={queued:"arrows-clockwise",localProcessing:"arrows-clockwise",uploading:"syncing",pending:"cloud",processing:"syncing",completed:"cloud-check",failed:"cloud-warning",failed_permanent:"cloud-warning"},this.selectors={panel:"aside#queue",toggle:"button.qtoggle",refreshButton:"button.refreshNow",countdown:".countdown",indicator:".qtoggle .indicator",count:".qtoggle .count",popup:".popup",itemsContainer:".qitems",clearButton:".dismiss-all",retryButton:".retry-all",filters:{all:'.filters [data-filter="all"]',received:'.filters [data-filter="queued"]',localProcessing:'.filters [data-filter="localProcessing"]',uploading:'.filters [data-filter="uploading"]',pending:'.filters [data-filter="pending"]',processing:'.filters [data-filter="processing"]',completed:'.filters [data-filter="completed"]',failed:'.filters [data-filter="failed"]'}},this.ui={panel:document.querySelector(this.selectors.panel),toggle:document.querySelector(this.selectors.toggle),count:document.querySelector(this.selectors.count),indicator:document.querySelector(this.selectors.indicator)},this.ui.panel){for(let[t,e]of Object.entries(this.selectors))if(!["panel","toggle","count","indicator"].includes(t))if("object"==typeof e){this.ui[t]={};for(let[s,i]of Object.entries(e))this.ui[t][s]=this.ui.panel.querySelector(i)}else this.ui[t]=this.ui.panel.querySelector(e)}else this.canUpdateUI=!1}updateUI(){if(!this.canUpdateUI)return;const t=Array.from(this.store.data.values()),e=this.store.lastResponse?.queue_stats||{queued:0,localProcessing:0,uploading:0,pending:0,processing:0,completed:0,failed:0,failed_permanent:0};if(this.ui.count){const s=t.length-e.completed;this.ui.count.textContent=s>0?s:"",this.ui.count.style.display=s>0?"":"none"}if(this.ui.indicator){const t=e.queued>0||e.uploading>0||e.pending>0||e.processing>0;this.ui.indicator.classList.toggle("active",t)}this.ui.clearButton.disabled=0===this.getOperationsByStatus("completed").length,this.ui.retryButton.disabled=0===this.getOperationsByStatus("failed").length&&0===this.getOperationsByStatus("failed_permanent").length,Object.entries(this.ui.filters).forEach((([s,i])=>{const a="all"===s?t.length:e[s]||0,n=i.querySelector(".count");n&&(n.textContent=a>0?a:""),i.setAttribute("data-count",a)})),this.renderOperations()}getStatusLabel(t){return{queued:"Queued",localProcessing:"Processing locally",uploading:"Uploading",pending:"Waiting on server",processing:"Processing",completed:"Completed",failed:"Failed (will retry)",failed_permanent:"Failed permanently"}[t]||t}getItemMessage(t){if(t.message)return t.message;if(t.error_message)return t.error_message;switch(t.status){case"queued":return"Waiting to send...";case"uploading":return"Sending to server...";case"pending":return t.position?`Position ${t.position} in queue`:"In server queue";case"processing":return t.progress?`${t.progress}% complete`:"Processing...";case"completed":return"Successfully completed";case"failed":return`Failed: ${t.lastError||"Unknown error"} (Retry ${t.retries}/${this.config.maxRetries})`;case"failed_permanent":return`Failed: ${t.lastError||"Unknown error"}`;default:return""}}calculateProgress(t){if(t.progress)return t.progress;return{queued:10,uploading:25,pending:40,processing:70,completed:100,failed:0,failed_permanent:0}[t.status]||0}renderOperations(){if(!this.ui.itemsContainer)return;const t=this.store.getFiltered();if(window.removeChildren(this.ui.itemsContainer),0===t.length){let t=window.getTemplate("emptyQueue");this.ui.itemsContainer.append(t),this.a11y.announce("Nothing queued.")}else t.forEach((t=>{const e=this.createOperationUI(t);this.ui.itemsContainer.append(e)}))}createOperationUI(t){const e=window.getTemplate("queueItem");return e.dataset.id=t.id,this.updateOperationUI(t,e),e}updateOperationUI(t,e=null){e||(e=this.ui.itemsContainer?.querySelector(`[data-id="${t.id}"]`)),e||(e=this.createOperationUI(t)),this.statuses.forEach((t=>e.classList.remove(t))),e.classList.add(t.status);let s="";t.updated_at?s=window.formatTimeAgo(new Date(t.updated_at)):t.created_at&&(s=window.formatTimeAgo(new Date(t.created_at)));const i=this.calculateProgress(t),a=e.querySelector(".type"),n=e.querySelector(".status"),r=e.querySelector(".info .details"),o=e.querySelector(".info .time"),l=e.querySelector(".progress .fill");if(a&&(a.textContent=t.title),n){n.querySelector(".icon")?.remove();let e=this.getStatusLabel(t.status);n.title=e,n.prepend(window.getIcon(this.icons[t.status])),n.querySelector("span").textContent=e}r&&(r.textContent=this.getItemMessage(t)),o&&(o.textContent=s),l&&(l.style.width=`${i}%`);const d=e.querySelector(".actions");d&&this.updateActionButtons(t,d)}updateActionButtons(t,e){switch(window.removeChildren(e),t.status){case"queued":case"localProcessing":case"pending":const s=window.getTemplate("button");s.classList.add("cancel"),s.dataset.action="cancel",s.textContent="Cancel",e.appendChild(s);break;case"failed":case"failed_permanent":const i=window.getTemplate("button"),a=window.getTemplate("button");i.classList.add("retry"),i.textContent="Retry",i.disabled=t.retries>=this.maxRetries,i.dataset.action="retry",a.classList.add("dismiss"),a.textContent="Dismiss",a.dataset.action="dismiss",e.appendChild(i),e.appendChild(a);break;case"completed":const n=window.getTemplate("button");n.dataset.action="dismiss",n.classList.add("dismiss"),n.textContent="Dismiss",e.appendChild(n)}}removeOperationFromUI(t){const e=this.ui.itemsContainer?.querySelector(`[data-id="${t}"]`);e&&(e.style.opacity="0",e.style.transform="scale(0.9)",setTimeout((()=>e.remove()),300))}updateCountdown(){if(!this.ui.countdown||!this.isPolling)return;let t=this.config.pollInterval/1e3;this.countdownTimer=setInterval((()=>{t--,this.ui.countdown.textContent=t,t<=0&&(clearInterval(this.countdownTimer),this.isPolling&&setTimeout((()=>this.updateCountdown()),100))}),1e3)}updateStatusPanel(t){this.ui.panel?.classList.remove(...this.classes),this.classes.includes(t)&&this.ui.panel?.classList.add(t)}setFilter(t){Object.values(this.ui.filters).forEach((e=>{e&&e.classList.toggle("active",e.dataset.filter===t)})),"all"===t?this.store.clearFilters():this.store.setFilter("status",t)}showPopup(t,e="success"){if(!this.ui.popup)return;const s=this.ui.popup.querySelector("span");s&&(s.textContent=t),this.ui.popup.className=`popup ${e} show`,setTimeout((()=>{this.ui.popup.classList.remove("show")}),3e3)}getOperationsByStatus(t,e=!0){return Array.isArray(t)||"string"!=typeof t||(t=[t]),e?Array.from(this.store.data.values()).filter((e=>t.includes(e.status))):Array.from(this.store.data.values()).filter((e=>!t.includes(e.status)))}hasQueuedOperations(){return this.getOperationsByStatus("queued").length>0}subscribe(t){return this.subscribers.add(t),()=>this.subscribers.delete(t)}notify(t,e){this.subscribers.forEach((s=>s(t,e)))}destroy(){this.stopPolling(),this.stopActivityTracking(),this.clickHandler&&document.removeEventListener("click",this.clickHandler),this.keyHandler&&document.removeEventListener("keydown",this.keyHandler),this.subscribers.clear()}}document.addEventListener("DOMContentLoaded",(function(){window.jvbQueue=new t}))})(); |
| | |
| | | (()=>{class e{constructor(){this.container=document.querySelector(".jvb-referral"),this.container&&(this.a11y=window.jvbA11y,this.toggle=document.querySelector('button[data-action="toggle-referral"]'),this.initElements(),this.initListeners(),this.checkForReferral())}initElements(){this.selectors={copy:"button.copy",login:".login",submit:"[type=submit]"},this.forms=this.container.querySelectorAll("form"),this.popup=new window.jvbPopup({toggle:this.toggle,popup:this.container,name:"Referral Box",onOpen:()=>{this.forms.forEach((e=>{e.addEventListener("submit",this.submitHandler)})),this.container.addEventListener("click",this.clickHandler),this.container.addEventListener("input",this.inputHandler)},onClose:()=>{this.forms.forEach((e=>{e.removeEventListener("submit",this.submitHandler)})),this.container.removeEventListener("click",this.clickHandler),this.container.removeEventListener("input",this.inputHandler)}}),this.tabs=null,this.container.querySelector("nav.tabs")&&(this.tabs=new window.jvbTabs(this.container,{updateURL:!1})),this.ui=window.uiFromSelectors(this.selectors,this.container)}initListeners(){this.clickHandler=this.handleClick.bind(this),this.inputHandler=this.handleInput.bind(this),this.submitHandler=this.handleFormSubmit.bind(this),this.changeHandler=this.handleChange.bind(this)}handleClick(e){if(e.target.classList.contains(".copy")){let t=e.target.dataset.target,r=this.container.querySelector(`#${t}`);r=!!r&&r.textContent,r&&this.handleCopy(e.target,r)}}handleChange(e){"referral-code"===e.target.id&&window.debouncer.schedule("check-referral",(()=>this.makeRequest("referrals/check-code",{code:e.target.value})))}handleInput(e){"referral-code"===e.target.id&&(e.target.value=e.target.value.toUpperCase())}initShareWidget(){this.initCopyButton(),this.loadStats()}async checkForReferral(){const e=this.getUrlParameter("seeReferral"),t=this.getUrlParameter("ref");if(!e&&!t)return;if(!t)return void this.popup.openPopup();const r=this.container.querySelector("#referral-code-input");if(!r)return;const n=t.toUpperCase();r.value=n,r.readOnly=!0,this.popup.togglePopup();try{const e=await this.validateCodeOnly(n);if(e.success){this.showReferrerBanner(e.referrer_name,n);const t=this.container.querySelector("#referral-name");t&&t.focus()}else r.readOnly=!1,this.showMessage("This referral link is invalid. Please enter a valid code.","error")}catch(e){console.error("Error validating code:",e),r.readOnly=!1}this.removeUrlParameter("ref")}getUrlParameter(e){return new URLSearchParams(window.location.search).get(e)}removeUrlParameter(e){const t=new URL(window.location);t.searchParams.delete(e),window.history.replaceState({},document.title,t.toString())}async validateCodeOnly(e){const t=await fetch(`${jvbSettings.api}/referrals/check-code`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({code:e})});return await t.json()}showReferrerBanner(e,t){const r=this.container.querySelector(".referral-header");if(!r)return;const n=document.createElement("div");n.className="referrer-banner",n.innerHTML=`\n <div class="banner-icon">🎉</div>\n <div class="banner-content">\n <strong>${window.escapeHtml(e)}</strong> referred you!\n <div class="banner-code">Code: <code>${window.escapeHtml(t)}</code></div>\n </div>\n `,r.parentNode.insertBefore(n,r.nextSibling);const a=r.querySelector("h3");a&&(a.textContent="Complete Your Registration");const s=r.querySelector("p");s&&(s.textContent="Enter your details below to claim your welcome reward!")}handleCopy(e,t=""){if(""===t||"string"!=typeof t)return;let r=e.textContent;(navigator.clipboard||navigator.clipboard.writeText)&&navigator.clipboard.writeText(t).then((()=>{e.textContent="Copied!",e.style.background="#00a32a",setTimeout((()=>{e.textContent=r,e.style.background=""}),2e3)}))}async loadStats(){if(this.container.querySelector(".referral-stats"))try{const e=await fetch(`${jvbSettings.api}/referrals/stats`,{headers:{"X-WP-Nonce":jvbSettings.nonce}}),t=await e.json();t.success&&t.stats&&this.updateStats(t.stats)}catch(e){console.error("Error loading stats:",e)}}updateStats(e){const t={total:this.container.querySelector('[data-stat="total"]'),treated:this.container.querySelector('[data-stat="treated"]'),pending:this.container.querySelector('[data-stat="pending"]'),rewards:this.container.querySelector('[data-stat="rewards"]')};t.total&&(t.total.textContent=e.total_referrals||0),t.treated&&(t.treated.textContent=e.treated_count||0),t.pending&&(t.pending.textContent=e.pending_count||0),t.rewards&&(t.rewards.textContent="$"+parseFloat(e.available_rewards||0).toFixed(2))}async handleFormSubmit(e){window.debouncer.cancel("check-referral"),e.preventDefault();const t=e.target,r=new FormData(t);let n={};this.setFormLoading(!0,t);try{let e={success:!1,message:""};"referral-code-form"===t.id?(r.get("name")||(e.message+="We need your name to know who you are."),r.get("email")||(e.message+="We need your email to confirm you have access to it."),r.get("referral_code")||(e.message+="We need the referral code to know who sent you."),r.get("name")&&r.get("email")&&r.get("referral_code")&&(n.name=r.get("name"),n.email=r.get("email"),n.code=r.get("referral_code"),e=await this.makeRequest("referrals/register",n))):"login-form"===t.id&&r.get("login-email")&&(n.type="login",n.email=r.get("login-email"),n.context={},n.context.redirect_to=window.location.href+"?seeReferral=1",console.log("Making Request with: ",n),e=await this.makeRequest("magic",n)),e.success?this.handleSuccess(e):(this.showMessage(e.message||"Something went wrong. Please try again.","error"),this.setFormLoading(!1,t))}catch(e){console.error("Error registering:",e),this.showMessage("Something went wrong. Please try again.","error"),this.setFormLoading(!1,t)}finally{this.setFormLoading(!1,t)}}async makeRequest(e,t){if(!["magic","referrals/register","referrals/check-code"].includes(e))return{success:!1,message:"Something went wrong (Invalid endpoint)."};console.log("Endpoint: ",e),console.log("Data: ",t);const r=await fetch(`${jvbSettings.api}${e}`,{method:"POST",headers:{"Content-Type":"application/json","X-WP-Nonce":jvbSettings.nonce},body:JSON.stringify(t)});return await r.json()}handleSuccess(e){this.container.querySelectorAll("form").forEach((e=>{window.fade(e,!1)}));const t=this.container.querySelector(".success-message");t&&(t.hidden=!1,t.scrollIntoView({behavior:"smooth",block:"center"}),this.dispatchEvent("emailSent",{email:e.email}))}setFormLoading(e,t){t.querySelectorAll("input").forEach((t=>t.disabled=e));let r=t.querySelector(".status"),n=r.querySelector(".message");r.hidden=e,r.classList.toggle("loading",e),n.textContent=e?"Checking with server...":""}showMessage(e,t="success"){const r=this.container.querySelector("#referral-message");r&&(r.textContent=e,r.className="message "+t,r.style.display="block","error"===t&&setTimeout((()=>{r.style.display="none"}),5e3))}dispatchEvent(e,t){const r=new CustomEvent("referralWidget:"+e,{detail:t,bubbles:!0});this.container.dispatchEvent(r)}}document.addEventListener("DOMContentLoaded",(()=>{window.jvbReferral=new e}))})(); |
| | | (()=>{class e{constructor(){this.container=document.querySelector(".jvb-referral"),this.container&&(this.a11y=window.jvbA11y,this.toggle=document.querySelector('button[data-action="toggle-referral"]'),this.initElements(),this.initListeners(),this.checkForReferral(),this.isLoggedIn()&&(this.loadStats(),this.loadRecentReferrals()))}initElements(){this.selectors={copyBtn:".copy-btn",checkCode:".check-code-btn",submit:"[type=submit]"},this.forms=this.container.querySelectorAll("form"),this.popup=new window.jvbPopup({toggle:this.toggle,popup:this.container,name:"Referral Box",onOpen:()=>{this.bindEventListeners(!0)},onClose:()=>{this.bindEventListeners(!1)}}),this.tabs=null,this.container.querySelector("nav.tabs")&&(this.tabs=new window.jvbTabs(this.container,{updateURL:!1})),this.ui=window.uiFromSelectors(this.selectors,this.container)}initListeners(){this.clickHandler=this.handleClick.bind(this),this.inputHandler=this.handleInput.bind(this),this.submitHandler=this.handleFormSubmit.bind(this)}bindEventListeners(e){const t=e?"addEventListener":"removeEventListener";this.forms.forEach((e=>{e[t]("submit",this.submitHandler)})),this.container[t]("click",this.clickHandler),this.container[t]("input",this.inputHandler)}isLoggedIn(){return Boolean(jvbSettings.currentUser)}handleClick(e){const t=e.target.closest(".copy-btn, .check-code-btn, .attn");t&&(t.classList.contains("copy-btn")?this.handleCopyClick(t):t.classList.contains("check-code-btn")?this.handleCheckCode(e):t.classList.contains("attn")&&t.classList.remove("attn"))}handleCopyClick(e){const t=e.dataset.target,s=this.container.querySelector(`#${t}`);if(!s)return;const r=s.textContent.trim();navigator.clipboard&&navigator.clipboard.writeText?navigator.clipboard.writeText(r).then((()=>{this.showCopySuccess(e)})).catch((()=>{this.selectText(s),this.showCopyFallback(e)})):(this.selectText(s),this.showCopyFallback(e))}selectText(e){if(window.getSelection&&document.createRange){const t=window.getSelection(),s=document.createRange();s.selectNodeContents(e),t.removeAllRanges(),t.addRange(s)}else if(document.body.createTextRange){const t=document.body.createTextRange();t.moveToElementText(e),t.select()}}showCopySuccess(e){const t=e.innerHTML;e.innerHTML=window.jvbIcon("check",{size:16})+" Copied!",e.classList.add("success"),setTimeout((()=>{e.innerHTML=t,e.classList.remove("success")}),2e3)}showCopyFallback(e){const t=e.innerHTML;e.innerHTML="✓ Selected - Press Ctrl+C",e.classList.add("selected"),setTimeout((()=>{e.innerHTML=t,e.classList.remove("selected")}),3e3)}handleInput(e){"referral_code"!==e.target.id&&"referral_code"!==e.target.name||(e.target.value=e.target.value.toUpperCase())}async handleCheckCode(e){e.preventDefault();const t=e.target.closest("form"),s=t.querySelector('[name="referral_code"]'),r=t.querySelector(".code-status");if(!s||!r)return;const n=s.value.trim();if(n){r.hidden=!1,r.className="code-status loading",r.innerHTML='<span class="spinner"></span> Checking...';try{const e=await this.validateCodeOnly(n);e.success?this.showCodeStatus(r,`✓ Valid! Referred by ${e.referrer_name}`,"success"):this.showCodeStatus(r,e.message||"Invalid code","error")}catch(e){console.error("Error checking code:",e),this.showCodeStatus(r,"Error checking code","error")}}else this.showCodeStatus(r,"Please enter a code","error")}showCodeStatus(e,t,s){e.hidden=!1,e.className=`code-status ${s}`,e.textContent=t,"error"===s&&setTimeout((()=>{e.hidden=!0}),5e3)}async checkForReferral(){const e=this.getUrlParameter("seeReferral"),t=this.getUrlParameter("ref");if(!e&&!t)return;if(!t)return void this.popup.openPopup();const s=this.container.querySelector('[name="referral_code"]');if(!s)return;const r=t.toUpperCase();s.value=r,s.readOnly=!0,this.popup.togglePopup();try{const e=await this.validateCodeOnly(r);if(e.success){const t=s.closest("form").querySelector(".code-status");t&&this.showCodeStatus(t,`✓ ${e.referrer_name} invited you!`,"success");const r=this.container.querySelector('[name="referral_name"]');r&&r.focus()}else s.readOnly=!1,this.showMessage("This referral link is invalid. Please enter a valid code.","error")}catch(e){console.error("Error validating code:",e),s.readOnly=!1}this.removeUrlParameter("ref")}getUrlParameter(e){return new URLSearchParams(window.location.search).get(e)}removeUrlParameter(e){const t=new URL(window.location);t.searchParams.delete(e),window.history.replaceState({},document.title,t.toString())}async validateCodeOnly(e){const t=await fetch(`${jvbSettings.api}referrals/check-code`,{method:"POST",headers:{"Content-Type":"application/json","X-WP-Nonce":jvbSettings.nonce},body:JSON.stringify({code:e})});return await t.json()}async loadStats(){if(this.container.querySelector(".stats-summary"))try{const e=await fetch(`${jvbSettings.api}referrals/my-stats?user=${jvbSettings.currentUser}`,{headers:{"X-WP-Nonce":jvbSettings.nonce}}),t=await e.json();t.success&&t.stats&&this.updateStats(t.stats)}catch(e){console.error("Error loading stats:",e)}}updateStats(e){const t={total:this.container.querySelector('[data-stat="total"]'),treated:this.container.querySelector('[data-stat="treated"]'),pending:this.container.querySelector('[data-stat="pending"]'),rewards:this.container.querySelector('[data-stat="rewards"]')};t.total&&(t.total.textContent=e.total_referrals||0),t.treated&&(t.treated.textContent=e.treated_count||0),t.pending&&(t.pending.textContent=e.pending_count||0),t.rewards&&(t.rewards.textContent="$"+parseFloat(e.available_rewards||0).toFixed(2))}async loadRecentReferrals(){const e=this.container.querySelector(".recent-referrals-list");if(e)try{const t=await fetch(`${jvbSettings.api}referrals/my-referrals?limit=5&user=${jvbSettings.currentUser}`,{headers:{"X-WP-Nonce":jvbSettings.nonce}}),s=await t.json();s.success&&s.referrals?this.renderRecentReferrals(e,s.referrals):e.innerHTML='<p class="no-referrals">No referrals yet</p>'}catch(t){console.error("Error loading referrals:",t),e.innerHTML='<p class="error">Failed to load referrals</p>'}}renderRecentReferrals(e,t){if(!t||0===t.length)return void(e.innerHTML='<p class="no-referrals">Share your code to get started!</p>');const s=t.map((e=>`\n\t\t\t<div class="referral-item">\n\t\t\t\t<div class="referral-info">\n\t\t\t\t\t<strong>${window.escapeHtml(e.referee_name)}</strong>\n\t\t\t\t\t<span class="status-badge ${e.status}">${e.status}</span>\n\t\t\t\t</div>\n\t\t\t\t<div class="referral-date">${this.formatDate(e.referred_at)}</div>\n\t\t\t</div>\n\t\t`)).join("");e.innerHTML=s}formatDate(e){const t=new Date(e),s=new Date,r=Math.abs(s-t),n=Math.floor(r/864e5);return 0===n?"Today":1===n?"Yesterday":n<7?`${n} days ago`:t.toLocaleDateString("en-US",{month:"short",day:"numeric"})}async handleFormSubmit(e){e.preventDefault();const t=e.target,s=new FormData(t);this.setFormLoading(!0,t);try{let e={success:!1,message:""};if("referral-code-form"===t.id){const t={name:s.get("referral_name"),email:s.get("referral_email"),code:s.get("referral_code")};t.name&&t.email&&t.code?e=await this.makeRequest("referrals/register",t):e.message="Please fill in all fields"}else if("login-form"===t.id){const t={type:"login",email:s.get("login_email"),context:{redirect_to:window.location.href+"?seeReferral=1"}};e=await this.makeRequest("magic",t)}e.success?this.handleSuccess(t,e):this.showFormMessage(t,e.message||"Something went wrong. Please try again.","error")}catch(e){console.error("Error submitting form:",e),this.showFormMessage(t,"Something went wrong. Please try again.","error")}finally{this.setFormLoading(!1,t)}}async makeRequest(e,t){if(!["magic","referrals/register","referrals/check-code"].includes(e))return{success:!1,message:"Invalid endpoint"};const s=await fetch(`${jvbSettings.api}${e}`,{method:"POST",headers:{"Content-Type":"application/json","X-WP-Nonce":jvbSettings.nonce},body:JSON.stringify(t)});return await s.json()}handleSuccess(e,t){e.style.display="none";const s=e.nextElementSibling;s&&s.classList.contains("success-content")&&(s.hidden=!1,s.scrollIntoView({behavior:"smooth",block:"center"})),this.dispatchEvent("emailSent",{email:t.email})}showFormMessage(e,t,s="error"){const r=e.querySelector(".status");if(!r)return;const n=r.querySelector(".message");n&&(n.textContent=t),r.hidden=!1,r.className=`status ${s}`,"error"===s&&setTimeout((()=>{r.hidden=!0}),5e3)}setFormLoading(e,t){t.querySelectorAll("input, button").forEach((t=>t.disabled=e));const s=t.querySelector(".status");if(s&&(s.classList.toggle("loading",e),e)){s.hidden=!1;const e=s.querySelector(".message");e&&(e.textContent="Sending...")}}dispatchEvent(e,t){const s=new CustomEvent("referralWidget:"+e,{detail:t,bubbles:!0});this.container.dispatchEvent(s)}}document.addEventListener("DOMContentLoaded",(()=>{window.jvbReferral=new e}))})(); |
| | |
| | | (()=>{class e{constructor(){this.a11y=window.jvbA11y,this.error=window.jvbError,this.index=-1,this.hasAutocomplete=!1,this.isInitializing=!0,this.taxonomiesToFetch=new Set,this.triggers=new Set([".taxonomy-toggle"]),this.subscribers=new Set,this.store=window.jvbStore.register("taxonomies",{storeName:"terms",keyPath:"id",showLoading:!1,indexes:[{name:"taxonomy",keyPath:"taxonomy"},{name:"parent",keyPath:"parent"},{name:"slug",keyPath:"slug",unique:!0},{name:"count",keyPath:"count"}],endpoint:"terms",TTL:12e4,filters:{taxonomy:"",page:1,search:"",parent:0},required:"taxonomy",delayFetch:!0}),this.fields=new Map,this.selectedTerms=new Map,this.activeField=null,this.currentConfig=null,this.currentSingular=null,this.currentPlural=null,this.disabled=!1,this.searchHandler=null,this.autocompleteHandler=null,this.isAutocompleteActive=!1,this.init()}init(){this.initModal(),this.scanExistingFields(),this.initGlobalListeners(),this.hasAutocomplete&&window.jvbTaxCreator&&(this.creator=new window.jvbTaxCreator(this)),this.store.subscribe(this.handleStoreEvent.bind(this)),this.isInitializing=!1,this.batchFetchTaxonomies()}handleStoreEvent(e,t){switch(e){case"data-loaded":const e=this.store.filters.taxonomy;if(e?.includes(",")&&this.handleBatchDataLoaded(e,t),e){(e.includes(",")?e.split(",").map((e=>e.trim())):[e]).forEach((e=>{this.updateFieldsForTaxonomy(e)}))}if(this.modal?.open&&this.handleTermsLoaded(t),this.isAutocompleteActive&&this.activeField){const e=this.fields.get(this.activeField),i=t.data?.items||[],s=t.filters?.search||"";this.showAutocompleteResults(e,i,s),this.isAutocompleteActive=!1}break;case"filters-changed":this.modal?.open&&this.showLoading();break;case"fetch-error":this.isAutocompleteActive&&this.activeField&&(this.showAutocompleteError(this.activeField),this.isAutocompleteActive=!1),this.handleFetchError(t.error)}}handleTermsLoaded(e){this.hideLoading();const t=this.store.getFiltered(),i=this.store.lastResponse?.page||{},s=e.filters?.search&&e.filters.search.length>0,o=i.page>1;this.notify("terms-loaded",{terms:t,filters:e.filters}),0===t.length?(o||this.showEmptyState(s?"No results found.":"No items available."),this.observer.unobserve(this.ui.sentinel)):(this.renderTerms(t,o,s),i.has_more?this.observer.observe(this.ui.sentinel):this.observer.unobserve(this.ui.sentinel)),this.a11y?.announce(t.length,o)}handleFetchError(e){console.error("Taxonomy fetch error:",e),this.hideLoading(),this.error?.log?this.error.log(e,{component:"TaxonomySelector",action:"fetchTerms"},(()=>this.fetchCurrentTerms())):this.showEmptyState("Error loading terms. Please try again.")}updateFieldButtonState(e){const t=this.fields.get(e);if(!t)return;const i=Array.from(this.store.data.values()).some((e=>e.taxonomy===t.taxonomy));t.toggle&&(t.toggle.disabled=!i&&!t.canCreate,t.toggle.title=i?`Select ${this.getPlural(t.taxonomy)}`:`No ${this.getSingular(t.taxonomy)} available`)}updateFieldsForTaxonomy(e){this.getFieldsForTaxonomy(e).forEach((e=>{this.updateFieldButtonState(e.id)}))}getFieldsForTaxonomy(e){return Array.from(this.fields.values()).filter((t=>t.taxonomy===e))}scanExistingFields(e=null){e||(e=document.body);e.querySelectorAll(".field.taxonomy, .field.post").forEach((e=>{try{this.registerField(e)}catch(t){this.error.log(t,{component:"TaxonomySelector",action:"scanExistingFields",container:e.dataset.name})}}))}registerField(e,t={}){let i=e.querySelector("input[type=hidden]");if(!i)return!1;"fieldId"in e.dataset||(e.dataset.fieldId=this.createFieldId(e));let s=e.dataset.fieldId,o=Object.hasOwn(t,"button")?t.button:e.querySelector("button.taxonomy-toggle");Object.hasOwn(t,"buttonSelector")&&this.triggers.add(t.buttonSelector);let r={id:s,input:i,container:e,taxonomy:o.dataset.taxonomy,name:e.dataset.field,maxSelection:parseInt(o.dataset.max)||0,canSearch:"search"in o.dataset,hasAutocomplete:"autocomplete"in o.dataset,autocompleteDropdown:e.querySelector(".autocomplete-dropdown")??!1,canCreate:"creatable"in o.dataset,isRequired:"required"in o.dataset,selectedTerms:new Set,toggle:o,selectedContainer:Object.hasOwn(t,"selected")?t.selected:e.querySelector(".selected-items"),...t};!this.hasAutocomplete&&r.hasAutocomplete&&(this.hasAutocomplete=!0,this.initAutocomplete());const a=i.value.trim();if(""!==a){a.split(",").map((e=>parseInt(e.trim()))).filter((e=>!isNaN(e))).forEach((e=>r.selectedTerms.add(e)))}return Object.hasOwn(t,"selectedItems")&&t.selectedItems.forEach((e=>{r.selectedTerms.add(e)})),this.fields.set(s,r),this.isInitializing&&this.taxonomiesToFetch.add(r.taxonomy),r.selectedTerms.size>0&&this.initFieldDisplay(s),s}registerFilterButton(e,t={}){const i=this.createFieldId(e);e.dataset.fieldId=i,t.buttonSelector&&this.triggers.add(t.buttonSelector);const s={id:i,input:null,container:t.container||e.closest(".filters")||e.parentElement,taxonomy:e.dataset.taxonomy,name:`filter_${e.dataset.taxonomy}`,maxSelection:parseInt(e.dataset.max)||0,canSearch:"search"in e.dataset,hasAutocomplete:!1,canCreate:!1,isRequired:!1,selectedTerms:new Set(t.selectedItems||[]),toggle:e,selectedContainer:t.selected||null,isFilterMode:!0,...t};return this.fields.set(i,s),this.isInitializing?this.taxonomiesToFetch.add(s.taxonomy):this.store.setFilter("taxonomy",s.taxonomy),i}createFieldId(e){return this.index++,"selector-"+this.index}async initFieldDisplay(e){const t=this.fields.get(e);if(!t||0===t.selectedTerms.size)return;Array.from(t.selectedTerms).forEach((t=>{const i=this.store.get(t);i&&this.addTermToDisplay(e,i.id,i.name,i.path)}))}initModal(){this.modalID="dialog#jvb-selector",this.modal=document.querySelector(this.modalID),this.modal?(this.initModalElements(),this.modalInstance=new window.jvbModal(this.modal,{handleForm:!1,save:null,open:null}),this.modalInstance.subscribe(((e,t)=>{switch(e){case"modal-open":this.openModal(t);break;case"modal-close":this.closeModal(t)}}))):console.warn("Taxonomy selector modal not found")}initModalElements(){this.selectors={search:{input:"[type=search]",clear:".clear-search",container:".search-wrapper"},termsList:".items-container",termsWrap:".items-wrap",breadcrumbs:{nav:"nav.term-navigation",back:".back-to-parent"},loading:{loading:".loading",text:".loading span"},selectedTerms:".selected-items",sentinel:".scroll-sentinel",modal:{title:"#modal-title",content:".modal-content"},create:{details:".create-new-term",parent:"#select_parent",summary:".create-new-term summary",name:"#term_name",button:".submit-term",label:{name:"[for=term_name]",parent:"[for=select_parent]"}},favouriteTerms:".favourite-terms"},this.ui=window.uiFromSelectors(this.selectors),this.observer=new IntersectionObserver((e=>{e.forEach((e=>{e.isIntersecting&&this.loadMoreTerms()}))}),{root:this.ui.termsWrap,threshold:.5})}initGlobalListeners(){document.addEventListener("click",this.handleClick.bind(this)),document.addEventListener("change",this.handleChange.bind(this)),this.hasAutocomplete&&this.initAutocomplete()}initAutocomplete(){this.autocompleteHandler=window.debounce((e=>this.handleAutocomplete(e)),300),document.addEventListener("input",this.autocompleteHandler),document.addEventListener("blur",this.cleanupAutocomplete.bind(this)),document.addEventListener("focus",(e=>{if(!("autocomplete"in e.target.dataset))return;const t=this.getFieldId(e.target),i=this.fields.get(t);i&&this.preloadTaxonomy(i.taxonomy)}),!0)}handleClick(e){const t=window.targetCheck(e,Array.from(this.triggers));if(t)return e.preventDefault(),void this.handleToggleClick(t);const i=window.targetCheck(e,"button.remove-item");if(i&&e.target.closest(".jvb-selector")){const e=this.getFieldId(i),t=i.closest(".selected-item").dataset.id;this.removeSelectedTerm(e,t)}else e.target.matches(".modal-close")?this.modalInstance&&this.modalInstance.handleClose():this.modal&&this.modal.contains(e.target)&&this.handleModalClick(e)}handleChange(e){if(window.targetCheck(e,".taxonomy.field, .post.field")&&"hidden"===e.target.type){const t=this.getFieldId(e.target);this.updateFieldFromInput(t)}else this.modal&&this.modal.contains(e.target)&&this.handleModalChange(e)}handleToggleClick(e){try{const t=this.getFieldId(e);if(!this.fields.get(t))return void console.error("Field not found for toggle:",t);this.setActiveField(t,!0)}catch(e){console.error("Error handling toggle click:",e),this.error?.log&&this.error.log(e,{component:"TaxonomySelector",action:"handleToggleClick"})}}setActiveField(e,t=!1){this.activeField=e,this.currentConfig=this.fields.get(e),this.currentSingular=this.getSingular(this.currentConfig.taxonomy),this.currentPlural=this.getPlural(this.currentConfig.taxonomy),t&&this.modalInstance.handleOpen(),this.store.setFilter("taxonomy",this.currentConfig.taxonomy),this.selectedTerms.clear(),this.currentConfig.selectedTerms.forEach((e=>{const t=this.store.get(e);t&&this.selectedTerms.set(e,{id:e,name:t.name,path:t.path})}))}handleModalClick(e){if(window.targetCheck(e,".remove-item")){let t=window.targetCheck(e,".selected-item");t&&this.removeSelectedTermFromModal(t.dataset.id)}else if(window.targetCheck(e,".back-to-parent"))this.navigateToParent();else if(window.targetCheck(e,".toggle-children")){let t=e.target.closest("li");this.navigateToChild(parseInt(t.dataset.id),t.querySelector(".term-name").textContent)}else if(window.targetCheck(e,".path-level")){let t=window.targetCheck(e,".path-level");this.navigateToPath(t)}}handleModalChange(e){if(window.targetCheck(e,this.modalID)&&"checkbox"===e.target.type){e.preventDefault(),e.stopPropagation();const t=parseInt(e.target.closest("li").dataset.id),i=e.target.closest("li").querySelector("label");e.target.checked?this.addSelectedTermToModal(t,i.title,i.dataset.path):this.removeSelectedTermFromModal(t)}}openForFilter(e,t,i=[]){const s=`filter-${e}-${Date.now()}`;this.fields.set(s,{id:s,input:null,container:null,taxonomy:e,name:`filter_${e}`,maxSelection:0,canSearch:!0,hasAutocomplete:!1,autocompleteDropdown:document.querySelector(".autocomplete-dropdown")??!1,canCreate:!1,isRequired:!1,selectedTerms:new Set(i),toggle:null,selectedContainer:null,isFilterMode:!0,filterCallback:t}),this.setActiveField(s,!0),this.modalInstance.handleOpen()}openModal(){this.currentConfig?(!this.creator&&this.currentConfig.canCreate&&"jvbTaxCreator"in window&&(this.creator=new window.jvbTaxCreator(this)),this.updateModalForTaxonomy(),this.updateModalSelections(),this.updateSelectionCount(),window.removeChildren(this.ui.termsList),this.showLoading()):console.error("No active field set")}updateSelectionCount(){if(!this.currentConfig)return;const e=this.selectedTerms.size,t=this.currentConfig.maxSelection,i=this.modal?.querySelector(".selection-count");i&&(i.textContent=t>0?`${e} of ${t} selected`:`${e} selected`)}getSingular(e){return jvbSettings.labels[e]?.single||e}getPlural(e){return jvbSettings.labels[e]?.plural||e}closeModal(){if(this.observer.unobserve(this.ui.sentinel),window.removeChildren(this.ui.termsList),this.notify("selected-terms",{terms:this.selectedTerms,taxonomy:this.currentConfig.taxonomy}),this.currentConfig?.isFilterMode){if(this.currentConfig.filterCallback){const e=Array.from(this.selectedTerms.keys());this.currentConfig.filterCallback(e,this.currentConfig.taxonomy)}}else this.activeField&&this.saveSelectionsToField(this.activeField);this.currentConfig?.canSearch&&this.searchHandler&&this.ui.search.input.removeEventListener("input",this.searchHandler),!this.hasAutocomplete&&this.creator&&delete this.creator,this.activeField=null,this.currentConfig=null}resetModalState(){this.disabled=!1,window.removeChildren(this.ui.termsList),window.removeChildren(this.ui.selectedTerms),this.ui.search.input.value="",window.removeChildren(this.ui.breadcrumbs.nav),this.ui.breadcrumbs.nav.appendChild(this.ui.breadcrumbs.back),this.ui.breadcrumbs.back.hidden=!0}updateModalForTaxonomy(){if(!this.currentConfig)return;this.ui.modal.title.textContent=`Select ${this.currentPlural}`,this.ui.search.container&&(this.ui.search.container.style.display=this.currentConfig.canSearch?"block":"none"),this.ui.create.details&&(this.ui.create.details.style.display=this.currentConfig.canCreate?"block":"none",this.ui.create.details.hidden=!this.currentConfig.canCreate,this.ui.create.summary&&(this.ui.create.summary.textContent=`Add new ${this.currentSingular}`),this.ui.create.label.name&&(this.ui.create.label.name.textContent=`Name this ${this.currentSingular}`),this.ui.create.label.parent&&(this.ui.create.label.parent.textContent="Nest it under"),this.ui.create.parent);const e=`Opened ${this.currentSingular} selection. Choose from checkboxes or search to filter results.`;this.a11y?.announce(e)}updateModalSelections(){window.removeChildren(this.ui.selectedTerms),this.selectedTerms.forEach(((e,t)=>{this.addTermToModalDisplay(t,e.name,e.path)})),this.checkSelectionLimits()}addSelectedTermToModal(e,t,i){this.selectedTerms.set(e,{id:e,name:t,path:i}),this.addTermToModalDisplay(e,t,i),this.checkSelectionLimits();const s=this.ui.termsList.querySelector(`input[value="${e}"]`);s&&(s.checked=!0)}removeSelectedTermFromModal(e){this.selectedTerms.delete(parseInt(e));const t=this.ui.selectedTerms.querySelector(`[data-id="${e}"]`);t&&t.remove();const i=this.ui.termsList.querySelector(`input[value="${e}"]`);i&&(i.checked=!1),this.checkSelectionLimits()}addTermToModalDisplay(e,t,i){const s=window.getTemplate("selectedTerm").cloneNode(!0);s.dataset.id=e,s.dataset.path=i,s.dataset.name=t,s.dataset.taxonomy=this.currentConfig.taxonomy,s.querySelector("span").textContent=i,s.querySelector("button").title=`Remove ${t}`,this.ui.selectedTerms.appendChild(s)}checkSelectionLimits(){this.currentConfig&&0!==this.currentConfig.maxSelection&&(this.disabled=this.selectedTerms.size>=this.currentConfig.maxSelection,this.setCheckboxes(this.disabled))}setCheckboxes(e){this.ui.termsList.querySelectorAll('input[type="checkbox"]').forEach((t=>{t.checked||(t.disabled=e)}))}saveSelectionsToField(e){const t=this.fields.get(e);if(!t)return;t.selectedTerms.clear(),window.removeChildren(t.selectedContainer),this.selectedTerms.forEach(((i,s)=>{t.selectedTerms.add(s),this.addTermToDisplay(e,s,i.name,i.path)}));const i=Array.from(t.selectedTerms);t.input.value=i.join(","),t.input.dispatchEvent(new Event("change",{bubbles:!0}))}removeSelectedTerm(e,t){const i=this.fields.get(e);if(!i)return;const s=parseInt(t);i.selectedTerms.delete(s);const o=i.selectedContainer.querySelector(`[data-id="${s}"]`);o&&o.remove();const r=Array.from(i.selectedTerms);i.input.value=r.join(","),i.input.dispatchEvent(new Event("change",{bubbles:!0}))}addTermToDisplay(e,t,i,s){const o=this.fields.get(e);if(!o||o.selectedContainer.querySelector(`[data-id="${t}"]`))return;const r=window.getTemplate("selectedTerm").cloneNode(!0);r.dataset.id=t,r.dataset.path=s,r.dataset.name=i,r.dataset.taxonomy=o.taxonomy,r.querySelector("span").textContent=s,r.querySelector("button").title=`Remove ${i}`,o.selectedContainer.appendChild(r)}updateFieldFromInput(e){const t=this.fields.get(e);if(!t)return;const i=t.input.value.trim();if(t.selectedTerms.clear(),window.removeChildren(t.selectedContainer),""!==i){i.split(",").map((e=>parseInt(e.trim()))).filter((e=>!isNaN(e))).forEach((e=>t.selectedTerms.add(e))),this.initFieldDisplay(e)}}handleSearch(e){const t=e.target.value.trim();this.searchHandler&&clearTimeout(this.searchHandler),this.searchHandler=setTimeout((()=>{this.store.setFilters({search:t,page:1,parent:t?0:this.store.filters.parent||0}),window.removeChildren(this.ui.termsList)}),300)}async handleAutocomplete(e){if(!("autocomplete"in e.target.dataset))return;const t=this.getFieldId(e.target),i=this.fields.get(t);if(!i)return;const s=e.target.value.trim();if(i.currentAutocompleteQuery=s,s.length<2)return i.autocompleteDropdown&&(i.autocompleteDropdown.hidden=!0),void(this.isAutocompleteActive=!1);this.activeField=t,this.isAutocompleteActive=!0,i.autocompleteDropdown&&(i.autocompleteDropdown.hidden=!1),this.store.setFilters({taxonomy:i.taxonomy,search:s,page:1})}cleanupAutocomplete(e){if(!("autocomplete"in e.target.dataset))return;const t=this.getFieldId(e.target);this.fields.get(t)&&this.creator&&delete this.creator}showAutocompleteError(e){const t=this.fields.get(e);if(!t)return;t.config.autocompleteDropdown||(t.config.autocompleteDropdown=t.element.querySelector(".autocomplete-dropdown"));const i=t.config.autocompleteDropdown;i&&(window.removeChildren(i),this.showEmptyState("Hmmm... something went wrong",i))}showAutocompleteResults(e,t,i){if(!e||!e.autocompleteDropdown)return;const s=e.autocompleteDropdown;window.removeChildren(s),0===t.length?this.showEmptyState("No items found.",s):t.forEach((t=>{const i=this.createAutocompleteTermElement(e,t);i&&s.appendChild(i)}));const o=e.currentAutocompleteQuery||i;if(e.canCreate&&o&&window.jvbTaxCreator){const e=this.createNewTermOption(o);s.appendChild(e)}s.hidden=!1}createNewTermOption(e){const t=document.createElement("button");return t.type="button",t.className="autocomplete-item create-term",t.dataset.query=e,t.innerHTML=`<strong>Create:</strong> "${e}"`,t}createAutocompleteTermElement(e,t){const i=document.createElement("button");return i.type="button",i.className="autocomplete-item",i.dataset.id=t.id,i.dataset.name=t.name,i.dataset.path=t.path||t.name,i.textContent=t.path||t.name,i.addEventListener("click",(()=>{e.selectedTerms.add(parseInt(t.id)),this.addTermToDisplay(e.id,t.id,t.name,t.path),e.input.value=Array.from(e.selectedTerms).join(","),e.input.dispatchEvent(new Event("change",{bubbles:!0})),e.autocompleteDropdown.hidden=!0;const i=e.container.querySelector("input[data-autocomplete]");i&&(i.value="")})),i}navigateToParent(){this.store.setFilters({parent:0,page:1}),window.removeChildren(this.ui.termsList),this.ui.breadcrumbs.back.hidden=!0}navigateToChild(e,t){this.store.setFilters({parent:e,page:1}),window.removeChildren(this.ui.termsList),this.updateBreadcrumbs(e,t),this.ui.breadcrumbs.back.hidden=!1}navigateToPath(e){const t=parseInt(e.dataset.id)||0;this.store.setFilters({parent:t,page:1}),window.removeChildren(this.ui.termsList),this.ui.breadcrumbs.back.hidden=0===t}loadMoreTerms(){const e=this.store.filters.page||1;this.store.setFilter("page",e+1)}renderTerms(e=null,t=!1,i=!1){if(e||(e=this.store.getFiltered()),t||window.removeChildren(this.ui.termsList),0===e.length)return void(t||this.showEmptyState());const s=this.store.filters.parent||0;this.ui.breadcrumbs.back.hidden=0===s;const o=document.createDocumentFragment();e.forEach((e=>{const t=this.createTermElement({id:parseInt(e.id),name:e.name,hasChildren:e.hasChildren,path:e.path||null,show:i});t&&o.appendChild(t)})),this.ui.termsList.appendChild(o)}createTermElement(e){if(!e||!e.name)return null;const t=window.getTemplate("termListItem").cloneNode(!0);t.dataset.id=e.id;const i=this.selectedTerms.has(e.id),s=t.querySelector("input"),o=t.querySelector("label"),r=t.querySelector("span, .term-name");if(s&&o&&r&&(s.id=`${this.currentConfig.container.id}${e.id}`,s.name=`${this.currentConfig.container.id}${this.currentConfig.taxonomy}-select`,s.value=e.id,s.disabled=!i&&this.disabled,s.checked=i,o.htmlFor=s.id,o.title=e.path||e.name,o.dataset.path=e.path,r.textContent=e.show?e.path:e.name),e.hasChildren){const i=window.getTemplate?window.getTemplate("termChildrenToggle"):this.createChildrenToggle();i&&(i.ariaLabel=`View sub-terms of ${e.name}`,t.appendChild(i))}return t}createChildrenToggle(){const e=document.createElement("button");return e.type="button",e.className="toggle-children",e.innerHTML="→",e}updateBreadcrumbs(e,t){const i=window.getTemplate("termBreadcrumb").cloneNode(!0);i.dataset.id=e,i.textContent=t,i.title=t;const s=this.ui.breadcrumbs.nav.querySelector(`[data-id="${e}"]`);if(s)for(;s.nextElementSibling;)s.nextElementSibling.remove();else this.ui.breadcrumbs.nav.appendChild(i)}showLoading(){this.ui.loading.loading.hidden=!1,this.modal.classList.add("loading");const e=this.store?.filters?.search||"",t=this.store?.filters?.parent||0;let i=""!==e?`searching for "${e}" items`:0===t?"loading items":"loading child items";window.typeLoop?this.stopTyping=window.typeLoop(this.ui.loading.text,i):this.ui.loading.text.textContent=i}hideLoading(){this.ui.loading.loading.hidden=!0,this.modal.classList.remove("loading"),this.stopTyping&&this.stopTyping()}showEmptyState(e="No items found.",t=null){t||(t=this.ui.termsList);const i=window.getTemplate("noResults").cloneNode(!0);e&&i.querySelector("span")&&(i.querySelector("span").textContent=e),t.appendChild(i)}getFieldId(e){if(e.dataset.fieldId)return e.dataset.fieldId;const t=e.closest("[data-field-id]");return t?t.dataset.fieldId:null}async batchFetchTaxonomies(){if(0===this.taxonomiesToFetch.size)return;const e=Array.from(this.taxonomiesToFetch);this.taxonomiesToFetch.clear(),this.store.setFilters({taxonomy:e.join(","),page:1,search:"",parent:0})}handleBatchDataLoaded(e,t){const i=e.split(",").map((e=>e.trim())),s=this.store.getStore();i.forEach((e=>{const t={taxonomy:e,page:1,search:"",parent:0},i=this.generateCacheKeyForFilters(t),o={key:i,items:Array.from(this.store.data.values()).filter((t=>t.taxonomy===e)).map((e=>e.id)),timestamp:Date.now(),endpoint:s.config.endpoint,filters:t};if(s.cache.set(i,o),s.db?.objectStoreNames.contains("cache")){s.db.transaction(["cache"],"readwrite").objectStore("cache").put(o)}this.updateFieldsForTaxonomy(e)})),this.fields.forEach(((e,t)=>{e.selectedTerms.size>0&&this.initFieldDisplay(t)}))}generateCacheKeyForFilters(e){const t=Object.keys(e).sort().reduce(((t,i)=>(t[i]=e[i],t)),{});return JSON.stringify(t)}async preloadTaxonomy(e){this.store.setFilters({taxonomy:e,page:1,search:"",parent:0})}subscribe(e){return this.subscribers.add(e),()=>this.subscribers.delete(e)}notify(e,t={}){this.subscribers.forEach((i=>{try{i(e,t)}catch(e){console.error("Subscriber error:",e)}}))}destroy(){document.removeEventListener("click",this.handleClick),document.removeEventListener("change",this.handleChange),this.observer?.disconnect(),this.store.destroy(),this.subscribers.clear(),this.fields.clear(),this.selectedTerms.clear()}}document.addEventListener("DOMContentLoaded",(function(){window.jvbSelector=new e}))})(); |
| | | (()=>{class e{constructor(){this.a11y=window.jvbA11y,this.error=window.jvbError,this.index=-1,this.hasAutocomplete=!1,this.isInitializing=!0,this.taxonomiesToFetch=new Set,this.triggers=new Set([".taxonomy-toggle"]),this.subscribers=new Set;const e=window.jvbStore.register("taxonomies",{storeName:"terms",keyPath:"id",showLoading:!1,indexes:[{name:"taxonomy",keyPath:"taxonomy"},{name:"parent",keyPath:"parent"},{name:"slug",keyPath:"slug",unique:!0},{name:"count",keyPath:"count"}],endpoint:"terms",TTL:12e4,filters:{taxonomy:"",page:1,search:"",parent:0},required:"taxonomy",delayFetch:!0});this.store=e.terms,this.fields=new Map,this.selectedTerms=new Map,this.activeField=null,this.currentConfig=null,this.currentSingular=null,this.currentPlural=null,this.disabled=!1,this.searchHandler=null,this.autocompleteHandler=null,this.isAutocompleteActive=!1,this.init()}init(){this.initModal(),this.scanExistingFields(),this.initGlobalListeners(),this.hasAutocomplete&&window.jvbTaxCreator&&(this.creator=new window.jvbTaxCreator(this)),this.store.subscribe(this.handleStoreEvent.bind(this)),this.isInitializing=!1,this.batchFetchTaxonomies()}handleStoreEvent(e,t){switch(e){case"data-loaded":const e=this.store.filters.taxonomy;if(e?.includes(",")&&this.handleBatchDataLoaded(e,t),e){(e.includes(",")?e.split(",").map((e=>e.trim())):[e]).forEach((e=>{this.updateFieldsForTaxonomy(e)}))}if(this.modal?.open&&this.handleTermsLoaded(t),this.isAutocompleteActive&&this.activeField){const e=this.fields.get(this.activeField),i=t.data?.items||[],s=t.filters?.search||"";this.showAutocompleteResults(e,i,s),this.isAutocompleteActive=!1}break;case"filters-changed":this.modal?.open&&this.showLoading();break;case"fetch-error":this.isAutocompleteActive&&this.activeField&&(this.showAutocompleteError(this.activeField),this.isAutocompleteActive=!1),this.handleFetchError(t.error)}}handleTermsLoaded(e){this.hideLoading();const t=this.store.getFiltered(),i=this.store.lastResponse?.page||{},s=e.filters?.search&&e.filters.search.length>0,o=i.page>1;this.notify("terms-loaded",{terms:t,filters:e.filters}),0===t.length?(o||this.showEmptyState(s?"No results found.":"No items available."),this.observer.unobserve(this.ui.sentinel)):(this.renderTerms(t,o,s),i.has_more?this.observer.observe(this.ui.sentinel):this.observer.unobserve(this.ui.sentinel)),this.a11y?.announce(t.length,o)}handleFetchError(e){console.error("Taxonomy fetch error:",e),this.hideLoading(),this.error?.log?this.error.log(e,{component:"TaxonomySelector",action:"fetchTerms"},(()=>this.fetchCurrentTerms())):this.showEmptyState("Error loading terms. Please try again.")}updateFieldButtonState(e){const t=this.fields.get(e);if(!t)return;const i=Array.from(this.store.data.values()).some((e=>e.taxonomy===t.taxonomy));t.toggle&&(t.toggle.disabled=!i&&!t.canCreate,t.toggle.title=i?`Select ${this.getPlural(t.taxonomy)}`:`No ${this.getSingular(t.taxonomy)} available`)}updateFieldsForTaxonomy(e){this.getFieldsForTaxonomy(e).forEach((e=>{this.updateFieldButtonState(e.id)}))}getFieldsForTaxonomy(e){return Array.from(this.fields.values()).filter((t=>t.taxonomy===e))}scanExistingFields(e=null){e||(e=document.body);e.querySelectorAll(".field.taxonomy, .field.post").forEach((e=>{try{this.registerField(e)}catch(t){this.error.log(t,{component:"TaxonomySelector",action:"scanExistingFields",container:e.dataset.name})}}))}registerField(e,t={}){let i=e.querySelector("input[type=hidden]");if(!i)return!1;"fieldId"in e.dataset||(e.dataset.fieldId=this.createFieldId(e));let s=e.dataset.fieldId,o=Object.hasOwn(t,"button")?t.button:e.querySelector("button.taxonomy-toggle");Object.hasOwn(t,"buttonSelector")&&this.triggers.add(t.buttonSelector);let r={id:s,input:i,container:e,taxonomy:o.dataset.taxonomy,name:e.dataset.field,maxSelection:parseInt(o.dataset.max)||0,canSearch:"search"in o.dataset,hasAutocomplete:"autocomplete"in o.dataset,autocompleteDropdown:e.querySelector(".autocomplete-dropdown")??!1,canCreate:"creatable"in o.dataset,isRequired:"required"in o.dataset,selectedTerms:new Set,toggle:o,selectedContainer:Object.hasOwn(t,"selected")?t.selected:e.querySelector(".selected-items"),...t};!this.hasAutocomplete&&r.hasAutocomplete&&(this.hasAutocomplete=!0,this.initAutocomplete());const a=i.value.trim();if(""!==a){a.split(",").map((e=>parseInt(e.trim()))).filter((e=>!isNaN(e))).forEach((e=>r.selectedTerms.add(e)))}return Object.hasOwn(t,"selectedItems")&&t.selectedItems.forEach((e=>{r.selectedTerms.add(e)})),this.fields.set(s,r),this.isInitializing&&this.taxonomiesToFetch.add(r.taxonomy),r.selectedTerms.size>0&&this.initFieldDisplay(s),s}registerFilterButton(e,t={}){const i=this.createFieldId(e);e.dataset.fieldId=i,t.buttonSelector&&this.triggers.add(t.buttonSelector);const s={id:i,input:null,container:t.container||e.closest(".filters")||e.parentElement,taxonomy:e.dataset.taxonomy,name:`filter_${e.dataset.taxonomy}`,maxSelection:parseInt(e.dataset.max)||0,canSearch:"search"in e.dataset,hasAutocomplete:!1,canCreate:!1,isRequired:!1,selectedTerms:new Set(t.selectedItems||[]),toggle:e,selectedContainer:t.selected||null,isFilterMode:!0,...t};return this.fields.set(i,s),this.isInitializing?this.taxonomiesToFetch.add(s.taxonomy):this.store.setFilter("taxonomy",s.taxonomy),i}createFieldId(e){return this.index++,"selector-"+this.index}async initFieldDisplay(e){const t=this.fields.get(e);if(!t||0===t.selectedTerms.size)return;Array.from(t.selectedTerms).forEach((t=>{const i=this.store.get(t);i&&this.addTermToDisplay(e,i.id,i.name,i.path)}))}initModal(){this.modalID="dialog#jvb-selector",this.modal=document.querySelector(this.modalID),this.modal?(this.initModalElements(),this.modalInstance=new window.jvbModal(this.modal,{handleForm:!1,save:null,open:null}),this.modalInstance.subscribe(((e,t)=>{switch(e){case"modal-open":this.openModal(t);break;case"modal-close":this.closeModal(t)}}))):console.warn("Taxonomy selector modal not found")}initModalElements(){this.selectors={search:{input:"[type=search]",clear:".clear-search",container:".search-wrapper"},termsList:".items-container",termsWrap:".items-wrap",breadcrumbs:{nav:"nav.term-navigation",back:".back-to-parent"},loading:{loading:".loading",text:".loading span"},selectedTerms:".selected-items",sentinel:".scroll-sentinel",modal:{title:"#modal-title",content:".modal-content"},create:{details:".create-new-term",parent:"#select_parent",summary:".create-new-term summary",name:"#term_name",button:".submit-term",label:{name:"[for=term_name]",parent:"[for=select_parent]"}},favouriteTerms:".favourite-terms"},this.ui=window.uiFromSelectors(this.selectors),this.observer=new IntersectionObserver((e=>{e.forEach((e=>{e.isIntersecting&&this.loadMoreTerms()}))}),{root:this.ui.termsWrap,threshold:.5})}initGlobalListeners(){document.addEventListener("click",this.handleClick.bind(this)),document.addEventListener("change",this.handleChange.bind(this)),this.hasAutocomplete&&this.initAutocomplete()}initAutocomplete(){this.autocompleteHandler=window.debounce((e=>this.handleAutocomplete(e)),300),document.addEventListener("input",this.autocompleteHandler),document.addEventListener("blur",this.cleanupAutocomplete.bind(this)),document.addEventListener("focus",(e=>{if(!("autocomplete"in e.target.dataset))return;const t=this.getFieldId(e.target),i=this.fields.get(t);i&&this.preloadTaxonomy(i.taxonomy)}),!0)}handleClick(e){const t=window.targetCheck(e,Array.from(this.triggers));if(t)return e.preventDefault(),void this.handleToggleClick(t);const i=window.targetCheck(e,"button.remove-item");if(i&&e.target.closest(".jvb-selector")){const e=this.getFieldId(i),t=i.closest(".selected-item").dataset.id;this.removeSelectedTerm(e,t)}else e.target.matches(".modal-close")?this.modalInstance&&this.modalInstance.handleClose():this.modal&&this.modal.contains(e.target)&&this.handleModalClick(e)}handleChange(e){if(window.targetCheck(e,".taxonomy.field, .post.field")&&"hidden"===e.target.type){const t=this.getFieldId(e.target);this.updateFieldFromInput(t)}else this.modal&&this.modal.contains(e.target)&&this.handleModalChange(e)}handleToggleClick(e){try{const t=this.getFieldId(e);if(!this.fields.get(t))return void console.error("Field not found for toggle:",t);this.setActiveField(t,!0)}catch(e){console.error("Error handling toggle click:",e),this.error?.log&&this.error.log(e,{component:"TaxonomySelector",action:"handleToggleClick"})}}setActiveField(e,t=!1){this.activeField=e,this.currentConfig=this.fields.get(e),this.currentSingular=this.getSingular(this.currentConfig.taxonomy),this.currentPlural=this.getPlural(this.currentConfig.taxonomy),t&&this.modalInstance.handleOpen(),this.store.setFilter("taxonomy",this.currentConfig.taxonomy),this.selectedTerms.clear(),this.currentConfig.selectedTerms.forEach((e=>{const t=this.store.get(e);t&&this.selectedTerms.set(e,{id:e,name:t.name,path:t.path})}))}handleModalClick(e){if(window.targetCheck(e,".remove-item")){let t=window.targetCheck(e,".selected-item");t&&this.removeSelectedTermFromModal(t.dataset.id)}else if(window.targetCheck(e,".back-to-parent"))this.navigateToParent();else if(window.targetCheck(e,".toggle-children")){let t=e.target.closest("li");this.navigateToChild(parseInt(t.dataset.id),t.querySelector(".term-name").textContent)}else if(window.targetCheck(e,".path-level")){let t=window.targetCheck(e,".path-level");this.navigateToPath(t)}}handleModalChange(e){if(window.targetCheck(e,this.modalID)&&"checkbox"===e.target.type){e.preventDefault(),e.stopPropagation();const t=parseInt(e.target.closest("li").dataset.id),i=e.target.closest("li").querySelector("label");e.target.checked?this.addSelectedTermToModal(t,i.title,i.dataset.path):this.removeSelectedTermFromModal(t)}}openForFilter(e,t,i=[]){const s=`filter-${e}-${Date.now()}`;this.fields.set(s,{id:s,input:null,container:null,taxonomy:e,name:`filter_${e}`,maxSelection:0,canSearch:!0,hasAutocomplete:!1,autocompleteDropdown:document.querySelector(".autocomplete-dropdown")??!1,canCreate:!1,isRequired:!1,selectedTerms:new Set(i),toggle:null,selectedContainer:null,isFilterMode:!0,filterCallback:t}),this.setActiveField(s,!0),this.modalInstance.handleOpen()}openModal(){this.currentConfig?(!this.creator&&this.currentConfig.canCreate&&"jvbTaxCreator"in window&&(this.creator=new window.jvbTaxCreator(this)),this.updateModalForTaxonomy(),this.updateModalSelections(),this.updateSelectionCount(),window.removeChildren(this.ui.termsList),this.showLoading()):console.error("No active field set")}updateSelectionCount(){if(!this.currentConfig)return;const e=this.selectedTerms.size,t=this.currentConfig.maxSelection,i=this.modal?.querySelector(".selection-count");i&&(i.textContent=t>0?`${e} of ${t} selected`:`${e} selected`)}getSingular(e){return jvbSettings.labels[e]?.single||e}getPlural(e){return jvbSettings.labels[e]?.plural||e}closeModal(){if(this.observer.unobserve(this.ui.sentinel),window.removeChildren(this.ui.termsList),this.notify("selected-terms",{terms:this.selectedTerms,taxonomy:this.currentConfig.taxonomy}),this.currentConfig?.isFilterMode){if(this.currentConfig.filterCallback){const e=Array.from(this.selectedTerms.keys());this.currentConfig.filterCallback(e,this.currentConfig.taxonomy)}}else this.activeField&&this.saveSelectionsToField(this.activeField);this.currentConfig?.canSearch&&this.searchHandler&&this.ui.search.input.removeEventListener("input",this.searchHandler),!this.hasAutocomplete&&this.creator&&delete this.creator,this.activeField=null,this.currentConfig=null}resetModalState(){this.disabled=!1,window.removeChildren(this.ui.termsList),window.removeChildren(this.ui.selectedTerms),this.ui.search.input.value="",window.removeChildren(this.ui.breadcrumbs.nav),this.ui.breadcrumbs.nav.appendChild(this.ui.breadcrumbs.back),this.ui.breadcrumbs.back.hidden=!0}updateModalForTaxonomy(){if(!this.currentConfig)return;this.ui.modal.title.textContent=`Select ${this.currentPlural}`,this.ui.search.container&&(this.ui.search.container.style.display=this.currentConfig.canSearch?"block":"none"),this.ui.create.details&&(this.ui.create.details.style.display=this.currentConfig.canCreate?"block":"none",this.ui.create.details.hidden=!this.currentConfig.canCreate,this.ui.create.summary&&(this.ui.create.summary.textContent=`Add new ${this.currentSingular}`),this.ui.create.label.name&&(this.ui.create.label.name.textContent=`Name this ${this.currentSingular}`),this.ui.create.label.parent&&(this.ui.create.label.parent.textContent="Nest it under"),this.ui.create.parent);const e=`Opened ${this.currentSingular} selection. Choose from checkboxes or search to filter results.`;this.a11y?.announce(e)}updateModalSelections(){window.removeChildren(this.ui.selectedTerms),this.selectedTerms.forEach(((e,t)=>{this.addTermToModalDisplay(t,e.name,e.path)})),this.checkSelectionLimits()}addSelectedTermToModal(e,t,i){this.selectedTerms.set(e,{id:e,name:t,path:i}),this.addTermToModalDisplay(e,t,i),this.checkSelectionLimits();const s=this.ui.termsList.querySelector(`input[value="${e}"]`);s&&(s.checked=!0)}removeSelectedTermFromModal(e){this.selectedTerms.delete(parseInt(e));const t=this.ui.selectedTerms.querySelector(`[data-id="${e}"]`);t&&t.remove();const i=this.ui.termsList.querySelector(`input[value="${e}"]`);i&&(i.checked=!1),this.checkSelectionLimits()}addTermToModalDisplay(e,t,i){const s=window.getTemplate("selectedTerm").cloneNode(!0);s.dataset.id=e,s.dataset.path=i,s.dataset.name=t,s.dataset.taxonomy=this.currentConfig.taxonomy,s.querySelector("span").textContent=i,s.querySelector("button").title=`Remove ${t}`,this.ui.selectedTerms.appendChild(s)}checkSelectionLimits(){this.currentConfig&&0!==this.currentConfig.maxSelection&&(this.disabled=this.selectedTerms.size>=this.currentConfig.maxSelection,this.setCheckboxes(this.disabled))}setCheckboxes(e){this.ui.termsList.querySelectorAll('input[type="checkbox"]').forEach((t=>{t.checked||(t.disabled=e)}))}saveSelectionsToField(e){const t=this.fields.get(e);if(!t)return;t.selectedTerms.clear(),window.removeChildren(t.selectedContainer),this.selectedTerms.forEach(((i,s)=>{t.selectedTerms.add(s),this.addTermToDisplay(e,s,i.name,i.path)}));const i=Array.from(t.selectedTerms);t.input.value=i.join(","),t.input.dispatchEvent(new Event("change",{bubbles:!0}))}removeSelectedTerm(e,t){const i=this.fields.get(e);if(!i)return;const s=parseInt(t);i.selectedTerms.delete(s);const o=i.selectedContainer.querySelector(`[data-id="${s}"]`);o&&o.remove();const r=Array.from(i.selectedTerms);i.input.value=r.join(","),i.input.dispatchEvent(new Event("change",{bubbles:!0}))}addTermToDisplay(e,t,i,s){const o=this.fields.get(e);if(!o||o.selectedContainer.querySelector(`[data-id="${t}"]`))return;const r=window.getTemplate("selectedTerm").cloneNode(!0);r.dataset.id=t,r.dataset.path=s,r.dataset.name=i,r.dataset.taxonomy=o.taxonomy,r.querySelector("span").textContent=s,r.querySelector("button").title=`Remove ${i}`,o.selectedContainer.appendChild(r)}updateFieldFromInput(e){const t=this.fields.get(e);if(!t)return;const i=t.input.value.trim();if(t.selectedTerms.clear(),window.removeChildren(t.selectedContainer),""!==i){i.split(",").map((e=>parseInt(e.trim()))).filter((e=>!isNaN(e))).forEach((e=>t.selectedTerms.add(e))),this.initFieldDisplay(e)}}handleSearch(e){const t=e.target.value.trim();this.searchHandler&&clearTimeout(this.searchHandler),this.searchHandler=setTimeout((()=>{this.store.setFilters({search:t,page:1,parent:t?0:this.store.filters.parent||0}),window.removeChildren(this.ui.termsList)}),300)}async handleAutocomplete(e){if(!("autocomplete"in e.target.dataset))return;const t=this.getFieldId(e.target),i=this.fields.get(t);if(!i)return;const s=e.target.value.trim();if(i.currentAutocompleteQuery=s,s.length<2)return i.autocompleteDropdown&&(i.autocompleteDropdown.hidden=!0),void(this.isAutocompleteActive=!1);this.activeField=t,this.isAutocompleteActive=!0,i.autocompleteDropdown&&(i.autocompleteDropdown.hidden=!1),this.store.setFilters({taxonomy:i.taxonomy,search:s,page:1})}cleanupAutocomplete(e){if(!("autocomplete"in e.target.dataset))return;const t=this.getFieldId(e.target);this.fields.get(t)&&this.creator&&delete this.creator}showAutocompleteError(e){const t=this.fields.get(e);if(!t)return;t.config.autocompleteDropdown||(t.config.autocompleteDropdown=t.element.querySelector(".autocomplete-dropdown"));const i=t.config.autocompleteDropdown;i&&(window.removeChildren(i),this.showEmptyState("Hmmm... something went wrong",i))}showAutocompleteResults(e,t,i){if(!e||!e.autocompleteDropdown)return;const s=e.autocompleteDropdown;window.removeChildren(s),0===t.length?this.showEmptyState("No items found.",s):t.forEach((t=>{const i=this.createAutocompleteTermElement(e,t);i&&s.appendChild(i)}));const o=e.currentAutocompleteQuery||i;if(e.canCreate&&o&&window.jvbTaxCreator){const e=this.createNewTermOption(o);s.appendChild(e)}s.hidden=!1}createNewTermOption(e){const t=document.createElement("button");return t.type="button",t.className="autocomplete-item create-term",t.dataset.query=e,t.innerHTML=`<strong>Create:</strong> "${e}"`,t}createAutocompleteTermElement(e,t){const i=document.createElement("button");return i.type="button",i.className="autocomplete-item",i.dataset.id=t.id,i.dataset.name=t.name,i.dataset.path=t.path||t.name,i.textContent=t.path||t.name,i.addEventListener("click",(()=>{e.selectedTerms.add(parseInt(t.id)),this.addTermToDisplay(e.id,t.id,t.name,t.path),e.input.value=Array.from(e.selectedTerms).join(","),e.input.dispatchEvent(new Event("change",{bubbles:!0})),e.autocompleteDropdown.hidden=!0;const i=e.container.querySelector("input[data-autocomplete]");i&&(i.value="")})),i}navigateToParent(){this.store.setFilters({parent:0,page:1}),window.removeChildren(this.ui.termsList),this.ui.breadcrumbs.back.hidden=!0}navigateToChild(e,t){this.store.setFilters({parent:e,page:1}),window.removeChildren(this.ui.termsList),this.updateBreadcrumbs(e,t),this.ui.breadcrumbs.back.hidden=!1}navigateToPath(e){const t=parseInt(e.dataset.id)||0;this.store.setFilters({parent:t,page:1}),window.removeChildren(this.ui.termsList),this.ui.breadcrumbs.back.hidden=0===t}loadMoreTerms(){const e=this.store.filters.page||1;this.store.setFilter("page",e+1)}renderTerms(e=null,t=!1,i=!1){if(e||(e=this.store.getFiltered()),t||window.removeChildren(this.ui.termsList),0===e.length)return void(t||this.showEmptyState());const s=this.store.filters.parent||0;this.ui.breadcrumbs.back.hidden=0===s;const o=document.createDocumentFragment();e.forEach((e=>{const t=this.createTermElement({id:parseInt(e.id),name:e.name,hasChildren:e.hasChildren,path:e.path||null,show:i});t&&o.appendChild(t)})),this.ui.termsList.appendChild(o)}createTermElement(e){if(!e||!e.name)return null;const t=window.getTemplate("termListItem").cloneNode(!0);t.dataset.id=e.id;const i=this.selectedTerms.has(e.id),s=t.querySelector("input"),o=t.querySelector("label"),r=t.querySelector("span, .term-name");if(s&&o&&r&&(s.id=`${this.currentConfig.container.id}${e.id}`,s.name=`${this.currentConfig.container.id}${this.currentConfig.taxonomy}-select`,s.value=e.id,s.disabled=!i&&this.disabled,s.checked=i,o.htmlFor=s.id,o.title=e.path||e.name,o.dataset.path=e.path,r.textContent=e.show?e.path:e.name),e.hasChildren){const i=window.getTemplate?window.getTemplate("termChildrenToggle"):this.createChildrenToggle();i&&(i.ariaLabel=`View sub-terms of ${e.name}`,t.appendChild(i))}return t}createChildrenToggle(){const e=document.createElement("button");return e.type="button",e.className="toggle-children",e.innerHTML="→",e}updateBreadcrumbs(e,t){const i=window.getTemplate("termBreadcrumb").cloneNode(!0);i.dataset.id=e,i.textContent=t,i.title=t;const s=this.ui.breadcrumbs.nav.querySelector(`[data-id="${e}"]`);if(s)for(;s.nextElementSibling;)s.nextElementSibling.remove();else this.ui.breadcrumbs.nav.appendChild(i)}showLoading(){this.ui.loading.loading.hidden=!1,this.modal.classList.add("loading");const e=this.store?.filters?.search||"",t=this.store?.filters?.parent||0;let i=""!==e?`searching for "${e}" items`:0===t?"loading items":"loading child items";window.typeLoop?this.stopTyping=window.typeLoop(this.ui.loading.text,i):this.ui.loading.text.textContent=i}hideLoading(){this.ui.loading.loading.hidden=!0,this.modal.classList.remove("loading"),this.stopTyping&&this.stopTyping()}showEmptyState(e="No items found.",t=null){t||(t=this.ui.termsList);const i=window.getTemplate("noResults").cloneNode(!0);e&&i.querySelector("span")&&(i.querySelector("span").textContent=e),t.appendChild(i)}getFieldId(e){if(e.dataset.fieldId)return e.dataset.fieldId;const t=e.closest("[data-field-id]");return t?t.dataset.fieldId:null}async batchFetchTaxonomies(){if(0===this.taxonomiesToFetch.size)return;const e=Array.from(this.taxonomiesToFetch);this.taxonomiesToFetch.clear(),this.store.setFilters({taxonomy:e.join(","),page:1,search:"",parent:0})}handleBatchDataLoaded(e,t){const i=e.split(",").map((e=>e.trim())),s=this.store.getStore();i.forEach((e=>{const t={taxonomy:e,page:1,search:"",parent:0},i=this.generateCacheKeyForFilters(t),o={key:i,items:Array.from(this.store.data.values()).filter((t=>t.taxonomy===e)).map((e=>e.id)),timestamp:Date.now(),endpoint:s.config.endpoint,filters:t};if(s.cache.set(i,o),s.db?.objectStoreNames.contains("cache")){s.db.transaction(["cache"],"readwrite").objectStore("cache").put(o)}this.updateFieldsForTaxonomy(e)})),this.fields.forEach(((e,t)=>{e.selectedTerms.size>0&&this.initFieldDisplay(t)}))}generateCacheKeyForFilters(e){const t=Object.keys(e).sort().reduce(((t,i)=>(t[i]=e[i],t)),{});return JSON.stringify(t)}async preloadTaxonomy(e){this.store.setFilters({taxonomy:e,page:1,search:"",parent:0})}subscribe(e){return this.subscribers.add(e),()=>this.subscribers.delete(e)}notify(e,t={}){this.subscribers.forEach((i=>{try{i(e,t)}catch(e){console.error("Subscriber error:",e)}}))}destroy(){document.removeEventListener("click",this.handleClick),document.removeEventListener("change",this.handleChange),this.observer?.disconnect(),this.store.destroy(),this.subscribers.clear(),this.fields.clear(),this.selectedTerms.clear()}}document.addEventListener("DOMContentLoaded",(function(){window.jvbSelector=new e}))})(); |
| | |
| | | (()=>{class e{constructor(){this.queue=window.jvbQueue,this.a11y=window.jvbA11y,this.error=window.jvbError,this.fieldStore=window.jvbStore.register("upload_fields",{storeName:"fieldStates",keyPath:"id",version:2,indexes:[{name:"fieldId",keyPath:"fieldId"},{name:"timestamp",keyPath:"timestamp"},{name:"content",keyPath:"content"},{name:"itemId",keyPath:"itemId"},{name:"status",keyPath:"status"}],TTL:6048e5,delayFetch:!0}),this.uploadStore=window.jvbStore.register("uploads",{name:"uploads",storeName:"uploads",keyPath:"id",storeBlobs:!0,indexes:[{name:"fieldId",keyPath:"fieldId"},{name:"status",keyPath:"status"},{name:"groupId",keyPath:"groupId"},{name:"attachmentId",keyPath:"attachmentId"}],delayFetch:!0}),window.jvbUploadBlobs=this.uploadStore,this.fieldStore.subscribe(this.handleFieldStoreEvent.bind(this)),this.uploadStore.subscribe(this.handleUploadStoreEvent.bind(this)),this.uploadElements=new Map,this.fieldElements=new Map,this.groupElements=new Map,this.selected=new Map,this.selectionHandlers=new Map,this.previewUrls=new Set,this.sortableInstances=new Map,this.initWorker(),this.subscribers=new Set,this.dragController=null,this.selectors={field:{field:"[data-upload-field]",input:'input[type="file"]',dropZone:".file-upload-container",preview:".item-grid.preview",progress:".image-progress"},groups:{container:".upload-group",grid:".item-grid.group",header:".group-header",selectAll:'[name="select-all-group"]',actions:".group-actions",count:".selection-controls .info"},items:{item:"[data-upload-id]",checkbox:'[name*="select-item"]',featured:'[name="featured"]',details:"details"}},this.statusMapping={received:"Image Received",local_processing:"Processing Image...",queued:"Waiting to upload...",uploading:"Uploading to Server",pending:"Successfully sent to server. In line for further processing.",processing:"Processing on server...",completed:"Upload complete!",failed:"Upload failed (will retry)",failed_permanent:"Upload failed permanently"},this.sortableConfig={animation:150,draggable:".item",handle:".select-item, img",ghostClass:"sortable-ghost",chosenClass:"sortable-chosen",dragClass:"sortable-drag",onEnd:e=>this.handleReorder(e)},this.init()}async init(){this.initializeFields(),this.initListeners(),this.queue.subscribe(((e,t)=>{if(!["uploads","uploads/meta","uploads/groups"].includes(t.endpoint))return;const s=t.data instanceof FormData?t.data.get("fieldId"):t.data?.fieldId;switch(e){case"cancel-operation":s&&this.handleOperationCancelled(s);break;case"operation-status":s&&this.updateFieldStatus(s,t.status);break;case"operation-complete":this.handleOperationComplete(t,s);break;case"operation-failed":case"operation-failed-permanent":this.handleOperationFailed(t,s)}})),window.addEventListener("beforeunload",(()=>{this.cleanupAllPreviewUrls()}))}initWorker(){this.worker={worker:null,timeout:null,tasks:new Map,restart:{count:0,max:3},settings:{timeout:1e4,batchSize:1,maxConcurrent:3,restartAfterTimeout:!0}}}initializeFields(){document.querySelectorAll(this.selectors.field.field).forEach((e=>this.registerUploader(e)))}scanFields(e){e.querySelectorAll(this.selectors.field.field).forEach((e=>this.registerUploader(e)))}registerUploader(e){const t=this.determineFieldId(e),s=this.extractFieldConfig(e),o=this.buildFieldUI(e),r={id:t,config:s,uploads:new Set,groups:[],state:"ready",timestamp:Date.now()};return this.fieldStore.save(r),this.fieldElements.set(t,{element:e,ui:o,config:s}),e.dataset.uploader=t,this.addFieldSelectionHandler(t),"post_group"!==s.destination||this.dragController||this.initGroupFeatures(),"single"!==s.type&&this.initSortable(t),t}extractFieldConfig(e){return{destination:e.dataset.destination||"meta",content:e.dataset.content||null,mode:e.dataset.mode||"direct",type:e.dataset.type||"single",name:e.dataset.field,itemID:e.dataset.itemId||0,maxFiles:parseInt(e.dataset.maxFiles)||999,subtype:e.dataset.subtype||"image"}}buildFieldUI(e){let t={field:e,input:e.querySelector(this.selectors.field.input),dropZone:e.querySelector(this.selectors.field.dropZone),preview:e.querySelector(this.selectors.field.preview),progress:{progress:e.querySelector(this.selectors.field.progress),bar:e.querySelector(".bar"),fill:e.querySelector(".fill"),details:e.querySelector(".details"),text:e.querySelector(".details .text"),count:e.querySelector(".details .count")}},s=e.querySelector(".group-display");return s&&(t.groups={display:s,container:e.querySelector(".item-grid.groups"),empty:e.querySelector(".empty-group"),groups:new Map}),t}initSortable(e){if(!window.Sortable)return;const t=this.fieldElements.get(e);if(!t)return;t.element.querySelectorAll(".item-grid.preview, .item-grid.group").forEach((t=>{if(t.sortableInstance)return;const s=t.classList.contains("group")?`${e}-group-${t.closest(".upload-group")?.dataset.groupId}`:`${e}-preview`,o=new Sortable(t,{...this.sortableConfig,group:{name:e,pull:!0,put:!0},onAdd:e=>{this.updateSortableState(e.to)},onRemove:e=>{this.updateSortableState(e.from)}});t.sortableInstance=o,this.sortableInstances.set(s,o)}))}updateSortableState(e){const t=e?.sortableInstance;if(!t)return;const s=e.querySelectorAll(".item").length>0;t.option("disabled",!s)}refreshSortable(e){const t=this.fieldElements.get(e);if(!t)return;t.element.querySelectorAll(".item-grid.preview, .item-grid.group").forEach((e=>this.updateSortableState(e)))}handleReorder(e){const t=e.to.closest(".field, .upload");if(!t)return;let s=t.querySelector('input[type="hidden"]'),o=Array.from(t.querySelectorAll(".item")).map((e=>e.dataset.id));console.log(o),s.value=o.join(","),window.jvbA11y&&window.jvbA11y.announce("Item reordered"),t.dispatchEvent(new CustomEvent("jvb-items-reordered",{detail:{from:e.from,to:e.to,oldIndex:e.oldIndex,newIndex:e.newIndex},bubbles:!0}))}initGroupFeatures(){this.dragController=new window.jvbDragHandler({draggableSelector:this.selectors.items.item,dropTargetSelector:`${this.selectors.field.preview}, ${this.selectors.groups.grid}, .empty-group`,ignoreSelector:"input:not(.upload-select), button, select, textarea, details, summary, a",previewElement:"img, video, .icon",getItemId:e=>e.dataset.uploadId,getSelectedItems:e=>{const t=this.getFieldIdFromElement(e),s=e.dataset.uploadId,o=this.getCurrentSelection(t);return o&&o.includes(s)?o:[s]},validateDrop:(e,t)=>{const s=this.getFieldIdFromElement(t),o=document.querySelector(`[data-upload-id="${e[0]}"]`);return s===this.getFieldIdFromElement(o)},onDrop:(e,t)=>{this.handleItemDrop(e,t),t.scrollIntoView({behavior:"smooth",block:"center"})},onDragEnd:(e,t)=>{if(t){const t=document.querySelector(`[data-upload-id="${e[0]}"]`),s=this.getFieldIdFromElement(t),o=this.selectionHandlers.get(s);o?.clearSelection()}},previewOptions:{multiOffset:{x:-60,y:-80},singleOffset:{x:-50,y:-60},showCount:!0}})}initListeners(){this.clickHandler=this.handleClick.bind(this),this.changeHandler=this.handleChange.bind(this),document.addEventListener("click",this.clickHandler),document.addEventListener("change",this.changeHandler),this.dragEnterHandler=this.handleExternalDragEnter.bind(this),this.dragLeaveHandler=this.handleExternalDragLeave.bind(this),this.dragOverHandler=this.handleExternalDragOver.bind(this),this.dropHandler=this.handleExternalDrop.bind(this),document.addEventListener("dragenter",this.dragEnterHandler),document.addEventListener("dragleave",this.dragLeaveHandler),document.addEventListener("dragover",this.dragOverHandler),document.addEventListener("drop",this.dropHandler)}handleExternalDragLeave(e){const t=e.target.closest(this.selectors.field.dropZone);t&&!t.contains(e.relatedTarget)&&t.classList.remove("dragover")}handleExternalDragEnter(e){if(!e.dataTransfer.types.includes("Files"))return;const t=e.target.closest(this.selectors.field.dropZone);t&&(e.preventDefault(),t.classList.add("dragover"))}handleExternalDragOver(e){if(!e.dataTransfer.types.includes("Files"))return;e.target.closest(this.selectors.field.dropZone)&&(e.preventDefault(),e.dataTransfer.dropEffect="copy")}handleExternalDrop(e){const t=e.target.closest(this.selectors.field.dropZone);if(!t)return;e.preventDefault(),t.classList.remove("dragover");const s=Array.from(e.dataTransfer.files);if(0===s.length)return;const o=this.getFieldIdFromElement(t);o&&(this.processFiles(o,s),this.a11y.announce(`${s.length} file(s) dropped for upload`))}handleItemDrop(e,t){const s=t.classList.contains("preview");let o=t;if(t.classList.contains("empty-group")){const e=this.getFieldIdFromElement(t),s=this.createGroup(e);if(!s)return;o=s.grid}e.forEach((e=>{s?this.removeFromGroup(e):this.addToGroup(e,o)}));const r=this.getFieldIdFromElement(t);this.schedulePersistance(r);const a=e.length>1?`Moved ${e.length} items`:"Moved item";this.a11y.announce(a)}handleClick(e){if(e.target.matches(this.selectors.field.dropZone)||e.target.closest(this.selectors.field.dropZone)){const t=e.target.closest(this.selectors.field.dropZone);if(t&&!e.target.matches("input, button, a")){const e=t.querySelector(this.selectors.field.input);e?.click()}}const t=e.target.closest("[data-action]");t&&this.handleAction(t)}handleChange(e){const t=this.getFieldIdFromElement(e.target);if(e.target.matches(this.selectors.field.input)){const s=Array.from(e.target.files);s.length>0&&t&&this.processFiles(t,s)}if(t){const s=this.getFieldData(t);"post_group"===s?.config.destination?this.handleGroupMetaChange(e.target):this.queueUploadMeta(e)}}async processFiles(e,t){const s=this.getFieldData(e),o=this.fieldElements.get(e);if(!s||!o)return;o.ui.dropZone&&(o.ui.dropZone.hidden=!0),o.ui.groups?.display&&(o.ui.groups.display.hidden=!1);const r=t.length;let a=0;this.updateUploadProgress(e,0,r,"Processing files...");const i=Array.from(t).map((async t=>{try{const i=`upload_${Date.now()}_${Math.random().toString(36).substr(2,9)}`,n={id:i,attachmentId:null,fieldId:e,status:"local_processing",groupId:null,meta:{originalName:t.name,size:t.size,type:t.type}},l=this.createPreviewUrl(t),d=t.type.startsWith("image/")?await this.processImage(t,s.config.subtype):t;await this.saveBlobData(i,d||t);const c=this.getSubtypeFromMime(t.type),u=this.createUploadElement({id:i,preview:l,meta:n.meta,subtype:c},"post_group"===s.config.destination);return this.showUploadProgress(i,!0),this.updateUploadItemProgress(i,50,"local_processing"),o.ui.preview&&(o.ui.preview.appendChild(u),this.uploadElements.set(i,{element:u,preview:l,location:o.ui.preview})),n.status="processed",await this.uploadStore.save(n),s.uploads.add(i),await this.saveFieldData(s),a++,this.updateUploadProgress(e,a,r,"Processing files..."),this.updateUploadItemProgress(i,100,"processed"),setTimeout((()=>this.showUploadProgress(i,!1)),1e3),i}catch(s){return console.error("Error processing file:",t.name,s),a++,this.updateUploadProgress(e,a,r,"Processing files..."),null}}));await Promise.all(i),this.updateFieldState(e),this.refreshSortable(e),"post_group"!==s.config.destination&&(await this.queueUpload(e),this.maybeLockUploads(e))}async processImage(e,t){const s=this.worker.settings.timeout;return new Promise(((o,r)=>{let a,i=!1;a=setTimeout((()=>{i||(i=!0,this.worker.tasks.delete(t),this.worker.settings.restartAfterTimeout&&this.restartCompressionWorker(),r(new Error(`Processing timeout for ${e.name}`)))}),s),this.worker.tasks.set(t,{file:e,timeoutId:a}),this.handleProcess(e,t).then((e=>{i||(i=!0,clearTimeout(a),this.worker.tasks.delete(t),o(e))})).catch((e=>{i||(i=!0,clearTimeout(a),this.worker.tasks.delete(t),r(e))}))}))}async handleProcess(e,t){if(!e.type.startsWith("image/"))return e;const s=this.getMaxDimension();if(this.shouldUseWorker(e))try{if(this.worker.worker||this.initCompressionWorker(),this.worker.worker)return await this.processWithWorker(e,t,s,.85)}catch(e){console.warn("Worker processing failed, falling back to main thread:",e)}return await this.processOnMainThread(e,s,.85)}async processOnMainThread(e,t,s){return new Promise(((o,r)=>{const a=new Image,i=document.createElement("canvas"),n=i.getContext("2d");let l=null;const d=()=>{a.onload=null,a.onerror=null,l&&(URL.revokeObjectURL(l),l=null),i.width=1,i.height=1,n.clearRect(0,0,1,1)};a.onload=()=>{try{const{width:l,height:c}=this.calculateOptimalDimensions(a,t);i.width=l,i.height=c,n.imageSmoothingEnabled=!0,n.imageSmoothingQuality="high",n.drawImage(a,0,0,l,c);const u=this.getOptimalFormat(e),p=this.getOptimalQuality(e,s);i.toBlob((t=>{if(d(),t){const s=new File([t],this.getProcessedFileName(e,u),{type:u,lastModified:Date.now()});o(s)}else r(new Error("Canvas toBlob failed"))}),u,p)}catch(e){d(),r(new Error(`Canvas processing failed: ${e.message}`))}},a.onerror=()=>{d(),r(new Error(`Failed to load image: ${e.name}`))};try{l=this.createPreviewUrl(e),a.src=l}catch(e){d(),r(new Error(`Failed to create object URL: ${e.message}`))}}))}getOptimalFormat(e){return"image/gif"===e.type||"image/svg+xml"===e.type?e.type:this.supportsWebP()?"image/webp":"image/jpeg"}getOptimalQuality(e,t){return e.size<512e3?Math.max(t,.9):e.size<2097152?t:Math.min(t,.8)}getProcessedFileName(e,t){return e.name.replace(/\.[^/.]+$/,"")+({"image/webp":".webp","image/jpeg":".jpg","image/png":".png","image/gif":".gif"}[t]||".jpg")}getMaxDimension(){const e=window.screen.width,t=window.devicePixelRatio||1;return e*t>2560?2400:e*t>1920?1920:1200}shouldUseWorker(e){return this.worker.worker&&e.size>1048576&&"undefined"!=typeof OffscreenCanvas}async processWithWorker(e,t,s,o){return new Promise(((r,a)=>{if(!this.worker.worker)return void a(new Error("Worker not available"));const i=`${t}_${Date.now()}`,n=t=>{if(t.data.messageId===i)if(this.worker.worker.removeEventListener("message",n),this.worker.worker.removeEventListener("error",l),t.data.success){const s=new File([t.data.blob],this.getProcessedFileName(e,t.data.format||"image/webp"),{type:t.data.format||"image/webp",lastModified:Date.now()});r(s)}else a(new Error(t.data.error||"Worker processing failed"))},l=e=>{this.worker.worker.removeEventListener("message",n),this.worker.worker.removeEventListener("error",l),a(new Error(`Worker error: ${e.message}`))};this.worker.worker.addEventListener("message",n),this.worker.worker.addEventListener("error",l),this.worker.worker.postMessage({messageId:i,file:e,maxDimension:s,quality:o,outputFormat:this.getOptimalFormat(e)})}))}restartCompressionWorker(){this.worker.worker&&(this.worker.worker.terminate(),this.worker.worker=null),this.worker.tasks.clear(),this.worker.restart.count>=this.worker.restart.max?console.error("Max worker restarts reached, disabling worker"):(this.worker.restart.count++,this.initCompressionWorker())}initCompressionWorker(){if(!this.worker.worker&&"undefined"!=typeof Worker)try{const e=new Blob(["\n\t\t\t\tself.onmessage = async function(e) {\n\t\t\t\t\tconst { messageId, file, maxDimension, quality, outputFormat } = e.data;\n\t\t\t\t\ttry {\n\t\t\t\t\t\tconst bitmap = await createImageBitmap(file);\n\t\t\t\t\t\tconst scale = Math.min(maxDimension / bitmap.width, maxDimension / bitmap.height, 1);\n\t\t\t\t\t\tconst width = Math.round(bitmap.width * scale);\n\t\t\t\t\t\tconst height = Math.round(bitmap.height * scale);\n\t\t\t\t\t\tconst canvas = new OffscreenCanvas(width, height);\n\t\t\t\t\t\tconst ctx = canvas.getContext('2d');\n\t\t\t\t\t\tctx.imageSmoothingEnabled = true;\n\t\t\t\t\t\tctx.imageSmoothingQuality = 'high';\n\t\t\t\t\t\tctx.drawImage(bitmap, 0, 0, width, height);\n\t\t\t\t\t\tbitmap.close();\n\t\t\t\t\t\tconst blob = await canvas.convertToBlob({ type: outputFormat, quality: quality });\n\t\t\t\t\t\tself.postMessage({ messageId, success: true, blob: blob, format: outputFormat });\n\t\t\t\t\t} catch (error) {\n\t\t\t\t\t\tself.postMessage({ messageId, success: false, error: error.message });\n\t\t\t\t\t}\n\t\t\t\t};\n\t\t\t"],{type:"application/javascript"});this.worker.worker=new Worker(this.createPreviewUrl(e))}catch(e){console.warn("Failed to initialize compression worker:",e),this.worker.worker=null}}calculateOptimalDimensions(e,t){let{width:s,height:o}=e;if(s<=t&&o<=t)return{width:s,height:o};const r=Math.min(t/s,t/o);return{width:Math.round(s*r),height:Math.round(o*r)}}supportsWebP(){return 0===document.createElement("canvas").toDataURL("image/webp").indexOf("data:image/webp")}createPreviewUrl(e){const t=URL.createObjectURL(e);return this.previewUrls||(this.previewUrls=new Set),this.previewUrls.add(t),t}revokePreviewUrl(e){e?.startsWith("blob:")&&(URL.revokeObjectURL(e),this.previewUrls?.delete(e))}async submitUploads(e){const t=this.getFieldData(e);this.fieldElements.get(e);if(!t?.uploads||0===t.uploads.size)return;let s=Array.from(t.uploads);if(0===s.length)return void this.error.log("No uploads to upload",{component:"UploadManager",action:"submitGroupedUploads",fieldId:e});const o=this.getFieldGroups(e);if(0===o.length)return void this.error.log("No groups created for post_group upload",{component:"UploadManager",action:"submitGroupedUploads",fieldId:e});const r=[],a=new FormData;let i=[];for(const e of o){const t={images:[],fields:{}};for(let[s,o]of Object.entries(e.changes))t.fields[s]=o;const o=s.filter((t=>{const s=this.uploadStore.get(t);return s?.groupId===e.id}));for(const e of o){const s=await this.getBlobData(e);if(s){a.append("files[]",s);const o={upload_id:e,index:i.length},r=this.uploadElements.get(e),n=r?.element?.querySelector('[name="featured"]');n?.checked&&(t.fields.featured=e),t.images.push(o),i.push(e)}}r.push(t)}const n=s.filter((e=>{const t=this.uploadStore.get(e);return!t?.groupId}));for(const e of n){const t={images:[],fields:{}},s=await this.getBlobData(e);if(s){a.append("files[]",s);const o={upload_id:e,index:i.length};t.images.push(o),i.push(e)}r.push(t)}a.append("content",t.config.content),a.append("user",t.config.itemID),a.append("posts",JSON.stringify(r)),a.append("upload_ids",JSON.stringify(i));const l={endpoint:"uploads/groups",method:"POST",data:a,title:`Creating ${r.length} ${t.config.content}${r.length>1?"s":""} from uploads...`,popup:`Creating ${r.length} post${r.length>1?"s":""}...`,canMerge:!1,headers:{action_nonce:jvbSettings.dash},append:"_upload"};try{const e=await this.queue.addToQueue(l);return s.forEach((t=>{const s=this.uploadStore.get(t);s&&(s.operationId=e,s.status="queued",this.uploadStore.save(s),this.updateUploadStatus(t,"queued"))})),t.operationId=e,await this.saveFieldData(t),this.a11y.announce(`Creating ${r.length} post${r.length>1?"s":""} from your uploads`),e}catch(t){throw this.error.log(t,{component:"UploadManager",action:"submitGroupedUploads",fieldId:e}),t}}async queueUpload(e){const t=this.getFieldData(e);if(!t?.uploads||0===t.uploads.size)return;const s=Array.from(t.uploads),o=this.prepareUploadData(t,s);this.a11y.announce("Queuing for upload");const r={endpoint:"uploads",method:"POST",data:o,title:`Uploading ${s.length} file${s.length>1?"s":""} to server...`,popup:`Uploading ${s.length} file${s.length>1?"s":""}...`,canMerge:!1,headers:{action_nonce:jvbSettings.dash},append:"_upload"};try{const e=await this.queue.addToQueue(r);return s.forEach((t=>{const s=this.uploadStore.get(t);s&&(s.operationId=e,s.status="queued",this.uploadStore.save(s),this.updateUploadStatus(t,"queued"))})),t.operationId=e,await this.saveFieldData(t),e}catch(e){throw e}}async prepareUploadData(e,t){const s=new FormData;s.append("content",e.config.content),s.append("mode",e.config.mode),s.append("field_name",e.config.name),s.append("fieldId",e.id),s.append("field_type",e.config.type),s.append("subtype",e.config.subtype),s.append("item_id",e.config.itemID),s.append("destination",e.config.destination||"meta");let o=[];const r=t.map((async e=>{const t=this.uploadStore.get(e);if(!t)return;const r=await this.getBlobData(e);r&&(s.append("files[]",r),o.push(t.id))}));return await Promise.all(r),s.append("upload_ids",JSON.stringify(o)),s}async queueUploadMeta(e){const t=this.getUploadIdFromElement(e.target),s=this.uploadStore.get(t);if(!s)return;if(!this.getFieldData(s.fieldId))return;let o={};o[e.target.name]=e.target.value,s.meta={...s.meta,...o},await this.uploadStore.save(s);let r={};r[s.attachmentId??s.id]=s.meta;const a={endpoint:"uploads/meta",method:"POST",data:r,title:"Updating meta",canMerge:!0,headers:{action_nonce:jvbSettings.dash}};try{await this.queue.addToQueue(a)}catch(e){this.error.log(e,{component:"UploadManager",action:"sendMetaUpdate",uploadId:s.id})}}async handleOperationComplete(e,t){if((e.result?.data||e.serverData?.data||[]).forEach((e=>{const t=this.uploadStore.get(e.upload_id);t&&(t.attachmentId=e.attachment_id,t.status="completed",this.uploadStore.save(t),this.updateUploadStatus(e.upload_id,"completed"))})),!t)return;const s=this.getFieldData(t);if(!s)return;const o=Array.from(s.uploads).filter((e=>{const t=this.uploadStore.get(e);return"completed"===t?.status}));for(const e of o)await this.clearUpload(e,!1),s.uploads.delete(e);0===s.uploads.size?(await this.clearFieldFromStores(t),this.a11y.announce("All uploads completed successfully")):await this.saveFieldData(s),this.updateFieldState(t)}handleOperationFailed(e,t){(e.data instanceof FormData?JSON.parse(e.data.get("upload_ids")||"[]"):e.data.upload_ids||[]).forEach((t=>{const s=this.uploadStore.get(t);s&&(s.status="operation-failed-permanent"===e.status?"failed_permanent":"failed",this.uploadStore.save(s),this.updateUploadStatus(t,s.status))})),t&&this.updateFieldState(t)}async handleOperationCancelled(e){const t=this.getFieldData(e);if(!t)return;const s=t.uploads instanceof Set?Array.from(t.uploads):t.uploads;for(const e of s)await this.clearUpload(e,!1);await this.clearFieldFromStores(e),this.updateFieldState(e),this.a11y.announce("Upload cancelled")}getFieldGroups(e){const t=this.getFieldData(e);return t?.groups?t.groups.map((e=>({id:e.id,uploads:e.uploads||[],changes:e.changes||{}}))):[]}getSelectedRestorationUploads(e){let t=[];return e.querySelectorAll("[type=checkbox]:checked").forEach((e=>{const s=e.closest(".item");s&&t.push({uploadId:s.dataset.uploadId,fieldId:s.dataset.fieldId})})),t}async restoreSelectedUploads(e){const t=new Map;e.forEach((e=>{t.has(e.fieldId)||t.set(e.fieldId,[]),t.get(e.fieldId).push(e.uploadId)}));for(const[e,s]of t.entries()){const t=this.fieldStore.get(e);t&&(t.uploads=s,await this.restoreField(t))}}async restoreField(e){const{config:t,context:s,uploads:o,groups:r,id:a}=e;s?.modalType&&await this.openModalForRestore(s);let i=document.querySelector(`.field.upload[data-field="${t.name}"]`);if(!i){const e=`${t.content}_${t.itemID}_${t.name}`;i=document.querySelector(`.field.upload[data-uploader="${e}"]`)}if(!i)return void console.warn(`Field ${t.name} not found for restoration`,t);let n=i.dataset.uploader;n&&this.fieldElements.has(n)||(n=this.registerUploader(i));const l=this.fieldElements.get(n),d=this.getFieldData(n);if(!l||!d)return void console.error("Failed to register field for restoration");d.state=e.state||"ready",l.ui||(l.ui=this.buildFieldUI(i)),l.ui.groups?.display&&(l.ui.groups.display.hidden=!1),l.ui.dropZone&&(l.ui.dropZone.hidden=!0),r&&r.length>0&&await this.restoreGroups(n,r);const c=o instanceof Set?Array.from(o):Array.isArray(o)?o:[];for(const e of c){const t=this.uploadStore.get(e);t&&await this.restoreUpload(n,t)}await this.saveFieldData(d),this.updateFieldState(n),this.maybeLockUploads(n),this.refreshSortable(n),"direct"===t.mode&&"post_group"!==t.destination&&await this.queueUpload(n)}async restoreUpload(e,t){const s=this.fieldElements.get(e),o=this.getFieldData(e);if(!s||!o)return void console.error("Field not found for upload restoration:",e);const r=await this.getBlobData(t.id);if(!r)return void console.warn("Blob data not found for upload:",t.id);const a=this.createPreviewUrl(r),i=this.getSubtypeFromMime(r.type),n=this.createUploadElement({id:t.id,preview:a,meta:t.meta||{originalName:r.name,size:r.size,type:r.type},subtype:i},"post_group"===o.config.destination);let l;if(t.groupId){const e=this.groupElements.get(t.groupId);if(e?.grid){l=e.grid;const s=o.groups?.find((e=>e.id===t.groupId));s&&(s.uploads||(s.uploads=[]),s.uploads.includes(t.id)||s.uploads.push(t.id))}else l=s.ui.preview,t.groupId=null}else l=s.ui.preview;l?l.appendChild(n):s.ui.preview&&(s.ui.preview.appendChild(n),l=s.ui.preview),this.uploadElements.set(t.id,{element:n,preview:a,location:l}),o.uploads||(o.uploads=new Set),o.uploads.add(t.id),t.status="processed",await this.uploadStore.save(t),l&&this.updateSortableState(l)}async restoreGroups(e,t){const s=this.fieldElements.get(e),o=this.getFieldData(e);if(s&&o){for(const s of t){const t=this.createGroup(e,s.id);if(!t){console.warn("Failed to create group:",s.id);continue}const r=o.groups?.find((e=>e.id===s.id));if(r&&(s.changes&&(r.changes={...s.changes}),s.uploads&&(r.uploads=[...s.uploads]),s.changes)){const e=t.element.querySelector('[name*="post_title"]'),o=t.element.querySelector('[name*="post_excerpt"]');e&&s.changes.post_title&&(e.value=s.changes.post_title),o&&s.changes.post_excerpt&&(o.value=s.changes.post_excerpt)}}await this.saveFieldData(o)}else console.error("Field not found for group restoration:",e)}async openModalForRestore(e){if(!e)return;const{modalType:t,itemId:s}=e;let o=null;switch(t){case"create":o=document.querySelector('[data-action="create"]');break;case"edit":s&&(o=document.querySelector(`[data-action="edit"][data-id="${s}"]`));break;case"bulkEdit":o=document.querySelector('[data-action="bulk-edit"]')}o?(o.click(),await new Promise((e=>setTimeout(e,300)))):console.warn("Modal trigger not found for restoration:",e)}formatBytes(e,t=2){if(0===e)return"0 Bytes";const s=t<0?0:t,o=Math.floor(Math.log(e)/Math.log(1024));return parseFloat((e/Math.pow(1024,o)).toFixed(s))+" "+["Bytes","KB","MB","GB"][o]}async clearUpload(e,t=!0){const s=this.uploadElements.get(e);if(s&&(this.revokePreviewUrl(s.preview),s.element)){const e=s.element.dataset.previewUrl;this.revokePreviewUrl(e),delete s.element.dataset.previewUrl}if(this.uploadElements.delete(e),await this.uploadStore.delete(e),t){const t=this.uploadStore.get(e);t?.fieldId&&await this.schedulePersistance(t.fieldId)}}async clearFieldFromStores(e){const t=this.getFieldData(e);if(t?.uploads){const e=t.uploads instanceof Set?Array.from(t.uploads):t.uploads;for(const t of e)await this.uploadStore.delete(t)}await this.fieldStore.delete(e)}cleanupAllPreviewUrls(){this.previewUrls&&(this.previewUrls.forEach((e=>{try{URL.revokeObjectURL(e)}catch(e){}})),this.previewUrls.clear())}updateFieldState(e){const t=this.fieldElements.get(e),s=this.getFieldData(e);if(!t||!s)return;const o=t.element,r=s.uploads?.size||0,a=t.ui.groups?.container?.querySelectorAll(".upload-group").length>0;o.dataset.hasUploads=r>0?"true":"false",o.dataset.uploadCount=r.toString(),o.dataset.hasGroups=a?"true":"false",t.ui.preview&&t.ui.preview.setAttribute("aria-label",`Upload preview area with ${r} item${1!==r?"s":""}`)}updateUploadProgress(e,t,s,o){const r=this.fieldElements.get(e);if(!r?.ui?.progress?.progress)return;const a=r.ui.progress,i=s>0?t/s*100:0;a.fill&&(a.fill.style.width=`${i}%`),a.text&&(a.text.textContent=o),a.count&&(a.count.textContent=`${t}/${s}`),a.progress.hidden=t===s}updateFieldStatus(e,t){const s=this.getFieldData(e);s&&(s.state=t,this.saveFieldData(s))}updateUploadStatus(e,t){const s=this.uploadStore.get(e);s&&(s.status=t,this.uploadStore.save(s),this.updateUploadUI(e))}updateUploadUI(e){const t=this.uploadElements.get(e),s=this.uploadStore.get(e);if(!s||!t?.element)return;t.element.className=t.element.className.replace(/status-[\w-]+/g,""),t.element.classList.add(`status-${s.status}`);t.element.querySelector(".progress")&&this.updateUploadItemProgress(e,this.getStatusProgress(s.status),s.status)}showUploadProgress(e,t=!0){const s=this.uploadElements.get(e);if(!s?.element)return;const o=s.element.querySelector(".progress");o&&(t?(o.style.removeProperty("animation"),o.hidden=!1):(o.style.animation="fadeOut var(--transition-base)",setTimeout((()=>{o.hidden=!0}),300)))}updateUploadItemProgress(e,t,s=null){const o=this.uploadElements.get(e);if(!o?.element)return;const r=o.element.querySelector(".progress");if(!r)return;const a=r.querySelector(".fill"),i=r.querySelector(".details"),n=r.querySelector(".icon");a&&(a.style.width=`${t}%`),s&&i&&(i.textContent=this.getStatusText(s)),s&&n&&(n.innerHTML=this.getStatusIcon(s).outerHTML)}maybeLockUploads(e){const t=this.fieldElements.get(e),s=this.getFieldData(e);if(!t?.ui?.dropZone||!s)return;if("post_group"===s.config.destination)return;const o=s.uploads?.size||0,r=s.config?.maxFiles||999;t.ui.dropZone.hidden=o>=r,t.element.classList.toggle("at-max-uploads",o>=r)}createGroup(e,t=null){const s=this.getFieldData(e),o=this.fieldElements.get(e);if(!s||!o)return null;t||(t=`group_${Date.now()}_${Math.random().toString(36).substr(2,9)}`);const r=this.createGroupElement(t,e);if(!r)return null;o.ui.groups||(o.ui.groups={groups:new Map,container:null,empty:null,display:null}),o.ui.groups.groups.set(t,r),o.ui.groups.container&&o.ui.groups.empty?o.ui.groups.container.insertBefore(r,o.ui.groups.empty):o.ui.groups.container&&o.ui.groups.container.appendChild(r);const a=r.querySelector(".item-grid.group");if(this.groupElements.set(t,{element:r,grid:a,fieldId:e}),s.groups||(s.groups=[]),s.groups.push({id:t,uploads:[],changes:{}}),this.saveFieldData(s),this.addGroupSelectionHandler(e,t),a){const s=new Sortable(a,{...this.sortableConfig,group:{name:e,pull:!0,put:!0},disabled:!0,onAdd:e=>this.updateSortableState(e.to),onRemove:e=>this.updateSortableState(e.from)});a.sortableInstance=s,this.sortableInstances.set(`${e}-group-${t}`,s)}return{id:t,element:r,grid:a}}createGroupElement(e,t){let s=window.getTemplate("imageGroup");if(!s)return;s.dataset.groupId=e,s.dataset.fieldId=t;let o=window.getTemplate("groupMetadata");const r=s.querySelector(".fields");if(r&&o){r.append(o);const a=r.querySelector('[name="post_title"]'),i=r.querySelector('[name="post_excerpt"]');a&&(a.id=`${e}_title`,a.name=`${e}[post_title]`),i&&(i.id=`${e}_excerpt`,i.name=`${e}[post_excerpt]`);const n=this.getFieldData(t);if(n&&""!==n.config.content){let e=s.querySelector("summary");e&&(e.textContent=n.config.content+" Fields")}}else s.querySelector("details")?.remove();const a=s.querySelector(".item-grid.group");return a&&(a.dataset.groupId=e),s}deleteGroup(e,t=!0){const s=this.groupElements.get(e);if(!s)return;const o=this.getFieldData(s.fieldId);if(!o)return;const r=o.groups?.find((t=>t.id===e));let a=!0;t&&r?.uploads?.length>0&&(a=!window.confirm("Delete uploads in group?")),t&&a&&r?.uploads&&r.uploads.forEach((e=>{this.removeFromGroup(e)})),o.groups&&(o.groups=o.groups.filter((t=>t.id!==e)),this.saveFieldData(o)),s.element&&(s.element.remove(),this.a11y.announce("Group removed")),this.groupElements.delete(e);const i=`${s.fieldId}-group-${e}`,n=this.sortableInstances.get(i);n?.destroy&&n.destroy(),this.sortableInstances.delete(i),this.schedulePersistance(s.fieldId)}addToGroup(e,t=null,s=!0){const o=this.uploadStore.get(e),r=this.uploadElements.get(e);if(!o||!r)return;const a=this.getFieldData(o.fieldId),i=this.fieldElements.get(o.fieldId);if(!a||!i)return;if(!t&&r.location===i.ui.preview||t===r.location)return;if(o.groupId){const t=a.groups?.find((e=>e.id===o.groupId));t&&(t.uploads=t.uploads.filter((t=>t!==e)),0===t.uploads.length&&this.deleteGroup(o.groupId))}const n=r.element.querySelector('[name*="select-item"]');n&&(n.checked=!1);let l=r.element.querySelector('[name="featured"]');if(l&&(l.hidden=!t),!t||t.classList.contains("preview"))t=i.ui.preview,o.groupId=null;else{const s=t.dataset.groupId;l&&(l.name=s+"_"+l.name);const r=a.groups?.find((e=>e.id===s));r&&(r.uploads||(r.uploads=[]),r.uploads.push(e),o.groupId=s)}r.location=t,t.append(r.element),this.uploadStore.save(o),s&&this.saveFieldData(a),this.updateSortableState(t),r.location&&r.location!==t&&this.updateSortableState(r.location)}removeFromGroup(e){const t=this.uploadStore.get(e),s=this.uploadElements.get(e);if(!t||!s)return;const o=this.getFieldData(t.fieldId),r=this.fieldElements.get(t.fieldId);if(!o||!r)return;if(t.groupId){const s=o.groups?.find((e=>e.id===t.groupId));s&&(s.uploads=s.uploads.filter((t=>t!==e)),0===s.uploads.length&&this.deleteGroup(t.groupId,!1)),t.groupId=null}r.ui?.preview&&(r.ui.preview.appendChild(s.element),s.location=r.ui.preview);const a=s.element.querySelector('[name="featured"]');a&&(a.hidden=!0,a.checked=!1),this.uploadStore.save(t),this.updateSortableState(r.ui.preview)}removeUpload(e,t){const s=this.getFieldData(e),o=this.uploadStore.get(t),r=this.uploadElements.get(t);if(!s||!o)return;if(s.uploads?.delete(t),o.groupId){const e=s.groups?.find((e=>e.id===o.groupId));e&&(e.uploads=e.uploads.filter((e=>e!==t)),0===e.uploads.length&&this.deleteGroup(o.groupId))}r?.element?.remove(),this.clearUpload(t),this.saveFieldData(s),this.updateFieldState(e),this.maybeLockUploads(e);const a=this.selectionHandlers.get(e);a&&a.deselect(t),this.a11y.announce("Upload removed")}handleGroupMetaChange(e){const t=this.getGroupFromElement(e);if(!t)return;const s=this.getFieldData(t.fieldId),o=s?.groups?.find((e=>e.id===t.element.dataset.groupId));if(!o)return;o.changes||(o.changes={});let r=e.name;r.includes("group")&&(r=r.replace(`${o.id}_`,"").replace(`${o.id}[`,"").replace("]","")),o.changes[r]=e.value,this.saveFieldData(s),this.schedulePersistance(t.fieldId)}handleAction(e){const t=e.dataset.action,s=this.getFieldIdFromElement(e);switch(t){case"add-to-group":this.handleAddToGroup(e);break;case"delete-group":this.handleDeleteGroup(e);break;case"delete-upload":case"remove-from-group":this.handleRemoveItem(e);break;case"upload":const t=this.fieldElements.get(s);t&&(t.element.closest("details").open=!1,document.body.classList.add("uploading"),this.submitUploads(s));break;case"restore":this.handleRestoreUploads().then((()=>{}));break;case"clear-cache":confirm("Save these uploads for later?")||this.cleanupStoredUploads(),this.cleanupRestore()}}handleAddToGroup(e){const t=e.closest(this.selectors.field.field),s=t?.dataset.uploader;if(!s)return;const o=this.selected.get(s);if(o&&0!==o.size){const e=this.createGroup(s);if(!e)return;o.forEach((t=>{this.addToGroup(t,e.grid)}));const t=this.selectionHandlers.get(s);t?.clearSelection(),this.a11y.announce(`Created group with ${o.size} items`)}else this.createGroup(s);this.schedulePersistance(s)}handleDeleteGroup(e){const t=e.closest(this.selectors.groups.container);if(!t)return;const s=t.dataset.groupId,o=this.getFieldIdFromElement(t);if(!confirm("Delete this group? Items will be moved back to the upload area."))return;t.querySelectorAll(this.selectors.items.item).forEach((e=>{const t=e.dataset.uploadId;this.removeFromGroup(t)})),this.deleteGroup(s),this.a11y.announce("Group deleted, items returned to upload area"),this.schedulePersistance(o)}handleRemoveItem(e){const t=e.closest(this.selectors.items.item);if(!t)return;const s=t.dataset.uploadId,o=this.getFieldIdFromElement(t);confirm("Remove this item?")&&(this.removeUpload(o,s),this.a11y.announce("Item removed"),this.schedulePersistance(o))}addFieldSelectionHandler(e){if(this.selectionHandlers.has(e))return this.selectionHandlers.get(e);const t=this.fieldElements.get(e);if(!t?.element)return;const s=new window.jvbHandleSelection({container:t.element,ui:{selectAll:t.element.querySelector('[name="select-all-uploads"]'),bulkControls:t.element.querySelector(".selection-actions"),count:t.element.querySelector(".selection-count")},itemSelector:"[data-upload-id]",checkboxSelector:'[name*="select-item"]'});return s.subscribe(((t,s)=>{switch(t){case"item-selected":case"item-deselected":case"range-selected":this.selected.set(e,s.selectedItems);break;case"select-all":this.handleSelectAll(s.container,s.selected)}})),this.selectionHandlers.set(e,s),s}addGroupSelectionHandler(e,t){const s=`${e}_${t}`;if(this.selectionHandlers.has(s))return this.selectionHandlers.get(s);const o=this.groupElements.get(t);if(!o?.element)return;const r=new window.jvbHandleSelection({container:o.element,ui:{selectAll:o.element.querySelector(this.selectors.groups.selectAll),bulkControls:o.element.querySelector(this.selectors.groups.actions),count:o.element.querySelector(this.selectors.groups.count)},itemSelector:"[data-upload-id]",checkboxSelector:'[name*="select-item"]'});return r.subscribe(((t,s)=>{switch(t){case"item-selected":case"item-deselected":case"range-selected":this.selected.set(e,s.selectedItems);break;case"select-all":this.handleSelectAll(s.container,s.selected)}})),this.selectionHandlers.set(s,r),r}handleSelectAll(e,t){}getCurrentSelection(e){let t=[];for(let[s,o]of this.selectionHandlers)(e===s||s.includes(e))&&o.selectedItems.size>0&&(t=t.concat([...o.selectedItems]));return t}getFieldData(e){const t=this.fieldStore.get(e);return t?(Array.isArray(t.uploads)?t.uploads=new Set(t.uploads):t.uploads||(t.uploads=new Set),Array.isArray(t.groups)||(t.groups=[]),t):null}async saveFieldData(e){await this.fieldStore.save({...e,timestamp:Date.now()})}determineFieldId(e){return`${e.dataset.content||e.closest("dialog")?.dataset.content||e.closest("form")?.dataset.save||""}_${e.dataset.itemId||e.closest("dialog")?.dataset.itemId||""}_${e.dataset.field||""}`}getFromElement(e,t){const s={field:{selector:this.selectors.field.field,key:"uploader",getRuntimeData:e=>this.fieldElements.get(e),getStoreData:e=>this.getFieldData(e)},upload:{selector:this.selectors.items.item,key:"uploadId",getRuntimeData:e=>this.uploadElements.get(e),getStoreData:e=>this.uploadStore.get(e)},group:{selector:this.selectors.groups.container,key:"groupId",getRuntimeData:e=>this.groupElements.get(e),getStoreData:e=>{const t=this.groupElements.get(e);if(!t)return null;const s=this.getFieldData(t.fieldId);return s?.groups?.find((t=>t.id===e))}}},o=s[t];if(!o)return null;const r=e.closest(o.selector);if(!r)return null;const a=r.dataset[o.key];return{...o.getRuntimeData(a),...o.getStoreData(a)}}getFieldFromElement(e){return this.getFromElement(e,"field")}getUploadFromElement(e){return this.getFromElement(e,"upload")}getGroupFromElement(e){return this.getFromElement(e,"group")}getFieldIdFromElement(e){const t=this.getFromElement(e,"field");return t?.id??null}getUploadIdFromElement(e){const t=this.getFromElement(e,"upload");return t?.id??null}getGroupIdFromElement(e){const t=this.getFromElement(e,"group");return t?.id??null}getSubtypeFromMime(e){return e.startsWith("image/")?"image":e.startsWith("video/")?"video":"document"}getStatusText(e){return this.statusMapping[e]||e}getStatusIcon(e){return window.getIcon(this.queue.icons[e])}getStatusProgress(e){return{local_processing:28,queued:50,uploading:66,pending:75,processing:89,completed:100}[e]||0}getModalType(e){if(!e?.element)return null;if(void 0!==e._cachedModalType)return e._cachedModalType;const t=e.element.closest("dialog");if(!t)return e._cachedModalType=null,null;let s=null;return s=t.classList.contains("edit")?"edit":t.classList.contains("create")?"create":t.classList.contains("bulkEdit")?"bulkEdit":t.className,e._cachedModalType=s,s}createUploadElement(e,t=!1){let s=window.getTemplate("uploadItem");if(!s)return;s.dataset.uploadId=e.id,s.dataset.subtype=e.subtype||"image";let[o,r,a,i,n]=[s.querySelector('[name="featured"]'),s.querySelector("img"),s.querySelector("video"),s.querySelector("label > span"),s.querySelector("details")];switch(o&&(o.value=e.id),e.subtype){case"image":r&&(r.src=e.preview,r.alt=e.meta?.originalName||""),a?.remove(),i?.remove();break;case"video":a&&(a.src=e.preview),r?.remove(),i?.remove();break;case"document":const t=e.meta?.originalName||"",s=t.split(".").pop()?.toLowerCase()||"",o={pdf:"file-pdf",csv:"file-csv",doc:"file-doc",docx:"file-doc",txt:"file-txt",xls:"file-xls",xlsx:"file-xls"},n=window.getIcon(o[s]||"file");i&&(i.innerText=t,i.prepend(n)),r?.remove(),a?.remove()}if(n){let e=window.getTemplate("uploadMeta");e&&n.append(e)}return s.draggable=t,s.querySelectorAll("input").forEach((t=>{let s=t.id;if(s){let o=s+e.id,r=t.parentNode.querySelector(`label[for="${s}"]`);t.id=o,r&&(r.htmlFor=o)}})),s}normalizeFieldData(e){return e?(Array.isArray(e.uploads)?e.uploads=new Set(e.uploads):e.uploads||(e.uploads=new Set),Array.isArray(e.groups)||(e.groups=[]),e.groups=e.groups.map((e=>({...e,uploads:Array.isArray(e.uploads)?e.uploads:[]}))),e):null}schedulePersistance(e){const t=`persist_${e}`;window.debouncer.schedule(t,(()=>this.persistFieldState(e)),1e3)}async persistFieldState(e){const t=this.getFieldData(e);t&&await this.saveFieldData(t)}async saveBlobData(e,t){const s=await t.arrayBuffer(),o=this.uploadStore.get(e)||{id:e};o.blobData={buffer:s,name:t.name,type:t.type,size:t.size,lastModified:t.lastModified||Date.now()},await this.uploadStore.save(o)}async getBlobData(e){const t=this.uploadStore.get(e);if(!t?.blobData)return null;const s=new Blob([t.blobData.buffer],{type:t.blobData.type});return new File([s],t.blobData.name,{type:t.blobData.type,lastModified:t.blobData.lastModified})}handleFieldStoreEvent(e,t){if("data-loaded"===e)this.checkForStoredUploads()}handleUploadStoreEvent(e,t){switch(e){case"data-loaded":break;case"item-saved":this.showSaveIndicator(t.key)}}async checkForStoredUploads(){const e=this.fieldStore.getAll();console.log("Checking for stored uploads...",{fieldStates:e.length,uploadStoreSize:this.uploadStore.data.size});const t=e.filter((e=>{if(!e.uploads)return!1;return(e.uploads instanceof Set?Array.from(e.uploads):Array.isArray(e.uploads)?e.uploads:[]).some((e=>{const t=this.uploadStore.get(e);return t&&!t.operationId&&["completed","processed","local_processing","processed-original"].includes(t.status)}))}));console.log("Found pending fields:",t.length),0!==t.length&&this.showRecoveryNotification(t)}async showRecoveryNotification(e){const t=e.reduce(((e,t)=>e+t.uploads.length),0),s=e.reduce(((e,t)=>e+(t.groups?.length||0)),0);let o,r=window.getTemplate("restoreNotification");if(!r)return void console.error("Restore notification template not found");if(s>0){o=`${s} ${s>1?"groups":"group"} with ${t} ${t>1?"uploads":"upload"} can be restored.`}else o=`${t} upload(s) from ${e.length} field(s) can be recovered.`;const a=r.querySelector(".restore-details");a&&(a.textContent=o);for(const t of e){let e=window.getTemplate("restoreField");if(!e)continue;const s=e.querySelector("h3");s&&(s.textContent=t.config.name||"Unnamed Field");const o=e.querySelector(".item-grid.restore");for(const e of t.uploads){let s=window.getTemplate("uploadItem");if(!s)continue;const r=await this.getBlobData(e.id);if(r)try{const o=this.createPreviewUrl(r);let[a,i,n,l,d]=[s.querySelector('[name="featured"]'),s.querySelector("img"),s.querySelector("video"),s.querySelector("label > span"),s.querySelector("details")];s.dataset.uploadId=e.id,s.dataset.fieldId=t.id;let c=this.getSubtypeFromMime(r.type);switch(s.dataset.subtype=c,c){case"image":[i.src,i.alt]=[o,r.name??e.meta?.originalName??""],n.remove(),l.remove();break;case"video":n.src=o,i.remove(),l.remove();break;case"document":let t;switch(""){case"pdf":t=window.getIcon("file-pdf");break;case"csv":t=window.getIcon("file-csv");break;case"doc":t=window.getIcon("file-doc");break;case"txt":t=window.getIcon("file-txt");break;case"xls":t=window.getIcon("file-xls");break;default:t=window.getIcon("file")}l.innerText=e.originalFile.name,l.prepend(t),i.remove(),n.remove()}s.dataset.previewUrl=o}catch(t){console.warn("Failed to create preview for upload:",e.id,t)}const a=s.querySelector("summary span");a&&(a.textContent=e.meta?.originalName||"Unknown file");const i=s.querySelector("details");i&&e.meta&&(i.textContent=`${this.formatBytes(e.meta.size)} • ${e.meta.type}`),s.querySelectorAll("input").forEach((t=>{let s=t.id;if(s){let o=s+e.id,r=t.parentNode.querySelector(`label[for="${s}"]`);t.id=o,r&&(r.htmlFor=o)}})),o&&o.appendChild(s)}r.querySelector(".wrap").appendChild(o)}document.querySelector(".field.upload").appendChild(r),r=document.querySelector("dialog.restore-uploads"),this.restoreModal=new window.jvbModal(r),this.restoreSelection=new window.jvbHandleSelection({container:r,ui:{selectAll:r.querySelector("#select-all-restore"),count:r.querySelector(".selection-count")}}),this.restoreModal.handleOpen()}async handleRestoreUploads(){let e=document.querySelector("dialog.restore-uploads");if(!e)return;const t=this.getSelectedRestorationUploads(e);0!==t.length&&(await this.restoreSelectedUploads(t),this.cleanupRestore())}showSaveIndicator(e){}cleanupRestore(){this.restoreModal.handleClose(),this.restoreSelection.destroy(),this.restoreSelection=null,this.restoreModal.destroy(),this.restoreModal.modal.remove(),this.restoreModal=null}async cleanupStoredUploads(){await this.fieldStore.clear(),await this.uploadStore.clear()}subscribe(e){return this.subscribers.add(e),()=>this.subscribers.delete(e)}notify(e,t={}){this.subscribers.forEach((s=>{try{s(e,t)}catch(e){console.error("Subscriber error:",e)}}))}destroy(){document.removeEventListener("click",this.clickHandler),document.removeEventListener("change",this.changeHandler),document.removeEventListener("dragenter",this.dragEnterHandler),document.removeEventListener("dragleave",this.dragLeaveHandler),document.removeEventListener("dragover",this.dragOverHandler),document.removeEventListener("drop",this.dropHandler),this.dragController&&this.dragController.destroy(),this.selectionHandlers.forEach((e=>e.destroy())),this.selectionHandlers.clear(),this.cleanupAllPreviewUrls(),this.sortableInstances.forEach((e=>{e?.destroy&&e.destroy()})),this.sortableInstances.clear(),this.uploadElements.clear(),this.fieldElements.clear(),this.groupElements.clear(),this.selected.clear(),this.subscribers.clear()}}document.addEventListener("DOMContentLoaded",(()=>{window.jvbUploads=new e}))})(); |
| | | (()=>{class e{constructor(){this.queue=window.jvbQueue,this.a11y=window.jvbA11y,this.error=window.jvbError,this.fieldStoreReady=!1,this.uploadStoreReady=!1,this.hasCheckedForUploads=!1;const{fields:e,uploads:t}=window.jvbStore.register("uploads",[{storeName:"fields",keyPath:"id",indexes:[{name:"fieldId",keyPath:"fieldId"},{name:"timestamp",keyPath:"timestamp"},{name:"content",keyPath:"content"},{name:"itemId",keyPath:"itemId"},{name:"status",keyPath:"status"}],TTL:6048e5,delayFetch:!0},{storeName:"uploads",keyPath:"id",storeBlobs:!0,indexes:[{name:"fieldId",keyPath:"fieldId"},{name:"status",keyPath:"status"},{name:"groupId",keyPath:"groupId"},{name:"attachmentId",keyPath:"attachmentId"}],delayFetch:!0}]);this.fieldStore=e,this.uploadStore=t,window.jvbUploadBlobs=this.uploadStore,this.fieldStore.subscribe(this.handleFieldStoreEvent.bind(this)),this.uploadStore.subscribe(this.handleUploadStoreEvent.bind(this)),this.uploadElements=new Map,this.fieldElements=new Map,this.groupElements=new Map,this.selected=new Map,this.selectionHandlers=new Map,this.previewUrls=new Set,this.sortableInstances=new Map,this.initWorker(),this.subscribers=new Set,this.selectors={field:{field:"[data-upload-field]",input:'input[type="file"]',dropZone:".file-upload-container",preview:".item-grid.preview",progress:".image-progress"},groups:{container:".upload-group",grid:".item-grid.group",header:".group-header",selectAll:'[name="select-all-group"]',actions:".group-actions",count:".selection-controls .info"},items:{item:"[data-upload-id]",checkbox:'[name*="select-item"]',featured:'[name="featured"]',details:"details"}},this.statusMapping={received:"Image Received",local_processing:"Processing Image...",queued:"Waiting to upload...",uploading:"Uploading to Server",pending:"Successfully sent to server. In line for further processing.",processing:"Processing on server...",completed:"Upload complete!",failed:"Upload failed (will retry)",failed_permanent:"Upload failed permanently"},this.init()}async init(){this.initializeFields(),this.initListeners(),this.queue.subscribe(((e,t)=>{if(!["uploads","uploads/meta","uploads/groups"].includes(t.endpoint))return;const s=t.data instanceof FormData?t.data.get("fieldId"):t.data?.fieldId;switch(e){case"cancel-operation":s&&this.handleOperationCancelled(s);break;case"operation-status":s&&this.updateFieldStatus(s,t.status);break;case"operation-complete":this.handleOperationComplete(t,s);break;case"operation-failed":case"operation-failed-permanent":this.handleOperationFailed(t,s)}})),window.addEventListener("beforeunload",(()=>{this.cleanupAllPreviewUrls()}))}initWorker(){this.worker={worker:null,timeout:null,tasks:new Map,restart:{count:0,max:3},settings:{timeout:1e4,batchSize:1,maxConcurrent:3,restartAfterTimeout:!0}}}initializeFields(){document.querySelectorAll(this.selectors.field.field).forEach((e=>this.registerUploader(e)))}scanFields(e){e.querySelectorAll(this.selectors.field.field).forEach((e=>this.registerUploader(e)))}registerUploader(e){const t=this.determineFieldId(e),s=this.extractFieldConfig(e),o=this.buildFieldUI(e),r={id:t,config:s,uploads:new Set,groups:[],state:"ready",timestamp:Date.now()};return this.fieldStore.save(r),this.fieldElements.set(t,{element:e,ui:o,config:s}),e.dataset.uploader=t,this.addFieldSelectionHandler(t),"single"!==s.type&&this.initSortable(t),t}extractFieldConfig(e){return{destination:e.dataset.destination||"meta",content:e.dataset.content||null,mode:e.dataset.mode||"direct",type:e.dataset.type||"single",name:e.dataset.field,itemID:e.dataset.itemId||0,maxFiles:parseInt(e.dataset.maxFiles)||999,subtype:e.dataset.subtype||"image"}}buildFieldUI(e){let t={field:e,input:e.querySelector(this.selectors.field.input),dropZone:e.querySelector(this.selectors.field.dropZone),preview:e.querySelector(this.selectors.field.preview),progress:{progress:e.querySelector(this.selectors.field.progress),bar:e.querySelector(".bar"),fill:e.querySelector(".fill"),details:e.querySelector(".details"),text:e.querySelector(".details .text"),count:e.querySelector(".details .count")}},s=e.querySelector(".group-display");return s&&(t.groups={display:s,container:e.querySelector(".item-grid.groups"),empty:e.querySelector(".empty-group"),groups:new Map}),t}initSortable(e){if(!window.Sortable)return;!Sortable._multiDragMounted&&Sortable.MultiDrag&&(Sortable.mount(new Sortable.MultiDrag),Sortable._multiDragMounted=!0);const t=this.fieldElements.get(e);if(!t)return;t.element.querySelectorAll(".item-grid.preview, .item-grid.group").forEach((t=>{const s=t.classList.contains("group")?t.closest(".upload-group")?.dataset.groupId:null;this.createSortableForGrid(t,e,s)}));const s=t.element.querySelector(".empty-group");s&&!s.sortableInstance&&(s.sortableInstance=new Sortable(s,{animation:150,draggable:".item",multiDrag:!0,selectedClass:"selected-for-drag",avoidImplicitDeselect:!0,group:{name:e,pull:!1,put:!0},ghostClass:"sortable-ghost",chosenClass:"sortable-chosen",dragClass:"sortable-drag",onEnd:t=>this.handleDrop(t,e)}))}syncSortableSelection(e,t){this.sortableInstances.forEach(((s,o)=>{if(o.startsWith(e)){s.el.querySelectorAll(".item").forEach((e=>{const s=e.dataset.uploadId;t.has(s)?Sortable.utils.select(e):Sortable.utils.deselect(e)}))}}))}handleDrop(e,t){const s=e.to,o=e.from,r=e.items?.length>0?e.items:[e.item],a=r.map((e=>e.dataset.uploadId));switch(this.getDropTargetType(s)){case"empty-group":this.handleDropToEmptyGroup(r,a,t);break;case"preview":default:this.handleDropToPreview(r,a,t);break;case"group":this.handleDropToGroup(r,a,s,o,t)}this.updateSortableState(s),o!==s&&this.updateSortableState(o)}getDropTargetType(e){return e.classList.contains("empty-group")?"empty-group":e.classList.contains("preview")?"preview":e.classList.contains("group")?"group":"unknown"}handleDropToGroup(e,t,s,o,r){try{if(s===o)return void this.handleReorder({to:s,items:e});t.forEach((e=>{this.addToGroup(e,s,!1)})),this.schedulePersistance(r);const a=e.length>1?`Moved ${e.length} items to group`:"Moved item to group";this.a11y.announce(a);const i=this.selectionHandlers.get(r);i?.clearSelection()}catch(t){this.handleDropError(e,r,t)}}handleDropToPreview(e,t,s){try{t.forEach((e=>{this.removeFromGroup(e)})),this.schedulePersistance(s);const o=e.length>1?`Moved ${e.length} items to preview`:"Moved item to preview";this.a11y.announce(o);const r=this.selectionHandlers.get(s);r?.clearSelection()}catch(t){this.handleDropError(e,s,t)}}handleDropError(e,t,s,o="An error occurred"){console.error("Drop error:",s);const r=this.fieldElements.get(t);r?.ui?.preview&&e.forEach((e=>r.ui.preview.appendChild(e))),this.a11y.announce(`${o}. Items returned to preview.`)}handleDropToEmptyGroup(e,t,s){try{const o=this.createGroup(s);if(!o)return void this.handleDropError(e,s,new Error("Group creation failed"),"Failed to create group");e.forEach(((e,s)=>{o.grid.appendChild(e),this.addToGroup(t[s],o.grid,!1)})),this.schedulePersistance(s);const r=e.length>1?`Created group with ${e.length} items`:"Created group with item";this.a11y.announce(r);const a=this.selectionHandlers.get(s);a?.clearSelection()}catch(t){this.handleDropError(e,s,t)}}updateSortableState(e){const t=e?.sortableInstance;t&&t.option("disabled",!1)}refreshSortable(e){const t=this.fieldElements.get(e);if(!t)return;t.element.querySelectorAll(".item-grid.preview, .item-grid.group").forEach((e=>this.updateSortableState(e)))}handleReorder(e){const t=e.to,s=t.closest(".field, .upload");if(!s)return;e.items&&e.items.length>0?e.items:e.item;let o=Array.from(t.querySelectorAll(".item:not(.sortable-ghost):not(.sortable-clone)")).map((e=>e.dataset.uploadId)).filter((e=>e));console.log("Reordered items:",o);let r=s.querySelector('input[type="hidden"]');r&&o.length>0&&(r.value=o.join(","));const a=this.getFieldIdFromElement(t);if(a){const e=this.getFieldData(a);if(t.classList.contains("group")){const s=t.dataset.groupId,r=e?.groups?.find((e=>e.id===s));r&&(r.uploads=o)}this.schedulePersistance(a)}this.a11y.announce("Item reordered"),s.dispatchEvent(new CustomEvent("jvb-items-reordered",{detail:{from:e.from,to:e.to,oldIndex:e.oldIndex,newIndex:e.newIndex,items:o},bubbles:!0}))}initListeners(){this.clickHandler=this.handleClick.bind(this),this.changeHandler=this.handleChange.bind(this),document.addEventListener("click",this.clickHandler),document.addEventListener("change",this.changeHandler),this.dragEnterHandler=this.handleExternalDragEnter.bind(this),this.dragLeaveHandler=this.handleExternalDragLeave.bind(this),this.dragOverHandler=this.handleExternalDragOver.bind(this),this.dropHandler=this.handleExternalDrop.bind(this),document.addEventListener("dragenter",this.dragEnterHandler),document.addEventListener("dragleave",this.dragLeaveHandler),document.addEventListener("dragover",this.dragOverHandler),document.addEventListener("drop",this.dropHandler)}handleExternalDragLeave(e){const t=e.target.closest(this.selectors.field.dropZone);t&&!t.contains(e.relatedTarget)&&t.classList.remove("dragover")}handleExternalDragEnter(e){if(!e.dataTransfer.types.includes("Files"))return;const t=e.target.closest(this.selectors.field.dropZone);t&&(e.preventDefault(),t.classList.add("dragover"))}handleExternalDragOver(e){if(!e.dataTransfer.types.includes("Files"))return;e.target.closest(this.selectors.field.dropZone)&&(e.preventDefault(),e.dataTransfer.dropEffect="copy")}handleExternalDrop(e){const t=e.target.closest(this.selectors.field.dropZone);if(!t)return;e.preventDefault(),t.classList.remove("dragover");const s=Array.from(e.dataTransfer.files);if(0===s.length)return;const o=this.getFieldIdFromElement(t);o&&(this.processFiles(o,s),this.a11y.announce(`${s.length} file(s) dropped for upload`))}handleClick(e){if(e.target.matches(this.selectors.field.dropZone)||e.target.closest(this.selectors.field.dropZone)){const t=e.target.closest(this.selectors.field.dropZone);if(t&&!e.target.matches("input, button, a")){const e=t.querySelector(this.selectors.field.input);e?.click()}}const t=e.target.closest("[data-action]");t&&this.handleAction(t)}handleChange(e){const t=this.getFieldIdFromElement(e.target);if(e.target.matches(this.selectors.field.input)){const s=Array.from(e.target.files);s.length>0&&t&&this.processFiles(t,s)}if(t){const s=this.getFieldData(t);"post_group"===s?.config.destination?this.handleGroupMetaChange(e.target):this.queueUploadMeta(e)}}async processFiles(e,t){const s=this.getFieldData(e),o=this.fieldElements.get(e);if(!s||!o)return;o.ui.dropZone&&(o.ui.dropZone.hidden=!0),o.ui.groups?.display&&(o.ui.groups.display.hidden=!1);const r=t.length;let a=0;this.updateUploadProgress(e,0,r,"Processing files...");const i=Array.from(t).map((async t=>{try{const i=`upload_${Date.now()}_${Math.random().toString(36).substr(2,9)}`,l={id:i,attachmentId:null,fieldId:e,status:"local_processing",groupId:null,meta:{originalName:t.name,size:t.size,type:t.type}};await this.uploadStore.save(l);const n=this.createPreviewUrl(t),d=t.type.startsWith("image/")?await this.processImage(t,s.config.subtype):t;this.showUploadProgress(i,!0),this.updateUploadItemProgress(i,50,"local_processing"),await this.saveBlobData(i,d||t);const c=this.getSubtypeFromMime(t.type),u=this.createUploadElement({id:i,preview:n,meta:l.meta,subtype:c},"post_group"===s.config.destination);o.ui.preview&&(o.ui.preview.appendChild(u),this.uploadElements.set(i,{element:u,preview:n,location:o.ui.preview}));const p=this.uploadStore.get(i);return p&&(p.status="processed",await this.uploadStore.save(p)),s.uploads.add(i),await this.saveFieldData(s),a++,this.updateUploadProgress(e,a,r,"Processing files..."),this.updateUploadItemProgress(i,100,"processed"),setTimeout((()=>this.showUploadProgress(i,!1)),1e3),i}catch(s){return console.error("Error processing file:",t.name,s),a++,this.updateUploadProgress(e,a,r,"Processing files..."),null}}));await Promise.all(i),this.updateFieldState(e),this.refreshSortable(e),"post_group"!==s.config.destination&&(await this.queueUpload(e),this.maybeLockUploads(e))}async processImage(e,t){const s=this.worker.settings.timeout;return new Promise(((o,r)=>{let a,i=!1;a=setTimeout((()=>{i||(i=!0,this.worker.tasks.delete(t),this.worker.settings.restartAfterTimeout&&this.restartCompressionWorker(),r(new Error(`Processing timeout for ${e.name}`)))}),s),this.worker.tasks.set(t,{file:e,timeoutId:a}),this.handleProcess(e,t).then((e=>{i||(i=!0,clearTimeout(a),this.worker.tasks.delete(t),o(e))})).catch((e=>{i||(i=!0,clearTimeout(a),this.worker.tasks.delete(t),r(e))}))}))}async handleProcess(e,t){if(!e.type.startsWith("image/"))return e;const s=this.getMaxDimension();if(this.shouldUseWorker(e))try{if(this.worker.worker||this.initCompressionWorker(),this.worker.worker)return await this.processWithWorker(e,t,s,.85)}catch(e){console.warn("Worker processing failed, falling back to main thread:",e)}return await this.processOnMainThread(e,s,.85)}async processOnMainThread(e,t,s){return new Promise(((o,r)=>{const a=new Image,i=document.createElement("canvas"),l=i.getContext("2d");let n=null;const d=()=>{a.onload=null,a.onerror=null,n&&(URL.revokeObjectURL(n),n=null),i.width=1,i.height=1,l.clearRect(0,0,1,1)};a.onload=()=>{try{const{width:n,height:c}=this.calculateOptimalDimensions(a,t);i.width=n,i.height=c,l.imageSmoothingEnabled=!0,l.imageSmoothingQuality="high",l.drawImage(a,0,0,n,c);const u=this.getOptimalFormat(e),p=this.getOptimalQuality(e,s);i.toBlob((t=>{if(d(),t){const s=new File([t],this.getProcessedFileName(e,u),{type:u,lastModified:Date.now()});o(s)}else r(new Error("Canvas toBlob failed"))}),u,p)}catch(e){d(),r(new Error(`Canvas processing failed: ${e.message}`))}},a.onerror=()=>{d(),r(new Error(`Failed to load image: ${e.name}`))};try{n=this.createPreviewUrl(e),a.src=n}catch(e){d(),r(new Error(`Failed to create object URL: ${e.message}`))}}))}getOptimalFormat(e){return"image/gif"===e.type||"image/svg+xml"===e.type?e.type:this.supportsWebP()?"image/webp":"image/jpeg"}getOptimalQuality(e,t){return e.size<512e3?Math.max(t,.9):e.size<2097152?t:Math.min(t,.8)}getProcessedFileName(e,t){return e.name.replace(/\.[^/.]+$/,"")+({"image/webp":".webp","image/jpeg":".jpg","image/png":".png","image/gif":".gif"}[t]||".jpg")}getMaxDimension(){const e=window.screen.width,t=window.devicePixelRatio||1;return e*t>2560?2400:e*t>1920?1920:1200}shouldUseWorker(e){return this.worker.worker&&e.size>1048576&&"undefined"!=typeof OffscreenCanvas}async processWithWorker(e,t,s,o){return new Promise(((r,a)=>{if(!this.worker.worker)return void a(new Error("Worker not available"));const i=`${t}_${Date.now()}`,l=t=>{if(t.data.messageId===i)if(this.worker.worker.removeEventListener("message",l),this.worker.worker.removeEventListener("error",n),t.data.success){const s=new File([t.data.blob],this.getProcessedFileName(e,t.data.format||"image/webp"),{type:t.data.format||"image/webp",lastModified:Date.now()});r(s)}else a(new Error(t.data.error||"Worker processing failed"))},n=e=>{this.worker.worker.removeEventListener("message",l),this.worker.worker.removeEventListener("error",n),a(new Error(`Worker error: ${e.message}`))};this.worker.worker.addEventListener("message",l),this.worker.worker.addEventListener("error",n),this.worker.worker.postMessage({messageId:i,file:e,maxDimension:s,quality:o,outputFormat:this.getOptimalFormat(e)})}))}restartCompressionWorker(){this.worker.worker&&(this.worker.worker.terminate(),this.worker.worker=null),this.worker.tasks.clear(),this.worker.restart.count>=this.worker.restart.max?console.error("Max worker restarts reached, disabling worker"):(this.worker.restart.count++,this.initCompressionWorker())}initCompressionWorker(){if(!this.worker.worker&&"undefined"!=typeof Worker)try{const e=new Blob(["\n\t\t\t\tself.onmessage = async function(e) {\n\t\t\t\t\tconst { messageId, file, maxDimension, quality, outputFormat } = e.data;\n\t\t\t\t\ttry {\n\t\t\t\t\t\tconst bitmap = await createImageBitmap(file);\n\t\t\t\t\t\tconst scale = Math.min(maxDimension / bitmap.width, maxDimension / bitmap.height, 1);\n\t\t\t\t\t\tconst width = Math.round(bitmap.width * scale);\n\t\t\t\t\t\tconst height = Math.round(bitmap.height * scale);\n\t\t\t\t\t\tconst canvas = new OffscreenCanvas(width, height);\n\t\t\t\t\t\tconst ctx = canvas.getContext('2d');\n\t\t\t\t\t\tctx.imageSmoothingEnabled = true;\n\t\t\t\t\t\tctx.imageSmoothingQuality = 'high';\n\t\t\t\t\t\tctx.drawImage(bitmap, 0, 0, width, height);\n\t\t\t\t\t\tbitmap.close();\n\t\t\t\t\t\tconst blob = await canvas.convertToBlob({ type: outputFormat, quality: quality });\n\t\t\t\t\t\tself.postMessage({ messageId, success: true, blob: blob, format: outputFormat });\n\t\t\t\t\t} catch (error) {\n\t\t\t\t\t\tself.postMessage({ messageId, success: false, error: error.message });\n\t\t\t\t\t}\n\t\t\t\t};\n\t\t\t"],{type:"application/javascript"});this.worker.worker=new Worker(this.createPreviewUrl(e))}catch(e){console.warn("Failed to initialize compression worker:",e),this.worker.worker=null}}calculateOptimalDimensions(e,t){let{width:s,height:o}=e;if(s<=t&&o<=t)return{width:s,height:o};const r=Math.min(t/s,t/o);return{width:Math.round(s*r),height:Math.round(o*r)}}supportsWebP(){return 0===document.createElement("canvas").toDataURL("image/webp").indexOf("data:image/webp")}createPreviewUrl(e){const t=URL.createObjectURL(e);return this.previewUrls||(this.previewUrls=new Set),this.previewUrls.add(t),t}revokePreviewUrl(e){e?.startsWith("blob:")&&(URL.revokeObjectURL(e),this.previewUrls?.delete(e))}async submitUploads(e){const t=this.getFieldData(e);this.fieldElements.get(e);if(!t?.uploads||0===t.uploads.size)return;let s=Array.from(t.uploads);if(0===s.length)return void this.error.log("No uploads to upload",{component:"UploadManager",action:"submitGroupedUploads",fieldId:e});const o=this.getFieldGroups(e);if(0===o.length)return void this.error.log("No groups created for post_group upload",{component:"UploadManager",action:"submitGroupedUploads",fieldId:e});const r=[],a=new FormData;let i=[];for(const e of o){const t={images:[],fields:{}};for(let[s,o]of Object.entries(e.changes))t.fields[s]=o;const o=s.filter((t=>{const s=this.uploadStore.get(t);return s?.groupId===e.id}));for(const e of o){const s=await this.getBlobData(e);if(s){a.append("files[]",s);const o={upload_id:e,index:i.length},r=this.uploadElements.get(e),l=r?.element?.querySelector('[name="featured"]');l?.checked&&(t.fields.featured=e),t.images.push(o),i.push(e)}}r.push(t)}const l=s.filter((e=>{const t=this.uploadStore.get(e);return!t?.groupId}));for(const e of l){const t={images:[],fields:{}},s=await this.getBlobData(e);if(s){a.append("files[]",s);const o={upload_id:e,index:i.length};t.images.push(o),i.push(e)}r.push(t)}a.append("content",t.config.content),a.append("user",t.config.itemID),a.append("posts",JSON.stringify(r)),a.append("upload_ids",JSON.stringify(i));const n={endpoint:"uploads/groups",method:"POST",data:a,title:`Creating ${r.length} ${t.config.content}${r.length>1?"s":""} from uploads...`,popup:`Creating ${r.length} post${r.length>1?"s":""}...`,canMerge:!1,headers:{action_nonce:jvbSettings.dash},append:"_upload"};try{const e=await this.queue.addToQueue(n);return s.forEach((t=>{const s=this.uploadStore.get(t);s&&(s.operationId=e,s.status="queued",this.uploadStore.save(s),this.updateUploadStatus(t,"queued"))})),t.operationId=e,await this.saveFieldData(t),this.a11y.announce(`Creating ${r.length} post${r.length>1?"s":""} from your uploads`),e}catch(t){throw this.error.log(t,{component:"UploadManager",action:"submitGroupedUploads",fieldId:e}),t}}async queueUpload(e){const t=this.getFieldData(e);if(!t?.uploads||0===t.uploads.size)return;const s=Array.from(t.uploads),o=this.prepareUploadData(t,s);this.a11y.announce("Queuing for upload");const r={endpoint:"uploads",method:"POST",data:o,title:`Uploading ${s.length} file${s.length>1?"s":""} to server...`,popup:`Uploading ${s.length} file${s.length>1?"s":""}...`,canMerge:!1,headers:{action_nonce:jvbSettings.dash},append:"_upload"};try{const e=await this.queue.addToQueue(r);return s.forEach((t=>{const s=this.uploadStore.get(t);s&&(s.operationId=e,s.status="queued",this.uploadStore.save(s),this.updateUploadStatus(t,"queued"))})),t.operationId=e,await this.saveFieldData(t),e}catch(e){throw e}}async prepareUploadData(e,t){const s=new FormData;s.append("content",e.config.content),s.append("mode",e.config.mode),s.append("field_name",e.config.name),s.append("fieldId",e.id),s.append("field_type",e.config.type),s.append("subtype",e.config.subtype),s.append("item_id",e.config.itemID),s.append("destination",e.config.destination||"meta");let o=[];const r=t.map((async e=>{const t=this.uploadStore.get(e);if(!t)return;const r=await this.getBlobData(e);r&&(s.append("files[]",r),o.push(t.id))}));return await Promise.all(r),s.append("upload_ids",JSON.stringify(o)),s}async queueUploadMeta(e){const t=this.getUploadIdFromElement(e.target),s=this.uploadStore.get(t);if(!s)return;if(!this.getFieldData(s.fieldId))return;let o={};o[e.target.name]=e.target.value,s.meta={...s.meta,...o},await this.uploadStore.save(s);let r={};r[s.attachmentId??s.id]=s.meta;const a={endpoint:"uploads/meta",method:"POST",data:r,title:"Updating meta",canMerge:!0,headers:{action_nonce:jvbSettings.dash}};try{await this.queue.addToQueue(a)}catch(e){this.error.log(e,{component:"UploadManager",action:"sendMetaUpdate",uploadId:s.id})}}async handleOperationComplete(e,t){if((e.result?.data||e.serverData?.data||[]).forEach((e=>{const t=this.uploadStore.get(e.upload_id);t&&(t.attachmentId=e.attachment_id,t.status="completed",this.uploadStore.save(t),this.updateUploadStatus(e.upload_id,"completed"))})),!t)return;const s=this.getFieldData(t);if(!s)return;const o=Array.from(s.uploads).filter((e=>{const t=this.uploadStore.get(e);return"completed"===t?.status}));for(const e of o)await this.clearUpload(e,!1),s.uploads.delete(e);0===s.uploads.size?(await this.clearFieldFromStores(t),this.a11y.announce("All uploads completed successfully")):await this.saveFieldData(s),this.updateFieldState(t)}handleOperationFailed(e,t){(e.data instanceof FormData?JSON.parse(e.data.get("upload_ids")||"[]"):e.data.upload_ids||[]).forEach((t=>{const s=this.uploadStore.get(t);s&&(s.status="operation-failed-permanent"===e.status?"failed_permanent":"failed",this.uploadStore.save(s),this.updateUploadStatus(t,s.status))})),t&&this.updateFieldState(t)}async handleOperationCancelled(e){const t=this.getFieldData(e);if(!t)return;const s=t.uploads instanceof Set?Array.from(t.uploads):t.uploads;for(const e of s)await this.clearUpload(e,!1);await this.clearFieldFromStores(e),this.updateFieldState(e),this.a11y.announce("Upload cancelled")}getFieldGroups(e){const t=this.getFieldData(e);return t?.groups?t.groups.map((e=>({id:e.id,uploads:e.uploads||[],changes:e.changes||{}}))):[]}getSelectedRestorationUploads(e){let t=[];return e.querySelectorAll("[type=checkbox]:checked").forEach((e=>{const s=e.closest(".item");s&&t.push({uploadId:s.dataset.uploadId,fieldId:s.dataset.fieldId})})),t}async restoreSelectedUploads(e){const t=new Map;e.forEach((e=>{t.has(e.fieldId)||t.set(e.fieldId,[]),t.get(e.fieldId).push(e.uploadId)}));for(const[e,s]of t.entries()){const t=this.fieldStore.get(e);t&&(t.uploads=s,await this.restoreField(t))}}async restoreField(e){const{config:t,context:s,uploads:o,groups:r,id:a}=e;s?.modalType&&await this.openModalForRestore(s);let i=document.querySelector(`.field.upload[data-field="${t.name}"]`);if(!i){const e=`${t.content}_${t.itemID}_${t.name}`;i=document.querySelector(`.field.upload[data-uploader="${e}"]`)}if(!i)return void console.warn(`Field ${t.name} not found for restoration`,t);let l=i.dataset.uploader;l&&this.fieldElements.has(l)||(l=this.registerUploader(i));const n=this.fieldElements.get(l),d=this.getFieldData(l);if(!n||!d)return void console.error("Failed to register field for restoration");d.state=e.state||"ready",n.ui||(n.ui=this.buildFieldUI(i)),n.ui.groups?.display&&(n.ui.groups.display.hidden=!1),n.ui.dropZone&&(n.ui.dropZone.hidden=!0),r&&r.length>0&&await this.restoreGroups(l,r);const c=o instanceof Set?Array.from(o):Array.isArray(o)?o:[];for(const e of c){const t=this.uploadStore.get(e);t&&await this.restoreUpload(l,t)}await this.saveFieldData(d),this.updateFieldState(l),this.maybeLockUploads(l),this.refreshSortable(l),"direct"===t.mode&&"post_group"!==t.destination&&await this.queueUpload(l)}async restoreUpload(e,t){const s=this.fieldElements.get(e),o=this.getFieldData(e);if(!s||!o)return void console.error("Field not found for upload restoration:",e);const r=await this.getBlobData(t.id);if(!r)return void console.warn("Blob data not found for upload:",t.id);const a=this.createPreviewUrl(r),i=this.getSubtypeFromMime(r.type),l=this.createUploadElement({id:t.id,preview:a,meta:t.meta||{originalName:r.name,size:r.size,type:r.type},subtype:i},"post_group"===o.config.destination);let n;if(t.groupId){const e=this.groupElements.get(t.groupId);if(e?.grid){n=e.grid;const s=o.groups?.find((e=>e.id===t.groupId));s&&(s.uploads||(s.uploads=[]),s.uploads.includes(t.id)||s.uploads.push(t.id))}else n=s.ui.preview,t.groupId=null}else n=s.ui.preview;n?n.appendChild(l):s.ui.preview&&(s.ui.preview.appendChild(l),n=s.ui.preview),this.uploadElements.set(t.id,{element:l,preview:a,location:n}),o.uploads||(o.uploads=new Set),o.uploads.add(t.id),t.status="processed",await this.uploadStore.save(t),n&&this.updateSortableState(n)}async restoreGroups(e,t){const s=this.fieldElements.get(e),o=this.getFieldData(e);if(s&&o){for(const s of t){const t=this.createGroup(e,s.id);if(!t){console.warn("Failed to create group:",s.id);continue}const r=o.groups?.find((e=>e.id===s.id));if(r&&(s.changes&&(r.changes={...s.changes}),s.uploads&&(r.uploads=[...s.uploads]),s.changes)){const e=t.element.querySelector('[name*="post_title"]'),o=t.element.querySelector('[name*="post_excerpt"]');e&&s.changes.post_title&&(e.value=s.changes.post_title),o&&s.changes.post_excerpt&&(o.value=s.changes.post_excerpt)}}await this.saveFieldData(o)}else console.error("Field not found for group restoration:",e)}async openModalForRestore(e){if(!e)return;const{modalType:t,itemId:s}=e;let o=null;switch(t){case"create":o=document.querySelector('[data-action="create"]');break;case"edit":s&&(o=document.querySelector(`[data-action="edit"][data-id="${s}"]`));break;case"bulkEdit":o=document.querySelector('[data-action="bulk-edit"]')}o?(o.click(),await new Promise((e=>setTimeout(e,300)))):console.warn("Modal trigger not found for restoration:",e)}formatBytes(e,t=2){if(0===e)return"0 Bytes";const s=t<0?0:t,o=Math.floor(Math.log(e)/Math.log(1024));return parseFloat((e/Math.pow(1024,o)).toFixed(s))+" "+["Bytes","KB","MB","GB"][o]}async clearUpload(e,t=!0){const s=this.uploadElements.get(e);if(s&&(this.revokePreviewUrl(s.preview),s.element)){const e=s.element.dataset.previewUrl;this.revokePreviewUrl(e),delete s.element.dataset.previewUrl}if(this.uploadElements.delete(e),await this.uploadStore.delete(e),t){const t=this.uploadStore.get(e);t?.fieldId&&await this.schedulePersistance(t.fieldId)}}async clearFieldFromStores(e){const t=this.getFieldData(e);if(t?.uploads){const e=t.uploads instanceof Set?Array.from(t.uploads):t.uploads;for(const t of e)await this.uploadStore.delete(t)}await this.fieldStore.delete(e)}cleanupAllPreviewUrls(){this.previewUrls&&(this.previewUrls.forEach((e=>{try{URL.revokeObjectURL(e)}catch(e){}})),this.previewUrls.clear())}updateFieldState(e){const t=this.fieldElements.get(e),s=this.getFieldData(e);if(!t||!s)return;const o=t.element,r=s.uploads?.size||0,a=t.ui.groups?.container?.querySelectorAll(".upload-group").length>0;o.dataset.hasUploads=r>0?"true":"false",o.dataset.uploadCount=r.toString(),o.dataset.hasGroups=a?"true":"false",t.ui.preview&&t.ui.preview.setAttribute("aria-label",`Upload preview area with ${r} item${1!==r?"s":""}`)}updateUploadProgress(e,t,s,o){const r=this.fieldElements.get(e);if(!r?.ui?.progress?.progress)return;const a=r.ui.progress,i=s>0?t/s*100:0;a.fill&&(a.fill.style.width=`${i}%`),a.text&&(a.text.textContent=o),a.count&&(a.count.textContent=`${t}/${s}`),a.progress.hidden=t===s}updateFieldStatus(e,t){const s=this.getFieldData(e);s&&(s.state=t,this.saveFieldData(s))}updateUploadStatus(e,t){const s=this.uploadStore.get(e);s&&(s.status=t,this.uploadStore.save(s),this.updateUploadUI(e))}updateUploadUI(e){const t=this.uploadElements.get(e),s=this.uploadStore.get(e);if(!s||!t?.element)return;t.element.className=t.element.className.replace(/status-[\w-]+/g,""),t.element.classList.add(`status-${s.status}`);t.element.querySelector(".progress")&&this.updateUploadItemProgress(e,this.getStatusProgress(s.status),s.status)}showUploadProgress(e,t=!0){const s=this.uploadElements.get(e);if(!s?.element)return;const o=s.element.querySelector(".progress");o&&(t?(o.style.removeProperty("animation"),o.hidden=!1):(o.style.animation="fadeOut var(--transition-base)",setTimeout((()=>{o.hidden=!0}),300)))}updateUploadItemProgress(e,t,s=null){const o=this.uploadElements.get(e);if(!o?.element)return;const r=o.element.querySelector(".progress");if(!r)return;const a=r.querySelector(".fill"),i=r.querySelector(".details"),l=r.querySelector(".icon");a&&(a.style.width=`${t}%`),s&&i&&(i.textContent=this.getStatusText(s)),s&&l&&(l.innerHTML=this.getStatusIcon(s).outerHTML)}maybeLockUploads(e){const t=this.fieldElements.get(e),s=this.getFieldData(e);if(!t?.ui?.dropZone||!s)return;const o=s.uploads?.size||0,r="post_group"===s.config.destination?20:s.config?.maxFiles||999;t.ui.dropZone.hidden=o>=r,t.element.classList.toggle("at-max-uploads",o>=r),"post_group"===s.config.destination&&o>=r&&this.a11y.announce("Maximum of 20 uploads reached. Please submit current uploads before adding more.")}createSortableForGrid(e,t,s=null){if(!e||e.sortableInstance)return;const o=new Sortable(e,{animation:150,draggable:".item",multiDrag:!0,selectedClass:"selected-for-drag",avoidImplicitDeselect:!0,group:{name:t,pull:!0,put:!0},ghostClass:"sortable-ghost",chosenClass:"sortable-chosen",dragClass:"sortable-drag",onEnd:e=>this.handleDrop(e,t),onSelect:e=>{const t=e.item.querySelector('[name*="select-item"]');t&&!t.checked&&(t.checked=!0,t.dispatchEvent(new Event("change",{bubbles:!0})))},onDeselect:e=>{const t=e.item.querySelector('[name*="select-item"]');t&&t.checked&&(t.checked=!1,t.dispatchEvent(new Event("change",{bubbles:!0})))},onAdd:e=>this.updateSortableState(e.to),onRemove:e=>this.updateSortableState(e.from)});e.sortableInstance=o;const r=s?`${t}-group-${s}`:`${t}-preview`;return this.sortableInstances.set(r,o),o}createGroup(e,t=null){const s=this.getFieldData(e),o=this.fieldElements.get(e);if(!s||!o)return null;t||(t=`group_${Date.now()}_${Math.random().toString(36).substr(2,9)}`);const r=this.createGroupElement(t,e);if(!r)return null;o.ui.groups||(o.ui.groups={groups:new Map,container:null,empty:null,display:null}),o.ui.groups.groups.set(t,r),o.ui.groups.container&&o.ui.groups.empty?o.ui.groups.container.insertBefore(r,o.ui.groups.empty):o.ui.groups.container&&o.ui.groups.container.appendChild(r);const a=r.querySelector(".item-grid.group");this.groupElements.set(t,{element:r,grid:a,fieldId:e}),s.groups||(s.groups=[]);return s.groups.find((e=>e.id===t))||(s.groups.push({id:t,uploads:[],changes:{}}),this.saveFieldData(s)),this.addGroupSelectionHandler(e,t),a&&this.createSortableForGrid(a,e,t),{id:t,element:r,grid:a}}createGroupElement(e,t){let s=window.getTemplate("imageGroup");if(!s)return;s.dataset.groupId=e,s.dataset.fieldId=t;let o=window.getTemplate("groupMetadata");const r=s.querySelector(".fields");if(r&&o){r.append(o);const a=r.querySelector('[name="post_title"]'),i=r.querySelector('[name="post_excerpt"]');a&&(a.id=`${e}_title`,a.name=`${e}[post_title]`),i&&(i.id=`${e}_excerpt`,i.name=`${e}[post_excerpt]`);const l=this.getFieldData(t);if(l&&""!==l.config.content){let e=s.querySelector("summary");e&&(e.textContent=l.config.content+" Fields")}}else s.querySelector("details")?.remove();const a=s.querySelector(".item-grid.group");return a&&(a.dataset.groupId=e),s}deleteGroup(e,t=!0){const s=this.groupElements.get(e);if(!s)return;const o=this.getFieldData(s.fieldId);if(!o)return;const r=o.groups?.find((t=>t.id===e));let a=!0;t&&r?.uploads?.length>0&&(a=!window.confirm("Delete uploads in group?")),t&&a&&r?.uploads&&r.uploads.forEach((e=>{this.removeFromGroup(e)})),o.groups&&(o.groups=o.groups.filter((t=>t.id!==e)),this.saveFieldData(o)),s.element&&(s.element.remove(),this.a11y.announce("Group removed")),this.groupElements.delete(e);const i=`${s.fieldId}-group-${e}`,l=this.sortableInstances.get(i);l?.destroy&&l.destroy(),this.sortableInstances.delete(i),this.schedulePersistance(s.fieldId)}addToGroup(e,t=null,s=!0){const o=this.uploadStore.get(e),r=this.uploadElements.get(e);if(!o||!r)return;const a=this.getFieldData(o.fieldId),i=this.fieldElements.get(o.fieldId);if(!a||!i)return;if(!t&&r.location===i.ui.preview||t===r.location)return;if(o.groupId){const t=a.groups?.find((e=>e.id===o.groupId));t&&(t.uploads=t.uploads.filter((t=>t!==e)),0===t.uploads.length&&this.deleteGroup(o.groupId))}const l=r.element.querySelector('[name*="select-item"]');l&&(l.checked=!1);let n=r.element.querySelector('[name="featured"]');if(n&&(n.hidden=!t),!t||t.classList.contains("preview"))t=i.ui.preview,o.groupId=null;else{const s=t.dataset.groupId;n&&(n.name=s+"_"+n.name);const r=a.groups?.find((e=>e.id===s));r&&(r.uploads||(r.uploads=[]),r.uploads.push(e),o.groupId=s)}r.location=t,t.append(r.element),this.uploadStore.save(o),s&&this.saveFieldData(a),this.updateSortableState(t),r.location&&r.location!==t&&this.updateSortableState(r.location)}removeFromGroup(e){const t=this.uploadStore.get(e),s=this.uploadElements.get(e);if(!t||!s)return;const o=this.getFieldData(t.fieldId),r=this.fieldElements.get(t.fieldId);if(!o||!r)return;if(t.groupId){const s=o.groups?.find((e=>e.id===t.groupId));s&&(s.uploads=s.uploads.filter((t=>t!==e)),0===s.uploads.length&&this.deleteGroup(t.groupId,!1)),t.groupId=null}r.ui?.preview&&(r.ui.preview.appendChild(s.element),s.location=r.ui.preview);const a=s.element.querySelector('[name="featured"]');a&&(a.hidden=!0,a.checked=!1),this.uploadStore.save(t),this.updateSortableState(r.ui.preview)}removeUpload(e,t){const s=this.getFieldData(e),o=this.uploadStore.get(t),r=this.uploadElements.get(t);if(!s||!o)return;if(s.uploads?.delete(t),o.groupId){const e=s.groups?.find((e=>e.id===o.groupId));e&&(e.uploads=e.uploads.filter((e=>e!==t)),0===e.uploads.length&&this.deleteGroup(o.groupId))}r?.element?.remove(),this.clearUpload(t),this.saveFieldData(s),this.updateFieldState(e),this.maybeLockUploads(e);const a=this.selectionHandlers.get(e);a&&a.deselect(t),this.a11y.announce("Upload removed")}handleGroupMetaChange(e){const t=this.getGroupFromElement(e);if(!t)return;const s=this.getFieldData(t.fieldId),o=s?.groups?.find((e=>e.id===t.element.dataset.groupId));if(!o)return;o.changes||(o.changes={});let r=e.name;r.includes("group")&&(r=r.replace(`${o.id}_`,"").replace(`${o.id}[`,"").replace("]","")),o.changes[r]=e.value,this.saveFieldData(s),this.schedulePersistance(t.fieldId)}handleAction(e){const t=e.dataset.action,s=this.getFieldIdFromElement(e);switch(t){case"add-to-group":this.handleAddToGroup(e);break;case"delete-group":this.handleDeleteGroup(e);break;case"delete-upload":case"remove-from-group":this.handleRemoveItem(e);break;case"upload":const t=this.fieldElements.get(s);t&&(t.element.closest("details").open=!1,document.body.classList.add("uploading"),this.submitUploads(s));break;case"restore":this.handleRestoreUploads().then((()=>{}));break;case"restore-all":this.handleRestoreAll().then((()=>{}));break;case"clear-cache":confirm("Save these uploads for later?")||this.cleanupStoredUploads(),this.cleanupRestore()}}handleAddToGroup(e){const t=e.closest(this.selectors.field.field),s=t?.dataset.uploader;if(!s)return;const o=this.selected.get(s);if(o&&0!==o.size){const e=this.createGroup(s);if(!e)return;o.forEach((t=>{this.addToGroup(t,e.grid)}));const t=this.selectionHandlers.get(s);t?.clearSelection(),this.a11y.announce(`Created group with ${o.size} items`)}else this.createGroup(s);this.schedulePersistance(s)}handleDeleteGroup(e){const t=e.closest(this.selectors.groups.container);if(!t)return;const s=t.dataset.groupId,o=this.getFieldIdFromElement(t);if(!confirm("Delete this group? Items will be moved back to the upload area."))return;t.querySelectorAll(this.selectors.items.item).forEach((e=>{const t=e.dataset.uploadId;this.removeFromGroup(t)})),this.deleteGroup(s),this.a11y.announce("Group deleted, items returned to upload area"),this.schedulePersistance(o)}handleRemoveItem(e){const t=e.closest(this.selectors.items.item);if(!t)return;const s=t.dataset.uploadId,o=this.getFieldIdFromElement(t);confirm("Remove this item?")&&(this.removeUpload(o,s),this.a11y.announce("Item removed"),this.schedulePersistance(o))}addFieldSelectionHandler(e){if(this.selectionHandlers.has(e))return this.selectionHandlers.get(e);const t=this.fieldElements.get(e);if(!t?.element)return;const s=new window.jvbHandleSelection({container:t.element,ui:{selectAll:t.element.querySelector('[name="select-all-uploads"]'),bulkControls:t.element.querySelector(".selection-actions"),count:t.element.querySelector(".selection-count")},itemSelector:"[data-upload-id]",checkboxSelector:'[name*="select-item"]'});return s.subscribe(((t,s)=>{switch(t){case"item-selected":case"item-deselected":case"range-selected":this.syncSortableSelection(e,s.selectedItems),this.selected.set(e,s.selectedItems);break;case"select-all":this.handleSelectAll(s.container,s.selected)}})),this.selectionHandlers.set(e,s),s}addGroupSelectionHandler(e,t){const s=`${e}_${t}`;if(this.selectionHandlers.has(s))return this.selectionHandlers.get(s);const o=this.groupElements.get(t);if(!o?.element)return;const r=new window.jvbHandleSelection({container:o.element,ui:{selectAll:o.element.querySelector(this.selectors.groups.selectAll),bulkControls:o.element.querySelector(this.selectors.groups.actions),count:o.element.querySelector(this.selectors.groups.count)},itemSelector:"[data-upload-id]",checkboxSelector:'[name*="select-item"]'});return r.subscribe(((t,s)=>{switch(t){case"item-selected":case"item-deselected":case"range-selected":this.selected.set(e,s.selectedItems);break;case"select-all":this.handleSelectAll(s.container,s.selected)}})),this.selectionHandlers.set(s,r),r}handleSelectAll(e,t){}getCurrentSelection(e){let t=[];for(let[s,o]of this.selectionHandlers)(e===s||s.includes(e))&&o.selectedItems.size>0&&(t=t.concat([...o.selectedItems]));return t}getFieldData(e){const t=this.fieldStore.get(e);return t?(Array.isArray(t.uploads)?t.uploads=new Set(t.uploads):t.uploads||(t.uploads=new Set),Array.isArray(t.groups)||(t.groups=[]),t):null}async saveFieldData(e){console.log("💾 Saving:",e.id,{uploads:e.uploads?.size,groups:e.groups?.length}),await this.fieldStore.save({...e,timestamp:Date.now()})}determineFieldId(e){return`${e.dataset.content||e.closest("dialog")?.dataset.content||e.closest("form")?.dataset.save||""}_${e.dataset.itemId||e.closest("dialog")?.dataset.itemId||""}_${e.dataset.field||""}`}getFromElement(e,t){const s={field:{selector:this.selectors.field.field,key:"uploader",getRuntimeData:e=>this.fieldElements.get(e),getStoreData:e=>this.getFieldData(e)},upload:{selector:this.selectors.items.item,key:"uploadId",getRuntimeData:e=>this.uploadElements.get(e),getStoreData:e=>this.uploadStore.get(e)},group:{selector:this.selectors.groups.container,key:"groupId",getRuntimeData:e=>this.groupElements.get(e),getStoreData:e=>{const t=this.groupElements.get(e);if(!t)return null;const s=this.getFieldData(t.fieldId);return s?.groups?.find((t=>t.id===e))}}},o=s[t];if(!o)return null;const r=e.closest(o.selector);if(!r)return null;const a=r.dataset[o.key];return{...o.getRuntimeData(a),...o.getStoreData(a)}}getFieldFromElement(e){return this.getFromElement(e,"field")}getUploadFromElement(e){return this.getFromElement(e,"upload")}getGroupFromElement(e){return this.getFromElement(e,"group")}getFieldIdFromElement(e){const t=this.getFromElement(e,"field");return t?.id??null}getUploadIdFromElement(e){const t=this.getFromElement(e,"upload");return t?.id??null}getGroupIdFromElement(e){const t=this.getFromElement(e,"group");return t?.id??null}getSubtypeFromMime(e){return e.startsWith("image/")?"image":e.startsWith("video/")?"video":"document"}getStatusText(e){return this.statusMapping[e]||e}getStatusIcon(e){return window.getIcon(this.queue.icons[e])}getStatusProgress(e){return{local_processing:28,queued:50,uploading:66,pending:75,processing:89,completed:100}[e]||0}getModalType(e){if(!e?.element)return null;if(void 0!==e._cachedModalType)return e._cachedModalType;const t=e.element.closest("dialog");if(!t)return e._cachedModalType=null,null;let s=null;return s=t.classList.contains("edit")?"edit":t.classList.contains("create")?"create":t.classList.contains("bulkEdit")?"bulkEdit":t.className,e._cachedModalType=s,s}createUploadElement(e,t=!1){let s=window.getTemplate("uploadItem");if(!s)return;s.dataset.uploadId=e.id,s.dataset.subtype=e.subtype||"image";let[o,r,a,i,l]=[s.querySelector('[name="featured"]'),s.querySelector("img"),s.querySelector("video"),s.querySelector("label > span"),s.querySelector("details")];switch(o&&(o.value=e.id),e.subtype){case"image":r&&(r.src=e.preview,r.alt=e.meta?.originalName||""),a?.remove(),i?.remove();break;case"video":a&&(a.src=e.preview),r?.remove(),i?.remove();break;case"document":const t=e.meta?.originalName||"",s=t.split(".").pop()?.toLowerCase()||"",o={pdf:"file-pdf",csv:"file-csv",doc:"file-doc",docx:"file-doc",txt:"file-txt",xls:"file-xls",xlsx:"file-xls"},l=window.getIcon(o[s]||"file");i&&(i.innerText=t,i.prepend(l)),r?.remove(),a?.remove()}if(l){let e=window.getTemplate("uploadMeta");e&&l.append(e)}return s.draggable=t,s.querySelectorAll("input").forEach((t=>{let s=t.id;if(s){let o=s+e.id,r=t.parentNode.querySelector(`label[for="${s}"]`);t.id=o,r&&(r.htmlFor=o)}})),s}normalizeFieldData(e){return e?(Array.isArray(e.uploads)?e.uploads=new Set(e.uploads):e.uploads||(e.uploads=new Set),Array.isArray(e.groups)||(e.groups=[]),e.groups=e.groups.map((e=>({...e,uploads:Array.isArray(e.uploads)?e.uploads:[]}))),e):null}schedulePersistance(e){const t=`persist_${e}`;window.debouncer.schedule(t,(()=>this.persistFieldState(e)),250)}async persistFieldState(e){const t=this.getFieldData(e);t&&await this.saveFieldData(t)}async saveBlobData(e,t){const s=await t.arrayBuffer(),o=this.uploadStore.get(e)||{id:e};o.blobData={buffer:s,name:t.name,type:t.type,size:t.size,lastModified:t.lastModified||Date.now()},await this.uploadStore.save(o)}async getBlobData(e){const t=this.uploadStore.get(e);if(!t?.blobData)return null;const s=new Blob([t.blobData.buffer],{type:t.blobData.type});return new File([s],t.blobData.name,{type:t.blobData.type,lastModified:t.blobData.lastModified})}handleFieldStoreEvent(e,t){if("data-loaded"===e)this.fieldStoreReady=!0,this.checkIfBothStoresReady()}handleUploadStoreEvent(e,t){switch(e){case"data-loaded":this.uploadStoreReady=!0,this.checkIfBothStoresReady();break;case"item-saved":this.showSaveIndicator(t.key)}}checkIfBothStoresReady(){this.fieldStoreReady&&this.uploadStoreReady&&!this.hasCheckedForUploads&&(this.hasCheckedForUploads=!0,this.checkForStoredUploads())}async checkForStoredUploads(){const e=this.fieldStore.getAll();console.log("Checking for stored uploads...",{fieldStates:e.length,uploadStoreSize:this.uploadStore.data.size}),console.log(this.uploadStore.getAll()),console.log(this.fieldStore.getAll());const t=e.filter((e=>{if(!e.uploads)return!1;return(e.uploads instanceof Set?Array.from(e.uploads):Array.isArray(e.uploads)?e.uploads:[]).some((e=>{const t=this.uploadStore.get(e);return t&&!t.operationId&&["completed","processed","local_processing","processed-original"].includes(t.status)}))}));console.log("Found pending fields:",t.length),0!==t.length&&this.showRecoveryNotification(t)}async showRecoveryNotification(e){const t=e.reduce(((e,t)=>e+t.uploads.length),0),s=e.reduce(((e,t)=>e+(t.groups?.length||0)),0);let o,r=window.getTemplate("restoreNotification");if(!r)return void console.error("Restore notification template not found");if(s>0){o=`${s} ${s>1?"groups":"group"} with ${t} ${t>1?"uploads":"upload"} can be restored.`}else o=`${t} upload(s) from ${e.length} field(s) can be recovered.`;const a=r.querySelector(".restore-details");a&&(a.textContent=o);for(const t of e){let e=window.getTemplate("restoreField");if(!e)continue;const s=e.querySelector("h3");s&&(s.textContent=t.config.name||"Unnamed Field");const o=e.querySelector(".item-grid.restore");for(let e of t.uploads){const s=this.uploadStore.get(e);let r=window.getTemplate("uploadItem");if(!r)continue;const a=await this.getBlobData(s.id);if(a)try{const e=this.createPreviewUrl(a);let[o,i,l,n,d]=[r.querySelector('[name="featured"]'),r.querySelector("img"),r.querySelector("video"),r.querySelector("label > span"),r.querySelector("details")];r.dataset.uploadId=s.id,r.dataset.fieldId=t.id;let c=this.getSubtypeFromMime(a.type);switch(r.dataset.subtype=c,c){case"image":[i.src,i.alt]=[e,a.name??s.meta?.originalName??""],l.remove(),n.remove();break;case"video":l.src=e,i.remove(),n.remove();break;case"document":let t;switch(""){case"pdf":t=window.getIcon("file-pdf");break;case"csv":t=window.getIcon("file-csv");break;case"doc":t=window.getIcon("file-doc");break;case"txt":t=window.getIcon("file-txt");break;case"xls":t=window.getIcon("file-xls");break;default:t=window.getIcon("file")}n.innerText=s.originalFile.name,n.prepend(t),i.remove(),l.remove()}r.dataset.previewUrl=e}catch(e){console.warn("Failed to create preview for upload:",s.id,e)}const i=r.querySelector("summary span");i&&(i.textContent=s.meta?.originalName||"Unknown file");const l=r.querySelector("details");l&&s.meta&&(l.textContent=`${this.formatBytes(s.meta.size)} • ${s.meta.type}`),r.querySelectorAll("input").forEach((e=>{let t=e.id;if(t){let o=t+s.id,r=e.parentNode.querySelector(`label[for="${t}"]`);e.id=o,r&&(r.htmlFor=o)}})),o&&o.appendChild(r)}r.querySelector(".wrap").appendChild(o)}document.querySelector(".field.upload").appendChild(r),r=document.querySelector("dialog.restore-uploads"),this.restoreModal=new window.jvbModal(r),this.restoreSelection=new window.jvbHandleSelection({container:r,ui:{selectAll:r.querySelector("#select-all-restore"),count:r.querySelector(".selection-count")}}),this.restoreModal.handleOpen()}async handleRestoreUploads(){let e=document.querySelector("dialog.restore-uploads");if(!e)return;const t=this.getSelectedRestorationUploads(e);0!==t.length&&(await this.restoreSelectedUploads(t),this.cleanupRestore())}async handleRestoreAll(){let e=document.querySelector("dialog.restore-uploads");if(!e)return;const t=[];e.querySelectorAll(".item.upload").forEach((e=>{let s=e.dataset.uploadId,o=e.dataset.fieldId;t.push({uploadId:s,fieldId:o})})),await this.restoreSelectedUploads(t),this.cleanupRestore()}showSaveIndicator(e){}cleanupRestore(){this.restoreModal.handleClose(),this.restoreSelection.destroy(),this.restoreSelection=null,this.restoreModal.destroy(),this.restoreModal.modal.remove(),this.restoreModal=null}async cleanupStoredUploads(){await this.fieldStore.clear(),await this.uploadStore.clear()}subscribe(e){return this.subscribers.add(e),()=>this.subscribers.delete(e)}notify(e,t={}){this.subscribers.forEach((s=>{try{s(e,t)}catch(e){console.error("Subscriber error:",e)}}))}destroy(){document.removeEventListener("click",this.clickHandler),document.removeEventListener("change",this.changeHandler),document.removeEventListener("dragenter",this.dragEnterHandler),document.removeEventListener("dragleave",this.dragLeaveHandler),document.removeEventListener("dragover",this.dragOverHandler),document.removeEventListener("drop",this.dropHandler),this.dragController&&this.dragController.destroy(),this.selectionHandlers.forEach((e=>e.destroy())),this.selectionHandlers.clear(),this.cleanupAllPreviewUrls(),this.sortableInstances.forEach((e=>{e?.destroy&&e.destroy()})),this.sortableInstances.clear(),this.uploadElements.clear(),this.fieldElements.clear(),this.groupElements.clear(),this.selected.clear(),this.subscribers.clear()}}document.addEventListener("DOMContentLoaded",(()=>{window.jvbUploads=new e}))})(); |
| | |
| | | nav#faq{--height:fit-content;background-color:var(--base-100);border-radius:var(--outerRadius);display:block;padding:1.5rem}nav#faq ol{counter-reset:faq;display:block;height:-moz-fit-content;height:fit-content;list-style:decimal-leading-zero}nav#faq ol li{counter-increment:faq}nav#faq ol li:before{content:counter(faq);display:block;font-family:var(--heading);font-weight:var(--hBold)}nav#faq h2{font-size:var(--large);right:0;margin:.5rem 0}nav#faq a{padding:.5rem}.faq-block{max-width:none;padding-bottom:3rem;width:100%}.faq-block>*{margin:1rem auto;max-width:var(--alignWide)}.faq-block h2{margin:5rem 0 1.5rem}.faq-block h3{margin:0;text-transform:none}.faq-block :target{background-color:var(--base);outline:none}.faq-block :target h2{background-color:var(--base);border-radius:var(--outerRadius);padding:1rem 1.5rem}.faq-block details{margin:1rem auto;max-width:var(--maxWidth);padding:.75rem}.faq-block details+details{margin-top:3rem}.faq-block details .button{display:block;height:-moz-fit-content;height:fit-content;margin-right:auto} |
| | | nav#faq{--height:fit-content;background-color:var(--base-100);border-radius:var(--outerRadius);display:block;padding:1.5rem;touch-action:auto}nav#faq ol{counter-reset:faq;display:block;height:-moz-fit-content;height:fit-content;list-style:decimal-leading-zero}nav#faq ol li{counter-increment:faq}nav#faq ol li:before{content:counter(faq);display:block;font-family:var(--heading);font-weight:var(--hBold)}nav#faq h2{font-size:var(--large);right:0;margin:.5rem 0}nav#faq a{padding:.5rem}.faq-block{max-width:none;padding-bottom:3rem;width:100%}.faq-block>*{margin:1rem auto;max-width:var(--alignWide)}.faq-block h2{margin:5rem 0 1.5rem}.faq-block h3{margin:0;text-transform:none}.faq-block :target{background-color:var(--base);outline:none}.faq-block :target h2{background-color:var(--base);border-radius:var(--outerRadius);padding:1rem 1.5rem}.faq-block details{margin:1rem auto;max-width:var(--maxWidth);padding:.75rem}.faq-block details+details{margin-top:3rem}.faq-block details .button{display:block;height:-moz-fit-content;height:fit-content;margin-right:auto} |
| | |
| | | nav#faq{--height:fit-content;background-color:var(--base-100);border-radius:var(--outerRadius);display:block;padding:1.5rem}nav#faq ol{counter-reset:faq;display:block;height:-moz-fit-content;height:fit-content;list-style:decimal-leading-zero}nav#faq ol li{counter-increment:faq}nav#faq ol li:before{content:counter(faq);display:block;font-family:var(--heading);font-weight:var(--hBold)}nav#faq h2{font-size:var(--large);left:0;margin:.5rem 0}nav#faq a{padding:.5rem}.faq-block{max-width:none;padding-bottom:3rem;width:100%}.faq-block>*{margin:1rem auto;max-width:var(--alignWide)}.faq-block h2{margin:5rem 0 1.5rem}.faq-block h3{margin:0;text-transform:none}.faq-block :target{background-color:var(--base);outline:none}.faq-block :target h2{background-color:var(--base);border-radius:var(--outerRadius);padding:1rem 1.5rem}.faq-block details{margin:1rem auto;max-width:var(--maxWidth);padding:.75rem}.faq-block details+details{margin-top:3rem}.faq-block details .button{display:block;height:-moz-fit-content;height:fit-content;margin-left:auto} |
| | | nav#faq{--height:fit-content;background-color:var(--base-100);border-radius:var(--outerRadius);display:block;padding:1.5rem;touch-action:auto}nav#faq ol{counter-reset:faq;display:block;height:-moz-fit-content;height:fit-content;list-style:decimal-leading-zero}nav#faq ol li{counter-increment:faq}nav#faq ol li:before{content:counter(faq);display:block;font-family:var(--heading);font-weight:var(--hBold)}nav#faq h2{font-size:var(--large);left:0;margin:.5rem 0}nav#faq a{padding:.5rem}.faq-block{max-width:none;padding-bottom:3rem;width:100%}.faq-block>*{margin:1rem auto;max-width:var(--alignWide)}.faq-block h2{margin:5rem 0 1.5rem}.faq-block h3{margin:0;text-transform:none}.faq-block :target{background-color:var(--base);outline:none}.faq-block :target h2{background-color:var(--base);border-radius:var(--outerRadius);padding:1rem 1.5rem}.faq-block details{margin:1rem auto;max-width:var(--maxWidth);padding:.75rem}.faq-block details+details{margin-top:3rem}.faq-block details .button{display:block;height:-moz-fit-content;height:fit-content;margin-left:auto} |
| | |
| | | <?php return array('dependencies' => array(), 'version' => '40e69d90292156d1e263'); |
| | | <?php return array('dependencies' => array(), 'version' => '66b80732cd4eebba785a'); |
| | |
| | | (()=>{class e{constructor(){this.container=document.querySelector("section.feed-block"),this.container&&(this.a11y=window.jvbA11y,this.cache=new window.jvbCache("feed"),this.error=window.jvbError,this.config={source:"",context:"",highlight:null,gallery:!1,view:this.cache.get("feedView")||"grid",...this.container.dataset},this.initElements(),this.initFilters(),this.loadWhenAble())}loadWhenAble(){"requestIdleCallback"in window?requestIdleCallback((()=>{this.initTaxonomies(),this.initStore(),this.initListeners(),this.initGallery()}),{timeout:2e3}):setTimeout((()=>{this.initTaxonomies(),this.initStore(),this.initListeners(),this.initGallery()}),100)}initElements(){this.currentTaxonomies=new Set,this.taxonomyFilters={},this.elements={filterTrigger:"[data-filter]",filters:{content:'[data-filter="content"]',orderby:'[data-filter="orderby"]',order:'[data-filter="order"]',match:'[data-filter="match"]',favourites:'[data-filter="favourites"]',taxonomy:'[data-filter^="taxonomy"]'},selectedTax:".selected-terms",clearFilter:"button.clear-filters",loadMore:"button.load-more",filterContainer:".filters",grid:".item-grid"},this.ui=window.uiFromSelectors(this.elements),this.ui.content=this.ui.filterContainer.querySelectorAll('[name="content"]'),this.ui.taxonomies=this.ui.filterContainer.querySelectorAll("[data-taxonomy]"),this.ui.content.length>0?this.contentTypes=Array.from(this.ui.content).map((e=>e.value)):this.contentTypes=[this.container.dataset.content],this.ui.taxonomies.length>0?this.taxonomies=Array.from(this.ui.taxonomies).map((e=>e.dataset.taxonomy)):this.taxonomies=[]}async initTaxonomies(){this.selector=window.jvbSelector;const e=document.querySelectorAll('[data-filter="taxonomy"]');this.selector.isInitializing=!0,e.forEach((e=>{const t=e.dataset.taxonomy;this.currentTaxonomies.add(t),this.selector.registerFilterButton(e,{button:e,buttonSelector:'[data-filter="taxonomy"]',selected:this.ui.selectedTax})})),setTimeout((()=>{this.selector.batchFetchTaxonomies(),this.selector.isInitializing=!1}),0),this.selector.subscribe(((e,t)=>{"selected-terms"===e&&this.handleTaxonomyChange(t)}))}handleTaxonomyChange(e){const{terms:t,taxonomy:i}=e;t.size>0?this.taxonomyFilters[i]=Array.from(t.keys()):delete this.taxonomyFilters[i];let s={page:1};Object.keys(this.taxonomyFilters).length>0&&(s.taxonomy=this.taxonomyFilters),this.updateFilter(s)}clearAllTaxonomies(){this.taxonomyFilters={},window.removeChildren(this.ui.selectedTax),this.updateFilter({taxonomy:null,page:1})}initFilters(){this.filters={content:this.contentTypes[0],orderby:"date",order:"desc",page:1},this.config.context&&(this.filters.context=this.config.context),this.config.source&&(this.filters.source=this.config.source),this.processCachedFilters(),this.processURLFilters(),this.syncUIToFilters()}syncUIToFilters(){Object.entries(this.filters).forEach((([e,t])=>{const i=this.ui.filterContainer.querySelector(`[data-filter="${e}"][value="${t}"]`);i&&(i.checked=!0)})),this.updateContentFor(this.filters.content)}nextPage(){this.store.setFilter("page",this.store.filters.page++)}initStore(){this.addPlaceholders(),this.store=window.jvbStore.register("feed",{endpoint:"feed",keyPath:"id",indexes:[{name:"content",keyPath:"content"},{name:"taxonomy",keyPath:"taxonomy"},{name:"user",keyPath:"user"},{name:"date",keyPath:"modified"},{name:"title",keyPath:"title"}],filters:this.filters,TTL:216e5,showLoading:!0,required:"content",delayFetch:!0}),this.store.subscribe(((e,t)=>{"data-loaded"===e&&(this.renderItems(),this.ui.loadMore.hidden=!0,this.store.lastResponse&&this.store.lastResponse.has_more&&(this.ui.loadMore.hidden=!this.store.lastResponse.has_more))}))}initGallery(){this.gallery=!!this.config.gallery&&window.jvbGallery,this.gallery&&this.gallery.subscribe(((e,t)=>{"load-more"===e&&this.store.lastResponse&&this.store.lastResponse.has_more&&this.nextPage()}))}processCachedFilters(){Object.keys(this.filters).forEach((e=>{let t=this.cache.get(`${this.config.source}_${this.config.context}_${e}`);t&&t!==this.filters[e]&&(this.filters[e]=t)}))}processURLFilters(){if(this.filters.page>1)return!1;const e=new URLSearchParams(window.location.search);if(!e.toString())return!1;["content","order","orderby","favourites","match"].forEach((t=>{let i=e.get(`f_${t}`);if(i){this.filters[t]=i;let e=this.ui.filters[t];e&&(e.checked=!0)}}));let t=!1;if(e.forEach(((e,i)=>{if(i.startsWith("f_tax_")){t=!0;const s=i.replace("f_tax_","");this.taxonomyFilters[s]||(this.taxonomyFilters[s]=[]),this.taxonomyFilters[s]=e.split(",").map(Number)}})),t)for(let[e,t]in Object.entries(this.taxonomyFilters)){let i=this.ui.filterContainer.querySelector(`[data-taxonomy="${e}"]`);i&&(i.dataset.fieldId?(this.selector.get(i.dataset.fieldId).selectedTerms=new Set(t),this.selector.initFieldDisplay(i.dataset.fieldId)):this.selector.registerField(i,{button:i,buttonSelector:'[data-filter="taxonomy"]',selected:this.ui.selectedTax,selectedItems:t}))}return!0}updateURL(){const e=new URLSearchParams;["content","order","orderby","match"].forEach((t=>{this.filters[t]&&e.set(`f_${t}`,this.filters[t])})),Object.entries(this.taxonomyFilters).forEach((([t,i])=>{i.length>0&&e.set(`f_tax_${t}`,i.join(","))}));const t=`${window.location.pathname}${e.toString()?"?"+e.toString():""}`;window.history.pushState({filters:this.filters},"",t)}renderItems(){let e=this.store.getFiltered();if(1===this.store.filters.page&&(window.removeChildren(this.ui.grid),this.addPlaceholders()),0===e.length)return void this.a11y.announceItems(0,this.store.filters.page>0);const t=document.createDocumentFragment(),i=s=>{const r=Math.min(s+10,e.length);for(let i=s;i<r;i++){const s=e[i],r=this.createItemElement(s);t.appendChild(r)}r<e.length?requestAnimationFrame((()=>i(r))):(this.removePlaceholders(),this.ui.grid.append(t),this.observeImages(this.ui.grid),this.config.gallery&&this.gallery.updateGalleryItems(this.gallery.getGalleryItems()),this.a11y.makeNavigable(this.ui.grid.querySelectorAll(".item:not([data-keyboard-nav])")),this.a11y.announceItems(e.length,this.store.filters.page>1,this.store.hasMore))};e.length>0?i(0):this.a11y.announceItems(0,this.store.filters.page>1,!1),this.ui.filters.match.hidden=window.isEmptyObject(this.taxonomyFilters),this.ui.clearFilter.hidden=window.isEmptyObject(this.taxonomyFilters)}createItemElement(e){let t=window.getTemplate("feed-item");return Object.hasOwn(t.dataset,"timeline")?this.createTimelineElement(e,t):t}createTimelineElement(e,t){var i,s;let[r,a,o,n,l,h,d,c,m,u]=[t,t.querySelector("a"),t.querySelector("img.before"),t.querySelector("img.after"),t.querySelector("summary span:last-of-type"),t.querySelector("p.started time"),t.querySelector("p.updated time"),t.querySelector("p.total b"),t.querySelector(".term-list"),Object.values(e.fields.order)],f=u.length-1,y=e.images[u[0].post_thumbnail],g=e.images[u[f].post_thumbnail];return[r.dataset.id,a.href,o.src,o.dataset.small,o.dataset.medium,n.src,n.dataset.small,n.dataset.medium,l.textContent,h.textContent,d.textContent,c.textContent]=[e.id,e.url,y.tiny,y.small,y.medium,g.tiny,g.small,g.medium,`${l.textContent} ${f} Tx`,null!==(i=u[0].date)&&void 0!==i?i:e.date,null!==(s=u[f].date)&&void 0!==s?s:"",`${f} Treatments`],t}addPlaceholders(){let e=this.contentTypes.length;for(let t=0;t<12;t++){let t,i=window.getTemplate("placeholderTemplate"),s=Math.floor(Math.random()*e);t=this.ui.content.length>0?this.ui.content.filter((e=>e.value===this.contentTypes[s])).querySelector(".icon").cloneNode(!0):window.getIcon(this.container.dataset.icon),i.appendChild(t),this.ui.grid.appendChild(i)}}removePlaceholders(){this.ui.grid.querySelector(".placeholder")&&window.removeChildren(this.ui.grid)}updateFilter(e){let t=["taxonomy","favourites","match",...Object.keys(this.filters)];e=Object.keys(e).filter((e=>t.includes(e))).reduce(((t,i)=>(t[i]=e[i],t)),{}),window.getDifferences.map(this.filters,e)&&(this.filters={...this.filters,...e},this.updateURL(),this.store.setFilters(e))}updateContentFor(e){this.ui.filterContainer.querySelectorAll('[data-filter="taxonomy"]').forEach((t=>{const i=t.dataset.for?.split(",")||[];t.hidden=i.length>0&&!i.includes(e)})),this.ui.filterContainer.querySelectorAll("[data-for]").forEach((t=>{const i=t.dataset.for?.split(",")||[];i.length>0&&(t.hidden=!i.includes(e),t.hidden&&t.checked&&(t.checked=!1))}));const t=this.ui.filterContainer.querySelector('[name="orderby"]:checked');this.updateOrderDirectionVisibility(t?.value)}updateOrderDirectionVisibility(e){const t=this.ui.filterContainer.querySelector(".order-direction");if(t){const i=t.dataset.forOrder?.split(",")||[];t.hidden=i.length>0&&!i.includes(e)}}initListeners(){this.popStateHandler=this.handlePopState.bind(this),this.clickHandler=this.handleClick.bind(this),this.changeHandler=this.handleChange.bind(this),this.imageObserver=null,this.resizeObserver=null,"IntersectionObserver"in window&&(this.imageObserver=new IntersectionObserver((e=>{e.forEach((e=>{this.loadImage(e.target),this.imageObserver.unobserve(e.target)}))}),{rootMargin:"100px",threshold:.1})),"ResizeObserver"in window?this.resizeObserver=new ResizeObserver(window.debounce((()=>{this.updateImageSizes()}),250)):window.addEventListener("resize",window.debounce((()=>{this.updateImageSizes()}),250)),window.addEventListener("popstate",this.popStateHandler),document.addEventListener("click",this.clickHandler),document.addEventListener("change",this.changeHandler)}loadImage(e){const t=this.getAppropriateImageSize(e);t&&t!==e.src&&(e.src=t,e.dataset.loaded="true")}getAppropriateImageSize(e){return window.innerWidth<768&&e.dataset.small?e.dataset.small:e.dataset.medium?e.dataset.medium:e.src}observeImages(e){e.querySelectorAll("img[data-small], img[data-medium]").forEach((e=>{e.dataset.loaded||this.imageObserver.observe(e)}))}handlePopState(e){e.state?.filters&&this.processURLFilters()&&(this.store.setFilters(this.filters),this.a11y.announce("Feed filters updated from browser history"))}handleClick(e){window.targetCheck(e,this.elements.loadMore)?this.nextPage():window.targetCheck(e,this.elements.clearFilter)?this.clearAllTaxonomies():window.targetCheck(e,".remove-item")&&this.handleRemoveSelectedTerm(e)}handleRemoveSelectedTerm(e){const t=e.target.closest(".selected-item");if(!t)return;const i=parseInt(t.dataset.id),s=t.dataset.taxonomy;this.taxonomyFilters[s]&&(this.taxonomyFilters[s]=this.taxonomyFilters[s].filter((e=>e!==i)),0===this.taxonomyFilters[s].length&&delete this.taxonomyFilters[s]),t.remove(),this.updateFilter({taxonomy:Object.keys(this.taxonomyFilters).length>0?this.taxonomyFilters:null,page:1})}handleChange(e){let t=e.target;Object.hasOwn(t.dataset,"filter")&&("content"===t.dataset.filter?(this.updateContentFor(t.value),this.updateFilter({content:t.value,page:1})):"orderby"===t.dataset.filter?(this.updateOrderDirectionVisibility(t.value),this.updateFilter({orderby:t.value,page:1})):"order"===t.dataset.filter?this.updateFilter({order:t.value,page:1}):"match"===t.dataset.filter?this.updateFilter({match:t.checked?"all":"any",page:1}):"favourites"===t.dataset.filter&&this.updateFilter({favourites:t.checked,page:1}))}}document.addEventListener("DOMContentLoaded",(function(){window.feedBlock=new e}))})(); |
| | | (()=>{class e{constructor(){this.container=document.querySelector("section.feed-block"),this.container&&(this.a11y=window.jvbA11y,this.cache=new window.jvbCache("feed"),this.error=window.jvbError,this.config={source:"",context:"",highlight:null,gallery:!1,view:this.cache.get("feedView")||"grid",...this.container.dataset},this.initElements(),this.initFilters(),this.loadWhenAble())}loadWhenAble(){"requestIdleCallback"in window?requestIdleCallback((()=>{this.initTaxonomies(),this.initStore(),this.initListeners(),this.initGallery()}),{timeout:2e3}):setTimeout((()=>{this.initTaxonomies(),this.initStore(),this.initListeners(),this.initGallery()}),100)}initElements(){this.currentTaxonomies=new Set,this.taxonomyFilters={},this.elements={filterTrigger:"[data-filter]",filters:{content:'[data-filter="content"]',orderby:'[data-filter="orderby"]',order:'[data-filter="order"]',match:'[data-filter="match"]',favourites:'[data-filter="favourites"]',taxonomy:'[data-filter^="taxonomy"]'},selectedTax:".selected-items",clearFilter:"button.clear-filters",loadMore:"button.load-more",filterContainer:".filters",grid:".item-grid"},this.ui=window.uiFromSelectors(this.elements),this.ui.content=this.ui.filterContainer.querySelectorAll('[name="content"]'),this.ui.taxonomies=this.ui.filterContainer.querySelectorAll("[data-taxonomy]"),this.ui.content.length>0?this.contentTypes=Array.from(this.ui.content).map((e=>e.value)):this.contentTypes=[this.container.dataset.content],this.ui.taxonomies.length>0?this.taxonomies=Array.from(this.ui.taxonomies).map((e=>e.dataset.taxonomy)):this.taxonomies=[]}async initTaxonomies(){this.selector=window.jvbSelector;const e=document.querySelectorAll('[data-filter="taxonomy"]');this.selector.isInitializing=!0,e.forEach((e=>{const t=e.dataset.taxonomy;this.currentTaxonomies.add(t),this.selector.registerFilterButton(e,{button:e,buttonSelector:'[data-filter="taxonomy"]',selected:this.ui.selectedTax}),this.addTaxonomyPreloadListeners(e,t)})),this.selector.isInitializing=!1,this.selector.subscribe(((e,t)=>{"selected-terms"===e&&this.handleTaxonomyChange(t)}))}addTaxonomyPreloadListeners(e,t){const i=()=>{this.selector.preloadTaxonomy(t)};e.addEventListener("mouseenter",i,{once:!0}),e.addEventListener("pointerdown",i,{once:!0}),e.addEventListener("focus",i,{once:!0})}handleTaxonomyChange(e){const{terms:t,taxonomy:i}=e;t.size>0?this.taxonomyFilters[i]=Array.from(t.keys()):delete this.taxonomyFilters[i];let s={page:1};Object.keys(this.taxonomyFilters).length>0&&(s.taxonomy=this.taxonomyFilters),this.updateFilter(s)}clearAllTaxonomies(){this.taxonomyFilters={},window.removeChildren(this.ui.selectedTax),this.updateFilter({taxonomy:null,page:1})}initFilters(){this.filters={content:this.contentTypes[0],orderby:"date",order:"desc",page:1},this.config.context&&(this.filters.context=this.config.context),this.config.source&&(this.filters.source=this.config.source),this.processCachedFilters(),this.processURLFilters(),this.syncUIToFilters()}syncUIToFilters(){Object.entries(this.filters).forEach((([e,t])=>{const i=this.ui.filterContainer.querySelector(`[data-filter="${e}"][value="${t}"]`);i&&(i.checked=!0)})),this.updateContentFor(this.filters.content)}nextPage(){this.store.setFilter("page",this.store.filters.page++)}initStore(){const e=window.jvbStore.register("feed",{storeName:"feed",endpoint:"feed",keyPath:"id",indexes:[{name:"content",keyPath:"content"},{name:"taxonomy",keyPath:"taxonomy"},{name:"user",keyPath:"user"},{name:"date",keyPath:"modified"},{name:"title",keyPath:"title"}],filters:this.filters,TTL:216e5,showLoading:!0,required:"content",delayFetch:!0});this.store=e.feed,this.store.subscribe(((e,t)=>{"data-loaded"===e&&(this.renderItems(),this.ui.loadMore.hidden=!0,this.store.lastResponse&&this.store.lastResponse.has_more&&(this.ui.loadMore.hidden=!this.store.lastResponse.has_more))}))}initGallery(){this.gallery=!!this.config.gallery&&window.jvbGallery,this.gallery&&this.gallery.subscribe(((e,t)=>{"load-more"===e&&this.store.lastResponse&&this.store.lastResponse.has_more&&this.nextPage()}))}processCachedFilters(){Object.keys(this.filters).forEach((e=>{let t=this.cache.get(`${this.config.source}_${this.config.context}_${e}`);t&&t!==this.filters[e]&&(this.filters[e]=t)}))}processURLFilters(){if(this.filters.page>1)return!1;const e=new URLSearchParams(window.location.search);if(!e.toString())return!1;["content","order","orderby","favourites","match"].forEach((t=>{let i=e.get(`f_${t}`);if(i){this.filters[t]=i;let e=this.ui.filters[t];e&&(e.checked=!0)}}));let t=!1;if(e.forEach(((e,i)=>{if(i.startsWith("f_tax_")){t=!0;const s=i.replace("f_tax_","");this.taxonomyFilters[s]||(this.taxonomyFilters[s]=[]),this.taxonomyFilters[s]=e.split(",").map(Number)}})),t)for(let[e,t]in Object.entries(this.taxonomyFilters)){let i=this.ui.filterContainer.querySelector(`[data-taxonomy="${e}"]`);i&&(i.dataset.fieldId?(this.selector.get(i.dataset.fieldId).selectedTerms=new Set(t),this.selector.initFieldDisplay(i.dataset.fieldId)):this.selector.registerField(i,{button:i,buttonSelector:'[data-filter="taxonomy"]',selected:this.ui.selectedTax,selectedItems:t}))}return!0}updateURL(){const e=new URLSearchParams;["content","order","orderby","match"].forEach((t=>{this.filters[t]&&e.set(`f_${t}`,this.filters[t])})),Object.entries(this.taxonomyFilters).forEach((([t,i])=>{i.length>0&&e.set(`f_tax_${t}`,i.join(","))}));const t=`${window.location.pathname}${e.toString()?"?"+e.toString():""}`;window.history.pushState({filters:this.filters},"",t)}renderItems(){let e=this.store.getFiltered();if(1===this.store.filters.page&&window.removeChildren(this.ui.grid),0===e.length)return void this.a11y.announceItems(0,this.store.filters.page>0);const t=document.createDocumentFragment(),i=s=>{const r=Math.min(s+10,e.length);for(let i=s;i<r;i++){const s=e[i],r=this.createItemElement(s);t.appendChild(r)}r<e.length?requestAnimationFrame((()=>i(r))):(this.removePlaceholders(),this.ui.grid.append(t),this.observeImages(this.ui.grid),this.config.gallery&&this.gallery.updateGalleryItems(this.gallery.getGalleryItems()),this.a11y.makeNavigable(this.ui.grid.querySelectorAll(".item:not([data-keyboard-nav])")),this.a11y.announceItems(e.length,this.store.filters.page>1,this.store.hasMore))};e.length>0?i(0):this.a11y.announceItems(0,this.store.filters.page>1,!1),this.ui.filters.match.hidden=window.isEmptyObject(this.taxonomyFilters),this.ui.clearFilter.hidden=window.isEmptyObject(this.taxonomyFilters)}createItemElement(e){let t=window.getTemplate("feed-item");return Object.hasOwn(t.dataset,"timeline")?this.createTimelineElement(e,t):t}createTimelineElement(e,t){var i,s;let[r,a,o,n,l,d,h,c,m,u]=[t,t.querySelector("a"),t.querySelector("img.before"),t.querySelector("img.after"),t.querySelector("summary span:last-of-type"),t.querySelector("p.started time"),t.querySelector("p.updated time"),t.querySelector("p.total b"),t.querySelector(".term-list"),Object.values(e.fields.order)],f=u.length-1,y=e.images[u[0].post_thumbnail],g=e.images[u[f].post_thumbnail];return[r.dataset.id,a.href,o.src,o.dataset.small,o.dataset.medium,n.src,n.dataset.small,n.dataset.medium,l.textContent,d.textContent,h.textContent,c.textContent]=[e.id,e.url,y.tiny,y.small,y.medium,g.tiny,g.small,g.medium,`${l.textContent} ${f} Tx`,null!==(i=u[0].date)&&void 0!==i?i:e.date,null!==(s=u[f].date)&&void 0!==s?s:"",`${f} Treatments`],t}removePlaceholders(){const e=this.ui.grid.querySelectorAll(".placeholder");e.length>0&&e.forEach((e=>e.remove()))}addPlaceholders(){let e=this.contentTypes.length;const t=document.createDocumentFragment();for(let i=0;i<12;i++){let i,s=window.getTemplate("placeholderTemplate"),r=Math.floor(Math.random()*e);i=this.ui.content.length>0?this.ui.content.filter((e=>e.value===this.contentTypes[r])).querySelector(".icon").cloneNode(!0):window.getIcon(this.container.dataset.icon),s.append(i),t.append(s)}this.ui.grid.append(t)}updateFilter(e){let t=["taxonomy","favourites","match",...Object.keys(this.filters)];e=Object.keys(e).filter((e=>t.includes(e))).reduce(((t,i)=>(t[i]=e[i],t)),{}),window.getDifferences.map(this.filters,e)&&(this.filters={...this.filters,...e},this.updateURL(),this.store.setFilters(e))}updateContentFor(e){this.ui.filterContainer.querySelectorAll('[data-filter="taxonomy"]').forEach((t=>{const i=t.dataset.for?.split(",")||[];t.hidden=i.length>0&&!i.includes(e)})),this.ui.filterContainer.querySelectorAll("[data-for]").forEach((t=>{const i=t.dataset.for?.split(",")||[];i.length>0&&(t.hidden=!i.includes(e),t.hidden&&t.checked&&(t.checked=!1))}));const t=this.ui.filterContainer.querySelector('[name="orderby"]:checked');this.updateOrderDirectionVisibility(t?.value)}updateOrderDirectionVisibility(e){const t=this.ui.filterContainer.querySelector(".order-direction");if(t){const i=t.dataset.forOrder?.split(",")||[];t.hidden=i.length>0&&!i.includes(e)}}initListeners(){this.popStateHandler=this.handlePopState.bind(this),this.clickHandler=this.handleClick.bind(this),this.changeHandler=this.handleChange.bind(this),this.imageObserver=null,this.resizeObserver=null,"IntersectionObserver"in window&&(this.imageObserver=new IntersectionObserver((e=>{e.forEach((e=>{this.loadImage(e.target),this.imageObserver.unobserve(e.target)}))}),{rootMargin:"100px",threshold:.1})),"ResizeObserver"in window?this.resizeObserver=new ResizeObserver(window.debounce((()=>{this.updateImageSizes()}),250)):window.addEventListener("resize",window.debounce((()=>{this.updateImageSizes()}),250)),window.addEventListener("popstate",this.popStateHandler),document.addEventListener("click",this.clickHandler),document.addEventListener("change",this.changeHandler)}loadImage(e){const t=this.getAppropriateImageSize(e);t&&t!==e.src&&(e.src=t,e.dataset.loaded="true")}getAppropriateImageSize(e){return window.innerWidth<768&&e.dataset.small?e.dataset.small:e.dataset.medium?e.dataset.medium:e.src}observeImages(e){e.querySelectorAll("img[data-small], img[data-medium]").forEach((e=>{e.dataset.loaded||this.imageObserver.observe(e)}))}handlePopState(e){e.state?.filters&&this.processURLFilters()&&(this.store.setFilters(this.filters),this.a11y.announce("Feed filters updated from browser history"))}handleClick(e){window.targetCheck(e,this.elements.loadMore)?this.nextPage():window.targetCheck(e,this.elements.clearFilter)?this.clearAllTaxonomies():window.targetCheck(e,".remove-item")&&this.handleRemoveSelectedTerm(e)}handleRemoveSelectedTerm(e){const t=e.target.closest(".selected-item");if(!t)return;const i=parseInt(t.dataset.id),s=t.dataset.taxonomy;this.taxonomyFilters[s]&&(this.taxonomyFilters[s]=this.taxonomyFilters[s].filter((e=>e!==i)),0===this.taxonomyFilters[s].length&&delete this.taxonomyFilters[s]),t.remove(),this.updateFilter({taxonomy:Object.keys(this.taxonomyFilters).length>0?this.taxonomyFilters:null,page:1})}handleChange(e){let t=e.target;Object.hasOwn(t.dataset,"filter")&&("content"===t.dataset.filter?(this.updateContentFor(t.value),this.updateFilter({content:t.value,page:1})):"orderby"===t.dataset.filter?(this.updateOrderDirectionVisibility(t.value),this.updateFilter({orderby:t.value,page:1})):"order"===t.dataset.filter?this.updateFilter({order:t.value,page:1}):"match"===t.dataset.filter?this.updateFilter({match:t.checked?"all":"any",page:1}):"favourites"===t.dataset.filter&&this.updateFilter({favourites:t.checked,page:1}))}}document.addEventListener("DOMContentLoaded",(function(){window.feedBlock=new e}))})(); |
| | |
| | | if ($inheritUser) { |
| | | global $post; |
| | | $inheritUser = $post->post_author; |
| | | }else { |
| | | $inheritUser = null; |
| | | } |
| | | try { |
| | | $gmb = JVB()->connect('gmb', $inheritUser); |
| | |
| | | .gmb-reviews{max-width:var(--alignWide)}.gmb-reviews>.row.btw .button{height:-moz-max-content;height:max-content;width:100%}.gmb-reviews>.row.btw p{width:-moz-fit-content;width:fit-content}.gmb-reviews .stars{align-items:center;display:inline-flex;flex-wrap:nowrap;justify-content:center}.gmb-reviews ul{list-style:none;margin:0;padding:0}.gmb-reviews ul li{margin:2rem 0;position:relative}@media(min-width:768px){.gmb-reviews ul li:nth-of-type(odd){right:-2rem}.gmb-reviews ul li:nth-of-type(2n){left:-2rem}}.gmb-reviews blockquote{margin:0;padding:0}.gmb-reviews blockquote .content,.gmb-reviews blockquote .content:after{border-width:4px 1px}.gmb-reviews blockquote .content:before{border-width:8px;bottom:-4px}.gmb-reviews blockquote cite{position:relative}.gmb-reviews blockquote cite img{right:-8rem;position:absolute;top:0;width:4.5rem}.gmb-reviews blockquote cite p{margin:0}.gmb-reviews blockquote cite .wrap{--wrap:wrap}.gmb-reviews blockquote cite .wrap p,.gmb-reviews blockquote cite .wrap time{max-width:49%}.gmb-reviews blockquote cite .wrap .stars{width:100%}.gmb-reviews blockquote time{white-space:nowrap}.gmb-reviews .stars .icon{background-color:var(--action-0)}.gmb-reviews article{background-color:var(--base);border-radius:var(--outerRadius);padding:1rem}.gmb-reviews article header{--align:center}.gmb-reviews article header>img{right:0;position:relative}.gmb-reviews article time{font-style:italic}.gmb-reviews article .review{padding:1.5rem}.gmb-reviews article h4{width:-moz-max-content;width:max-content}.gmb-reviews article .icon{color:var(--action-0)}.gmb-reviews .footer .button{width:100%} |
| | | .gmb-reviews{max-width:none}.gmb-reviews>.row.btw{max-width:var(--alignWide)}.gmb-reviews>.row.btw .button{height:-moz-max-content;height:max-content;width:100%}.gmb-reviews>.row.btw p{width:-moz-fit-content;width:fit-content}.gmb-reviews .stars{align-items:center;display:inline-flex;flex-wrap:nowrap;justify-content:center}.gmb-reviews ul{list-style:none;margin:0;max-width:var(--wider);padding:0}.gmb-reviews ul li{background-color:var(--base-100);margin:2rem 0;padding:1rem;position:relative}@media(min-width:768px){.gmb-reviews ul li:nth-of-type(odd){right:-2rem}.gmb-reviews ul li:nth-of-type(2n){left:-2rem}}.gmb-reviews blockquote{margin:0;padding:0}.gmb-reviews blockquote .content,.gmb-reviews blockquote .content:after{border-width:4px 1px}.gmb-reviews blockquote .content:before{border-width:8px;bottom:-4px}.gmb-reviews blockquote cite{position:relative}.gmb-reviews blockquote cite img{right:-8rem;position:absolute;top:0;width:4.5rem}.gmb-reviews blockquote cite p{margin:0}.gmb-reviews blockquote cite .wrap{--wrap:wrap}.gmb-reviews blockquote cite .wrap p,.gmb-reviews blockquote cite .wrap time{max-width:49%}.gmb-reviews blockquote cite .wrap .stars{width:100%}.gmb-reviews blockquote time{white-space:nowrap}.gmb-reviews .stars .icon{background-color:var(--action-0)}.gmb-reviews article{background-color:var(--base);border-radius:var(--outerRadius);padding:1rem}.gmb-reviews article header{--align:center}.gmb-reviews article header>img{right:0;position:relative}.gmb-reviews article time{font-style:italic}.gmb-reviews article .review{padding:1.5rem}.gmb-reviews article h4{width:-moz-max-content;width:max-content}.gmb-reviews article .icon{color:var(--action-0)}.gmb-reviews .footer .button{width:100%} |
| | |
| | | .gmb-reviews{max-width:var(--alignWide)}.gmb-reviews>.row.btw .button{height:-moz-max-content;height:max-content;width:100%}.gmb-reviews>.row.btw p{width:-moz-fit-content;width:fit-content}.gmb-reviews .stars{align-items:center;display:inline-flex;flex-wrap:nowrap;justify-content:center}.gmb-reviews ul{list-style:none;margin:0;padding:0}.gmb-reviews ul li{margin:2rem 0;position:relative}@media(min-width:768px){.gmb-reviews ul li:nth-of-type(odd){left:-2rem}.gmb-reviews ul li:nth-of-type(2n){right:-2rem}}.gmb-reviews blockquote{margin:0;padding:0}.gmb-reviews blockquote .content,.gmb-reviews blockquote .content:after{border-width:4px 1px}.gmb-reviews blockquote .content:before{border-width:8px;bottom:-4px}.gmb-reviews blockquote cite{position:relative}.gmb-reviews blockquote cite img{left:-8rem;position:absolute;top:0;width:4.5rem}.gmb-reviews blockquote cite p{margin:0}.gmb-reviews blockquote cite .wrap{--wrap:wrap}.gmb-reviews blockquote cite .wrap p,.gmb-reviews blockquote cite .wrap time{max-width:49%}.gmb-reviews blockquote cite .wrap .stars{width:100%}.gmb-reviews blockquote time{white-space:nowrap}.gmb-reviews .stars .icon{background-color:var(--action-0)}.gmb-reviews article{background-color:var(--base);border-radius:var(--outerRadius);padding:1rem}.gmb-reviews article header{--align:center}.gmb-reviews article header>img{left:0;position:relative}.gmb-reviews article time{font-style:italic}.gmb-reviews article .review{padding:1.5rem}.gmb-reviews article h4{width:-moz-max-content;width:max-content}.gmb-reviews article .icon{color:var(--action-0)}.gmb-reviews .footer .button{width:100%} |
| | | .gmb-reviews{max-width:none}.gmb-reviews>.row.btw{max-width:var(--alignWide)}.gmb-reviews>.row.btw .button{height:-moz-max-content;height:max-content;width:100%}.gmb-reviews>.row.btw p{width:-moz-fit-content;width:fit-content}.gmb-reviews .stars{align-items:center;display:inline-flex;flex-wrap:nowrap;justify-content:center}.gmb-reviews ul{list-style:none;margin:0;max-width:var(--wider);padding:0}.gmb-reviews ul li{background-color:var(--base-100);margin:2rem 0;padding:1rem;position:relative}@media(min-width:768px){.gmb-reviews ul li:nth-of-type(odd){left:-2rem}.gmb-reviews ul li:nth-of-type(2n){right:-2rem}}.gmb-reviews blockquote{margin:0;padding:0}.gmb-reviews blockquote .content,.gmb-reviews blockquote .content:after{border-width:4px 1px}.gmb-reviews blockquote .content:before{border-width:8px;bottom:-4px}.gmb-reviews blockquote cite{position:relative}.gmb-reviews blockquote cite img{left:-8rem;position:absolute;top:0;width:4.5rem}.gmb-reviews blockquote cite p{margin:0}.gmb-reviews blockquote cite .wrap{--wrap:wrap}.gmb-reviews blockquote cite .wrap p,.gmb-reviews blockquote cite .wrap time{max-width:49%}.gmb-reviews blockquote cite .wrap .stars{width:100%}.gmb-reviews blockquote time{white-space:nowrap}.gmb-reviews .stars .icon{background-color:var(--action-0)}.gmb-reviews article{background-color:var(--base);border-radius:var(--outerRadius);padding:1rem}.gmb-reviews article header{--align:center}.gmb-reviews article header>img{left:0;position:relative}.gmb-reviews article time{font-style:italic}.gmb-reviews article .review{padding:1.5rem}.gmb-reviews article h4{width:-moz-max-content;width:max-content}.gmb-reviews article .icon{color:var(--action-0)}.gmb-reviews .footer .button{width:100%} |
| | |
| | | $function = BASE.$method; |
| | | |
| | | if (function_exists($function)) { |
| | | return $function($block, $content); |
| | | // return $this->cache->remember( |
| | | // $block, |
| | | // function () use ($function, $block, $content) { |
| | | // return $function($block, $content); |
| | | // } |
| | | // ); |
| | | return $function($block, $content); |
| | | } else if (method_exists($this, $method)) { |
| | | return $this->$method($block, $content); |
| | | //TODO: Recache it |
| | | |
| | | // return $this->cache->remember( |
| | | // $block, |
| | | // function () use ($method, $block, $content) { |
| | |
| | | 'core/site-title', |
| | | 'jvb/forms' |
| | | ]; |
| | | if (!in_array($block['blockName'], $ignore)) { |
| | | // if (!in_array($block['blockName'], $ignore)) { |
| | | // jvbDump('No method found for '.print_r($block['blockName'], true)); |
| | | } |
| | | // } |
| | | } |
| | | if ($block['blockName'] === 'jvb/feed') { |
| | | // Enqueue the feed block script (it will automatically load dependencies) |
| | |
| | | 'main'; |
| | | |
| | | if ($content == '') { |
| | | return do_blocks(get_the_content(get_the_ID())); |
| | | global $post; |
| | | $block['innerBlocks'] = parse_blocks($post->post_content); |
| | | return $this->innerBlocks($block); |
| | | } else { |
| | | return $this->inside($block, $tag, $content); |
| | | } |
| | |
| | | { |
| | | global $post; |
| | | $ID = get_post_thumbnail_id($post->ID); |
| | | return '<figure'.$this->getClassesAndStyles($block['attrs']).'>'. |
| | | $this->image($ID). |
| | | '</figure>'; |
| | | $aOpen = $aClose = ''; |
| | | if(!is_single($ID)) { |
| | | $aOpen = '<a href="'.get_the_permalink($post->ID).'">'; |
| | | $aClose = '</a>'; |
| | | } |
| | | |
| | | return $aOpen.'<figure'.$this->getClassesAndStyles($block['attrs']).'>'. |
| | | apply_filters('jvbCoreFeaturedImage', $this->image($ID), $post->post_type). |
| | | '</figure>'.$aClose; |
| | | } |
| | | //core_post_navigation_link |
| | | //core_post_template |
| | |
| | | |
| | | protected function render_core_query(array $block, string $content):string |
| | | { |
| | | // jvbDump($block); |
| | | // $queryID = $block['attrs']['queryId']; |
| | | // $args = []; |
| | | // $inherit = $block['attrs']['inherit']??false; |
| | | // if ($inherit) { |
| | | // global $wp_query; |
| | | // $loop = $wp_query; |
| | | // } else { |
| | | // foreach ($block['attrs']['query'] as $key => $value) { |
| | | // if (empty($value)) { |
| | | // continue; |
| | | // } |
| | | // switch ($key) { |
| | | // case 'postType': |
| | | // $args['post_type'] = $value; |
| | | // break; |
| | | // case 'perPage': |
| | | // $args['posts_per_page'] = $value; |
| | | // break; |
| | | // case 'orderBy': |
| | | // $args['orderby'] = $value; |
| | | // break; |
| | | // case 'taxQuery': |
| | | // $taxQuery = []; |
| | | // foreach ($value as $tax => $terms) { |
| | | // $taxQuery[] = [ |
| | | // 'taxonomy' => $tax, |
| | | // 'terms' => $terms |
| | | // ]; |
| | | // } |
| | | // if (!empty($taxQuery)) { |
| | | // $args['tax_query'] = $taxQuery; |
| | | // if (count($taxQuery) > 1) { |
| | | // $args['tax_query']['relation'] = 'OR'; |
| | | // } |
| | | // } |
| | | // break; |
| | | // case 'sticky': |
| | | // if ($value === 'ignore') { |
| | | // $args['ignore_sticky_posts'] = true; |
| | | // } else if ($value === 'exclude'){ |
| | | // $args['post__not_in'] = get_option('sticky_posts'); |
| | | // } else if ($value === 'only') { |
| | | // $args['include'] = get_option('sticky_posts'); |
| | | // } |
| | | // break; |
| | | // case 'search': |
| | | // $args['s'] = $value; |
| | | // break; |
| | | // default: |
| | | // $args[$key] = $value; |
| | | // break; |
| | | // |
| | | // } |
| | | // } |
| | | // //Add in any args from the query string |
| | | // $search = 'query-'.$queryID; |
| | | // foreach ($_GET as $key => $value) { |
| | | // if (str_contains($key, $search)) { |
| | | // $key = str_replace($search, '', $key); |
| | | // if ($key === 'page') { |
| | | // $args['paged'] = (int)$value; |
| | | // } |
| | | // } |
| | | // } |
| | | // $loop = new WP_Query($args); |
| | | // } |
| | | |
| | | // $inner = $this->innerBlocks($block); |
| | | // foreach ($block['innerBlocks'] as $innerBlock) { |
| | | // switch ($innerBlock['blockName']) { |
| | | // case 'core/post-template': |
| | | // $inner .= '<ul class="item-grid">'; |
| | | // if ($loop->have_posts()) { |
| | | // while($loop->have_posts()) { |
| | | // $loop->the_post(); |
| | | // $inner .= $this->doBlocks |
| | | // } |
| | | // } |
| | | // $inner .= '</ul>'; |
| | | // break; |
| | | // } |
| | | // } |
| | | $queryID = $block['attrs']['queryId']; |
| | | $args = []; |
| | | $inherit = $block['attrs']['inherit']??false; |
| | | if ($inherit) { |
| | | global $wp_query; |
| | | $loop = $wp_query; |
| | | } else { |
| | | foreach ($block['attrs']['query'] as $key => $value) { |
| | | if (empty($value)) { |
| | | continue; |
| | | } |
| | | switch ($key) { |
| | | case 'postType': |
| | | if ($value === BASE.'progress'){ |
| | | $args['post_parent'] = 0; |
| | | } |
| | | $args['post_type'] = $value; |
| | | break; |
| | | case 'perPage': |
| | | $args['posts_per_page'] = $value; |
| | | break; |
| | | case 'orderBy': |
| | | $args['orderby'] = $value; |
| | | break; |
| | | case 'taxQuery': |
| | | $taxQuery = []; |
| | | foreach ($value as $tax => $terms) { |
| | | $taxQuery[] = [ |
| | | 'taxonomy' => $tax, |
| | | 'terms' => $terms |
| | | ]; |
| | | } |
| | | if (!empty($taxQuery)) { |
| | | $args['tax_query'] = $taxQuery; |
| | | if (count($taxQuery) > 1) { |
| | | $args['tax_query']['relation'] = 'OR'; |
| | | } |
| | | } |
| | | break; |
| | | case 'sticky': |
| | | if ($value === 'ignore') { |
| | | $args['ignore_sticky_posts'] = true; |
| | | } else if ($value === 'exclude'){ |
| | | $args['post__not_in'] = get_option('sticky_posts'); |
| | | } else if ($value === 'only') { |
| | | $args['include'] = get_option('sticky_posts'); |
| | | } |
| | | break; |
| | | case 'search': |
| | | $args['s'] = $value; |
| | | break; |
| | | default: |
| | | $args[$key] = $value; |
| | | break; |
| | | |
| | | } |
| | | } |
| | | //Add in any args from the query string |
| | | $search = 'query-'.$queryID; |
| | | foreach ($_GET as $key => $value) { |
| | | if (str_contains($key, $search)) { |
| | | $key = str_replace($search, '', $key); |
| | | if ($key === 'page') { |
| | | $args['paged'] = (int)$value; |
| | | } |
| | | } |
| | | } |
| | | $loop = new WP_Query($args); |
| | | } |
| | | |
| | | $inner = ''; |
| | | |
| | | foreach ($block['innerBlocks'] as $innerBlock) { |
| | | switch ($innerBlock['blockName']) { |
| | | case 'core/post-template': |
| | | $inner .= '<section class="item-grid">'; |
| | | if ($loop->have_posts()) { |
| | | while($loop->have_posts()) { |
| | | $loop->the_post(); |
| | | $postType = get_post_type(); |
| | | $inner .= '<div class="item '.jvbNoBase($postType).'">'.$this->innerBlocks($innerBlock).'</div>'; |
| | | } |
| | | } |
| | | $inner .= '</section>'; |
| | | break; |
| | | |
| | | } |
| | | } |
| | | |
| | | |
| | | |
| | | $tagName = (array_key_exists('tagName', $block['attrs'])) ? $block['attrs']['tagName'] : 'div'; |
| | | $out = '<'.$tagName.' class="loop">'.$this->innerBlocks($block).'</'.$tagName.'>'; |
| | | // if ($inherit) { |
| | | // wp_reset_postdata(); |
| | | // } |
| | | $out = '<'.$tagName.' class="loop">'.$inner.'</'.$tagName.'>'; |
| | | if ($inherit) { |
| | | wp_reset_postdata(); |
| | | } |
| | | return $out; |
| | | } |
| | | |
| | |
| | | protected function renderGrid(): void |
| | | { |
| | | ?> |
| | | <div class="item-grid"></div> |
| | | <div class="item-grid"> |
| | | <?php |
| | | $total = count($this->config['content']) - 1; |
| | | for ($i = 1; $i <= 36; $i++) { |
| | | $rand = rand(0, $total); |
| | | $config = Features::getConfig($this->config['content'][$rand]); |
| | | $icon = jvbIcon($config['icon']??'logo-triangle'); |
| | | ?> |
| | | <div class="placeholder"><?=apply_filters('jvbFeedPlaceholder', $icon) ?></div> |
| | | <?php |
| | | } |
| | | ?> |
| | | </div> |
| | | <?php |
| | | } |
| | | |
| | |
| | | <p>Try tweaking those filters a bit.</p> |
| | | <p>Edmonton\'s got talent - let\'s find it.</p> |
| | | </div>', $this->config). '</template>'; |
| | | |
| | | echo '<template class="placeholderTemplate"><div class="placeholder">'.apply_filters('jvbFeedPlaceholder', '').'</div></template>'; |
| | | echo '<template class="placeholderTemplate"><div class="placeholder">'.apply_filters('jvbFeedPlaceholder', jvbIcon('logo-triangle')).'</div></template>'; |
| | | } |
| | | |
| | | /** |
| | |
| | | </div> |
| | | </div> |
| | | <div class="m-actions row nowrap"> |
| | | <button type="button" data-action="restore-all"> |
| | | <?= jvbIcon('infinity') ?> |
| | | Restore All |
| | | </button> |
| | | <button type="button" data-action="restore"> |
| | | <?= jvbIcon('arrow-counter-clockwise') ?> |
| | | Restore Selected |
| | |
| | | protected $wpdb; |
| | | protected string $jane_clients_table; |
| | | protected array $import_stats = []; |
| | | |
| | | protected int $lineNumber = 0; |
| | | protected array $skipped_details = []; |
| | | // CSV column mapping |
| | | protected array $column_map = [ |
| | | 'patient_guid' => 'patient_guid', |
| | |
| | | 'last_name' => 'Last Name', |
| | | 'email' => 'Email', |
| | | ]; |
| | | protected array $headers = []; |
| | | |
| | | public function __construct() |
| | | { |
| | |
| | | * |
| | | * @param string $file_path Path to the CSV file |
| | | * @param array $options Import options (e.g., update_existing, send_welcome_email) |
| | | * @return array Import results with stats and errors |
| | | * @return array|WP_Error Import results with stats and errors |
| | | */ |
| | | public function importFromCSV(string $file_path, array $options = []): array |
| | | public function importFromCSV(string $file_path, array $options = []): array|WP_Error |
| | | { |
| | | $this->skipped_details = []; |
| | | $this->lineNumber = 0; // Reset line number |
| | | |
| | | // Initialize stats |
| | | $this->import_stats = [ |
| | | 'total_rows' => 0, |
| | |
| | | 'updated' => 0, |
| | | 'skipped' => 0, |
| | | 'errors' => [], |
| | | 'unmatched_emails' => [] |
| | | 'unmatched_emails' => [], |
| | | 'skipped_details' => [] // Add this |
| | | ]; |
| | | |
| | | // Validate file exists |
| | |
| | | fclose($handle); |
| | | return new WP_Error('invalid_csv', 'CSV file is empty or invalid'); |
| | | } |
| | | $this->headers = array_map('trim', $headers); |
| | | |
| | | // Map column indices |
| | | $column_indices = $this->mapColumnIndices($headers); |
| | | $column_indices = $this->mapColumnIndices($this->headers); |
| | | if (is_wp_error($column_indices)) { |
| | | fclose($handle); |
| | | return $column_indices; |
| | |
| | | // Process each row |
| | | while (($row = fgetcsv($handle)) !== false) { |
| | | $this->import_stats['total_rows']++; |
| | | $this->lineNumber++; |
| | | |
| | | $result = $this->processClientRow($row, $column_indices, [ |
| | | 'update_existing' => $update_existing, |
| | | 'send_welcome_email' => $send_welcome_email, |
| | | 'create_users' => $create_users |
| | | 'create_users' => $create_users, |
| | | 'default_role' => $options['default_role'] ?? null |
| | | ]); |
| | | |
| | | if (is_wp_error($result)) { |
| | |
| | | |
| | | fclose($handle); |
| | | |
| | | // Add skipped details to stats |
| | | $this->import_stats['skipped_details'] = $this->skipped_details; |
| | | |
| | | return $this->import_stats; |
| | | } |
| | | |
| | |
| | | |
| | | // Validate required fields |
| | | if (empty($patient_guid) || empty($email)) { |
| | | $this->skipped_details[] = [ |
| | | 'name' => $first_name . ' ' . $last_name, |
| | | 'guid' => $patient_guid, |
| | | 'email' => $email, |
| | | 'reason' => 'Missing guid or email', |
| | | 'line' => $this->lineNumber |
| | | ]; |
| | | return new WP_Error('invalid_data', 'Missing patient_guid or email'); |
| | | } |
| | | |
| | | // Sanitize email |
| | | $email = sanitize_email($email); |
| | | if (!is_email($email)) { |
| | | $this->skipped_details[] = [ |
| | | 'name' => $first_name . ' ' . $last_name, |
| | | 'guid' => $patient_guid, |
| | | 'email' => $email, |
| | | 'reason' => 'Invalid Email', |
| | | 'line' => $this->lineNumber |
| | | ]; |
| | | return new WP_Error('invalid_email', 'Invalid email address: ' . $email); |
| | | } |
| | | |
| | | // Check if client already exists in mapping table |
| | | $existing_mapping = $this->getClientByGuid($patient_guid); |
| | | |
| | | // Build full data array with all CSV columns for meta storage |
| | | $data = []; |
| | | foreach ($row as $index => $value) { |
| | | $header = $this->headers[$index] ?? 'unknown_' . $index; |
| | | $data[$header] = trim($value); |
| | | } |
| | | |
| | | // Ensure these keys exist for backward compatibility |
| | | $data['patient_guid'] = $patient_guid; |
| | | $data['First Name'] = $first_name; |
| | | $data['Last Name'] = $last_name; |
| | | $data['Email'] = $email; |
| | | |
| | | // Find or create WordPress user |
| | | $user = get_user_by('email', $email); |
| | | $action = 'existing'; |
| | | |
| | | if (!$user && $options['create_users']) { |
| | | if ($user) { |
| | | if ($options['update_existing']) { |
| | | $this->updateExistingClient($user->ID, $data, $options); |
| | | $action = 'updated'; |
| | | } else { |
| | | $this->skipped_details[] = [ |
| | | 'name' => $first_name . ' ' . $last_name, |
| | | 'email' => $email, |
| | | 'reason' => 'User already exists (update not enabled)', |
| | | 'line' => $this->lineNumber |
| | | ]; |
| | | return new WP_Error('user_exists', 'User already exists'); |
| | | } |
| | | } elseif ($options['create_users']) { |
| | | // Create new user |
| | | $user_id = $this->createWordPressUser($email, $first_name, $last_name, $options['send_welcome_email']); |
| | | $user_id = $this->createClientUser($data, $options); |
| | | |
| | | if (is_wp_error($user_id)) { |
| | | $this->skipped_details[] = [ |
| | | 'name' => $first_name . ' ' . $last_name, |
| | | 'guid' => $patient_guid, |
| | | 'email' => $email, |
| | | 'reason' => $user_id->get_error_message(), |
| | | 'line' => $this->lineNumber |
| | | ]; |
| | | return $user_id; |
| | | } |
| | | |
| | | $user = get_user_by('ID', $user_id); |
| | | $action = 'created'; |
| | | |
| | | } elseif (!$user) { |
| | | } else { |
| | | // User doesn't exist and we're not creating users |
| | | $this->skipped_details[] = [ |
| | | 'name' => $first_name . ' ' . $last_name, |
| | | 'guid' => $patient_guid, |
| | | 'email' => $email, |
| | | 'reason' => 'User not found and create_users is false', |
| | | 'line' => $this->lineNumber |
| | | ]; |
| | | $this->import_stats['unmatched_emails'][] = $email; |
| | | return new WP_Error('user_not_found', 'User not found and create_users is false'); |
| | | |
| | | } else { |
| | | $action = 'existing'; |
| | | } |
| | | |
| | | // Update or insert client mapping |
| | |
| | | 'last_name' => $last_name, |
| | | 'email' => $email |
| | | ]); |
| | | $action = 'updated'; |
| | | } |
| | | } else { |
| | | $this->insertClientMapping([ |
| | |
| | | 'last_name' => $last_name, |
| | | 'email' => $email |
| | | ]); |
| | | if ($action !== 'created') { |
| | | $action = 'mapped'; |
| | | } |
| | | } |
| | | |
| | | return [ |
| | |
| | | } |
| | | |
| | | /** |
| | | * Create a new WordPress user |
| | | * Create a new client user from Jane App data |
| | | * |
| | | * @param string $email User email |
| | | * @param string $first_name First name |
| | | * @param string $last_name Last name |
| | | * @param bool $send_welcome_email Whether to send welcome email |
| | | * @param array $data Client data from CSV |
| | | * @param array $options Import options |
| | | * @return int|WP_Error User ID or error |
| | | */ |
| | | protected function createWordPressUser(string $email, string $first_name, string $last_name, bool $send_welcome_email = false): int|WP_Error |
| | | protected function createClientUser(array $data, array $options) |
| | | { |
| | | $email = sanitize_email($data['Email']); |
| | | $first_name = sanitize_text_field($data['First Name'] ?? ''); |
| | | $last_name = sanitize_text_field($data['Last Name'] ?? ''); |
| | | |
| | | // Generate username from email |
| | | $username = $this->generateUsername($email); |
| | | $username = sanitize_user(substr($email, 0, strpos($email, '@'))); |
| | | |
| | | // Generate random password |
| | | $password = wp_generate_password(12, true, true); |
| | | // Ensure unique username |
| | | $base_username = $username; |
| | | $counter = 1; |
| | | while (username_exists($username)) { |
| | | $username = $base_username . $counter; |
| | | $counter++; |
| | | } |
| | | |
| | | $userdata = [ |
| | | // Get the role from options with proper fallback |
| | | $role = $options['default_role'] ?? get_option(BASE . 'client_import_role', JVB_USER); |
| | | |
| | | // Ensure role exists, fallback to JVB_USER if not |
| | | if (!get_role($role)) { |
| | | $role = JVB_USER; |
| | | } |
| | | |
| | | // Create user |
| | | $user_data = [ |
| | | 'user_login' => $username, |
| | | 'user_email' => $email, |
| | | 'user_pass' => $password, |
| | | 'first_name' => $first_name, |
| | | 'last_name' => $last_name, |
| | | 'display_name' => trim($first_name . ' ' . $last_name), |
| | | 'role' => apply_filters(BASE . 'jane_import_default_role', 'customer') |
| | | 'role' => $role, |
| | | 'user_pass' => wp_generate_password(16, true, true) |
| | | ]; |
| | | |
| | | $user_id = wp_insert_user($userdata); |
| | | $user_id = wp_insert_user($user_data); |
| | | |
| | | if (is_wp_error($user_id)) { |
| | | return $user_id; |
| | | } |
| | | |
| | | // Send welcome email if requested |
| | | if ($send_welcome_email) { |
| | | wp_send_new_user_notifications($user_id, 'both'); |
| | | // Store Jane App data as user meta |
| | | $this->storeClientMeta($user_id, $data); |
| | | |
| | | // Send welcome email if enabled |
| | | if ($options['send_welcome_email'] ?? false) { |
| | | wp_new_user_notification($user_id, null, 'user'); |
| | | } |
| | | |
| | | do_action(BASE . 'jane_client_created', $user_id, $userdata); |
| | | |
| | | return $user_id; |
| | | } |
| | | |
| | | /** |
| | | * Store Jane App client data as user meta |
| | | * |
| | | * @param int $user_id |
| | | * @param array $data |
| | | */ |
| | | protected function storeClientMeta(int $user_id, array $data): void |
| | | { |
| | | // Store Jane App specific fields |
| | | if (!empty($data['patient_guid'])) { |
| | | update_user_meta($user_id, BASE . 'jane_patient_guid', sanitize_text_field($data['patient_guid'])); |
| | | } |
| | | |
| | | if (!empty($data['Patient Number'])) { |
| | | update_user_meta($user_id, BASE . 'jane_patient_number', sanitize_text_field($data['Patient Number'])); |
| | | } |
| | | |
| | | if (!empty($data['Member Since'])) { |
| | | update_user_meta($user_id, BASE . 'member_since', sanitize_text_field($data['Member Since'])); |
| | | } |
| | | |
| | | if (!empty($data['Mobile Phone'])) { |
| | | update_user_meta($user_id, BASE . 'phone', sanitize_text_field($data['Mobile Phone'])); |
| | | } |
| | | |
| | | if (!empty($data['Birth Date'])) { |
| | | update_user_meta($user_id, BASE . 'birth_date', sanitize_text_field($data['Birth Date'])); |
| | | } |
| | | |
| | | if (!empty($data['Referral Source'])) { |
| | | update_user_meta($user_id, BASE . 'referral_source', sanitize_text_field($data['Referral Source'])); |
| | | } |
| | | |
| | | // Store full Jane App data as JSON for reference |
| | | update_user_meta($user_id, BASE . 'jane_import_data', $data); |
| | | update_user_meta($user_id, BASE . 'jane_import_date', current_time('mysql')); |
| | | } |
| | | |
| | | /** |
| | | * Update existing client with Jane App data |
| | | * |
| | | * @param int $user_id |
| | | * @param array $data |
| | | * @param array $options |
| | | */ |
| | | protected function updateExistingClient(int $user_id, array $data, array $options): void |
| | | { |
| | | // Update user fields if they're empty |
| | | $user_data = ['ID' => $user_id]; |
| | | |
| | | $current_user = get_user_by('ID', $user_id); |
| | | |
| | | if (empty($current_user->first_name) && !empty($data['First Name'])) { |
| | | $user_data['first_name'] = sanitize_text_field($data['First Name']); |
| | | } |
| | | |
| | | if (empty($current_user->last_name) && !empty($data['Last Name'])) { |
| | | $user_data['last_name'] = sanitize_text_field($data['Last Name']); |
| | | } |
| | | |
| | | if (count($user_data) > 1) { |
| | | wp_update_user($user_data); |
| | | } |
| | | |
| | | // Always update meta data |
| | | $this->storeClientMeta($user_id, $data); |
| | | } |
| | | |
| | | /** |
| | | * Generate unique username from email |
| | | * |
| | | * @param string $email Email address |
| | |
| | | protected string $rewards_table; |
| | | protected ReferralManager $referral_manager; |
| | | protected array $import_stats = []; |
| | | protected array $skipped_details = []; |
| | | |
| | | // CSV column mapping |
| | | protected array $column_map = [ |
| | |
| | | * |
| | | * @param string $file_path Path to the CSV file |
| | | * @param array $options Import options |
| | | * @return array Import results with stats and errors |
| | | * @return array|WP_Error Import results with stats and errors |
| | | */ |
| | | public function importFromCSV(string $file_path, array $options = []): array |
| | | public function importFromCSV(string $file_path, array $options = []): array|WP_Error |
| | | { |
| | | // Initialize stats |
| | | $this->import_stats = [ |
| | |
| | | return new WP_Error('invalid_csv', 'CSV file is empty or invalid'); |
| | | } |
| | | |
| | | // Trim whitespace from headers to handle inconsistent CSV formats |
| | | $headers = array_map('trim', $headers); |
| | | |
| | | // Map column indices |
| | | $column_indices = $this->mapColumnIndices($headers); |
| | | if (is_wp_error($column_indices)) { |
| | |
| | | return $column_indices; |
| | | } |
| | | |
| | | // Get Patient column index for error reporting |
| | | $patient_name_index = array_search('Patient', $headers); |
| | | |
| | | // Start transaction for data integrity |
| | | $this->wpdb->query('START TRANSACTION'); |
| | | |
| | | try { |
| | | // Process each row |
| | | $row_number = 1; // Header is row 1, data starts at row 2 |
| | | while (($row = fgetcsv($handle)) !== false) { |
| | | $row_number++; |
| | | $this->import_stats['total_rows']++; |
| | | |
| | | $result = $this->processSalesRow($row, $column_indices, [ |
| | |
| | | ]); |
| | | |
| | | if (is_wp_error($result)) { |
| | | $this->import_stats['errors'][] = [ |
| | | 'row' => $this->import_stats['total_rows'], |
| | | 'error' => $result->get_error_message() |
| | | $error_data = [ |
| | | 'row' => $row_number, |
| | | 'patient_guid' => trim($row[$column_indices['patient_guid']] ?? 'Unknown'), |
| | | 'patient_name' => trim($row[$patient_name_index] ?? 'Unknown'), |
| | | 'item' => trim($row[$column_indices['item']] ?? 'Unknown'), |
| | | 'reason' => $result->get_error_message() |
| | | ]; |
| | | |
| | | $this->import_stats['errors'][] = $error_data; |
| | | $this->import_stats['skipped']++; |
| | | } else { |
| | | $this->import_stats['processed']++; |
| | |
| | | |
| | | $result = json_decode(wp_remote_retrieve_body($response), true); |
| | | |
| | | error_log('Cloudflare Result: '.print_r($result, true)); |
| | | |
| | | // If we get a response with success field (even if false), connection works |
| | | return array_key_exists('success', $result) && $result['success']; |
| | | return array_key_exists('success', $result); |
| | | } |
| | | |
| | | /** |
| | |
| | | */ |
| | | protected array $credentials = []; // Service credentials (API keys, tokens, etc.) |
| | | protected ?int $userID = null; // User context for user-specific integrations |
| | | |
| | | private bool $token_refresh_attempted = false; // Circuit breaker for token refresh |
| | | |
| | | protected array $fields = []; // The fields to generate that become credentials |
| | | protected array $advanced = []; // The fields that are optional settings |
| | |
| | | if (!empty($this->credentials['expires_at'])) { |
| | | $expires_at = intval($this->credentials['expires_at']); |
| | | if ($expires_at <= time()) { |
| | | // Token expired, try to refresh |
| | | if (!empty($this->credentials['refresh_token'])) { |
| | | return $this->refreshOAuthToken(); |
| | | } |
| | | return false; |
| | | } |
| | | } |
| | |
| | | if ($this->isOAuthService && $this->hasOAuthCredentials()) { |
| | | // Check if token is expired first |
| | | if (!$this->isOAuthValid()) { |
| | | // Only attempt refresh once per request |
| | | if (!$this->token_refresh_attempted) { |
| | | $this->token_refresh_attempted = true; |
| | | $this->logDebug('OAuth token expired, attempting refresh'); |
| | | |
| | | if (!$this->refreshOAuthToken()) { |
| | | $this->logError('Failed to refresh expired OAuth token'); |
| | | $this->logError('Failed to refresh expired OAuth token - stopping execution'); |
| | | // Token refresh failed - DO NOT continue making API requests |
| | | return; |
| | | } |
| | | } else { |
| | | // Already attempted refresh in this request |
| | | $this->logDebug('Token refresh already attempted, skipping'); |
| | | return; |
| | | } |
| | | } |
| | | // Check if we should proactively refresh (before expiry) |
| | | elseif ($this->shouldRefreshToken()) { |
| | | elseif ($this->shouldRefreshToken() && !$this->token_refresh_attempted) { |
| | | $this->token_refresh_attempted = true; |
| | | $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 |
| | | // Not critical - token is still valid, so continue |
| | | } |
| | | } |
| | | } |
| | |
| | | // Switch context |
| | | $this->userID = $user_id; |
| | | $this->credentials = []; |
| | | $this->resetTokenRefreshFlag(); // ADD THIS LINE |
| | | |
| | | $this->ensureInitialized(); |
| | | } |
| | |
| | | $response = $this->makeOAuthRequest('POST', $this->oauth['token'], $request_data); |
| | | |
| | | if (is_wp_error($response)) { |
| | | $this->logError('Failed to refresh Square token', [ |
| | | $this->logError('Failed to refresh OAuth token for '.$this->service_name, [ |
| | | 'error' => $response->get_error_message() |
| | | ]); |
| | | return false; |
| | |
| | | $this->credentials['access_token'] = $response['access_token']; |
| | | $this->credentials['expires_at'] = time() + ($response['expires_in'] ?? 2592000); // 30 days |
| | | |
| | | // Note: Square returns the SAME refresh token |
| | | // Note: Some services return the SAME refresh token |
| | | if (isset($response['refresh_token'])) { |
| | | $this->credentials['refresh_token'] = $response['refresh_token']; |
| | | } |
| | |
| | | throw new Exception('Failed to save JPEG image'); |
| | | } |
| | | } |
| | | /** |
| | | * Reset token refresh attempt flag |
| | | * Called automatically when switching users |
| | | */ |
| | | protected function resetTokenRefreshFlag(): void |
| | | { |
| | | $this->token_refresh_attempted = false; |
| | | } |
| | | } |
| | |
| | | 'icon' => jvbCSSIcon('settings'), |
| | | 'position' => 0 |
| | | ]; |
| | | $this->subpages = apply_filters('jvbAdminSubpages', []); |
| | | $this->subpages = get_option(BASE.'adminSubpage', []); |
| | | // delete_option(BASE.'admin_actions'); |
| | | // delete_option(BASE.'admin_subpages'); |
| | | // $this->getSubpages(); |
| | |
| | | add_filter(BASE.'admin_action_filter', [$this, 'handleCacheActions'], 10, 3); |
| | | |
| | | add_action('rest_api_init', [$this, 'registerRestRoutes']); |
| | | // Handle form submissions |
| | | add_action('admin_init', [$this, 'handleAdminPageSubmission']); |
| | | add_action('admin_notices', [$this, 'displayAdminNotices']); |
| | | } |
| | | |
| | | /** |
| | |
| | | </script> |
| | | <?php |
| | | } |
| | | |
| | | public static function addSubpage(string $key, array $value):void |
| | | { |
| | | $option = get_option(BASE.'adminSubpage', []); |
| | | if (empty($option) || !array_key_exists($key, $option)) { |
| | | $option[$key] = $value; |
| | | update_option(BASE.'adminSubpage', $option); |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * Handle admin page form submissions |
| | | * Fires after WordPress admin_init |
| | | */ |
| | | public function handleAdminPageSubmission(): void |
| | | { |
| | | // Only process on our admin pages |
| | | if (!isset($_GET['page']) || strpos($_GET['page'], BASE) !== 0) { |
| | | return; |
| | | } |
| | | |
| | | // Check for form submission |
| | | if ($_SERVER['REQUEST_METHOD'] !== 'POST' || !isset($_POST['submit'])) { |
| | | return; |
| | | } |
| | | |
| | | // Verify nonce |
| | | $nonce_field = BASE . 'admin_page_nonce'; |
| | | if (!isset($_POST['_wpnonce']) || !wp_verify_nonce($_POST['_wpnonce'], $nonce_field)) { |
| | | add_action('admin_notices', function() { |
| | | echo '<div class="notice notice-error"><p>Security check failed. Please try again.</p></div>'; |
| | | }); |
| | | return; |
| | | } |
| | | |
| | | $page_slug = sanitize_text_field($_GET['page']); |
| | | |
| | | // Allow other classes to handle their page submissions |
| | | $result = apply_filters('jvb_admin_page_submission', null, $page_slug, $_POST); |
| | | |
| | | // Store result in transient for after redirect |
| | | if (is_array($result)) { |
| | | set_transient(BASE . 'admin_notice_' . get_current_user_id(), $result, 30); |
| | | } |
| | | |
| | | // Redirect to prevent form resubmission (POST-Redirect-GET pattern) |
| | | wp_safe_redirect(add_query_arg(['page' => $page_slug], admin_url('admin.php'))); |
| | | exit; |
| | | } |
| | | |
| | | /** |
| | | * Display admin notices from form submissions |
| | | */ |
| | | public function displayAdminNotices(): void |
| | | { |
| | | $notice = get_transient(BASE . 'admin_notice_' . get_current_user_id()); |
| | | |
| | | if ($notice && is_array($notice)) { |
| | | delete_transient(BASE . 'admin_notice_' . get_current_user_id()); |
| | | |
| | | $type = $notice['success'] ? 'success' : 'error'; |
| | | $message = $notice['message'] ?? 'Settings saved.'; |
| | | |
| | | echo '<div class="notice notice-' . esc_attr($type) . ' is-dismissible"><p>' . esc_html($message) . '</p></div>'; |
| | | } |
| | | } |
| | | } |
| | |
| | | if ($page === '' || $page === 'dash') { |
| | | return; |
| | | } |
| | | |
| | | $page = $this->getCurrentPageTitle(); |
| | | // Check if page exists in allowed pages |
| | | $allowedPages = $this->getUserAllowedPages(); |
| | | if (!array_key_exists($page, $allowedPages)) { |
| | | if (!in_array($page, $allowedPages)) { |
| | | error_log("User not allowed to access page: {$page}"); |
| | | $this->redirectToDashboard(); |
| | | return; |
| | |
| | | if ($page === 'dash') { |
| | | continue; |
| | | } |
| | | $slug = $this->getSlug($slug, $page); |
| | | $existing = get_page_by_path($slug, OBJECT, BASE.'dash'); |
| | | if ($existing) { |
| | | continue; |
| | | } |
| | | $title = $page; |
| | | |
| | | $slug = $this->getSlug($slug, $page); |
| | | |
| | | $ID = wp_insert_post(array( |
| | | 'post_title' => $title, |
| | | 'post_name' => $slug, |
| | |
| | | return; |
| | | } |
| | | |
| | | wp_enqueue_script('jvb-loading'); |
| | | wp_enqueue_script('jvb-form'); |
| | | |
| | | // Consolidate all dashboard settings |
| | | wp_localize_script('jvb-loading', 'dashboardSettings', array( |
| | | 'loadingMessages' => array( |
| | | 'default' => 'Loading...', |
| | | 'error' => 'Failed to load page' |
| | | ), |
| | | 'strings' => array( |
| | | 'deleteConfirm' => 'Are you sure you want to delete this item?', |
| | | 'bulkDeleteConfirm' => 'Are you sure you want to delete these items?', |
| | | 'deleteSuccess' => 'Item(s) deleted successfully', |
| | | 'deleteError' => 'Error deleting item(s)', |
| | | 'saveSuccess' => 'Changes saved successfully', |
| | | 'saveError' => 'Error saving changes', |
| | | 'loadError' => 'Error loading content' |
| | | ), |
| | | 'currentUser' => array( |
| | | 'id' => $this->user->ID, |
| | | 'name' => $this->user->display_name, |
| | | 'role' => array_values($this->user->roles)[0] ?? '', |
| | | 'type' => str_replace(BASE, '', array_values($this->user->roles)[0]), |
| | | 'city' => '', // Add if needed, |
| | | 'artistID' => $this->userLink, |
| | | ) |
| | | )); |
| | | |
| | | wp_enqueue_script('jvb-selector'); |
| | | wp_enqueue_script('jvb-uploader'); |
| | | wp_enqueue_script('jvb-content'); |
| | |
| | | $message = apply_filters('jvbNewUserEmail', $message, $user); |
| | | |
| | | $message .= $this->signature; |
| | | $wp_new_user_notification_email['message'] = $this->getEmailTemplate($message, JVB_EMAIL['subjects']['newUser']); |
| | | $wp_new_user_notification_email['message'] = $this->getEmailTemplate($message, JVB_EMAIL['types']['newUser']['subject']?:'New User'); |
| | | |
| | | // Change the subject line |
| | | $prefix = JVB_EMAIL['types']['newUser']['showPrefix']??true; |
| | | $prefix = ($prefix) ? $this->prefix : ''; |
| | | $wp_new_user_notification_email['subject'] = $prefix.JVB_EMAIL['types']['newUser']['subject']; |
| | | $wp_new_user_notification_email['subject'] = $prefix.JVB_EMAIL['types']['newUser']['subject']?:'New User'; |
| | | |
| | | return $wp_new_user_notification_email; |
| | | } |
| | |
| | | { |
| | | $prefix = JVB_EMAIL['types']['resetPass']['showPrefix']??true; |
| | | $prefix = ($prefix) ? $this->prefix : ''; |
| | | return $prefix.JVB_EMAIL['types']['resetPass']['subject']; |
| | | return $prefix.JVB_EMAIL['types']['resetPass']['subject']?:'Password Reset'; |
| | | |
| | | } |
| | | |
| | |
| | | $email_change_email['message'] = $this->getEmailTemplate($content, 'Email Address Changed'); |
| | | $prefix = JVB_EMAIL['types']['emailChange']['showPrefix']??true; |
| | | $prefix = ($prefix) ? $this->prefix : ''; |
| | | $email_change_email['subject'] = $prefix.JVB_EMAIL['types']['emailChange']['subject']; |
| | | $email_change_email['subject'] = $prefix.JVB_EMAIL['types']['emailChange']['subject']?:'Email Address Changed'; |
| | | |
| | | |
| | | return $email_change_email; |
| | |
| | | $pass_change_email['message'] = $this->getEmailTemplate($content, 'Password Changed'); |
| | | $prefix = JVB_EMAIL['types']['passwordChange']['showPrefix']??true; |
| | | $prefix = ($prefix) ? $this->prefix : ''; |
| | | $pass_change_email['subject'] = $prefix.JVB_EMAIL['types']['passwordChange']['subject']; |
| | | $pass_change_email['subject'] = $prefix.JVB_EMAIL['types']['passwordChange']['subject']?:'Password Changed'; |
| | | |
| | | return $pass_change_email; |
| | | } |
| | |
| | | global $wpdb; |
| | | $this->wpdb = $wpdb; |
| | | $this->cache = CacheManager::for('referrals', WEEK_IN_SECONDS); |
| | | $this->referrals_table = BASE . 'referrals'; |
| | | $this->rewards_table = BASE . 'referral_rewards'; |
| | | $this->referrals_table = $wpdb->prefix . BASE . 'referrals'; |
| | | $this->rewards_table = $wpdb->prefix . BASE . 'referral_rewards'; |
| | | $this->magic_link = new MagicLinkManager(); |
| | | |
| | | $this->referralPage = $this->getReferralPageId(); |
| | |
| | | // Schedule cron jobs for reports |
| | | $this->registerCronJobs(); |
| | | |
| | | // Register admin subpage |
| | | // add_filter('jvbAdminSubpages', [$this, 'addSubpage'], 10, 1); |
| | | |
| | | // Add admin bar label for referral page |
| | | add_action('admin_bar_menu', [$this, 'addReferralPageLabel'], 999); |
| | | |
| | |
| | | |
| | | // Handle settings save |
| | | add_action('admin_init', [$this, 'registerSettings']); |
| | | // Handle admin page form submission |
| | | add_filter('jvb_admin_page_submission', [$this, 'handleAdminSubmission'], 10, 3); |
| | | } |
| | | |
| | | public function addLoginInputs(string $action):void |
| | |
| | | */ |
| | | public function getUserReferralCode(int $user_id, ?string $custom_code = null) |
| | | { |
| | | $user = get_user_by('ID', $user_id); |
| | | $user = get_userdata($user_id); |
| | | if (!$user) { |
| | | return new WP_Error('invalid_user', 'User not found'); |
| | | } |
| | |
| | | return $actions; |
| | | } |
| | | |
| | | /** |
| | | * Display referral sidebar for non-logged-in users |
| | | */ |
| | | function getUnloggedInReferral(): string |
| | | { |
| | | ob_start(); |
| | | JVB()->connect('cloudflare')->renderTurnstile(); |
| | | $turnstile = ob_get_clean(); |
| | | |
| | | $meta = new MetaForm(); |
| | | $codeForm = '<form id="referral-code-form"> |
| | | $reward_text = $this->getRewardText(true); |
| | | |
| | | // Pre-fill code if from referral link |
| | | $prefill_code = $_GET['ref'] ?? ''; |
| | | $referrer_name = ''; |
| | | if ($prefill_code) { |
| | | $referrer = $this->getUserByReferralCode($prefill_code); |
| | | $referrer_name = $referrer ? strtok($referrer->display_name, ' ') : ''; |
| | | } |
| | | |
| | | $codeForm = '<div class="referral-reward-banner"> |
| | | '.jvbIcon('confetti').' |
| | | <h4>Get ' . esc_html($reward_text) . '!</h4> |
| | | ' . ($referrer_name ? '<p>' . esc_html($referrer_name) . ' invited you to join us</p>' : '') . ' |
| | | </div> |
| | | <form id="referral-code-form"> |
| | | '.jvbFormStatus().$meta->return('referral_name', null, [ |
| | | 'required' => true, |
| | | 'type' => 'text', |
| | |
| | | 'placeholder'=> 'look@me.com', |
| | | 'autocomplete'=> 'email' |
| | | ]). |
| | | $meta->return('referral_code', null, [ |
| | | $meta->return('referral_code', $prefill_code, [ |
| | | 'required' => true, |
| | | 'type' => 'text', |
| | | 'label' => 'Referral Code', |
| | | 'pattern' => '[A-Za-z0-9]+', |
| | | 'maxLength' => 20, |
| | | 'autocomplete'=>'off' |
| | | 'autocomplete'=>'off', |
| | | 'data-referrer' => $referrer_name |
| | | ]).' |
| | | <button type="button" class="button-secondary check-code-btn"> |
| | | '.jvbIcon('check-circle', ['size' => 16]).' Verify Code |
| | | </button> |
| | | <div class="code-status" hidden></div> |
| | | <button type="submit"> |
| | | Get Started |
| | | </button> |
| | |
| | | We\'ll send you a link to complete your registration. |
| | | </p> |
| | | '.$turnstile.' |
| | | </form><div class="success-content" hidden> |
| | | </form> |
| | | <div class="success-content" hidden> |
| | | <h3>Check Your Email!</h3> |
| | | <p>We\'ve sent you a magic link to complete your registration. Click the link to activate your account and claim your reward!</p> |
| | | <p class="hint">Can\'t find it? Check your spam folder.</p> |
| | |
| | | <p class="hint">Can\'t find it? Check your spam folder.</p> |
| | | </div>'; |
| | | |
| | | $footer = '<div class="referral-footer"> |
| | | <a href="' . wp_login_url() . '" class="text-link">Prefer to use a password?</a> |
| | | </div>'; |
| | | $tabs = [ |
| | | 'enterCode' => [ |
| | | 'title' => 'Have a Code?', |
| | | 'description' => [ |
| | | 'Enter the code given to you to get 20% off your first treatment!' |
| | | 'Enter your referral code to get started' |
| | | ], |
| | | 'content' => $codeForm |
| | | ], |
| | | 'login' => [ |
| | | 'title' => 'Login', |
| | | 'description' => [ |
| | | 'Login to see your rewards' |
| | | 'Already have an account? Log in to see your rewards' |
| | | ], |
| | | 'content' => $loginForm |
| | | 'content' => $loginForm.$footer |
| | | ] |
| | | ]; |
| | | |
| | | |
| | | |
| | | return jvbRenderTabs($tabs, true); |
| | | } |
| | | |
| | | |
| | | protected function getReferralSuccessMessage(string $code): string |
| | | { |
| | | $referrer = $this->getUserByReferralCode($code); |
| | |
| | | return ob_get_clean(); |
| | | } |
| | | |
| | | /** |
| | | * Display referral sidebar for logged-in users |
| | | */ |
| | | public function getLoggedInReferral(int $user_id):string |
| | | { |
| | | // Logged-in user widget |
| | | $referral_code = get_user_meta($user_id, BASE . 'referral_code', true); |
| | | |
| | | // Generate code if user doesn't have one |
| | | if (empty($referral_code)) { |
| | | $referral_code = $this->getUserReferralCode($user_id); |
| | | if (is_wp_error($referral_code)) { |
| | | return ''; |
| | | } |
| | | } |
| | | |
| | | $share_url = $this->getShareURL($referral_code); |
| | | |
| | | ob_start(); |
| | | ?> |
| | | <header> |
| | | <h3>Share the ♡</h3> |
| | | <p>Invite your friends.</p> |
| | | <p>Earn rewards when they book!</p> |
| | | <p>Invite friends. Earn rewards.</p> |
| | | </header> |
| | | |
| | | <div class="row even share-buttons"> |
| | | <a href="mailto:?subject=<?php echo urlencode('Check out ' . get_bloginfo('name')); ?>&body=<?php echo urlencode('I thought you might be interested: ' . $share_url); ?>" |
| | | class="share-btn email-share"> |
| | | <?php echo jvbIcon('envelope', ['size' => 20]); ?> |
| | | Email |
| | | </a> |
| | | <a href="https://www.facebook.com/sharer/sharer.php?u=<?php echo urlencode($share_url); ?>" |
| | | target="_blank" |
| | | rel="noopener noreferrer" |
| | | class="share-btn facebook-share"> |
| | | <?php echo jvbIcon('facebook-logo', ['size' => 20]); ?> |
| | | Facebook |
| | | </a> |
| | | <a href="https://twitter.com/intent/tweet?url=<?php echo urlencode($share_url); ?>&text=<?php echo urlencode('Check this out!'); ?>" |
| | | target="_blank" |
| | | rel="noopener noreferrer" |
| | | class="share-btn twitter-share"> |
| | | <?php echo jvbIcon('twitter-logo', ['size' => 20]); ?> |
| | | Twitter |
| | | </a> |
| | | </div> |
| | | <?php $this->getShareButtons($user_id); ?> |
| | | |
| | | <div class="copy-section"> |
| | | <h4>Your Referral Link</h4> |
| | | <div class="row btw"> |
| | | <code id="your-referral-link"><?= esc_url($share_url)?></code> |
| | | <button type="button" class="copy" data-target="your-referral-link"> |
| | | Copy Link |
| | | <div class="copy-group"> |
| | | <code id="referral-link" class="copy-target"><?= esc_url($share_url) ?></code> |
| | | <button type="button" class="copy-btn" data-target="referral-link" aria-label="Copy referral link"> |
| | | <?php echo jvbIcon('copy', ['size' => 16]); ?> |
| | | </button> |
| | | </div> |
| | | |
| | | |
| | | <h4>Your Code</h4> |
| | | <div class="row btw"> |
| | | <code id="your-referral-code"><?=esc_html($referral_code)?></code> |
| | | <button type="button" class="copy" data-target="your-referral-code"> |
| | | Copy Code |
| | | <div class="copy-group"> |
| | | <code id="referral-code" class="copy-target"><?= esc_html($referral_code) ?></code> |
| | | <button type="button" class="copy-btn" data-target="referral-code" aria-label="Copy referral code"> |
| | | <?php echo jvbIcon('copy', ['size' => 16]); ?> |
| | | </button> |
| | | </div> |
| | | </div> |
| | | |
| | | <div class="row btw referral-stats"> |
| | | <div class="stat-item"> |
| | | <span class="stat-value" data-stat="total">-</span> |
| | | <div class="recent-referrals-section"> |
| | | <h4>Recent Referrals</h4> |
| | | <div class="recent-referrals-list" data-user-id="<?= $user_id ?>"> |
| | | <div class="loading">Loading...</div> |
| | | </div> |
| | | </div> |
| | | |
| | | <div class="stats-summary"> |
| | | <div class="stat-row"> |
| | | <span class="stat-label">Total Referrals</span> |
| | | <span class="stat-value" data-stat="total">-</span> |
| | | </div> |
| | | <div class="stat-item"> |
| | | <span class="stat-value" data-stat="treated">-</span> |
| | | <div class="stat-row"> |
| | | <span class="stat-label">Successful</span> |
| | | <span class="stat-value" data-stat="treated">-</span> |
| | | </div> |
| | | <div class="stat-item"> |
| | | <span class="stat-value" data-stat="pending">-</span> |
| | | <div class="stat-row"> |
| | | <span class="stat-label">Pending</span> |
| | | <span class="stat-value" data-stat="pending">-</span> |
| | | </div> |
| | | <div class="stat-item"> |
| | | <span class="stat-value" data-stat="rewards">$0.00</span> |
| | | <div class="stat-row highlight"> |
| | | <span class="stat-label">Available Rewards</span> |
| | | <span class="stat-value" data-stat="rewards">$0.00</span> |
| | | </div> |
| | | </div> |
| | | |
| | | <a href="<?= get_home_url(null, '/dash/referrals')?>" class="view-dashboard-btn"> |
| | | Dashboard <?= jvbIcon('arrow-right', ['size' => 16]); ?> |
| | | </a> |
| | | |
| | | <?php |
| | | return ob_get_clean(); |
| | | } |
| | |
| | | * @param array $subpages |
| | | * @return array |
| | | */ |
| | | public function addSubpage(array $subpages): array |
| | | public static function addSubpage():void |
| | | { |
| | | $subpages[] = [ |
| | | 'page_title' => 'Referral Settings', |
| | | $subpage = [ |
| | | 'page_title' => 'Referral System', |
| | | 'menu_title' => 'Referrals', |
| | | 'capability' => 'manage_options', |
| | | 'menu_slug' => 'jvb-referrals', |
| | | 'callback' => [$this, 'renderAdminPage'], |
| | | 'icon' => 'users', |
| | | 'menu_slug' => BASE . 'referral-admin', |
| | | 'callback' => [self::class, 'renderAdminPageStatic'] |
| | | ]; |
| | | AdminPages::addSubPage(BASE.'referral-admin', $subpage); |
| | | } |
| | | |
| | | return $subpages; |
| | | /** |
| | | * Static wrapper for renderAdminPage |
| | | * Called by WordPress when admin page is rendered |
| | | */ |
| | | public static function renderAdminPageStatic(): void |
| | | { |
| | | // Get the properly initialized instance from JVB singleton |
| | | JVB()->referrals()->renderAdminPage(); |
| | | } |
| | | |
| | | /** |
| | |
| | | */ |
| | | public function renderAdminPage(): void |
| | | { |
| | | // Handle form submission |
| | | if (isset($_POST['submit']) && check_admin_referer(BASE . 'referral_settings_nonce')) { |
| | | update_option(BASE . 'referral_page_id', absint($_POST[BASE . 'referral_page_id'] ?? 0)); |
| | | ?> |
| | | <div class="wrap jvb-admin-wrap"> |
| | | <h1>Referral System Management</h1> |
| | | |
| | | $reward_settings = [ |
| | | 'referrer_reward_applies_to' => sanitize_text_field($_POST['referrer_reward_applies_to'] ?? 'per_user'), |
| | | 'referrer_reward_amount' => floatval($_POST['referrer_reward_amount'] ?? 25.00), |
| | | 'referrer_reward_type' => sanitize_text_field($_POST['referrer_reward_type'] ?? 'fixed'), |
| | | 'referee_reward_type' => sanitize_text_field($_POST['referee_reward_type'] ?? 'percentage'), |
| | | 'referee_reward_amount' => floatval($_POST['referee_reward_amount'] ?? 20), |
| | | 'referee_reward_applies_to' => sanitize_text_field($_POST['referee_reward_applies_to'] ?? 'first_order'), |
| | | ]; |
| | | <!-- CSV Upload Section --> |
| | | <div class="card"> |
| | | <h2>Import Data from Jane App</h2> |
| | | <p>Upload your exported CSV files from Jane App to sync client and sales data.</p> |
| | | |
| | | update_option(BASE . 'referral_reward_settings', $this->sanitizeRewardSettings($reward_settings)); |
| | | <div class="jvb-upload-section" style="display: grid; grid-template-columns: 1fr 1fr; gap: 20px; margin-top: 20px;"> |
| | | <!-- Client List Upload --> |
| | | <div class="jvb-upload-box"> |
| | | <h3>Client List</h3> |
| | | <form id="client-upload-form" enctype="multipart/form-data"> |
| | | <input type="file" |
| | | name="client_file" |
| | | id="client_file" |
| | | accept=".csv" |
| | | required /> |
| | | <button type="submit" class="button button-primary" style="margin-top: 10px;"> |
| | | Upload Clients |
| | | </button> |
| | | </form> |
| | | <div id="client-upload-status" style="margin-top: 10px;"></div> |
| | | </div> |
| | | |
| | | echo '<div class="notice notice-success is-dismissible"><p>Settings saved successfully.</p></div>'; |
| | | <!-- Sales Export Upload --> |
| | | <div class="jvb-upload-box"> |
| | | <h3>Sales Export</h3> |
| | | <form id="sales-upload-form" enctype="multipart/form-data"> |
| | | <input type="file" |
| | | name="sales_file" |
| | | id="sales_file" |
| | | accept=".csv" |
| | | required /> |
| | | <button type="submit" class="button button-primary" style="margin-top: 10px;"> |
| | | Upload Sales |
| | | </button> |
| | | </form> |
| | | <div id="sales-upload-status" style="margin-top: 10px;"></div> |
| | | </div> |
| | | </div> |
| | | </div> |
| | | |
| | | <!-- Referrals Table --> |
| | | <div class="card" style="margin-top: 20px;"> |
| | | <h2>Referrals Management</h2> |
| | | |
| | | <div class="jvb-table-controls" style="margin-bottom: 15px; display: flex; gap: 10px; align-items: center;"> |
| | | <label> |
| | | Filter by Status: |
| | | <select id="referral-status-filter"> |
| | | <option value="">All Statuses</option> |
| | | <option value="pending">Pending</option> |
| | | <option value="consulted">Consulted</option> |
| | | <option value="treated">Treated</option> |
| | | <option value="cancelled">Cancelled</option> |
| | | </select> |
| | | </label> |
| | | <input type="text" |
| | | id="referral-search" |
| | | placeholder="Search by name or email..." |
| | | style="min-width: 250px;" /> |
| | | <button type="button" class="button" id="refresh-table">Refresh</button> |
| | | </div> |
| | | |
| | | <div id="referrals-table-container"> |
| | | <div class="jvb-loading">Loading referrals...</div> |
| | | </div> |
| | | </div> |
| | | |
| | | <!-- Settings Section --> |
| | | <?= $this->renderAdminHTML() ?> |
| | | </div> |
| | | |
| | | <style> |
| | | .jvb-upload-box { |
| | | padding: 20px; |
| | | background: #f9f9f9; |
| | | border: 1px solid #ddd; |
| | | border-radius: 4px; |
| | | } |
| | | .jvb-upload-box h3 { |
| | | margin-top: 0; |
| | | } |
| | | .referrals-table { |
| | | width: 100%; |
| | | border-collapse: collapse; |
| | | } |
| | | .referrals-table th, |
| | | .referrals-table td { |
| | | padding: 12px; |
| | | text-align: left; |
| | | border-bottom: 1px solid #ddd; |
| | | } |
| | | .referrals-table th { |
| | | background: #f5f5f5; |
| | | font-weight: 600; |
| | | } |
| | | .referrals-table tr:hover { |
| | | background: #f9f9f9; |
| | | } |
| | | .referral-status { |
| | | padding: 4px 8px; |
| | | border-radius: 3px; |
| | | font-size: 12px; |
| | | font-weight: 500; |
| | | } |
| | | .referral-status.pending { |
| | | background: #fff3cd; |
| | | color: #856404; |
| | | } |
| | | .referral-status.consulted { |
| | | background: #d1ecf1; |
| | | color: #0c5460; |
| | | } |
| | | .referral-status.treated { |
| | | background: #d4edda; |
| | | color: #155724; |
| | | } |
| | | .referral-actions { |
| | | display: flex; |
| | | gap: 5px; |
| | | } |
| | | .notice.notice-success, |
| | | .notice.notice-error { |
| | | margin: 10px 0; |
| | | } |
| | | </style> |
| | | |
| | | <script> |
| | | jQuery(document).ready(function($) { |
| | | // Client upload |
| | | // Client upload |
| | | $('#client-upload-form').on('submit', function(e) { |
| | | e.preventDefault(); |
| | | const formData = new FormData(this); |
| | | formData.append('file', $('#client_file')[0].files[0]); |
| | | |
| | | $('#client-upload-status').html('<span class="spinner is-active"></span> Uploading...'); |
| | | |
| | | $.ajax({ |
| | | url: '<?= rest_url('jvb/v1/referrals/upload-clients') ?>', |
| | | method: 'POST', |
| | | data: formData, |
| | | processData: false, |
| | | contentType: false, |
| | | beforeSend: function(xhr) { |
| | | xhr.setRequestHeader('X-WP-Nonce', '<?= wp_create_nonce('wp_rest') ?>'); |
| | | }, |
| | | success: function(response) { |
| | | if (response.success) { |
| | | let message = '<div class="notice notice-success"><p>' + response.message; |
| | | message += '<br>Created: ' + (response.stats.created || 0); |
| | | message += ', Updated: ' + (response.stats.updated || 0); |
| | | message += ', Skipped: ' + (response.stats.skipped || 0) + '</p>'; |
| | | |
| | | // Show skipped details if any |
| | | if (response.stats.skipped_details && response.stats.skipped_details.length > 0) { |
| | | message += '<details style="margin-top: 10px;"><summary>View skipped records (' + response.stats.skipped_details.length + ')</summary>'; |
| | | message += '<table class="widefat" style="margin-top: 10px;"><thead><tr>'; |
| | | message += '<th>Line</th><th>Name</th><th>Email</th><th>GUID</th><th>Reason</th>'; |
| | | message += '</tr></thead><tbody>'; |
| | | response.stats.skipped_details.forEach(function(item) { |
| | | message += '<tr>'; |
| | | message += '<td>' + (item.line || '-') + '</td>'; |
| | | message += '<td>' + (item.name || '-') + '</td>'; |
| | | message += '<td>' + (item.email || '-') + '</td>'; |
| | | message += '<td>' + (item.guid || '-') + '</td>'; |
| | | message += '<td>' + item.reason + '</td>'; |
| | | message += '</tr>'; |
| | | }); |
| | | message += '</tbody></table></details>'; |
| | | } |
| | | |
| | | echo $this->renderAdminHTML(); |
| | | message += '</div>'; |
| | | |
| | | $('#client-upload-status').html(message); |
| | | $('#client-upload-form')[0].reset(); |
| | | loadReferralsTable(); |
| | | } else { |
| | | $('#client-upload-status').html( |
| | | '<div class="notice notice-error"><p>' + response.message + '</p></div>' |
| | | ); |
| | | } |
| | | }, |
| | | error: function(xhr) { |
| | | $('#client-upload-status').html( |
| | | '<div class="notice notice-error"><p>Upload failed</p></div>' |
| | | ); |
| | | } |
| | | }); |
| | | }); |
| | | |
| | | // Sales upload |
| | | $('#sales-upload-form').on('submit', function(e) { |
| | | e.preventDefault(); |
| | | const formData = new FormData(this); |
| | | formData.append('file', $('#sales_file')[0].files[0]); |
| | | |
| | | $('#sales-upload-status').html('<span class="spinner is-active"></span> Uploading...'); |
| | | |
| | | $.ajax({ |
| | | url: '<?= rest_url('jvb/v1/referrals/upload-sales') ?>', |
| | | method: 'POST', |
| | | data: formData, |
| | | processData: false, |
| | | contentType: false, |
| | | beforeSend: function(xhr) { |
| | | xhr.setRequestHeader('X-WP-Nonce', '<?= wp_create_nonce('wp_rest') ?>'); |
| | | }, |
| | | success: function(response) { |
| | | if (response.success) { |
| | | $('#sales-upload-status').html( |
| | | '<div class="notice notice-success"><p>' + response.message + |
| | | '<br>Consultations: ' + response.stats.consultations + |
| | | ', Treatments: ' + response.stats.treatments + |
| | | ', Skipped: ' + response.stats.skipped + '</p></div>' |
| | | ); |
| | | $('#sales-upload-form')[0].reset(); |
| | | loadReferralsTable(); |
| | | } else { |
| | | $('#sales-upload-status').html( |
| | | '<div class="notice notice-error"><p>' + response.message + '</p></div>' |
| | | ); |
| | | } |
| | | }, |
| | | error: function(xhr) { |
| | | $('#sales-upload-status').html( |
| | | '<div class="notice notice-error"><p>Upload failed</p></div>' |
| | | ); |
| | | } |
| | | }); |
| | | }); |
| | | |
| | | // Load referrals table |
| | | function loadReferralsTable(page = 1) { |
| | | const status = $('#referral-status-filter').val(); |
| | | const search = $('#referral-search').val(); |
| | | |
| | | $.ajax({ |
| | | url: '<?= rest_url('jvb/v1/referrals/list') ?>', |
| | | method: 'GET', |
| | | data: { |
| | | page: page, |
| | | per_page: 20, |
| | | status: status, |
| | | search: search |
| | | }, |
| | | beforeSend: function(xhr) { |
| | | xhr.setRequestHeader('X-WP-Nonce', '<?= wp_create_nonce('wp_rest') ?>'); |
| | | $('#referrals-table-container').html('<div class="jvb-loading">Loading...</div>'); |
| | | }, |
| | | success: function(response) { |
| | | if (response.success) { |
| | | renderReferralsTable(response); |
| | | } |
| | | } |
| | | }); |
| | | } |
| | | |
| | | // Render table |
| | | function renderReferralsTable(data) { |
| | | let html = '<table class="referrals-table widefat">'; |
| | | html += '<thead><tr>'; |
| | | html += '<th>Referrer</th>'; |
| | | html += '<th>Referee</th>'; |
| | | html += '<th>Email</th>'; |
| | | html += '<th>Status</th>'; |
| | | html += '<th>Referred Date</th>'; |
| | | html += '<th>Total Referrals</th>'; |
| | | html += '<th>Actions</th>'; |
| | | html += '</tr></thead><tbody>'; |
| | | |
| | | if (data.referrals.length === 0) { |
| | | html += '<tr><td colspan="7" style="text-align: center;">No referrals found</td></tr>'; |
| | | } else { |
| | | data.referrals.forEach(function(ref) { |
| | | html += '<tr>'; |
| | | html += '<td>' + (ref.referrer_name || 'Unknown') + '</td>'; |
| | | html += '<td>' + (ref.referee_display_name || ref.referee_name) + '</td>'; |
| | | html += '<td>' + (ref.referee_display_email || ref.referee_email) + '</td>'; |
| | | html += '<td><span class="referral-status ' + ref.status + '">' + ref.status + '</span></td>'; |
| | | html += '<td>' + new Date(ref.referred_at).toLocaleDateString() + '</td>'; |
| | | html += '<td>' + (ref.referrer_total_referrals || 0) + '</td>'; |
| | | html += '<td class="referral-actions">'; |
| | | |
| | | if (ref.status === 'pending') { |
| | | html += '<button class="button button-small mark-consulted" data-id="' + ref.id + '">Mark Consulted</button>'; |
| | | } |
| | | if (ref.status !== 'treated') { |
| | | html += '<button class="button button-small mark-treated" data-id="' + ref.id + '">Mark Treated</button>'; |
| | | } |
| | | |
| | | html += '</td>'; |
| | | html += '</tr>'; |
| | | }); |
| | | } |
| | | |
| | | html += '</tbody></table>'; |
| | | |
| | | // Add pagination |
| | | if (data.total_pages > 1) { |
| | | html += '<div class="tablenav"><div class="tablenav-pages">'; |
| | | for (let i = 1; i <= data.total_pages; i++) { |
| | | const active = i === data.page ? 'button-primary' : 'button'; |
| | | html += '<button class="button ' + active + ' page-link" data-page="' + i + '">' + i + '</button> '; |
| | | } |
| | | html += '</div></div>'; |
| | | } |
| | | |
| | | $('#referrals-table-container').html(html); |
| | | } |
| | | |
| | | // Event handlers for actions |
| | | $(document).on('click', '.mark-consulted', function() { |
| | | const id = $(this).data('id'); |
| | | if (!confirm('Mark this referral as consulted? This will create the consultation reward.')) return; |
| | | |
| | | $.ajax({ |
| | | url: '<?= rest_url('jvb/v1/referrals/mark-consulted') ?>', |
| | | method: 'POST', |
| | | data: JSON.stringify({ referral_id: id }), |
| | | contentType: 'application/json', |
| | | beforeSend: function(xhr) { |
| | | xhr.setRequestHeader('X-WP-Nonce', '<?= wp_create_nonce('wp_rest') ?>'); |
| | | }, |
| | | success: function(response) { |
| | | if (response.success) { |
| | | alert(response.message); |
| | | loadReferralsTable(); |
| | | } else { |
| | | alert('Error: ' + response.message); |
| | | } |
| | | } |
| | | }); |
| | | }); |
| | | |
| | | $(document).on('click', '.mark-treated', function() { |
| | | const id = $(this).data('id'); |
| | | if (!confirm('Mark this referral as treated? This will create rewards for both parties.')) return; |
| | | |
| | | $.ajax({ |
| | | url: '<?= rest_url('jvb/v1/referrals/mark-treated') ?>', |
| | | method: 'POST', |
| | | data: JSON.stringify({ referral_id: id }), |
| | | contentType: 'application/json', |
| | | beforeSend: function(xhr) { |
| | | xhr.setRequestHeader('X-WP-Nonce', '<?= wp_create_nonce('wp_rest') ?>'); |
| | | }, |
| | | success: function(response) { |
| | | if (response.success) { |
| | | alert(response.message); |
| | | loadReferralsTable(); |
| | | } else { |
| | | alert('Error: ' + response.message); |
| | | } |
| | | } |
| | | }); |
| | | }); |
| | | |
| | | $(document).on('click', '.page-link', function() { |
| | | loadReferralsTable($(this).data('page')); |
| | | }); |
| | | |
| | | $('#referral-status-filter, #refresh-table').on('change click', function() { |
| | | loadReferralsTable(); |
| | | }); |
| | | |
| | | // Search with debounce |
| | | let searchTimeout; |
| | | $('#referral-search').on('keyup', function() { |
| | | clearTimeout(searchTimeout); |
| | | searchTimeout = setTimeout(function() { |
| | | loadReferralsTable(); |
| | | }, 500); |
| | | }); |
| | | |
| | | // Initial load |
| | | loadReferralsTable(); |
| | | }); |
| | | </script> |
| | | <?php |
| | | } |
| | | |
| | | protected function renderAdminHTML():string |
| | |
| | | <h1>Referral Settings</h1> |
| | | |
| | | <form method="post" action=""> |
| | | <?php wp_nonce_field(BASE . 'referral_settings_nonce'); ?> |
| | | <?php wp_nonce_field(BASE . 'admin_page_nonce'); ?> |
| | | |
| | | <div class="card"> |
| | | <h2>Referral Page</h2> |
| | |
| | | </th> |
| | | <td> |
| | | <?php |
| | | if (!$this->referralPage) { |
| | | $this->referralPage = $this->getReferralPageId(); |
| | | } |
| | | wp_dropdown_pages([ |
| | | 'name' => BASE . 'referral_page_id', |
| | | 'id' => BASE . 'referral_page_id', |
| | |
| | | </select> |
| | | </td> |
| | | </tr> |
| | | <tr> |
| | | <th scope="row"> |
| | | <label for="<?= BASE ?>client_import_role">Client Import Role</label> |
| | | </th> |
| | | <td> |
| | | <?php |
| | | $selected_role = get_option(BASE . 'client_import_role', ''); |
| | | $roles = wp_roles()->get_names(); |
| | | ?> |
| | | <select name="<?= BASE ?>client_import_role" id="<?= BASE ?>client_import_role"> |
| | | <?php foreach ($roles as $role_value => $role_name): ?> |
| | | <option value="<?= esc_attr($role_value) ?>" <?php selected($selected_role, $role_value); ?>> |
| | | <?= esc_html($role_name) ?> |
| | | </option> |
| | | <?php endforeach; ?> |
| | | </select> |
| | | <p class="description"> |
| | | Role assigned to users imported from Jane App client list. |
| | | </p> |
| | | </td> |
| | | </tr> |
| | | </table> |
| | | </div> |
| | | |
| | |
| | | */ |
| | | protected function renderReferralStats(bool $wrapCard = false):string |
| | | { |
| | | ob_start(); |
| | | global $wpdb; |
| | | global $wpdb; // Use fresh global instead of stored reference |
| | | |
| | | $total_referrals = $wpdb->get_var("SELECT COUNT(*) FROM {$this->referrals_table}"); |
| | | $pending_referrals = $wpdb->get_var("SELECT COUNT(*) FROM {$this->referrals_table} WHERE status = 'pending'"); |
| | | $treated_referrals = $wpdb->get_var("SELECT COUNT(*) FROM {$this->referrals_table} WHERE status = 'treated'"); |
| | | ob_start(); |
| | | |
| | | // Get fresh table name references |
| | | $referrals_table = $wpdb->prefix . BASE . 'referrals'; |
| | | |
| | | // Use proper WordPress prepare even for COUNT |
| | | $total_referrals = $wpdb->get_var( |
| | | $wpdb->prepare( |
| | | "SELECT COUNT(*) FROM `{$referrals_table}` WHERE 1=%d", |
| | | 1 |
| | | ) |
| | | ); |
| | | |
| | | $pending_referrals = $wpdb->get_var( |
| | | $wpdb->prepare( |
| | | "SELECT COUNT(*) FROM `{$referrals_table}` WHERE status = %s", |
| | | 'pending' |
| | | ) |
| | | ); |
| | | |
| | | $treated_referrals = $wpdb->get_var( |
| | | $wpdb->prepare( |
| | | "SELECT COUNT(*) FROM `{$referrals_table}` WHERE status = %s", |
| | | 'treated' |
| | | ) |
| | | ); |
| | | |
| | | ?> |
| | | <table class="widefat"> |
| | | <tr> |
| | | <th>Total Referrals</th> |
| | | <td><?= esc_html($total_referrals) ?></td> |
| | | <td><?= esc_html($total_referrals ?? 0) ?></td> |
| | | </tr> |
| | | <tr> |
| | | <th>Pending</th> |
| | | <td><?= esc_html($pending_referrals) ?></td> |
| | | <td><?= esc_html($pending_referrals ?? 0) ?></td> |
| | | </tr> |
| | | <tr> |
| | | <th>Treated</th> |
| | | <td><?= esc_html($treated_referrals) ?></td> |
| | | <td><?= esc_html($treated_referrals ?? 0) ?></td> |
| | | </tr> |
| | | </table> |
| | | <?php |
| | | $table = ob_get_clean(); |
| | | |
| | | if ($wrapCard) { |
| | | $table = '<div class="card"> |
| | | <h2>Referral Statistics</h2> |
| | | '.$table.' |
| | | </div>'; |
| | | } |
| | | |
| | | return $table; |
| | | } |
| | | |
| | |
| | | } |
| | | |
| | | if (!$this->referralPage) { |
| | | $this->referralPage = $this->getReferralPageId(); |
| | | } |
| | | if (!$this->referralPage) { |
| | | return; |
| | | } |
| | | |
| | |
| | | if ('post.php' !== $pagenow || !$post) { |
| | | return; |
| | | } |
| | | |
| | | if (!$this->referralPage) { |
| | | $this->referralPage = $this->getReferralPageId(); |
| | | } |
| | | if ($post->ID === $this->referralPage) { |
| | | echo '<div class="notice notice-info">'; |
| | | echo '<p>' . __('This page is designated as the <strong>Referral Page</strong>.', 'jvbase') . '</p>'; |
| | |
| | | |
| | | public function renderDashPage(string $content, string $page):string |
| | | { |
| | | if ($page !== 'referrals') { |
| | | if ($page !== 'Referrals') { |
| | | return $content; |
| | | } |
| | | $out = ''; |
| | | if (current_user_can('manage_options')) { |
| | | $out .= $this->renderAdminHTML(); |
| | | } else { |
| | | $out .= $this->renderReferralStats(true); |
| | | |
| | | // Regular users get their referral dashboard |
| | | $user_id = get_current_user_id(); |
| | | $referral_code = get_user_meta($user_id, BASE . 'referral_code', true); |
| | | |
| | | if (!$referral_code) { |
| | | $referral_code = $this->getUserReferralCode($user_id); |
| | | } |
| | | return ($out === '') ? $content : '<form id="referrals" class="col" data-save="referrals">'.$out.'</form>'; |
| | | |
| | | $stats = $this->getUserStats($user_id); |
| | | $referrals = $this->getUserReferrals($user_id, ['limit' => 20]); |
| | | |
| | | ob_start(); |
| | | ?> |
| | | <div class="referral-dashboard"> |
| | | <div class="referral-header"> |
| | | <h2>Your Referrals</h2> |
| | | <p>Share your code and earn rewards when your referrals complete their first treatment!</p> |
| | | </div> |
| | | |
| | | <?php $this->getShareButtons($user_id); ?> |
| | | |
| | | <!-- Referral Code Card --> |
| | | <div class="referral-code-card"> |
| | | <h3>Your Referral Code</h3> |
| | | <div class="code-display"> |
| | | <span class="code"><?= esc_html($referral_code) ?></span> |
| | | <button class="button copy-code" data-code="<?= esc_attr($referral_code) ?>"> |
| | | Copy Code |
| | | </button> |
| | | </div> |
| | | <p class="share-link"> |
| | | Share link: <input type="text" readonly value="<?= home_url('/?ref=' . $referral_code) ?>" |
| | | onclick="this.select()" style="width: 100%; margin-top: 5px;" /> |
| | | </p> |
| | | </div> |
| | | <form class="invite"> |
| | | <?php |
| | | $meta = new MetaForm(); |
| | | $field = [ |
| | | 'type' => 'repeater', |
| | | 'label' => 'Invite Your Friends', |
| | | 'fields' => [ |
| | | 'name' => [ |
| | | 'type' => 'text', |
| | | 'label' => 'name', |
| | | ], |
| | | 'email' => [ |
| | | 'type' => 'email', |
| | | 'label' => 'email', |
| | | ] |
| | | ] |
| | | ]; |
| | | $meta->render('invite', [], $field); |
| | | ?> |
| | | </form> |
| | | |
| | | <!-- Stats Grid --> |
| | | <div class="stats-grid"> |
| | | <div class="stat-card"> |
| | | <h4>Total Referrals</h4> |
| | | <span class="stat-number"><?= esc_html($stats['total_referrals'] ?? 0) ?></span> |
| | | </div> |
| | | <div class="stat-card"> |
| | | <h4>Completed Treatments</h4> |
| | | <span class="stat-number"><?= esc_html($stats['treated_count'] ?? 0) ?></span> |
| | | </div> |
| | | <div class="stat-card"> |
| | | <h4>Pending</h4> |
| | | <span class="stat-number"><?= esc_html($stats['pending_count'] ?? 0) ?></span> |
| | | </div> |
| | | <div class="stat-card highlight"> |
| | | <h4>Available Rewards</h4> |
| | | <span class="stat-number">$<?= number_format($stats['available_rewards'] ?? 0, 2) ?></span> |
| | | </div> |
| | | </div> |
| | | |
| | | <!-- Referrals List --> |
| | | <div class="referrals-list-card"> |
| | | <h3>Your Referrals</h3> |
| | | <?php if (empty($referrals)): ?> |
| | | <p>You haven't referred anyone yet. Share your code to get started!</p> |
| | | <?php else: ?> |
| | | <table class="referrals-table"> |
| | | <thead> |
| | | <tr> |
| | | <th>Name</th> |
| | | <th>Email</th> |
| | | <th>Status</th> |
| | | <th>Referred Date</th> |
| | | </tr> |
| | | </thead> |
| | | <tbody> |
| | | <?php foreach ($referrals as $ref): ?> |
| | | <tr> |
| | | <td><?= esc_html($ref->referee_name) ?></td> |
| | | <td><?= esc_html($ref->referee_email) ?></td> |
| | | <td><span class="status-badge <?= esc_attr($ref->status) ?>"><?= esc_html(ucfirst($ref->status)) ?></span></td> |
| | | <td><?= date('M j, Y', strtotime($ref->referred_at)) ?></td> |
| | | </tr> |
| | | <?php endforeach; ?> |
| | | </tbody> |
| | | </table> |
| | | <?php endif; ?> |
| | | </div> |
| | | </div> |
| | | |
| | | |
| | | <script> |
| | | jQuery(document).ready(function($) { |
| | | $('.copy-code').on('click', function() { |
| | | const code = $(this).data('code'); |
| | | navigator.clipboard.writeText(code).then(function() { |
| | | alert('Code copied to clipboard!'); |
| | | }); |
| | | }); |
| | | }); |
| | | </script> |
| | | <?php |
| | | return ob_get_clean(); |
| | | } |
| | | |
| | | /** |
| | | * Handle admin page form submission |
| | | * |
| | | * @param mixed $result Previous result |
| | | * @param string $page_slug Current page slug |
| | | * @param array $post_data POST data |
| | | * @return array|null Result array or null if not our page |
| | | */ |
| | | public function handleAdminSubmission($result, string $page_slug, array $post_data): ?array |
| | | { |
| | | // Only handle our page |
| | | if ($page_slug !== BASE . 'referral-admin') { |
| | | return $result; |
| | | } |
| | | |
| | | try { |
| | | // Save referral page |
| | | $page_id = isset($post_data[BASE . 'referral_page_id']) ? absint($post_data[BASE . 'referral_page_id']) : 0; |
| | | update_option(BASE . 'referral_page_id', $page_id); |
| | | |
| | | // Save client import role |
| | | $import_role = sanitize_text_field($post_data[BASE . 'client_import_role'] ?? JVB_USER); |
| | | update_option(BASE . 'client_import_role', $import_role); |
| | | |
| | | // Save reward settings |
| | | $settings = [ |
| | | 'referrer_reward_type' => sanitize_text_field($post_data['referrer_reward_type'] ?? 'fixed'), |
| | | 'referrer_reward_amount' => floatval($post_data['referrer_reward_amount'] ?? 25.00), |
| | | 'referrer_reward_applies_to' => sanitize_text_field($post_data['referrer_reward_applies_to'] ?? 'per_user'), |
| | | 'referee_reward_type' => sanitize_text_field($post_data['referee_reward_type'] ?? 'percentage'), |
| | | 'referee_reward_amount' => floatval($post_data['referee_reward_amount'] ?? 20), |
| | | 'referee_reward_applies_to' => sanitize_text_field($post_data['referee_reward_applies_to'] ?? 'first_order') |
| | | ]; |
| | | |
| | | update_option(BASE . 'referral_settings', $settings); |
| | | |
| | | return [ |
| | | 'success' => true, |
| | | 'message' => 'Referral settings saved successfully!' |
| | | ]; |
| | | |
| | | } catch (\Exception $e) { |
| | | return [ |
| | | 'success' => false, |
| | | 'message' => 'Failed to save settings: ' . $e->getMessage() |
| | | ]; |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * Get formatted reward text for referee |
| | | * |
| | | * @param bool $full Include "off your first treatment" text |
| | | * @return string |
| | | */ |
| | | public function getRewardText(bool $full = true): string |
| | | { |
| | | $reward_amount = $this->settings['referee_reward_amount'] ?? 20; |
| | | $reward_type = $this->settings['referee_reward_type'] ?? 'percentage'; |
| | | |
| | | $reward_text = $reward_type === 'percentage' |
| | | ? $reward_amount . '% off' |
| | | : '$' . number_format($reward_amount, 2) . ' off'; |
| | | |
| | | if ($full) { |
| | | $reward_text .= ' your first treatment'; |
| | | } |
| | | |
| | | return $reward_text; |
| | | } |
| | | |
| | | public function getShareButtons(int $user_id):void |
| | | { |
| | | $referral_code = $this->getUserReferralCode($user_id); |
| | | if (is_wp_error($referral_code)) { |
| | | return; |
| | | } |
| | | |
| | | $share_url = $this->getShareURL($referral_code); |
| | | $referral_page_id = $this->getReferralPageId(); |
| | | |
| | | // SMS share text |
| | | $sms_text = urlencode("Check out " . get_bloginfo('name') . "! " . $share_url); |
| | | |
| | | // Share message |
| | | $share_message = urlencode("I love " . get_bloginfo('name') . "! Thought you might want to check them out."); |
| | | |
| | | ?> |
| | | <nav class="share"> |
| | | <h4>Quick Share</h4> |
| | | <ul class="share-buttons-grid"> |
| | | <a href="mailto:?subject=<?php echo urlencode('Check out ' . get_bloginfo('name')); ?>&body=<?php echo urlencode($share_message . ' ' . $share_url); ?>" |
| | | class="button" title="Email"> |
| | | <?php echo jvbIcon('envelope'); ?> |
| | | </a> |
| | | <a href="sms:?&body=<?php echo $sms_text; ?>" |
| | | class="button" title="Text"> |
| | | <?php echo jvbIcon('chat'); ?> |
| | | </a> |
| | | <a href="https://www.facebook.com/sharer/sharer.php?u=<?php echo urlencode($share_url); ?>" |
| | | target="_blank" |
| | | rel="noopener noreferrer" |
| | | class="button" title="Facebook"> |
| | | <?php echo jvbIcon('facebook-logo'); ?> |
| | | </a> |
| | | <a href="https://twitter.com/intent/tweet?url=<?php echo urlencode($share_url); ?>&text=<?php echo urlencode($share_message); ?>" |
| | | target="_blank" |
| | | rel="noopener noreferrer" |
| | | class="button" title="Twitter"> |
| | | <?php echo jvbIcon('twitter-logo'); ?> |
| | | </a> |
| | | <a href="https://wa.me/?text=<?php echo $sms_text; ?>" |
| | | target="_blank" |
| | | rel="noopener noreferrer" |
| | | class="button" title="WhatsApp"> |
| | | <?php echo jvbIcon('whatsapp-logo'); ?> |
| | | </a> |
| | | </ul> |
| | | </nav> |
| | | <?php |
| | | } |
| | | } |
| | | |
| | |
| | | if (Features::forContent($post_type)->has('is_calendar')) { |
| | | $args = $this->applyCalendarFilters($args, $params); |
| | | } |
| | | $taxonomies = array_filter($params, function($param) { |
| | | return str_starts_with($param, 'tax_'); |
| | | }, ARRAY_FILTER_USE_KEY); |
| | | if (!empty($taxonomies)) { |
| | | $params['taxonomies'] = []; |
| | | foreach ($taxonomies as $taxonomy => $terms) { |
| | | $taxonomy = str_replace('tax_', '', $taxonomy); |
| | | $params['taxonomies'][$taxonomy] = $terms; |
| | | } |
| | | } |
| | | if (array_key_exists('taxonomies', $params)) { |
| | | $args = $this->applyTaxonomyFilters($args, $params); |
| | | } |
| | |
| | | return $images; |
| | | } |
| | | |
| | | protected function formatTimeline(WP_Post $post):array |
| | | public function formatTimeline(WP_Post $post):array |
| | | { |
| | | $item = $this->prepareItem($post, true, false); |
| | | //Step 1: Get the fields that apply to all posts |
| | |
| | | ] |
| | | ); |
| | | } |
| | | if (array_key_exists('taxonomy', $data) && is_string($data['taxonomy'])) { |
| | | $data['taxonomy'] = json_decode($data['taxonomy'], true); |
| | | } |
| | | $args = $this->applyContextFilters($args, $data); |
| | | $args = $this->applyTaxonomyFilters($args, $data); |
| | | $args = $this->applyOrderFilters($args, $data); |
| | |
| | | if ($cache_check) { |
| | | return $cache_check; // Returns 304 Not Modified |
| | | } |
| | | error_log('Feed Request Args: '.print_r($args, true)); |
| | | |
| | | $key = $this->cache->generateKey($args); |
| | | $cached = $this->cache->get($key); |
| | | if ($cached) { |
| | |
| | | <?php |
| | | namespace JVBase\rest\routes; |
| | | |
| | | use JVBase\managers\ReferralManager; |
| | | use JVBase\importers\JaneAppClientImporter; |
| | | use JVBase\managers\JaneSalesImporter; |
| | | use JVBase\rest\RestRouteManager; |
| | | use WP_REST_Request; |
| | | use WP_REST_Response; |
| | |
| | | */ |
| | | class ReferralRoutes extends RestRouteManager |
| | | { |
| | | protected ReferralManager $manager; |
| | | protected string $referrals_table; |
| | | protected string $rewards_table; |
| | | protected string $treatments_table; |
| | | protected string $jane_clients_table; |
| | | protected $wpdb; |
| | | |
| | | public function __construct() |
| | | { |
| | | $this->route = 'referrals'; |
| | | $this->cache_name = 'referrals'; |
| | | parent::__construct(); |
| | | |
| | | global $wpdb; |
| | | $this->wpdb = $wpdb; |
| | | $this->referrals_table = $wpdb->prefix . BASE . 'referrals'; |
| | | $this->rewards_table = $wpdb->prefix . BASE . 'referral_rewards'; |
| | | $this->treatments_table = $wpdb->prefix . BASE . 'referral_treatments'; |
| | | $this->jane_clients_table = $wpdb->prefix . BASE . 'jane_clients'; |
| | | } |
| | | |
| | | public function registerRoutes(): void |
| | |
| | | ] |
| | | ] |
| | | ]); |
| | | |
| | | |
| | | /*************************** |
| | | * ADDITIONAL |
| | | */ |
| | | // CSV Uploads |
| | | register_rest_route($this->namespace, '/referrals/upload-clients', [ |
| | | 'methods' => 'POST', |
| | | 'callback' => [$this, 'handleClientUpload'], |
| | | 'permission_callback' => [$this, 'checkAdminPermission'] |
| | | ]); |
| | | |
| | | register_rest_route($this->namespace, '/referrals/upload-sales', [ |
| | | 'methods' => 'POST', |
| | | 'callback' => [$this, 'handleSalesUpload'], |
| | | 'permission_callback' => [$this, 'checkAdminPermission'] |
| | | ]); |
| | | |
| | | // Referral List & Details |
| | | register_rest_route($this->namespace, '/referrals/list', [ |
| | | 'methods' => 'GET', |
| | | 'callback' => [$this, 'getReferralsList'], |
| | | 'permission_callback' => [$this, 'checkAdminPermission'] |
| | | ]); |
| | | |
| | | register_rest_route($this->namespace, '/referrals/(?P<id>\d+)', [ |
| | | 'methods' => 'GET', |
| | | 'callback' => [$this, 'getReferralDetails'], |
| | | 'permission_callback' => [$this, 'checkAdminPermission'] |
| | | ]); |
| | | |
| | | // Manual Status Updates |
| | | register_rest_route($this->namespace, '/referrals/mark-consulted', [ |
| | | 'methods' => 'POST', |
| | | 'callback' => [$this, 'handleMarkConsulted'], |
| | | 'permission_callback' => [$this, 'checkAdminPermission'] |
| | | ]); |
| | | |
| | | register_rest_route($this->namespace, '/referrals/mark-treated', [ |
| | | 'methods' => 'POST', |
| | | 'callback' => [$this, 'handleMarkTreated'], |
| | | 'permission_callback' => [$this, 'checkAdminPermission'] |
| | | ]); |
| | | |
| | | // User-facing endpoints |
| | | register_rest_route($this->namespace, '/referrals/my-stats', [ |
| | | 'methods' => 'GET', |
| | | 'callback' => [$this, 'getMyStats'], |
| | | 'permission_callback' => [$this, 'checkPermission'] |
| | | ]); |
| | | |
| | | register_rest_route($this->namespace, '/referrals/my-referrals', [ |
| | | 'methods' => 'GET', |
| | | 'callback' => [$this, 'getMyReferrals'], |
| | | 'permission_callback' => [$this, 'checkPermission'] |
| | | ]); |
| | | } |
| | | |
| | | /** |
| | | * Check admin-only permission |
| | | */ |
| | | public function checkAdminPermission(WP_REST_Request $request): bool |
| | | { |
| | | return current_user_can('manage_options') && parent::checkPermission($request); |
| | | } |
| | | |
| | | public function checkPermission(WP_REST_Request $request): bool |
| | |
| | | |
| | | public function addReferralCodePostRegistration(WP_REST_Request $request): WP_REST_Response |
| | | { |
| | | if (!$this->manager) { |
| | | $this->manager = JVB()->referrals(); |
| | | } |
| | | $user_id = get_current_user_id(); |
| | | $code = $request->get_param('code'); |
| | | |
| | | // Check if user already has a referral (can't change) |
| | | $existing = $this->manager->getReferralByReferee($user_id); |
| | | $existing = JVB()->referrals()->getReferralByReferee($user_id); |
| | | if ($existing) { |
| | | return $this->error('You already have a referral code applied', 'already_referred', 400); |
| | | } |
| | | |
| | | // Validate the code exists |
| | | $referrer = $this->manager->getUserByReferralCode($code); |
| | | $referrer = JVB()->referrals()->getUserByReferralCode($code); |
| | | if (!$referrer) { |
| | | return $this->error('Invalid referral code', 'invalid_code', 400); |
| | | } |
| | | |
| | | // Create the referral |
| | | $user = wp_get_current_user(); |
| | | $result = $this->manager->createReferral($referrer->ID, $user_id, $code); |
| | | $result = JVB()->referrals()->createReferral($referrer->ID, $user_id, $code); |
| | | |
| | | if ($result) { |
| | | return $this->success([ |
| | |
| | | |
| | | return $this->error('Failed to apply referral code', 'creation_failed', 500); |
| | | } |
| | | |
| | | /********************************** |
| | | * ADDITIONAL |
| | | */ |
| | | /** |
| | | * Handle client CSV upload |
| | | */ |
| | | public function handleClientUpload(WP_REST_Request $request): WP_REST_Response |
| | | { |
| | | $files = $request->get_file_params(); |
| | | |
| | | if (!isset($files['file'])) { |
| | | return new WP_REST_Response([ |
| | | 'success' => false, |
| | | 'message' => 'No file uploaded' |
| | | ], 400); |
| | | } |
| | | |
| | | $file = $files['file']; |
| | | |
| | | // Validate file type |
| | | $allowed_types = ['text/csv', 'application/vnd.ms-excel', 'text/plain']; |
| | | if (!in_array($file['type'], $allowed_types)) { |
| | | return new WP_REST_Response([ |
| | | 'success' => false, |
| | | 'message' => 'File must be a CSV' |
| | | ], 400); |
| | | } |
| | | |
| | | // Validate file size (10MB max) |
| | | if ($file['size'] > 10 * 1024 * 1024) { |
| | | return new WP_REST_Response([ |
| | | 'success' => false, |
| | | 'message' => 'File size exceeds 10MB limit' |
| | | ], 400); |
| | | } |
| | | |
| | | // Import using JaneAppClientImporter |
| | | $importer = new JaneAppClientImporter(); |
| | | $default_role = get_option(BASE . 'client_import_role', JVB_USER); |
| | | |
| | | $options = [ |
| | | 'update_existing' => true, |
| | | 'send_welcome_email' => false, |
| | | 'create_users' => true, |
| | | 'default_role' => $default_role |
| | | ]; |
| | | |
| | | $result = $importer->importFromCSV($file['tmp_name'], $options); |
| | | |
| | | if (is_wp_error($result)) { |
| | | return new WP_REST_Response([ |
| | | 'success' => false, |
| | | 'message' => $result->get_error_message() |
| | | ], 500); |
| | | } |
| | | |
| | | // Build detailed message |
| | | $message = sprintf( |
| | | 'Import complete: %d created, %d updated, %d skipped', |
| | | $result['created'], |
| | | $result['updated'], |
| | | $result['skipped'] |
| | | ); |
| | | |
| | | $details = []; |
| | | if (!empty($result['skipped_details'])) { |
| | | $details = $result['skipped_details']; |
| | | } |
| | | |
| | | // Clear cache |
| | | $this->cache->clear(); |
| | | |
| | | return new WP_REST_Response([ |
| | | 'success' => true, |
| | | 'message' => $message, |
| | | 'stats' => $result, |
| | | 'skipped_details' => $details |
| | | ]); |
| | | } |
| | | |
| | | /** |
| | | * Handle sales CSV upload |
| | | */ |
| | | public function handleSalesUpload(WP_REST_Request $request): WP_REST_Response |
| | | { |
| | | $files = $request->get_file_params(); |
| | | |
| | | if (!isset($files['file'])) { |
| | | return new WP_REST_Response([ |
| | | 'success' => false, |
| | | 'message' => 'No file uploaded' |
| | | ], 400); |
| | | } |
| | | |
| | | $file = $files['file']; |
| | | |
| | | // Validate file type |
| | | $allowed_types = ['text/csv', 'application/vnd.ms-excel', 'text/plain']; |
| | | if (!in_array($file['type'], $allowed_types)) { |
| | | return new WP_REST_Response([ |
| | | 'success' => false, |
| | | 'message' => 'File must be a CSV' |
| | | ], 400); |
| | | } |
| | | |
| | | // Validate file size (10MB max) |
| | | if ($file['size'] > 10 * 1024 * 1024) { |
| | | return new WP_REST_Response([ |
| | | 'success' => false, |
| | | 'message' => 'File size exceeds 10MB limit' |
| | | ], 400); |
| | | } |
| | | |
| | | // Import using JaneSalesImporter |
| | | $importer = new JaneSalesImporter(); |
| | | $options = [ |
| | | 'skip_existing' => true |
| | | ]; |
| | | |
| | | $result = $importer->importFromCSV($file['tmp_name'], $options); |
| | | |
| | | if (is_wp_error($result)) { |
| | | return new WP_REST_Response([ |
| | | 'success' => false, |
| | | 'message' => $result->get_error_message() |
| | | ], 500); |
| | | } |
| | | |
| | | // Clear cache |
| | | $this->cache->clear(); |
| | | |
| | | return new WP_REST_Response([ |
| | | 'success' => true, |
| | | 'message' => 'Sales imported successfully', |
| | | 'stats' => $result |
| | | ]); |
| | | } |
| | | |
| | | /** |
| | | * Get referrals list for table display |
| | | */ |
| | | public function getReferralsList(WP_REST_Request $request): WP_REST_Response |
| | | { |
| | | $page = $request->get_param('page') ?: 1; |
| | | $per_page = $request->get_param('per_page') ?: 20; |
| | | $orderby = $request->get_param('orderby') ?: 'referred_at'; |
| | | $order = strtoupper($request->get_param('order')) === 'ASC' ? 'ASC' : 'DESC'; |
| | | $status = $request->get_param('status') ?: ''; |
| | | $search = $request->get_param('search') ?: ''; |
| | | |
| | | $offset = ($page - 1) * $per_page; |
| | | |
| | | // Build WHERE clause |
| | | $where_clauses = []; |
| | | $where_params = []; |
| | | |
| | | if (!empty($status)) { |
| | | $where_clauses[] = "r.status = %s"; |
| | | $where_params[] = $status; |
| | | } |
| | | |
| | | if (!empty($search)) { |
| | | $where_clauses[] = "(r.referee_name LIKE %s OR r.referee_email LIKE %s OR referrer.display_name LIKE %s)"; |
| | | $search_term = '%' . $this->wpdb->esc_like($search) . '%'; |
| | | $where_params[] = $search_term; |
| | | $where_params[] = $search_term; |
| | | $where_params[] = $search_term; |
| | | } |
| | | |
| | | $where = !empty($where_clauses) ? ' WHERE ' . implode(' AND ', $where_clauses) : ''; |
| | | |
| | | // Sanitize orderby to prevent SQL injection |
| | | $allowed_orderby = ['referred_at', 'consulted_at', 'treated_at', 'status', 'referee_name', 'referrer_name']; |
| | | if (!in_array($orderby, $allowed_orderby)) { |
| | | $orderby = 'referred_at'; |
| | | } |
| | | |
| | | // Get referrals with user info |
| | | $query = "SELECT |
| | | r.*, |
| | | referrer.display_name as referrer_name, |
| | | referrer.user_email as referrer_email, |
| | | referee.display_name as referee_display_name, |
| | | referee.user_email as referee_display_email, |
| | | (SELECT COUNT(*) FROM {$this->referrals_table} WHERE referrer_id = r.referrer_id) as referrer_total_referrals, |
| | | (SELECT SUM(amount) FROM {$this->rewards_table} WHERE user_id = r.referrer_id AND status = 'available') as referrer_available_rewards |
| | | FROM {$this->referrals_table} r |
| | | LEFT JOIN {$this->wpdb->users} referrer ON r.referrer_id = referrer.ID |
| | | LEFT JOIN {$this->wpdb->users} referee ON r.referee_id = referee.ID |
| | | {$where} |
| | | ORDER BY {$orderby} {$order} |
| | | LIMIT %d OFFSET %d"; |
| | | |
| | | $where_params[] = $per_page; |
| | | $where_params[] = $offset; |
| | | |
| | | $prepared_query = $this->wpdb->prepare($query, $where_params); |
| | | $referrals = $this->wpdb->get_results($prepared_query); |
| | | |
| | | // Get total count |
| | | $count_query = "SELECT COUNT(*) FROM {$this->referrals_table} r |
| | | LEFT JOIN {$this->wpdb->users} referrer ON r.referrer_id = referrer.ID |
| | | {$where}"; |
| | | |
| | | $total = $this->wpdb->get_var( |
| | | !empty($where_params) && count($where_params) > 2 ? |
| | | $this->wpdb->prepare($count_query, array_slice($where_params, 0, -2)) : |
| | | $count_query |
| | | ); |
| | | |
| | | return new WP_REST_Response([ |
| | | 'success' => true, |
| | | 'referrals' => $referrals, |
| | | 'total' => (int)$total, |
| | | 'page' => (int)$page, |
| | | 'per_page' => (int)$per_page, |
| | | 'total_pages' => ceil($total / $per_page) |
| | | ]); |
| | | } |
| | | |
| | | /** |
| | | * Get details for a specific referral |
| | | */ |
| | | public function getReferralDetails(WP_REST_Request $request): WP_REST_Response |
| | | { |
| | | $referral_id = $request->get_param('id'); |
| | | |
| | | $referral = $this->wpdb->get_row($this->wpdb->prepare( |
| | | "SELECT r.*, |
| | | referrer.display_name as referrer_name, |
| | | referee.display_name as referee_display_name |
| | | FROM {$this->referrals_table} r |
| | | LEFT JOIN {$this->wpdb->users} referrer ON r.referrer_id = referrer.ID |
| | | LEFT JOIN {$this->wpdb->users} referee ON r.referee_id = referee.ID |
| | | WHERE r.id = %d", |
| | | $referral_id |
| | | )); |
| | | |
| | | if (!$referral) { |
| | | return new WP_REST_Response([ |
| | | 'success' => false, |
| | | 'message' => 'Referral not found' |
| | | ], 404); |
| | | } |
| | | |
| | | // Get associated treatments |
| | | $treatments = $this->wpdb->get_results($this->wpdb->prepare( |
| | | "SELECT * FROM {$this->treatments_table} WHERE referral_id = %d ORDER BY treatment_date DESC", |
| | | $referral_id |
| | | )); |
| | | |
| | | // Get associated rewards |
| | | $rewards = $this->wpdb->get_results($this->wpdb->prepare( |
| | | "SELECT * FROM {$this->rewards_table} WHERE referral_id = %d", |
| | | $referral_id |
| | | )); |
| | | |
| | | return new WP_REST_Response([ |
| | | 'success' => true, |
| | | 'referral' => $referral, |
| | | 'treatments' => $treatments, |
| | | 'rewards' => $rewards |
| | | ]); |
| | | } |
| | | |
| | | /** |
| | | * Handle manual mark as consulted |
| | | */ |
| | | public function handleMarkConsulted(WP_REST_Request $request): WP_REST_Response |
| | | { |
| | | $referral_id = $request->get_param('referral_id'); |
| | | |
| | | if (!$referral_id) { |
| | | return new WP_REST_Response([ |
| | | 'success' => false, |
| | | 'message' => 'Referral ID required' |
| | | ], 400); |
| | | } |
| | | |
| | | $referral = $this->wpdb->get_row($this->wpdb->prepare( |
| | | "SELECT * FROM {$this->referrals_table} WHERE id = %d", |
| | | $referral_id |
| | | )); |
| | | |
| | | if (!$referral) { |
| | | return new WP_REST_Response([ |
| | | 'success' => false, |
| | | 'message' => 'Referral not found' |
| | | ], 404); |
| | | } |
| | | |
| | | if ($referral->status !== 'pending') { |
| | | return new WP_REST_Response([ |
| | | 'success' => false, |
| | | 'message' => 'Referral is not pending' |
| | | ], 400); |
| | | } |
| | | |
| | | // Update to consulted |
| | | $this->wpdb->update( |
| | | $this->referrals_table, |
| | | [ |
| | | 'status' => 'consulted', |
| | | 'consulted_at' => current_time('mysql') |
| | | ], |
| | | ['id' => $referral_id], |
| | | ['%s', '%s'], |
| | | ['%d'] |
| | | ); |
| | | |
| | | // Create consultation reward (20% off) |
| | | $this->wpdb->insert( |
| | | $this->rewards_table, |
| | | [ |
| | | 'referral_id' => $referral_id, |
| | | 'user_id' => $referral->referee_id, |
| | | 'reward_type' => 'referee', |
| | | 'amount' => 20, |
| | | 'reward_calculation' => 'percentage', |
| | | 'status' => 'available', |
| | | 'created_at' => current_time('mysql'), |
| | | 'notes' => 'Consultation reward - 20% off first treatment' |
| | | ], |
| | | ['%d', '%d', '%s', '%f', '%s', '%s', '%s', '%s'] |
| | | ); |
| | | |
| | | // Clear cache |
| | | $this->cache->clear(); |
| | | |
| | | return new WP_REST_Response([ |
| | | 'success' => true, |
| | | 'message' => 'Marked as consulted and reward created' |
| | | ]); |
| | | } |
| | | |
| | | /** |
| | | * Handle manual mark as treated |
| | | */ |
| | | public function handleMarkTreated(WP_REST_Request $request): WP_REST_Response |
| | | { |
| | | $referral_id = $request->get_param('referral_id'); |
| | | |
| | | if (!$referral_id) { |
| | | return new WP_REST_Response([ |
| | | 'success' => false, |
| | | 'message' => 'Referral ID required' |
| | | ], 400); |
| | | } |
| | | |
| | | $referral = $this->wpdb->get_row($this->wpdb->prepare( |
| | | "SELECT * FROM {$this->referrals_table} WHERE id = %d", |
| | | $referral_id |
| | | )); |
| | | |
| | | if (!$referral) { |
| | | return new WP_REST_Response([ |
| | | 'success' => false, |
| | | 'message' => 'Referral not found' |
| | | ], 404); |
| | | } |
| | | |
| | | if ($referral->status === 'treated') { |
| | | return new WP_REST_Response([ |
| | | 'success' => false, |
| | | 'message' => 'Referral already marked as treated' |
| | | ], 400); |
| | | } |
| | | |
| | | // Update to treated |
| | | $this->wpdb->update( |
| | | $this->referrals_table, |
| | | [ |
| | | 'status' => 'treated', |
| | | 'treated_at' => current_time('mysql'), |
| | | 'treatment_count' => ($referral->treatment_count ?? 0) + 1 |
| | | ], |
| | | ['id' => $referral_id], |
| | | ['%s', '%s', '%d'], |
| | | ['%d'] |
| | | ); |
| | | |
| | | // Create full rewards for both parties |
| | | $settings = JVB()->referrals()->getRewardSettings(); |
| | | |
| | | // Referrer reward |
| | | $this->wpdb->insert( |
| | | $this->rewards_table, |
| | | [ |
| | | 'referral_id' => $referral_id, |
| | | 'user_id' => $referral->referrer_id, |
| | | 'reward_type' => 'referrer', |
| | | 'amount' => $settings['referrer_reward_amount'], |
| | | 'reward_calculation' => $settings['referrer_reward_type'], |
| | | 'status' => 'available', |
| | | 'created_at' => current_time('mysql'), |
| | | 'notes' => 'Referral reward for completed treatment' |
| | | ], |
| | | ['%d', '%d', '%s', '%f', '%s', '%s', '%s', '%s'] |
| | | ); |
| | | |
| | | // Referee reward |
| | | $this->wpdb->insert( |
| | | $this->rewards_table, |
| | | [ |
| | | 'referral_id' => $referral_id, |
| | | 'user_id' => $referral->referee_id, |
| | | 'reward_type' => 'referee', |
| | | 'amount' => $settings['referee_reward_amount'], |
| | | 'reward_calculation' => $settings['referee_reward_type'], |
| | | 'status' => 'available', |
| | | 'created_at' => current_time('mysql'), |
| | | 'notes' => 'Treatment completion reward' |
| | | ], |
| | | ['%d', '%d', '%s', '%f', '%s', '%s', '%s', '%s'] |
| | | ); |
| | | |
| | | // Clear cache |
| | | $this->cache->clear(); |
| | | |
| | | return new WP_REST_Response([ |
| | | 'success' => true, |
| | | 'message' => 'Marked as treated and rewards created' |
| | | ]); |
| | | } |
| | | |
| | | /** |
| | | * Get current user's referral stats |
| | | */ |
| | | public function getMyStats(WP_REST_Request $request): WP_REST_Response |
| | | { |
| | | $user_id = get_current_user_id(); |
| | | $stats = JVB()->referrals()->getUserStats($user_id); |
| | | |
| | | return new WP_REST_Response([ |
| | | 'success' => true, |
| | | 'stats' => $stats |
| | | ]); |
| | | } |
| | | |
| | | /** |
| | | * Get current user's referrals |
| | | */ |
| | | public function getMyReferrals(WP_REST_Request $request): WP_REST_Response |
| | | { |
| | | $user_id = get_current_user_id(); |
| | | $limit = $request->get_param('limit') ?: 20; |
| | | |
| | | $referrals = JVB()->referrals()->getUserReferrals($user_id, ['limit' => $limit]); |
| | | |
| | | return new WP_REST_Response([ |
| | | 'success' => true, |
| | | 'referrals' => $referrals |
| | | ]); |
| | | } |
| | | } |
| | |
| | | $data = $request->get_params(); |
| | | |
| | | |
| | | |
| | | error_log('[UploadRoutes]:handleGroupingRequest: data'.print_r($data, true)); |
| | | error_log('[UploadRoutes]:handleGroupingRequest: args'.print_r($args, true)); |
| | | |
| | | error_log('get_file_params: '.print_r($files, true)); |
| | | global $_FILES; |
| | | error_log('Global Files: '.print_r($_FILES, true)); |
| | | |
| | | if (!$args['content'] || !$args['user'] || !$args['posts']) { |
| | | |
| | |
| | | |
| | | require(JVB_DIR . '/inc/helpers/all.php'); |
| | | require(JVB_DIR . '/inc/meta/_setup.php'); |
| | | require(JVB_DIR . '/inc/importers/_setup.php'); |
| | | require(JVB_DIR . '/inc/managers/_setup.php'); |
| | | |
| | | function jvbIcon($name, $options = []) { |
| | |
| | | 'jvb-uploader', |
| | | JVB_URL.'assets/js/min/uploader.min.js', |
| | | [ |
| | | 'sortable-js', |
| | | 'sortable-multidrag', |
| | | 'jvb-cache', |
| | | 'jvb-a11y', |
| | | 'jvb-utility', |
| | | 'jvb-handle-selection', |
| | | 'jvb-modal', |
| | | 'jvb-drag-handler', |
| | | // 'jvb-drag-handler', |
| | | // 'jvb-loading', |
| | | 'jvb-queue', |
| | | 'jvb-notifications' |
| | |
| | | true |
| | | ); |
| | | |
| | | // Load MultiDrag plugin |
| | | wp_register_script( |
| | | 'sortable-multidrag', |
| | | 'https://cdn.jsdelivr.net/npm/sortablejs@latest/plugins/MultiDrag.min.js', |
| | | array('sortable-js'), |
| | | null, |
| | | true |
| | | ); |
| | | |
| | | //Custom Dashboard Navigator |
| | | // wp_register_script( |
| | | // 'jvb-dashboard-navigator', |
| | |
| | | background-color: var(--base-100); |
| | | border-radius: var(--outerRadius); |
| | | padding: 1.5rem; |
| | | touch-action: auto; |
| | | ol { |
| | | list-style: decimal-leading-zero; |
| | | height: fit-content; |
| | |
| | | favourites: '[data-filter="favourites"]', |
| | | taxonomy: '[data-filter^="taxonomy"]' |
| | | }, |
| | | selectedTax: '.selected-terms', |
| | | selectedTax: '.selected-items', |
| | | clearFilter: 'button.clear-filters', |
| | | loadMore: 'button.load-more', |
| | | filterContainer: '.filters', |
| | |
| | | buttons.forEach((button) => { |
| | | const taxonomy = button.dataset.taxonomy; |
| | | this.currentTaxonomies.add(taxonomy); |
| | | |
| | | this.selector.registerFilterButton(button, { |
| | | button: button, |
| | | buttonSelector: '[data-filter="taxonomy"]', |
| | | selected: this.ui.selectedTax |
| | | }); |
| | | |
| | | // Add preload listeners |
| | | this.addTaxonomyPreloadListeners(button, taxonomy); |
| | | }); |
| | | |
| | | // Make this non-blocking |
| | | setTimeout(() => { |
| | | this.selector.batchFetchTaxonomies(); |
| | | this.selector.isInitializing = false; |
| | | }, 0); |
| | | |
| | | this.selector.subscribe((event, data) => { |
| | | if (event === 'selected-terms') this.handleTaxonomyChange(data); |
| | | }); |
| | | } |
| | | |
| | | addTaxonomyPreloadListeners(button, taxonomy) { |
| | | const preload = () => { |
| | | this.selector.preloadTaxonomy(taxonomy); |
| | | }; |
| | | |
| | | // Desktop hover |
| | | button.addEventListener('mouseenter', preload, { once: true }); |
| | | |
| | | // Touch/keyboard (fires before click) |
| | | button.addEventListener('pointerdown', preload, { once: true }); |
| | | |
| | | // Keyboard focus |
| | | button.addEventListener('focus', preload, { once: true }); |
| | | } |
| | | |
| | | handleTaxonomyChange(data) { |
| | | const { terms, taxonomy } = data; |
| | | |
| | |
| | | } |
| | | |
| | | initStore() { |
| | | this.addPlaceholders(); |
| | | this.store = window.jvbStore.register( |
| | | const store = window.jvbStore.register( |
| | | 'feed', |
| | | { |
| | | storeName: 'feed', |
| | | endpoint: 'feed', |
| | | keyPath: 'id', |
| | | indexes: [ |
| | |
| | | delayFetch: true |
| | | } |
| | | ); |
| | | this.store = store.feed; |
| | | |
| | | this.store.subscribe((event, data) => { |
| | | switch (event) { |
| | |
| | | let items = this.store.getFiltered(); |
| | | if (this.store.filters['page'] === 1) { |
| | | window.removeChildren(this.ui.grid); |
| | | this.addPlaceholders(); |
| | | } |
| | | |
| | | if (items.length === 0) { |
| | |
| | | return template; |
| | | } |
| | | |
| | | removePlaceholders() { |
| | | const placeholders = this.ui.grid.querySelectorAll('.placeholder'); |
| | | if (placeholders.length > 0) { |
| | | placeholders.forEach(p => p.remove()); |
| | | } |
| | | } |
| | | |
| | | |
| | | addPlaceholders() { |
| | | let total = this.contentTypes.length; |
| | | const fragment = document.createDocumentFragment(); |
| | | for (let i = 0; i < 12; i++) { |
| | | let template = window.getTemplate('placeholderTemplate'); |
| | | |
| | |
| | | } else { |
| | | icon = window.getIcon(this.container.dataset.icon); |
| | | } |
| | | template.appendChild(icon); |
| | | this.ui.grid.appendChild(template); |
| | | template.append(icon); |
| | | fragment.append(template); |
| | | } |
| | | } |
| | | |
| | | removePlaceholders() { |
| | | if (this.ui.grid.querySelector('.placeholder')) { |
| | | window.removeChildren(this.ui.grid); |
| | | } |
| | | this.ui.grid.append(fragment); |
| | | } |
| | | |
| | | |
| | |
| | | if ($inheritUser) { |
| | | global $post; |
| | | $inheritUser = $post->post_author; |
| | | }else { |
| | | $inheritUser = null; |
| | | } |
| | | try { |
| | | $gmb = JVB()->connect('gmb', $inheritUser); |
| | |
| | | .gmb-reviews { |
| | | max-width: var(--alignWide); |
| | | max-width: none; |
| | | > .row.btw { |
| | | max-width:var(--alignWide); |
| | | .button { |
| | | width: 100%; |
| | | height: max-content; |
| | |
| | | list-style: none; |
| | | margin: 0; |
| | | padding: 0; |
| | | max-width: var(--wider); |
| | | li { |
| | | margin: 2rem 0; |
| | | position:relative; |
| | | background-color: var(--base-100); |
| | | padding: 1rem; |
| | | @media (min-width: 768px) { |
| | | &:nth-of-type(odd) { |
| | | left: -2rem; |