Jake Vanderwerf
2026-05-01 48721c85ebcfa973ee81719d2467ca80e4253dc9
=Edmonton Ink hard test begins! Real testing of the managers and reset routes will commence. So far, just ensuring our classes are all loaded correctly: Site() and its sub-classes Membership, Login, etc. Care should be taken to load conditionally on 'init', as we finish defining most settings by 'plugins_loaded' at priority 5
70 files modified
40 files deleted
1 files added
1 files renamed
36203 ■■■■■ changed files
JVBase.php 16 ●●●●● patch | view | raw | blame | history
activate.php 10 ●●●●● patch | view | raw | blame | history
assets/css/copy-hours.min.css 2 ●●●●● patch | view | raw | blame | history
assets/css/nav.min.css 2 ●●●●● patch | view | raw | blame | history
assets/js/concise/UtilityFunctions.js 25 ●●●●● patch | view | raw | blame | history
assets/js/concise/navigation.js 212 ●●●●● patch | view | raw | blame | history
assets/js/min/navigation.min.js 2 ●●●●● patch | view | raw | blame | history
assets/js/min/utility.min.js 2 ●●●●● patch | view | raw | blame | history
base/Membership.php 46 ●●●●● patch | view | raw | blame | history
base/SchemaHelper.php 113 ●●●●● patch | view | raw | blame | history
base/Site.php 79 ●●●●● patch | view | raw | blame | history
base/_setup.php 8 ●●●●● patch | view | raw | blame | history
base/content.php 6 ●●●●● patch | view | raw | blame | history
base/users.php 38 ●●●●● patch | view | raw | blame | history
checks.php 67 ●●●●● patch | view | raw | blame | history
inc/admin/ContentTaxonomy.php 5 ●●●●● patch | view | raw | blame | history
inc/admin/_setup.php 8 ●●●●● patch | view | raw | blame | history
inc/blocks/CustomBlocks.php 95 ●●●●● patch | view | raw | blame | history
inc/blocks/_setup.php 112 ●●●●● patch | view | raw | blame | history
inc/helpers/crud.php 34 ●●●●● patch | view | raw | blame | history
inc/helpers/dashboard.php 2 ●●●●● patch | view | raw | blame | history
inc/helpers/members.php 4 ●●●●● patch | view | raw | blame | history
inc/helpers/ui.php 68 ●●●●● patch | view | raw | blame | history
inc/importers/JaneAppClientImporter.php 6 ●●●●● patch | view | raw | blame | history
inc/integrations/Cloudflare.php 6 ●●●●● patch | view | raw | blame | history
inc/integrations/Square.php 4 ●●●●● patch | view | raw | blame | history
inc/managers/AdminPages.php 2 ●●●●● patch | view | raw | blame | history
inc/managers/ApprovalManager.php 144 ●●●●● patch | view | raw | blame | history
inc/managers/CRUDManager.php 8 ●●●●● patch | view | raw | blame | history
inc/managers/DashboardManager.php 4 ●●●●● patch | view | raw | blame | history
inc/managers/DirectoryManager.php 11 ●●●●● patch | view | raw | blame | history
inc/managers/FavouritesManager.php 2 ●●●●● patch | view | raw | blame | history
inc/managers/IconsManager.php 14 ●●●●● patch | view | raw | blame | history
inc/managers/InvitationsManager.php 8 ●●●●● patch | view | raw | blame | history
inc/managers/KarmaManager.php 10 ●●●●● patch | view | raw | blame | history
inc/managers/LoginManager.php 24 ●●●●● patch | view | raw | blame | history
inc/managers/NewsRelationships.php 2 ●●●●● patch | view | raw | blame | history
inc/managers/NotificationManager.php 5 ●●●●● patch | view | raw | blame | history
inc/managers/Notifications/Notifications.php 9 ●●●●● patch | view | raw | blame | history
inc/managers/ReferralManager.php 7 ●●●●● patch | view | raw | blame | history
inc/managers/RoleManager.php 174 ●●●●● patch | view | raw | blame | history
inc/managers/SEO/ConfigManager.php 399 ●●●●● patch | view | raw | blame | history
inc/managers/SEO/FieldBuilder.php 89 ●●●●● patch | view | raw | blame | history
inc/managers/SEO/FieldOverrideBuilder.php 44 ●●●●● patch | view | raw | blame | history
inc/managers/SEO/SEOAdminPage.php 1 ●●●●● patch | view | raw | blame | history
inc/managers/SEO/SchemaBuilder.php 1761 ●●●●● patch | view | raw | blame | history
inc/managers/SEO/SchemaFieldHelpers.php 1219 ●●●●● patch | view | raw | blame | history
inc/managers/SEO/SchemaOutputManager.php 650 ●●●●● patch | view | raw | blame | history
inc/managers/SEO/SchemaReferenceBuilder.php 498 ●●●●● patch | view | raw | blame | history
inc/managers/SEO/TemplateResolver.php 664 ●●●●● patch | view | raw | blame | history
inc/managers/SEO/TypeBuilder.php 85 ●●●●● patch | view | raw | blame | history
inc/managers/SEO/_setup.php 16 ●●●●● patch | view | raw | blame | history
inc/managers/SEO/schemas/SchemaDefinition.php 59 ●●●●● patch | view | raw | blame | history
inc/managers/SEO/schemas/SchemaResolverInterface.php 39 ●●●●● patch | view | raw | blame | history
inc/managers/SEO/schemas/SchemaResolverRegistry.php 76 ●●●●● patch | view | raw | blame | history
inc/managers/SEO/schemas/_setup.php 27 ●●●●● patch | view | raw | blame | history
inc/managers/SEO/schemas/resolvers/BaseResolver.php 228 ●●●●● patch | view | raw | blame | history
inc/managers/SEO/schemas/resolvers/CollectionPageResolver.php 88 ●●●●● patch | view | raw | blame | history
inc/managers/SEO/schemas/resolvers/FAQPageResolver.php 75 ●●●●● patch | view | raw | blame | history
inc/managers/SEO/schemas/resolvers/LocalBusinessResolver.php 88 ●●●●● patch | view | raw | blame | history
inc/managers/SEO/schemas/resolvers/PersonResolver.php 127 ●●●●● patch | view | raw | blame | history
inc/managers/SEO/schemas/resolvers/VisualArtworkResolver.php 134 ●●●●● patch | view | raw | blame | history
inc/managers/VerifyEntryManager.php 2 ●●●●● patch | view | raw | blame | history
inc/managers/_setup.php 29 ●●●●● patch | view | raw | blame | history
inc/meta/MetaFormOld.php 1781 ●●●●● patch | view | raw | blame | history
inc/meta/MetaManager.php 1429 ●●●●● patch | view | raw | blame | history
inc/meta/MetaOld.php 701 ●●●●● patch | view | raw | blame | history
inc/meta/_setup.php 2 ●●●●● patch | view | raw | blame | history
inc/registrar/Fields.php 103 ●●●●● patch | view | raw | blame | history
inc/registrar/Posts.php 1 ●●●●● patch | view | raw | blame | history
inc/registrar/Registrar.php 46 ●●●●● patch | view | raw | blame | history
inc/registrar/_setup.php 2 ●●●●● patch | view | raw | blame | history
inc/registrar/config/Register.php 76 ●●●●● patch | view | raw | blame | history
inc/registrar/config/Section.php 6 ●●●●● patch | view | raw | blame | history
inc/registrar/config/seo/Schema.php 2 ●●●●● patch | view | raw | blame | history
inc/registrar/fields/Field.php 36 ●●●●● patch | view | raw | blame | history
inc/registrar/fields/RepeaterField.php 8 ●●●●● patch | view | raw | blame | history
inc/registrar/helpers/MakeCalendarType.php 4 ●●●●● patch | view | raw | blame | history
inc/registrar/helpers/MakeTrackChanges.php 4 ●●●●● patch | view | raw | blame | history
inc/registrar/helpers/MakeVerification.php 2 ●●●●● patch | view | raw | blame | history
inc/registry/CheckCustomTables.php 1786 ●●●●● patch | view | raw | blame | history
inc/registry/ContentRegistry.php 163 ●●●●● patch | view | raw | blame | history
inc/registry/FieldRegistry.php 244 ●●●●● patch | view | raw | blame | history
inc/registry/MakeCalendarType.php 323 ●●●●● patch | view | raw | blame | history
inc/registry/OptionsRegistry.php 105 ●●●●● patch | view | raw | blame | history
inc/registry/PostTypeRegistrar.php 251 ●●●●● patch | view | raw | blame | history
inc/registry/TaxonomyRegistrar.php 349 ●●●●● patch | view | raw | blame | history
inc/registry/UserRoleRegistrar.php 34 ●●●●● patch | view | raw | blame | history
inc/registry/_setup.php 17 ●●●●● patch | view | raw | blame | history
inc/registry/index.php 2 ●●●●● patch | view | raw | blame | history
inc/registry/providers/CalendarFieldProvider.php 109 ●●●●● patch | view | raw | blame | history
inc/registry/providers/CommonFieldProvider.php 591 ●●●●● patch | view | raw | blame | history
inc/registry/providers/FieldProviderInterface.php 14 ●●●●● patch | view | raw | blame | history
inc/registry/providers/IntegrationFieldProvider.php 127 ●●●●● patch | view | raw | blame | history
inc/registry/providers/index.php 2 ●●●●● patch | view | raw | blame | history
inc/rest/RegisterRoutes.php 2 ●●●●● patch | view | raw | blame | history
inc/rest/RestRouteManager.php 6 ●●●●● patch | view | raw | blame | history
inc/rest/routes/ApprovalRoutes.php 543 ●●●●● patch | view | raw | blame | history
inc/rest/routes/FeedRoutes.php 2 ●●●●● patch | view | raw | blame | history
inc/rest/routes/LoginRoutes.php 8 ●●●●● patch | view | raw | blame | history
inc/rest/routes/NotificationsRoutes.php 2 ●●●●● patch | view | raw | blame | history
inc/rest/routes/ReferralRoutes.php 3 ●●●●● patch | view | raw | blame | history
inc/rest/routes/TermRoutes.php 2 ●●●●● patch | view | raw | blame | history
inc/rest/routes/UploadRoutes.php 4 ●●●●● patch | view | raw | blame | history
inc/ui/CRUDSkeleton.php 10 ●●●●● patch | view | raw | blame | history
inc/utility/Checker.php 305 ●●●●● patch | view | raw | blame | history
inc/utility/Features.php 833 ●●●●● patch | view | raw | blame | history
inc/utility/Validator.php 745 ●●●●● patch | view | raw | blame | history
inc/utility/setup.php 4 ●●●●● patch | view | raw | blame | history
jvb.php 7 ●●●●● patch | view | raw | blame | history
package-lock.json 17611 ●●●●● patch | view | raw | blame | history
package.json 2 ●●●●● patch | view | raw | blame | history
JVBase.php
@@ -3,8 +3,10 @@
use JVBase\blocks\CustomBlocks;
use JVBase\base\Site;
use JVBase\managers\ApprovalManager;
use JVBase\managers\EmailManager;
use JVBase\managers\ErrorHandler;
use JVBase\managers\FavouritesManager;
use JVBase\managers\InvitationsManager;
use JVBase\managers\LoginManager;
use JVBase\managers\MagicLinkManager;
@@ -149,6 +151,9 @@
            $this->managers['notifications'] = new NotificationManager();
            $this->routes['notifications'] = new NotificationsRoutes();
        }
        if (!empty(Registrar::getFeatured('approve_new'))) {
            $this->managers['approvals'] = new ApprovalManager();
        }
        if (Site::has('feed_block') || Site::has('dashboard')) {
            $this->routes['term'] = new TermRoutes();
        }
@@ -168,6 +173,7 @@
        }
        if (Site::has('favourites')) {
            $this->managers['favourites'] = new FavouritesManager();
            $this->routes['favourites'] = new FavouritesRoutes();
        }
@@ -296,8 +302,9 @@
    public function userCanConnect(string $service, int $userID): bool
    {
        $allowed = JVB_USER[jvbUserRole($userID)]['integrations'] ?? [];
        return user_can($userID, 'manage_options') || in_array($service, $allowed);
        $role = jvbUserRole($userID);
        $registrar = Registrar::getInstance($role);
        return user_can($userID, 'manage_options') || $registrar->hasIntegration($service);
    }
    public function getAvailableServices(bool $keys = true): array
@@ -388,4 +395,9 @@
    {
        return $this->managers['terms'];
    }
    public function approvals ():ApprovalManager|false
    {
        return $this->managers['approvals']??false;
    }
}
activate.php
@@ -38,17 +38,9 @@
    error_log('Starting schedules...');
    jvbSchedules();
    error_log('Schedules done!');
    error_log('checking Admin capabilities...');
    jvb_register_do_once('admin_caps', 'jvbAddAdminCaps');
    RoleManager::activate();
    error_log('Admin caps done!');
    error_log('Removing unneeded roles...');
    remove_role('contributor');
    remove_role('author');
    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');
assets/css/copy-hours.min.css
@@ -1 +1 @@
.group-fields{position:relative}.hours-copy-btn:hover{background-color:var(--action-50);transform:scale(1.05)}.hours-copy-btn:active{transform:scale(.95)}.hours-copy-btn .icon{--w:0.875rem}.copy-hours-content h3{margin:0 0 1rem 0;color:var(--contrast);font-size:var(--txt-large)}.copy-hours-source{background-color:var(--base-100);padding:1rem;border-radius:var(--radius);margin-bottom:1.5rem;border:1px solid var(--base-200)}.copy-hours-source h4{margin:0 0 .5rem 0;color:var(--contrast-100);text-transform:uppercase;font-size:var(--txt-small);font-weight:600}.source-info{--gap:.25rem}.source-day{font-weight:600;color:var(--contrast);text-transform:capitalize}.source-hours{--gap:1rem;font-weight:500;color:var(--contrast)}.source-hours.closed{color:var(--contrast-200);font-style:italic}.copy-hours-targets{margin-bottom:2rem}.copy-hours-targets h4{margin:0 0 1rem 0;color:var(--contrast-100);text-transform:uppercase;font-size:var(--txt-small);font-weight:600}.day-checkboxes{display:grid;grid-template-columns:repeat(auto-fit,minmax(120px,1fr));gap:.75rem}.feedback{position:fixed;top:2rem;right:2rem;background-color:var(--action-50);color:var(--action-contrast);padding:1rem 1.5rem;border-radius:var(--radius);box-shadow:rgba(var(--base-rgb),var(--op-45)) var(--shdw);z-index:10000;opacity:0;transform:translateX(100px);transition:all var(--trans-base);display:flex;align-items:center;gap:.5rem}.feedback.show{opacity:1;transform:translateX(0)}.feedback .icon{--w:1.25rem}
:target{outline:0!important;padding:0!important}.dashboard .qtoggle{left:0;bottom:0}.dashboard>header{justify-content:flex-end;position:fixed}.dashboard>header img{width:var(--btn)}.dashboard h1:first-of-type{margin-top:4rem!important}nav.dashboard-nav,nav.dashboard-nav ul{--dir:row}nav.dashboard-nav ul{touch-action:pan-x;overflow:auto hidden}main>footer{padding:0}main>*{max-width:min(768px,90vw)!important;margin:0 auto!important}main h1{margin:0!important;font-size:var(--txt-large)}.item-grid .item{position:relative}img{width:100%;height:auto;aspect-ratio:1;object-fit:cover}.replace.replace{grid-column:full;padding:0 var(--btn_);max-width:none!important;margin:0!important}.replace .dashboard-page{max-width:var(--wide)}.group-display .item-grid{grid-template-columns:repeat(2,1fr)}.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{bottom:0;right:0}.item-actions button{min-height:0;width:var(--chipchip);height:var(--chipchip);background-color:rgba(var(--base-rgb),var(--op-45))}.item-actions button:hover{background-color:var(--base)}.list-view h3,.list-view p{margin:0!important}.list-view h3{font-size:var(--txt-medium)}@media (min-width:768px){.grid-view{grid-template-columns:repeat(auto-fill,minmax(200px,1fr))}}@media (max-width:768px){.bulk-controls.bulk-controls.nowrap{--wrap:wrap}}.bulk-controls{margin:1rem 0}.bulk-controls .selected-count{font-weight:400;font-size:var(--txt-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(--trans-base),opacity var(--trans-base),border var(--trans-base),padding var(--trans-base)}.selected label:has(:checked){border-color:var(--action-0);padding:0;opacity:1;filter:none;transition:filter var(--trans-base),opacity var(--trans-base),border var(--trans-base),padding var(--trans-base)}form.table img,form.table label.select-item{width:6rem;height:6rem}form.table .item-grid.preview{margin:0}td p{width:max-content}.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(--action-0);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(--action-0);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:0;padding:1rem 0;border-top:1px solid var(--base-200);border-bottom:1px solid var(--base-200);--gap:0}.all-filters .row{--justify:flex-start}.all-filters[open]{--gap:.5rem}.all-filters summary{width:100%;display:flex;justify-content:space-between}.all-filters summary [data-action=clear-filters]{--w:1em!important;width:max-content;font-size:var(--txt-x-small)}.all-filters [data-action=refresh]{margin-left:auto;--w:1em!important;flex-wrap:nowrap;justify-content:flex-start;transition:var(--trans-size);display:flex;font-size:var(--txt-x-small)}.all-filters [data-action=refresh]:focus,.all-filters [data-action=refresh]:hover{width:max-content}.all-filters [data-action=refresh] span{display:none;white-space:nowrap}.all-filters [data-action=refresh]:focus span,.all-filters [data-action=refresh]:hover span{display:block}.all-filters .btn+label{box-shadow:var(--shdw-none);color:var(--base-200)}.all-filters .radio-options input:not(.ch):checked+label{box-shadow:rgba(var(--base-rgb),var(--op-6)) var(--shdw-inset);color:var(--contrast-200);border-color:var(--contrast-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(--txt-small);font-weight:900;width:15vw;display:inline-flex;align-items:center;padding-right:2rem}@media (max-width:767px){.all-filters>.row{padding:.5rem 0}.all-filters span.label{padding-top:.5rem;width:100%;border-top:1px solid var(--base-200)}}.controls .icon{--w:1.4rem}.all-filters .btn+label,.all-filters button{height:var(--chip_);padding:.125rem!important;min-width:0;min-height:var(--chip_);width:var(--chip_)}.all-filters>.row{padding:.25rem 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(--trans-base),width var(--trans-base),padding var(--trans-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(--trans-base),width var(--trans-base),padding var(--trans-base)}.all-filters>.search,.search-container,input[type=search]{width:100%}.crud form.table td .label,.crud form.table td label:not(.select-item-label):not(.radio-option){display:none}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:98vh;width:98vw;max-width:none;max-height:none;inset:0;margin:auto}dialog>.wrap{min-height:100%}dialog .item.upload.upload{display:flex;gap:1rem}dialog .item.upload .preview{width:40%}dialog .item.upload .group{width:60%}.upload details{width:100%}.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(--txt-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(--txt-large)}.dashboard.dash .replace>ul{display:flex;list-style:none;align-items:flex-start;justify-content:flex-start;flex-wrap:wrap;gap:.5rem}nav.tabs.tabs{bottom:0;left:0;right:var(--btn)}.dashboard.settings nav.tabs.tabs{--height:3.5rem;--x:var(--btn_);position:fixed;bottom:var(--btn);left:var(--x);right:var(--x);z-index:99;width:calc(100% - var(--x) - var(--x));background-color:var(--base)}.jvb-seo-admin nav.tabs.tabs{position:sticky;padding-bottom:0;bottom:unset;left:0;right:0;top:var(--btn)}.jvb-seo-admin nav.tabs button{border:none;margin:0 .125rem;background-color:var(--base-200);box-shadow:var(--shdw-none)}.jvb-seo-admin nav.tabs button.active{background-color:var(--base);color:var(--action-0)}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(--radius-outer);padding:1rem;position:relative;transition:all var(--trans-base);box-shadow:rgba(var(--base-rgb),var(--op-45)) var(--shdw)}.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(--txt-medium);margin:0}.integration .meta{margin-bottom:1rem;text-align:right;color:var(--contrast-200);font-size:var(--txt-small)}.integration .setup{font-size:var(--txt-small);font-weight:700;text-transform:uppercase}.integration .setup .indicator{font-size:var(--txt-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}.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(--radius-outer);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:var(--wide)}.card{background-color:var(--base-100);padding:30px;border-radius:var(--radius-outer);text-align:center;margin-bottom:2rem}.dashboard-page.referral{text-align:center}.referral-dashboard .empty-state{padding:3rem 7vw}.referral-dashboard .empty-state h3{margin-top:0}.referral-dashboard .empty-state h3 .icon:first-of-type{margin-right:1rem}.referral-dashboard .empty-state h3 .icon:last-of-type{margin-left:1rem}.item-grid.stats .card{border:1px solid var(--base);display:flex;justify-content:flex-end;align-items:center;flex-direction:column}.item-grid.stats .card.highlight{box-shadow:var(--contrast-rgb) var(--shadow);background-color:var(--action-200);color:var(--action-contrast);grid-column:1/-1;margin:0 4rem 30px;aspect-ratio:unset}.card h4{font-size:var(--medium);color:var(--contrast-200);font-weight:var(--fw-b-bold);margin:0 0 .5rem}.card span{color:var(--action-0);font-weight:var(--fw-b-bold);font-size:var(--txt-xx-large)}.card.highlight span{color:var(--action-contrast)}nav.sidebar{--wrap:nowrap;position:fixed;top:var(--btn);bottom:0;left:0;z-index:var(--z-4);height:calc(100% - var(--btn));background-color:var(--base);box-shadow:rgba(var(--base-rgb),var(--op-45)) var(--shdw);width:var(--btn);transition:var(--trans-size);overflow:hidden auto}nav.sidebar .icon{--w:var(--chip_);width:var(--btn);transition:var(--trans-size),margin var(--trans-base)}nav.sidebar.open{width:fit-content;max-width:100%}nav.sidebar.open .icon{--w:var(--chip);margin:.75rem;width:var(--w)}nav.sidebar ul{height:max-content;width:100%;--gap:0}nav.sidebar .title{display:block}nav.sidebar .toggle{width:var(--btn);height:var(--chipchip);box-shadow:none;background-color:transparent;min-height:0}nav.sidebar .toggle:focus,nav.sidebar .toggle:hover{background-color:var(--action-0);color:var(--action-contrast)}nav.sidebar .toggle.main{position:fixed;left:unset;bottom:0;right:0;width:var(--btn);height:var(--btn);z-index:var(--z-8);box-shadow:rgba(var(--base-rgb),var(--op-45)) var(--shdw)}nav.sidebar .title{white-space:nowrap}nav.sidebar li{--justify:center;flex-wrap:nowrap;overflow:hidden;align-items:flex-start}nav.sidebar.open li>div{width:100%;padding-right:var(--btn)}nav.sidebar.open li.has-submenu>div{padding-right:0}nav.sidebar.open li.has-submenu>ul{padding-left:var(--chip)}nav.sidebar .a{color:var(--contrast-200)}nav.sidebar .a,nav.sidebar a{height:var(--chipchip);display:flex;justify-content:center;align-items:center;transition:none;padding-left:0}nav.sidebar.open .a,nav.sidebar.open a{width:100%;justify-content:flex-start}nav.sidebar .has-submenu ul{max-height:0;height:0;overflow:hidden;transition:var(--trans-size)}nav.sidebar .has-submenu.open>ul{height:100%;max-height:fit-content}header .title,header .title a{height:var(--btn);margin:0;display:block}header .title{margin-left:var(--btn)}header .title a{width:var(--btn)}.dashboard #queue{bottom:0}
assets/css/nav.min.css
@@ -1 +1 @@
nav,nav ol,nav ul{--padding:0 1rem;--wrap:nowrap;display:flex;flex-direction:var(--dir,row);justify-content:var(--justify,flex-start);align-items:var(--align,center);gap:var(--gap,0);flex-wrap:var(--wrap,nowrap);height:var(--btn,3rem);max-width:100%;font-family:var(--heading);padding:0;margin:0}nav li{display:flex;align-items:center;height:max(var(--btn),max-content);width:100%;max-inline-size:none;padding:0}nav a,nav button{display:flex;text-decoration:none;align-items:center;justify-content:center;height:var(--btn);width:100%;white-space:nowrap;text-transform:uppercase;transition:var(--trans-color)}nav a{height:var(--btn);padding:var(--padding)}nav button{justify-content:center;aspect-ratio:1;padding:0;border:2px solid var(--base);color:var(--contrast);border-radius:0}nav .current a,nav a.current,nav a:focus,nav a:focus:visited,nav a:hover,nav button:focus{background-color:var(--action-0);color:var(--action-contrast)}.toggle .icon{transform:rotate(0);transition:transform var(--trans-base)}.has-submenu.open>button .icon{transform:rotate(900deg)}.has-submenu{position:relative}ul.submenu{--dir:column;height:max-content;position:absolute;top:100%;left:0;max-height:0;transform:scaleY(0);transform-origin:top;width:max(100%,max-content);background-color:rgba(var(--base-rgb),var(--op-3));border:2px solid rgba(var(--base-rgb),var(--op-3));transition:all var(--trans-t) var(--trans-fn);box-shadow:var(--shdw-none);overflow:hidden}.submenu li{background-color:rgba(var(--base-rgb),var(--op-6));border:1px solid var(--base-50)}.open>ul.submenu{transform:scaleY(1);max-height:1000%;box-shadow:rgba(var(--base-rgb),var(--op-45)) var(--shdw)}.screen-reader-text{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border-width:0}nav a:focus:not(:focus-visible){outline:0}nav a:focus-visible{outline:2px solid var(--action-0);outline-offset:2px}nav.always{--dir:column;--wrap:nowrap;position:fixed;bottom:0;right:0;width:var(--btn);z-index:var(--z-10)}nav.always.open{--justify:flex-end;width:100vw;height:100vh;padding-bottom:var(--btn_);background-color:rgba(var(--base-rgb),var(--op-6));backdrop-filter:blur(5px)}nav.always>ul{--dir:column;--align:center;--justify:flex-start;--gap:0;height:100%;position:relative;right:-300vw;width:100vw;max-height:100%;padding:1rem 0 0;overflow:hidden auto;transition:right var(--trans-base)}nav.always.open>ul{right:0}nav.always li{flex-wrap:wrap;background-color:rgba(var(--base-rgb),var(--op-6))}nav.always a{padding:1rem;max-width:calc(100% - var(--btn));text-align:center}nav.always .has-submenu{display:flex}nav.always .has-submenu>a{flex:1}nav.always .has-submenu>button{flex:0 0 var(--btn)}nav.always .submenu{position:relative;padding-right:4rem;height:max-content;top:0;width:100%;border:2px solid var(--action-0);background-color:rgba(var(--contrast-rgb),var(--op-1))}nav.always .submenu li{background-color:rgba(var(--base-rgb),var(--op-3))}nav.always>button{position:fixed;bottom:0;right:0;width:var(--btn);height:var(--btn);background-color:var(--base);color:var(--contrast);box-shadow:rgba(var(--base-rgb),var(--op-45)) var(--shdw);transition:width var(--trans-base)}nav.always>button:hover{background-color:var(--action-0);color:var(--action-contrast)}nav.always.open>button{width:100%;background-color:rgba(var(--base-rgb),var(--op-6));backdrop-filter:blur(5px);z-index:1000000}nav.always.open>button .icon-list,nav.always>button .icon-x{display:none}nav.always.open>button .icon-x,nav.always>button .icon-list{display:block;width:32px;height:32px}@media (min-width:768px){nav.always>ul{padding-top:var(--btn)}}nav#breadcrumbs{height:max-content;--wrap:wrap;--gap:0;width:max-content;max-width:var(--full);position:absolute;background-color:rgba(var(--base-rgb),var(--op-4));font-size:var(--txt-x-small);padding:.125em;z-index:var(--z-7)}#breadcrumbs ol{height:max-content;--wrap:wrap!important;--justify:flex-start!important}#breadcrumbs li{width:max-content}#breadcrumbs a{height:var(--chip)}#breadcrumbs li::after{content:'/';color:var(--contrast-200);padding:0 .25rem}#breadcrumbs li:last-of-type::after{display:none}#breadcrumbs :is(a,span){padding:0 .125rem;color:var(--contrast);text-transform:none}#breadcrumbs a:focus{background-color:transparent;color:var(--action-0)}nav.fixed.bottom{position:fixed;bottom:0;left:0;width:calc(100% - var(--btn));box-shadow:rgba(var(--base-rgb),var(--op-45)) var(--shdw);z-index:var(--z-9)}nav.fixed.bottom ul{--justify:space-between;width:100%;background-color:var(--base);padding:0 .25rem}nav.fixed.bottom li{flex:1;justify-content:center}nav.fixed.bottom a{gap:1rem;--w:var(--chip_);color:var(--contrast);font-size:var(--txt-x-small)}@media (min-width:768px){nav.fixed.bottom a{font-size:var(--txt-medium)}}nav.on-this-page{--justify:space-between;position:fixed;bottom:0;left:0;width:calc(100% - var(--btn));max-width:none;padding:0 .5rem;background-color:rgba(var(--base-rgb),var(--op-4));color:var(--base-200);box-shadow:rgba(var(--base-rgb),var(--op-45)) var(--shdw);z-index:var(--z-6)}.on-this-page a,.on-this-page li{width:100%;height:100%}body:has(nav.fixed) nav.on-this-page{bottom:var(--btn)}body:has(.additional-actionsbutton) nav.on-this-page{width:calc(100vw - var(--btn_) - 1rem)}.on-this-page button{order:3;padding:0 1rem;width:max-content;aspect-ratio:unset;height:var(--btn)}.on-this-page.open button{order:0}.on-this-page ul{width:100%;gap:0}.on-this-page li{justify-content:center}.on-this-page .active a{background-color:rgba(var(--base-rgb),var(--op-6));color:var(--action-contrast)}.on-this-page a{padding:0}.on-this-page #back-to-top span{display:none}.on-this-page.open #back-to-top span{display:block}nav.letters li{height:var(--chip);max-width:calc(7.69% - 2px)}nav.letters ul{--wrap:wrap}nav.letters,nav.letters ul{height:var(--chipchip)}@media (min-width:768px){nav.letters,nav.letters ul{height:var(--chip)}nav.letters ul{--wrap:nowrap}nav.letters li{max-width:none}nav.letters a{padding:.25rem .66rem}}nav.index{--justify:space-between;--padding:0;background-color:rgba(var(--base-rgb),var(--op-6))}.index ul{width:100%}.index li{flex-shrink:0;transform:scaleX(0);max-width:0;overflow:hidden}.index li.active,.index li.adj{transform:scaleX(1);width:calc(100% - var(--btn_));flex-shrink:1;max-width:none}.index li:first-of-type{flex-shrink:1;transform:scaleX(1);order:9999;width:var(--btn);height:var(--btn);max-width:none}@media (max-width:767px){.index li.adj{transform:scaleX(0);max-width:0}}.index a{border-bottom:4px solid transparent}.index .active a{border-color:var(--action-0);color:var(--contrast)}.index.open{--dir:column-reverse;height:var(--maxHeight);width:100%;align-items:flex-end;background-color:rgba(var(--base-rgb),var(--op-6));backdrop-filter:blur(5px);z-index:var(--z-10)}.index.open ul{--dir:column;--justify:flex-end;height:100%;width:100%}.index.open li{width:100%;height:var(--btn);max-width:100%!important;transform:scaleX(1);overflow:visible}.index.open a{justify-content:flex-end;padding:0 2rem 0 0;background-color:transparent}nav.condensed{height:max-content;--wrap:wrap;--gap:0 .25rem}nav.condensed ul{min-height:var(--chip_);height:max-content;--justify:center;--wrap:wrap}.condensed li{width:max-content;min-height:var(--chip)}.condensed li+li::before{content:'·';padding:0 .25em}.condensed a{height:max-content;min-height:var(--chip);font-size:var(--txt-x-small);padding:0 .25rem;text-transform:none;border-bottom:2px solid transparent}.condensed a:focus{border-color:var(--action-0)}ul.socials{--dir:row;height:max-content;--gap:.5rem;--justify:stretch;--wrap:nowrap;overflow:auto hidden;touch-action:pan-x}.always ul.socials,.always ul.socials a,.always ul.socials li{width:100%}ul.socials a{padding:.5rem;max-width:none}ul.socials .icon{margin:0}nav.tabs{position:fixed;bottom:var(--btn);left:var(--btnbtn);right:var(--btnbtn);padding-bottom:2px;z-index:var(--z-6);touch-action:pan-x pan-y;--wrap:nowrap;overflow:auto hidden}nav.tabs button{aspect-ratio:unset}nav.tabs button.active{cursor:default}nav.tabs button.active:hover{background-color:var(--base-100);color:var(--contrast)}nav.tabs button h2{--wrap:nowrap;margin:0;font-size:var(--txt-x-small)}.tab-content nav.tabs button{height:var(--chip_);padding:.25rem .75rem;min-height:0}.tab-content.active{padding:1rem 0}.tab-content h2{margin:0 0 .5rem}.tab-content nav.tabs{height:max-content;background-color:var(--base);--gap:0}.tab-content .tab-content nav.tabs{background-color:var(--base-100)}.tab-content .tab-content .tab-content nav.tabs{background-color:var(--base-200)}.tab-content nav.tabs button.active h2{color:var(--action-0)}nav.menu a{padding:.5rem .66rem}nav.share{height:max-content;margin:1rem 0}nav.share ul{overflow:visible}nav.share h4{display:inline-block;width:max-content;margin:.25rem .5rem .25rem 0;font-size:var(--txt-x-small)}:where(body>header,.wp-site-blocks>header){--dir:row;--justify:space-between;position:sticky;top:0;left:0;right:0;height:var(--btn);width:100vw;display:flex;align-items:center;padding:0 .5rem;background-color:var(--base);box-shadow:rgba(var(--base-rgb),var(--op-45)) var(--shdw);z-index:var(--z-9)}.wp-site-blocks>header img{width:var(--btn)}nav.term-navigation:has([hidden]){display:none}.dashboard-nav{--justify:flex-start;width:100%}nav.filters{--dir:row;--justify:flex-start;overflow:auto hidden}nav.filters .filter{width:auto;padding:.25rem .75rem}
nav,nav ol,nav ul{--padding:0 1rem;--wrap:nowrap;display:flex;flex-direction:var(--dir,row);justify-content:var(--justify,flex-start);align-items:var(--align,center);gap:var(--gap,0);flex-wrap:var(--wrap,nowrap);height:var(--btn,3rem);max-width:100%;font-family:var(--heading);padding:0;margin:0}nav li{display:flex;align-items:center;height:max(var(--btn),max-content);width:100%;max-inline-size:none;padding:0}nav a,nav button{display:flex;text-decoration:none;align-items:center;justify-content:center;height:var(--btn);width:100%;white-space:nowrap;text-transform:uppercase;transition:var(--trans-color);border-radius:0;background-color:transparent}nav a{height:var(--btn);padding:var(--padding)}nav button{justify-content:center;aspect-ratio:1;padding:0;border:2px solid var(--base);color:var(--contrast);border-radius:0}nav .current a,nav a.current,nav a:focus,nav a:focus:visited,nav button:focus{background-color:var(--action-0);color:var(--action-contrast)}.toggle .icon-caret-down{transform:rotate(0);transition:transform var(--trans-base)}.has-submenu.open>button .icon-caret-down{transform:rotate(900deg)}.has-submenu{position:relative}ul.submenu{--dir:column;height:max-content;position:absolute;top:100%;right:0;max-height:0;transform:scaleY(0);transform-origin:top;width:100%;min-width:max-content;background-color:rgba(var(--base-rgb),var(--op-3));border:2px solid rgba(var(--base-rgb),var(--op-3));transition:all var(--trans-t) var(--trans-fn);box-shadow:var(--shdw-none);overflow:hidden}.submenu li{background-color:rgba(var(--base-rgb),var(--op-6));border:1px solid var(--base-50)}.submenu a{height:var(--chipchip)}.open>ul.submenu{transform:scaleY(1);max-height:1000%;box-shadow:rgba(var(--base-rgb),var(--op-45)) var(--shdw)}.screen-reader-text{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border-width:0}nav a:focus:not(:focus-visible){outline:0}nav a:focus-visible{outline:2px solid var(--action-0);outline-offset:2px}nav.always{--dir:column;--wrap:nowrap;position:fixed;bottom:0;right:0;width:var(--btn);z-index:var(--z-10)}nav.always.open{--justify:flex-end;width:100vw;height:100vh;padding-bottom:var(--btn_);background-color:rgba(var(--base-rgb),var(--op-6));backdrop-filter:blur(5px)}nav.always>ul{--dir:column;--align:center;--justify:flex-start;--gap:0;height:100%;position:relative;right:-300vw;width:100vw;max-height:100%;padding:1rem 0 0;overflow:hidden auto;transition:right var(--trans-base)}nav.always.open>ul{right:0}nav.always li{flex-wrap:wrap;background-color:rgba(var(--base-rgb),var(--op-6))}nav.always a{padding:1rem;max-width:calc(100% - var(--btn));text-align:center}nav.always .has-submenu{display:flex}nav.always .has-submenu>a{flex:1}nav.always .has-submenu>button{flex:0 0 var(--btn)}nav.always .submenu{position:relative;padding-right:4rem;height:max-content;top:0;width:100%;border:2px solid var(--action-0);background-color:rgba(var(--contrast-rgb),var(--op-1))}nav.always .submenu li{background-color:rgba(var(--base-rgb),var(--op-3))}nav.always>button{position:fixed;bottom:0;right:0;width:var(--btn);height:var(--btn);background-color:var(--base);color:var(--contrast);box-shadow:rgba(var(--base-rgb),var(--op-45)) var(--shdw);transition:width var(--trans-base)}nav.always>button:hover{background-color:var(--action-0);color:var(--action-contrast)}nav.always.open>button{width:100%;background-color:rgba(var(--base-rgb),var(--op-6));backdrop-filter:blur(5px);z-index:1000000}nav.always.open>button .icon-list,nav.always>button .icon-x{display:none}nav.always.open>button .icon-x,nav.always>button .icon-list{display:block;width:32px;height:32px}@media (min-width:768px){nav.always>ul{padding-top:var(--btn)}}nav#breadcrumbs{height:max-content;--wrap:wrap;--gap:0;width:max-content;max-width:var(--full);position:absolute;background-color:rgba(var(--base-rgb),var(--op-4));font-size:var(--txt-x-small);padding:.125em;z-index:var(--z-7)}#breadcrumbs ol{height:max-content;--wrap:wrap!important;--justify:flex-start!important}#breadcrumbs li{width:max-content}#breadcrumbs a{height:var(--chip)}#breadcrumbs li::after{content:'/';color:var(--contrast-200);padding:0 .25rem}#breadcrumbs li:last-of-type::after{display:none}#breadcrumbs :is(a,span){padding:0 .125rem;color:var(--contrast);text-transform:none}#breadcrumbs a:focus{background-color:transparent;color:var(--action-0)}nav.fixed.bottom{position:fixed;bottom:0;left:0;width:calc(100% - var(--btn));box-shadow:rgba(var(--base-rgb),var(--op-45)) var(--shdw);z-index:var(--z-9)}nav.fixed.bottom ul{--justify:space-between;width:100%;background-color:var(--base);padding:0 .25rem}nav.fixed.bottom li{flex:1;justify-content:center}nav.fixed.bottom a{gap:1rem;--w:var(--chip_);color:var(--contrast);font-size:var(--txt-x-small)}@media (min-width:768px){nav.fixed.bottom a{font-size:var(--txt-medium)}}nav.on-this-page{--justify:space-between;position:fixed;bottom:0;left:0;width:calc(100% - var(--btn));max-width:none;padding:0 .5rem;background-color:rgba(var(--base-rgb),var(--op-4));color:var(--base-200);box-shadow:rgba(var(--base-rgb),var(--op-45)) var(--shdw);z-index:var(--z-6)}.on-this-page a,.on-this-page li{width:100%;height:100%}body:has(nav.fixed) nav.on-this-page{bottom:var(--btn)}body:has(.additional-actionsbutton) nav.on-this-page{width:calc(100vw - var(--btn_) - 1rem)}.on-this-page button{order:3;padding:0 1rem;width:max-content;aspect-ratio:unset;height:var(--btn)}.on-this-page.open button{order:0}.on-this-page ul{width:100%;gap:0}.on-this-page li{justify-content:center}.on-this-page .active a{background-color:rgba(var(--base-rgb),var(--op-6));color:var(--action-contrast)}.on-this-page a{padding:0}.on-this-page #back-to-top span{display:none}.on-this-page.open #back-to-top span{display:block}nav.letters li{height:var(--chip);max-width:calc(7.69% - 2px)}nav.letters ul{--wrap:wrap}nav.letters,nav.letters ul{height:var(--chipchip)}@media (min-width:768px){nav.letters,nav.letters ul{height:var(--chip)}nav.letters ul{--wrap:nowrap}nav.letters li{max-width:none}nav.letters a{padding:.25rem .66rem}}nav.index{--justify:space-between;--padding:0;background-color:rgba(var(--base-rgb),var(--op-6))}.index ul{width:100%}.index li{flex-shrink:0;transform:scaleX(0);max-width:0;overflow:hidden}.index li.active,.index li.adj{transform:scaleX(1);width:calc(100% - var(--btn_));flex-shrink:1;max-width:none}.index li:first-of-type{flex-shrink:1;transform:scaleX(1);order:9999;width:var(--btn);height:var(--btn);max-width:none}@media (max-width:767px){.index li.adj{transform:scaleX(0);max-width:0}}.index a{border-bottom:4px solid transparent}.index .active a{border-color:var(--action-0);color:var(--contrast)}.index.open{--dir:column-reverse;height:var(--maxHeight);width:100%;align-items:flex-end;background-color:rgba(var(--base-rgb),var(--op-6));backdrop-filter:blur(5px);z-index:var(--z-10)}.index.open ul{--dir:column;--justify:flex-end;height:100%;width:100%}.index.open li{width:100%;height:var(--btn);max-width:100%!important;transform:scaleX(1);overflow:visible}.index.open a{justify-content:flex-end;padding:0 2rem 0 0;background-color:transparent}nav.condensed{height:max-content;--wrap:wrap;--gap:0 .25rem}nav.condensed ul{min-height:var(--chip_);height:max-content;--justify:center;--wrap:wrap}.condensed li{width:max-content;min-height:var(--chip)}.condensed li+li::before{content:'·';padding:0 .25em}.condensed a{height:max-content;min-height:var(--chip);font-size:var(--txt-x-small);padding:0 .25rem;text-transform:none;border-bottom:2px solid transparent}.condensed a:focus{border-color:var(--action-0)}ul.socials{--dir:row;height:max-content;--gap:.5rem;--justify:stretch;--wrap:nowrap;overflow:auto hidden;touch-action:pan-x}.always ul.socials,.always ul.socials a,.always ul.socials li{width:100%}ul.socials a{padding:.5rem;max-width:none}ul.socials .icon{margin:0}nav.tabs{position:fixed;bottom:var(--btn);left:var(--btnbtn);right:var(--btnbtn);padding-bottom:2px;z-index:var(--z-6);touch-action:pan-x pan-y;--wrap:nowrap;overflow:auto hidden}nav.tabs button{aspect-ratio:unset}nav.tabs button.active{cursor:default}nav.tabs button.active:hover{background-color:var(--base-100);color:var(--contrast)}nav.tabs button h2{--wrap:nowrap;margin:0;font-size:var(--txt-x-small)}.tab-content nav.tabs button{height:var(--chip_);padding:.25rem .75rem;min-height:0}.tab-content.active{padding:1rem 0}.tab-content h2{margin:0 0 .5rem}.tab-content nav.tabs{height:max-content;background-color:var(--base);--gap:0}.tab-content .tab-content nav.tabs{background-color:var(--base-100)}.tab-content .tab-content .tab-content nav.tabs{background-color:var(--base-200)}.tab-content nav.tabs button.active h2{color:var(--action-0)}nav.menu a{padding:.5rem .66rem}nav.share{height:max-content;margin:1rem 0}nav.share ul{overflow:visible}nav.share h4{display:inline-block;width:max-content;margin:.25rem .5rem .25rem 0;font-size:var(--txt-x-small)}:where(body>header,.wp-site-blocks>header){--dir:row;--justify:space-between;position:sticky;top:0;left:0;right:0;height:var(--btn);width:100vw;display:flex;align-items:center;padding:0 .5rem;background-color:var(--base);box-shadow:rgba(var(--base-rgb),var(--op-45)) var(--shdw);z-index:var(--z-9)}.wp-site-blocks>header img{width:var(--btn)}nav.term-navigation:has([hidden]){display:none}.dashboard-nav{--justify:flex-start;width:100%}nav.filters{--dir:row;--justify:flex-start;overflow:auto hidden}nav.filters .filter{width:auto;padding:.25rem .75rem}
assets/js/concise/UtilityFunctions.js
@@ -1010,3 +1010,28 @@
    window.decodeHelper.innerHTML = text;
    return window.decodeHelper.value;
}
window.focusNextElement = function() {
    //add all elements we want to include in our selection
    var focussableElements =
        'a:not([disabled]), button:not([disabled]), input[type=text]:not([disabled]), [tabindex]:not([disabled]):not([tabindex="-1"])';
    if (document.activeElement && document.activeElement.form) {
        var focussable = Array.prototype.filter.call(
            document.activeElement.form.querySelectorAll(focussableElements),
            function (element) {
                //check for visibility while always include the current activeElement
                return (
                    element.offsetWidth > 0 ||
                    element.offsetHeight > 0 ||
                    element === document.activeElement
                );
            }
        );
        var index = focussable.indexOf(document.activeElement);
        if (index > -1) {
            var nextElement = focussable[index + 1] || focussable[0];
            nextElement.focus();
        }
    }
}
assets/js/concise/navigation.js
@@ -7,6 +7,9 @@
        }
        this.openNav = null;
        this.openSubmenu = null;
        this.releaseFocusTrap = null;
        this.clicked = new Set();
        this.initListeners();
    }
@@ -20,8 +23,8 @@
                this.counter++;
            }
            if (nav.querySelector('.submenu')) {
                nav.addEventListener('mouseenter', this.hoverOnListener);
                nav.addEventListener('mouseleave', this.hoverOffListener);
                nav.addEventListener('mouseenter', this.handleHoverOn.bind(this));
                nav.addEventListener('mouseleave', this.handleHoverOff.bind(this));
            }
            let [
@@ -39,14 +42,20 @@
                submenus: submenus,
                submenuToggles: submenuToggles
            }
            this.navs.set(navID, elements);
            this.counter++;
            submenus.forEach(menu => {
                menu.id = 'submenu-'+this.counter;
                menu.addEventListener('mouseenter', this.handleHoverOn.bind(this));
                menu.addEventListener('mouseleave', this.handleHoverOff.bind(this));
                this.counter++;
            });
            this.navs.set(navID, elements);
        });
    }
    initListeners() {
        this.clickListener = this.handleClick.bind(this);
        this.escapeListener = this.handleEscape.bind(this);
        this.keysListener = this.handleKeys.bind(this);
        this.hoverOnListener = this.handleHoverOn.bind(this);
        this.hoverOffListener = this.handleHoverOff.bind(this);
@@ -56,59 +65,144 @@
        if (this.navs.size === 0) {
            return;
        }
        if (this.openNav && e.target.closest(`#${this.openNav}`) === null) {
            console.log('Closing nav', this.openNav);
            this.toggleNav(false, this.openNav);
        }
        // if (!e.target.closest(this.openNav)) {
        //  console.log('Not closest nav ids');
        //  console.log(this.navIDs());
        //  return;
        // }
        let toggle = e.target.closest('.toggle.main');
        if (toggle) {
            let nav = toggle.closest('nav');
            this.toggleNav(!nav.classList.contains('open'), nav.id);
            let isOpening = !this.clicked.has(nav);
            let shouldToggle = nav.classList.contains('open') !== isOpening;
            if (shouldToggle) {
                this.toggleNav(isOpening, nav.id);
            }
            if (isOpening) {
                this.clicked.add(nav);
            } else {
                this.clicked.delete(nav);
            }
            return;
        }
        let submenuToggle = e.target.closest('[data-action="toggle-submenu"], .has-submenu .a')
        if (submenuToggle) {
            let li = submenuToggle.closest('li');
            this.toggleSubmenu(!li.classList.contains('open'), li);
        }
            let isOpening = !this.clicked.has(li);
            let shouldToggle = li.classList.contains('open') !== isOpening;
            if (isOpening) {
                this.clicked.add(li);
            } else {
                this.clicked.delete(li);
            }
            if (shouldToggle) {
                this.toggleSubmenu(isOpening, li);
            }
            return;
        }
        if (!this.openNav) {
            return;
        }
        let close = true;
        for (let [navID, elements] of this.navs) {
            if (e.target.closest('#'+navID)) {
                close = false;
                break;
            }
        }
        if (close) {
            this.toggleNav(false, this.openNav);
        }
    }
    handleHoverOn(e) {
        let nav =  e.target.closest('nav');
        if (nav) {
            this.toggleNav(true, nav.id);
        let target = e.currentTarget;
        if (this.clicked.has(target)) {
            return;
        }
        let submenu = e.target.closest('.has-submenu');
        if (submenu) {
            this.toggleSubmenu(true, submenu);
        if (target.classList.contains('has-submenu')) {
            this.toggleSubmenu(true, target);
        } else if (target.tagName === 'NAV') {
            if (!target.classList.contains('mobile')) {
                this.toggleNav(true, target.id);
            }
        }
    }
    handleHoverOff(e) {
        let nav =  e.target.closest('nav');
        if (nav) {
            this.toggleNav(false, nav.id);
        let target = e.currentTarget;
        if (this.clicked.has(target)) {
            return;
        }
        // let submenu = e.target.closest('.has-submenu');
        // if (submenu) {
        //  this.toggleSubmenu(false, submenu);
        // }
        if (target.classList.contains('has-submenu')) {
            this.toggleSubmenu(false, target);
        } else if (target.tagName === 'NAV') {
            if (target.classList.contains('mobile')) {
                return;
            }
            let nav = this.navs.get(target.id);
            let shouldToggle = true;
            for (let submenu of nav.submenus) {
                if (this.clicked.has(submenu)) {
                    shouldToggle = false;
                    break;
                }
            }
            if (shouldToggle) {
                this.toggleNav(false, target.id);
            }
        }
    }
    handleEscape(e) {
        if (this.openNav && e.key === 'Escape') {
            this.toggleNav(false, this.openNav);
    handleKeys(e) {
        if (!this.openNav) return;
        switch (e.key) {
            case 'Escape':
                this.closeAll();
                break;
            case 'ArrowDown':
                this.focusNextItem();
                e.preventDefault();
                break;
            case 'ArrowUp':
                this.focusPrevItem();
                e.preventDefault();
                break;
        }
    }
    closeAll() {
        let nav = this.navs.get(this.openNav);
        if (nav && this.clicked.has(nav.nav)) {
            this.clicked.delete(nav.nav);
        }
        this.toggleNav(false, this.openNav);
    }
    focusNextItem() {
        const items = this.getFocusableItems();
        const i = items.indexOf(document.activeElement);
        const next = items[i + 1] || items[0];
        next.focus();
    }
    focusPrevItem() {
        const items = this.getFocusableItems();
        const i = items.indexOf(document.activeElement);
        const prev = items[i - 1] || items[items.length - 1];
        prev.focus();
    }
    getFocusableItems() {
        return Array.from(document.querySelectorAll(
            'nav.open a, nav.open button'
        )).filter(el => !el.disabled && !el.closest('[hidden]') && !el.closest('[inert]'));
    }
    toggleNav(on, id) {
        let nav = this.navs.get(id);
        if (!nav) {
@@ -120,12 +214,20 @@
        }
        if (on) {
            this.openNav = id;
            document.addEventListener('keydown', this.escapeListener);
            document.addEventListener('keydown', this.keysListener);
            if (nav.nav.classList.contains('mobile')) {
                this.releaseFocusTrap = window.jvbA11y.trapFocus(nav.nav);
            }
        } else {
            if (this.releaseFocusTrap) {
                this.releaseFocusTrap();
                this.releaseFocusTrap = null;
            }
            if (this.openNav === id) {
                this.openNav = null;
            }
            document.removeEventListener('keydown', this.escapeListener);
            document.removeEventListener('keydown', this.keysListener);
            if (!nav.nav.classList.contains('sidebar')) {
                Array.from(nav.submenus).forEach(submenu => {
                    if(submenu.classList.contains('open')) {
@@ -133,17 +235,41 @@
                    }
                });
            }
            Array.from(nav.submenus).forEach(submenu => {
                if (this.clicked.has(submenu)) {
                    this.clicked.delete(submenu);
                }
            });
        }
        nav.nav.ariaExpanded = on;
        nav.nav.classList.toggle('open', on);
        nav.ariaHidden = !on;
        if (nav.nav.classList.contains('mobile')) {
            const content = nav.nav.querySelector(':scope > ul');
            if (content) content.inert = !on;
        }
        nav.nav.setAttribute('aria-expanded', on);
        if (on) {
            nav.nav.querySelector('a:not(.skip-to-content)')?.focus();
        }
    }
    toggleSubmenu(on, submenu) {
        if (on && this.openSubmenu && this.openSubmenu !== submenu) {
            this.toggleSubmenu(false, this.openSubmenu);
        }
        if (on) {
            this.openSubmenu = submenu;
        } else if (this.openSubmenu === submenu) {
            this.openSubmenu = null;
        }
        let [
            toggle,
            firstLink
@@ -152,14 +278,22 @@
            submenu.querySelector('a')
        ];
        if (!on) {
            toggle.focus();
        }
        const content = submenu.querySelector(':scope > ul');
        if (content) content.inert = !on;
        let label = toggle.getAttribute('aria-label');
        window.jvbA11y.announce(on ? `${label} expanded` : `${label} collapsed`);
        submenu.classList.toggle('open', on);
        submenu.ariaHidden = !on;
        toggle.ariaExpanded = on;
        toggle.setAttribute('aria-expanded', on);
        if (on && firstLink) {
            firstLink.focus();
        }
    }
}
document.addEventListener('DOMContentLoaded', function() {
assets/js/min/navigation.min.js
@@ -1 +1 @@
(()=>{class e{constructor(){this.counter=0,this.initElements(),0!==this.navs.size&&(this.openNav=null,this.initListeners())}initElements(){this.navs=new Map,document.querySelectorAll("nav:has(.submenu), nav:has(.toggle)").forEach((e=>{let t=e.id;""===t&&(t=`nav-${this.counter}`,e.id=t,this.counter++),e.querySelector(".submenu")&&(e.addEventListener("mouseenter",this.hoverOnListener),e.addEventListener("mouseleave",this.hoverOffListener));let[s,n,i]=[e.querySelectorAll("nav .toggle"),e.querySelectorAll(".has-submenu"),e.querySelectorAll(".toggle:not(.main)")],a={nav:e,toggles:s,submenus:n,submenuToggles:i};this.navs.set(t,a),this.counter++}))}initListeners(){this.clickListener=this.handleClick.bind(this),this.escapeListener=this.handleEscape.bind(this),this.hoverOnListener=this.handleHoverOn.bind(this),this.hoverOffListener=this.handleHoverOff.bind(this),document.addEventListener("click",this.clickListener)}handleClick(e){if(0===this.navs.size)return;this.openNav&&null===e.target.closest(`#${this.openNav}`)&&(console.log("Closing nav",this.openNav),this.toggleNav(!1,this.openNav));let t=e.target.closest(".toggle.main");if(t){let e=t.closest("nav");this.toggleNav(!e.classList.contains("open"),e.id)}let s=e.target.closest('[data-action="toggle-submenu"], .has-submenu .a');if(s){let e=s.closest("li");this.toggleSubmenu(!e.classList.contains("open"),e)}}handleHoverOn(e){let t=e.target.closest("nav");t&&this.toggleNav(!0,t.id);let s=e.target.closest(".has-submenu");s&&this.toggleSubmenu(!0,s)}handleHoverOff(e){let t=e.target.closest("nav");t&&this.toggleNav(!1,t.id)}handleEscape(e){this.openNav&&"Escape"===e.key&&this.toggleNav(!1,this.openNav)}toggleNav(e,t){let s=this.navs.get(t);s&&(e&&t!==this.openNav&&this.toggleNav(!1,this.openNav),e?(this.openNav=t,document.addEventListener("keydown",this.escapeListener)):(this.openNav===t&&(this.openNav=null),document.removeEventListener("keydown",this.escapeListener),s.nav.classList.contains("sidebar")||Array.from(s.submenus).forEach((e=>{e.classList.contains("open")&&this.toggleSubmenu(!1,e)}))),s.nav.ariaExpanded=e,s.nav.classList.toggle("open",e),s.ariaHidden=!e,e&&s.nav.querySelector("a:not(.skip-to-content)")?.focus())}toggleSubmenu(e,t){let[s,n]=[t.querySelector(".toggle"),t.querySelector("a")];t.classList.toggle("open",e),t.ariaHidden=!e,s.ariaExpanded=e,e&&n&&n.focus()}}document.addEventListener("DOMContentLoaded",(function(){window.jvbNav=new e}))})();
(()=>{class e{constructor(){this.counter=0,this.initElements(),0!==this.navs.size&&(this.openNav=null,this.openSubmenu=null,this.releaseFocusTrap=null,this.clicked=new Set,this.initListeners())}initElements(){this.navs=new Map,document.querySelectorAll("nav:has(.submenu), nav:has(.toggle)").forEach((e=>{let t=e.id;""===t&&(t=`nav-${this.counter}`,e.id=t,this.counter++),e.querySelector(".submenu")&&(e.addEventListener("mouseenter",this.handleHoverOn.bind(this)),e.addEventListener("mouseleave",this.handleHoverOff.bind(this)));let[s,n,i]=[e.querySelectorAll("nav .toggle"),e.querySelectorAll(".has-submenu"),e.querySelectorAll(".toggle:not(.main)")],a={nav:e,toggles:s,submenus:n,submenuToggles:i};this.counter++,n.forEach((e=>{e.id="submenu-"+this.counter,e.addEventListener("mouseenter",this.handleHoverOn.bind(this)),e.addEventListener("mouseleave",this.handleHoverOff.bind(this)),this.counter++})),this.navs.set(t,a)}))}initListeners(){this.clickListener=this.handleClick.bind(this),this.keysListener=this.handleKeys.bind(this),this.hoverOnListener=this.handleHoverOn.bind(this),this.hoverOffListener=this.handleHoverOff.bind(this),document.addEventListener("click",this.clickListener)}handleClick(e){if(0===this.navs.size)return;let t=e.target.closest(".toggle.main");if(t){let e=t.closest("nav"),s=!this.clicked.has(e);return e.classList.contains("open")!==s&&this.toggleNav(s,e.id),void(s?this.clicked.add(e):this.clicked.delete(e))}let s=e.target.closest('[data-action="toggle-submenu"], .has-submenu .a');if(s){let e=s.closest("li"),t=!this.clicked.has(e),n=e.classList.contains("open")!==t;return t?this.clicked.add(e):this.clicked.delete(e),void(n&&this.toggleSubmenu(t,e))}if(!this.openNav)return;let n=!0;for(let[t,s]of this.navs)if(e.target.closest("#"+t)){n=!1;break}n&&this.toggleNav(!1,this.openNav)}handleHoverOn(e){let t=e.currentTarget;this.clicked.has(t)||(t.classList.contains("has-submenu")?this.toggleSubmenu(!0,t):"NAV"===t.tagName&&(t.classList.contains("mobile")||this.toggleNav(!0,t.id)))}handleHoverOff(e){let t=e.currentTarget;if(!this.clicked.has(t))if(t.classList.contains("has-submenu"))this.toggleSubmenu(!1,t);else if("NAV"===t.tagName){if(t.classList.contains("mobile"))return;let e=this.navs.get(t.id),s=!0;for(let t of e.submenus)if(this.clicked.has(t)){s=!1;break}s&&this.toggleNav(!1,t.id)}}handleKeys(e){if(this.openNav)switch(e.key){case"Escape":this.closeAll();break;case"ArrowDown":this.focusNextItem(),e.preventDefault();break;case"ArrowUp":this.focusPrevItem(),e.preventDefault()}}closeAll(){let e=this.navs.get(this.openNav);e&&this.clicked.has(e.nav)&&this.clicked.delete(e.nav),this.toggleNav(!1,this.openNav)}focusNextItem(){const e=this.getFocusableItems(),t=e.indexOf(document.activeElement);(e[t+1]||e[0]).focus()}focusPrevItem(){const e=this.getFocusableItems(),t=e.indexOf(document.activeElement);(e[t-1]||e[e.length-1]).focus()}getFocusableItems(){return Array.from(document.querySelectorAll("nav.open a, nav.open button")).filter((e=>!e.disabled&&!e.closest("[hidden]")&&!e.closest("[inert]")))}toggleNav(e,t){let s=this.navs.get(t);if(s){if(e&&t!==this.openNav&&this.toggleNav(!1,this.openNav),e?(this.openNav=t,document.addEventListener("keydown",this.keysListener),s.nav.classList.contains("mobile")&&(this.releaseFocusTrap=window.jvbA11y.trapFocus(s.nav))):(this.releaseFocusTrap&&(this.releaseFocusTrap(),this.releaseFocusTrap=null),this.openNav===t&&(this.openNav=null),document.removeEventListener("keydown",this.keysListener),s.nav.classList.contains("sidebar")||Array.from(s.submenus).forEach((e=>{e.classList.contains("open")&&this.toggleSubmenu(!1,e)})),Array.from(s.submenus).forEach((e=>{this.clicked.has(e)&&this.clicked.delete(e)}))),s.nav.classList.toggle("open",e),s.nav.classList.contains("mobile")){const t=s.nav.querySelector(":scope > ul");t&&(t.inert=!e)}s.nav.setAttribute("aria-expanded",e),e&&s.nav.querySelector("a:not(.skip-to-content)")?.focus()}}toggleSubmenu(e,t){e&&this.openSubmenu&&this.openSubmenu!==t&&this.toggleSubmenu(!1,this.openSubmenu),e?this.openSubmenu=t:this.openSubmenu===t&&(this.openSubmenu=null);let[s,n]=[t.querySelector(".toggle"),t.querySelector("a")];e||s.focus();const i=t.querySelector(":scope > ul");i&&(i.inert=!e);let a=s.getAttribute("aria-label");window.jvbA11y.announce(e?`${a} expanded`:`${a} collapsed`),t.classList.toggle("open",e),s.setAttribute("aria-expanded",e),e&&n&&n.focus()}}document.addEventListener("DOMContentLoaded",(function(){window.jvbNav=new e}))})();
assets/js/min/utility.min.js
@@ -1 +1 @@
(()=>{window.fade=function(e,t=!0){t?e.style.animation="fadeIn var(--transition-base)":(e.style.animation="fadeOut var(--transition-base)",window.debouncer.schedule(`remove-${e.dataset.id??e.id??e.className.replace(" ","-")}`,(()=>{e.remove()}),500))},window.formatTimeAgo=function(e,t="default"){const n=e instanceof Date?e:new Date(e),i=n-new Date,o=i<0,r=Math.floor(Math.abs(i)/1e3),a=Math.floor(r/60),s=Math.floor(a/60),l=Math.floor(s/24);if(0===a)return"Just now";let c="";if(r<10)c="a moment";else if(r<60)c="less than a minute";else if(a<5)c="a few minutes";else if(s<24)c=0===s?`${a} ${1===a?"minute":"minutes"}`:`about ${s} ${1===s?"hour":"hours"}`;else{if(!(l<7)){if("default"===t)return n.toLocaleDateString();const e={Y:n.getFullYear(),y:String(n.getFullYear()).slice(-2),F:n.toLocaleDateString("en-CA",{month:"long"}),M:n.toLocaleDateString("en-CA",{month:"short"}),m:String(n.getMonth()+1).padStart(2,"0"),n:n.getMonth()+1,d:String(n.getDate()).padStart(2,"0"),j:n.getDate(),D:n.toLocaleDateString("en-CA",{weekday:"short"}),l:n.toLocaleDateString("en-CA",{weekday:"long"}),H:String(n.getHours()).padStart(2,"0"),i:String(n.getMinutes()).padStart(2,"0"),s:String(n.getSeconds()).padStart(2,"0"),h:String(n.getHours()%12||12).padStart(2,"0"),g:n.getHours()%12||12,A:n.getHours()>=12?"PM":"AM",a:n.getHours()>=12?"pm":"am"};return t.replace(/[YyFMmnjDlHishgAa]/g,(t=>e[t]))}if(1===l)return o?"yesterday":"tomorrow";c=`about ${l} days`,c=`${l} ${1===l?"day":"days"}`}return o?`${c} ago`:`in ${c}`},window.uppercaseFirst=function(e){return e.charAt(0).toUpperCase()+e.slice(1)},window.templates=new Map,document.addEventListener("DOMContentLoaded",(()=>{window.loadTemplates()})),window.loadTemplates=function(){document.querySelectorAll("template").forEach((e=>{const t=Array.from(e.classList);if(t.length>0){const n=e.content.cloneNode(!0).firstElementChild;t.forEach((e=>{window.templates.has(e)||window.templates.set(e,n)}))}}))},window.getTemplate=function(e){return 0===window.templates.size&&window.loadTemplates(),!!window.templates.has(e)&&window.templates.get(e).cloneNode(!0)};window.jvbTemplates=new class{constructor(){this.templates=new Map,this.definitions=new Map}registerAll(e=document){e.querySelectorAll("template").forEach((e=>{e.classList.forEach((t=>{this.templates.has(t)||this.templates.set(t,e)}))}))}define(e,t={},n=null){this.definitions.set(e,{refs:t.refs||null,manyRefs:t.manyRefs||null,setup:t.setup||null,context:n})}create(e,t={}){const n=this.templates.get(e);if(!n)return console.warn(`[TemplateRegistry] Template "${e}" not found`),null;const i=n.content.cloneNode(!0).firstElementChild;if(!i)return null;const o=this.definitions.get(e),r=o?.refs?this.#e(i,o.refs):{},a=o?.manyRefs?this.#e(i,o.manyRefs,!1):{};return o?.setup?.({el:i,refs:r,manyRefs:a,data:t}),i}#e(e,t,n=!0){const i={};for(const[o,r]of Object.entries(t)){let t,a=!1;"string"==typeof r?t=r:(t=r.selector,a=!!r.required);const s=n?e.querySelector(t):e.querySelectorAll(t);a&&(n&&!s&&console.warn(`[TemplateRegistry] Required ref "${o}" not found: ${t}`),n||0!==s.length||console.warn(`[TemplateRegistry] Required manyRef "${o}" not found: ${t}`)),i[o]=n?s:Array.from(s)}return i}},document.addEventListener("DOMContentLoaded",(()=>{window.jvbTemplates.registerAll()})),window.icon=null,window.getIcon=function(e,t=""){if(void 0===e)return"";window.icon||(window.icon=document.createElement("i"),window.icon.className="icon",window.icon.ariaHidden=!0);let n=window.icon.cloneNode(!0);return t=""!==t&&["regular","bold","duotone","fill","light","thin"].includes("style")?`-${t.slice(0,2)}`:"",n.classList.add(`icon-${e}${t}`),n},window.formatNumber=function(e){return e.toString().replace(/\B(?=(\d{3})+(?!\d))/g,",")},window.formatPrice=function(e,t="CAD"){return new Intl.NumberFormat("en-CA",{style:"currency",currency:t}).format(e)},window.escapeHtml=function(e){return e?("string"==typeof e||e instanceof String||(e=String(e)),e.replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;").replace(/'/g,"&#039;")):""},window.removeChildren=function(e){if(0!==e.children.length)for(;e.firstChild;)e.removeChild(e.firstChild)},window.formatDateRange=function(e,t){const n=new Date(e),i=new Date(t);return n.toDateString()===i.toDateString()?n.toLocaleDateString("en-CA",{year:"numeric",month:"short",day:"numeric"}):n.getMonth()===i.getMonth()&&n.getFullYear()===i.getFullYear()?`${n.toLocaleDateString("en-CA",{month:"short",day:"numeric"})} - ${i.getDate()}, ${i.getFullYear()}`:n.getFullYear()===i.getFullYear()?`${n.toLocaleDateString("en-CA",{month:"short",day:"numeric"})} - ${i.toLocaleDateString("en-CA",{month:"short",day:"numeric"})}, ${i.getFullYear()}`:`${n.toLocaleDateString("en-CA",{month:"short",day:"numeric",year:"numeric"})} - ${i.toLocaleDateString("en-CA",{month:"short",day:"numeric",year:"numeric"})}`},window.throttle=function(e,t=300){let n;return function(...i){n||(e.apply(this,i),n=!0,setTimeout((()=>n=!1),t))}},window.chunkIt=async function(e,t,n,i=10){const o=[];for(let t=0;t<e.length;t+=i)o.push(e.slice(t,t+i));for(const e of o){const i=document.createDocumentFragment();e.forEach((e=>{const n=t(e);n&&i.append(n)})),n(i),await new Promise((e=>requestAnimationFrame(e)))}},window.prefixInput=function(e,t,n=null,i=!1,o=!1){if(!e)return void console.warn("prefixInput called with null/undefined input");const r=e.id,a=i?t:`${t}${e.name}`;let s=null;s=n?n.querySelector(`label[for="${r}"]`):e.labels&&e.labels.length>0?e.labels[0]:"LABEL"===e.previousElementSibling?.tagName?e.previousElementSibling:"LABEL"===e.nextElementSibling?.tagName?e.nextElementSibling:e.closest("[data-field]")?.querySelector(`label[for="${r}"]`),s&&(s.htmlFor=a),e.id=a,o&&(e.name=a)},window.uppercaseFirst=function(e){return e.charAt(0).toUpperCase()+e.slice(1)},window.sanitizeHtml=function(e){const t=document.createElement("div");return t.textContent=e,t.innerHTML},window.generateID=function(e="jvb"){return`${e}_${Date.now()}_${Math.random().toString(36).slice(2,9)}`},window.showProgress=function(e,t,n,i="",o=""){const r=t<n;e.progress&&r&&window.fade(e.progress,!0);const a=n>0?t/n*100:0;e.fill&&(e.fill.style.width=`${a}%`),e.details&&(e.details.textContent=i),e.count&&(e.count.textContent=`${t}/${n}`),e.icon&&(e.icon.className=""===o?"icon":"icon icon-"+o),e.progress&&t===n&&window.fade(e.progress,!1)},window.formatDate=function(e){if(!e)return"";const t=new Date(e),n=new Date,i=Math.floor((n-t)/864e5);return i<1?"Today":i<2?"Yesterday":i<7?`${i} days ago`:t.toLocaleDateString()},window.getPluralContent=function(e){return"artwork"===e?"artwork":e+"s"},window.showToast=function(e,t="success",n={}){window.jvbNotifications.showToast(e,t,n)},window.dateFormatter=new Intl.DateTimeFormat("en-CA",{year:"numeric",month:"long",day:"numeric",hour:"2-digit",minute:"2-digit",second:"2-digit",timeZoneName:"short"}),window.formatDate=function(e){return e instanceof Date&&!isNaN(e)||(e=new Date(e)),window.dateFormatter.format(e)},window.typeText=function(e,t,n=50){return new Promise((i=>{e._typeInterval&&(clearInterval(e._typeInterval),delete e._typeInterval);let o=0;e.textContent="",e._typeInterval=setInterval((()=>{o<t.length?(e.textContent+=t.charAt(o),o++):(clearInterval(e._typeInterval),delete e._typeInterval,i())}),n)}))},window.eraseText=function(e,t=10){return new Promise((n=>{e._eraseInterval&&(clearInterval(e._eraseInterval),delete e._eraseInterval);let i=e.textContent,o=i.length;e._eraseInterval=setInterval((()=>{o>0?(o--,e.textContent=i.substring(0,o)):(clearInterval(e._eraseInterval),delete e._eraseInterval,n())}),t)}))},window.typeLoop=function(e,t,n=50,i=10,o=1e3,r=250){const a=e.id||e.dataset.typeKey||`type-${Date.now()}`;e.dataset.typeKey||(e.dataset.typeKey=a),e._stopTyping&&e._stopTyping();let s=!0;const l=function(){s=!1,e._typeInterval&&(clearInterval(e._typeInterval),delete e._typeInterval),e._eraseInterval&&(clearInterval(e._eraseInterval),delete e._eraseInterval)};return e._stopTyping=l,async function(){for(;s&&(await window.typeText(e,t,n),s)&&(await new Promise((e=>setTimeout(e,o))),s)&&(await window.eraseText(e,i),s);)await new Promise((e=>setTimeout(e,r)))}(),l},window.toCamelCase=function(e){return e.replace(/-([a-z])/g,(function(e){return e[1].toUpperCase()}))},window.targetCheck=function(e,t){return Array.isArray(t)&&(t=t.join(",")),"string"==typeof t&&(e.target.closest(t)??!1)},window.getDifferences={VALUE_CREATED:"created",VALUE_UPDATED:"updated",VALUE_DELETED:"deleted",VALUE_UNCHANGED:"unchanged",map:function(e,t){if(this.isFunction(e)||this.isFunction(t))throw"Invalid argument. Function given, object expected.";if(this.isFile(e)||this.isFile(t)){const n=this.compareFiles(e,t);return n===this.VALUE_UNCHANGED?null:{type:n,data:void 0===e?t:e}}if(this.isValue(e)||this.isValue(t)){const n=this.compareValues(e,t);if(n===this.VALUE_UNCHANGED)return null;let i;switch(n){case this.VALUE_CREATED:i=t;break;case this.VALUE_DELETED:i=this.getEmptyValue(e);break;case this.VALUE_UPDATED:default:i=t}return{type:n,data:i}}let n={},i=!1;for(let o in e)if(!this.isFunction(e[o])){let r;t&&void 0!==t[o]&&(r=t[o]);const a=this.map(e[o],r);null!==a&&(a.hasOwnProperty("type")&&a.hasOwnProperty("data")?n[o]=a.data:n[o]=a,i=!0)}if(t)for(let o in t)if(!this.isFunction(t[o])&&(void 0===e||void 0===e[o])){const e=this.map(void 0,t[o]);null!==e&&(e.hasOwnProperty("type")&&e.hasOwnProperty("data")?n[o]=e.data:n[o]=e,i=!0)}return i?n:null},getEmptyValue:function(e){return this.isArray(e)?[]:this.isObject(e)?{}:"number"==typeof e?0:"boolean"!=typeof e&&""},compareValues:function(e,t){return e===t||this.isDate(e)&&this.isDate(t)&&e.getTime()===t.getTime()?this.VALUE_UNCHANGED:void 0===e?this.VALUE_CREATED:void 0===t?this.VALUE_DELETED:this.VALUE_UPDATED},isFunction:function(e){return"[object Function]"===Object.prototype.toString.call(e)},isArray:function(e){return"[object Array]"===Object.prototype.toString.call(e)},isDate:function(e){return"[object Date]"===Object.prototype.toString.call(e)},isObject:function(e){return"[object Object]"===Object.prototype.toString.call(e)},isFile:function(e){return e instanceof File},isValue:function(e){return!this.isObject(e)&&!this.isArray(e)},compareFiles:function(e,t){return!this.isFile(e)&&this.isFile(t)?this.VALUE_CREATED:this.isFile(e)&&!this.isFile(t)?this.VALUE_DELETED:this.isFile(e)&&this.isFile(t)?e.name===t.name&&e.size===t.size&&e.type===t.type&&e.lastModified===t.lastModified?this.VALUE_UNCHANGED:this.VALUE_UPDATED:this.VALUE_UNCHANGED},merge:function(e,t){if(null==e)return t;if(null==t)return e;if(this.isFunction(e)||this.isFunction(t))return t;if(this.isFile(e)||this.isFile(t))return t;if(this.isValue(e)||this.isValue(t)||this.isArray(e)||this.isArray(t))return t;if(this.isObject(e)&&this.isObject(t)){let n={};for(let t in e)this.isFunction(e[t])||(n[t]=e[t]);for(let i in t)this.isFunction(t[i])||(void 0!==e[i]?n[i]=this.merge(e[i],t[i]):n[i]=t[i]);return n}return t}},window.deepMerge=function(e,t){return window.getDifferences.merge(e,t)},window.isInt=function(e){return!isNaN(parseInt(e))&&isFinite(e)},window.isNumeric=function(e){return!isNaN(parseFloat(e))&&isFinite(e)},window.uiFromSelectors=function(e,t=null,n=!1){let i={};for(let[o,r]of Object.entries(e))i[o]="object"==typeof r?window.uiFromSelectors(r,t):t?n?t.querySelectorAll(r):t.querySelector(r):n?document.querySelectorAll(r):document.querySelector(r);return i},window.sleep=async function(e=50){return new Promise((t=>setTimeout(t,e)))};window.debouncer=new class{constructor(){this.timeouts=new Map,window.addEventListener("beforeunload",(()=>this.cleanup()))}schedule(e,t,n=1e3){this.cancel(e),this.timeouts.set(e,setTimeout((()=>{t(),this.timeouts.delete(e)}),n))}cancel(e){this.timeouts.has(e)&&(clearTimeout(this.timeouts.get(e)),this.timeouts.delete(e))}cleanup(){for(let e of this.timeouts.values())clearTimeout(e);this.timeouts.clear()}};document.body;const e=document.documentElement,t=document.querySelector(".scroll-progress .bar");let n=window.scrollY||e.scrollTop||0,i=-1,o=!1,r=0;function a(){r=Math.max(0,e.scrollHeight-window.innerHeight)}function s(e){if(!t)return;const n=r>0?e/r:0,i=Math.max(0,Math.min(1,n));t.style.transform=`scaleX(${i})`}function l(){const t=window.scrollY||e.scrollTop||0;t>n?i=1:t<n&&(i=-1),n=t,document.body.classList.toggle("scroll-up",i<0&&t>0),s(t),o=!1}window.addEventListener("scroll",(()=>{o||(o=!0,requestAnimationFrame(l))}),{passive:!0}),window.addEventListener("resize",(()=>{window.debouncer.schedule("recalc-max-scroll",(()=>{a(),s(window.scrollY||e.scrollTop||0)}),20)})),a(),s(n),window.decodeHTMLEntities=function(e){return window.decodeHelper||(window.decodeHelper=document.createElement("textarea")),window.decodeHelper.innerHTML=e,window.decodeHelper.value}})();
(()=>{window.fade=function(e,t=!0){t?e.style.animation="fadeIn var(--transition-base)":(e.style.animation="fadeOut var(--transition-base)",window.debouncer.schedule(`remove-${e.dataset.id??e.id??e.className.replace(" ","-")}`,(()=>{e.remove()}),500))},window.formatTimeAgo=function(e,t="default"){const n=e instanceof Date?e:new Date(e),i=n-new Date,o=i<0,r=Math.floor(Math.abs(i)/1e3),a=Math.floor(r/60),s=Math.floor(a/60),l=Math.floor(s/24);if(0===a)return"Just now";let c="";if(r<10)c="a moment";else if(r<60)c="less than a minute";else if(a<5)c="a few minutes";else if(s<24)c=0===s?`${a} ${1===a?"minute":"minutes"}`:`about ${s} ${1===s?"hour":"hours"}`;else{if(!(l<7)){if("default"===t)return n.toLocaleDateString();const e={Y:n.getFullYear(),y:String(n.getFullYear()).slice(-2),F:n.toLocaleDateString("en-CA",{month:"long"}),M:n.toLocaleDateString("en-CA",{month:"short"}),m:String(n.getMonth()+1).padStart(2,"0"),n:n.getMonth()+1,d:String(n.getDate()).padStart(2,"0"),j:n.getDate(),D:n.toLocaleDateString("en-CA",{weekday:"short"}),l:n.toLocaleDateString("en-CA",{weekday:"long"}),H:String(n.getHours()).padStart(2,"0"),i:String(n.getMinutes()).padStart(2,"0"),s:String(n.getSeconds()).padStart(2,"0"),h:String(n.getHours()%12||12).padStart(2,"0"),g:n.getHours()%12||12,A:n.getHours()>=12?"PM":"AM",a:n.getHours()>=12?"pm":"am"};return t.replace(/[YyFMmnjDlHishgAa]/g,(t=>e[t]))}if(1===l)return o?"yesterday":"tomorrow";c=`about ${l} days`,c=`${l} ${1===l?"day":"days"}`}return o?`${c} ago`:`in ${c}`},window.uppercaseFirst=function(e){return e.charAt(0).toUpperCase()+e.slice(1)},window.templates=new Map,document.addEventListener("DOMContentLoaded",(()=>{window.loadTemplates()})),window.loadTemplates=function(){document.querySelectorAll("template").forEach((e=>{const t=Array.from(e.classList);if(t.length>0){const n=e.content.cloneNode(!0).firstElementChild;t.forEach((e=>{window.templates.has(e)||window.templates.set(e,n)}))}}))},window.getTemplate=function(e){return 0===window.templates.size&&window.loadTemplates(),!!window.templates.has(e)&&window.templates.get(e).cloneNode(!0)};window.jvbTemplates=new class{constructor(){this.templates=new Map,this.definitions=new Map}registerAll(e=document){e.querySelectorAll("template").forEach((e=>{e.classList.forEach((t=>{this.templates.has(t)||this.templates.set(t,e)}))}))}define(e,t={},n=null){this.definitions.set(e,{refs:t.refs||null,manyRefs:t.manyRefs||null,setup:t.setup||null,context:n})}create(e,t={}){const n=this.templates.get(e);if(!n)return console.warn(`[TemplateRegistry] Template "${e}" not found`),null;const i=n.content.cloneNode(!0).firstElementChild;if(!i)return null;const o=this.definitions.get(e),r=o?.refs?this.#e(i,o.refs):{},a=o?.manyRefs?this.#e(i,o.manyRefs,!1):{};return o?.setup?.({el:i,refs:r,manyRefs:a,data:t}),i}#e(e,t,n=!0){const i={};for(const[o,r]of Object.entries(t)){let t,a=!1;"string"==typeof r?t=r:(t=r.selector,a=!!r.required);const s=n?e.querySelector(t):e.querySelectorAll(t);a&&(n&&!s&&console.warn(`[TemplateRegistry] Required ref "${o}" not found: ${t}`),n||0!==s.length||console.warn(`[TemplateRegistry] Required manyRef "${o}" not found: ${t}`)),i[o]=n?s:Array.from(s)}return i}},document.addEventListener("DOMContentLoaded",(()=>{window.jvbTemplates.registerAll()})),window.icon=null,window.getIcon=function(e,t=""){if(void 0===e)return"";window.icon||(window.icon=document.createElement("i"),window.icon.className="icon",window.icon.ariaHidden=!0);let n=window.icon.cloneNode(!0);return t=""!==t&&["regular","bold","duotone","fill","light","thin"].includes("style")?`-${t.slice(0,2)}`:"",n.classList.add(`icon-${e}${t}`),n},window.formatNumber=function(e){return e.toString().replace(/\B(?=(\d{3})+(?!\d))/g,",")},window.formatPrice=function(e,t="CAD"){return new Intl.NumberFormat("en-CA",{style:"currency",currency:t}).format(e)},window.escapeHtml=function(e){return e?("string"==typeof e||e instanceof String||(e=String(e)),e.replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;").replace(/'/g,"&#039;")):""},window.removeChildren=function(e){if(0!==e.children.length)for(;e.firstChild;)e.removeChild(e.firstChild)},window.formatDateRange=function(e,t){const n=new Date(e),i=new Date(t);return n.toDateString()===i.toDateString()?n.toLocaleDateString("en-CA",{year:"numeric",month:"short",day:"numeric"}):n.getMonth()===i.getMonth()&&n.getFullYear()===i.getFullYear()?`${n.toLocaleDateString("en-CA",{month:"short",day:"numeric"})} - ${i.getDate()}, ${i.getFullYear()}`:n.getFullYear()===i.getFullYear()?`${n.toLocaleDateString("en-CA",{month:"short",day:"numeric"})} - ${i.toLocaleDateString("en-CA",{month:"short",day:"numeric"})}, ${i.getFullYear()}`:`${n.toLocaleDateString("en-CA",{month:"short",day:"numeric",year:"numeric"})} - ${i.toLocaleDateString("en-CA",{month:"short",day:"numeric",year:"numeric"})}`},window.throttle=function(e,t=300){let n;return function(...i){n||(e.apply(this,i),n=!0,setTimeout((()=>n=!1),t))}},window.chunkIt=async function(e,t,n,i=10){const o=[];for(let t=0;t<e.length;t+=i)o.push(e.slice(t,t+i));for(const e of o){const i=document.createDocumentFragment();e.forEach((e=>{const n=t(e);n&&i.append(n)})),n(i),await new Promise((e=>requestAnimationFrame(e)))}},window.prefixInput=function(e,t,n=null,i=!1,o=!1){if(!e)return void console.warn("prefixInput called with null/undefined input");const r=e.id,a=i?t:`${t}${e.name}`;let s=null;s=n?n.querySelector(`label[for="${r}"]`):e.labels&&e.labels.length>0?e.labels[0]:"LABEL"===e.previousElementSibling?.tagName?e.previousElementSibling:"LABEL"===e.nextElementSibling?.tagName?e.nextElementSibling:e.closest("[data-field]")?.querySelector(`label[for="${r}"]`),s&&(s.htmlFor=a),e.id=a,o&&(e.name=a)},window.uppercaseFirst=function(e){return e.charAt(0).toUpperCase()+e.slice(1)},window.sanitizeHtml=function(e){const t=document.createElement("div");return t.textContent=e,t.innerHTML},window.generateID=function(e="jvb"){return`${e}_${Date.now()}_${Math.random().toString(36).slice(2,9)}`},window.showProgress=function(e,t,n,i="",o=""){const r=t<n;e.progress&&r&&window.fade(e.progress,!0);const a=n>0?t/n*100:0;e.fill&&(e.fill.style.width=`${a}%`),e.details&&(e.details.textContent=i),e.count&&(e.count.textContent=`${t}/${n}`),e.icon&&(e.icon.className=""===o?"icon":"icon icon-"+o),e.progress&&t===n&&window.fade(e.progress,!1)},window.formatDate=function(e){if(!e)return"";const t=new Date(e),n=new Date,i=Math.floor((n-t)/864e5);return i<1?"Today":i<2?"Yesterday":i<7?`${i} days ago`:t.toLocaleDateString()},window.getPluralContent=function(e){return"artwork"===e?"artwork":e+"s"},window.showToast=function(e,t="success",n={}){window.jvbNotifications.showToast(e,t,n)},window.dateFormatter=new Intl.DateTimeFormat("en-CA",{year:"numeric",month:"long",day:"numeric",hour:"2-digit",minute:"2-digit",second:"2-digit",timeZoneName:"short"}),window.formatDate=function(e){return e instanceof Date&&!isNaN(e)||(e=new Date(e)),window.dateFormatter.format(e)},window.typeText=function(e,t,n=50){return new Promise((i=>{e._typeInterval&&(clearInterval(e._typeInterval),delete e._typeInterval);let o=0;e.textContent="",e._typeInterval=setInterval((()=>{o<t.length?(e.textContent+=t.charAt(o),o++):(clearInterval(e._typeInterval),delete e._typeInterval,i())}),n)}))},window.eraseText=function(e,t=10){return new Promise((n=>{e._eraseInterval&&(clearInterval(e._eraseInterval),delete e._eraseInterval);let i=e.textContent,o=i.length;e._eraseInterval=setInterval((()=>{o>0?(o--,e.textContent=i.substring(0,o)):(clearInterval(e._eraseInterval),delete e._eraseInterval,n())}),t)}))},window.typeLoop=function(e,t,n=50,i=10,o=1e3,r=250){const a=e.id||e.dataset.typeKey||`type-${Date.now()}`;e.dataset.typeKey||(e.dataset.typeKey=a),e._stopTyping&&e._stopTyping();let s=!0;const l=function(){s=!1,e._typeInterval&&(clearInterval(e._typeInterval),delete e._typeInterval),e._eraseInterval&&(clearInterval(e._eraseInterval),delete e._eraseInterval)};return e._stopTyping=l,async function(){for(;s&&(await window.typeText(e,t,n),s)&&(await new Promise((e=>setTimeout(e,o))),s)&&(await window.eraseText(e,i),s);)await new Promise((e=>setTimeout(e,r)))}(),l},window.toCamelCase=function(e){return e.replace(/-([a-z])/g,(function(e){return e[1].toUpperCase()}))},window.targetCheck=function(e,t){return Array.isArray(t)&&(t=t.join(",")),"string"==typeof t&&(e.target.closest(t)??!1)},window.getDifferences={VALUE_CREATED:"created",VALUE_UPDATED:"updated",VALUE_DELETED:"deleted",VALUE_UNCHANGED:"unchanged",map:function(e,t){if(this.isFunction(e)||this.isFunction(t))throw"Invalid argument. Function given, object expected.";if(this.isFile(e)||this.isFile(t)){const n=this.compareFiles(e,t);return n===this.VALUE_UNCHANGED?null:{type:n,data:void 0===e?t:e}}if(this.isValue(e)||this.isValue(t)){const n=this.compareValues(e,t);if(n===this.VALUE_UNCHANGED)return null;let i;switch(n){case this.VALUE_CREATED:i=t;break;case this.VALUE_DELETED:i=this.getEmptyValue(e);break;case this.VALUE_UPDATED:default:i=t}return{type:n,data:i}}let n={},i=!1;for(let o in e)if(!this.isFunction(e[o])){let r;t&&void 0!==t[o]&&(r=t[o]);const a=this.map(e[o],r);null!==a&&(a.hasOwnProperty("type")&&a.hasOwnProperty("data")?n[o]=a.data:n[o]=a,i=!0)}if(t)for(let o in t)if(!this.isFunction(t[o])&&(void 0===e||void 0===e[o])){const e=this.map(void 0,t[o]);null!==e&&(e.hasOwnProperty("type")&&e.hasOwnProperty("data")?n[o]=e.data:n[o]=e,i=!0)}return i?n:null},getEmptyValue:function(e){return this.isArray(e)?[]:this.isObject(e)?{}:"number"==typeof e?0:"boolean"!=typeof e&&""},compareValues:function(e,t){return e===t||this.isDate(e)&&this.isDate(t)&&e.getTime()===t.getTime()?this.VALUE_UNCHANGED:void 0===e?this.VALUE_CREATED:void 0===t?this.VALUE_DELETED:this.VALUE_UPDATED},isFunction:function(e){return"[object Function]"===Object.prototype.toString.call(e)},isArray:function(e){return"[object Array]"===Object.prototype.toString.call(e)},isDate:function(e){return"[object Date]"===Object.prototype.toString.call(e)},isObject:function(e){return"[object Object]"===Object.prototype.toString.call(e)},isFile:function(e){return e instanceof File},isValue:function(e){return!this.isObject(e)&&!this.isArray(e)},compareFiles:function(e,t){return!this.isFile(e)&&this.isFile(t)?this.VALUE_CREATED:this.isFile(e)&&!this.isFile(t)?this.VALUE_DELETED:this.isFile(e)&&this.isFile(t)?e.name===t.name&&e.size===t.size&&e.type===t.type&&e.lastModified===t.lastModified?this.VALUE_UNCHANGED:this.VALUE_UPDATED:this.VALUE_UNCHANGED},merge:function(e,t){if(null==e)return t;if(null==t)return e;if(this.isFunction(e)||this.isFunction(t))return t;if(this.isFile(e)||this.isFile(t))return t;if(this.isValue(e)||this.isValue(t)||this.isArray(e)||this.isArray(t))return t;if(this.isObject(e)&&this.isObject(t)){let n={};for(let t in e)this.isFunction(e[t])||(n[t]=e[t]);for(let i in t)this.isFunction(t[i])||(void 0!==e[i]?n[i]=this.merge(e[i],t[i]):n[i]=t[i]);return n}return t}},window.deepMerge=function(e,t){return window.getDifferences.merge(e,t)},window.isInt=function(e){return!isNaN(parseInt(e))&&isFinite(e)},window.isNumeric=function(e){return!isNaN(parseFloat(e))&&isFinite(e)},window.uiFromSelectors=function(e,t=null,n=!1){let i={};for(let[o,r]of Object.entries(e))i[o]="object"==typeof r?window.uiFromSelectors(r,t):t?n?t.querySelectorAll(r):t.querySelector(r):n?document.querySelectorAll(r):document.querySelector(r);return i},window.sleep=async function(e=50){return new Promise((t=>setTimeout(t,e)))};window.debouncer=new class{constructor(){this.timeouts=new Map,window.addEventListener("beforeunload",(()=>this.cleanup()))}schedule(e,t,n=1e3){this.cancel(e),this.timeouts.set(e,setTimeout((()=>{t(),this.timeouts.delete(e)}),n))}cancel(e){this.timeouts.has(e)&&(clearTimeout(this.timeouts.get(e)),this.timeouts.delete(e))}cleanup(){for(let e of this.timeouts.values())clearTimeout(e);this.timeouts.clear()}};document.body;const e=document.documentElement,t=document.querySelector(".scroll-progress .bar");let n=window.scrollY||e.scrollTop||0,i=-1,o=!1,r=0;function a(){r=Math.max(0,e.scrollHeight-window.innerHeight)}function s(e){if(!t)return;const n=r>0?e/r:0,i=Math.max(0,Math.min(1,n));t.style.transform=`scaleX(${i})`}function l(){const t=window.scrollY||e.scrollTop||0;t>n?i=1:t<n&&(i=-1),n=t,document.body.classList.toggle("scroll-up",i<0&&t>0),s(t),o=!1}window.addEventListener("scroll",(()=>{o||(o=!0,requestAnimationFrame(l))}),{passive:!0}),window.addEventListener("resize",(()=>{window.debouncer.schedule("recalc-max-scroll",(()=>{a(),s(window.scrollY||e.scrollTop||0)}),20)})),a(),s(n),window.decodeHTMLEntities=function(e){return window.decodeHelper||(window.decodeHelper=document.createElement("textarea")),window.decodeHelper.innerHTML=e,window.decodeHelper.value},window.focusNextElement=function(){if(document.activeElement&&document.activeElement.form){var e=Array.prototype.filter.call(document.activeElement.form.querySelectorAll('a:not([disabled]), button:not([disabled]), input[type=text]:not([disabled]), [tabindex]:not([disabled]):not([tabindex="-1"])'),(function(e){return e.offsetWidth>0||e.offsetHeight>0||e===document.activeElement})),t=e.indexOf(document.activeElement);if(t>-1)(e[t+1]||e[0]).focus()}}})();
base/Membership.php
@@ -1,5 +1,7 @@
<?php
namespace JVBase\base;
use JVBase\registrar\Registrar;
/**
 * JVB_MEMBERSHIP defines the structure of the directory
 *         Options include:
@@ -38,6 +40,7 @@
{
    protected bool $member_content = true;
    protected bool $invitable = false;
    protected bool $member_verified = false;
    protected array $can_invite = [];
    protected bool $notifications = true;
    protected bool $member_expires = false;
@@ -89,6 +92,35 @@
        return false;
    }
    public function setInvitable(array $invitable):void
    {
        $users = Registrar::getRegistered('user');
        $invitable = array_filter($invitable, function($role) use ($users) {
            if (!in_array($role, $users)) {
                error_log('[Membership]::set Invitable role '.$role.' does not exist.');
                return false;
            }
            return true;
        }, ARRAY_FILTER_USE_KEY);
        $allowed = [];
        foreach ($invitable as $inviter => $canInvites) {
            $canInvites = array_filter($canInvites, function($role) use ($users){
                if (!in_array($role, $users)) {
                    error_log('[Membership]::set Invitable role '.$role.' does not exist.');
                    return false;
                }
                return true;
            });
            if (!empty($canInvites)) {
                $allowed[$inviter] = $canInvites;
            }
        }
        if (!empty($allowed)) {
            $this->can_invite = $allowed;
        }
    }
    public function getInviters():array
    {
        return $this->can_invite;
@@ -102,6 +134,13 @@
        return array_key_exists($role, $this->can_invite) ? $this->can_invite[$role] : [];
    }
    public function setApprovers(array $roles):void
    {
        $allowed = Registrar::getRegistered('users');
        $this->can_approve = array_filter($roles, function($role) use ($allowed){
            return in_array($role, $allowed);
        });
    }
    public function getApprovers():array
    {
        return $this->can_approve;
@@ -113,6 +152,13 @@
    }
    public function setCanSeeForum(array $roles):void
    {
        $allowed = Registrar::getRegistered('users');
        $this->member_only = array_filter($roles, function($role) use ($allowed){
            return in_array($role, $allowed);
        });
    }
    public function canSeeForum(string $role):bool
    {
        return $this->forum && (empty($this->member_only) || in_array($role, $this->member_only));
base/SchemaHelper.php
File was renamed from base/seo.php
@@ -295,116 +295,3 @@
        return $class;
    }
}
/**
 * JVB_SCHEMA: Site-wide schema configuration
 *
 * Structure:
 *   - business: LocalBusiness/Organization for home page
 *   - website: WebSite schema configuration
 *   - actions: PotentialAction definitions
 *   - attribution: Developer/maintainer info
 */
/**
JVB_CONTENT['artwork'] = [
    'singular' => 'Artwork',
    'plural' => 'Artworks',
    // ... other config
    'seo' => [
        'meta' => [
            'title' => '{{post_title}} by {{linked_user.display_name}} | {{site_title}}',
            'description' => '{{style.primary.name}} artwork by {{linked_user.display_name}}. {{short_bio|default:View this piece and more.}}',
            'archive_title' => 'Artwork Gallery',
            'archive_description' => 'Browse our collection of tattoo artwork and designs.',
        ],
        'schema' => [
            'type' => 'VisualArtwork',
            'mappings' => [
                'artform' => 'style.primary',           // DefinedTerm from taxonomy
                'creator' => 'linked_user',             // Person from linked user
                'image' => 'featured_image',            // ImageObject
                'artMedium' => 'medium.names:3',        // Comma-separated term names
            ],
            'overrides' => [
                'inLanguage' => 'en',
            ]
        ]
    ]
];
JVB_CONTENT['artist'] = [
    'singular' => 'Artist',
    'plural' => 'Artists',
    'seo' => [
        'meta' => [
            'title' => '{{post_title}} - {{artist_type|default:Tattoo Artist}} in {{city.primary.name|default:Edmonton}}',
            'description' => '{{short_bio|truncate:155}}',
        ],
        'schema' => [
            'type' => 'Person',
            'mappings' => [
                'image' => 'image_portrait',
                'jobTitle' => 'artist_type',
                'worksFor' => 'shop.primary',           // LocalBusiness reference
                'knowsAbout' => 'style.names',          // Array of style names
                'areaServed' => 'city.primary.name',
            ]
        ]
    ]
];
JVB_TAXONOMY['shop'] = [
    'singular' => 'Shop',
    'plural' => 'Shops',
    'seo' => [
        'meta' => [
            'title' => '{{term_name}} - Tattoo Shop in {{city.primary.name|default:Edmonton}}',
            'description' => '{{tagline|default:Visit}} {{term_name}}. {{short_bio|truncate:120}}',
        ],
        'schema' => [
            'type' => 'TattooParlor',  // or LocalBusiness
            'mappings' => [
                'address' => 'location',
                'telephone' => 'phone',
                'email' => 'email',
                'openingHoursSpecification' => 'hours',
                'image' => 'image',
                'priceRange' => 'price_range',
                'paymentAccepted' => 'payment_accepted',
            ],
            'overrides' => [
                'additionalTypeTrait' => 'https://schema.org/TattooParlor',
            ]
        ]
    ]
];
JVB_TAXONOMY['style'] = [
    'singular' => 'Style',
    'plural' => 'Styles',
    'seo' => [
        'meta' => [
            'title' => '{{term_name}} Tattoos in Edmonton | {{site_title}}',
            'description' => '{{tagline|default:Explore}} {{term_name}} tattoo artists and designs. {{characteristics|strip|truncate:100}}',
        ],
        'schema' => [
            'type' => 'DefinedTerm',
            'mappings' => [
                'alternateName' => 'alternate_name',
            ]
        ]
    ]
];
**/
base/Site.php
@@ -14,11 +14,21 @@
    protected static Membership $membershipConfig;
    protected static Site $instance;
    protected static string $icons = 'regular';
    /**
     * @var array Dashboard  config
     */
    protected static string $dashboardTitle;
    /**
     * @var bool $is_directory Whether this is a membership directory
     */
    protected static bool $is_directory = false;
    protected static string $directory_singular;
    protected static string $directory_plural;
    /**
     * @var bool Whether to add a help menu to the header
     */
    protected static bool $help_menu = false;
    /**
     * @var bool $has_membership Whether this site has membership
     */
@@ -31,6 +41,7 @@
     * @var bool $referrals Whether to implement a referral system, with rewards
     */
    protected static bool $referrals = false;
    protected static string $defaultReferralRole = BASE.'client';
    /**
     * @var bool $magic_link Whether users can login without a password
     */
@@ -200,35 +211,41 @@
        }
        return self::$membershipConfig;
    }
    public function setDashboardTitle(string $title):void
    {
        self::$dashboardTitle = $title;
    }
    public static function dashboardTitle():bool|string
    {
        return self::$dashboardTitle ?? false;
    }
    public function setDirectorySingular(string $title):void
    {
        self::$directory_singular = $title;
    }
    public static function getDirectorySingular():bool|string
    {
        return self::$directory_singular ?? false;
    }
    public function setDirectoryPlural(string $title):void
    {
        self::$directory_plural = $title;
    }
    public static function getDirectoryPlural():bool|string
    {
        return self::$directory_plural ?? false;
    }
    public static function setDefaultReferralRole(string $role):void
    {
        self::$defaultReferralRole = $role;
    }
    public static function getDefaultReferralRole():string
    {
        return self::$defaultReferralRole;
    }
}
$defaults = [
    'icons'               => 'light',
    'directory'        => false,        //as in, a membership directory
    'membership'      => false,
    'has_map'             => false,         //TODO: migrate to integrations['gmb']
    'dashboard'           => false,
    'support'         => false,
    'feed_block'      => false,
    'email_notifications' => false,
    'integrations'  => [
        'bluesky'     => false,
        'cloudflare'  => false,
        'facebook'    => false,
        'maps'        => false,
        'gmb'         => false,
        'helcim'      => false,
        'instagram'   => false,
        'square'      => false,
        'umami'       => false,
    ],
    'is_restaurant'       => false,
    'limit_hours'         => false,
    'enthusiast'          => false,
    'favourites'          => false,             //optional flag to allow enthusiasts, but not favourites
];
$jvb_site = array_merge($defaults, apply_filters('jvb_site', []));
define('JVB_SITE', $jvb_site);
base/_setup.php
@@ -9,14 +9,14 @@
function jvbDefineSite() {
    Site::getInstance();
}
require(JVB_DIR.'/base/content.php');
//require(JVB_DIR.'/base/content.php');
require(JVB_DIR.'/base/email.php');
require(JVB_DIR.'/base/taxonomies.php');
require(JVB_DIR.'/base/users.php');
//require(JVB_DIR.'/base/taxonomies.php');
//require(JVB_DIR.'/base/users.php');
require(JVB_DIR.'/base/Login.php');
require(JVB_DIR.'/base/Membership.php');
require(JVB_DIR.'/base/options.php');
require(JVB_DIR.'/base/seo.php');
require(JVB_DIR.'/base/SchemaHelper.php');
$base = apply_filters('jvb_base', 'jvb_');
$base = (str_ends_with($base, '_')) ? $base : $base.'_';
base/content.php
@@ -18,9 +18,9 @@
 *      - rewrites      = (array) key = associated taxonomy; value = what to append to rewrite base
 */
$defaults = [];
if (!empty(JVB_SITE) && array_key_exists('has_support', JVB_SITE) && JVB_SITE['has_support'] === true) {
    $defaults = array_merge($defaults, jvb_support_post_type());
}
//if (!empty(JVB_SITE) && array_key_exists('has_support', JVB_SITE) && JVB_SITE['has_support'] === true) {
//  $defaults = array_merge($defaults, jvb_support_post_type());
//}
$content = array_merge($defaults, apply_filters('jvb_content', []));
define('JVB_CONTENT', $content);
base/users.php
@@ -25,25 +25,25 @@
 */
$defaults = [];
if (array_key_exists('enthusiast', JVB_SITE) && JVB_SITE['enthusiast'] === true) {
    $defaults['enthusiast'] = [
        'label'            => 'Enthusiast',
        'has_dashboard'    => true,
        'can_create'       => [],
        'can_register'     => true,
        'can_favourite'    => true,
        'keep_stats'       => true,
        'join_text'        => 'Save your favourites. Get Notified.',
        'join_title'       => 'Welcome to the Scene.',
        'join_description' => [
            'Save your favourites for easy access',
            'Get notified when your favourite artists add new content',
            'Stay in the loop with local flash days and events',
            'Discover styles and artists that match your vision'
        ],
        'join_bonus'       => '<strong>BONUS: </strong>Everything\'s free. And always will be. We work with partners vetted by the community to keep the lights on.',
    ];
}
//if (array_key_exists('enthusiast', JVB_SITE) && JVB_SITE['enthusiast'] === true) {
//  $defaults['enthusiast'] = [
//      'label'            => 'Enthusiast',
//      'has_dashboard'    => true,
//      'can_create'       => [],
//      'can_register'     => true,
//      'can_favourite'    => true,
//      'keep_stats'       => true,
//      'join_text'        => 'Save your favourites. Get Notified.',
//      'join_title'       => 'Welcome to the Scene.',
//      'join_description' => [
//          'Save your favourites for easy access',
//          'Get notified when your favourite artists add new content',
//          'Stay in the loop with local flash days and events',
//          'Discover styles and artists that match your vision'
//      ],
//      'join_bonus'       => '<strong>BONUS: </strong>Everything\'s free. And always will be. We work with partners vetted by the community to keep the lights on.',
//  ];
//}
$users = array_merge($defaults, apply_filters('jvb_user', []));
define('JVB_USER', $users);
checks.php
@@ -12,61 +12,28 @@
    return (array_key_exists($key, $config) && $config[$key] === true);
}
function jvbUserIsVerified():bool
{
    $membership = Site::membership();
    return !($membership && $membership->has('member_verified')) || current_user_can('skip_moderation');
}
function jvbUserTypes():array
{
    $types = get_option(BASE.'user_types');
    if (JVB_TESTING) {
        $types = false;
    }
    if ($types === false) {
        $types = [];
        foreach (JVB_USER as $type => $config) {
            if (array_key_exists('profile', $config)) {
                $types[$type] = BASE.$config['profile'];
            }
        }
        update_option(BASE.'user_types', $types);
    }
    return $types;
    return  Registrar::getFeatured('profile_link', 'user');
}
function isJVBContentTax():bool
{
    return is_tax(array_map(function ($tax) {
        return jvbCheckBase($tax);
    }, Registrar::getFeatured('is_content', 'term')));
}
function taxIsJVBContentTax($tax):bool
{
    $allowed = Registrar::getFeatured('is_content', 'term');
    $tax = jvbNoBase($tax);
    return in_array($tax, $allowed);
}
function jvbIsOpen():bool
{
    if (!jvbCheck('limit_hours', JVB_SITE)) {
        return true;
    }
    if (get_option(BASE.'open_to_public') !== '1') {
        return false;
    }
    //Check if today_hours is set
    if (get_option(BASE.'today_hours')) {
        return jvbIsTimeBetween();
    }
    //Default to the stored settings
    return jvbIsCurrentlyOpen();
}
//function jvbIsOpen():bool
//{
//
//  if (!jvbCheck('limit_hours', JVB_SITE)) {
//      return true;
//  }
//  if (get_option(BASE.'open_to_public') !== '1') {
//      return false;
//  }
//  //Check if today_hours is set
//  if (get_option(BASE.'today_hours')) {
//      return jvbIsTimeBetween();
//  }
//  //Default to the stored settings
//  return jvbIsCurrentlyOpen();
//}
function jvbTermHasPosts(int $termID, string $taxonomy):bool
inc/admin/ContentTaxonomy.php
@@ -6,7 +6,6 @@
}
use JVBase\registrar\Registrar;
use JVBase\registry\TaxonomyRegistrar;
use WP_Error;
use Exception;
@@ -51,7 +50,7 @@
        // Handle individual taxonomy rebuild
        if (isset($_POST['rebuild_taxonomy']) && wp_verify_nonce($_POST['_wpnonce'], 'rebuild_taxonomy')) {
            $taxonomy = sanitize_text_field($_POST['taxonomy']);
            $registrar = Registrar::getInstance($taxonomy));
            $registrar = Registrar::getInstance($taxonomy);
            if ($registrar && $registrar->hasFeature('is_content')) {
                $results = $this->rebuildCustomTable($taxonomy);
                // Store results in transient to display after redirect
@@ -324,7 +323,7 @@
        ];
        // Check if this is a content taxonomy
        $registrar = Registrar::getInstance($taxonomy));
        $registrar = Registrar::getInstance($taxonomy);
        if (!$registrar->hasFeature('is_content')) {
            $results['messages'][] = "Taxonomy {$taxonomy} is not a content taxonomy";
            return $results;
inc/admin/_setup.php
@@ -5,6 +5,10 @@
require(JVB_DIR . '/inc/admin/Integrations.php');
require(JVB_DIR . '/inc/admin/SEOAdmin.php');
if (!empty(Registrar::getFeatured('is_content', 'term'))) {
    require(JVB_DIR . '/inc/admin/ContentTaxonomy.php');
add_action('init', 'jvb_maybe_setup_content_taxonomy', 1);
function jvb_maybe_setup_content_taxonomy(): void
{
    if (!empty(Registrar::getFeatured('is_content', 'term'))) {
        require(JVB_DIR . '/inc/admin/ContentTaxonomy.php');
    }
}
inc/blocks/CustomBlocks.php
@@ -4,6 +4,7 @@
use DateTime;
use DOMDocument;
use JVBase\managers\Cache;
use JVBase\managers\SEO\BreadcrumbManager;
use WP_Block;
use WP_Query;
@@ -18,7 +19,7 @@
    {
        $this->cache = Cache::for('blocks', WEEK_IN_SECONDS);
        $this->cache->connect('post')->connect('taxonomy');
        add_filter('render_block', [$this, 'render'], 900, 3);
        add_filter('render_block', [$this, 'render'], 900, 2);
        add_action('init', [$this, 'registerBlockStyles']);
    }
@@ -64,7 +65,7 @@
        );
    }
    public function render(string $content, array $block, WP_Block $instance)
    public function render(string $content, array $block)
    {
        $blockName = $this->sanitizeBlockName($block);
        $method = 'render_'.$blockName;
@@ -344,7 +345,7 @@
               '</h'.$level.'>';
    }
    public function render_core_list(array $block):string
    public function render_core_list(array $block, string $content):string
    {
        $tag = (array_key_exists('ordered', $block['attrs'])) ? 'ol' : 'ul';
        return '<'.$tag.$this->getClassesAndStyles($block['attrs']).'>'.$this->innerBlocks($block).'</'.$tag.'>';
@@ -490,7 +491,7 @@
            aria-expanded="false">'.
            jvbIcon('list', ['title'=>'Toggle Menu']).
            jvbIcon('x', ['title'=>'Toggle Menu']).
            '</button>';
        '</button>';
        $class = ($toggle === '') ?
            $this->getClassesAndStyles($block['attrs'], ['mobile']) :
            $this->getClassesAndStyles($block['attrs']);
@@ -498,6 +499,7 @@
            '<nav><ul>'.jvbNotificationMenu().jvbHelpMenu().'</ul></nav>' :
            '';
        //Allows to add custom items to a menu, based on the menu name
        $helpmenu = apply_filters('jvbMenuExtraAfter', $helpmenu, get_the_title($ID));
        $main = trim(apply_filters('jvbMenuExtra', $this->innerBlocks($block), get_the_title($ID), $block));
@@ -818,6 +820,7 @@
        if (($isHeaderTemplate || $isFooterTemplate)) {
            $innerContent = $content;
            $tag = $isHeaderTemplate ?: $isFooterTemplate ?: 'div';
@@ -836,7 +839,7 @@
                    jvbIcon('sun-dim', ['title'=> 'Light Mode']).
                    jvbIcon('moon', ['title'=>'Dark Mode']).
                    '</span></label>' : '';
                $breadcrumbs = jvbBuildBreadcrumbs();
                $breadcrumbs = BreadcrumbManager::getInstance()->renderNavigation();
                $afterHeader = apply_filters('jvbBelowHeader', $afterHeader);
                if ($afterHeader !== '') {
@@ -851,17 +854,12 @@
                }
                    $footerText = jvbRandomFooterText();
            }
//          jvbDump($beforeHeader,'beforeHeader');
//          jvbDump('<'.$tag.$this->getClassesAndStyles($block['attrs']).'>','tag');
//          jvbDump($themeSwitch,'themeSwitch');
//          jvbDump($this->inside($block, $tag, $content),'inside');
//          jvbDump($footerText,'footerText');
//          jvbDump($afterHeader, 'afterheader');
//          jvbDump($breadcrumbs, 'breadcrumbs');
            return $beforeHeader.'<'.$tag.$this->getClassesAndStyles($block['attrs']).'>'.
            $content = $beforeHeader.'<'.$tag.$this->getClassesAndStyles($block['attrs']).'>'.
                   $themeSwitch .
                   $this->inside($block, $tag, $content) .
                    $this->inside($block, false, $innerContent).
//                 $this->innerBlocks($block).
//                  $innerContent.
                   $footerText.'</'.$tag.'>'.$afterHeader.$breadcrumbs;
        }
@@ -929,51 +927,46 @@
    {
        $content = '';
        foreach ($block['innerBlocks'] as $b) {
            $method = 'render_'.$this->sanitizeBlockName($b);
            $function = BASE.$method;
            $content .= $before;
            if (function_exists($function)) {
                $content .= $function($b, '');
            } else if (method_exists($this, $method)) {
                $content .= $this->$method($b, '');
            } else {
                $content .= render_block($b);
            }
            $content .= $after;
            $content .= $this->render('', $b);
        }
        return $content;
    }
    public function inside(array $block, mixed $tag = false, mixed $o = false):string
    {
        if (!$o) {
            $o = trim($block['innerHTML']);
        }
        if (!$tag) {
            //check to see if there was one dynamically set first
            $tag = (array_key_exists('tagName', $block['attrs'])) ? $block['attrs']['tagName'] : '';
            $tag = ($tag == '') ? str_replace('<', '', strtok($o, '>')) : '';
            $tag = (str_contains($tag, ' class')) ? strtok($tag, ' class') : $tag;
            $tag = trim($tag);
        }
        if (!str_starts_with($o, '<'.$tag)) {
    public function inside(array $block, mixed $tag = false, mixed $o = false): string
    {
        if (!$o) {
            $o = trim($block['innerHTML']);
        }
        $dom = new \DOMDocument();
        @$dom->loadHTML('<?xml encoding="utf-8"?>' . $o, LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD);
        // Find the real outermost element
        $root = null;
        foreach ($dom->childNodes as $node) {
            if ($node->nodeType === XML_ELEMENT_NODE) {
                $root = $node;
                break;
            }
        }
        if (!$root) {
            return $o;
        }
        $len = strlen('</'.$tag.'>');
        // Only enforce tag match if explicitly provided
        if ($tag && strtolower($root->nodeName) !== strtolower($tag)) {
            return $o;
        }
        return substr_replace(
            str_replace(
                strtok($o, '>').'>',
                ' ',
                $o
            ),
            '',
            -$len,
            $len
        );
    }
        $inner = '';
        foreach ($root->childNodes as $child) {
            $inner .= $dom->saveHTML($child);
        }
        return trim($inner);
    }
    /**
     * Extract content from a specific nested element
inc/blocks/_setup.php
@@ -6,30 +6,64 @@
//require(JVB_DIR . '/inc/blocks/RegisterBlocks.php');
require(JVB_DIR . '/inc/blocks/CustomBlocks.php');
if (Site::has('feed_block')) {
    require(JVB_DIR . '/inc/blocks/FeedBlock.php');
    new JVBase\blocks\FeedBlock();
add_action('init', 'jvb_conditionally_load_blocks', 1);
function jvb_conditionally_load_blocks(): void
{
    if (Site::has('feed_block')) {
        require(JVB_DIR . '/inc/blocks/FeedBlock.php');
        new JVBase\blocks\FeedBlock();
    }
    if (Site::has('is_restaurant')) {
        require(JVB_DIR . '/inc/blocks/MenuBlock.php');
        new JVBase\blocks\MenuBlock();
    }
    if (Site::has('faq')) {
        require(JVB_DIR . '/inc/blocks/FAQBlock.php');
        new JVBase\blocks\FAQBlock();
    }
    if (!empty(Registrar::getFeatured('is_glossary'))) {
        require(JVB_DIR . '/inc/blocks/GlossaryBlock.php');
        new JVBase\blocks\GlossaryBlock();
    }
    if (!empty(Registrar::getFeatured('is_timeline'))) {
        require(JVB_DIR . '/inc/blocks/TimelineBlock.php');
        new JVBase\blocks\TimelineBlock();
    }
    if (Site::hasIntegration('gmb')) {
        require(JVB_DIR . '/build/gmbreviews/render.php');
        register_block_type(
            JVB_DIR . '/build/gmbreviews',
            [
                'render_callback'   => 'jvbRenderGMBReviewsBlock'
            ]);
    }
//    if (jvbSiteUsesFeedBlock()) {
//        register_block_type(
//            JVB_DIR . '/build/feed',
//            [
//                'render_callback'   => 'jvbRenderFeedBlock'
//            ]
//        );
//    }
    if (!empty(Registrar::getFeatured('show_directory'))) {
        register_block_type(
            JVB_DIR . '/build/list',
            [
                'render_callback'   => 'jvbRenderListBlock'
            ]
        );
    }
    register_block_type(
        JVB_DIR . '/build/drawer-menu',
    );
}
if (Site::has('is_restaurant')) {
    require(JVB_DIR . '/inc/blocks/MenuBlock.php');
    new JVBase\blocks\MenuBlock();
}
if (Site::has('faq')) {
    require(JVB_DIR . '/inc/blocks/FAQBlock.php');
    new JVBase\blocks\FAQBlock();
}
if (!empty(Registrar::getFeatured('is_glossary'))) {
    require(JVB_DIR . '/inc/blocks/GlossaryBlock.php');
    new JVBase\blocks\GlossaryBlock();
}
if (!empty(Registrar::getFeatured('is_timeline'))) {
    require(JVB_DIR . '/inc/blocks/TimelineBlock.php');
    new JVBase\blocks\TimelineBlock();
}
require(JVB_DIR . '/inc/blocks/SummaryBlock.php');
new JVBase\blocks\SummaryBlock();
@@ -52,37 +86,3 @@
    ]);
}
add_filter('block_categories_all', 'jvbRegisterBlockCategory');
if (Site::hasIntegration('gmb')) {
    require(JVB_DIR . '/build/gmbreviews/render.php');
}
function jvbRegisterBlocks():void
{
    if (Site::hasIntegration('gmb')) {
        register_block_type(
            JVB_DIR . '/build/gmbreviews',
        [
            'render_callback'   => 'jvbRenderGMBReviewsBlock'
        ]);
    }
//    if (jvbSiteUsesFeedBlock()) {
//        register_block_type(
//            JVB_DIR . '/build/feed',
//            [
//                'render_callback'   => 'jvbRenderFeedBlock'
//            ]
//        );
//    }
    if (!empty(Registrar::getFeatured('show_directory'))) {
        register_block_type(
            JVB_DIR . '/build/list',
            [
                'render_callback'   => 'jvbRenderListBlock'
            ]
        );
    }
    register_block_type(
        JVB_DIR . '/build/drawer-menu',
    );
}
add_action('init', 'jvbRegisterBlocks');
inc/helpers/crud.php
@@ -5,6 +5,7 @@
}
use JVBase\managers\Cache;
use JVBase\managers\RoleManager;
use JVBase\meta\Form;
use JVBase\registrar\Registrar;
@@ -94,10 +95,8 @@
            'label' => 'Scrapped',
        ]
    ];
    $permission = ($content === 'news') ? 'update' : $content;
    $permission = JVB_CONTENT[$content]['plural']??$content.'s';
    $canPublish = current_user_can("publish_{$permission}");
    $permission = JVB()->roles()->getContentPlural($content);
    $canPublish = current_user_can("publish_{$permission}") && current_user_can('skip_moderation');
    $out = '<div class="filters">';
    if (!empty($statusFilters)) {
        $out .= '<div class="status">';
@@ -173,9 +172,6 @@
 */
function jvbGetBulkActions(string $content):array
{
    $permission = ($content === 'news') ? 'update' : $content;
    $permission = JVB_CONTENT[$content]['plural']??$content.'s';
    $bulkActions = [
        'publish' => [
            'icon'        => 'show',
@@ -198,7 +194,8 @@
            'disabled'    => true,
        ]
    ];
    if (current_user_can("publish_{$permission}")) {
    $permission = JVB()->roles()->getContentPlural($content);
    if (current_user_can('skip_moderation') && current_user_can("publish_{$permission}")) {
        $bulkActions['publish']['disabled'] = false;
    }
    if (current_user_can("edit_{$permission}")) {
@@ -311,28 +308,15 @@
    string $postType = '',
    bool $prefix = false
):void {
    switch ($contentType) {
        case 'post':
            $settings = JVB_CONTENT;
            break;
        case 'term':
            $settings = JVB_TAXONOMY;
            break;
        case 'user':
            $settings = JVB_USER;
            break;
        default:
            return;
    }
    $sections = $settings[$postType]['sections']??[];
    if (empty($sections)) {
    $registrar = Registrar::getInstance($postType);
    if (!$registrar || empty($registrar->getSections())) {
        return;
    }
    echo '<div class="container">';
    $nav = '<nav class="tabs row start" role="tablist">';
    $i = 1;
    foreach ($sections as $slug => $section) {
    foreach ($registrar->getSections() as $slug => $section) {
        $nav .= '<button type="button" class="tab';
        $ariaActive = 'false';
@@ -354,7 +338,7 @@
          data-object-id="<?=$ID?>" data-content-type="<?=$postType?>">
        <?php
        $i = 0;
        foreach ($sections as $slug => $section) {
        foreach ($registrar->getSections() as $slug => $section) {
            $tabName = ($prefix) ? $ID.'-'.$slug : $slug;
            $class = ($i == 0) ? ' active' : '';
inc/helpers/dashboard.php
@@ -78,7 +78,7 @@
    $types = get_post_meta($postID, BASE.'content_types', true);
    if ($types === '') {
        $types = [];
        $link = (int) get_post_meta($postID, BASE.'link', true);
        $link = (int) get_post_meta($postID, BASE.'profile_link', true);
        if ($link === '' || !is_numeric($link)) {
            return $types;
        }
inc/helpers/members.php
@@ -173,7 +173,7 @@
    if (!$user) {
        return [];
    }
    $id = (int) get_user_meta($userID, BASE.'link', true);
    $id = (int) get_user_meta($userID, BASE.'profile_link', true);
    $meta = Meta::forPost($id);
    $artist = $meta->getAll(['first_name','type','city','shop']);
@@ -211,7 +211,7 @@
    }
    $user = ($ID === 0) ? wp_get_current_user() : get_userdata($ID);
    return array_values(array_intersect(
        array_keys(array_merge(JVB_USER, ['administrator'])),
        array_keys(array_merge(Registrar::getRegistered('user'), ['administrator'])),
        array_map(function ($role) {
            return jvbNoBase($role);
        },
inc/helpers/ui.php
@@ -1,5 +1,6 @@
<?php
use JVBase\managers\Cache;
use JVBase\meta\Form;
use JVBase\base\Site;
use JVBase\utility\Image;
@@ -123,13 +124,13 @@
            </a>
        </li>
        <li class="notifications has-submenu">
            <button class="toggle notifications" type="button" title="Toggle Notifications" aria-expanded="false" aria-controls="notifications-dropdown">
            <button class="toggle notifications" type="button" data-action="toggle-submenu" title="Toggle Notifications" aria-expanded="false" aria-controls="notifications-dropdown">
                <?= jvbIcon('bell', ['title'=> 'No Notifications'])?>
                <?= jvbIcon('bell-ringing', ['title'=> 'New Notifications']) ?>
                <span class="notification-badge" aria-label="Notifications">
                </span>
            </button>
            <ul class="notifications-preview submenu">
            <ul class="notifications-preview submenu" id="notifications-dropdown">
                <li id="view-all"><a href="<?=get_home_url(null, '/dash/notifications/')?>" class="view-all">View All Notifications</a></li>
            </ul>
            <template class="notificationItem">
@@ -159,45 +160,46 @@
 */
function jvbHelpMenu():string
{
    if (!Site::has('helpMenu')) {
    if (!Site::has('help_menu')) {
        return '';
    }
    $out = get_option(BASE.'help_menu');
    if ($out === false) {
        $open = '<li><a href="';
        $mid = '">';
        $close = '</a></li>';
    $cache = Cache::for('help_menu');
    $out = $cache->remember(
        'help_menu',
        function () {
            $open = '<li><a href="';
            $mid = '">';
            $close = '</a></li>';
        $titles = array(
            'About',
            'Contact',
            'Request a Feature',
            'Technical Issue',
        );
        $links = '';
        foreach ($titles as $t) {
            $page = new WP_Query(array(
                'post_type' => 'page',
                'title'     => $t,
                'fields'    => 'ids'
            ));
            if ($page->have_posts()) {
                $end = ($t == 'About') ? '<li><a href="'.get_home_url(null, '/directory/partners/').'" title="View our Partners">Partners</a></li>' : '';
                $links .= $open.get_the_permalink($page->posts[0]).'" title="'.$t.$mid.$t.$close.$end;
            }
            wp_reset_postdata();
        }
            $titles = array(
                'About',
                'Contact',
                'Request a Feature',
                'Technical Issue',
            );
            $links = '';
            foreach ($titles as $t) {
                $page = new WP_Query(array(
                    'post_type' => 'page',
                    'title'     => $t,
                    'fields'    => 'ids'
                ));
                if ($page->have_posts()) {
                    $end = ($t == 'About') ? '<li><a href="'.get_home_url(null, '/directory/partners/').'" title="View our Partners">Partners</a></li>' : '';
                    $links .= $open.get_the_permalink($page->posts[0]).'" title="'.$t.$mid.$t.$close.$end;
                }
                wp_reset_postdata();
            }
        $out = '<li class="has-submenu">
                <button class="toggle quick-help" type="button" title="Toggle Quick Help Menu" aria-expanded="false" aria-controls="quick-help" aria-label="Toggle Quick Help Menu">'.jvbIcon('question', ['title'=> 'Quick Help']).'</button>
                <ul class="submenu">
            return '<li class="has-submenu">
                <button class="toggle quick-help" data-action="toggle-submenu" type="button" title="Toggle Quick Help Menu" aria-expanded="false" aria-controls="quick-help" aria-label="Toggle Quick Help Menu">'.jvbIcon('question', ['title'=> 'Quick Help']).'</button>
                <ul class="submenu" id="quick-help">
                    '.$links.'
                </ul>
            </li>';
        update_option(BASE.'help_menu', $out);
    }
        }
    );
    if (is_user_logged_in()) {
        $out .= '<li><a href="'.wp_logout_url(get_home_url()).'" title="Logout">'.jvbIcon('sign-out').'<span class="screen-reader-text">Logout</span></a></li>';
inc/importers/JaneAppClientImporter.php
@@ -320,11 +320,11 @@
        }
        // Get the role from options with proper fallback
        $role = $options['default_role'] ?? get_option(BASE . 'client_import_role', JVB_USER);
        $role = $options['default_role'] ?? get_option(BASE . 'client_import_role', BASE.'client');
        // Ensure role exists, fallback to JVB_USER if not
        // Ensure role exists
        if (!get_role($role)) {
            $role = JVB_USER;
            return new WP_Error('invalid_role', 'Invalid role');
        }
        // Create user
inc/integrations/Cloudflare.php
@@ -253,10 +253,4 @@
        // Cloudflare Turnstile uses POST parameters, not headers
        return [];
    }
    protected function getApiUrl(string $endpoint, ?string $baseKey = null): string
    {
        // Only used for direct API calls, not through base class methods
        return 'https://challenges.cloudflare.com/turnstile/v0/siteverify';
    }
}
inc/integrations/Square.php
@@ -1660,7 +1660,7 @@
        // Set user role (assuming you have a customer role defined)
        $user = new \WP_User($user_id);
        $user->set_role(BASE.'foodie'); // Or whatever role from JVB_USER
        $user->set_role(BASE.'foodie'); // Or whatever role
        // Generate password reset key
        $reset_key = get_password_reset_key($user);
@@ -2005,7 +2005,7 @@
        wp_enqueue_script('jvb-square-checkout');
        wp_localize_script('jvb-square-checkout', 'squareConfig', [
            'isOpen'         => jvbIsOpen(),
//TODO          'isOpen'         => jvbIsOpen(),
            'application_id' => $this->credentials['client_id'] ?? '',
            'location_id'    => $this->locationId,
            'environment'    => $this->environment,
inc/managers/AdminPages.php
@@ -32,7 +32,7 @@
            'menu_title' => 'JakeVan',
            'capability' => 'manage_options',
            'menu_slug' => BASE . 'settings',
            'icon' => jvbCSSIcon('settings'),
            'icon' => jvbCSSIcon('gear-six'),
            'position' => 0
        ];
        $this->subpages = get_option(BASE.'adminSubpage', []);
inc/managers/ApprovalManager.php
@@ -3,8 +3,8 @@
use DateTime;
use JVBase\base\Site;
use JVBase\registrar\Registrar;
use JVBase\managers\CustomTable;
if (!defined('ABSPATH')) {
    exit;
@@ -14,12 +14,20 @@
    protected array $tables =[];
    protected int $expiresAt = 21; //days for request
    protected int $requiredVotes = 3; //Number of votes before a term is approved
    protected bool $hasApproval = false;
    public function __construct()
    {
        $this->defineTables();
        if (empty($this->tables)) {
            return;
        }
        $this->hasApproval = Site::membership() && Site::membership()->has('member_verified');
        if ($this->hasApproval) {
            add_action('user_register', [$this, 'handleRegistration'], 10, 2);
        }
        add_action('jvb_cleanup_expired_approvals', [$this, 'cleanupExpired']);
    }
    protected function defineTables():void
@@ -27,6 +35,7 @@
        $types = Registrar::getFeatured('approve_new');
        foreach ($types as $type) {
            $requests = CustomTable::for("approval_{$type}_requests");
            $registrar = Registrar::getInstance($type);
            $requests->setColumns([
                'id'                => 'bigint(20) unsigned NOT NULL AUTO_INCREMENT',
@@ -54,9 +63,9 @@
            ]);
            $base = BASE;
            $requests->setConstraints([
                "CONSTRAINTS `{$base}{$type}_approval_requester` FOREIGN KEY (`user_id`)
                "CONSTRAINT `{$base}{$type}_approval_requester` FOREIGN KEY (`user_id`)
                REFERENCES `{$requests->getUserTable()}` (`ID`) ON DELETE CASCADE",
                "CONSTRAINTS `{$base}{$type}_approval_parent_term` FOREIGN KEY (`parent_id`)
                "CONSTRAINT `{$base}{$type}_approval_parent_term` FOREIGN KEY (`parent_id`)
                REFERENCES `{$requests->getTermTable()}` (`term_id`) ON DELETE CASCADE"
            ]);
            $requests->defineTable();
@@ -81,7 +90,7 @@
            $votes->setConstraints([
                "CONSTRAINT `{$base}{$type}_user_approval_request` FOREIGN KEY (`request_id`)
                REFERENCES `{$requests->getFullTableName()} (`id`) ON DELETE CASCADE",
                REFERENCES `{$requests->getFullTableName()}` (`id`) ON DELETE CASCADE",
                "CONSTRAINT `{$base}{$type}_user_approval_voter` FOREIGN KEY (`user_id`)
                REFERENCES `{$votes->getUserTable()}` (`ID`) ON DELETE CASCADE"
            ]);
@@ -94,7 +103,23 @@
            ];
        }
    }
    /**
     * Handler for user registration
     *
     * @param int $user_id New user ID
     * @param object $user the new user object
     *
     * @return void
     */
    public function handleRegistration(int $user_id, object $user): void
    {
        $registrar = Registrar::getInstance(jvbUserRole($user_id));
        if ($registrar && $registrar->hasFeature('approve_new')) {
            $user->add_cap('skip_moderation', false);
            $this->createApproval($user_id, $registrar->getSlug(), $user->display_name);
        }
    }
    public function canApprove(int $userID, string $type):bool
    {
        $type = jvbNoBase($type);
@@ -198,6 +223,8 @@
            return $this->response(false, 'Invalid vote');
        }
        $response = $votes->findOrCreate([
            'request_id'    => $request_id,
            'user_id'       => $userID
@@ -209,12 +236,37 @@
        if (!$response) {
            return $this->response(false, 'Could not store vote for some reason.');
        }
        $approvers = $request['approved_by'];
        $rejectors = $request['rejected_By'];
        $both = array_merge($approvers, $rejectors);
        //See if the user has already voted
        //If the user is changing their vote, proceed. If it's the same, we can bail early.
        if (in_array($userID, $both)) {
            switch ($vote) {
                case 'reject':
                    if (in_array($userID, $approvers)) {
                        unset($approvers[array_search($userID, $approvers)]);
                    } else {
                        return $this->response(true, 'You already voted this way.');
                    }
                    break;
                case 'approve':
                    if (in_array($userID, $rejectors)) {
                        unset($rejectors[array_search($userID, $rejectors)]);
                    } else {
                        return $this->response(true, 'You already voted this way.');
                    }
            }
        }
        switch ($vote) {
            case 'reject':
                $rejectors[] = $userID;
                $request['rejections']++;
                break;
            case 'approve':
                $approvers[] = $userID;
                $request['approvals']++;
                break;
            default:
@@ -227,8 +279,10 @@
            $this->finalizeApproval($request_id, $type);
        }
        $updated = $requests->update([
            'rejections'=> $request['rejections'],
            'approvals' => $request['approvals']
            'rejections'    => $request['rejections'],
            'approvals'     => $request['approvals'],
            'rejected_by'   => $rejectors,
            'approved_by'   => $approvers
        ],
        [
            'request_id' => $request_id
@@ -298,7 +352,9 @@
                $updatedID = $term['term_id'];
            }
        } elseif ($registrar->getType() === 'user') {
            $user = get_userdata($request['user_id']);
            $user->add_cap('skip_moderation', true);
            update_user_meta($request['user_id'], BASE.'verification_date', current_time('mysql'));
        }
        $updates = [
@@ -314,7 +370,7 @@
            ]);
        JVB()->notification()->addNotification($request['user_id'], $type.'approved', null, 'Your suggestion "'.$request['name'].'" was denied.');
        JVB()->notification()->notify($request['user_id'], $type.'approved', null, 'Your suggestion "'.$request['name'].'" was denied.');
    }
    public function finalizeDenial(int $request_id, string $type):void
    {
@@ -331,7 +387,7 @@
                'request_id' => $request_id
            ]);
        JVB()->notification()->addNotification($request['user_id'], $type.'denied', null, 'Your suggestion "'.$request['name'].'" was denied.');
        JVB()->notification()->notify($request['user_id'], $type.'denied', null, 'Your suggestion "'.$request['name'].'" was denied.');
    }
    public function getApprovalRequests(int $userID, ?string $type = null):array
@@ -429,4 +485,74 @@
            ]
        ];
    }
    /**
     * Create artist approval request
     *
     * @param int $user_id User ID to be approved
     *
     * @return int|false Request ID or false on failure
     */
    /**
     * Create artist approval request - REFACTORED
     */
    public function createArtistApprovalRequest(int $user_id): int|false
    {
        $userRole = jvbUserRole($user_id);
        $table = $this->tables[$userRole]['requests']??false;
        if (!$table) {
            return false;
        }
        return $table->transaction(function($table) use ($user_id) {
            // Check for existing request
            $existing = $table->where(['user_id' => $user_id])->first();
            if ($existing) {
                return $existing->id;
            }
            $user_data = get_userdata($user_id);
            return $table->create([
                'user_id' => $user_id,
                'status' => 'pending',
                'expires_at' => date('Y-m-d H:i:s', strtotime('+30 days')),
                'current_approvals' => 0,
                'current_rejections' => 0,
                'required_approvals' => 3, // From config
                'approved_by' => json_encode([]),
                'rejected_by' => json_encode([]),
            ]);
        });
    }
    public function getRequest(int $requestID, string $type):array|false
    {
        $table = $this->requests($type);
        if (!$table) {
            return false;
        }
        return $table->get(['id' => $requestID])??false;
    }
    public function getVotes(int $requestID, string $type):array|false
    {
        $table = $this->votes($type);
        if (!$table) {
            return false;
        }
        return $table->getMany(['request_id' => $requestID])??false;
    }
    public function cleanupExpired():void
    {
        $now = current_time('mysql');
        foreach ($this->tables as $type => $tables) {
            $tables['requests']->query(
                "UPDATE {table}
                SET status = 'expired'
                WHERE status = 'pending'
                AND expires_at <%s",
                [$now]
            );
        }
    }
}
inc/managers/CRUDManager.php
@@ -1,6 +1,7 @@
<?php
namespace JVBase\managers;
use JVBase\base\Site;
use JVBase\registrar\Registrar;
use JVBase\ui\CRUDSkeleton;
@@ -99,7 +100,7 @@
        $this->skeleton->addCapabilities(['view', 'edit', 'create', 'delete']);
        $plural = strtolower($this->registrar->getPlural() ?? $this->content . 's');
        $canPublish = jvbUserIsVerified() && user_can($this->user_id, "publish_{$plural}");
        $canPublish = $this->userIsVerified() && user_can($this->user_id, "publish_{$plural}");
        $this->skeleton->userCanPublish($canPublish);
        // Bulk actions
@@ -122,6 +123,11 @@
        add_filter('jvbAdditionalActions', [$this, 'createItem']);
    }
    protected function userIsVerified():bool {
        $membership = Site::membership();
        return !($membership && $membership->has('member_verified')) || current_user_can('skip_moderation');
    }
    /**
     * Setup uploader configuration
     */
inc/managers/DashboardManager.php
@@ -33,7 +33,7 @@
        $this->cache->flush();
        $this->user = wp_get_current_user();
        $this->role = jvbUserRole($this->user->ID);
        $this->userLink = (int)get_user_meta($this->user->ID, BASE.'link', true);
        $this->userLink = (int)get_user_meta($this->user->ID, BASE.'profile_link', true);
        $this->baseURL = get_home_url(null, '/dash');
        add_action('template_redirect', [$this, 'handleRedirects']);
@@ -647,7 +647,7 @@
        <!DOCTYPE html>
    <html <?php language_attributes(); ?>>
        <head>
            <title><?= (array_key_exists('dashboard_title', JVB_SITE)) ? JVB_SITE['dashboard_title'] : 'Dashboard | '.get_bloginfo('name') ?></title>
            <title><?= Site::dashboardTitle()??'Dashboard | '.get_bloginfo('name') ?></title>
            <meta charset="<?php bloginfo('charset'); ?>">
            <meta name="viewport" content="width=device-width, initial-scale=1.0">
            <?php
inc/managers/DirectoryManager.php
@@ -38,14 +38,14 @@
        jvb_register_do_once('buildDirectories', [$this, 'activate']);
        add_action('init', [$this, 'registerDirectories']);
        add_action('render_block', [$this, 'renderBlock'], 998, 3);
        add_filter('render_block', [$this, 'renderBlock'], 998, 3);
    }
    public function registerDirectories():void
    {
        $singular = (array_key_exists('directory_label', JVB_SITE)) ? JVB_SITE['directory_label'][0] : 'Directory';
        $plural = (array_key_exists('directory_label', JVB_SITE)) ? JVB_SITE['directory_label'][1] : 'Directories';
        $singular = !empty(Site::getDirectorySingular())?:'Directory';
        $plural = !empty(Site::getDirectorySingular())?: 'Directories';
        $config = [
            'labels'    => [
                'name'               => $plural,
@@ -712,10 +712,7 @@
    public function referAs($plural = false):string
    {
        if (!empty(JVB_SITE) && array_key_exists('directory_label', JVB_SITE)) {
            return ($plural) ? JVB_SITE['directory_label'][1] : JVB_SITE['directory_label'][0];
        }
        return ($plural) ? 'Directories' : 'Directory';
        return ($plural) ? Site::getDirectoryPlural()??'Directories' : Site::getDirectorySingular()??'Directory';
    }
    /*****************************************************
inc/managers/FavouritesManager.php
@@ -135,7 +135,7 @@
            $table->setKeys([
                ['key' => 'PRIMARY', 'value' => '(`id`)'],
                ['key'  => 'UNIQUE', 'value' => '`unique_share_user` (`list_id`, `user_id`)'],
                ['key'=> 'UNIQUE', 'value' => '`unique_share_email` (`list_id`, `email`'],
                ['key'=> 'UNIQUE', 'value' => '`unique_share_email` (`list_id`, `email`)'],
                '`list_shares` (`list_id`)',
                '`list_user` (`list_id`, `user_id`)',
                '`status_index` (`status`)'
inc/managers/IconsManager.php
@@ -44,9 +44,7 @@
    {
        $this->source = $source;
        $this->cache = Cache::for('icons_' . $source, WEEK_IN_SECONDS);
        $this->style = (array_key_exists('icons', JVB_SITE) && in_array(JVB_SITE['icons'], $this->styles))
            ? JVB_SITE['icons']
            : 'regular';
        $this->style = Site::icon();
        $this->addMap();
@@ -521,7 +519,7 @@
     */
    public function get(string $name, array $options = []): string
    {
        if ($name === '') {
        if (empty($name)) {
            //No icon requested
            return '';
        }
@@ -699,6 +697,14 @@
        if (!$style) {
            $style = $this->style;
        }
        $icon = $this->map[$icon] ?? $icon;
        // Validate icon exists
        if (!$this->iconExists($icon, $style)) {
            error_log('[IconsManager] Icon not found: ' . $icon);
            return '';
        }
        $svg = $this->getEncodedSVG($icon, $style);
        if ($svg !== '') {
            return "data:image/svg+xml;base64,{$svg}";
inc/managers/InvitationsManager.php
@@ -49,9 +49,9 @@
            'name'              => 'varchar(255) NOT NULL',
            'email'             => 'varchar(255) NOT NULL',
            'invitation_token'  => 'varchar(255) NOT NULL',
            'invited_role'      => 'varchar(50) NOT NULL COMMENT \'Role being invited to\'',
            'status'            => 'ENUM(\'pending\',\'accepted\',\'rejected\',\'expired\',\'revoked\') DEFAULT \'pending\'',
            'inviters'          => 'JSON NOT NULL COMMENT \'Array of {user_id, invited_at}\'',
            'invited_role'      => 'varchar(50) NOT NULL',      //Role being invited to
            'status'            => "ENUM('pending','accepted','rejected','expired','revoked') DEFAULT 'pending'",
            'inviters'          => 'JSON NOT NULL',             // Array of {user_id, invited_at}
            'new_user_id'       => $table->getUserIDType().' DEFAULT NULL',
            'expires_at'        => 'datetime NOT NULL',
            'accepted_at'       => 'datetime DEFAULT NULL',
@@ -67,7 +67,7 @@
        $table->setKeys([
            ['key' => 'PRIMARY', 'value' => '(`id`)'],
            ['key' => 'UNIQUE KEY', 'value' => '`unique_email_role` (`email`, `invited_role`)'],
            ['key' => 'UNIQUE', 'value' => '`unique_email_role` (`email`, `invited_role`)'],
            '`token_lookup` (`invitation_token`)',
            '`status_expiry` (`status`, `expires_at`)',
            '`role_status` (`invited_role`, `status`)',
inc/managers/KarmaManager.php
@@ -122,7 +122,7 @@
    protected function defineTable():bool
    {
        $table = CustomTable::for('karma_'.$this->key);
        [$type, $table, $column] = $this->getReferenceTable($table);
        [$type, $referenceTable, $column] = $this->getReferenceTable($table);
        if (!$type) {
            error_log('[KarmaManager]::defineTable Attempted to build reference for invalid table: '.$this->references);
            CustomTable::destroyInstance('karma_'.$this->key);
@@ -134,7 +134,7 @@
            'item_id'   => "{$type} NOT NULL",
            'user_id'   => "{$table->getUserIDType()} NOT NULL",
            'content'   => 'varchar(255) NOT NULL',
            'vote'      => "ENUM('up,'down') NOT NULL",
            'vote'      => "ENUM('up','down') NOT NULL",
            'created_at'=> 'datetime NOT NULL DEFAULT CURRENT_TIMESTAMP',
            'updated_at'=> 'datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP'
        ]);
@@ -147,9 +147,9 @@
        ]);
        $base = BASE;
        $table->setConstraints([
            "CONSTRAINT `{$base}kt_{$type}_item_id` FOREIGN KEY (`item_id`)
            REFERENCES `{$table}` (`{$column}`) ON DELETE CASCADE",
            "CONSTRAINT `{$base}kt_{$type}_user_id` FOREIGN KEY (`user_id`)
            "CONSTRAINT `{$base}kt_{$this->key}_{$type}_item_id` FOREIGN KEY (`item_id`)
            REFERENCES `{$referenceTable}` (`{$column}`) ON DELETE CASCADE",
            "CONSTRAINT `{$base}kt_{$this->key}_{$type}_user_id` FOREIGN KEY (`user_id`)
            REFERENCES `{$table->getUserTable()}` (`ID`) ON DELETE CASCADE"
        ]);
        $table->defineTable();
inc/managers/LoginManager.php
@@ -5,7 +5,7 @@
use JVBase\forms\TaxonomySelector;
use JVBase\meta\Form;
use WP_Error;
use JVBase\registrar\Registrar;use WP_Error;
use WP_User;
if (!defined('ABSPATH')) {
@@ -133,19 +133,19 @@
                    'hint'  => 'Have a referral code? Paste it here!'
                ];
            }
            if (count(JVB_USER) > 1) {
                foreach (JVB_USER as $slug => $config) {
                    if (!array_key_exists('can_register', $config) || !$config['can_register']) {
                        continue;
                    }
                    $icon = $config['icon'] ?? '';
            $canRegister = Registrar::getFeatured('can_register', 'user');
            if (!empty($canRegister)) {
                foreach ($canRegister as $role) {
                    $registrar = Registrar::getInstance($role);
                    $config = $registrar->getConfig('register');
                    $icon = $registrar->getIcon('user');
                    $icon = ($icon !== '') ? jvbIcon($icon) : '';
                    $select[$slug] = '<span class="label">'.$icon.$config['label'].'</span><span class="text">'.$config['register']['text']??''.'</span>';
                    if (!empty($config['register']['fields']??[])){
                        foreach ($config['register']['fields'] as $field) {
                    $select[$role] = '<span class="label">'.$icon.$registrar->getSingular().'</span><span class="text">'.$config['description']??Site::login()->getDescription('register')??''.'</span>';
                    if (!empty($config['fields'])){
                        foreach ($config['fields'] as $field) {
                            $field['condition'] = [
                                'field' => 'user_select',
                                'value' => $slug,
                                'value' => $role,
                                'operator'  => '=='
                            ];
                            $fields[] = $field;
@@ -289,7 +289,7 @@
            }
        }
    }
    public function loginUrl(string $login_url, string $redirect, bool $force_reauth):string
    public function loginUrl(string $login_url, ?string $redirect, bool $force_reauth):string
    {
        // This will append /custom-login/ to you main site URL as configured in general settings (ie https://domain.com/custom-login/)
        $login_url = site_url( '/login/', 'login' );
inc/managers/NewsRelationships.php
@@ -215,7 +215,7 @@
     */
    public function handleLinkChange(int $meta_id, int $object_id, string $meta_key, string|null $meta_value = null):void
    {
        // Only proceed for BASE.'link' meta key
        // Only proceed for BASE.'profile_link' meta key
        if ($meta_key !== BASE . 'link') {
            return;
        }
inc/managers/NotificationManager.php
@@ -82,6 +82,11 @@
       return $this->notifications->markActioned($userID, $notification_id, $result);
   }
   public function getNotificationTypes(bool $all = false):array
   {
        return $this->notifications->getNotificationTypes($all);
   }
    /************************************************
     * CONTENT NOTIFICATIONS
     * These are pooled notifications of new content for:
inc/managers/Notifications/Notifications.php
@@ -130,11 +130,16 @@
        $this->types = $types;
    }
    public function getNotificationTypes(bool $all = false):array
    {
        return ($all) ? $this->types : array_keys($this->types);
    }
    private function defineTable():void
    {
        $table = CustomTable::for('notifications_main');
        $typeEnum = implode(',',array_map(function($type) { return '`'.$type.'`';}, array_keys($this->types)));
        $typeEnum = implode(',',array_map(function($type) { return "'{$type}'";}, array_keys($this->types)));
        $table->setColumns([
            'id'            => 'bigint(20) unsigned NOT NULL AUTO_INCREMENT',
@@ -159,7 +164,7 @@
            '`user_status` (`for_user`, `status`)',
            '`target_lookup` (`target_id`, `target_type`)',
            '`unread_notifications` (`for_user`, `status`, `created_at`)',
            '`requires_action` (`for_user`, `requires_action`, `action_taken`)',
            '`requires_action` (`for_user`, `action_taken`)',
            '`from_user_lookup` (`for_user`, `from_user`, `target_id`, `target_type`, `type`)'
        ]);
inc/managers/ReferralManager.php
@@ -43,16 +43,17 @@
        'referee_reward_type' => 'percentage',  // 'percentage' or 'fixed'
        'referee_reward_amount' => 20,  // 20% or $20
        'referee_reward_applies_to' => 'first_order',  // 'first_order' or 'all_orders'
        'referral_role' => BASE.'client'
    ];
    protected string $role = BASE.'client';
    protected string $role;
    protected array $settings;
    public function __construct()
    {
        $this->defineTables();
        $this->role = Site::getDefaultReferralRole();
        $this->default_settings['referral_role'] = $this->role;
        global $wpdb;
        $this->wpdb = $wpdb;
        $this->cache = Cache::for('referrals', WEEK_IN_SECONDS);
@@ -2837,7 +2838,7 @@
            update_option(BASE . 'referral_page_id', $page_id);
            // Save client import role
            $import_role = sanitize_text_field($post_data[BASE . 'referral_role'] ?? JVB_USER);
            $import_role = sanitize_text_field($post_data[BASE . 'referral_role'] ?? Site::getDefaultReferralRole());
            update_option(BASE . 'referral_role', $import_role);
            // Save reward settings
inc/managers/RoleManager.php
@@ -13,6 +13,7 @@
{
    private array $roles;
    private array $content;
    private array $subTypes = [];
    public function __construct()
    {
@@ -26,8 +27,23 @@
           Registrar::getFeatured('is_content', 'term')
       ));
       add_action('set_user_role', [$this, 'updateRoles'], 10, 3);
       $this->checkRoleSubTypes();
    }
    public function checkRoleSubTypes():void
    {
        foreach ($this->roles as $role) {
            $registrar = Registrar::getInstance($role);
            if ($registrar->getUserSubtype()){
                $this->subTypes[jvbCheckBase($registrar->getUserSubtype())] = $registrar->getCreatable();
            }
        }
        if (!empty($this->subTypes)) {
            add_action('set_object_terms', [$this, 'maybeSwitchPermissions'],10, 6);
        }
    }
    public function updateRoles(int $userID, string $role, array $oldRoles):void
    {
        if (doing_action('set_user_role') > 1) {
@@ -93,7 +109,6 @@
        $roles = array_keys($user->roles);
        foreach ($roles as $role) {
            $role = jvbNoBase($role);
            $registrar = Registrar::getInstance($role);
            if (!$registrar) {
                return false;
@@ -217,7 +232,7 @@
        if (!in_array($type, $this->roles)) {
            return false;
        }
        $link = get_user_meta($user->ID, BASE.'link', true);
        $link = get_user_meta($user->ID, BASE.'profile_link', true);
        if ($link === '') {
            $type = BASE.$type;
            $name = $user->display_name;
@@ -230,8 +245,8 @@
                'post_author'   => $user->ID,
            ]);
            if ($link) {
                update_user_meta($user->ID, BASE.'link', $link);
                update_post_meta($link, BASE.'link', $user->ID);
                update_user_meta($user->ID, BASE.'profile_link', $link);
                update_post_meta($link, BASE.'profile_link', $user->ID);
            }
        }
        return $link;
@@ -259,8 +274,8 @@
        if ($registrar->hasFeature('has_dashboard') ?? false) {
            $admin_role = get_role('administrator');
            if ($admin_role) {
                $admin_role->add_cap("manage_{$role_name}s", true);
                $admin_role->add_cap("edit_{$role_name}_settings", true);
                $admin_role->add_cap("manage_{$role_name}s");
                $admin_role->add_cap("edit_{$role_name}_settings");
            }
        }
    }
@@ -392,12 +407,15 @@
        return [
            "edit_{$content}",
            "edit_published_{$content}",
            "read_{$content}",
            "delete_{$content}",
            "delete_published_{$content}",
            "edit_{$plural}",
            "edit_others_{$plural}",
            "publish_{$plural}",
            "read_private_{$plural}",
            "edit_private_{$plural}",
            "delete_private_{$plural}",
        ];
    }
    protected function getOthersCapabilities(string $content):array
@@ -410,9 +428,6 @@
        return [
            "edit_others_{$plural}",
            "delete_others_{$plural}",
            "read_private_{$plural}",
            "edit_private_{$plural}",
            "delete_private_{$plural}",
        ];
    }
@@ -431,12 +446,60 @@
        return str_replace(' ', '_', $content.'s');
    }
    public function activate(): void
    public static function activate(): void
    {
        error_log('[RoleManager]::activate');
        error_log('Registering roles...');
        $instance = new self;
        foreach (Registrar::getRegistered('user') as $role) {
            $this->registerRole($role);
            $instance->registerRole($role);
        }
        error_log('Roles registered!');
        error_log('Removing unneeded roles...');
        remove_role('contributor');
        remove_role('author');
        remove_role('editor');
        error_log('Roles removed!');
        error_log('Adding Admin Capabilities');
        $instance->addAdminCaps();
        error_log('Ensuring Existing User\'s Roles...');
        $instance->ensureRoleCaps();
        error_log('Roles activated!');
    }
        protected function addAdminCaps():void
        {
            $users = get_users(['role' => 'administrator']);
            foreach (array_merge(Registrar::getRegistered('post'), Registrar::getFeatured('is_content')) as $slug) {
                $this->grantRoleCapabilities('administrator', $slug);
                $this->grantRoleOthersCapabilities('administrator', $slug);
                foreach ($users as $user) {
                    $this->grantContent($user, $slug);
                    $this->grantOthersContent($user, $slug);
                }
            }
        }
        protected function ensureRoleCaps():void
        {
            $roles = Registrar::getRegistered('user');
            foreach ($roles as $role) {
                $registrar = Registrar::getInstance($role);
                $creatable = $registrar->getCreatable();
                $manageable = $registrar->getManageOthers();
                if (!empty($creatable) || !empty($manageable)) {
                    $users = get_users(['role' => jvbCheckBase($role)]);
                    foreach ($users as $user) {
                        foreach ($creatable as $slug) {
                            $this->grantContent($user, $slug);
                        }
                        foreach ($manageable as $slug) {
                            $this->grantOthersContent($user, $slug);
                        }
                    }
                }
            }
        }
    /******************************************************************
     * OWNABLE and MANAGABLE terms (ie: tattoo shops)
@@ -758,4 +821,91 @@
        }
        return null;
    }
    public function maybeSwitchPermissions(int $object_id, array $terms, array $tt_ids, string $taxonomy, bool $append, array $old_tt_ids):void
    {
        //This shouldn't happen, but whatever
        if (empty($this->subTypes)) {
            return;
        }
        if (!in_array($taxonomy, array_keys($this->subTypes))) {
            return;
        }
        $new = array_diff($tt_ids, $old_tt_ids);
        $old = array_diff($old_tt_ids, $tt_ids);
        $userID = (int)get_post_meta($object_id, BASE.'profile_link',true);
        if ($userID === 0) {
            return;
        }
        $user = get_userdata($userID);
        if (!$user) {
            return;
        }
        //Revoke old first
        if (!empty($old)) {
            $old = array_filter(array_map(function ($id) use ($taxonomy) {
                $termID = $this->getTermIDFromTTID($id);
                return $this->getRootTermSlug($termID, $taxonomy);
            }, $old));
            foreach ($old as $slug) {
                if (!array_key_exists($slug, $this->subTypes[$taxonomy])) {
                    error_log('[RoleManager]::maybeSwitchPermissions Could not find creatable types for role subtype '.$slug);
                    continue;
                }
                foreach ($this->subTypes[$taxonomy][$slug] ?? [] as $s) {
                    $this->grantContent($user, $s, false);
                }
            }
        }
        if (!empty($new)) {
            $new = array_filter(array_map(function ($id) use ($taxonomy){
                $termID = $this->getTermIDFromTTID($id);
                return $this->getRootTermSlug($termID, $taxonomy);
            }, $new));
            foreach ($new as $slug) {
                if (!array_key_exists($slug, $this->subTypes[$taxonomy])) {
                    error_log('[RoleManager]::maybeSwitchPermissions Could not find creatable types for role subtype '.$slug);
                    continue;
                }
                foreach ($this->subTypes[$taxonomy][$slug] ?? [] as $s) {
                    $this->grantContent($user, $s);
                }
            }
        }
    }
    /**
     * Helper function to get term_id from term_taxonomy_id
     * @param int $tt_id
     *
     * @return int
     */
    private function getTermIDFromTTID(int $tt_id):int
    {
        global $wpdb;
        return $wpdb->get_var($wpdb->prepare(
            "SELECT term_id FROM {$wpdb->term_taxonomy} WHERE term_taxonomy_id = %d",
            $tt_id
        ));
    }
    private function getRootTermSlug(int $termID, string $taxonomy): string|false
    {
        $term = get_term($termID, $taxonomy);
        if (!$term || is_wp_error($term)) {
            return false;
        }
        while ($term->parent !== 0) {
            $term = get_term($term->parent, $taxonomy);
            if (!$term || is_wp_error($term)) {
                return false;
            }
        }
        return $term->slug;
    }
}
inc/managers/SEO/ConfigManager.php
File was deleted
inc/managers/SEO/FieldBuilder.php
File was deleted
inc/managers/SEO/FieldOverrideBuilder.php
File was deleted
inc/managers/SEO/SEOAdminPage.php
@@ -19,7 +19,6 @@
 */
class SEOAdminPage
{
    private ConfigManager $config;
//    private SchemaBuilder $registry;
    public function __construct()
inc/managers/SEO/SchemaBuilder.php
File was deleted
inc/managers/SEO/SchemaFieldHelpers.php
File was deleted
inc/managers/SEO/SchemaOutputManager.php
File was deleted
inc/managers/SEO/SchemaReferenceBuilder.php
File was deleted
inc/managers/SEO/TemplateResolver.php
File was deleted
inc/managers/SEO/TypeBuilder.php
File was deleted
inc/managers/SEO/_setup.php
@@ -1,19 +1,5 @@
<?php
//New System
require(JVB_DIR . '/inc/managers/SEO/render/_setup.php');
//require(JVB_DIR . '/inc/managers/SEO/schemas/_setup.php');
//
//require(JVB_DIR . '/inc/managers/SEO/FieldBuilder.php');
//require(JVB_DIR . '/inc/managers/SEO/FieldOverrideBuilder.php');
//require(JVB_DIR . '/inc/managers/SEO/TypeBuilder.php');
//require(JVB_DIR . '/inc/managers/SEO/SchemaBuilder.php');
//require(JVB_DIR.'/base/seo.php');
//require(JVB_DIR . '/inc/managers/SEO/ConfigManager.php');
require(JVB_DIR . '/inc/managers/SEO/BreadcrumbManager.php');
//require(JVB_DIR . '/inc/managers/SEO/SchemaFieldHelpers.php');
//require(JVB_DIR . '/inc/managers/SEO/SchemaReferenceBuilder.php');
//require(JVB_DIR . '/inc/managers/SEO/TemplateResolver.php');
//require(JVB_DIR . '/inc/managers/SEO/SchemaOutputManager.php');
require(JVB_DIR . '/inc/managers/SEO/SEOAdminPage.php');
//require(JVB_DIR . '/inc/managers/SEO/SEOAdminPage.php');
inc/managers/SEO/schemas/SchemaDefinition.php
File was deleted
inc/managers/SEO/schemas/SchemaResolverInterface.php
File was deleted
inc/managers/SEO/schemas/SchemaResolverRegistry.php
File was deleted
inc/managers/SEO/schemas/_setup.php
File was deleted
inc/managers/SEO/schemas/resolvers/BaseResolver.php
File was deleted
inc/managers/SEO/schemas/resolvers/CollectionPageResolver.php
File was deleted
inc/managers/SEO/schemas/resolvers/FAQPageResolver.php
File was deleted
inc/managers/SEO/schemas/resolvers/LocalBusinessResolver.php
File was deleted
inc/managers/SEO/schemas/resolvers/PersonResolver.php
File was deleted
inc/managers/SEO/schemas/resolvers/VisualArtworkResolver.php
File was deleted
inc/managers/VerifyEntryManager.php
@@ -77,7 +77,7 @@
            return $this->response(false, 'Term does not exist');
        }
        $profile = get_user_meta($userID, BASE.'link', true);
        $profile = get_user_meta($userID, BASE.'profile_link', true);
        if (empty($profile)) {
            return $this->response(false, 'No Profile found');
        }
inc/managers/_setup.php
@@ -1,5 +1,6 @@
<?php
use JVBase\managers\ApprovalManager;
use JVBase\managers\Cache;
use JVBase\managers\IconsManager;
use JVBase\registrar\Registrar;
@@ -48,16 +49,20 @@
        require(JVB_DIR . '/inc/managers/UserTermsManager.php');
    }
    if (Site::has('notifications')) {
        require(JVB_DIR . '/inc/managers/Notifications/Content.php');
        require(JVB_DIR . '/inc/managers/Notifications/EmailDigests.php');
        require(JVB_DIR . '/inc/managers/Notifications/Notifications.php');
        require(JVB_DIR . '/inc/managers/Notifications/Preferences.php');
        require(JVB_DIR . '/inc/managers/NotificationManager.php');
    if (!empty(Registrar::getFeatured('approve_new'))) {
        require(JVB_DIR . '/inc/managers/ApprovalManager.php');
    }
    $membership = Site::membership();
    if ($membership) {
        if ($membership->has('notifications')) {
            require(JVB_DIR . '/inc/managers/Notifications/Content.php');
            require(JVB_DIR . '/inc/managers/Notifications/EmailDigests.php');
            require(JVB_DIR . '/inc/managers/Notifications/Notifications.php');
            require(JVB_DIR . '/inc/managers/Notifications/Preferences.php');
            require(JVB_DIR . '/inc/managers/NotificationManager.php');
        }
        if ($membership->has('forum') && !empty(Registrar::getFeatured('is_content', 'term'))) {
            require(JVB_DIR . '/inc/managers/NewsRelationships.php');
        }
@@ -77,6 +82,18 @@
    if (Site::has('referrals')) {
        require(JVB_DIR . '/inc/managers/ReferralManager.php');
    }
    if (!empty(Registrar::getFeatured('karma'))) {
        require(JVB_DIR . '/inc/managers/KarmaManager.php');
    }
    if (Site::has('favourites')) {
        require(JVB_DIR . '/inc/managers/FavouritesManager.php');
    }
}
add_action('init', 'jvb_load_karma', 1);
function jvb_load_karma():void
{
}
require(JVB_DIR . '/inc/managers/ErrorHandler.php');
require(JVB_DIR . '/inc/managers/queue/_setup.php');
inc/meta/MetaFormOld.php
File was deleted
inc/meta/MetaManager.php
File was deleted
inc/meta/MetaOld.php
File was deleted
inc/meta/_setup.php
@@ -14,5 +14,3 @@
require(JVB_DIR . '/inc/meta/Registry.php');
require(JVB_DIR . '/inc/meta/Sanitizer.php');
//OLD SYSTEM
//  require(JVB_DIR . '/inc/meta/MetaManager.php');
inc/registrar/Fields.php
@@ -178,32 +178,29 @@
    public function addCommon(string $name):self
    {
        match ($name) {
            'wiki'      => $this->addWikiField(),
            'links'     => $this->addLinksField(),
            'contact'   => $this->addContactField(),
            'reviews', 'review' => $this->addReviewField(),
            'alternate_name'    => $this->addAlternateName(),
            'keywords' => $this->addKeywords(),
            default => error_log('[Field]addCommon: No configuration found for '.$name.'.')
        };
        $method = 'add'.implode('',array_map('ucfirst',explode('_', $name))).'Field';
        if (method_exists($this, $method)) {
            $this->$method();
        } else {
            error_log('[Field]addCommon: No configuration found for '.$name.'.');
        }
        return $this;
    }
    protected function addWikiField():void
    protected function addWikiField(?string $label = null):void
    {
        $this->addField(
            'wiki',
            [
                'type'        => 'url',
                'label'       => 'Wikipedia Page',
                'label'       => $label ?: 'Wikipedia Page',
                'description' => 'For the schema',
                'quickEdit'   => true,
            ]
        );
    }
    protected function addLinksField():void
    protected function addLinksField(?string $label = null):void
    {
        $this->addField(
            'links',
@@ -211,7 +208,7 @@
                'type'        => 'repeater',
                'quickEdit'   => true,
                'add_label' => 'title',
                'label'       => 'Online Links',
                'label'       => $label ?:'Online Links',
                'description' => 'These are listed publicly on the website',
                'fields'      => [
                    'url'     => [
@@ -226,6 +223,7 @@
                        'type'        => 'text',
                        'label'       => 'Tracker',
                        'description' => 'If you are set up to track link referrals, add what comes after the ? here.',
                        'default'       => 'ref=edmonton_ink'
                    ],
                ],
                'section'     => 'contact'
@@ -267,14 +265,14 @@
        );
    }
    protected function addReviewField():void
    protected function addReviewField(?string $label = null):void
    {
        $this->addField(
            'reviews',
            [
                'type'    => 'repeater',
                'add_label' => 'name',
                'label'   => 'Reviews',
                'label'   => $label ?: 'Reviews',
                'fields'  => [
                    'name'   => [
                        'type'  => 'text',
@@ -317,7 +315,7 @@
        );
    }
    protected function addAlternateName():void
    protected function addAlternateNameField():void
    {
        $this->addField(
            'alternate_name',
@@ -334,7 +332,7 @@
            ]
        );
    }
    protected function addKeywords():void
    protected function addKeywordsField():void
    {
        $this->addField(
            'keywords',
@@ -353,4 +351,75 @@
            ]
        );
    }
    protected function addOutsidePhotoField():void
    {
        $this->addField(
            'outside_photo',
            [
                'type'  => 'image',
                'limit' => 1,
                'label' => __('Outside Photo', 'jvb')
            ]
        );
    }
    protected function addSloganField():void
    {
        $this->addField(
            'slogan',
            [
                'type'  => 'text',
                'label' => __('Tagline or Slogan', 'jvb')
            ]
        );
    }
    protected function addPaymentField():void
    {
        $this->addField(
            'payment_accepted',
            [
                'type'  => 'set',
                'label' => __('Payment Accepted', 'jvb'),
                'options'   => [
                    'Cash'          => 'Cash',
                    'Credit Card'   => 'Credit Card',
                    'Debit'         => 'Debit',
                    'Google Pay'    => 'Google Pay',
                    'Apple Pay'     => 'Apple Pay',
                    'PayPal'        => 'PayPal',
                    'Interac'       => 'Interac',
                    'AMEX'          => 'AMEX',
                ],
            ]
        );
    }
    protected function addAmenitiesField():void
    {
        $this->addField(
            'amenities',
            [
                'type'  => 'set',
                'label' => __('Amenities', 'jvb'),
                'options' => [
                    'Wheelchair Accessible' => 'Wheelchair Accessible',
                    'Free Parking' => 'Free Parking',
                    'Private Rooms' => 'Private Rooms',
                    'Air Conditioning' => 'Air Conditioning',
                    'WiFi' => 'WiFi',
                    'Gender Neutral Restroom' => 'Gender Neutral Restroom',
                    'LGBTQ+ Friendly' => 'LGBTQ+ Friendly',
                    'Sterilization Room' => 'Sterilization Room',
                    'Refreshments Available' => 'Refreshments Available',
                    'Street Level Access' => 'Street Level Access',
                    'Single Use Needles' => 'Single Use Needles',
                    'Consultation Room' => 'Consultation Room',
                    'Aftercare Products Available' => 'Aftercare Products Available',
                    'Walk-Ins Welcome' => 'Walk-Ins Welcome',
                ]
            ]
        );
    }
}
inc/registrar/Posts.php
@@ -454,4 +454,5 @@
        }
        return $url;
    }
}
inc/registrar/Registrar.php
@@ -32,6 +32,7 @@
    protected string $type;
    protected string $singular;
    protected string $plural;
    protected string $profile;
    protected string $description ='';
    protected Fields $fields;
    protected array $sections = [];
@@ -187,6 +188,16 @@
     * @var array slugs of other user roles this role can manage
     */
    protected array $manage_others = [];
    /**
     * @var string The slug of the taxonomy that defines different access points
     * Example, for edmonton.ink, we have artist_type, and $this->>can_create would be:
     *      [
     *          'piercer'   => ['piercings', 'artwork', 'events'],
     *          'tattoo_artist'=> ['tattoos', 'artwork', 'events'],
     *          'artist'    => ['artwork']
     *      ]
     */
    protected string $user_subtype;
    /** Configs **/
    protected Breadcrumbs $breadcrumbs;
@@ -219,7 +230,7 @@
//      $this->initClasses();
        $this->setFields();
        add_action('init', [$this, 'register'], 0);
        add_action('init', [$this, 'register'], 2);
        add_filter('jvbDashboardPage', [$this, 'renderDashPage'], 10, 3);
    }
@@ -691,7 +702,7 @@
        }
        if ($this->karma) {
            $this->karmaManager = KarmaManager::for($this->slug);
            $this->karmaManager = KarmaManager::for($this->slug, $this->type);
        }
    }
    public static function getInstance(string $slug):Registrar|false
@@ -919,7 +930,38 @@
     * FLAGGED FEATURES
    *****************************************************************/
    public function profile(?string $slug = null, ?string $singular = null, ?string $plural = null):self
    {
        if (!$slug) {
            $slug = $this->slug.'_profile';
        }
        if (!$singular) {
            $singular = $this->singular;
        }
        if (!$plural) {
            $plural = $this->plural;
        }
        $this->profile_link = true;
        $this->profile = $slug;
        return Registrar::forPost($slug, $singular, $plural);
    }
    public function getProfile():self|false
    {
        if (!$this->profile_link) {
            return false;
        }
        return self::getInstance($this->profile);
    }
    public function setUserSubtype(string $type):self
    {
        $this->user_subtype = sanitize_text_field($type);
        return $this;
    }
    public function getUserSubtype():string|false
    {
        return $this->user_subtype?:false;
    }
}
inc/registrar/_setup.php
@@ -5,7 +5,9 @@
require_once(JVB_DIR . '/inc/registrar/config/Directory.php');
require_once(JVB_DIR . '/inc/registrar/config/Feed.php');
require_once(JVB_DIR . '/inc/registrar/config/SEO.php');
require_once(JVB_DIR . '/inc/registrar/config/Section.php');
require_once(JVB_DIR . '/inc/registrar/config/Integration.php');
require_once(JVB_DIR . '/inc/registrar/config/Register.php');
require_once(JVB_DIR . '/inc/registrar/config/seo/_setup.php');
require_once(JVB_DIR . '/inc/registrar/fields/Field.php');
inc/registrar/config/Register.php
New file
@@ -0,0 +1,76 @@
<?php
namespace JVBase\registrar\config;
if (!defined('ABSPATH')) {
    exit;
}
final class Register extends Config {
    public string $title;
    public string $description;
    public array $fields = [
        'username' => [
            'type'  => 'email',
            'label' => 'Your Email',
            'required' => true,
        ],
        'password'  => [
            'type'  => 'text',
            'subtype' => 'password',
            'label' => 'Your Password',
        ],
        'password_check' => [
            'type'  => 'text',
            'subtype' => 'password',
            'label' => 'Verify Password',
        ],
    ];
    public function __construct() {
    }
    public function setTitle(string $title): self {
        $this->title = $title;
        return $this;
    }
    public function getTitle():string
    {
        return $this->title;
    }
    public function setDescription(string|array $desc):self
    {
        if (is_array($desc)) {
            $desc = implode('', array_map(function ($p) { return '<p>'.$p.'</p>';}, $desc));
        } else if (!str_contains('<p>', $desc)) {
            $desc = '<p>'.$desc.'</p>';
        }
        $this->description = $desc;
        return $this;
    }
    public function getDescription():string|array
    {
        return $this->description;
    }
    public function setFields(array $fields):self
    {
        $this->fields = array_merge($this->fields, $fields);
        return $this;
    }
    public function getFields():array
    {
        return $this->fields;
    }
    function getConfig(): array
    {
        return [
            'title' => $this->title ?? false,
            'description' => $this->description??false,
            'fields'    => $this->fields
        ];
    }
}
inc/registrar/config/Section.php
@@ -13,10 +13,12 @@
    protected string $description = '';
    protected string $icon = '';
    protected array $fields = [];
    protected Registrar $registrar;
    public function __construct(string $title) {
    public function __construct(string $title, Registrar $registrar) {
        $this->title = $title;
        $this->slug = sanitize_title($title);
        $this->registrar = $registrar;
    }
    public function setTitle(string $title): self {
@@ -46,7 +48,7 @@
    protected function checkFields(string|array $fields):string|array
    {
        $allFields =  Registrar::getInstance($this->slug)->getFields();
        $allFields =  $this->registrar->getFields();
        if (is_array($fields)){
            foreach ($fields as $index =>$fieldName){
                if (!array_key_exists($fieldName, $allFields)){
inc/registrar/config/seo/Schema.php
@@ -182,6 +182,8 @@
                    $class = JVB()->schemaHelper()::classFromConfig($config, $meta);
                    $class->setAuthor(JVB()->seo()->getCreator(true));
                    $class = apply_filters('jvb_single_'.$this->slug.'_schema_output', $class, $ID);
                    return $class->outputSchema();
                }
            );
inc/registrar/fields/Field.php
@@ -20,6 +20,7 @@
    protected bool $quill;              // whether to use quill
    protected int $maxLength;           // of characters
    protected string $subtype;
    protected array $condition;
    protected array $allowedSubtype = ['text', 'url','number','tel','email','number'];
    /**
     * @var ?bool For timeline post types. Indicates whether all posts get this field, or just the parent
@@ -55,7 +56,7 @@
                $method = 'set'.implode('',array_map('ucfirst',explode('_', $key)));;
                $class->$method($value);
            } else {
                error_log('Instance: '.print_r($class, true));
//              error_log('Instance: '.print_r($class, true));
                error_log('[JVBase\registrar\Field] Invalid key for '.$name.': '.$key);
            }
        }
@@ -78,7 +79,7 @@
        return $this->hint;
    }
    protected function setType(string $type):void{
    public function setType(string $type):void{
        $allowed = array_keys(MetaTypeManager::getTypes());
        if (!in_array($type, $allowed)) {
            error_log('[JVBase\registrar\Field] Invalid type attempted '.$type);
@@ -87,24 +88,24 @@
        $this->type = $type;
    }
    protected function setLabel(string $label):void{
    public function setLabel(string $label):void{
        $this->label = $label;
    }
    protected function setRequired(bool $required):void{
    public function setRequired(bool $required):void{
        $this->required = $required;
    }
    protected function setHidden(bool $hidden):void{
    public function setHidden(bool $hidden):void{
        $this->hidden = $hidden;
    }
    protected function setQuickEdit(bool $quickEdit):void{
    public function setQuickEdit(bool $quickEdit):void{
        $this->quickEdit = $quickEdit;
    }
    protected function setDefault(mixed $default):void
    public function setDefault(mixed $default):void
    {
        $this->default = $default;
    }
    protected function setQuill(bool $quill):void
    public function setQuill(bool $quill):void
    {
        $this->quill = $quill;
    }
@@ -126,7 +127,7 @@
        return $this->section??null;
    }
    protected function setMaxLength(int $maxLength):void
    public function setMaxLength(int $maxLength):void
    {
        $this->maxLength = $maxLength;
    }
@@ -163,4 +164,21 @@
    {
        return $this->subtype;
    }
    public function setCondition(array $condition):void
    {
        $required = ['field', 'operator', 'value'];
        foreach ($required as $field) {
            if (!array_key_exists($field, $condition)) {
                error_log('[Field]::setCondition Required condition '.$field.' not found');
                return;
            }
        }
        $this->condition = $condition;
    }
    public function getCondition(array $condition):array
    {
        return $this->condition;
    }
}
inc/registrar/fields/RepeaterField.php
@@ -11,11 +11,11 @@
    protected string $add_label = 'Add Row';
    protected string $row_label = 'New Item';
    public function setAdd_label(string $label):void
    public function setAddLabel(string $label):void
    {
        $this->add_label = $label;
    }
    public function getAdd_label():string
    public function getAddLabel():string
    {
        return $this->add_label;
    }
@@ -33,7 +33,7 @@
        }
        return true;
    }
    public function setRow_label(string $label):void
    public function setRowLabel(string $label):void
    {
        if (str_contains($label, '{{')) {
            if (!$this->checkFieldVariables($label)) {
@@ -43,7 +43,7 @@
        $this->row_label = $label;
    }
    public function getRow_label():string
    public function getRowLabel():string
    {
        return $this->row_label;
    }
inc/registrar/helpers/MakeCalendarType.php
@@ -115,13 +115,11 @@
            'max_participants' => [
                'type'      => 'number',
                'label'     => __('Maximum Participants', 'jvb'),
                'bulkEdit'=> true,
            ],
            'is_free' => [
                'type' => 'true_false',
                'label' => 'Free Event?',
                'default' => 1,
                'bulkEdit'=> true,
            ],
            'cost' => [
                'type' => 'number',
@@ -132,7 +130,6 @@
                    'operator' => '!=',
                    'value' => 1
                ],
                'bulkEdit'=> true,
            ],
            'ticket_url' => [
                'type' => 'url',
@@ -151,7 +148,6 @@
                'type' => 'true_false',
                'label' => 'Drop-ins Welcome?',
                'default' => true,
                'bulkEdit'=> true,
            ],
            'recurrence' => [
                'type' => 'group',
inc/registrar/helpers/MakeTrackChanges.php
@@ -37,7 +37,7 @@
        $table->setKeys([
            ['key' => 'PRIMARY', 'value' => '(`id`)'],
            ['key' => 'UNIQUE', 'value' => '(`profile_id`, `term_id`, `start_date`)'],
            ['key' => 'UNIQUE', 'value' => 'profile_term_date (`profile_id`, `term_id`, `start_date`)'],
            'content_role (`term_id`, `role`)',
            'user_id (`user_id`)',
            'profile_id (`profile_id`)',
@@ -62,7 +62,7 @@
            return;
        }
        $user = get_post_meta($object_id, BASE.'link', true);
        $user = get_post_meta($object_id, BASE.'profile_link', true);
        if (empty($author)) {
            $user = get_post($object_id)->post_author??false;
            if (!$user) {
inc/registrar/helpers/MakeVerification.php
@@ -5,7 +5,6 @@
    exit;
}
use JVBase\registrar\Registrar;
use JVBase\managers\CustomTable;
class MakeVerification {
    protected CustomTable $table;
@@ -13,5 +12,4 @@
    {
//      $this->defineTables();
    }
}
inc/registry/CheckCustomTables.php
File was deleted
inc/registry/ContentRegistry.php
File was deleted
inc/registry/FieldRegistry.php
File was deleted
inc/registry/MakeCalendarType.php
File was deleted
inc/registry/OptionsRegistry.php
File was deleted
inc/registry/PostTypeRegistrar.php
File was deleted
inc/registry/TaxonomyRegistrar.php
File was deleted
inc/registry/UserRoleRegistrar.php
File was deleted
inc/registry/_setup.php
File was deleted
inc/registry/index.php
File was deleted
inc/registry/providers/CalendarFieldProvider.php
File was deleted
inc/registry/providers/CommonFieldProvider.php
File was deleted
inc/registry/providers/FieldProviderInterface.php
File was deleted
inc/registry/providers/IntegrationFieldProvider.php
File was deleted
inc/registry/providers/index.php
File was deleted
inc/rest/RegisterRoutes.php
@@ -301,7 +301,7 @@
        }
        //get the user's profile id
        $profileID = get_user_meta($userID, BASE.'link', true);
        $profileID = get_user_meta($userID, BASE.'profile_link', true);
        if (!$profileID) {
            return false;
        }
inc/rest/RestRouteManager.php
@@ -158,11 +158,7 @@
    protected function checkContent(string $content, bool $bool = false):string|bool
    {
        $result = JVB_CONTENT[$content]??JVB_TAXONOMY[$content]??JVB_USER[$content]??'';
        if ($bool) {
            return $result !== '';
        }
        return $result;
        return (bool)Registrar::getInstance($content);
    }
inc/rest/routes/ApprovalRoutes.php
@@ -33,18 +33,13 @@
        parent::__construct();
        $this->initTypes();
        if ($this->hasMemberApproval) {
            add_action('user_register', [$this, 'handleNewUserRegistration'], 10, 2);
        }
        add_action('jvb_cleanup_expired_approvals', [$this, 'cleanupExpiredApprovals']);
    }
    protected function initTypes():void
    {
        $this->userTypes = [];
        $this->termTypes = [];
        $this->allTypes = [];
        if ($this->hasMemberApproval) {
            $this->userTypes = Registrar::getFeatured('approve_new', 'user');
            $this->allTypes = $this->userTypes;
@@ -79,27 +74,6 @@
            ->register();
    }
    /**
     * Handler for user registration
     *
     * @param int $user_id New user ID
     * @param object $user the new user object
     *
     * @return void
     */
    public function handleNewUserRegistration(int $user_id, object $user): void
    {
        $intersect = array_intersect(
            array_map(fn($role) => BASE.$role, $this->userTypes),
            (array) $user->roles
        );
        if (!empty($intersect)) {
            $user->add_cap('skip_moderation', false);
            $this->createArtistApprovalRequest($user_id);
        }
    }
    /**
     * @param WP_REST_Request $request
@@ -131,255 +105,25 @@
     */
    protected function handleVote(string $type, string $vote, int $request_id, int $user_id, string $notes = ''): bool
    {
        if (!in_array($vote, ['approve', 'reject'])) {
        if (!in_array($vote, ['approve', 'reject', 'dismiss'])) {
            return false;
        }
        $requestTable = $this->getTableName($type, 'requests');
        $voteTable = $this->getTableName($type, 'votes');
        $requests = CustomTable::for($requestTable);
        $votes = CustomTable::for($voteTable);
        try {
            return $requests->transaction(function($requests) use ($votes, $request_id, $user_id, $vote, $notes, $type) {
                // Get the approval request
                $request = $requests->where(['id' => $request_id])->first();
                if (!$request || $request->status !== 'pending') {
                    throw new Exception("Invalid approval request");
                }
                // Check if user already voted
                $existingVote = $votes->where([
                    'request_id' => $request_id,
                    'user_id' => $user_id
                ])->first();
                if ($existingVote) {
                    if ($existingVote->vote !== $vote) {
                        // Update vote
                        $votes->where(['id' => $existingVote->id])
                            ->updateResults(['vote' => $vote]);
                        return true;
                    }
                    throw new Exception("User has already voted on this request");
                }
                // Insert new vote
                $votes->create([
                    'request_id' => $request_id,
                    'user_id' => $user_id,
                    'vote' => $vote,
                    'notes' => $notes,
                ]);
                // Update request based on vote type
                $user = get_userdata($user_id);
                if ($vote === 'approve') {
                    $this->handleApproval($requests, $request, $request_id, $user, $type);
                } else {
                    $this->handleRejection($requests, $request, $request_id, $user, $type);
                }
                return true;
            });
        } catch (Exception $e) {
            $this->logError('handleVote', [
                'error' => $e->getMessage(),
                'user_id' => $user_id,
                'request_id' => $request_id,
                'vote' => $vote
            ]);
            return false;
        }
        $result = JVB()->approvals()->markApproval($request_id, $user_id, $type, $vote, $notes);
        return $result['success'];
    }
    /**
     * Handle approval vote logic
     */
    protected function handleApproval(CustomTable $table, object $request, int $request_id, $user, string $type): void
    {
        $approvers = json_decode($request->approved_by, true) ?: [];
        $approvers[$user->ID] = [
            'name' => $user->display_name,
            'voted' => current_time('mysql')
        ];
        $table->where(['id' => $request_id])->updateResults([
            'current_approvals' => $request->current_approvals + 1,
            'approved_by' => json_encode($approvers),
            'expires_at' => $this->rebuildExpiryDate()
        ]);
        // Check if threshold met
        if ($request->current_approvals + 1 >= $request->required_approvals) {
            match ($type) {
                'term' => $this->makeTermLive($request),
                default => $this->completeVerification($request_id, $type),
            };
        }
    }
    /**
     * Handle rejection vote logic
     */
    protected function handleRejection(CustomTable $table, object $request, int $request_id, $user, string $type): void
    {
        $rejecters = json_decode($request->rejected_by, true) ?: [];
        $rejecters[$user->ID] = [
            'name' => $user->display_name,
            'voted' => current_time('mysql')
        ];
        $table->where(['id' => $request_id])->updateResults([
            'current_rejections' => $request->current_rejections + 1,
            'rejected_by' => json_encode($rejecters),
            'expires_at' => $this->rebuildExpiryDate()
        ]);
        // Check if threshold met
        if ($request->current_rejections + 1 >= $request->required_approvals) {
            match ($type) {
                'term' => $this->makeTermUnalive($request),
                default => $this->denyVerification($request_id, $type),
            };
        }
    }
    protected function rebuildExpiryDate()
    {
        return date('Y-m-d H:i:s', strtotime("+{$this->expiryDays} days", time()));
    }
    /**
     * @param string $type user/artist or term
     * @param array $request
     *
     * @return bool|int
     */
    protected function createApprovalRequest(string $type, array $request): int
    {
        $tableName = $this->getTableName($type, 'requests');
        $id = CustomTable::for($tableName)->create($request);
        if (!$id) {
            throw new Exception('Failed to create approval request');
        }
        return $id;
    }
    /*************
     * Artist Approvals
     ************/
    /**
     * Create artist approval request
     *
     * @param int $user_id User ID to be approved
     *
     * @return int|false Request ID or false on failure
     */
    /**
     * Create artist approval request - REFACTORED
     */
    public function createArtistApprovalRequest(int $user_id): int|false
    {
        $userRole = jvbUserRole($user_id);
        $tableName = $this->getTableName($userRole, 'requests');
        $table = CustomTable::for($tableName);
        try {
            return $table->transaction(function($table) use ($user_id) {
                // Check for existing request
                $existing = $table->where(['user_id' => $user_id])->first();
                if ($existing) {
                    return $existing->id;
                }
                $user_data = get_userdata($user_id);
                return $table->create([
                    'user_id' => $user_id,
                    'status' => 'pending',
                    'expires_at' => date('Y-m-d H:i:s', strtotime('+30 days')),
                    'current_approvals' => 0,
                    'current_rejections' => 0,
                    'required_approvals' => 3, // From config
                    'approved_by' => json_encode([]),
                    'rejected_by' => json_encode([]),
                ]);
            });
        } catch (Exception $e) {
            $this->logError('createArtistApprovalRequest', [
                'error' => $e->getMessage(),
                'user_id' => $user_id
            ]);
            return false;
        }
    }
    /**
     * Mark an artist as verified
     *
     * @param int $user_id The user to verify
     * @param int $verified_by ID of user who verified them (optional)
     *
     * @return bool Success status
     */
    public function verifyArtist(int $user_id, int $verified_by = 0):bool
    {
        $user = get_userdata($user_id);
        // Check if user has the artist role
        if (!array_intersect(array_map(function ($role) { return BASE.$role; }, $this->userTypes), $user->roles)) {
            return false;
        }
        // Add the capability
        $user->add_cap('skip_moderation', true);
        // Store verification metadata
        update_user_meta($user_id, BASE . 'verification_date', current_time('mysql'));
        if ($verified_by) {
            update_user_meta($user_id, BASE . 'verified_by', $verified_by);
        }
        return true;
    }
    /**
     * Mark an artist as verified
     *
     * @param int $user_id The user to verify
     * @param int $verified_by ID of user who verified them (optional)
     *
     * @return bool Success status
     */
    public function unverifyArtist(int $user_id, int $verified_by = 0):bool
    {
        $user = get_userdata($user_id);
        // Check if user has the artist role
        if (!array_intersect(array_map(function ($role) { return BASE.$role; }, $this->userTypes), $user->roles)) {
            return false;
        }
        // Add the capability
        $user->add_cap('skip_moderation', false);
        // Store verification metadata
        update_user_meta($user_id, BASE . 'unverification_date', current_time('mysql'));
        if ($verified_by) {
            update_user_meta($user_id, BASE . 'unverified_by', $verified_by);
        }
        return true;
    }
    /**
     * Record an approval vote for an artist
     *
     * @param int $user_id User casting the approval vote
@@ -394,57 +138,6 @@
        return $this->handleVote(jvbUserRole($user_id), $vote, $request_id, $user_id, $notes);
    }
    /**
     * Complete verification - REFACTORED
     */
    protected function completeVerification(int $request_id, string $type = 'artist'): void
    {
        $tableName = $this->getTableName($type, 'requests');
        $table = CustomTable::for($tableName);
        $table->where(['id' => $request_id])->updateResults([
            'status' => 'approved',
            'approved_at' => current_time('mysql')
        ]);
        $request = $table->where(['id' => $request_id])->first();
        if ($request && $request->user_id) {
            $user = new \WP_User($request->user_id);
            $user->add_cap('skip_moderation', true);
            JVB()->notification()->addNotification(
                $request->user_id,
                'approval_granted',
                ['message' => 'Your account has been verified!']
            );
        }
        $this->cache->flush();
    }
    protected function denyVerification(int $request_id, string $type = 'artist'): void
    {
        $tableName = $this->getTableName($type, 'requests');
        $table = CustomTable::for($tableName);
        $table->where(['id' => $request_id])->updateResults([
            'status' => 'rejected',
            'rejected_at' => current_time('mysql')
        ]);
        $request = $table->where(['id' => $request_id])->first();
        if ($request && $request->user_id) {
            JVB()->notification()->addNotification(
                $request->user_id,
                'approval_denied',
                ['message' => 'Your verification request was not approved.']
            );
        }
        $this->cache->flush();
    }
    /**
     * Get verification details for a request
@@ -456,25 +149,16 @@
     */
    public function getVerificationDetails(int $requestID, string $type): array|false
    {
        $requestTable = CustomTable::for($this->getTableName($type, 'requests'));
        $voteTable = CustomTable::for($this->getTableName($type, 'votes'));
        $request = $requestTable->where(['id' => $requestID])->first(ARRAY_A);
        $request = JVB()->approvals()->getRequest($requestID, $type);
        if (!$request) {
            return false;
        }
        // Get the votes for this request
        $votes = $voteTable
            ->where(['request_id' => $request['id']])
            ->orderBy('created_at', 'ASC')
            ->getResults(ARRAY_A);
        $votes = JVB()->approvals()->getVotes($requestID, $type);
        // Join with user data for display names
        foreach ($votes as &$vote) {
            $user = get_userdata($vote['user_id']);
            $vote['approver_name'] = $user ? $user->display_name : 'Unknown';
            $vote['approver_name'] = $user ? jvbGetUsername($vote['user_id']) : 'Someone';
        }
        return [
@@ -484,139 +168,7 @@
        ];
    }
    /*************
     * Term Approvals
     ************/
    public function voteForTerm(int $user_id, int $request_id, string $vote, string $notes = ''):bool
    {
        return $this->handleVote('term', $vote, $user_id, $request_id, $notes);
    }
    /**
     * Publish an approved term
     *
     * @param object $request Approval request object
     *
     * @return boolean Success or failure
     */
    protected function makeTermLive(object $request): bool
    {
        try {
            $taxonomy = $request->taxonomy;
            $term_name = $request->name;
            $parent = $request->parent;
            $result = wp_insert_term($term_name, $taxonomy, [
                'parent' => $parent
            ]);
            if (is_wp_error($result)) {
                throw new Exception($result->get_error_message());
            }
            $term_id = $result['term_id'];
            // Update request status
            CustomTable::for($this->getTableName('term', 'requests'))
                ->where(['id' => $request->id])
                ->updateResults([
                    'status' => 'approved',
                    'created_term' => $term_id
                ]);
            $userIDs = [];
            $approvedBy = [];
            $approvers = json_decode($request->approved_by, true) ?: [];
            $requesters = json_decode($request->requested_by, true) ?: [];
            $rejectors = json_decode($request->rejected_by, true) ?: [];
            foreach (array_merge($requesters, $approvers, $rejectors) as $user_id => $info) {
                $userIDs[] = $user_id;
            }
            foreach ($approvers as $user_id => $info) {
                $approvedBy[] = $info['name'];
            }
            $approvedBy = jvbCommaList($approvedBy);
            JVB()->notification()->addNotification(
                $userIDs,
                'term_approved',
                [
                    'term_id' => $term_id,
                    'term_name' => $term_name,
                    'taxonomy' => $taxonomy,
                    'approved_by' => $approvedBy
                ]
            );
            return true;
        } catch (Exception $e) {
            $this->logError('makeTermLive', [
                'error' => $e->getMessage(),
                'request_id' => $request->id,
                'term_name' => $term_name ?? '',
                'taxonomy' => $taxonomy ?? ''
            ]);
            return false;
        }
    }
    /**
     * Reject a proposed term
     *
     * @param object $request request object
     *
     * @return boolean Success or failure
     */
    protected function makeTermUnalive(object $request): bool
    {
        try {
            // Update request status
            CustomTable::for($this->getTableName('term', 'requests'))
                ->where(['id' => $request->id])
                ->updateResults([
                    'status' => 'rejected'
                ]);
            $userIDs = [];
            $rejectedBy = [];
            $approvers = json_decode($request->approved_by, true) ?: [];
            $requesters = json_decode($request->requested_by, true) ?: [];
            $rejectors = json_decode($request->rejected_by, true) ?: [];
            foreach (array_merge($requesters, $approvers, $rejectors) as $user_id => $info) {
                $userIDs[] = $user_id;
            }
            foreach ($rejectors as $user_id => $info) {
                $rejectedBy[] = $info['name'];
            }
            $rejectedBy = jvbCommaList($rejectedBy);
            JVB()->notification()->addNotification(
                $userIDs,
                'term_rejected',
                [
                    'term_name' => $request->name,
                    'taxonomy' => $request->taxonomy,
                    'rejected_by' => $rejectedBy
                ]
            );
            return true;
        } catch (Exception $e) {
            $this->logError('makeTermUnalive', [
                'error' => $e->getMessage(),
                'request_id' => $request->id,
                'term_name' => $request->name ?? '',
                'taxonomy' => $request->taxonomy ?? ''
            ]);
            return false;
        }
    }
    /**
     * Create a new term approval request
@@ -636,80 +188,14 @@
        int $parent = 0,
        int $required_approvals = 3
    ): int|false {
        $table = CustomTable::for($this->getTableName('term', 'requests'));
        try {
            return $table->transaction(function($table) use ($user_id, $taxonomy, $name, $parent, $required_approvals) {
                // Check for existing request
                $existing = $table->where([
                    'name' => $name,
                    'taxonomy' => $taxonomy,
                    'parent' => $parent,
                    'status' => 'pending'
                ])->first();
                if ($existing) {
                    $requestedBy = json_decode($existing->requested_by, true) ?: [];
                    if (isset($requestedBy[$user_id])) {
                        return (int)$existing->id;
                    }
                    $requestedBy[$user_id] = get_userdata($user_id)->display_name;
                    $table->where(['id' => $existing->id])->updateResults([
                        'requested_by' => json_encode($requestedBy)
                    ]);
                    return (int)$existing->id;
                }
                // Create new request
                return $this->createApprovalRequest('term', [
                    'taxonomy' => $taxonomy,
                    'name' => $name,
                    'parent' => $parent ?: null,
                    'status' => 'pending',
                    'required_approvals' => $required_approvals,
                    'current_approvals' => 0,
                    'current_rejections' => 0,
                    'requested_by' => json_encode([$user_id => get_userdata($user_id)->display_name]),
                    'expires_at' => date('Y-m-d H:i:s', strtotime('+30 days')),
                ]);
            });
        } catch (Exception $e) {
            $this->logError('createTermApprovalRequest', [
                'error' => $e->getMessage(),
                'user_id' => $user_id,
                'taxonomy' => $taxonomy,
                'name' => $name
            ]);
            return false;
        }
    }
    /**
     * Clean up expired approval requests and notify admin
     *
     * @return void
     */
    public function cleanupExpiredApprovals(): void
    {
        $now = current_time('mysql');
        foreach ($this->allTypes as $type) {
            $tableName = $this->getTableName($type, 'requests');
            CustomTable::for($tableName)->query(
                "UPDATE {table}
                 SET status = 'expired'
                 WHERE status = 'pending'
                   AND expires_at < %s",
                [$now]
            );
        }
        $this->cache->flush();
        $result = JVB()->approvals()->createApproval(
            $user_id,
            $taxonomy,
            $name,
            $parent
        );
        return $result['success'];
    }
    protected function getTableName(string $type, string $suffix): string
@@ -751,6 +237,7 @@
    private function getUserApprovals(string $status = 'pending'): array
    {
        $table = CustomTable::for($this->getTableName('artist', 'requests'));
        $query = $table;
inc/rest/routes/FeedRoutes.php
@@ -338,7 +338,7 @@
    protected function getAuthorData(WP_Post $post)
    {
        $author = $post->post_author;
        $userLink = get_user_meta($author, BASE.'link', true);
        $userLink = get_user_meta($author, BASE.'profile_link', true);
        return $this->cache->remember(
            $userLink,
            function () use ($userLink, $author) {
inc/rest/routes/LoginRoutes.php
@@ -631,17 +631,17 @@
        }
        // Check if role is valid and can register
        $role_config = JVB_USER[$user_select] ?? null;
        $registrar = Registrar::getInstance($user_select);
        if (!$role_config) {
        if (!$registrar) {
            return new WP_Error('invalid_role', 'Invalid role selected.');
        }
        if (!($role_config['can_register'] ?? false)) {
        if (!($registrar->hasFeature('can_register') ?? false)) {
            return new WP_Error('role_not_allowed', 'This role cannot be selected during registration.');
        }
        return BASE . $user_select;
        return $registrar->getBased();
    }
    /**
inc/rest/routes/NotificationsRoutes.php
@@ -134,7 +134,7 @@
    public function init(): void
    {
        $this->manager = JVB()->notification();
        $this->notification_types = $this->manager->getNotificationTypes();
        $this->notification_types = $this->manager->getNotificationTypes(true);
    }
    /**
inc/rest/routes/ReferralRoutes.php
@@ -1,6 +1,7 @@
<?php
namespace JVBase\rest\routes;
use JVBase\base\Site;
use JVBase\importers\JaneAppClientImporter;
use JVBase\importers\JaneAppSalesImporter;
use JVBase\managers\CustomTable;
@@ -611,7 +612,7 @@
        // Import using JaneAppClientImporter
        $importer = new JaneAppClientImporter();
        $default_role = get_option(BASE . 'referral_role', JVB_USER);
        $default_role = get_option(BASE . 'referral_role', Site::getDefaultReferralRole());
        $options = [
            'update_existing' => true,
inc/rest/routes/TermRoutes.php
@@ -236,7 +236,7 @@
        if ($request->get_param('main_context') && in_array(jvbCheckBase(json_decode($request->get_param('main_context'), true)['context']), jvbUserTypes())) {
            $main_context = json_decode($request->get_param('main_context'), true);
            $userID = get_post_meta($main_context['id'], BASE.'link', true);
            $userID = get_post_meta($main_context['id'], BASE.'profile_link', true);
            $manager = new UserTermsManager();
            $related = $manager->fetchUserTerms($userID, $taxonomy);
inc/rest/routes/UploadRoutes.php
@@ -180,7 +180,7 @@
                            !array_key_exists('post_id', $data) &&
                            !array_key_exists('term_id', $data) &&
                            !array_key_exists('item_id', $data)) {
                            $args['post_id'] = (int)get_user_meta((int) $value, BASE.'link', true);
                            $args['post_id'] = (int)get_user_meta((int) $value, BASE.'profile_link', true);
                        }
                    }
                    break;
@@ -639,7 +639,7 @@
        } elseif (array_key_exists('term_id', $data)) {
            $meta = Meta::forTerm($data['term_id']);
        } else {
            $link = (int)get_user_meta($data['user'], BASE.'link');
            $link = (int)get_user_meta($data['user'], BASE.'profile_link');
            $meta = Meta::forPost($link);
        }
inc/ui/CRUDSkeleton.php
@@ -1,6 +1,7 @@
<?php
namespace JVBase\ui;
use JVBase\base\Site;
use JVBase\managers\UserTermsManager;
use JVBase\meta\Form;
use JVBase\registrar\Registrar;
@@ -234,7 +235,7 @@
    protected function taxConfig(string $taxonomy, string $label = ''):array
    {
        $isVerified = jvbUserIsVerified();
        $isVerified = $this->userIsVerified();
        $label = ($label === '') ? Registrar::getInstance($taxonomy)->getPlural() : $label;
        return [
            'type'      => 'taxonomy',
@@ -246,6 +247,13 @@
        ];
    }
    protected function userIsVerified():bool
    {
        $membership = Site::membership();
        return !($membership && $membership->has('member_verified')) || current_user_can('skip_moderation');
    }
    public function addSearch():self
    {
        $this->hasSearch = true;
inc/utility/Checker.php
File was deleted
inc/utility/Features.php
File was deleted
inc/utility/Validator.php
File was deleted
inc/utility/setup.php
@@ -1,9 +1,5 @@
<?php
require(JVB_DIR.'/inc/utility/Checker.php');
require(JVB_DIR.'/inc/utility/Features.php');
require(JVB_DIR.'/inc/utility/helpers.php');
require(JVB_DIR.'/inc/utility/Validator.php');
require(JVB_DIR.'/inc/utility/Image.php');
jvb.php
@@ -107,15 +107,10 @@
require(JVB_DIR.'/base/_setup.php');
if (empty(JVB_SITE)) {
    return;
}
require(JVB_DIR.'/inc/utility/setup.php');
require(JVB_DIR.'/checks.php');
require(JVB_DIR . '/inc/registry/_setup.php');
require(JVB_DIR . '/inc/registrar/_setup.php');
require(JVB_DIR . '/activate.php');
require(JVB_DIR . '/inc/helpers/all.php');
package-lock.json
Diff too large
package.json
@@ -16,6 +16,6 @@
        "start": "wp-scripts start"
    },
    "devDependencies": {
        "@wordpress/scripts": "^31.2.0"
        "@wordpress/scripts": "^19.2.4"
    }
}