From 2127b1bdd73ecd2423e443992da4b442f5a3c1a3 Mon Sep 17 00:00:00 2001
From: Jake Vanderwerf <get@jakevanderwerf.ca>
Date: Wed, 04 Feb 2026 21:19:25 +0000
Subject: [PATCH] =Major overhaul of MetaManager.php -> Meta.php and RestRouteManager.php -> Rest.php. Seems to work for JakeVan
---
assets/css/icons.css | 2
inc/managers/SchemaManager.php | 25
inc/managers/CustomTable.php | 747 +
inc/registry/ContentRegistry.php | 35
src/summary/render.php | 73
assets/css/dash.min.css | 2
inc/managers/Cache.php | 2
inc/helpers/formatting.php | 30
inc/rest/Response.php | 5
inc/rest/RateLimits.php | 98
inc/rest/routes/TermRoutes.php | 522
inc/meta/Form.php | 1733 +++
assets/js/min/form.min.js | 2
inc/managers/DashboardManager.php | 14
inc/rest/routes/ContentRoutes.php | 867 -
inc/rest/routes/FeedRoutes.php | 290
inc/meta/MetaFormOld.php | 3155 ++---
inc/rest/routes/ApprovalRoutes.php | 1191 -
inc/managers/_setup.php | 5
assets/js/concise/CRUD.js | 6
inc/helpers/crud.php | 7
inc/rest/routes/ResponseRoutes.php | 1052 -
JVBase.php | 311
build/summary/render.php | 73
inc/managers/LoginManager.php | 11
inc/integrations/Square.php | 32
inc/meta/MetaManager.php | 22
inc/ui/CRUDSkeleton.php | 58
inc/integrations/Integrations.php | 31
inc/rest/routes/ShopRoutes.php | 35
inc/rest/routes/SEORoutes.php | 78
assets/js/concise/quill.js | 64
inc/integrations/Cloudflare.php | 1
inc/rest/routes/NewsRoutes.php | 105
base/taxonomies.php | 2
inc/meta/Field.php | 51
assets/js/concise/UploadManager.js | 2
inc/managers/queue/executors/InvitationExecutor.php | 353
inc/managers/queue/Processor.php | 32
activate.php | 31
assets/css/forms.min.css | 2
inc/meta/Meta.php | 295
inc/rest/Route.php | 242
inc/rest/routes/ErrorRoutes.php | 63
inc/rest/routes/ReferralRoutes.php | 496
inc/rest/routes/ContentTermsRoutes.php | 17
inc/blocks/FAQBlock.php | 2
inc/managers/SEO/_edmonotonink.php | 48
inc/managers/queue/Storage.php | 31
inc/rest/_setup.php | 22
inc/rest/routes/UploadRoutes.php | 189
assets/js/min/populate.min.js | 2
inc/managers/SEO/SEOAdminPage.php | 8
inc/managers/SEO/SchemaOutputManager.php | 8
inc/integrations/GoogleMyBusiness.php | 26
inc/managers/queue/Result.php | 10
inc/blocks/GlossaryBlock.php | 2
inc/meta/Item.php | 95
inc/managers/queue/executors/UploadExecutor.php | 32
assets/js/concise/TaxonomySelector.js | 2
inc/managers/ScriptLoader.php | 2
inc/rest/routes/Invitations.php | 1650 --
inc/rest/routes/SettingsRoutes.php | 50
base/options.php | 2
inc/rest/PermissionHandler.php | 32
inc/rest/Rest.php | 727 +
assets/css/dash.css | 1
inc/rest/routes/MagicLinkRoutes.php | 1
inc/rest/routes/LoginRoutes.php | 1264 +-
inc/integrations/Helcim.php | 20
inc/managers/InvitationsManager.php | 737 +
inc/managers/queue/_setup.php | 1
inc/rest/routes/VoteRoutes.php | 437
inc/managers/SEO/SchemaReferenceBuilder.php | 9
inc/rest/routes/NotificationsRoutes.php | 2924 ++---
inc/helpers/ui.php | 44
inc/meta/_setup.php | 12
inc/registry/TaxonomyRegistrar.php | 8
inc/managers/RoleManager.php | 276
inc/rest/routes/OptionsRoutes.php | 46
inc/meta/Registry.php | 182
inc/managers/SEO/SchemaRegistry.php | 4
inc/rest/routes/ImporterRoutes.php | 154
inc/managers/SEO/TemplateResolver.php | 10
inc/meta/Validator.php | 375
inc/rest/routes/IntegrationsSquareRoutes.php | 129
inc/managers/SEO/SchemaFieldHelpers.php | 14
inc/blocks/TimelineBlock.php | 6
inc/managers/queue/executors/ContentExecutor.php | 17
inc/registry/UserRoleRegistrar.php | 4
inc/importers/JaneAppSalesImporter.php | 5
inc/integrations/Facebook.php | 10
inc/helpers/all.php | 2
inc/helpers/time.php | 29
assets/js/min/selector.min.js | 2
inc/rest/RegisterRoutes.php | 20
assets/js/concise/FormController.js | 57
inc/managers/DirectoryManager.php | 32
inc/managers/SEO/SchemaBuilder.php | 2
inc/registry/CheckCustomTables.php | 116
inc/rest/routes/FormRoutes.php | 111
cleanup.php | 2
inc/registry/PostTypeRegistrar.php | 4
assets/js/min/crud.min.js | 2
inc/EmbedGenerator.php | 9
assets/js/concise/PopulateForm.js | 5
inc/forms/PostSelector.php | 2
inc/helpers/forms.php | 6
inc/helpers/renderFields.php | 141
inc/meta/Storage.php | 401
inc/rest/RateLimiter.php | 1
inc/rest/routes/FavouritesRoutes.php | 5264 +++------
inc/meta/MetaTypeManager.php | 18
assets/js/min/quill.min.js | 2
inc/managers/ReferralManager.php | 16
inc/meta/Render.php | 427
inc/meta/Sanitizer.php | 76
base/membership.php | 1
inc/managers/queue/Queue.php | 7
inc/helpers/members.php | 4
inc/blocks/MenuBlock.php | 18
inc/rest/routes/QueueRoutes.php | 525
inc/rest/routes/IntegrationsRoutes.php | 251
inc/managers/SEOMetaManager.php | 88
assets/js/min/uploader.min.js | 2
inc/registry/OptionsRegistry.php | 22
templates/dashboard/sections/news.php | 5
/dev/null | 266
inc/rest/RestRouteManager.php | 3
inc/templates.php | 21
inc/blocks/FormBlock.php | 19
inc/meta/Repeater.php | 397
132 files changed, 15,933 insertions(+), 14,343 deletions(-)
diff --git a/JVBase.php b/JVBase.php
index b6b64a5..a9519c0 100644
--- a/JVBase.php
+++ b/JVBase.php
@@ -3,9 +3,10 @@
use JVBase\blocks\CustomBlocks;
use JVBase\integrations\BlueSky;
-use JVBase\managers\cache\Cache;
+use JVBase\managers\Cache;
use JVBase\managers\EmailManager;
use JVBase\managers\ErrorHandler;
+use JVBase\managers\InvitationsManager;
use JVBase\managers\LoginManager;
use JVBase\managers\MagicLinkManager;
use JVBase\managers\queue\Queue;
@@ -26,9 +27,9 @@
use JVBase\rest\routes\ContentRoutes;
use JVBase\rest\routes\TermRoutes;
use JVBase\rest\routes\UploadRoutes;
-use JVBase\rest\routes\BioRoutes;
+//use JVBase\rest\routes\BioRoutes;
use JVBase\rest\routes\SettingsRoutes;
-use JVBase\rest\routes\ShopRoutes;
+//use JVBase\rest\routes\ShopRoutes;
use JVBase\rest\routes\SEORoutes;
use JVBase\rest\routes\QueueRoutes;
use JVBase\rest\routes\ErrorRoutes;
@@ -36,13 +37,13 @@
use JVBase\rest\routes\LoginRoutes;
use JVBase\rest\routes\NewsRoutes;
use JVBase\rest\routes\ReferralRoutes;
-use JVBase\rest\routes\MagicLinkRoutes;
+//use JVBase\rest\routes\MagicLinkRoutes;
use JVBase\rest\routes\ResponseRoutes;
use JVBase\rest\routes\OptionsRoutes;
use JVBase\rest\routes\VoteRoutes;
use JVBase\rest\routes\Invitations;
use JVBase\rest\routes\ApprovalRoutes;
-use JVBase\rest\routes\AdminRoutes;
+//use JVBase\rest\routes\AdminRoutes;
use JVBase\rest\routes\IntegrationsRoutes;
use JVBase\utility\Features;
@@ -52,13 +53,13 @@
class JVB
{
- protected static JVB|null $instance = null;
- protected array $managers = [];
- protected array $content = [];
- protected array $taxonomies = [];
+ protected static JVB|null $instance = null;
+ protected array $managers = [];
+ protected array $content = [];
+ protected array $taxonomies = [];
protected array $integrations = [];
- protected array $blocks = [];
- protected array $routes = [];
+ protected array $blocks = [];
+ protected array $routes = [];
protected CustomBlocks $customBlocks;
protected array $serviceMap = [
@@ -74,44 +75,44 @@
'postmark' => 'JVBase\integrations\PostMark',
];
- public static function getInstance():JVB
- {
- if (self::$instance === null) {
- self::$instance = new self();
- }
- return self::$instance;
- }
+ public static function getInstance(): JVB
+ {
+ if (self::$instance === null) {
+ self::$instance = new self();
+ }
+ return self::$instance;
+ }
- public function __construct()
- {
+ public function __construct()
+ {
$this->customBlocks = new CustomBlocks();
- $this->managers = [
- 'errors' => new ErrorHandler(),
- 'queue' => new Queue(),
+ $this->managers = [
+ 'errors' => new ErrorHandler(),
+ 'queue' => new Queue(),
// 'dash' => new DashboardManager(),
- 'roles' => new RoleManager(),
+ 'roles' => new RoleManager(),
// 'forms' => new FormManager(),
- 'schema' => new SchemaOutputManager(),
- 'admin' => new AdminPages(),
- 'seoAdmin' => new SEOAdminPage(),
+ 'schema' => new SchemaOutputManager(),
+ 'admin' => new AdminPages(),
+ 'seoAdmin' => new SEOAdminPage(),
// 'uploads' => new UploadManager(),
- 'userTerms' => new UserTermsManager(),
- 'email' => new EmailManager(),
- ];
+ 'userTerms' => new UserTermsManager(),
+ 'email' => new EmailManager(),
+ ];
$this->routes = [
- 'login' => new LoginRoutes(),
- 'integrations' => new IntegrationsRoutes(),
- 'seo' => new SEORoutes(),
- 'queue' => new QueueRoutes(),
- 'settings' => new SettingsRoutes(),
- 'upload' => new UploadRoutes(),
- 'forms' => new FormRoutes()
+ 'login' => new LoginRoutes(),
+ 'integrations' => new IntegrationsRoutes(),
+ 'seo' => new SEORoutes(),
+ 'queue' => new QueueRoutes(),
+ 'settings' => new SettingsRoutes(),
+ 'upload' => new UploadRoutes(),
+ 'forms' => new FormRoutes()
];
if (Features::forSite()->has('magicLink')) {
- $this->routes['magicLink'] = new MagicLinkRoutes();
+// $this->routes['magicLink'] = new MagicLinkRoutes();
$this->managers['magicLink'] = new MagicLinkManager();
}
if (Features::forSite()->has('referrals')) {
@@ -123,69 +124,72 @@
$this->managers['dash'] = new DashboardManager();
}
- if (Features::hasIntegration('square')){
+ if (Features::hasIntegration('square')) {
$this->routes['square'] = new IntegrationsSquareRoutes();
}
- if (Features::forSite()->has('feed_block')) {
- $this->routes['feed'] = new FeedRoutes();
- }
- if (jvbSiteHasNotifications()) {
+ if (Features::forSite()->has('feed_block')) {
+ $this->routes['feed'] = new FeedRoutes();
+ }
+ if (jvbSiteHasNotifications()) {
$this->managers['notifications'] = new NotificationManager();
- $this->routes['notifications'] = new NotificationsRoutes();
- }
- if (Features::forSite()->has('feed_block') || jvbSiteHasDashboard()) {
- $this->routes['term'] = new TermRoutes();
- }
+ $this->routes['notifications'] = new NotificationsRoutes();
+ }
+ if (Features::forSite()->has('feed_block') || jvbSiteHasDashboard()) {
+ $this->routes['term'] = new TermRoutes();
+ }
if (Features::forSite()->has('is_directory')) {
$this->managers['directory'] = new DirectoryManager();
}
- if (jvbSiteHasDashboard()) {
- $this->routes['error'] = new ErrorRoutes();
- $this->routes['admin'] = new AdminRoutes();
- $this->routes['content']= new ContentRoutes();
- $this->routes['bio'] = new BioRoutes();
- $this->routes['shop'] = new ShopRoutes();
- $this->routes['options']= new OptionsRoutes();
- }
+ if (jvbSiteHasDashboard()) {
+ $this->routes['error'] = new ErrorRoutes();
+// $this->routes['admin'] = new AdminRoutes();
+ $this->routes['content'] = new ContentRoutes();
+// $this->routes['bio'] = new BioRoutes();
+// $this->routes['shop'] = new ShopRoutes();
+ $this->routes['options'] = new OptionsRoutes();
+ }
- if (jvbSiteHasFavourites()) {
- $this->routes['favourites'] = new FavouritesRoutes();
- }
+ if (jvbSiteHasFavourites()) {
+ $this->routes['favourites'] = new FavouritesRoutes();
+ }
- if (Features::forMembership()->has('forum')) {
- $this->routes['news'] = new NewsRoutes();
- }
- if (Features::anyContentHas('response') || Features::anyTaxonomyHas('response') || Features::anyUserHas('response')) {
- $this->routes['comments'] = new ResponseRoutes();
- }
- if (Features::anyContentHas('karma') || Features::anyTaxonomyHas('karma') || Features::anyUserHas('karma')) {
- $this->routes['vote'] = new VoteRoutes();
- }
- if (Features::anyContentHas('karma') || Features::anyTaxonomyHas('karma') || Features::anyUserHas('karma')
+ if (Features::forMembership()->has('forum')) {
+ $this->routes['news'] = new NewsRoutes();
+ }
+ if (Features::forMembership()->has('invitable')) {
+ $this->managers['invitations'] = new InvitationsManager();
+ }
+ if (Features::anyContentHas('response') || Features::anyTaxonomyHas('response') || Features::anyUserHas('response')) {
+ $this->routes['comments'] = new ResponseRoutes();
+ }
+ if (Features::anyContentHas('karma') || Features::anyTaxonomyHas('karma') || Features::anyUserHas('karma')) {
+ $this->routes['vote'] = new VoteRoutes();
+ }
+ if (Features::anyContentHas('karma') || Features::anyTaxonomyHas('karma') || Features::anyUserHas('karma')
|| Features::forMembership()->has('member_verified') ||
Features::forMembership()->has('term_approval')) {
- $this->routes['approvals'] = new ApprovalRoutes();
- }
- if (Features::forMembership()->has('can_invite')) {
- $this->routes['invites'] = new Invitations();
- }
+ $this->routes['approvals'] = new ApprovalRoutes();
+ }
+ if (Features::forMembership()->has('can_invite')) {
+ $this->routes['invites'] = new Invitations();
+ }
$this->setupIntegrations();
add_action('wp_footer', [$this, 'additionalActions']);
// $this->managers['notifications'] = new NotificationManager();
- // Register activation hook
- register_activation_hook(JVB_DIR . '/jvb.php', [$this, 'activate']);
- }
+ // Register activation hook
+ register_activation_hook(JVB_DIR . '/jvb.php', [$this, 'activate']);
+ }
- protected function setupIntegrations():void
+ protected function setupIntegrations(): void
{
- if (array_key_exists('integrations', JVB_SITE)){
- foreach (JVB_SITE['integrations'] as $service => $use){
+ if (array_key_exists('integrations', JVB_SITE)) {
+ foreach (JVB_SITE['integrations'] as $service => $use) {
if (!$use) {
continue;
}
@@ -197,73 +201,82 @@
}
}
- public function registeredContent():array
- {
- return array_merge(array_keys($this->content), array_keys($this->taxonomies));
- }
- public function dashboard():DashboardManager|false
- {
- return $this->managers['dash']??false;
- }
- public function directories():DirectoryManager|false
+ public function registeredContent(): array
{
- return $this->managers['directory']??false;
+ return array_merge(array_keys($this->content), array_keys($this->taxonomies));
}
- public function error():ErrorHandler
- {
- return $this->managers['errors'];
- }
- public function file()
- {
- return $this->managers['file'];
- }
- public function queue():Queue
- {
- return $this->managers['queue'];
- }
+ public function dashboard(): DashboardManager|false
+ {
+ return $this->managers['dash'] ?? false;
+ }
+
+ public function directories(): DirectoryManager|false
+ {
+ return $this->managers['directory'] ?? false;
+ }
+
+ public function error(): ErrorHandler
+ {
+ return $this->managers['errors'];
+ }
+
+ public function file()
+ {
+ return $this->managers['file'];
+ }
+
+ public function queue(): Queue
+ {
+ return $this->managers['queue'];
+ }
// public function forms()
// {
// return $this->managers['forms'];
// }
- public function notification():NotificationManager|false
- {
- return $this->managers['notifications']??false;
- }
- public function routes($route):mixed
- {
- if (array_key_exists($route, $this->routes)) {
- return $this->routes[$route];
- }
- return false;
- }
- public function roles():RoleManager
- {
- return $this->managers['roles'];
- }
- public function admin()
- {
- return $this->managers['admin'];
- }
+ public function notification(): NotificationManager|false
+ {
+ return $this->managers['notifications'] ?? false;
+ }
+
+ public function routes($route): mixed
+ {
+ if (array_key_exists($route, $this->routes)) {
+ return $this->routes[$route];
+ }
+ return false;
+ }
+
+ public function roles(): RoleManager
+ {
+ return $this->managers['roles'];
+ }
+
+ public function admin()
+ {
+ return $this->managers['admin'];
+ }
+
public function seoAdmin()
{
return $this->managers['seoAdmin'];
}
- public function getFields($type):array
- {
- $content = JVB_CONTENT[$type]??JVB_TAXONOMY[$type]??JVB_USER[$type]??[];
- return $content['fields']??[];
- }
- public function getContent($type):mixed
- {
- return $this->content[$type]??$this->taxonomies[$type]??$this->blocks[$type]??null;
- }
+ public function getFields($type): array
+ {
+ $content = JVB_CONTENT[$type] ?? JVB_TAXONOMY[$type] ?? JVB_USER[$type] ?? [];
+ return $content['fields'] ?? [];
+ }
- public function connect(string $service, ?int $userID = null):mixed
+ public function getContent($type): mixed
+ {
+ return $this->content[$type] ?? $this->taxonomies[$type] ?? $this->blocks[$type] ?? null;
+ }
+
+ public function connect(string $service, ?int $userID = null): mixed
{
if ($userID) {
- if (!$this->userCanConnect($service, $userID)){
+ if (!$this->userCanConnect($service, $userID)) {
return null;
}
@@ -274,40 +287,48 @@
}
return (array_key_exists($service, $this->integrations)) ? $this->integrations[$service] : null;
}
- public function userCanConnect(string $service, int $userID):bool
+
+ public function userCanConnect(string $service, int $userID): bool
{
$allowed = JVB_USER[jvbUserRole($userID)]['integrations'] ?? [];
return user_can($userID, 'manage_options') || in_array($service, $allowed);
}
- public function getAvailableServices(bool $keys = true):array {
+
+ public function getAvailableServices(bool $keys = true): array
+ {
return ($keys) ? array_keys($this->integrations) : $this->integrations;
}
- public function activate():void
- {
- // Activate roles - will be properly initialized after post types are registered
- $this->roles()->activate();
- }
+ public function activate(): void
+ {
+ // Activate roles - will be properly initialized after post types are registered
+ $this->roles()->activate();
+ }
- public function addRoute($slug, $class):void
+ public function addRoute($slug, $class): void
{
$this->routes[$slug] = $class;
}
- public function email():EmailManager
+ public function email(): EmailManager
{
return $this->managers['email'];
}
- public function referrals():ReferralManager|false
+ public function referrals(): ReferralManager|false
{
- return $this->managers['referral']??false;
+ return $this->managers['referral'] ?? false;
}
- public function magicLink():MagicLinkManager|false
+ public function magicLink(): MagicLinkManager|false
{
- return $this->managers['magicLink']??false;
+ return $this->managers['magicLink'] ?? false;
+ }
+
+ public function invitations(): InvitationsManager|false
+ {
+ return $this->managers['invitations'] ?? false;
}
public function additionalActions():void
diff --git a/activate.php b/activate.php
index 83ce7d5..88932bc 100644
--- a/activate.php
+++ b/activate.php
@@ -12,7 +12,6 @@
function jvbActivatePlugin():void
{
- ob_start();
$validator = new JVBase\utility\Validator();
$validation = $validator->validateAll();
error_log('Validation result: '.print_r($validation, true));
@@ -49,23 +48,9 @@
error_log('Adding Umami tables');
Umami::createTables();
}
+
+ JVB()->directories()->activate();
error_log('Activation done! Huzzah!');
-
- $output = ob_get_clean();
- if ( $output ) {
- // Grab a backtrace to see what caused the output
- $trace = debug_backtrace( DEBUG_BACKTRACE_IGNORE_ARGS );
- $formatted = [];
- foreach ( $trace as $step ) {
- if ( isset( $step['file'], $step['line'] ) ) {
- $formatted[] = $step['file'] . ':' . $step['line'];
- }
- }
-
- error_log( "⚠️ Plugin activation produced unexpected output (" . strlen( $output ) . " chars)" );
- error_log( "Output: " . trim( $output ) );
- error_log( "Backtrace: " . implode( ' <- ', $formatted ) );
- }
}
function jvbAddAdminCaps()
@@ -218,6 +203,7 @@
ARRAY_FILTER_USE_KEY
);
foreach ($options as $key => $value) {
+ error_log('Deleting Option'.$key);
delete_option($key);
}
}
@@ -240,14 +226,13 @@
$pages = new WP_Query([
'post_type' => BASE.'directory',
'posts_per_page' => -1,
- 'post_status' => 'any',
+ 'post_status' => ['publish', 'draft','trash'],
'fields' => 'ids'
]);
- if ($pages->have_posts()) {
- foreach ($pages->posts as $ID) {
- wp_delete_post($ID, true);
- }
- }
+ foreach ($pages->posts as $ID) {
+ wp_delete_post($ID, true);
+ }
+
}
function jvbClearSchedules()
{
diff --git a/assets/css/dash.css b/assets/css/dash.css
index d3d300f..e69de29 100644
--- a/assets/css/dash.css
+++ b/assets/css/dash.css
@@ -1 +0,0 @@
-.icon-git-merge{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0yMDgsMTE0YTMwLDMwLDAsMCwwLTI5LjIxLDIzLjE5bC00NC02LjI4YTEwLDEwLDAsMCwxLTYuMTgtMy4zOUw5MS4xOCw4My44M0EzMCwzMCwwLDEsMCw3NCw4NS40djg1LjJhMzAsMzAsMCwxLDAsMTIsMFY5Ni4yMmwzMy41MiwzOS4xMWEyMiwyMiwwLDAsMCwxMy42LDcuNDZsNDUuMzUsNi40OEEzMCwzMCwwLDEsMCwyMDgsMTE0Wk02Miw1NkExOCwxOCwwLDEsMSw4MCw3NCwxOCwxOCwwLDAsMSw2Miw1NlpNOTgsMjAwYTE4LDE4LDAsMSwxLTE4LTE4QTE4LDE4LDAsMCwxLDk4LDIwMFptMTEwLTM4YTE4LDE4LDAsMSwxLDE4LTE4QTE4LDE4LDAsMCwxLDIwOCwxNjJaIi8+PC9zdmc+');}.icon-arrow-counter-clockwise{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0yMjIsMTI4YTk0LDk0LDAsMCwxLTkyLjc0LDk0SDEyOGE5My40Myw5My40MywwLDAsMS02NC41LTI1LjY1LDYsNiwwLDEsMSw4LjI0LTguNzJBODIsODIsMCwxLDAsNzAsNzBsLS4xOS4xOUwzOS40NCw5OEg3MmE2LDYsMCwwLDEsMCwxMkgyNGE2LDYsMCwwLDEtNi02VjU2YTYsNiwwLDAsMSwxMiwwVjkwLjM0TDYxLjYzLDYxLjRBOTQsOTQsMCwwLDEsMjIyLDEyOFoiLz48L3N2Zz4=');}.icon-check{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0yMjguMjQsNzYuMjRsLTEyOCwxMjhhNiw2LDAsMCwxLTguNDgsMGwtNTYtNTZhNiw2LDAsMCwxLDguNDgtOC40OEw5NiwxOTEuNTEsMjE5Ljc2LDY3Ljc2YTYsNiwwLDAsMSw4LjQ4LDguNDhaIi8+PC9zdmc+');}.icon-squares-four{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0xMDQsNDJINTZBMTQsMTQsMCwwLDAsNDIsNTZ2NDhhMTQsMTQsMCwwLDAsMTQsMTRoNDhhMTQsMTQsMCwwLDAsMTQtMTRWNTZBMTQsMTQsMCwwLDAsMTA0LDQyWm0yLDYyYTIsMiwwLDAsMS0yLDJINTZhMiwyLDAsMCwxLTItMlY1NmEyLDIsMCwwLDEsMi0yaDQ4YTIsMiwwLDAsMSwyLDJabTk0LTYySDE1MmExNCwxNCwwLDAsMC0xNCwxNHY0OGExNCwxNCwwLDAsMCwxNCwxNGg0OGExNCwxNCwwLDAsMCwxNC0xNFY1NkExNCwxNCwwLDAsMCwyMDAsNDJabTIsNjJhMiwyLDAsMCwxLTIsMkgxNTJhMiwyLDAsMCwxLTItMlY1NmEyLDIsMCwwLDEsMi0yaDQ4YTIsMiwwLDAsMSwyLDJabS05OCwzNEg1NmExNCwxNCwwLDAsMC0xNCwxNHY0OGExNCwxNCwwLDAsMCwxNCwxNGg0OGExNCwxNCwwLDAsMCwxNC0xNFYxNTJBMTQsMTQsMCwwLDAsMTA0LDEzOFptMiw2MmEyLDIsMCwwLDEtMiwySDU2YTIsMiwwLDAsMS0yLTJWMTUyYTIsMiwwLDAsMSwyLTJoNDhhMiwyLDAsMCwxLDIsMlptOTQtNjJIMTUyYTE0LDE0LDAsMCwwLTE0LDE0djQ4YTE0LDE0LDAsMCwwLDE0LDE0aDQ4YTE0LDE0LDAsMCwwLDE0LTE0VjE1MkExNCwxNCwwLDAsMCwyMDAsMTM4Wm0yLDYyYTIsMiwwLDAsMS0yLDJIMTUyYTIsMiwwLDAsMS0yLTJWMTUyYTIsMiwwLDAsMSwyLTJoNDhhMiwyLDAsMCwxLDIsMloiLz48L3N2Zz4=');}.icon-rows{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0yMDgsMTM4SDQ4YTE0LDE0LDAsMCwwLTE0LDE0djQwYTE0LDE0LDAsMCwwLDE0LDE0SDIwOGExNCwxNCwwLDAsMCwxNC0xNFYxNTJBMTQsMTQsMCwwLDAsMjA4LDEzOFptMiw1NGEyLDIsMCwwLDEtMiwySDQ4YTIsMiwwLDAsMS0yLTJWMTUyYTIsMiwwLDAsMSwyLTJIMjA4YTIsMiwwLDAsMSwyLDJaTTIwOCw1MEg0OEExNCwxNCwwLDAsMCwzNCw2NHY0MGExNCwxNCwwLDAsMCwxNCwxNEgyMDhhMTQsMTQsMCwwLDAsMTQtMTRWNjRBMTQsMTQsMCwwLDAsMjA4LDUwWm0yLDU0YTIsMiwwLDAsMS0yLDJINDhhMiwyLDAsMCwxLTItMlY2NGEyLDIsMCwwLDEsMi0ySDIwOGEyLDIsMCwwLDEsMiwyWiIvPjwvc3ZnPg==');}.icon-table{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0yMjQsNTBIMzJhNiw2LDAsMCwwLTYsNlYxOTJhMTQsMTQsMCwwLDAsMTQsMTRIMjE2YTE0LDE0LDAsMCwwLDE0LTE0VjU2QTYsNiwwLDAsMCwyMjQsNTBaTTM4LDExMEg4MnYzNkgzOFptNTYsMEgyMTh2MzZIOTRaTTIxOCw2MlY5OEgzOFY2MlpNMzgsMTkyVjE1OEg4MnYzNkg0MEEyLDIsMCwwLDEsMzgsMTkyWm0xNzgsMkg5NFYxNThIMjE4djM0QTIsMiwwLDAsMSwyMTYsMTk0WiIvPjwvc3ZnPg==');}.icon-infinity{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0yNDYsMTI4YTU0LDU0LDAsMCwxLTkyLjE4LDM4LjE4LDMuMDcsMy4wNywwLDAsMS0uMjUtLjI2bC02MC02Ny43NGE0Miw0MiwwLDEsMCwwLDU5LjY0bDguNTctOS42N2E2LDYsMCwxLDEsOSw4bC04LjY5LDkuODFhMy4wNywzLjA3LDAsMCwxLS4yNS4yNiw1NCw1NCwwLDEsMSwwLTc2LjM2LDMuMDcsMy4wNywwLDAsMSwuMjUuMjZsNjAsNjcuNzRhNDIsNDIsMCwxLDAsMC01OS42NGwtOC41Nyw5LjY3YTYsNiwwLDEsMS05LThsOC42OS05LjgxYTMuMDcsMy4wNywwLDAsMSwuMjUtLjI2QTU0LDU0LDAsMCwxLDI0NiwxMjhaIi8+PC9zdmc+');}.icon-eye{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0yNDUuNDgsMTI1LjU3Yy0uMzQtLjc4LTguNjYtMTkuMjMtMjcuMjQtMzcuODFDMjAxLDcwLjU0LDE3MS4zOCw1MCwxMjgsNTBTNTUsNzAuNTQsMzcuNzYsODcuNzZjLTE4LjU4LDE4LjU4LTI2LjksMzctMjcuMjQsMzcuODFhNiw2LDAsMCwwLDAsNC44OGMuMzQuNzcsOC42NiwxOS4yMiwyNy4yNCwzNy44QzU1LDE4NS40Nyw4NC42MiwyMDYsMTI4LDIwNnM3My0yMC41Myw5MC4yNC0zNy43NWMxOC41OC0xOC41OCwyNi45LTM3LDI3LjI0LTM3LjhBNiw2LDAsMCwwLDI0NS40OCwxMjUuNTdaTTEyOCwxOTRjLTMxLjM4LDAtNTguNzgtMTEuNDItODEuNDUtMzMuOTNBMTM0Ljc3LDEzNC43NywwLDAsMSwyMi42OSwxMjgsMTM0LjU2LDEzNC41NiwwLDAsMSw0Ni41NSw5NS45NEM2OS4yMiw3My40Miw5Ni42Miw2MiwxMjgsNjJzNTguNzgsMTEuNDIsODEuNDUsMzMuOTRBMTM0LjU2LDEzNC41NiwwLDAsMSwyMzMuMzEsMTI4QzIyNi45NCwxNDAuMjEsMTk1LDE5NCwxMjgsMTk0Wm0wLTExMmE0Niw0NiwwLDEsMCw0Niw0NkE0Ni4wNiw0Ni4wNiwwLDAsMCwxMjgsODJabTAsODBhMzQsMzQsMCwxLDEsMzQtMzRBMzQsMzQsMCwwLDEsMTI4LDE2MloiLz48L3N2Zz4=');}.icon-eye-slash{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik01Mi40NCwzNkE2LDYsMCwwLDAsNDMuNTYsNDRMNjQuNDQsNjdjLTM3LjI4LDIxLjktNTMuMjMsNTctNTMuOTIsNTguNTdhNiw2LDAsMCwwLDAsNC44OGMuMzQuNzcsOC42NiwxOS4yMiwyNy4yNCwzNy44QzU1LDE4NS40Nyw4NC42MiwyMDYsMTI4LDIwNmExMjQuOTEsMTI0LjkxLDAsMCwwLDUyLjU3LTExLjI1bDIzLDI1LjI5YTYsNiwwLDAsMCw4Ljg4LTguMDhabTQ4LjYyLDcxLjMyLDQ1LDQ5LjUyYTM0LDM0LDAsMCwxLTQ1LTQ5LjUyWk0xMjgsMTk0Yy0zMS4zOCwwLTU4Ljc4LTExLjQyLTgxLjQ1LTMzLjkzQTEzNC41NywxMzQuNTcsMCwwLDEsMjIuNjksMTI4YzQuMjktOC4yLDIwLjEtMzUuMTgsNTAtNTEuOTFMOTIuODksOTguM2E0Niw0NiwwLDAsMCw2MS4zNSw2Ny40OGwxNy44MSwxOS42QTExMy40NywxMTMuNDcsMCwwLDEsMTI4LDE5NFptNi40LTk5LjRhNiw2LDAsMCwxLDIuMjUtMTEuNzksNDYuMTcsNDYuMTcsMCwwLDEsMzcuMTUsNDAuODcsNiw2LDAsMCwxLTUuNDIsNi41M2wtLjU2LDBhNiw2LDAsMCwxLTYtNS40NUEzNC4xLDM0LjEsMCwwLDAsMTM0LjQsOTQuNlptMTExLjA4LDM1Ljg1Yy0uNDEuOTItMTAuMzcsMjMtMzIuODYsNDMuMTJhNiw2LDAsMSwxLTgtOC45NEExMzQuMDcsMTM0LjA3LDAsMCwwLDIzMy4zMSwxMjhhMTM0LjY3LDEzNC42NywwLDAsMC0yMy44Ni0zMi4wN0MxODYuNzgsNzMuNDIsMTU5LjM4LDYyLDEyOCw2MmExMjAuMTksMTIwLjE5LDAsMCwwLTE5LjY5LDEuNiw2LDYsMCwxLDEtMi0xMS44M0ExMzEuMTIsMTMxLjEyLDAsMCwxLDEyOCw1MGM0My4zOCwwLDczLDIwLjU0LDkwLjI0LDM3Ljc2LDE4LjU4LDE4LjU4LDI2LjksMzcsMjcuMjQsMzcuODFBNiw2LDAsMCwxLDI0NS40OCwxMzAuNDVaIi8+PC9zdmc+');}.icon-columns{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0xMDQsMzRINjRBMTQsMTQsMCwwLDAsNTAsNDhWMjA4YTE0LDE0LDAsMCwwLDE0LDE0aDQwYTE0LDE0LDAsMCwwLDE0LTE0VjQ4QTE0LDE0LDAsMCwwLDEwNCwzNFptMiwxNzRhMiwyLDAsMCwxLTIsMkg2NGEyLDIsMCwwLDEtMi0yVjQ4YTIsMiwwLDAsMSwyLTJoNDBhMiwyLDAsMCwxLDIsMlpNMTkyLDM0SDE1MmExNCwxNCwwLDAsMC0xNCwxNFYyMDhhMTQsMTQsMCwwLDAsMTQsMTRoNDBhMTQsMTQsMCwwLDAsMTQtMTRWNDhBMTQsMTQsMCwwLDAsMTkyLDM0Wm0yLDE3NGEyLDIsMCwwLDEtMiwySDE1MmEyLDIsMCwwLDEtMi0yVjQ4YTIsMiwwLDAsMSwyLTJoNDBhMiwyLDAsMCwxLDIsMloiLz48L3N2Zz4=');}.icon-caret-double-down{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0yMTIuMjQsMTMxLjc2YTYsNiwwLDAsMSwwLDguNDhsLTgwLDgwYTYsNiwwLDAsMS04LjQ4LDBsLTgwLTgwYTYsNiwwLDAsMSw4LjQ4LTguNDhMMTI4LDIwNy41MWw3NS43Ni03NS43NUE2LDYsMCwwLDEsMjEyLjI0LDEzMS43NlptLTg4LjQ4LDguNDhhNiw2LDAsMCwwLDguNDgsMGw4MC04MGE2LDYsMCwwLDAtOC40OC04LjQ4TDEyOCwxMjcuNTEsNTIuMjQsNTEuNzZhNiw2LDAsMCwwLTguNDgsOC40OFoiLz48L3N2Zz4=');}.icon-caret-double-right{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0xNDAuMjQsMTMyLjI0bC04MCw4MGE2LDYsMCwwLDEtOC40OC04LjQ4TDEyNy41MSwxMjgsNTEuNzYsNTIuMjRhNiw2LDAsMCwxLDguNDgtOC40OGw4MCw4MEE2LDYsMCwwLDEsMTQwLjI0LDEzMi4yNFptODAtOC40OC04MC04MGE2LDYsMCwwLDAtOC40OCw4LjQ4TDIwNy41MSwxMjhsLTc1Ljc1LDc1Ljc2YTYsNiwwLDEsMCw4LjQ4LDguNDhsODAtODBBNiw2LDAsMCwwLDIyMC4yNCwxMjMuNzZaIi8+PC9zdmc+');}.icon-door{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0yMzIsMjE4SDIwNlY0MGExNCwxNCwwLDAsMC0xNC0xNEg2NEExNCwxNCwwLDAsMCw1MCw0MFYyMThIMjRhNiw2LDAsMCwwLDAsMTJIMjMyYTYsNiwwLDAsMCwwLTEyWk02Miw0MGEyLDIsMCwwLDEsMi0ySDE5MmEyLDIsMCwwLDEsMiwyVjIxOEg2MlptMTA0LDkyYTEwLDEwLDAsMSwxLTEwLTEwQTEwLDEwLDAsMCwxLDE2NiwxMzJaIi8+PC9zdmc+');}.icon-book-bookmark{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0yMDgsMjZINzJBMzAsMzAsMCwwLDAsNDIsNTZWMjI0YTYsNiwwLDAsMCw2LDZIMTkyYTYsNiwwLDAsMCwwLTEySDU0di0yYTE4LDE4LDAsMCwxLDE4LTE4SDIwOGE2LDYsMCwwLDAsNi02VjMyQTYsNiwwLDAsMCwyMDgsMjZaTTExOCwzOGg1MnY3OEwxNDcuNTksOTkuMmE2LDYsMCwwLDAtNy4yLDBMMTE4LDExNlptODQsMTQ4SDcyYTI5Ljg3LDI5Ljg3LDAsMCwwLTE4LDZWNTZBMTgsMTgsMCwwLDEsNzIsMzhoMzR2OTBhNiw2LDAsMCwwLDkuNiw0LjhMMTQ0LDExMS41bDI4LjQxLDIxLjNBNiw2LDAsMCwwLDE4MiwxMjhWMzhoMjBaIi8+PC9zdmc+');}.icon-faders{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0xMzQsMTIwdjk2YTYsNiwwLDAsMS0xMiwwVjEyMGE2LDYsMCwwLDEsMTIsMFptNjYsNzRhNiw2LDAsMCwwLTYsNnYxNmE2LDYsMCwwLDAsMTIsMFYyMDBBNiw2LDAsMCwwLDIwMCwxOTRabTI0LTMySDIwNlY0MGE2LDYsMCwwLDAtMTIsMFYxNjJIMTc2YTYsNiwwLDAsMCwwLDEyaDQ4YTYsNiwwLDAsMCwwLTEyWk01NiwxNjJhNiw2LDAsMCwwLTYsNnY0OGE2LDYsMCwwLDAsMTIsMFYxNjhBNiw2LDAsMCwwLDU2LDE2MlptMjQtMzJINjJWNDBhNiw2LDAsMCwwLTEyLDB2OTBIMzJhNiw2LDAsMCwwLDAsMTJIODBhNiw2LDAsMCwwLDAtMTJabTcyLTQ4SDEzNFY0MGE2LDYsMCwwLDAtMTIsMFY4MkgxMDRhNiw2LDAsMCwwLDAsMTJoNDhhNiw2LDAsMCwwLDAtMTJaIi8+PC9zdmc+');}.icon-robot{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0yMDAsNTBIMTM0VjE2YTYsNiwwLDAsMC0xMiwwVjUwSDU2QTMwLDMwLDAsMCwwLDI2LDgwVjE5MmEzMCwzMCwwLDAsMCwzMCwzMEgyMDBhMzAsMzAsMCwwLDAsMzAtMzBWODBBMzAsMzAsMCwwLDAsMjAwLDUwWm0xOCwxNDJhMTgsMTgsMCwwLDEtMTgsMThINTZhMTgsMTgsMCwwLDEtMTgtMThWODBBMTgsMTgsMCwwLDEsNTYsNjJIMjAwYTE4LDE4LDAsMCwxLDE4LDE4Wk03NCwxMDhhMTAsMTAsMCwxLDEsMTAsMTBBMTAsMTAsMCwwLDEsNzQsMTA4Wm04OCwwYTEwLDEwLDAsMSwxLDEwLDEwQTEwLDEwLDAsMCwxLDE2MiwxMDhabTIsMzBIOTJhMjYsMjYsMCwwLDAsMCw1Mmg3MmEyNiwyNiwwLDAsMCwwLTUyWm0tMjIsMTJ2MjhIMTE0VjE1MFpNNzgsMTY0YTE0LDE0LDAsMCwxLDE0LTE0aDEwdjI4SDkyQTE0LDE0LDAsMCwxLDc4LDE2NFptODYsMTRIMTU0VjE1MGgxMGExNCwxNCwwLDAsMSwwLDI4WiIvPjwvc3ZnPg==');}.icon-plugs-connected{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0yMzYuMjQsMTkuNzZhNiw2LDAsMCwwLTguNDgsMEwxNzMuOTQsNzMuNTdsLTYuNzktNi43OGEzMCwzMCwwLDAsMC00Mi40MiwwTDEwMCw5MS41MWwtNy43Ni03Ljc1YTYsNiwwLDAsMC04LjQ4LDguNDhMOTEuNTEsMTAwLDY2Ljc5LDEyNC43M2EzMCwzMCwwLDAsMCwwLDQyLjQybDYuNzgsNi43OUwxOS43NiwyMjcuNzZhNiw2LDAsMSwwLDguNDgsOC40OGw1My44Mi01My44MSw2Ljc5LDYuNzhhMzAsMzAsMCwwLDAsNDIuNDIsMEwxNTYsMTY0LjQ5bDcuNzYsNy43NWE2LDYsMCwwLDAsOC40OC04LjQ4TDE2NC40OSwxNTZsMjQuNzItMjQuNzNhMzAsMzAsMCwwLDAsMC00Mi40MmwtNi43OC02Ljc5LDUzLjgxLTUzLjgyQTYsNiwwLDAsMCwyMzYuMjQsMTkuNzZabS0xMTMuNDUsMTYxYTE4LDE4LDAsMCwxLTI1LjQ2LDBMNzUuMjcsMTU4LjY3YTE4LDE4LDAsMCwxLDAtMjUuNDZMMTAwLDEwOC40OSwxNDcuNTEsMTU2Wm01Ny45NC01Ny45NEwxNTYsMTQ3LjUxLDEwOC40OSwxMDBsMjQuNzItMjQuNzNhMTgsMTgsMCwwLDEsMjUuNDYsMGwyMi4wNiwyMi4wNmExOCwxOCwwLDAsMSwwLDI1LjQ2Wk05MC40MywzNC4yM2E2LDYsMCwwLDEsMTEuMTQtNC40Nmw4LDIwYTYsNiwwLDEsMS0xMS4xNCw0LjQ2Wm0tNjQsNTkuNTRhNiw2LDAsMCwxLDcuOC0zLjM0bDIwLDhhNiw2LDAsMSwxLTQuNDYsMTEuMTRsLTIwLThBNiw2LDAsMCwxLDI2LjQzLDkzLjc3Wm0yMDMuMTQsNjguNDZhNiw2LDAsMCwxLTcuOCwzLjM0bC0yMC04YTYsNiwwLDAsMSw0LjQ2LTExLjE0bDIwLDhBNiw2LDAsMCwxLDIyOS41NywxNjIuMjNabS02NCw1OS41NGE2LDYsMCwxLDEtMTEuMTQsNC40NmwtOC0yMGE2LDYsMCwwLDEsMTEuMTQtNC40NloiLz48L3N2Zz4=');}.icon-user-circle{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0xMjgsMjZBMTAyLDEwMiwwLDEsMCwyMzAsMTI4LDEwMi4xMiwxMDIuMTIsMCwwLDAsMTI4LDI2Wk03MS40NCwxOThhNjYsNjYsMCwwLDEsMTEzLjEyLDAsODkuOCw4OS44LDAsMCwxLTExMy4xMiwwWk05NCwxMjBhMzQsMzQsMCwxLDEsMzQsMzRBMzQsMzQsMCwwLDEsOTQsMTIwWm05OS41MSw2OS42NGE3Ny41Myw3Ny41MywwLDAsMC00MC0zMS4zOCw0Niw0NiwwLDEsMC01MSwwLDc3LjUzLDc3LjUzLDAsMCwwLTQwLDMxLjM4LDkwLDkwLDAsMSwxLDEzMSwwWiIvPjwvc3ZnPg==');}.icon-password{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik00Niw1NlYyMDBhNiw2LDAsMCwxLTEyLDBWNTZhNiw2LDAsMCwxLDEyLDBabTk0LjU4LDU2LjQxTDExOCwxMTkuNzRWOTZhNiw2LDAsMCwwLTEyLDB2MjMuNzRsLTIyLjU4LTcuMzNhNiw2LDAsMSwwLTMuNzEsMTEuNDFsMjIuNTgsNy4zMy0xNCwxOS4yMWE2LDYsMCwxLDAsOS43LDcuMDZsMTQtMTkuMjEsMTQsMTkuMjFhNiw2LDAsMCwwLDkuNy03LjA2bC0xNC0xOS4yMSwyMi41OC03LjMzYTYsNiwwLDEsMC0zLjcxLTExLjQxWm0xMDMuNTYsMy44NWE2LDYsMCwwLDAtNy41Ni0zLjg1TDIxNCwxMTkuNzRWOTZhNiw2LDAsMCwwLTEyLDB2MjMuNzRsLTIyLjU4LTcuMzNhNiw2LDAsMSwwLTMuNzEsMTEuNDFsMjIuNTgsNy4zMy0xMy45NSwxOS4yMWE2LDYsMCwxLDAsOS43LDcuMDZsMTQtMTkuMjEsMTQsMTkuMjFhNiw2LDAsMCwwLDkuNy03LjA2bC0xMy45NS0xOS4yMSwyMi41OC03LjMzQTYsNiwwLDAsMCwyNDQuMTQsMTE2LjI2WiIvPjwvc3ZnPg==');}
\ No newline at end of file
diff --git a/assets/css/dash.min.css b/assets/css/dash.min.css
index eccad6b..d7944c4 100644
--- a/assets/css/dash.min.css
+++ b/assets/css/dash.min.css
@@ -1 +1 @@
-:target{outline:0!important;padding:0!important}.dashboard .qtoggle{left:0;bottom:0}.dashboard>header{justify-content:flex-end}.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:85vh;top:5vh}.tab-content h2{display:none}.group-fields.hours .group-fields,.group-fields.hours .group-fields .field{display:flex;justify-content:space-between;align-items:center}.group-fields.hours .group-fields{padding:1rem .5rem;gap:1rem}.group-fields.hours .group-fields:nth-of-type(2n+1){background-color:var(--base)}.group-fields.hours .group-fields .field{margin:0}.group-fields.hours .true-false{flex:1}.group-fields.hours .time{position:relative}.group-fields.hours .time label{margin:0;font-size:var(--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)}
\ No newline at end of file
+:target{outline:0!important;padding:0!important}.dashboard .qtoggle{left:0;bottom:0}.dashboard>header{justify-content:flex-end}.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:85vh;top:5vh}.tab-content h2{display:none}.group-fields.hours .group-fields,.group-fields.hours .group-fields .field{display:flex;justify-content:space-between;align-items:center}.group-fields.hours .group-fields{padding:1rem .5rem;gap:1rem}.group-fields.hours .group-fields:nth-of-type(2n+1){background-color:var(--base)}.group-fields.hours .group-fields .field{margin:0}.group-fields.hours .true-false{flex:1}.group-fields.hours .time{position:relative}.group-fields.hours .time label{margin:0;font-size:var(--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}
\ No newline at end of file
diff --git a/assets/css/forms.min.css b/assets/css/forms.min.css
index 388b49e..8431cc5 100644
--- a/assets/css/forms.min.css
+++ b/assets/css/forms.min.css
@@ -1 +1 @@
-input:is([type=date],[type=number],[type=text],[type=url],[type=email],[type=tel],[type=password],[type=search],[type=datetime-local],[type=time]),textarea{font-family:var(--body);font-size:var(--txt-medium);color:var(--contrast);padding:var(--p-y) var(--p-x);border-radius:var(--radius);background-color:var(--base);outline:0;border:1px solid var(--base-100);border-bottom:2px solid var(--contrast-200);width:100%;max-width:100%;margin:0 4px}input:is([type=date],[type=number],[type=text],[type=url],[type=email],[type=tel],[type=password],[type=search],[type=datetime-local],[type=time]):focus,textarea:focus{outline:var(--action-50);background-color:var(--base-100);color:var(--contrast)}input::placeholder,textarea::placeholder{font-family:var(--body);color:var(--base-200)}@media (min-width:768px){:root{--p-y:1rem}}select{background:var(--base);border:2px solid var(--base-100);border-radius:var(--radius);color:var(--contrast);cursor:pointer;font-family:var(--body);font-size:var(--txt-small);padding:.5rem 1rem;width:100%}select:disabled{background-color:var(--base-50);border-color:var(--base-100);color:var(--base-200);cursor:not-allowed}select option{background:var(--base);color:var(--contrast);padding:.5rem}select option:active,select option:checked,select option:focus,select option:hover{background:var(--action-0);color:var(--base);box-shadow:0 0 0 100px var(--action-0) inset}select option:checked{background:var(--action-0) linear-gradient(0deg,var(--action-0) 0,var(--action-0) 100%);color:var(--base)}select:hover{border-color:var(--action-0)}select:focus{border-color:var(--action-0)}input[type=search]:focus+.clear-search{opacity:1;cursor:pointer}.search-container .clear-search{opacity:0;cursor:default}.search-container .icon.search{padding:4px 8px;color:var(--contrast-200);--w:3rem}input[type=search]::-moz-search-clear-button,input[type=search]::-ms-clear,input[type=search]::-ms-reveal,input[type=search]::search-cancel-button{-webkit-appearance:none;-moz-appearance:none;appearance:none;display:none;visibility:hidden}input[type=search]::-webkit-search-cancel-button,input[type=search]::-webkit-search-decoration,input[type=search]::-webkit-search-results-button,input[type=search]::-webkit-search-results-decoration{-webkit-appearance:none}input[type=url]{background:var(--linkIcon);background-position:.5em;background-size:1em;background-repeat:no-repeat;padding-left:2em}.integration .label,label{text-transform:uppercase;font-weight:700;margin-bottom:.5rem;display:block}.field{margin:2rem 0;position:relative}.field:has(.has-tooltip) label{margin-left:2rem}legend{padding:0 1rem}.date-wrapper{position:relative;display:inline-block}input[type=date]{padding:8px 36px 8px 8px;border-radius:4px}input[type=date]::-webkit-calendar-picker-indicator{opacity:0;width:100%;height:100%;position:absolute;top:0;left:0;cursor:pointer}input[type=date]+.icon{--w:20px;position:absolute;right:10px;top:50%;transform:translateY(-50%);pointer-events:none}input:is([type=time],[type=datetime-local],[type=date]){padding:.5rem;border:1px solid var(--contrast-200);border-radius:4px;font-size:14px;min-width:180px;background:var(--base);color:var(--contrast);cursor:pointer}.date-wrapper input[type=date]:focus,.datetime-wrapper input[type=datetime-local]:focus,.field-input-wrapper input:is([type=time],[type=datetime-local],[type=date]):focus,.time-wrapper input[type=time]:focus{border-color:var(--action-0);box-shadow:0 0 0 2px rgba(var(--action-rgb),.1)}.date-wrapper .icon,.datetime-wrapper .icon,.field-input-wrapper .icon,.time-wrapper .icon{width:18px;height:18px;background-color:var(--contrast);opacity:.7}[type=checkbox],[type=radio],input.ch{position:absolute;opacity:0;left:-200vw}[type=checkbox]+label,[type=radio]+label,input.ch+label{position:relative;cursor:pointer}[type=checkbox]+label:hover,[type=radio]+label:hover{color:var(--action-0)}[type=checkbox]+label::after,[type=checkbox]+label::before,[type=radio]+label::after,[type=radio]+label::before,input.ch+label::after,input.ch+label::before{content:'';position:absolute;top:50%}[type=checkbox]+label::after,[type=radio]+label::after,input.ch+label::after{left:5px;transform:translateY(-70%) rotate(45deg);width:5px;height:10px;border:solid var(--light-0);border-width:0 2px 2px 0;display:none}[type=checkbox]+label::before,[type=radio]+label::before,input.ch+label::before{left:0;transform:translateY(-50%);width:1rem;height:1rem;border:2px solid var(--contrast-200);background-color:var(--base);border-radius:var(--radius)}[type=checkbox]:hover+label::before,[type=radio]:hover+label::before,input.ch:hover+label::before{border-color:var(--action-200)}[type=checkbox]:checked+label::before,[type=radio]:checked+label::before,input.ch:checked+label::before{background-color:var(--action-0);border-color:var(--action-100)}[type=radio]:checked+label::before{border-radius:50%}[type=checkbox]:checked+label::after,input.ch:checked+label::after{display:block;left:5px;top:50%;transform:translateY(-70%) rotate(45deg);width:.35rem;height:.66rem;border:solid var(--light-0);border-width:0 2px 2px 0}[type=checkbox]:disabled+label,[type=radio]:disabled+label,input.ch:disabled+label{cursor:not-allowed;background-color:var(--base-50);color:var(--base-200);border-color:var(--base-200)}[type=checkbox]:disabled+label:hover,[type=radio]:disabled+label:hover,input.ch:disabled+label:hover{background-color:var(--base-50);color:var(--base-200);border-color:var(--base-200)}[type=checkbox]:disabled+label::before,[type=radio]:disabled+label::before,input.ch:disabled+label::before{border-color:var(--base-200)}[type=checkbox]:not(.btn)+label,[type=radio]:not(.btn)+label,input.ch+label{flex:1;padding-left:2rem;transform-origin:top center;will-change:transform}.btn+label::after,.btn+label::before{display:none}.btn+label{--w:1.2em;border:1px solid var(--base-200);border-radius:var(--radius);min-width:2rem;min-height:2rem;margin:0;display:flex;justify-content:center;align-items:center;flex-wrap:nowrap;gap:.5rem;color:var(--contrast-200);opacity:.8}.radio-options.status label{padding:0 .5rem}.btn:checked+label{border-color:var(--contrast);color:var(--contrast);opacity:1}.btn+label:hover{color:var(--action-50);border-color:var(--action-50)}.btn[hidden]+label,input[hidden]+label{display:none!important}.checkbox-options{--gap:.5rem 2rem}.checkbox-options label{flex:unset!important}.radio-options{--gap:.125rem .5rem}.radio-options input:not(.ch)+label::before{display:none!important}.radio-options input:not(.ch)+label{flex:unset!important;padding:.25rem!important;border-radius:4px;border:1px solid var(--base-100);color:var(--contrast-200);font-weight:400;text-align:center}.radio-options input:not(.ch)+label:hover,.radio-options input:not(.ch):checked+label{border-color:var(--action-0);color:var(--action-0)}.quantity{margin:0;display:inline-flex;width:fit-content;align-items:center;justify-content:center;border:1px solid transparent;border-radius:4px;position:relative}.quantity:focus-within{border-color:var(--action-0)}.quantity label{margin:0;font-size:var(--txt-small)}.quantity button{background:var(--base);padding:0;width:var(--chip_);height:var(--chip_);min-height:0;z-index:0;position:relative;border:1px solid var(--base-200);color:var(--contrast-200)}.quantity button:hover:not(:disabled){color:var(--action-0);border-color:var(--action-0);background-color:var(--base)}.quantity button:active:not(:disabled){background-color:var(--action-0);color:var(--light-0);transform:scale(.95)}.quantity button:disabled{opacity:.5;cursor:not-allowed}.quantity input[type=number]{z-index:1;border:1px solid var(--base-200);background:var(--base);text-align:center;font-size:1.1rem;width:60px;height:48px;margin:0;padding:0!important;appearance:textfield}.quantity input[type=number]::-webkit-inner-spin-button,.quantity input[type=number]::-webkit-outer-spin-button{-webkit-appearance:none;margin:0}.quantity input[type=number]:focus{background-color:var(--base-50)}.quantity button.increase{left:-2px;border-radius:0 4px 4px 0}.quantity button.decrease{right:-2px;border-radius:4px 0 0 4px}.tab-content[hidden]{display:block!important;transform:scaleY(0);height:0;overflow:hidden}.tab-content[hidden]:focus-within{transform:scaleY(1);height:auto}nav.tabs h2{margin:0!important;line-height:1;font-size:var(--txt-medium);display:flex;color:var(--contrast);white-space:nowrap;gap:1rem}nav.tabs .active h2{color:var(--action-contrast)}nav.tabs button{padding:.75rem 1.5rem;border-radius:0;position:relative;border:2px solid var(--action-0)}nav.tabs>button:first-of-type{border-top-left-radius:var(--radius)}nav.tabs>button:last-of-type{border-top-right-radius:var(--radius)}.tabs>button:focus,.tabs>button:hover{background-color:var(--base-200)}.tabs>button::after{content:'';position:absolute;bottom:-2px;left:0;width:0;height:3px;background-color:var(--action-50);transition:width .3s}.tabs>button.active::after,.tabs>button:hover::after{width:100%}.tabs>button.active::after{background-color:var(--action-200)}.tabs>button.active{background-color:var(--action-0);color:var(--action-contrast)}.tabs>button.active:focus,.tabs>button.active:hover{background-color:var(--action-100)}.tab-content h2{display:none}details.uploader .file-upload-container{margin:1rem 0;max-width:100%}@media (min-width:768px){details.uploader .file-upload-container{margin:1rem var(--mr) 1rem var(--ml);max-width:var(--content)}}.empty-group,.file-upload-wrapper{border:2px dashed var(--action-0);border-radius:4px;padding:2rem;text-align:center;transition:all .3s ease;background:rgba(var(--action-rgb),var(--op-1));position:relative;cursor:pointer}.file-upload-wrapper h2{margin:0!important;font-size:var(--txt-large)}.dragover,.empty-group,.file-upload-wrapper:hover{background:rgba(var(--action-rgb),var(--op-2));border-color:var(--action-0)!important}.file-upload-wrapper input[type=file]{position:absolute;left:0;top:0;width:100%;height:100%;opacity:0;cursor:pointer}.empty-group p,.file-upload-text{color:var(--contrast);margin:0}.empty-group p strong,.file-upload-text strong{color:var(--action-0);text-decoration:underline}.field.upload{position:relative}.field.upload:not(.uploading) .progress{display:none}.field.upload .actions{position:absolute;top:0;right:0}.item-grid.groups{grid-template-columns:repeat(1,1fr)}.item-grid.group{margin-bottom:0}.item-grid.group .item,.item-grid.preview .item,.item-grid.restore .item{display:block}.item-grid.group button,.item-grid.preview button,.item-grid.restore button{padding:.25rem .5rem}.item-grid.group button .icon,.item-grid.preview button .icon,.item-grid.restore button .icon{--w:1.1em}.item-grid.group .item .preview>input[type=checkbox]:not(.label-button)+label,.item-grid.preview .item .preview>input[type=checkbox]:not(.label-button)+label,.item-grid.restore .item .preview>input[type=checkbox]:not(.label-button)+label{padding-left:0;margin:0}.item-grid.group .item .preview>input[type=checkbox]+label:before,.item-grid.preview .item .preview>input[type=checkbox]+label:before,.item-grid.restore .item .preview>input[type=checkbox]+label:before{transform:unset;top:.5rem;left:.5rem}.item-grid.group .item .preview>input[type=checkbox]+label::after,.item-grid.preview .item .preview>input[type=checkbox]+label::after,.item-grid.restore .item .preview>input[type=checkbox]+label::after{top:.5rem;left:.75rem;transform:translateY(20%) rotate(45deg)}.item-grid.group .item .item-actions,.item-grid.preview .item .item-actions,.item-grid.restore .item .item-actions{position:absolute;top:0;right:0;padding-left:var(--chipchip)}.item-grid.group summary,.item-grid.preview summary,.item-grid.restore summary{padding:.5rem}.item-grid.group:has([type=checkbox]:checked),.item-grid.preview:has([type=checkbox]:checked),.item-grid.restore:has([type=checkbox]:checked){padding:1rem;background-color:rgba(var(--contrast-rgb),var(--op-1))}.item-grid.group:has([type=checkbox]:checked) .item,.item-grid.preview:has([type=checkbox]:checked) .item,.item-grid.restore:has([type=checkbox]:checked) .item{padding:.75rem;opacity:.8}.item-grid.group:has([type=checkbox]:checked) .item img,.item-grid.preview:has([type=checkbox]:checked) .item img,.item-grid.restore:has([type=checkbox]:checked) .item img{filter:var(--filter)}.item-grid.group:has([type=checkbox]:checked) details,.item-grid.preview:has([type=checkbox]:checked) details,.item-grid.restore:has([type=checkbox]:checked) details{display:none}.item-grid.group .item:has([type=checkbox]:checked),.item-grid.preview .item:has([type=checkbox]:checked),.item-grid.restore .item:has([type=checkbox]:checked){padding:.5rem;background-color:rgba(var(--action-rgb),var(--op-4));opacity:1}.item-grid.preview summary span{display:none}.item-grid.group .item:has([type=checkbox]:checked) img,.item-grid.preview .item:has([type=checkbox]:checked) img,.item-grid.restore .item:has([type=checkbox]:checked) img{filter:none}[type=radio].featured+label .icon-star-fi,[type=radio].featured:checked+label .icon-star{display:none}[type=radio].featured+label .icon-star,[type=radio].featured:checked+label .icon-star-fi{display:inline-block}.restore.restore.item,.upload.upload.item{border-radius:var(--radius);aspect-ratio:unset;overflow:hidden;background:var(--base);border:1px solid var(--base-200)}.restore-item [for=select-item],.upload.item [for=select-item]{aspect-ratio:1}.upload.item:has(details[open]){grid-column:1/-1}.restore.item img,.upload.item img{transition:transform var(--trans-base)}.restore.item:hover img,.upload.item:hover img{transform:scale(1.02);transition:transform var(--trans-base)}.upload-group{background-image:var(--dashed-action);padding:5px;border-radius:var(--radius);background-color:rgba(var(--action-rgb),var(--op-1))}.upload-group .selected .field{margin:0}.upload-group .selection-actions button{aspect-ratio:unset}.submit-uploads{position:fixed;bottom:0;right:var(--btn_);z-index:var(--z-6);height:var(--btn);box-shadow:rgba(var(--base-rgb),var(--op-45)) var(--shdw);border-radius:var(--radius);animation:pulse-color 5s infinite;animation-delay:1s;background-color:var(--action-0);color:var(--action-contrast)}.submit-uploads:hover{background-color:var(--base-200);color:var(--contrast-200)}.empty-group{order:-1;grid-column:1/-1;padding:20px;background-image:var(--dashed-action);border-radius:var(--radius);margin:10px 0;cursor:pointer;transition:all var(--trans-base);text-align:center;background-color:rgba(var(--action-rgb),var(--op-1))}.group-display:not([hidden])~.file-upload-container{display:none}.dragging,.upload.item.dragging{opacity:.7;transform:scale(.95) rotate(3deg);z-index:var(--z-7);box-shadow:0 8px 25px rgba(0,0,0,.3)}.dragover{background:rgba(var(--action-rgb),var(--op-3))!important;border-color:var(--action-0)!important;transform:scale(1.05);animation:drop-pulse .8s infinite ease-in-out}.drag-preview{position:fixed;z-index:var(--z-9);width:fit-content;overflow:visible;pointer-events:none;opacity:.9;transform:scale(1.05);transition:transform .2s ease}.drag-preview .drag-items{width:max-content;height:max-content;position:relative}.drag-preview .drag-items .drag-item{width:120px;height:120px;position:absolute;top:0;left:0;background:var(--base);border-radius:var(--radius-outer);box-shadow:rgba(var(--base-rgb),var(--op-45)) var(--shdw)}.drag-preview .drag-items .drag-item:nth-child(1){transform:rotate(-3deg);z-index:3}.drag-preview .drag-items .drag-item:nth-child(2){left:8px;top:-4px;transform:rotate(4deg);z-index:2;transition-delay:30ms}.drag-preview .drag-items .drag-item:nth-child(3){left:-6px;top:-8px;transform:rotate(-5deg);z-index:1;transition-delay:60ms}.drag-preview .drag-items .drag-item:nth-child(4){left:12px;top:-12px;transform:rotate(3deg);z-index:0;transition-delay:90ms}.drag-preview .drag-items .drag-item:nth-child(n+5){left:-10px;top:-16px;transform:rotate(-4deg);z-index:0;opacity:.8}.drag-preview .drag-items img,.drag-preview .drag-items video{width:100%;height:100%;object-fit:cover;display:block}.drag-preview .drag-count{position:absolute;top:-8px;right:-8px;background:var(--base-200);color:var(--contrast);border-radius:50%;width:24px;height:24px;display:flex;align-items:center;justify-content:center;font-size:12px;font-weight:700;box-shadow:rgba(var(--base-rgb),var(--op-45)) var(--shdw);z-index:var(--z-3)}.item.dragging{opacity:.5;transform:scale(.95);filter:grayscale(50%);transition:opacity .2s ease,transform .2s ease,filter .2s ease}@keyframes drop-pulse{0%,100%{background-color:rgba(var(--action-rgb),var(--op-3));transform:scale(1.02)}50%{background-color:var(rgba(var(--action-rgb),var(--op-4)));transform:scale(1.04)}}.selection-actions{display:flex;gap:.25rem}@media (max-width:767px){body:not(.uploading):has(.group-display:not([hidden])){overflow:hidden}body:not(.uploading):has(.group-display:not([hidden])) .qtoggle{z-index:var(--z-1)}.group-display.group-display{position:fixed;top:var(--btn);bottom:var(--btn);left:0;right:0;max-height:var(--maxHeight);overflow:hidden;z-index:var(--z-6);width:calc(100% - 1rem);height:calc(100% - 1rem);padding:0 0 3rem;--justify:flex-start;--align:flex-start;--gap:0}.group-display::before{content:'';display:block;z-index:-1;top:-.5rem;bottom:-.5rem;left:-.5rem;right:-.5rem;position:absolute;background-color:rgba(var(--base-rgb),var(--op-6));filter:blur(5px)}.group-display .preview-wrap,.group-display .sidebar{--wrap:nowrap;height:50%;overflow:hidden auto;position:relative;padding:.5rem}.group-display .preview-wrap{top:0}.group-display .preview-wrap .selected{display:flex;justify-content:space-between;align-items:center}.group-display .sidebar{bottom:0;flex-wrap:nowrap;overflow:hidden auto;background-color:var(--contrast-200);color:var(--base)}.group-display .sidebar>.hint{color:var(--contrast)}.group-display .sidebar .header{display:none}.group-display .preview-actions{top:0;flex-shrink:0}.group-display .preview-wrap>.hint,.group-display .sidebar>.hint{bottom:0;margin:0;text-align:center}.group-display .preview-actions,.group-display .preview-wrap>.hint,.group-display .sidebar>.hint{position:absolute;left:0;right:0;background-color:rgba(var(--base-rgb),var(--op-6));z-index:var(--z-3);box-shadow:rgba(var(--base-rgb),var(--op-45)) var(--shdw)}.group-display .item-grid{height:100%;overflow:hidden auto;grid-template-columns:repeat(3,1fr);padding:2rem 0}.group-display .sidebar>.item-grid{grid-template-columns:repeat(1,1fr);gap:1rem;padding:0}.group-display .sidebar .empty-group{order:0;position:sticky;height:fit-content;top:0;z-index:var(--z-3);background-color:rgba(var(--action-rgb),var(--op-6))}.group-display .sidebar .upload-group{order:1}.group-display .sidebar .empty-group p{margin:0}.group-display .field,.group-display .field label{margin:0;padding:0}.group-display .sidebar h4{margin:.25rem}.group-display .item{width:100%;height:max-content}.submit-uploads{bottom:var(--btn);left:0;right:0;width:100%;height:3rem}body.uploading .group-display.group-display{position:relative;top:unset;bottom:unset;right:unset;left:unset}}@media (min-width:768px){.group-display.group-display{--wrap:nowrap;--dir:row;--gap:1rem;--align:flex-start}.group-display .preview-wrap,.group-display .sidebar{--justify:flex-start;--wrap:nowrap;max-height:calc(100vh - var(--btnbtn));overflow:hidden auto}.group-display .preview-wrap,.group-display .sidebar{width:50%}.preview-actions,.preview-wrap .hint{position:sticky;z-index:var(--z-3);box-shadow:rgba(var(--base-rgb),var(--op-45)) var(--shdw);background-color:var(--base);width:100%}.preview-actions{top:0;left:0;right:0}.preview-actions .field{margin:0}.preview-wrap .hint,.sidebar>.hint{bottom:-1rem;padding-bottom:1rem;margin:0;left:0;right:0;text-align:center}}.item-grid.restore{grid-template-columns:repeat(1,1fr)}dialog nav.tabs{position:sticky;top:0;background-color:var(--base-50);z-index:var(--z-6);box-shadow:rgba(var(--base-rgb),var(--op-45)) var(--shdw-down);margin-bottom:2rem}.editor-container .ql-toolbar{display:flex;background-color:var(--base-50);justify-content:flex-start;flex-wrap:wrap;padding:.25rem;gap:.5rem 1rem;border-top-left-radius:var(--radius);border-top-right-radius:var(--radius);border-bottom:4px solid var(--base-50)}.ql-toolbar .ql-formats{display:flex;gap:.25rem}.editor-container .ql-container{--padding:1rem;background-color:var(--base);border-bottom-left-radius:var(--radius);border-bottom-right-radius:var(--radius);height:fit-content;padding:2px;border:1px solid var(--base-200)}.editor-container .ql-container .ql-editor{padding:var(--padding);width:100%;height:100%}.ql-editor img{max-width:50%;height:auto}.ql-clipboard{left:-100000px;height:1px;overflow-y:hidden;position:absolute;top:50%}.ql-hidden{display:none}.ql-tooltip{position:absolute;transform:translateY(10px);background-color:var(--base-100);border:1px solid var(--base);box-shadow:0 0 5px rgba(var(--base-rgb),var(--op-6));color:var(--contrast);padding:5px 12px;white-space:nowrap}[data-type=single] .item-grid{display:flex}.repeater-row details summary::after{margin-left:0}.repeater-row details summary button{margin-left:auto}/*!* Group actions buttons - more visible *!*//*!* Group item grid - distinct from preview grid *!*//*!* Group count hint *!*//*!* ============================================================================*//*!* Base drag preview *!*//*!* Single item drag preview *!*//*!* Multi-item drag preview container *!*//*!* Items being dragged - reduce opacity on originals *!*//*!* Count badge on multi-item preview *!*//*!* ============================================================================*//*!* Ensure progress bar is visible when needed *!*//*!* Progress bar track *!*//*!* Progress bar fill *!*//*!* Progress details - styled for row layout with text and count *!*//*!* Individual item progress - overlay style *!*//*!* Item progress icon and status text *!*//*!* ============================================================================*//*!* Hide uploader when we have uploads *!*//*!* Show group display when we have uploads *!*//*!* ============================================================================*//*!* Selected items - more obvious *!*//*!* Selection checkbox - always visible on hover or when checked *!*//*!* Selection controls - more prominent *!*//*!* ============================================================================*//*!* Smooth dragover animation *!*//*!* ============================================================================*//*!* ============================================================================*//*!* Notification container - fixed overlay *!*//*!* Content card *!*//*!* Message section *!*//*!* Scrollable field list *!*//*!* Item grid for restore preview *!*//*!* Restore item *!*//*!* Checked state *!*//*!* Preview section *!*//*!* Item info *!*//*!* Checkbox controls *!*//*!* Actions section *!*//*!* Selection controls *!*//*!* Action buttons *!*//*!* Restore button - primary action *!*//*!* Scrap cache button - destructive action *!*//*!* Dismiss button - secondary action *!*//*!* Mobile responsive *!*//*!* Animation *!*//*!* Scrollbar styling for restore field list *!*/form{--step-size:2.5rem}.form-progress{padding:0 1rem}.form-progress .progress{background:var(--base-100);border-radius:var(--radius);padding:1rem}.form-progress .bar{height:6px;background:var(--base-200);border-radius:3px;overflow:hidden;margin-bottom:.5rem}.form-progress .fill{height:100%;background:linear-gradient(90deg,var(--action-0),var(--action-200));width:0%;transition:width .4s ease;border-radius:3px}.form-progress .step-text{font-size:var(--txt-small);font-weight:600;color:var(--contrast-200)}form nav.tabs{position:relative;top:0;left:0;right:0;padding:1rem 0;gap:0;z-index:0}form nav.tabs button{position:relative;background:0 0;border:none;padding:.5rem 1rem .5rem 3rem;z-index:1}form nav.tabs .step-number{width:2.5rem;height:100%;border-radius:50% 0 0 50%;position:absolute;left:0;top:0;background:var(--base-200);color:var(--contrast-50);display:flex;align-items:center;justify-content:center;font-weight:700;font-size:var(--txt-small);border:3px solid var(--base)}form nav.tabs button.pending .step-number{background:var(--base-100);color:var(--contrast-200)}form nav.tabs button.active .step-number,form nav.tabs button.current .step-number{background:var(--action-0);color:var(--action-contrast);border-color:var(--action-200)}form nav.tabs button.completed .step-number{background:var(--successBack);color:var(--successBack);border-color:var(--successText)}form nav.tabs button.completed .step-number::before{content:'✓';font-size:1.2rem;color:var(--successText);position:absolute}form nav.tabs button.completed h2{color:var(--contrast-200)}.step-navigation{margin-top:2rem;padding-top:2rem;border-top:1px solid var(--base-200);gap:1rem}.step-navigation .prev-step{background:var(--base-100)}.step-navigation .next-step,.step-navigation button[type=submit]{margin-left:auto}.field input.error,.field select.error,.field textarea.error{border-color:var(--errorBack)}.error-message{color:var(--errorText);font-size:var(--txt-small);margin-top:.25rem;display:block}@media (max-width:768px){form nav.tabs button{min-width:80px;font-size:var(--txt-small)}form nav.tabs button h2{font-size:var(--txt-small)}form{--step-size:2rem}}.field-input-wrapper{position:relative;display:flex;align-items:center;gap:.5rem}.field-input-wrapper input,.field-input-wrapper select,.field-input-wrapper textarea{flex:1}.validation-icon{display:flex;align-items:center;justify-content:center;font-size:1.25rem;animation:scaleIn .3s ease;--w:1.25rem}.validation-icon.error{color:var(--error)}.validation-icon.success{color:var(--success)}@keyframes scaleIn{from{transform:scale(0);opacity:0}to{transform:scale(1);opacity:1}}.validation-message{color:var(--error-0);font-size:var(--txt-small);margin-top:.25rem;display:block;animation:slideDown .2s ease}@keyframes slideDown{from{opacity:0;transform:translateY(-4px)}to{opacity:1;transform:translateY(0)}}.field.has-error input,.field.has-error select,.field.has-error textarea{border-color:var(--error);background-color:var(--errorBack)}.field.has-error input:focus,.field.has-error select:focus,.field.has-error textarea:focus{outline-color:var(--error);box-shadow:0 0 0 3px rgba(var(--error-rgb),.2)}.field.has-success input,.field.has-success select,.field.has-success textarea{border-color:var(--success)}.field label .required{color:var(--error);margin-left:.25rem}.form-summary{padding:2rem;border-radius:8px;margin-top:2rem;border:2px dashed var(--contrast-200)}.form-summary .message{margin-bottom:2rem}.form-summary .result+.result{position:relative;margin-top:1.5rem;padding-top:1.5rem}.form-summary .result+.result::before{position:absolute;top:0;left:16.5%;content:'';width:67%;height:1px;border-bottom:1px solid var(--base-200)}.form-summary h2{margin:1rem 0}.form-summary h4{background-color:var(--base-100);padding:.5rem 2rem;position:relative;left:-2rem;color:var(--contrast-200);font-size:.875rem;text-transform:uppercase;letter-spacing:.05em;margin-bottom:.75rem}.form-summary p{color:var(--text);margin:0}.group-summary,.repeater-summary{background:var(--base-100);padding:1rem;border-radius:4px;margin-top:.5rem}.repeater-row{margin-bottom:1rem}.repeater-row:last-child{margin-bottom:0}.ql-toolbar button{min-height:0;padding:.5rem}.success-message{color:var(--success);background-color:var(--successBack);border:1px solid var(--success);padding:.75rem 1rem;border-radius:var(--radius);margin-bottom:1rem;display:flex;align-items:center;gap:.5rem}.success-message .success-icon{width:1.25rem;height:1.25rem;flex-shrink:0}.success-box{background-color:var(--successBack);border:2px solid var(--success);padding:1.5rem;border-radius:var(--radius-outer);margin-bottom:1rem;text-align:center}.success-box h3{color:var(--success);margin-bottom:.5rem}.success-box p{margin:.5rem 0}.form-success{opacity:.9}.form-success .field:not(.form-success-message):not(.success-box){display:none}.form-success button[type=submit]{opacity:.6;pointer-events:none}.field-error input,.field-error select,.field-error textarea{border-color:var(--error)}.error-message{color:var(--error);font-size:var(--txt-small);margin-top:.25rem;display:block}.form-error{background-color:var(--errorBack);border:1px solid var(--error);padding:.75rem;border-radius:var(--radius);margin-bottom:1rem}.has-success input,.has-success select,.has-success textarea{border-color:var(--success)}.form-error{display:flex;align-items:center;gap:.5rem}.form-error .error-icon{width:1.25rem;height:1.25rem;flex-shrink:0}.invite details{margin-bottom:1.5rem}.field.tag-list .tag-input-row{display:flex;gap:.5rem;align-items:flex-start;margin-bottom:1rem;flex-wrap:wrap}.field.tag-list .tag-input-row .field{flex:1;min-width:150px;margin:0}.field.tag-list .tag-input-row .add-tag-item{flex-shrink:0;white-space:nowrap;margin-top:calc(var(--txt-medium) + 1rem)}.field.tag-list .tag-items{display:flex;flex-wrap:wrap;gap:.5rem;margin-bottom:1rem;min-height:2rem}.field.tag-list .tag-item{background:var(--base-200);padding:.4rem .75rem;border-radius:4px;display:inline-flex;align-items:center;gap:.5rem;font-size:.9rem;line-height:1.2}.field.tag-list .tag-item:hover{background:var(--base-100)}.field.tag-list .tag-label{max-width:300px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.field.tag-list .remove-tag{min-height:0;padding:.25rem;color:var(--contrast);transition:transform .2s;box-shadow:none}.field.tag-list .remove-tag:hover{transform:scale(1.2)}@media (max-width:768px){.field.tag-list .tag-input-row{flex-direction:column;align-items:stretch}.field.tag-list .tag-input-row .field{min-width:100%}}.pendingChanges{position:fixed;bottom:var(--btn);right:var(--btn_);margin-right:1rem;padding:1rem;border-radius:var(--radius);background-color:rgba(var(--base-rgb),var(--op-6));z-index:var(--z-6);width:50vw;animation:fadeInSlideUp .5s ease-out forwards}.pendingChanges button{min-height:0;width:calc(50% - .7rem);padding:.35rem}@keyframes fadeInSlideUp{from{opacity:0;transform:translateY(20px)}to{opacity:1;transform:translateY(0)}}
\ No newline at end of file
+input:is([type=date],[type=number],[type=text],[type=url],[type=email],[type=tel],[type=password],[type=search],[type=datetime-local],[type=time]),textarea{font-family:var(--body);font-size:var(--txt-medium);color:var(--contrast);padding:var(--p-y) var(--p-x);border-radius:var(--radius);background-color:var(--base);outline:0;border:1px solid var(--base-100);border-bottom:2px solid var(--contrast-200);width:100%;max-width:100%;margin:0 4px}input:is([type=date],[type=number],[type=text],[type=url],[type=email],[type=tel],[type=password],[type=search],[type=datetime-local],[type=time]):focus,textarea:focus{outline:var(--action-50);background-color:var(--base-100);color:var(--contrast)}input::placeholder,textarea::placeholder{font-family:var(--body);color:var(--base-200)}@media (min-width:768px){:root{--p-y:1rem}}select{background:var(--base);border:2px solid var(--base-100);border-radius:var(--radius);color:var(--contrast);cursor:pointer;font-family:var(--body);font-size:var(--txt-small);padding:.5rem 1rem;width:100%}select:disabled{background-color:var(--base-50);border-color:var(--base-100);color:var(--base-200);cursor:not-allowed}select option{background:var(--base);color:var(--contrast);padding:.5rem}select option:active,select option:checked,select option:focus,select option:hover{background:var(--action-0);color:var(--base);box-shadow:0 0 0 100px var(--action-0) inset}select option:checked{background:var(--action-0) linear-gradient(0deg,var(--action-0) 0,var(--action-0) 100%);color:var(--base)}select:hover{border-color:var(--action-0)}select:focus{border-color:var(--action-0)}input[type=search]:focus+.clear-search{opacity:1;cursor:pointer}.search-container .clear-search{opacity:0;cursor:default}.search-container .icon.search{padding:4px 8px;color:var(--contrast-200);--w:3rem}input[type=search]::-moz-search-clear-button,input[type=search]::-ms-clear,input[type=search]::-ms-reveal,input[type=search]::search-cancel-button{-webkit-appearance:none;-moz-appearance:none;appearance:none;display:none;visibility:hidden}input[type=search]::-webkit-search-cancel-button,input[type=search]::-webkit-search-decoration,input[type=search]::-webkit-search-results-button,input[type=search]::-webkit-search-results-decoration{-webkit-appearance:none}input[type=url]{background:var(--linkIcon);background-position:.5em;background-size:1em;background-repeat:no-repeat;padding-left:2em}.integration .label,label{text-transform:uppercase;font-weight:700;margin-bottom:.5rem;display:block}.field{margin:2rem 0;position:relative}.field:has(.has-tooltip) label{margin-left:2rem}legend{padding:0 1rem}.date-wrapper{position:relative;display:inline-block}input[type=date]{padding:8px 36px 8px 8px;border-radius:4px}input[type=date]::-webkit-calendar-picker-indicator{opacity:0;width:100%;height:100%;position:absolute;top:0;left:0;cursor:pointer}input[type=date]+.icon{--w:20px;position:absolute;right:10px;top:50%;transform:translateY(-50%);pointer-events:none}input:is([type=time],[type=datetime-local],[type=date]){padding:.5rem;border:1px solid var(--contrast-200);border-radius:4px;font-size:14px;min-width:180px;background:var(--base);color:var(--contrast);cursor:pointer}.date-wrapper input[type=date]:focus,.datetime-wrapper input[type=datetime-local]:focus,.field-input-wrapper input:is([type=time],[type=datetime-local],[type=date]):focus,.time-wrapper input[type=time]:focus{border-color:var(--action-0);box-shadow:0 0 0 2px rgba(var(--action-rgb),.1)}.date-wrapper .icon,.datetime-wrapper .icon,.field-input-wrapper .icon,.time-wrapper .icon{width:18px;height:18px;background-color:var(--contrast);opacity:.7}[type=checkbox],[type=radio],input.ch{position:absolute;opacity:0;left:-200vw}[type=checkbox]+label,[type=radio]+label,input.ch+label{position:relative;cursor:pointer}[type=checkbox]+label:hover,[type=radio]+label:hover{color:var(--action-0)}[type=checkbox]+label::after,[type=checkbox]+label::before,[type=radio]+label::after,[type=radio]+label::before,input.ch+label::after,input.ch+label::before{content:'';position:absolute;top:50%}[type=checkbox]+label::after,[type=radio]+label::after,input.ch+label::after{left:5px;transform:translateY(-70%) rotate(45deg);width:5px;height:10px;border:solid var(--light-0);border-width:0 2px 2px 0;display:none}[type=checkbox]+label::before,[type=radio]+label::before,input.ch+label::before{left:0;transform:translateY(-50%);width:1rem;height:1rem;border:2px solid var(--contrast-200);background-color:var(--base);border-radius:var(--radius)}[type=checkbox]:hover+label::before,[type=radio]:hover+label::before,input.ch:hover+label::before{border-color:var(--action-200)}[type=checkbox]:checked+label::before,[type=radio]:checked+label::before,input.ch:checked+label::before{background-color:var(--action-0);border-color:var(--action-100)}[type=radio]:checked+label::before{border-radius:50%}[type=checkbox]:checked+label::after,input.ch:checked+label::after{display:block;left:5px;top:50%;transform:translateY(-70%) rotate(45deg);width:.35rem;height:.66rem;border:solid var(--light-0);border-width:0 2px 2px 0}[type=checkbox]:disabled+label,[type=radio]:disabled+label,input.ch:disabled+label{cursor:not-allowed;background-color:var(--base-50);color:var(--base-200);border-color:var(--base-200)}[type=checkbox]:disabled+label:hover,[type=radio]:disabled+label:hover,input.ch:disabled+label:hover{background-color:var(--base-50);color:var(--base-200);border-color:var(--base-200)}[type=checkbox]:disabled+label::before,[type=radio]:disabled+label::before,input.ch:disabled+label::before{border-color:var(--base-200)}[type=checkbox]:not(.btn)+label,[type=radio]:not(.btn)+label,input.ch+label{flex:1;padding-left:2rem;transform-origin:top center;will-change:transform}.btn+label::after,.btn+label::before{display:none}.btn+label{--w:1.2em;border:1px solid var(--base-200);border-radius:var(--radius);min-width:2rem;min-height:2rem;margin:0;display:flex;justify-content:center;align-items:center;flex-wrap:nowrap;gap:.5rem;color:var(--contrast-200);opacity:.8}.radio-options.status label{padding:0 .5rem}.btn:checked+label{border-color:var(--contrast);color:var(--contrast);opacity:1}.btn+label:hover{color:var(--action-50);border-color:var(--action-50)}.btn[hidden]+label,input[hidden]+label{display:none!important}.checkbox-options{--gap:.5rem 2rem}.checkbox-options label{flex:unset!important}.radio-options{--gap:.125rem .5rem}.radio-options input:not(.ch)+label::before{display:none!important}.radio-options input:not(.ch)+label{flex:unset!important;padding:.25rem!important;border-radius:4px;border:1px solid var(--base-100);color:var(--contrast-200);font-weight:400;text-align:center}.radio-options input:not(.ch)+label:hover,.radio-options input:not(.ch):checked+label{border-color:var(--action-0);color:var(--action-0)}.quantity{margin:0;display:inline-flex;width:fit-content;align-items:center;justify-content:center;border:1px solid transparent;border-radius:4px;position:relative}.quantity:focus-within{border-color:var(--action-0)}.quantity label{margin:0;font-size:var(--txt-small)}.quantity button{background:var(--base);padding:0;width:var(--chip_);height:var(--chip_);min-height:0;z-index:0;position:relative;border:1px solid var(--base-200);color:var(--contrast-200)}.quantity button:hover:not(:disabled){color:var(--action-0);border-color:var(--action-0);background-color:var(--base)}.quantity button:active:not(:disabled){background-color:var(--action-0);color:var(--light-0);transform:scale(.95)}.quantity button:disabled{opacity:.5;cursor:not-allowed}.quantity input[type=number]{z-index:1;border:1px solid var(--base-200);background:var(--base);text-align:center;font-size:1.1rem;width:60px;height:48px;margin:0;padding:0!important;appearance:textfield}.quantity input[type=number]::-webkit-inner-spin-button,.quantity input[type=number]::-webkit-outer-spin-button{-webkit-appearance:none;margin:0}.quantity input[type=number]:focus{background-color:var(--base-50)}.quantity button.increase{left:-2px;border-radius:0 4px 4px 0}.quantity button.decrease{right:-2px;border-radius:4px 0 0 4px}.tab-content[hidden]{display:block!important;transform:scaleY(0);height:0;overflow:hidden}.tab-content[hidden]:focus-within{transform:scaleY(1);height:auto}nav.tabs h2{margin:0!important;line-height:1;font-size:var(--txt-medium);display:flex;color:var(--contrast);white-space:nowrap;gap:1rem}nav.tabs .active h2{color:var(--action-contrast)}nav.tabs button{padding:.75rem 1.5rem;border-radius:0;position:relative;border:2px solid var(--action-0)}nav.tabs>button:first-of-type{border-top-left-radius:var(--radius)}nav.tabs>button:last-of-type{border-top-right-radius:var(--radius)}.tabs>button:focus,.tabs>button:hover{background-color:var(--base-200)}.tabs>button::after{content:'';position:absolute;bottom:-2px;left:0;width:0;height:3px;background-color:var(--action-50);transition:width .3s}.tabs>button.active::after,.tabs>button:hover::after{width:100%}.tabs>button.active::after{background-color:var(--action-200)}.tabs>button.active{background-color:var(--action-0);color:var(--action-contrast)}.tabs>button.active:focus,.tabs>button.active:hover{background-color:var(--action-100)}.tab-content h2{display:none}details.uploader .file-upload-container{margin:1rem 0;max-width:100%}@media (min-width:768px){details.uploader .file-upload-container{margin:1rem var(--mr) 1rem var(--ml);max-width:var(--content)}}.empty-group,.file-upload-wrapper{border:2px dashed var(--action-0);border-radius:4px;padding:2rem;text-align:center;transition:all .3s ease;background:rgba(var(--action-rgb),var(--op-1));position:relative;cursor:pointer}.file-upload-wrapper h2{margin:0!important;font-size:var(--txt-large)}.dragover,.empty-group,.file-upload-wrapper:hover{background:rgba(var(--action-rgb),var(--op-2));border-color:var(--action-0)!important}.file-upload-wrapper input[type=file]{position:absolute;left:0;top:0;width:100%;height:100%;opacity:0;cursor:pointer}.empty-group p,.file-upload-text{color:var(--contrast);margin:0}.empty-group p strong,.file-upload-text strong{color:var(--action-0);text-decoration:underline}.field.upload{position:relative}.field.upload:not(.uploading) .progress{display:none}.field.upload .actions{position:absolute;top:0;right:0}.item-grid.groups{grid-template-columns:repeat(1,1fr)}.item-grid.group{margin-bottom:0}.item-grid.group .item,.item-grid.preview .item,.item-grid.restore .item{display:block}.item-grid.group button,.item-grid.preview button,.item-grid.restore button{padding:.25rem .5rem}.item-grid.group button .icon,.item-grid.preview button .icon,.item-grid.restore button .icon{--w:1.1em}.item-grid.group .item .preview>input[type=checkbox]:not(.label-button)+label,.item-grid.preview .item .preview>input[type=checkbox]:not(.label-button)+label,.item-grid.restore .item .preview>input[type=checkbox]:not(.label-button)+label{padding-left:0;margin:0}.item-grid.group .item .preview>input[type=checkbox]+label:before,.item-grid.preview .item .preview>input[type=checkbox]+label:before,.item-grid.restore .item .preview>input[type=checkbox]+label:before{transform:unset;top:.5rem;left:.5rem}.item-grid.group .item .preview>input[type=checkbox]+label::after,.item-grid.preview .item .preview>input[type=checkbox]+label::after,.item-grid.restore .item .preview>input[type=checkbox]+label::after{top:.5rem;left:.75rem;transform:translateY(20%) rotate(45deg)}.item-grid.group .item .item-actions,.item-grid.preview .item .item-actions,.item-grid.restore .item .item-actions{position:absolute;top:0;right:0;padding-left:var(--chipchip)}.item-grid.group summary,.item-grid.preview summary,.item-grid.restore summary{padding:.5rem}.item-grid.group:has([type=checkbox]:checked),.item-grid.preview:has([type=checkbox]:checked),.item-grid.restore:has([type=checkbox]:checked){padding:1rem;background-color:rgba(var(--contrast-rgb),var(--op-1))}.item-grid.group:has([type=checkbox]:checked) .item,.item-grid.preview:has([type=checkbox]:checked) .item,.item-grid.restore:has([type=checkbox]:checked) .item{padding:.75rem;opacity:.8}.item-grid.group:has([type=checkbox]:checked) .item img,.item-grid.preview:has([type=checkbox]:checked) .item img,.item-grid.restore:has([type=checkbox]:checked) .item img{filter:var(--filter)}.item-grid.group:has([type=checkbox]:checked) details,.item-grid.preview:has([type=checkbox]:checked) details,.item-grid.restore:has([type=checkbox]:checked) details{display:none}.item-grid.group .item:has([type=checkbox]:checked),.item-grid.preview .item:has([type=checkbox]:checked),.item-grid.restore .item:has([type=checkbox]:checked){padding:.5rem;background-color:rgba(var(--action-rgb),var(--op-4));opacity:1}.item-grid.preview summary span{display:none}.item-grid.group .item:has([type=checkbox]:checked) img,.item-grid.preview .item:has([type=checkbox]:checked) img,.item-grid.restore .item:has([type=checkbox]:checked) img{filter:none}[type=radio].featured+label .icon-star-fi,[type=radio].featured:checked+label .icon-star{display:none}[type=radio].featured+label .icon-star,[type=radio].featured:checked+label .icon-star-fi{display:inline-block}.restore.restore.item,.upload.upload.item{border-radius:var(--radius);aspect-ratio:unset;overflow:hidden;background:var(--base);border:1px solid var(--base-200)}.restore-item [for=select-item],.upload.item [for=select-item]{aspect-ratio:1}.upload.item:has(details[open]){grid-column:1/-1}.restore.item img,.upload.item img{transition:transform var(--trans-base)}.restore.item:hover img,.upload.item:hover img{transform:scale(1.02);transition:transform var(--trans-base)}.upload-group{background-image:var(--dashed-action);padding:5px;border-radius:var(--radius);background-color:rgba(var(--action-rgb),var(--op-1))}.upload-group .selected .field{margin:0}.upload-group .selection-actions button{aspect-ratio:unset}.submit-uploads{position:fixed;bottom:0;right:var(--btn_);z-index:var(--z-6);height:var(--btn);box-shadow:rgba(var(--base-rgb),var(--op-45)) var(--shdw);border-radius:var(--radius);animation:pulse-color 5s infinite;animation-delay:1s;background-color:var(--action-0);color:var(--action-contrast)}.submit-uploads:hover{background-color:var(--base-200);color:var(--contrast-200)}.empty-group{order:-1;grid-column:1/-1;padding:20px;background-image:var(--dashed-action);border-radius:var(--radius);margin:10px 0;cursor:pointer;transition:all var(--trans-base);text-align:center;background-color:rgba(var(--action-rgb),var(--op-1))}.group-display:not([hidden])~.file-upload-container{display:none}.dragging,.upload.item.dragging{opacity:.7;transform:scale(.95) rotate(3deg);z-index:var(--z-7);box-shadow:0 8px 25px rgba(0,0,0,.3)}.dragover{background:rgba(var(--action-rgb),var(--op-3))!important;border-color:var(--action-0)!important;transform:scale(1.05);animation:drop-pulse .8s infinite ease-in-out}.drag-preview{position:fixed;z-index:var(--z-9);width:fit-content;overflow:visible;pointer-events:none;opacity:.9;transform:scale(1.05);transition:transform .2s ease}.drag-preview .drag-items{width:max-content;height:max-content;position:relative}.drag-preview .drag-items .drag-item{width:120px;height:120px;position:absolute;top:0;left:0;background:var(--base);border-radius:var(--radius-outer);box-shadow:rgba(var(--base-rgb),var(--op-45)) var(--shdw)}.drag-preview .drag-items .drag-item:nth-child(1){transform:rotate(-3deg);z-index:3}.drag-preview .drag-items .drag-item:nth-child(2){left:8px;top:-4px;transform:rotate(4deg);z-index:2;transition-delay:30ms}.drag-preview .drag-items .drag-item:nth-child(3){left:-6px;top:-8px;transform:rotate(-5deg);z-index:1;transition-delay:60ms}.drag-preview .drag-items .drag-item:nth-child(4){left:12px;top:-12px;transform:rotate(3deg);z-index:0;transition-delay:90ms}.drag-preview .drag-items .drag-item:nth-child(n+5){left:-10px;top:-16px;transform:rotate(-4deg);z-index:0;opacity:.8}.drag-preview .drag-items img,.drag-preview .drag-items video{width:100%;height:100%;object-fit:cover;display:block}.drag-preview .drag-count{position:absolute;top:-8px;right:-8px;background:var(--base-200);color:var(--contrast);border-radius:50%;width:24px;height:24px;display:flex;align-items:center;justify-content:center;font-size:12px;font-weight:700;box-shadow:rgba(var(--base-rgb),var(--op-45)) var(--shdw);z-index:var(--z-3)}.item.dragging{opacity:.5;transform:scale(.95);filter:grayscale(50%);transition:opacity .2s ease,transform .2s ease,filter .2s ease}@keyframes drop-pulse{0%,100%{background-color:rgba(var(--action-rgb),var(--op-3));transform:scale(1.02)}50%{background-color:var(rgba(var(--action-rgb),var(--op-4)));transform:scale(1.04)}}.selection-actions{display:flex;gap:.25rem}@media (max-width:767px){body:not(.uploading):has(.group-display:not([hidden])){overflow:hidden}body:not(.uploading):has(.group-display:not([hidden])) .qtoggle{z-index:var(--z-1)}.group-display.group-display{position:fixed;top:var(--btn);bottom:var(--btn);left:0;right:0;max-height:var(--maxHeight);overflow:hidden;z-index:var(--z-6);width:calc(100% - 1rem);height:calc(100% - 1rem);padding:0 0 3rem;--justify:flex-start;--align:flex-start;--gap:0}.group-display::before{content:'';display:block;z-index:-1;top:-.5rem;bottom:-.5rem;left:-.5rem;right:-.5rem;position:absolute;background-color:rgba(var(--base-rgb),var(--op-6));filter:blur(5px)}.group-display .preview-wrap,.group-display .sidebar{--wrap:nowrap;height:50%;overflow:hidden auto;position:relative;padding:.5rem}.group-display .preview-wrap{top:0}.group-display .preview-wrap .selected{display:flex;justify-content:space-between;align-items:center}.group-display .sidebar{bottom:0;flex-wrap:nowrap;overflow:hidden auto;background-color:var(--contrast-200);color:var(--base)}.group-display .sidebar>.hint{color:var(--contrast)}.group-display .sidebar .header{display:none}.group-display .preview-actions{top:0;flex-shrink:0}.group-display .preview-wrap>.hint,.group-display .sidebar>.hint{bottom:0;margin:0;text-align:center}.group-display .preview-actions,.group-display .preview-wrap>.hint,.group-display .sidebar>.hint{position:absolute;left:0;right:0;background-color:rgba(var(--base-rgb),var(--op-6));z-index:var(--z-3);box-shadow:rgba(var(--base-rgb),var(--op-45)) var(--shdw)}.group-display .item-grid{height:100%;overflow:hidden auto;grid-template-columns:repeat(3,1fr);padding:2rem 0}.group-display .sidebar>.item-grid{grid-template-columns:repeat(1,1fr);gap:1rem;padding:0}.group-display .sidebar .empty-group{order:0;position:sticky;height:fit-content;top:0;z-index:var(--z-3);background-color:rgba(var(--action-rgb),var(--op-6))}.group-display .sidebar .upload-group{order:1}.group-display .sidebar .empty-group p{margin:0}.group-display .field,.group-display .field label{margin:0;padding:0}.group-display .sidebar h4{margin:.25rem}.group-display .item{width:100%;height:max-content}.submit-uploads{bottom:var(--btn);left:0;right:0;width:100%;height:3rem}body.uploading .group-display.group-display{position:relative;top:unset;bottom:unset;right:unset;left:unset}}@media (min-width:768px){.group-display.group-display{--wrap:nowrap;--dir:row;--gap:1rem;--align:flex-start}.group-display .preview-wrap,.group-display .sidebar{--justify:flex-start;--wrap:nowrap;max-height:calc(100vh - var(--btnbtn));overflow:hidden auto}.group-display .preview-wrap,.group-display .sidebar{width:50%}.preview-actions,.preview-wrap .hint{position:sticky;z-index:var(--z-3);box-shadow:rgba(var(--base-rgb),var(--op-45)) var(--shdw);background-color:var(--base);width:100%}.preview-actions{top:0;left:0;right:0}.preview-actions .field{margin:0}.preview-wrap .hint,.sidebar>.hint{bottom:-1rem;padding-bottom:1rem;margin:0;left:0;right:0;text-align:center}}.item-grid.restore{grid-template-columns:repeat(1,1fr)}dialog nav.tabs{position:sticky;top:0;background-color:var(--base-50);z-index:var(--z-6);box-shadow:rgba(var(--base-rgb),var(--op-45)) var(--shdw-down);margin-bottom:2rem}.editor-container .ql-toolbar{display:flex;background-color:var(--base-50);justify-content:flex-start;flex-wrap:wrap;padding:.25rem;gap:.5rem 1rem;border-top-left-radius:var(--radius);border-top-right-radius:var(--radius);border-bottom:4px solid var(--base-50)}.ql-toolbar .ql-formats{display:flex;gap:.25rem}.editor-container .ql-container{--padding:1rem;background-color:var(--base);border-bottom-left-radius:var(--radius);border-bottom-right-radius:var(--radius);height:fit-content;padding:2px;border:1px solid var(--base-200)}.editor-container .ql-container .ql-editor{padding:var(--padding);width:100%;height:100%}.ql-editor img{max-width:50%;height:auto}.ql-clipboard{left:-100000px;height:1px;overflow-y:hidden;position:absolute;top:50%}.ql-hidden{display:none}.ql-tooltip{position:absolute;transform:translateY(10px);background-color:var(--base-100);border:1px solid var(--base);box-shadow:0 0 5px rgba(var(--base-rgb),var(--op-6));color:var(--contrast);padding:5px 12px;white-space:nowrap}[data-type=single] .item-grid{display:flex}.repeater-row details summary::after{margin-left:0}.repeater-row details summary button{margin-left:auto}/*!* Group actions buttons - more visible *!*//*!* Group item grid - distinct from preview grid *!*//*!* Group count hint *!*//*!* ============================================================================*//*!* Base drag preview *!*//*!* Single item drag preview *!*//*!* Multi-item drag preview container *!*//*!* Items being dragged - reduce opacity on originals *!*//*!* Count badge on multi-item preview *!*//*!* ============================================================================*//*!* Ensure progress bar is visible when needed *!*//*!* Progress bar track *!*//*!* Progress bar fill *!*//*!* Progress details - styled for row layout with text and count *!*//*!* Individual item progress - overlay style *!*//*!* Item progress icon and status text *!*//*!* ============================================================================*//*!* Hide uploader when we have uploads *!*//*!* Show group display when we have uploads *!*//*!* ============================================================================*//*!* Selected items - more obvious *!*//*!* Selection checkbox - always visible on hover or when checked *!*//*!* Selection controls - more prominent *!*//*!* ============================================================================*//*!* Smooth dragover animation *!*//*!* ============================================================================*//*!* ============================================================================*//*!* Notification container - fixed overlay *!*//*!* Content card *!*//*!* Message section *!*//*!* Scrollable field list *!*//*!* Item grid for restore preview *!*//*!* Restore item *!*//*!* Checked state *!*//*!* Preview section *!*//*!* Item info *!*//*!* Checkbox controls *!*//*!* Actions section *!*//*!* Selection controls *!*//*!* Action buttons *!*//*!* Restore button - primary action *!*//*!* Scrap cache button - destructive action *!*//*!* Dismiss button - secondary action *!*//*!* Mobile responsive *!*//*!* Animation *!*//*!* Scrollbar styling for restore field list *!*/form{--step-size:2.5rem}.form-progress{padding:0 1rem}.form-progress .progress{background:var(--base-100);border-radius:var(--radius);padding:1rem}.form-progress .bar{height:6px;background:var(--base-200);border-radius:3px;overflow:hidden;margin-bottom:.5rem}.form-progress .fill{height:100%;background:linear-gradient(90deg,var(--action-0),var(--action-200));width:0%;transition:width .4s ease;border-radius:3px}.form-progress .step-text{font-size:var(--txt-small);font-weight:600;color:var(--contrast-200)}form nav.tabs{position:relative;top:0;left:0;right:0;padding:1rem 0;gap:0;z-index:0}form nav.tabs button{position:relative;background:0 0;border:none;padding:.5rem 1rem .5rem 3rem;z-index:1}form nav.tabs .step-number{width:2.5rem;height:100%;border-radius:50% 0 0 50%;position:absolute;left:0;top:0;background:var(--base-200);color:var(--contrast-50);display:flex;align-items:center;justify-content:center;font-weight:700;font-size:var(--txt-small);border:3px solid var(--base)}form nav.tabs button.pending .step-number{background:var(--base-100);color:var(--contrast-200)}form nav.tabs button.active .step-number,form nav.tabs button.current .step-number{background:var(--action-0);color:var(--action-contrast);border-color:var(--action-200)}form nav.tabs button.completed .step-number{background:var(--successBack);color:var(--successBack);border-color:var(--successText)}form nav.tabs button.completed .step-number::before{content:'✓';font-size:1.2rem;color:var(--successText);position:absolute}form nav.tabs button.completed h2{color:var(--contrast-200)}.step-navigation{margin-top:2rem;padding-top:2rem;border-top:1px solid var(--base-200);gap:1rem}.step-navigation .prev-step{background:var(--base-100)}.step-navigation .next-step,.step-navigation button[type=submit]{margin-left:auto}.field input.error,.field select.error,.field textarea.error{border-color:var(--errorBack)}.error-message{color:var(--errorText);font-size:var(--txt-small);margin-top:.25rem;display:block}@media (max-width:768px){form nav.tabs button{min-width:80px;font-size:var(--txt-small)}form nav.tabs button h2{font-size:var(--txt-small)}form{--step-size:2rem}}.field-input-wrapper{position:relative;display:flex;align-items:center;gap:.5rem}.field-input-wrapper input,.field-input-wrapper select,.field-input-wrapper textarea{flex:1}.validation-icon{display:flex;align-items:center;justify-content:center;font-size:1.25rem;animation:scaleIn .3s ease;--w:1.25rem}.validation-icon.error{color:var(--error)}.validation-icon.success{color:var(--success)}@keyframes scaleIn{from{transform:scale(0);opacity:0}to{transform:scale(1);opacity:1}}.validation-message{color:var(--error-0);font-size:var(--txt-small);margin-top:.25rem;display:block;animation:slideDown .2s ease}@keyframes slideDown{from{opacity:0;transform:translateY(-4px)}to{opacity:1;transform:translateY(0)}}.field.has-error input,.field.has-error select,.field.has-error textarea{border-color:var(--error);background-color:var(--errorBack)}.field.has-error input:focus,.field.has-error select:focus,.field.has-error textarea:focus{outline-color:var(--error);box-shadow:0 0 0 3px rgba(var(--error-rgb),.2)}.field.has-success input,.field.has-success select,.field.has-success textarea{border-color:var(--success)}.field label .required{color:var(--error);margin-left:.25rem}.form-summary{padding:2rem;border-radius:8px;margin-top:2rem;border:2px dashed var(--contrast-200)}.form-summary .message{margin-bottom:2rem}.form-summary .result+.result{position:relative;margin-top:1.5rem;padding-top:1.5rem}.form-summary .result+.result::before{position:absolute;top:0;left:16.5%;content:'';width:67%;height:1px;border-bottom:1px solid var(--base-200)}.form-summary h2{margin:1rem 0}.form-summary h4{background-color:var(--base-100);padding:.5rem 2rem;position:relative;left:-2rem;color:var(--contrast-200);font-size:.875rem;text-transform:uppercase;letter-spacing:.05em;margin-bottom:.75rem}.form-summary p{color:var(--text);margin:0}.group-summary,.repeater-summary{background:var(--base-100);padding:1rem;border-radius:4px;margin-top:.5rem}.repeater-row{margin-bottom:1rem}.repeater-row:last-child{margin-bottom:0}.ql-toolbar button{min-height:0;padding:.5rem}.success-message{color:var(--success);background-color:var(--successBack);border:1px solid var(--success);padding:.75rem 1rem;border-radius:var(--radius);margin-bottom:1rem;display:flex;align-items:center;gap:.5rem}.success-message .success-icon{width:1.25rem;height:1.25rem;flex-shrink:0}.success-box{background-color:var(--successBack);border:2px solid var(--success);padding:1.5rem;border-radius:var(--radius-outer);margin-bottom:1rem;text-align:center}.success-box h3{color:var(--success);margin-bottom:.5rem}.success-box p{margin:.5rem 0}.form-success{opacity:.9}.form-success .field:not(.form-success-message):not(.success-box){display:none}.form-success button[type=submit]{opacity:.6;pointer-events:none}.field-error input,.field-error select,.field-error textarea{border-color:var(--error)}.error-message{color:var(--error);font-size:var(--txt-small);margin-top:.25rem;display:block}.form-error{background-color:var(--errorBack);border:1px solid var(--error);padding:.75rem;border-radius:var(--radius);margin-bottom:1rem}.has-success input,.has-success select,.has-success textarea{border-color:var(--success)}.form-error{display:flex;align-items:center;gap:.5rem}.form-error .error-icon{width:1.25rem;height:1.25rem;flex-shrink:0}.invite details{margin-bottom:1.5rem}.field.tag-list .row{margin-bottom:1rem}.field.tag-list .row .field{flex:1;min-width:150px;margin:0}.field.tag-list .tag .add-tag-item{flex-shrink:0;white-space:nowrap;margin-top:calc(var(--txt-medium) + 1rem)}.field.tag-list .tag-items{display:flex;flex-wrap:wrap;gap:.5rem;margin-bottom:1rem;min-height:2rem}.field.tag-list .tag-item{background:var(--base-200);padding:.4rem .75rem;border-radius:4px;display:inline-flex;align-items:center;gap:.5rem;font-size:.9rem;line-height:1.2}.field.tag-list .tag-item:hover{background:var(--base-100)}.field.tag-list .tag-label{max-width:300px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.field.tag-list .remove-tag{min-height:0;padding:.25rem;color:var(--contrast);transition:transform .2s;box-shadow:none}.field.tag-list .remove-tag:hover{transform:scale(1.2)}@media (max-width:768px){.field.tag-list .tag{flex-direction:column;align-items:stretch}.field.tag-list .tag .field{min-width:100%}}.pendingChanges{position:fixed;bottom:var(--btn);right:var(--btn_);margin-right:1rem;padding:1rem;border-radius:var(--radius);background-color:rgba(var(--base-rgb),var(--op-6));z-index:var(--z-6);width:50vw;animation:fadeInSlideUp .5s ease-out forwards}.pendingChanges button{min-height:0;width:calc(50% - .7rem);padding:.35rem}@keyframes fadeInSlideUp{from{opacity:0;transform:translateY(20px)}to{opacity:1;transform:translateY(0)}}
\ No newline at end of file
diff --git a/assets/css/icons.css b/assets/css/icons.css
index b82cd88..8c83fa9 100644
--- a/assets/css/icons.css
+++ b/assets/css/icons.css
@@ -1 +1 @@
-.icon-google-logo{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0yMjIsMTI4YTk0LDk0LDAsMSwxLTIxLjQ5LTU5LjgyLDYsNiwwLDEsMS05LjI1LDcuNjRBODIsODIsMCwxLDAsMjA5Ljc4LDEzNEgxMjhhNiw2LDAsMCwxLDAtMTJoODhBNiw2LDAsMCwxLDIyMiwxMjhaIi8+PC9zdmc+');}.icon-apple-logo{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0yMTkuNCwxNjcuODRDMjAxLjcxLDE1NS42OSwxOTgsMTM1LjEyLDE5OCwxMjBjMC0xOC40MiwxMy44Ni0zNC4yOSwyMi4xMi00Mi4xMmE2LDYsMCwwLDAsMC04LjcxQzIwOCw1Ny43LDE4Ny4wNyw1MCwxNjgsNTBhNzAuMjMsNzAuMjMsMCwwLDAtNDAsMTIuNTUsNjkuNiw2OS42LDAsMCwwLTg5LjMxLDguMDhBNzIuNjMsNzIuNjMsMCwwLDAsMTgsMTIzLjM1YTEyNS4xMSwxMjUuMTEsMCwwLDAsMzkuNTMsODguMzNBMzcuODUsMzcuODUsMCwwLDAsODMuNiwyMjJoODcuN0EzNy44MywzNy44MywwLDAsMCwxOTksMjEwLjA3YTEyMi42LDEyMi42LDAsMCwwLDE3LjU0LTI0LjJjNi41NS0xMiw1Ljc3LTEzLjc1LDUtMTUuNDhBNi4wNyw2LjA3LDAsMCwwLDIxOS40LDE2Ny44NFptLTI5LjIzLDM0QTI1LjgyLDI1LjgyLDAsMCwxLDE3MS4zLDIxMEg4My42QTI1Ljg1LDI1Ljg1LDAsMCwxLDY1Ljc4LDIwMywxMTMuMjEsMTEzLjIxLDAsMCwxLDMwLDEyM2E2MC41NSw2MC41NSwwLDAsMSwxNy4yMS00NEE1Ni44Miw1Ni44MiwwLDAsMSw4OCw2MmguODFhNTcuMzUsNTcuMzUsMCwwLDEsMzUuNDQsMTIuNzEsNiw2LDAsMCwwLDcuNSwwQTU3LjM5LDU3LjM5LDAsMCwxLDE2OCw2MmMxMy44OSwwLDI4LjgxLDQuNjgsMzkuMTEsMTItOS40NCwxMC4xNC0yMS4xLDI2LjU5LTIxLjEsNDYsMCwyMy43OCw3LjgxLDQyLjYsMjIuNjYsNTQuNzdBMTA3LjMzLDEwNy4zMywwLDAsMSwxOTAuMTcsMjAxLjg5Wm0tNjAtMTcxLjM5QTM4LDM4LDAsMCwxLDE2NywyaDFhNiw2LDAsMCwxLDAsMTJoLTFhMjYsMjYsMCwwLDAtMjUuMTgsMTkuNSw2LDYsMCwxLDEtMTEuNjItM1oiLz48L3N2Zz4=');}.icon-check-circle{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0xNzIuMjQsOTkuNzZhNiw2LDAsMCwxLDAsOC40OGwtNTYsNTZhNiw2LDAsMCwxLTguNDgsMGwtMjQtMjRhNiw2LDAsMCwxLDguNDgtOC40OEwxMTIsMTUxLjUxbDUxLjc2LTUxLjc1QTYsNiwwLDAsMSwxNzIuMjQsOTkuNzZaTTIzMCwxMjhBMTAyLDEwMiwwLDEsMSwxMjgsMjYsMTAyLjEyLDEwMi4xMiwwLDAsMSwyMzAsMTI4Wm0tMTIsMGE5MCw5MCwwLDEsMC05MCw5MEE5MC4xLDkwLjEsMCwwLDAsMjE4LDEyOFoiLz48L3N2Zz4=');}.icon-cloud-slash{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik01Mi40NCwzNkE2LDYsMCwwLDAsNDMuNTYsNDRsNDAuMTgsNDQuMmMtLjQ1Ljg3LS45LDEuNzUtMS4zMiwyLjY0QTYyLDYyLDAsMSwwLDcyLDIxNGg4OGE4NS4yMyw4NS4yMywwLDAsMCwzMi4zNS02LjNMMjAzLjU2LDIyMGE2LDYsMCwwLDAsOC44OC04LjA4Wk0xNjAsMjAySDcyYTUwLDUwLDAsMSwxLDUuOS05OS42NEE4Ni4yNSw4Ni4yNSwwLDAsMCw3NCwxMjhhNiw2LDAsMCwwLDEyLDAsNzMuOTIsNzMuOTIsMCwwLDEsNi40NC0zMC4ybDkxLjIyLDEwMC4zNEE3My42NSw3My42NSwwLDAsMSwxNjAsMjAyWm04Ni03NGE4NS44NSw4NS44NSwwLDAsMS0yMS44NSw1Ny4yNyw2LDYsMCwwLDEtNC40NywyLDYsNiwwLDAsMS00LjQ3LTEwLDc0LDc0LDAsMCwwLTk5LTEwOC45Miw2LDYsMCwxLDEtNy4xMS05LjY3QTg2LDg2LDAsMCwxLDI0NiwxMjhaIi8+PC9zdmc+');}.icon-exclamation-mark{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0xNDIsMjAwYTE0LDE0LDAsMSwxLTE0LTE0QTE0LDE0LDAsMCwxLDE0MiwyMDBabS0xNC00MmE2LDYsMCwwLDAsNi02VjQ4YTYsNiwwLDAsMC0xMiwwVjE1MkE2LDYsMCwwLDAsMTI4LDE1OFoiLz48L3N2Zz4=');}.icon-cloud-arrow-down{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0yNDYsMTI4YTg1LjI3LDg1LjI3LDAsMCwxLTE3LjIsNTEuNiw2LDYsMCwxLDEtOS42LTcuMkE3NCw3NCwwLDEsMCw4NiwxMjhhNiw2LDAsMCwxLTEyLDAsODUuNTQsODUuNTQsMCwwLDEsMy45MS0yNS42NEE1MC42OCw1MC42OCwwLDAsMCw3MiwxMDJhNTAsNTAsMCwwLDAsMCwxMDBIOTZhNiw2LDAsMCwxLDAsMTJINzJBNjIsNjIsMCwxLDEsODIuNDMsOTAuODgsODYsODYsMCwwLDEsMjQ2LDEyOFptLTY2LjI0LDQzLjc2TDE1OCwxOTMuNTFWMTI4YTYsNiwwLDAsMC0xMiwwdjY1LjUxbC0yMS43Ni0yMS43NWE2LDYsMCwwLDAtOC40OCw4LjQ4bDMyLDMyYTYsNiwwLDAsMCw4LjQ4LDBsMzItMzJhNiw2LDAsMCwwLTguNDgtOC40OFoiLz48L3N2Zz4=');}.icon-caret-down{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0yMTIuMjQsMTAwLjI0bC04MCw4MGE2LDYsMCwwLDEtOC40OCwwbC04MC04MGE2LDYsMCwwLDEsOC40OC04LjQ4TDEyOCwxNjcuNTFsNzUuNzYtNzUuNzVhNiw2LDAsMCwxLDguNDgsOC40OFoiLz48L3N2Zz4=');}.icon-cloud-arrow-up{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0xODguMjQsMTY0LjI0YTYsNiwwLDAsMS04LjQ4LDBMMTU4LDE0Mi40OVYyMDhhNiw2LDAsMCwxLTEyLDBWMTQyLjQ5bC0yMS43NiwyMS43NWE2LDYsMCwwLDEtOC40OC04LjQ4bDMyLTMyYTYsNiwwLDAsMSw4LjQ4LDBsMzIsMzJBNiw2LDAsMCwxLDE4OC4yNCwxNjQuMjRaTTE2MCw0MkE4Ni4xLDg2LjEsMCwwLDAsODIuNDMsOTAuODgsNjIsNjIsMCwxLDAsNzIsMjE0aDQwYTYsNiwwLDAsMCwwLTEySDcyYTUwLDUwLDAsMCwxLDAtMTAwLDUwLjY4LDUwLjY4LDAsMCwxLDUuOTEuMzZBODUuNTQsODUuNTQsMCwwLDAsNzQsMTI4YTYsNiwwLDAsMCwxMiwwLDc0LDc0LDAsMSwxLDEwMy42LDY3Ljg1LDYsNiwwLDAsMCw0LjgsMTFBODYsODYsMCwwLDAsMTYwLDQyWiIvPjwvc3ZnPg==');}.icon-cloud-check{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0xNjAsNDJBODYuMTEsODYuMTEsMCwwLDAsODIuNDMsOTAuODgsNjIsNjIsMCwxLDAsNzIsMjE0aDg4YTg2LDg2LDAsMCwwLDAtMTcyWm0wLDE2MEg3MmE1MCw1MCwwLDAsMSwwLTEwMCw1MC42Nyw1MC42NywwLDAsMSw1LjkxLjM1QTg1LjYxLDg1LjYxLDAsMCwwLDc0LDEyOGE2LDYsMCwwLDAsMTIsMCw3NCw3NCwwLDEsMSw3NCw3NFptMzYuMjQtOTQuMjRhNiw2LDAsMCwxLDAsOC40OGwtNDgsNDhhNiw2LDAsMCwxLTguNDgsMGwtMjQtMjRhNiw2LDAsMCwxLDguNDgtOC40OEwxNDQsMTUxLjUxbDQzLjc2LTQzLjc1QTYsNiwwLDAsMSwxOTYuMjQsMTA3Ljc2WiIvPjwvc3ZnPg==');}.icon-cloud-warning{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0xNjAsNDJBODYuMTEsODYuMTEsMCwwLDAsODIuNDMsOTAuODgsNjIsNjIsMCwxLDAsNzIsMjE0aDg4YTg2LDg2LDAsMCwwLDAtMTcyWm0wLDE2MEg3MmE1MCw1MCwwLDAsMSwwLTEwMCw1MC42Nyw1MC42NywwLDAsMSw1LjkxLjM1QTg1LjYxLDg1LjYxLDAsMCwwLDc0LDEyOGE2LDYsMCwwLDAsMTIsMCw3NCw3NCwwLDEsMSw3NCw3NFptLTYtNzRWODhhNiw2LDAsMCwxLDEyLDB2NDBhNiw2LDAsMCwxLTEyLDBabTE2LDM2YTEwLDEwLDAsMSwxLTEwLTEwQTEwLDEwLDAsMCwxLDE3MCwxNjRaIi8+PC9zdmc+');}.icon-syncing{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHhtbDpzcGFjZT0icHJlc2VydmUiIHdpZHRoPSIzMiIgaGVpZ2h0PSIzMiIgZmlsbD0iY3VycmVudENvbG9yIiB2aWV3Qm94PSIwIDAgMjU2IDI1NiI+PHBhdGggaWQ9InJlZnJlc2giIGQ9Ik0xNjAuMDQ3IDEyMi44NzVhMzAuNzg0IDMwLjc4NCAwIDAgMC0yMS43NSA4Ljc5N2MtMi44NDIgMy4wMDMtLjQ2NyA0Ljk3MSAxLjMxMiAzLjE1NiAxMS4wNDMtMTAuNzg2IDI4LjcxLTEwLjY4IDM5LjYyNS4yMzRsNy4yMDMgNy4yMDRoLTEyLjg3NWMtMy4zNDcuMDA4LTMuMTY1IDMuODc1IDAgMy44NzVoMTYuMTFjMi4wNjIgMCAyLjU0LTEuNDE4IDIuNTYyLTQuOTdsLjA5NC0xNC45MjFjLjAyLTMuMjktMy40MzctMy4xNjUtMy40MzcgMHYxMi44NmwtNy4yMDMtNy4xODhhMzAuNzY4IDMwLjc2OCAwIDAgMC0yMS42NDEtOS4wNDd6bS0yOS41OTQgMzkuNzk3Yy0yLjA2MiAwLTIuNTI0IDEuNDAyLTIuNTQ3IDQuOTUzbC0uMDk0IDE0LjkyMmMtLjAyIDMuMjkgMy40MjIgMy4xNjQgMy40MjIgMHYtMTIuODZsNy4yMDMgNy4yMDRjMTEuOTU2IDExLjk1NSAzMS4zMTIgMTIuMDY0IDQzLjQwNy4yNSAyLjg0Mi0zLjAwMy40NTEtNC45ODgtMS4zMjgtMy4xNzItMTEuMDQzIDEwLjc4Ni0yOC43MSAxMC42OC0zOS42MjUtLjIzNWwtNy4xODgtNy4yMDNoMTIuODZjMy4zNDctLjAwOCAzLjE2NS0zLjg2IDAtMy44NmgtMTYuMTF6Ii8+PHBhdGggZD0iTTE2MCA0NGE4NC4xMSA4NC4xMSAwIDAgMC03Ni40MSA0OS4xMkE2MC43MSA2MC43MSAwIDAgMCA3MiA5MmE2MCA2MCAwIDAgMCAwIDEyMGg4OGE4NCA4NCAwIDAgMCAwLTE2OFptMCAxNjBINzJhNTIgNTIgMCAxIDEgOC41NS0xMDMuM0E4My42NiA4My42NiAwIDAgMCA3NiAxMjhhNCA0IDAgMCAwIDggMCA3NiA3NiAwIDEgMSA3NiA3NloiLz48L3N2Zz4=');}.icon-cloud-x{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0xNjAsNDJBODYuMTEsODYuMTEsMCwwLDAsODIuNDMsOTAuODgsNjIsNjIsMCwxLDAsNzIsMjE0aDg4YTg2LDg2LDAsMCwwLDAtMTcyWm0wLDE2MEg3MmE1MCw1MCwwLDAsMSwwLTEwMCw1MC42Nyw1MC42NywwLDAsMSw1LjkxLjM1QTg1LjYxLDg1LjYxLDAsMCwwLDc0LDEyOGE2LDYsMCwwLDAsMTIsMCw3NCw3NCwwLDEsMSw3NCw3NFptMjguMjQtODUuNzZMMTY4LjQ4LDEzNmwxOS43NiwxOS43NmE2LDYsMCwxLDEtOC40OCw4LjQ4TDE2MCwxNDQuNDhsLTE5Ljc2LDE5Ljc2YTYsNiwwLDAsMS04LjQ4LTguNDhMMTUxLjUyLDEzNmwtMTkuNzYtMTkuNzZhNiw2LDAsMCwxLDguNDgtOC40OEwxNjAsMTI3LjUybDE5Ljc2LTE5Ljc2YTYsNiwwLDAsMSw4LjQ4LDguNDhaIi8+PC9zdmc+');}.icon-arrows-clockwise{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0yMjIsNDhWOTZhNiw2LDAsMCwxLTYsNkgxNjhhNiw2LDAsMCwxLDAtMTJoMzMuNTJMMTgzLjQ3LDcyYTgxLjUxLDgxLjUxLDAsMCwwLTU3LjUzLTI0aC0uNDZBODEuNSw4MS41LDAsMCwwLDY4LjE5LDcxLjI4YTYsNiwwLDEsMS04LjM4LTguNTgsOTMuMzgsOTMuMzgsMCwwLDEsNjUuNjctMjYuNzZIMTI2YTkzLjQ1LDkzLjQ1LDAsMCwxLDY2LDI3LjUzbDE4LDE4VjQ4YTYsNiwwLDAsMSwxMiwwWk0xODcuODEsMTg0LjcyYTgxLjUsODEuNSwwLDAsMS01Ny4yOSwyMy4zNGgtLjQ2YTgxLjUxLDgxLjUxLDAsMCwxLTU3LjUzLTI0TDU0LjQ4LDE2Nkg4OGE2LDYsMCwwLDAsMC0xMkg0MGE2LDYsMCwwLDAtNiw2djQ4YTYsNiwwLDAsMCwxMiwwVjE3NC40OGwxOCwxOC4wNWE5My40NSw5My40NSwwLDAsMCw2NiwyNy41M2guNTJhOTMuMzgsOTMuMzgsMCwwLDAsNjUuNjctMjYuNzYsNiw2LDAsMSwwLTguMzgtOC41OFoiLz48L3N2Zz4=');}.icon-share-fat{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0yMzYuMjQsMTA3Ljc2bC04MC04MEE2LDYsMCwwLDAsMTQ2LDMyVjc0LjJjLTU0LjQ4LDMuNTktMTIwLjM5LDU1LTEyNy45MywxMjAuNjZhMTAsMTAsMCwwLDAsMTcuMjMsOGgwQzQ2LjU2LDE5MC44NSw4NywxNTIuNiwxNDYsMTUwLjEzVjE5MmE2LDYsMCwwLDAsMTAuMjQsNC4yNGw4MC04MEE2LDYsMCwwLDAsMjM2LjI0LDEwNy43NlpNMTU4LDE3Ny41MlYxNDRhNiw2LDAsMCwwLTYtNmMtMjcuNzMsMC01NC43Niw3LjI1LTgwLjMyLDIxLjU1YTE5My4zOCwxOTMuMzgsMCwwLDAtNDAuODEsMzAuNjVjNC43LTI2LjU2LDIwLjE2LTUyLDQ0LTcyLjI3Qzk4LjQ3LDk3Ljk0LDEyNy4yOSw4NiwxNTIsODZhNiw2LDAsMCwwLDYtNlY0Ni40OUwyMjMuNTEsMTEyWiIvPjwvc3ZnPg==');}.icon-trash{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0yMTYsNTBIMTc0VjQwYTIyLDIyLDAsMCwwLTIyLTIySDEwNEEyMiwyMiwwLDAsMCw4Miw0MFY1MEg0MGE2LDYsMCwwLDAsMCwxMkg1MFYyMDhhMTQsMTQsMCwwLDAsMTQsMTRIMTkyYTE0LDE0LDAsMCwwLDE0LTE0VjYyaDEwYTYsNiwwLDAsMCwwLTEyWk05NCw0MGExMCwxMCwwLDAsMSwxMC0xMGg0OGExMCwxMCwwLDAsMSwxMCwxMFY1MEg5NFpNMTk0LDIwOGEyLDIsMCwwLDEtMiwySDY0YTIsMiwwLDAsMS0yLTJWNjJIMTk0Wk0xMTAsMTA0djY0YTYsNiwwLDAsMS0xMiwwVjEwNGE2LDYsMCwwLDEsMTIsMFptNDgsMHY2NGE2LDYsMCwwLDEtMTIsMFYxMDRhNiw2LDAsMCwxLDEyLDBaIi8+PC9zdmc+');}.icon-star{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0yMzcuMjgsOTcuODdBMTQuMTgsMTQuMTgsMCwwLDAsMjI0Ljc2LDg4bC02MC4yNS00Ljg3LTIzLjIyLTU2LjJhMTQuMzcsMTQuMzcsMCwwLDAtMjYuNTgsMEw5MS40OSw4My4xMSwzMS4yNCw4OGExNC4xOCwxNC4xOCwwLDAsMC0xMi41Miw5Ljg5QTE0LjQzLDE0LjQzLDAsMCwwLDIzLDExMy4zMkw2OSwxNTIuOTNsLTE0LDU5LjI1YTE0LjQsMTQuNCwwLDAsMCw1LjU5LDE1LDE0LjEsMTQuMSwwLDAsMCwxNS45MS42TDEyOCwxOTYuMTJsNTEuNTgsMzEuNzFhMTQuMSwxNC4xLDAsMCwwLDE1LjkxLS42LDE0LjQsMTQuNCwwLDAsMCw1LjU5LTE1bC0xNC01OS4yNUwyMzMsMTEzLjMyQTE0LjQzLDE0LjQzLDAsMCwwLDIzNy4yOCw5Ny44N1ptLTEyLjE0LDYuMzctNDguNjksNDJhNiw2LDAsMCwwLTEuOTIsNS45MmwxNC44OCw2Mi43OWEyLjM1LDIuMzUsMCwwLDEtLjk1LDIuNTcsMi4yNCwyLjI0LDAsMCwxLTIuNi4xTDEzMS4xNCwxODRhNiw2LDAsMCwwLTYuMjgsMEw3MC4xNCwyMTcuNjFhMi4yNCwyLjI0LDAsMCwxLTIuNi0uMSwyLjM1LDIuMzUsMCwwLDEtMS0yLjU3bDE0Ljg4LTYyLjc5YTYsNiwwLDAsMC0xLjkyLTUuOTJsLTQ4LjY5LTQyYTIuMzcsMi4zNywwLDAsMS0uNzMtMi42NSwyLjI4LDIuMjgsMCwwLDEsMi4wNy0xLjY1bDYzLjkyLTUuMTZhNiw2LDAsMCwwLDUuMDYtMy42OWwyNC42My01OS42YTIuMzUsMi4zNSwwLDAsMSw0LjM4LDBsMjQuNjMsNTkuNmE2LDYsMCwwLDAsNS4wNiwzLjY5bDYzLjkyLDUuMTZhMi4yOCwyLjI4LDAsMCwxLDIuMDcsMS42NUEyLjM3LDIuMzcsMCwwLDEsMjI1LjE0LDEwNC4yNFoiLz48L3N2Zz4=');}.icon-alphabetical{--icon:url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMzIiIGhlaWdodD0iMzIiIGZpbGw9ImN1cnJlbnRDb2xvciIgdmVyc2lvbj0iMS4xIiB2aWV3Qm94PSIwIDAgMTgzLjc4IDE4NC4wNiIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48cGF0aCBkPSJtNTkuNTg2IDY5Ljc0MmMtMC44NTEzIDAtMS40NjEgMC4xOTY1Ni0xLjgzNjYgMC41OTcxOC0wLjM1MDU0IDAuMzc1NTgtMC41Mjk1OCAxLjAyMjktMC41Mjk1OCAxLjk0OTNzMC4xNzkwMyAxLjU5MzcgMC41Mjk1OCAxLjk5NDRjMC4zNzU1OCAwLjM3NTU4IDAuOTg1MjkgMC41NjMzOCAxLjgzNjYgMC41NjMzOGg3LjAxOTdsLTEyLjQyOCAzNC4zNjZoLTIuMTA3Yy0wLjg1MTMgMC0xLjQ2MSAwLjE5NjU2LTEuODM2NiAwLjU5NzE4LTAuMzUwNTQgMC4zNzU1OC0wLjUyOTU3IDEuMDM0MS0wLjUyOTU3IDEuOTYwNiAwIDAuOTI2NDQgMC4xNzkwMyAxLjU4MjUgMC41Mjk1NyAxLjk4MyAwLjM3NTU4IDAuMzc1NTkgMC45ODUyOSAwLjU2MzM4IDEuODM2NiAwLjU2MzM4aDEyLjU1MmMwLjg1MTMgMCAxLjQ1MjItMC4xODc3OSAxLjgwMjgtMC41NjMzOCAwLjM3NTU4LTAuNDAwNjIgMC41NjMzNy0xLjA1NjYgMC41NjMzNy0xLjk4MyAwLTAuOTI2NDUtMC4xODc3OS0xLjU4NS0wLjU2MzM3LTEuOTYwNi0wLjM1MDU0LTAuNDAwNjItMC45NTE0Ny0wLjU5NzE4LTEuODAyOC0wLjU5NzE4aC00LjU1MjFsMy4xMjExLTguOTM0OWgxOC4yMmwzLjA3NiA4LjkzNDloLTUuMDcwNGMtMC44NTEzIDAtMS40NjEgMC4xOTY1Ni0xLjgzNjYgMC41OTcxOC0wLjM1MDU0IDAuMzc1NTgtMC41Mjk1OCAxLjAzNDEtMC41Mjk1OCAxLjk2MDYgMCAwLjkyNjQ0IDAuMTc5MDMgMS41ODI1IDAuNTI5NTggMS45ODMgMC4zNzU1OCAwLjM3NTU5IDAuOTg1MjkgMC41NjMzOCAxLjgzNjYgMC41NjMzOGgxMy4yOTZjMC44NTEzIDAgMS40NTIyLTAuMTg3NzkgMS44MDI4LTAuNTYzMzggMC4zNzU1OC0wLjQwMDYyIDAuNTYzMzctMS4wNTY2IDAuNTYzMzctMS45ODMgMC0wLjkyNjQ1LTAuMTg3NzktMS41ODUtMC41NjMzNy0xLjk2MDYtMC4zNTA1NC0wLjQwMDYyLTAuOTUxNDctMC41OTcxOC0xLjgwMjgtMC41OTcxOGgtMi4yODczbC0xMy4yNjItMzcuMDM2Yy0wLjMwMDQ3LTAuODUxMy0wLjc1OTk0LTEuNDYxLTEuMzg1OS0xLjgzNjYtMC42MDA5My0wLjQwMDYyLTEuNDA5Ny0wLjU5NzE4LTIuNDExMy0wLjU5NzE4em00NC4xNDYgMGMtMC44NTEzIDAtMS40NzIzIDAuMTk2NTYtMS44NDc4IDAuNTk3MTgtMC4zNTA1NSAwLjM3NTU4LTAuNTE4MyAxLjAyMjktMC41MTgzIDEuOTQ5M3YxMS45MWMwIDAuODc2MzMgMC4yMDUzMiAxLjUwNjEgMC42MzA5OCAxLjg4MTcgMC40MjU2NiAwLjM3NTU4IDEuMTU5MyAwLjU2MzM3IDIuMTg1OSAwLjU2MzM3czEuNzQ5LTAuMTg3NzkgMi4xNzQ3LTAuNTYzMzdjMC40MjU2OS0wLjM3NTU4IDAuNjQyMjYtMS4wMDUzIDAuNjQyMjYtMS44ODE3di05LjM1MTdoMTguODUxbC0yNC43NTQgMzUuMzAxYy0wLjM1MDU0IDAuNTI1ODItMC41MTgzMSAxLjA3MTctMC41MTgzMSAxLjYyMjYgMCAwLjkyNjQ1IDAuMTY3NzcgMS41ODI1IDAuNTE4MzEgMS45ODMxIDAuMzc1NTggMC4zNzU1OCAwLjk5NjU0IDAuNTYzMzggMS44NDc4IDAuNTYzMzhoMjguNzY2YzAuODUxMyAwIDEuNDUyMi0wLjE4NzggMS44MDI4LTAuNTYzMzggMC4zNzU1OC0wLjQwMDYyIDAuNTYzMzgtMS4wNTY2IDAuNTYzMzgtMS45ODMxdi0xMi42NjVjMC0wLjg3NjMzLTAuMjE2NTgtMS40OTQ4LTAuNjQyMjUtMS44NzA0LTAuNDI1NjYtMC4zNzU1OC0xLjE0OC0wLjU2MzM4LTIuMTc0Ny0wLjU2MzM4LTEuMDI2NiAwLTEuNzQ5IDAuMTg3NzktMi4xNzQ3IDAuNTYzMzgtMC40MjU2NiAwLjM3NTU4LTAuNjQyMjQgMC45OTQwMi0wLjY0MjI0IDEuODcwNHYxMC4xMDdoLTE5Ljk3OGwyNC45MDEtMzUuNDU5YzAuMjUwMzktMC4zNTA1NCAwLjM3MTgzLTAuODM4ODMgMC4zNzE4My0xLjQ2NDggMC0wLjkyNjQ1LTAuMTg3OC0xLjU3MzctMC41NjMzOC0xLjk0OTMtMC4zNTA1NS0wLjQwMDYyLTAuOTUxNDctMC41OTcxOC0xLjgwMjgtMC41OTcxOHptLTMxLjc1MiA1LjEwNDJoMC43MDk4NWw2Ljk4NTkgMjAuMzE1aC0xNC43MTZ6bS0zNy43MjMtNDkuMTgzYy00LjczNDIgMC04LjYzMTMgMy44OTctOC42MzEzIDguNjMxM3YxMTUuNDdjMCA0LjczNDIgMy44OTcgOC42MzEzIDguNjMxMyA4LjYzMTNoMTE1LjI2YzQuNzM0MiAwIDguNjQyMS0zLjg5NyA4LjY0MjEtOC42MzEzdi0xMTUuNDdjMC00LjczNDItMy45MDgyLTguNjMxMy04LjY0MjEtOC42MzEzem0wIDUuNzI0aDExNS4yNmMxLjY1OCAwIDIuOTA3IDEuMjQ5MSAyLjkwNyAyLjkwNzF2MTE1LjQ3YzAgMS42NTgtMS4yNDkxIDIuOTA3LTIuOTA3IDIuOTA3aC0xMTUuMjZjLTEuNjU4IDAtMi44OTU4LTEuMjQ5MS0yLjg5NTgtMi45MDd2LTExNS40N2MwLTEuNjU4IDEuMjM3OC0yLjkwNzEgMi44OTU4LTIuOTA3MXoiIGZpbGw9ImN1cnJlbnRDb2xvciIgc3Ryb2tlLXdpZHRoPSIuNzIxMTQiLz48L3N2Zz4=');}.icon-scribble{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0yMDQuMjUsMTg4LjI0YTE2LjYzLDE2LjYzLDAsMCwwLDAsMjMuNTIsNiw2LDAsMSwxLTguNDgsOC40OCwyOC42MSwyOC42MSwwLDAsMSwwLTQwLjQ4bDkuMzctOS4zOGExNi42MywxNi42MywwLDAsMC0yMy41Mi0yMy41MWwtNjYuNzUsNjYuNzVhMjguNjMsMjguNjMsMCwwLDEtNDAuNDktNDAuNDlsOTguNzYtOTguNzVhMTYuNjMsMTYuNjMsMCwwLDAtMjMuNTItMjMuNTFMODIuODYsMTE3LjYyQTI4LjYzLDI4LjYzLDAsMCwxLDQyLjM3LDc3LjEzTDgzLjc1LDM1Ljc2YTYsNiwwLDEsMSw4LjQ5LDguNDhMNTAuODYsODUuNjJhMTYuNjMsMTYuNjMsMCwwLDAsMjMuNTIsMjMuNTFsNjYuNzUtNjYuNzVhMjguNjMsMjguNjMsMCwwLDEsNDAuNDksNDAuNDlMODIuODYsMTgxLjYyYTE2LjYzLDE2LjYzLDAsMCwwLDIzLjUyLDIzLjUxbDY2Ljc2LTY2Ljc1YTI4LjYzLDI4LjYzLDAsMCwxLDQwLjQ5LDQwLjQ5WiIvPjwvc3ZnPg==');}.icon-brackets-angle{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik04NS4wNiw0My4yMiwzMS4xMSwxMjhsNTQsODQuNzhhNiw2LDAsMCwxLTEuODQsOC4yOCw2LDYsMCwwLDEtOC4yOC0xLjg0bC01Ni04OGE2LDYsMCwwLDEsMC02LjQ0bDU2LTg4YTYsNiwwLDAsMSwxMC4xMiw2LjQ0Wm0xNTIsODEuNTYtNTYtODhhNiw2LDAsMSwwLTEwLjEyLDYuNDRMMjI0Ljg5LDEyOGwtNTMuOTUsODQuNzhhNiw2LDAsMCwwLDEuODQsOC4yOCw2LDYsMCwwLDAsOC4yOC0xLjg0bDU2LTg4QTYsNiwwLDAsMCwyMzcuMDYsMTI0Ljc4WiIvPjwvc3ZnPg==');}.icon-brain{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0yNDYsMTI0YTU0LjEzLDU0LjEzLDAsMCwwLTMyLTQ5LjMzVjcyYTQ2LDQ2LDAsMCwwLTg2LTIyLjY3QTQ2LDQ2LDAsMCwwLDQyLDcydjIuNjdhNTQsNTQsMCwwLDAsMCw5OC42M1YxNzZhNDYsNDYsMCwwLDAsODYsMjIuNjdBNDYsNDYsMCwwLDAsMjE0LDE3NnYtMi43QTU0LjA3LDU0LjA3LDAsMCwwLDI0NiwxMjRaTTg4LDIxMGEzNCwzNCwwLDAsMS0zNC0zMi45NEE1My42Nyw1My42NywwLDAsMCw2NCwxNzhoOGE2LDYsMCwwLDAsMC0xMkg2NEE0Miw0MiwwLDAsMSw1MCw4NC4zOWE2LDYsMCwwLDAsNC01LjY2VjcyYTM0LDM0LDAsMCwxLDY4LDB2NzMuMDVBNDUuODksNDUuODksMCwwLDAsODgsMTMwYTYsNiwwLDAsMCwwLDEyLDM0LDM0LDAsMCwxLDAsNjhabTEwNC00NGgtOGE2LDYsMCwwLDAsMCwxMmg4YTUzLjY3LDUzLjY3LDAsMCwwLDEwLS45NEEzNCwzNCwwLDEsMSwxNjgsMTQyYTYsNiwwLDAsMCwwLTEyLDQ1Ljg5LDQ1Ljg5LDAsMCwwLTM0LDE1LjA1VjcyYTM0LDM0LDAsMCwxLDY4LDB2Ni43M2E2LDYsMCwwLDAsNCw1LjY2QTQyLDQyLDAsMCwxLDE5MiwxNjZabTE0LTU0YTYsNiwwLDAsMS02LDZoLTRhMzQsMzQsMCwwLDEtMzQtMzRWODBhNiw2LDAsMCwxLDEyLDB2NGEyMiwyMiwwLDAsMCwyMiwyMmg0QTYsNiwwLDAsMSwyMDYsMTEyWk02MCwxMThINTZhNiw2LDAsMCwxLDAtMTJoNEEyMiwyMiwwLDAsMCw4Miw4NFY4MGE2LDYsMCwwLDEsMTIsMHY0QTM0LDM0LDAsMCwxLDYwLDExOFoiLz48L3N2Zz4=');}.icon-palette{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0xOTkuMzcsNTUuMzFBMTAxLjMyLDEwMS4zMiwwLDAsMCwxMjgsMjZoLTFBMTAyLDEwMiwwLDAsMCwyNiwxMjhjMCw0Mi4wOSwyNi4wNyw3Ny40NCw2OCw5Mi4yNkEzMC4yMSwzMC4yMSwwLDAsMCwxMDQuMTEsMjIyLDMwLjA2LDMwLjA2LDAsMCwwLDEzNCwxOTJhMTgsMTgsMCwwLDEsMTgtMThoNDYuMjFhMjkuODIsMjkuODIsMCwwLDAsMjkuMjUtMjMuMzFBMTAyLjcxLDEwMi43MSwwLDAsMCwyMzAsMTI3LjExLDEwMS4yNSwxMDEuMjUsMCwwLDAsMTk5LjM3LDU1LjMxWk0yMTUuNzYsMTQ4YTE3Ljg5LDE3Ljg5LDAsMCwxLTE3LjU1LDE0SDE1MmEzMCwzMCwwLDAsMC0zMCwzMCwxOCwxOCwwLDAsMS0yNCwxN0M2MSwxOTUuODYsMzgsMTY0Ljg1LDM4LDEyOGE5MCw5MCwwLDAsMSw4OS4wNy05MEgxMjhhOTAuMzQsOTAuMzQsMCwwLDEsOTAsODkuMjJBOTAuNDYsOTAuNDYsMCwwLDEsMjE1Ljc2LDE0OFpNMTM4LDc2YTEwLDEwLDAsMSwxLTEwLTEwQTEwLDEwLDAsMCwxLDEzOCw3NlpNOTQsMTAwQTEwLDEwLDAsMSwxLDg0LDkwLDEwLDEwLDAsMCwxLDk0LDEwMFptMCw1NmExMCwxMCwwLDEsMS0xMC0xMEExMCwxMCwwLDAsMSw5NCwxNTZabTg4LTU2YTEwLDEwLDAsMSwxLTEwLTEwQTEwLDEwLDAsMCwxLDE4MiwxMDBaIi8+PC9zdmc+');}.icon-pen-nib{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0yNDYsOTIuNjhhMTMuOTQsMTMuOTQsMCwwLDAtNC4xLTkuOUwxNzMuMjEsMTQuMWExNCwxNCwwLDAsMC0xOS44LDBMMTI0LjY4LDQyLjgzLDY2LjIyLDY0Ljc2YTE0LDE0LDAsMCwwLTguOSwxMC44TDM0LjA4LDIxNUE2LDYsMCwwLDAsNDAsMjIyYTYuNjEsNi42MSwwLDAsMCwxLS4wOGwxMzkuNDQtMjMuMjRhMTQsMTQsMCwwLDAsMTAuODEtOC45bDIxLjkyLTU4LjQ2LDI4Ljc0LTI4Ljc0QTEzLjkyLDEzLjkyLDAsMCwwLDI0Niw5Mi42OFptLTY2LDkyLjg5YTIsMiwwLDAsMS0xLjU0LDEuMjdMNTcuNDksMjA3bDUyLjg3LTUyLjg4YTI2LDI2LDAsMSwwLTguNDgtOC40OEw0OSwxOTguNTNsMjAuMTctMTIxQTIsMiwwLDAsMSw3MC40Myw3Nmw1Ni4wNi0yMUwyMDEsMTI5LjUxWk0xMTAsMTMyYTE0LDE0LDAsMSwxLDE0LDE0QTE0LDE0LDAsMCwxLDExMCwxMzJaTTIzMy40MSw5NC4xLDIwOCwxMTkuNTEsMTM2LjQ4LDQ4LDE2MS45LDIyLjU4YTIsMiwwLDAsMSwyLjgzLDBsNjguNjgsNjguNjlhMiwyLDAsMCwxLDAsMi44M1oiLz48L3N2Zz4=');}.icon-question{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0xMzgsMTgwYTEwLDEwLDAsMSwxLTEwLTEwQTEwLDEwLDAsMCwxLDEzOCwxODBaTTEyOCw3NGMtMjEsMC0zOCwxNS4yNS0zOCwzNHY0YTYsNiwwLDAsMCwxMiwwdi00YzAtMTIuMTMsMTEuNjYtMjIsMjYtMjJzMjYsOS44NywyNiwyMi0xMS42NiwyMi0yNiwyMmE2LDYsMCwwLDAtNiw2djhhNiw2LDAsMCwwLDEyLDB2LTIuNDJjMTguMTEtMi41OCwzMi0xNi42NiwzMi0zMy41OEMxNjYsODkuMjUsMTQ5LDc0LDEyOCw3NFptMTAyLDU0QTEwMiwxMDIsMCwxLDEsMTI4LDI2LDEwMi4xMiwxMDIuMTIsMCwwLDEsMjMwLDEyOFptLTEyLDBhOTAsOTAsMCwxLDAtOTAsOTBBOTAuMSw5MC4xLDAsMCwwLDIxOCwxMjhaIi8+PC9zdmc+');}.icon-city{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0yNDAsMjEwSDIzMFY4OGE2LDYsMCwwLDAtNi02SDE2MGE2LDYsMCwwLDAtNiw2djQySDEwMlY0MGE2LDYsMCwwLDAtNi02SDMyYTYsNiwwLDAsMC02LDZWMjEwSDE2YTYsNiwwLDAsMCwwLDEySDI0MGE2LDYsMCwwLDAsMC0xMlpNMTY2LDk0aDUyVjIxMEgxNjZabS0xMiw0OHY2OEgxMDJWMTQyWk0zOCw0Nkg5MFYyMTBIMzhaTTcwLDcyVjg4YTYsNiwwLDAsMS0xMiwwVjcyYTYsNiwwLDAsMSwxMiwwWm0wLDQ4djE2YTYsNiwwLDAsMS0xMiwwVjEyMGE2LDYsMCwwLDEsMTIsMFptMCw0OHYxNmE2LDYsMCwwLDEtMTIsMFYxNjhhNiw2LDAsMCwxLDEyLDBabTUyLDE2VjE2OGE2LDYsMCwwLDEsMTIsMHYxNmE2LDYsMCwwLDEtMTIsMFptNjQsMFYxNjhhNiw2LDAsMCwxLDEyLDB2MTZhNiw2LDAsMCwxLTEyLDBabTAtNDhWMTIwYTYsNiwwLDAsMSwxMiwwdjE2YTYsNiwwLDAsMS0xMiwwWiIvPjwvc3ZnPg==');}.icon-folder{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0yMTYsNzRIMTMwLjQ5bC0yNy45LTI3LjlhMTMuOTQsMTMuOTQsMCwwLDAtOS45LTQuMUg0MEExNCwxNCwwLDAsMCwyNiw1NlYyMDAuNjJBMTMuMzksMTMuMzksMCwwLDAsMzkuMzgsMjE0SDIxNi44OUExMy4xMiwxMy4xMiwwLDAsMCwyMzAsMjAwLjg5Vjg4QTE0LDE0LDAsMCwwLDIxNiw3NFpNNDAsNTRIOTIuNjlhMiwyLDAsMCwxLDEuNDEuNTlMMTEzLjUxLDc0SDM4VjU2QTIsMiwwLDAsMSw0MCw1NFpNMjE4LDIwMC44OWExLjExLDEuMTEsMCwwLDEtMS4xMSwxLjExSDM5LjM4QTEuNCwxLjQsMCwwLDEsMzgsMjAwLjYyVjg2SDIxNmEyLDIsMCwwLDEsMiwyWiIvPjwvc3ZnPg==');}.icon-hash{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0yMjQsOTBIMTczbDguODktNDguOTNhNiw2LDAsMSwwLTExLjgtMi4xNEwxNjAuODEsOTBIMTA5bDguODktNDguOTNhNiw2LDAsMCwwLTExLjgtMi4xNEw5Ni44MSw5MEg0OGE2LDYsMCwwLDAsMCwxMkg5NC42M2wtOS40Niw1MkgzMmE2LDYsMCwwLDAsMCwxMkg4M0w3NC4xLDIxNC45M2E2LDYsMCwwLDAsNC44Myw3QTUuNjQsNS42NCwwLDAsMCw4MCwyMjJhNiw2LDAsMCwwLDUuODktNC45M0w5NS4xOSwxNjZIMTQ3bC04Ljg5LDQ4LjkzYTYsNiwwLDAsMCw0LjgzLDcsNS42NCw1LjY0LDAsMCwwLDEuMDguMSw2LDYsMCwwLDAsNS44OS00LjkzTDE1OS4xOSwxNjZIMjA4YTYsNiwwLDAsMCwwLTEySDE2MS4zN2w5LjQ2LTUySDIyNGE2LDYsMCwwLDAsMC0xMlptLTc0LjgzLDY0SDk3LjM3bDkuNDYtNTJoNTEuOFoiLz48L3N2Zz4=');}.icon-shapes{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik02OS42OSw2Mi4xYTYsNiwwLDAsMC0xMS4zOCwwbC00MCwxMjBBNiw2LDAsMCwwLDI0LDE5MGg4MGE2LDYsMCwwLDAsNS42OS03LjlaTTMyLjMyLDE3OCw2NCw4M2wzMS42OCw5NVpNMjA2LDc2YTUwLDUwLDAsMSwwLTUwLDUwQTUwLjA2LDUwLjA2LDAsMCwwLDIwNiw3NlptLTg4LDBhMzgsMzgsMCwxLDEsMzgsMzhBMzgsMzgsMCwwLDEsMTE4LDc2Wm0xMDYsNzBIMTM2YTYsNiwwLDAsMC02LDZ2NTZhNiw2LDAsMCwwLDYsNmg4OGE2LDYsMCwwLDAsNi02VjE1MkE2LDYsMCwwLDAsMjI0LDE0NlptLTYsNTZIMTQyVjE1OGg3NloiLz48L3N2Zz4=');}.icon-diamonds-four{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0xMjMuNzYsMTA4LjI0YTYsNiwwLDAsMCw4LjQ4LDBsNDAtNDBhNiw2LDAsMCwwLDAtOC40OGwtNDAtNDBhNiw2LDAsMCwwLTguNDgsMGwtNDAsNDBhNiw2LDAsMCwwLDAsOC40OFpNMTI4LDMyLjQ5LDE1OS41MSw2NCwxMjgsOTUuNTEsOTYuNDksNjRabTQuMjQsMTE1LjI3YTYsNiwwLDAsMC04LjQ4LDBsLTQwLDQwYTYsNiwwLDAsMCwwLDguNDhsNDAsNDBhNiw2LDAsMCwwLDguNDgsMGw0MC00MGE2LDYsMCwwLDAsMC04LjQ4Wk0xMjgsMjIzLjUxLDk2LjQ5LDE5MiwxMjgsMTYwLjQ5LDE1OS41MSwxOTJabTEwOC4yNC05OS43NS00MC00MGE2LDYsMCwwLDAtOC40OCwwbC00MCw0MGE2LDYsMCwwLDAsMCw4LjQ4bDQwLDQwYTYsNiwwLDAsMCw4LjQ4LDBsNDAtNDBBNiw2LDAsMCwwLDIzNi4yNCwxMjMuNzZaTTE5MiwxNTkuNTEsMTYwLjQ5LDEyOCwxOTIsOTYuNDksMjIzLjUxLDEyOFptLTgzLjc2LTM1Ljc1LTQwLTQwYTYsNiwwLDAsMC04LjQ4LDBsLTQwLDQwYTYsNiwwLDAsMCwwLDguNDhsNDAsNDBhNiw2LDAsMCwwLDguNDgsMGw0MC00MEE2LDYsMCwwLDAsMTA4LjI0LDEyMy43NlpNNjQsMTU5LjUxLDMyLjQ5LDEyOCw2NCw5Ni40OSw5NS41MSwxMjhaIi8+PC9zdmc+');}.icon-crosshair-simple{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0xMjgsMjZBMTAyLDEwMiwwLDEsMCwyMzAsMTI4LDEwMi4xMiwxMDIuMTIsMCwwLDAsMTI4LDI2Wm02LDE5MS44VjE4NGE2LDYsMCwwLDAtMTIsMHYzMy44QTkwLjE1LDkwLjE1LDAsMCwxLDM4LjIsMTM0SDcyYTYsNiwwLDAsMCwwLTEySDM4LjJBOTAuMTUsOTAuMTUsMCwwLDEsMTIyLDM4LjJWNzJhNiw2LDAsMCwwLDEyLDBWMzguMkE5MC4xNSw5MC4xNSwwLDAsMSwyMTcuOCwxMjJIMTg0YTYsNiwwLDAsMCwwLDEyaDMzLjhBOTAuMTUsOTAuMTUsMCwwLDEsMTM0LDIxNy44WiIvPjwvc3ZnPg==');}.icon-circle-notch{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0yMzAsMTI4YTEwMiwxMDIsMCwwLDEtMjA0LDBjMC00MC4xOCwyMy4zNS03Ni44Niw1OS41LTkzLjQ1YTYsNiwwLDAsMSw1LDEwLjlDNTguNjEsNjAuMDksMzgsOTIuNDksMzgsMTI4YTkwLDkwLDAsMCwwLDE4MCwwYzAtMzUuNTEtMjAuNjEtNjcuOTEtNTIuNS04Mi41NWE2LDYsMCwwLDEsNS0xMC45QzIwNi42NSw1MS4xNCwyMzAsODcuODIsMjMwLDEyOFoiLz48L3N2Zz4=');}.icon-cards-three{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0yMDgsOTBINDhhMTQsMTQsMCwwLDAtMTQsMTR2OTZhMTQsMTQsMCwwLDAsMTQsMTRIMjA4YTE0LDE0LDAsMCwwLDE0LTE0VjEwNEExNCwxNCwwLDAsMCwyMDgsOTBabTIsMTEwYTIsMiwwLDAsMS0yLDJINDhhMiwyLDAsMCwxLTItMlYxMDRhMiwyLDAsMCwxLDItMkgyMDhhMiwyLDAsMCwxLDIsMlpNNTAsNjRhNiw2LDAsMCwxLDYtNkgyMDBhNiw2LDAsMCwxLDAsMTJINTZBNiw2LDAsMCwxLDUwLDY0Wk02NiwzMmE2LDYsMCwwLDEsNi02SDE4NGE2LDYsMCwwLDEsMCwxMkg3MkE2LDYsMCwwLDEsNjYsMzJaIi8+PC9zdmc+');}.icon-sun-dim{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0xMjIsNDBWMzJhNiw2LDAsMCwxLDEyLDB2OGE2LDYsMCwwLDEtMTIsMFptNjgsODhhNjIsNjIsMCwxLDEtNjItNjJBNjIuMDcsNjIuMDcsMCwwLDEsMTkwLDEyOFptLTEyLDBhNTAsNTAsMCwxLDAtNTAsNTBBNTAuMDYsNTAuMDYsMCwwLDAsMTc4LDEyOFpNNTkuNzYsNjguMjRhNiw2LDAsMSwwLDguNDgtOC40OGwtOC04YTYsNiwwLDAsMC04LjQ4LDguNDhabTAsMTE5LjUyLTgsOGE2LDYsMCwxLDAsOC40OCw4LjQ4bDgtOGE2LDYsMCwxLDAtOC40OC04LjQ4Wm0xMzYtMTM2LTgsOGE2LDYsMCwxLDAsOC40OCw4LjQ4bDgtOGE2LDYsMCwwLDAtOC40OC04LjQ4Wm0uNDgsMTM2YTYsNiwwLDAsMC04LjQ4LDguNDhsOCw4YTYsNiwwLDAsMCw4LjQ4LTguNDhaTTQwLDEyMkgzMmE2LDYsMCwwLDAsMCwxMmg4YTYsNiwwLDAsMCwwLTEyWm04OCw4OGE2LDYsMCwwLDAtNiw2djhhNiw2LDAsMCwwLDEyLDB2LThBNiw2LDAsMCwwLDEyOCwyMTBabTk2LTg4aC04YTYsNiwwLDAsMCwwLDEyaDhhNiw2LDAsMCwwLDAtMTJaIi8+PC9zdmc+');}.icon-moon{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0yMzIuMTMsMTQzLjY0YTYsNiwwLDAsMC02LTEuNDlBOTAuMDcsOTAuMDcsMCwwLDEsMTEzLjg2LDI5Ljg1YTYsNiwwLDAsMC03LjQ5LTcuNDhBMTAyLjg4LDEwMi44OCwwLDAsMCw1NC40OCw1OC42OCwxMDIsMTAyLDAsMCwwLDE5Ny4zMiwyMDEuNTJhMTAyLjg4LDEwMi44OCwwLDAsMCwzNi4zMS01MS44OUE2LDYsMCwwLDAsMjMyLjEzLDE0My42NFptLTQyLDQ4LjI5YTkwLDkwLDAsMCwxLTEyNi0xMjZBOTAuOSw5MC45LDAsMCwxLDk5LjY1LDM3LjY2LDEwMi4wNiwxMDIuMDYsMCwwLDAsMjE4LjM0LDE1Ni4zNSw5MC45LDkwLjksMCwwLDEsMTkwLjEsMTkxLjkzWiIvPjwvc3ZnPg==');}.icon-plus-square{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0yMDgsMzRINDhBMTQsMTQsMCwwLDAsMzQsNDhWMjA4YTE0LDE0LDAsMCwwLDE0LDE0SDIwOGExNCwxNCwwLDAsMCwxNC0xNFY0OEExNCwxNCwwLDAsMCwyMDgsMzRabTIsMTc0YTIsMiwwLDAsMS0yLDJINDhhMiwyLDAsMCwxLTItMlY0OGEyLDIsMCwwLDEsMi0ySDIwOGEyLDIsMCwwLDEsMiwyWm0tMzYtODBhNiw2LDAsMCwxLTYsNkgxMzR2MzRhNiw2LDAsMCwxLTEyLDBWMTM0SDg4YTYsNiwwLDAsMSwwLTEyaDM0Vjg4YTYsNiwwLDAsMSwxMiwwdjM0aDM0QTYsNiwwLDAsMSwxNzQsMTI4WiIvPjwvc3ZnPg==');}.icon-arrow-elbow-left-up{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0yMzgsMTkyYTYsNiwwLDAsMS02LDZIODhhNiw2LDAsMCwxLTYtNlY2Mi40OUw0NC4yNCwxMDAuMjRhNiw2LDAsMCwxLTguNDgtOC40OGw0OC00OGE2LDYsMCwwLDEsOC40OCwwbDQ4LDQ4YTYsNiwwLDEsMS04LjQ4LDguNDhMOTQsNjIuNDlWMTg2SDIzMkE2LDYsMCwwLDEsMjM4LDE5MloiLz48L3N2Zz4=');}.icon-arrow-elbow-right-up{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0yMjAuMjQsMTAwLjI0YTYsNiwwLDAsMS04LjQ4LDBMMTc0LDYyLjQ5VjE5MmE2LDYsMCwwLDEtNiw2SDI0YTYsNiwwLDAsMSwwLTEySDE2MlY2Mi40OWwtMzcuNzYsMzcuNzVhNiw2LDAsMCwxLTguNDgtOC40OGw0OC00OGE2LDYsMCwwLDEsOC40OCwwbDQ4LDQ4QTYsNiwwLDAsMSwyMjAuMjQsMTAwLjI0WiIvPjwvc3ZnPg==');}.icon-x{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0yMDQuMjQsMTk1Ljc2YTYsNiwwLDEsMS04LjQ4LDguNDhMMTI4LDEzNi40OSw2MC4yNCwyMDQuMjRhNiw2LDAsMCwxLTguNDgtOC40OEwxMTkuNTEsMTI4LDUxLjc2LDYwLjI0YTYsNiwwLDAsMSw4LjQ4LTguNDhMMTI4LDExOS41MWw2Ny43Ni02Ny43NWE2LDYsMCwwLDEsOC40OCw4LjQ4TDEzNi40OSwxMjhaIi8+PC9zdmc+');}.icon-floppy-disk{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0yMTcuOSw3My40MiwxODIuNTgsMzguMWExMy45LDEzLjksMCwwLDAtOS44OS00LjFINDhBMTQsMTQsMCwwLDAsMzQsNDhWMjA4YTE0LDE0LDAsMCwwLDE0LDE0SDIwOGExNCwxNCwwLDAsMCwxNC0xNFY4My4zMUExMy45LDEzLjksMCwwLDAsMjE3LjksNzMuNDJaTTE3MCwyMTBIODZWMTUyYTIsMiwwLDAsMSwyLTJoODBhMiwyLDAsMCwxLDIsMlptNDAtMmEyLDIsMCwwLDEtMiwySDE4MlYxNTJhMTQsMTQsMCwwLDAtMTQtMTRIODhhMTQsMTQsMCwwLDAtMTQsMTR2NThINDhhMiwyLDAsMCwxLTItMlY0OGEyLDIsMCwwLDEsMi0ySDE3Mi42OWEyLDIsMCwwLDEsMS40MS41OEwyMDkuNDIsODEuOWEyLDIsMCwwLDEsLjU4LDEuNDFaTTE1OCw3MmE2LDYsMCwwLDEtNiw2SDk2YTYsNiwwLDAsMSwwLTEyaDU2QTYsNiwwLDAsMSwxNTgsNzJaIi8+PC9zdmc+');}.icon-x-circle{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0xNjQuMjQsMTAwLjI0LDEzNi40OCwxMjhsMjcuNzYsMjcuNzZhNiw2LDAsMSwxLTguNDgsOC40OEwxMjgsMTM2LjQ4bC0yNy43NiwyNy43NmE2LDYsMCwwLDEtOC40OC04LjQ4TDExOS41MiwxMjgsOTEuNzYsMTAwLjI0YTYsNiwwLDAsMSw4LjQ4LTguNDhMMTI4LDExOS41MmwyNy43Ni0yNy43NmE2LDYsMCwwLDEsOC40OCw4LjQ4Wk0yMzAsMTI4QTEwMiwxMDIsMCwxLDEsMTI4LDI2LDEwMi4xMiwxMDIuMTIsMCwwLDEsMjMwLDEyOFptLTEyLDBhOTAsOTAsMCwxLDAtOTAsOTBBOTAuMSw5MC4xLDAsMCwwLDIxOCwxMjhaIi8+PC9zdmc+');}.icon-minus-square{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0yMDgsMzRINDhBMTQsMTQsMCwwLDAsMzQsNDhWMjA4YTE0LDE0LDAsMCwwLDE0LDE0SDIwOGExNCwxNCwwLDAsMCwxNC0xNFY0OEExNCwxNCwwLDAsMCwyMDgsMzRabTIsMTc0YTIsMiwwLDAsMS0yLDJINDhhMiwyLDAsMCwxLTItMlY0OGEyLDIsMCwwLDEsMi0ySDIwOGEyLDIsMCwwLDEsMiwyWm0tMzYtODBhNiw2LDAsMCwxLTYsNkg4OGE2LDYsMCwwLDEsMC0xMmg4MEE2LDYsMCwwLDEsMTc0LDEyOFoiLz48L3N2Zz4=');}.icon-pencil-simple{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0yMjUuOSw3NC43OCwxODEuMjEsMzAuMDlhMTQsMTQsMCwwLDAtMTkuOCwwTDM4LjEsMTUzLjQxYTEzLjk0LDEzLjk0LDAsMCwwLTQuMSw5LjlWMjA4YTE0LDE0LDAsMCwwLDE0LDE0SDkyLjY5YTEzLjk0LDEzLjk0LDAsMCwwLDkuOS00LjFMMjI1LjksOTQuNThhMTQsMTQsMCwwLDAsMC0xOS44Wk05NC4xLDIwOS40MWEyLDIsMCwwLDEtMS40MS41OUg0OGEyLDIsMCwwLDEtMi0yVjE2My4zMWEyLDIsMCwwLDEsLjU5LTEuNDFMMTM2LDcyLjQ4LDE4My41MSwxMjBaTTIxNy40MSw4Ni4xLDE5MiwxMTEuNTEsMTQ0LjQ5LDY0LDE2OS45LDM4LjU4YTIsMiwwLDAsMSwyLjgzLDBsNDQuNjgsNDQuNjlhMiwyLDAsMCwxLDAsMi44M1oiLz48L3N2Zz4=');}.icon-dots-six-vertical{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0xMDIsNjBBMTAsMTAsMCwxLDEsOTIsNTAsMTAsMTAsMCwwLDEsMTAyLDYwWm02MiwxMGExMCwxMCwwLDEsMC0xMC0xMEExMCwxMCwwLDAsMCwxNjQsNzBaTTkyLDExOGExMCwxMCwwLDEsMCwxMCwxMEExMCwxMCwwLDAsMCw5MiwxMThabTcyLDBhMTAsMTAsMCwxLDAsMTAsMTBBMTAsMTAsMCwwLDAsMTY0LDExOFpNOTIsMTg2YTEwLDEwLDAsMSwwLDEwLDEwQTEwLDEwLDAsMCwwLDkyLDE4NlptNzIsMGExMCwxMCwwLDEsMCwxMCwxMEExMCwxMCwwLDAsMCwxNjQsMTg2WiIvPjwvc3ZnPg==');}.icon-list{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0yMjIsMTI4YTYsNiwwLDAsMS02LDZINDBhNiw2LDAsMCwxLDAtMTJIMjE2QTYsNiwwLDAsMSwyMjIsMTI4Wk00MCw3MEgyMTZhNiw2LDAsMCwwLDAtMTJINDBhNiw2LDAsMCwwLDAsMTJaTTIxNiwxODZINDBhNiw2LDAsMCwwLDAsMTJIMjE2YTYsNiwwLDAsMCwwLTEyWiIvPjwvc3ZnPg==');}.icon-loading{--icon:url('data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiIHN0YW5kYWxvbmU9Im5vIj8+PHN2ZyAgICB3aWR0aD0iMzIiICAgIGhlaWdodD0iMzIiICAgIHZpZXdCb3g9IjAgMCAzMiAzMiIgICAgdmVyc2lvbj0iMS4xIiAgICB4bWw6c3BhY2U9InByZXNlcnZlIiAgICBzdHlsZT0iY2xpcC1ydWxlOmV2ZW5vZGQ7ZmlsbC1ydWxlOmV2ZW5vZGQ7c3Ryb2tlLWxpbmVjYXA6cm91bmQ7c3Ryb2tlLWxpbmVqb2luOnJvdW5kO3N0cm9rZS1taXRlcmxpbWl0OjEuNSIgICAgaWQ9InN2ZzEwIiAgICB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciICAgIHhtbG5zOnN2Zz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPjxkZWZzICAgIGlkPSJkZWZzMTAiIC8+PHBhdGggICAgaWQ9InBhdGgxMSIgICAgc3R5bGU9ImJhc2VsaW5lLXNoaWZ0OmJhc2VsaW5lO2Rpc3BsYXk6aW5saW5lO292ZXJmbG93OnZpc2libGU7dmVjdG9yLWVmZmVjdDpub25lO2ZpbGw6IzIyMjIyMjtlbmFibGUtYmFja2dyb3VuZDphY2N1bXVsYXRlO3N0b3AtY29sb3I6IzAwMDAwMDtzdG9wLW9wYWNpdHk6MSIgICAgZD0ibSAxNi42MjEwOTQsMS4xNDI1NzgxIGMgLTguMjY2MzIzMiwwIC0xNi4yMjA4NjczOCw2LjQ0MjgwOTUgLTE1LjU4NTkzNzgsMTQuNjg1NTQ2OSAwLjYwMTM0NTUsNy44MDczMDggNy40MzQxMjY0LDE0LjEyNjk4IDE0LjkzMzU5MzgsMTQuOTQzMzU5IDguODM5ODQ1LDAuOTYyMjgzIDE1LjUwNTQ2OSwtNi4zNzY5MTkgMTUuMDA1ODU5LC0xNC45ODYzMjggQyAzMC40OTU5LDcuNTM2MjY4NCAyNC44ODMzOTcsMS4xNDI1NzgxIDE2LjYyMTA5NCwxLjE0MjU3ODEgWiBtIDAsMC42NTAzOTA3IEMgMjYuNDg4Nzg2LDEuODAzODY0NSAyOS43MTQ1MTgsOS41OTM1ODMzIDMwLjMwMjczNCwxNS44MDQ2ODggMzEuMTQxOTgyLDI0LjY2NjM2NSAyMi4xNjA0NTksMzEuMTY4MDc3IDE2LjAzOTA2MiwzMC4xMjUgOC44OTUxMzI3LDI4LjkwNzY4MSAyLjI2MTMxNDIsMjMuMjc5Mzc2IDEuNjgzNTkzOCwxNS43NzkyOTcgMS4wNzY5MzM4LDcuOTAzMjc1NCA4LjcyMjU0NTEsMS43ODQyNjk5IDE2LjYyMTA5NCwxLjc5Mjk2ODggWiBtIC0wLjA2NDQ1LDEuMjE4NzUgYyAtMy42MTAwODMsMCAtNy4xNTQ3OTk1LDEuNDAxMDY4NyAtOS43MzA0NjkxLDMuNzAzMTI1IEMgNC4yNTA1MDIzLDkuMDE2OTAwMiAyLjY0MjAzNzIsMTIuMjI2Mjk1IDIuOTE5OTIxOSwxNS44MzM5ODQgMy40NDY5MzUsMjIuNjc1NzEyIDkuNDI4OTY0OSwyOC4xOTg5ODUgMTUuOTk4MDQ3LDI4LjkxNDA2MiAyMy43MTQyNTYsMjkuNzU0MDIzIDI5LjUzMTYwMywyMy4zMzE3IDI5LjA5NTcwMywxNS44MjAzMTIgMjguNjc3OTQ4LDguNjIxMzk1MyAyMy43NzY2ODYsMy4wMTE3MTg4IDE2LjU1NjY0MSwzLjAxMTcxODggWiBtIDAsMC4xOTUzMTI0IGMgNy4xMTkxMzQsMCAxMS45MzI3MSw1LjUwODEzNzMgMTIuMzQ1NzAzLDEyLjYyNDk5OTggQyAyOS4zMzIwNjIsMjMuMjM2ODk2IDIzLjYxODk1OCwyOS41NDU5OTggMTYuMDE5NTMxLDI4LjcxODc1IDkuNTQ1NDMyMSwyOC4wMTQwMTIgMy42MzQxNjM3LDIyLjU1NTE0MyAzLjExNTIzNDQsMTUuODE4MzU5IDIuODQyNDU2MywxMi4yNzY5NjcgNC40MTg0MTA5LDkuMTI4MzE2OSA2Ljk1NzAzMTIsNi44NTkzNzUgOS40OTU2NTE2LDQuNTkwNDMzMSAxMi45OTcwOTMsMy4yMDcwMzEyIDE2LjU1NjY0MSwzLjIwNzAzMTIgWiBtIC0wLjA3MDMxLDEuNDE2MDE1NyBjIC0zLjE2MTk3MywwIC02LjI2MzUwOSwxLjIyNTgxMzkgLTguNTE5NTMxMSwzLjI0MjE4NzUgQyA1LjcxMDc2OTEsOS44ODE2MDggNC4zMDE0NTQyLDEyLjY5NDU4OSA0LjU0NDkyMTksMTUuODU1NDY5IDUuMDA2NTYyNCwyMS44NDg1NTQgMTAuMjQ0MTc4LDI2LjY4NjE1OSAxNS45OTgwNDcsMjcuMzEyNSAyMi43NTcwMTMsMjguMDQ4MjYxIDI3Ljg1NDQ1MSwyMi40MjA5MzYgMjcuNDcyNjU2LDE1Ljg0MTc5NyAyNy4xMDY4MjQsOS41Mzc2MDI1IDIyLjgxMDE2LDQuNjIzMDQ2OSAxNi40ODYzMjgsNC42MjMwNDY5IFogbSAwLDAuMTk1MzEyNSBjIDYuMjIyOTIsMCAxMC40Mjk5NDYsNC44MTMwMTM4IDEwLjc5MTAxNiwxMS4wMzUxNTY2IDAuMzc1NjEzLDYuNDcyNjE1IC00LjYxNzU4NCwxMS45ODY3MiAtMTEuMjU5NzY2LDExLjI2MzY3MiBDIDEwLjM1ODY4NSwyNi41MDExODYgNS4xOTE4MzgxLDIxLjcyNzk4NSA0LjczODI4MTIsMTUuODM5ODQ0IDQuNDk5OTIwMSwxMi43NDUyNjIgNS44NzY3MzE1LDkuOTk0OTc3OCA4LjA5NTcwMzEsOC4wMTE3MTg4IDEwLjMxNDY3NSw2LjAyODQ1OTUgMTMuMzc0ODksNC44MTgzNTk0IDE2LjQ4NjMyOCw0LjgxODM1OTQgWiBtIC0wLjA2ODM2LDEuNDE2MDE1NiBjIC0yLjcxMzg3NywwIC01LjM3NjExOCwxLjA1MjUxNjQgLTcuMzEyNTAwMiwyLjc4MzIwMzEgLTEuOTM2MzgyOCwxLjczMDY4NjkgLTMuMTQ2NTUxNyw0LjE0NTMxMTkgLTIuOTM3NSw2Ljg1OTM3NDkgMC4zOTYyNjk5LDUuMTQ0NDMgNC44ODk0NDQyLDkuMjk0NDI5IDkuODI4MTI1Miw5LjgzMjAzMSA1LjgwMTc0OSwwLjYzMTU2MiAxMC4xNzkyNTcsLTQuMTk4ODI4IDkuODUxNTYyLC05Ljg0NTcwMyBDIDI1LjUzMzc1LDEwLjQ1MzgyMiAyMS44NDU2MTYsNi4yMzQzNzUgMTYuNDE3OTc0LDYuMjM0Mzc1IFogbSAwLDAuMTk1MzEyNSBjIDUuMzI2NzMsMCA4LjkyNTIyNiw0LjExNzkwNTUgOS4yMzQzNzUsOS40NDUzMTI1IDAuMzIxNTEzLDUuNTQwMzUxIC0zLjk0OTgwMSwxMC4yNTk0NzQgLTkuNjM0NzY2LDkuNjQwNjI1IEMgMTEuMTczODc1LDI0Ljk4ODM2MiA2Ljc0OTUxNDMsMjAuOTAwODE0IDYuMzYxMzI4MSwxNS44NjEzMjggNi4xNTczODMxLDEzLjIxMzU2MyA3LjMzNTA0MzEsMTAuODU5NjgyIDkuMjM0Mzc1LDkuMTYyMTA5NCAxMS4xMzM3MDcsNy40NjQ1MzcyIDEzLjc1NDYyOCw2LjQyOTY4NzUgMTYuNDE3OTY5LDYuNDI5Njg3NSBaIG0gLTAuMDY4MzYsMS40MTYwMTU2IGMgLTIuMjY1Nzc1LDAgLTQuNDg4NzI5LDAuODc5MjE5NiAtNi4xMDU0NjgsMi4zMjQyMTg5IC0xLjYxNjc0MDgsMS40NDQ5OTkgLTIuNjI3NzYwNywzLjQ2MTI2OSAtMi40NTMxMjU0LDUuNzI4NTE2IDAuMzMwODk4Niw0LjI5NTc2OCA0LjA4MTU5NjQsNy43NjAxMiA4LjIwNTA3ODQsOC4yMDg5ODQgNC44NDQ1MjUsMC41MjczNiA4LjUwMDE1NiwtMy41MDYwOTcgOC4yMjY1NjIsLTguMjIwNzAzIEMgMjMuOTYwNjcyLDExLjM3MTk5NiAyMC44ODEwNiw3Ljg0NTcwMzEgMTYuMzQ5NjE0LDcuODQ1NzAzMSBaIG0gMCwwLjE5NTMxMjUgYyA0LjQzMDUzNCwwIDcuNDIyNDYxLDMuNDIyNzk5NCA3LjY3OTY4OCw3Ljg1NTQ2ODQgMC4yNjc0MTIsNC42MDgwODIgLTMuMjgzOTc4LDguNTMyMjI2IC04LjAxMTcxOSw4LjAxNzU3OCBDIDExLjk4OTA3NSwyMy40NzU1MzggOC4zMDcxODk5LDIwLjA3NTU5MyA3Ljk4NDM3NSwxNS44ODQ3NjYgNy44MTQ4NDYzLDEzLjY4MzgxOSA4Ljc5NTMxMDUsMTEuNzI2MzM4IDEwLjM3NSwxMC4zMTQ0NTMgMTEuOTU0Njg5LDguOTAyNTY4OSAxNC4xMzQzNyw4LjA0MTAxNTYgMTYuMzQ5NjA5LDguMDQxMDE1NiBaIG0gLTAuMDY4MzYsMS40MTYwMTU2IGMgLTEuODE3NjcyLDAgLTMuNjAxMzQyLDAuNzAzOTY4OCAtNC44OTg0MzgsMS44NjMyODA4IC0xLjI5NzA5NSwxLjE1OTMxIC0yLjEwODk2ODMsMi43NzkxODUgLTEuOTY4NzQ5NSw0LjU5OTYxIDAuMjY1NTI2OSwzLjQ0NzExMSAzLjI3Mzc1MDUsNi4yMjU4MTMgNi41ODIwMzE1LDYuNTg1OTM3IDMuODg3Mjk1LDAuNDIzMTYgNi44MjMwMDgsLTIuODE1MzE4IDYuNjAzNTE1LC02LjU5NzY1NiBDIDIyLjM4OTU0MSwxMi4yODgyMjIgMTkuOTE2NDk1LDkuNDU3MDMxMSAxNi4yODEyNSw5LjQ1NzAzMTIgWiBtIDAsMC4xOTUzMTI2IGMgMy41MzQzMzMsMCA1LjkxNzc0MiwyLjcyNzY5NjIgNi4xMjMwNDcsNi4yNjU2MjUyIDAuMjEzMzExLDMuNjc1ODE0IC0yLjYxNjIwOCw2LjgwMzAyNSAtNi4zODY3MTksNi4zOTI1NzggLTMuMjEzMjk4LC0wLjM0OTc4NSAtNi4xNTA3NTk3LC0zLjA2MjEzIC02LjQwODIwMywtNi40MDQyOTcgLTAuMTM1MTEyMiwtMS43NTQxMjcgMC42NDQyNTIsLTMuMzEzMjU3IDEuOTA0Mjk3LC00LjQzOTQ1MyAxLjI2MDA0NSwtMS4xMjYxOTYgMy4wMDA0NDEsLTEuODE0NDUzMyA0Ljc2NzU3OCwtMS44MTQ0NTMyIHogbSAtMC4wNzAzMSwxLjQxNjAxNTIgYyAtMS4zNjk1NzIsMCAtMi43MTIsMC41MzA2NzUgLTMuNjg5NDU0LDEuNDA0Mjk3IC0wLjk3NzQ1MywwLjg3MzYyMiAtMS41OTAxNzcsMi4wOTUxNDUgLTEuNDg0Mzc1LDMuNDY4NzUgMC4yMDAxNTYsMi41OTg0NTIgMi40NjU5LDQuNjg5NTUxIDQuOTU4OTg1LDQuOTYwOTM4IDIuOTMwMDcsMC4zMTg5NTggNS4xNDM5MDgsLTIuMTIyNTg3IDQuOTc4NTE1LC00Ljk3MjY1NiAtMC4xNTgxNDUsLTIuNzI1MjQ0IC0yLjAyNDYyMiwtNC44NjEzMjkgLTQuNzYzNjcxLC00Ljg2MTMyOSB6IG0gMCwwLjE5NTMxMyBjIDIuNjM4MTM1LDAgNC40MTQ5NzUsMi4wMzQ1NDQgNC41NjgzNTksNC42Nzc3MzQgMC4xNTkyMTEsMi43NDM1NDYgLTEuOTUwMzg2LDUuMDczODI0IC00Ljc2MzY3Miw0Ljc2NzU3OCAtMi4zOTgxMDIsLTAuMjYxMDQ3IC00LjU5MTEzMSwtMi4yODc3NDEgLTQuNzgzMjAzLC00Ljc4MTI1IC0wLjEwMDY5NiwtMS4zMDczMDggMC40Nzk1MTksLTIuNDcwMDM5IDEuNDE5OTIyLC0zLjMxMDU0NiAwLjk0MDQwMywtMC44NDA1MDggMi4yMzk1NTcsLTEuMzUzNTE2IDMuNTU4NTk0LC0xLjM1MzUxNiB6IG0gLTAuMDY4MzYsMS40MTYwMTYgYyAtMC45MjE0NzIsMCAtMS44MjI2NTcsMC4zNTU0MjUgLTIuNDgwNDY5LDAuOTQzMzU5IC0wLjY1NzgxMSwwLjU4NzkzNCAtMS4wNzMzMzksMS40MTUwMSAtMS4wMDE5NTMsMi4zNDE3OTcgMC4xMzQ3ODUsMS43NDk3OTIgMS42NTYwOTUsMy4xNTMyOTEgMy4zMzM5ODUsMy4zMzU5MzcgMS45NzI4NDYsMC4yMTQ3NTkgMy40NjY3NiwtMS40MzE4MDkgMy4zNTU0NjgsLTMuMzQ5NjA5IC0wLjEwNjIyNCwtMS44MzA1MDMgLTEuMzY0MTc3LC0zLjI3MTQ4NyAtMy4yMDcwMzEsLTMuMjcxNDg0IHogbSAwLDAuMTk1MzEyIGMgMS43NDE5NDIsMCAyLjkxMjIwOSwxLjMzOTQ0IDMuMDEzNjcyLDMuMDg3ODkxIDAuMTA1MTEsMS44MTEyNzYgLTEuMjg0NTYyLDMuMzQ2NTc3IC0zLjE0MDYyNSwzLjE0NDUzMSAtMS41ODI5MDcsLTAuMTcyMzA3IC0zLjAzMzQ1NSwtMS41MTMzNTUgLTMuMTYwMTU2LC0zLjE1ODIwMyAtMC4wNjYyOCwtMC44NjA0OSAwLjMxNDc4NSwtMS42MjQ4NjggMC45MzU1NDcsLTIuMTc5Njg4IDAuNjIwNzQ5LC0wLjU1NDgxOSAxLjQ4MDYyLC0wLjg5NDUzMSAyLjM1MTU1NiwtMC44OTQ1MzEgeiBtIC0wLjA2ODM2LDEuNDE2MDE2IGMgLTAuNDczMzY5LDAgLTAuOTM1MjcxLDAuMTgyMTI5IC0xLjI3MzQzOCwwLjQ4NDM3NSAtMC4zMzgxNjcsMC4zMDIyNDYgLTAuNTU0NTQ2LDAuNzMwOTY5IC0wLjUxNzU3OCwxLjIxMDkzNyAwLjA2OTQxLDAuOTAxMTMzIDAuODQ4MjQ5LDEuNjE4OTgxIDEuNzEwOTM4LDEuNzEyODkxIDEuMDE1NjE2LDAuMTEwNTU3IDEuNzg5NjE0LC0wLjc0MTAzMSAxLjczMjQyMSwtMS43MjY1NjMgLTAuMDU0MywtMC45MzU3NjYgLTAuNzA1NjkxLC0xLjY4MTY0IC0xLjY1MjM0MywtMS42ODE2NCB6IG0gMCwwLjE5NTMxMiBjIDAuODQ1NzQsMCAxLjQwNzQ5LDAuNjQ0MzMzIDEuNDU3MDMxLDEuNDk4MDQ3IDAuMDUxMDEsMC44NzkwMDggLTAuNjE2NzkzLDEuNjE5MzI5IC0xLjUxNTYyNSwxLjUyMTQ4NCAtMC43Njc3MDYsLTAuMDgzNTcgLTEuNDc1NzgsLTAuNzM4OTY3IC0xLjUzNzEwOSwtMS41MzUxNTYgLTAuMDMxODYsLTAuNDEzNjcxIDAuMTUwMDU1LC0wLjc3OTY5NyAwLjQ1MTE3MiwtMS4wNDg4MjggMC4zMDExMTYsLTAuMjY5MTMxIDAuNzIxNjk4LC0wLjQzNTU0NyAxLjE0NDUzMSwtMC40MzU1NDcgeiIgLz48L3N2Zz4=');}.icon-magnifying-glass{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0yMjguMjQsMjE5Ljc2bC01MS4zOC01MS4zOGE4Ni4xNSw4Ni4xNSwwLDEsMC04LjQ4LDguNDhsNTEuMzgsNTEuMzhhNiw2LDAsMCwwLDguNDgtOC40OFpNMzgsMTEyYTc0LDc0LDAsMSwxLDc0LDc0QTc0LjA5LDc0LjA5LDAsMCwxLDM4LDExMloiLz48L3N2Zz4=');}.icon-infinity{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0yNDYsMTI4YTU0LDU0LDAsMCwxLTkyLjE4LDM4LjE4LDMuMDcsMy4wNywwLDAsMS0uMjUtLjI2bC02MC02Ny43NGE0Miw0MiwwLDEsMCwwLDU5LjY0bDguNTctOS42N2E2LDYsMCwxLDEsOSw4bC04LjY5LDkuODFhMy4wNywzLjA3LDAsMCwxLS4yNS4yNiw1NCw1NCwwLDEsMSwwLTc2LjM2LDMuMDcsMy4wNywwLDAsMSwuMjUuMjZsNjAsNjcuNzRhNDIsNDIsMCwxLDAsMC01OS42NGwtOC41Nyw5LjY3YTYsNiwwLDEsMS05LThsOC42OS05LjgxYTMuMDcsMy4wNywwLDAsMSwuMjUtLjI2QTU0LDU0LDAsMCwxLDI0NiwxMjhaIi8+PC9zdmc+');}.icon-arrow-counter-clockwise{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0yMjIsMTI4YTk0LDk0LDAsMCwxLTkyLjc0LDk0SDEyOGE5My40Myw5My40MywwLDAsMS02NC41LTI1LjY1LDYsNiwwLDEsMSw4LjI0LTguNzJBODIsODIsMCwxLDAsNzAsNzBsLS4xOS4xOUwzOS40NCw5OEg3MmE2LDYsMCwwLDEsMCwxMkgyNGE2LDYsMCwwLDEtNi02VjU2YTYsNiwwLDAsMSwxMiwwVjkwLjM0TDYxLjYzLDYxLjRBOTQsOTQsMCwwLDEsMjIyLDEyOFoiLz48L3N2Zz4=');}.icon-clock{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0xMjgsMjZBMTAyLDEwMiwwLDEsMCwyMzAsMTI4LDEwMi4xMiwxMDIuMTIsMCwwLDAsMTI4LDI2Wm0wLDE5MmE5MCw5MCwwLDEsMSw5MC05MEE5MC4xLDkwLjEsMCwwLDEsMTI4LDIxOFptNjItOTBhNiw2LDAsMCwxLTYsNkgxMjhhNiw2LDAsMCwxLTYtNlY3MmE2LDYsMCwwLDEsMTIsMHY1MGg1MEE2LDYsMCwwLDEsMTkwLDEyOFoiLz48L3N2Zz4=');}.icon-house{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0yMTcuOSwxMTAuMWwtODAtODBhMTQsMTQsMCwwLDAtMTkuOCwwbC04MCw4MEExMy45MiwxMy45MiwwLDAsMCwzNCwxMjB2OTZhNiw2LDAsMCwwLDYsNmg2NGE2LDYsMCwwLDAsNi02VjE1OGgzNnY1OGE2LDYsMCwwLDAsNiw2aDY0YTYsNiwwLDAsMCw2LTZWMTIwQTEzLjkyLDEzLjkyLDAsMCwwLDIxNy45LDExMC4xWk0yMTAsMjEwSDE1OFYxNTJhNiw2LDAsMCwwLTYtNkgxMDRhNiw2LDAsMCwwLTYsNnY1OEg0NlYxMjBhMiwyLDAsMCwxLC41OC0xLjQybDgwLTgwYTIsMiwwLDAsMSwyLjg0LDBsODAsODBBMiwyLDAsMCwxLDIxMCwxMjBaIi8+PC9zdmc+');}.icon-logo{--icon:url('data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiIHN0YW5kYWxvbmU9Im5vIj8+PHN2ZyAgICB3aWR0aD0iMzIiICAgIGhlaWdodD0iMzIiICAgIHZpZXdCb3g9IjAgMCAzMiAzMiIgICAgdmVyc2lvbj0iMS4xIiAgICB4bWw6c3BhY2U9InByZXNlcnZlIiAgICBzdHlsZT0iY2xpcC1ydWxlOmV2ZW5vZGQ7ZmlsbC1ydWxlOmV2ZW5vZGQ7c3Ryb2tlLWxpbmVjYXA6c3F1YXJlO3N0cm9rZS1saW5lam9pbjpyb3VuZDtzdHJva2UtbWl0ZXJsaW1pdDoxLjUiICAgIGlkPSJzdmcxNCIgICAgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIiAgICB4bWxuczpzdmc9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48ZGVmcyAgICBpZD0iZGVmczE0IiAvPjxwYXRoICAgIGQ9Ik0gMTYuNTgwMDc4LDIuMTMyODEyNSBDIDguODY0ODQ0OSwyLjEzMjgxMjUgMS40NDA2MDIxLDguMTQ2NjIxOCAyLjAzMzIwMzEsMTUuODM5ODQ0IDIuNTk0NDU4OCwyMy4xMjY2NjYgOC45NzEyMDEyLDI5LjAyNTE1NSAxNS45NzA3MDMsMjkuNzg3MTA5IDI0LjIyMTIyNCwzMC42ODUyNCAzMC40NDA5MTEsMjMuODM0Mjc3IDI5Ljk3NDYwOSwxNS43OTg4MjggMjkuNTI3ODEzLDguMDk5ODY1NSAyNC4yOTE1NiwyLjEzMjgxMjUgMTYuNTgwMDc4LDIuMTMyODEyNSBaIG0gMCwwLjYwNzQyMTkgYyAwLjAxMDQ2LDAgMC4wMjA4MywwIDAuMDMxMjUsMCBWIDI5LjIzMjQyMiBjIC0wLjE5MDMyMywtMC4wMTIxOCAtMC4zODE1MjEsLTAuMDI3ODMgLTAuNTc0MjE5LC0wLjA0ODgzIEMgOS4zMTMwNDUzLDI4LjQ1MTYxNSAzLjE3Nzg3NzUsMjIuNzkzMDQ0IDIuNjM4NjcxOSwxNS43OTI5NjkgMi4wNzI0NTYsOC40NDIwMTUzIDkuMjA4MTAwOCwyLjc0MDIzNDQgMTYuNTgwMDc4LDIuNzQwMjM0NCBaIE0gMTYuMDkxNzk3LDMuODg0NzY1NiAxNiwzLjg4ODY3MTkgQyAxMi43MjU0NTQsNC4wMTgzNDg5IDkuNTUyMzM3OSw1LjM2NDY4MzggNy4yNTU4NTk0LDcuNSA0Ljk1OTM4MDksOS42MzUzMTYyIDMuNTQwMjcwMywxMi41NjQ5NzIgMy43OTI5Njg4LDE1Ljg0NTcwMyA0LjI4NDc3MzksMjIuMjMwMDQ1IDkuODY0NDgxMiwyNy4zODM2MDYgMTUuOTk0MTQxLDI4LjA1MjczNCBsIDAuMDg5ODQsMC4wMDk4IDAuMDIxNDgsLTAuMTgxNjQxIC0wLjA5MTgsLTAuMDA5OCBDIDkuOTcyNjc0OSwyNy4yMTE2NDQgNC40NTg4NjkxLDIyLjExNjQ2OCAzLjk3NDYwOTQsMTUuODMwMDc4IDMuNzI2NTU1OSwxMi42MDk2NTEgNS4xMTU4MDg0LDkuNzM5MDQzNyA3LjM3ODkwNjIsNy42MzQ3NjU2IDkuNjQyMDA0MSw1LjUzMDQ4NzUgMTIuNzc4NTM5LDQuMTk4MTk2OCAxNi4wMDc4MTIsNC4wNzAzMTI1IGwgMC4wOTE4LC0wLjAwMzkxIHogbSAwLDEuNTAxOTUzMSBMIDE2LDUuMzkwNjI1IEMgMTMuMTMxOTQ5LDUuNTA0MjA0NyAxMC4zNTMyOTgsNi42ODQzNDE1IDguMzQxNzk2OSw4LjU1NDY4NzUgNi4zMzAyOTYyLDEwLjQyNTAzMyA1LjA4NzE5MjksMTIuOTkwODE5IDUuMzA4NTkzNywxNS44NjUyMzQgNS43MzkzOTQsMjEuNDU3NjY5IDEwLjYyNTE2MSwyNS45NzA1NDcgMTUuOTk0MTQxLDI2LjU1NjY0MSBsIDAuMDg5ODQsMC4wMDk4IDAuMDIxNDgsLTAuMTc5Njg3IC0wLjA5MTgsLTAuMDA5OCBDIDEwLjczMzM1NCwyNS44MDA1MzMgNS45MTM0ODkyLDIxLjM0NjA0NiA1LjQ5MDIzNDQsMTUuODUxNTYzIDUuMjczNDc4NCwxMy4wMzc0NTEgNi40ODY3MjM3LDEwLjUyNjgwOCA4LjQ2NDg0MzgsOC42ODc1IDEwLjQ0Mjk2NCw2Ljg0ODE5MjIgMTMuMTg1MDMyLDUuNjg0MDUyNiAxNi4wMDc4MTIsNS41NzIyNjU2IGwgMC4wOTE4LC0wLjAwMzkxIHogbSAwLDEuNTAzOTA2MyBMIDE2LDYuODkyNTc4MSBjIC0yLjQ2MTU3NywwLjA5NzQ4MyAtNC44NDU3MjgsMS4xMTE0MTc0IC02LjU3MjI2NTYsMi43MTY3OTY5IC0xLjcyNjUzOCwxLjYwNTM4IC0yLjc5NTU3NDEsMy44MDcyODIgLTIuNjA1NDY4OCw2LjI3NTM5MSAwLjM2OTc5ODYsNC44MDA1NCA0LjU2MzUzMzQsOC42NzQ2NzQgOS4xNzE4NzU0LDkuMTc3NzM0IGwgMC4wODk4NCwwLjAwOTggMC4wMjE0OCwtMC4xODE2NDEgLTAuMDkxOCwtMC4wMDk4IEMgMTEuNDk0MDM3LDI0LjM4NzQ4MSA3LjM2NjE1NTcsMjAuNTczNjM0IDcuMDAzOTA2MiwxNS44NzEwOTQgNi44MTg0NDgxLDEzLjQ2MzMyIDcuODU3NjQwNSwxMS4zMTY1MTMgOS41NTA3ODEzLDkuNzQyMTg3NSAxMS4yNDM5MjIsOC4xNjc4NjE4IDEzLjU5MTUyOSw3LjE3MTg2MDggMTYuMDA3ODEyLDcuMDc2MTcxOSBsIDAuMDkxOCwtMC4wMDM5MSB6IG0gMCwxLjUwMTk1MzEgTCAxNiw4LjM5NjQ4NDQgYyAtMi4wNTUwNzMsMC4wODEzODQgLTQuMDQ0Nzc1LDAuOTI1MjMzNCAtNS40ODYzMjgsMi4yNjU2MjQ2IC0xLjQ0MTU1MzUsMS4zNDAzOTMgLTIuMzM0NTg4MSwzLjE4MjM3OSAtMi4xNzU3ODE0LDUuMjQ0MTQxIDAuMzA4NzkyLDQuMDA4NTc5IDMuODA4NjA1NCw3LjI0MDEzNiA3LjY1NjI1MDQsNy42NjAxNTYgbCAwLjA4OTg0LDAuMDA5OCAwLjAwNzgsLTAuMDY4MzYgdiAtMC4wMDIgbCAwLjAwMiwtMC4wMDk4IGMgOS40OWUtNCwtMC4wMDM0IDAuMDAzNSwtMC4wMDYyIDAuMDAzOSwtMC4wMDk4IDYuNDJlLTQsLTAuMDA2NyA4LjAyZS00LC0wLjAxMzAxIDAsLTAuMDE5NTMgbCAwLjAwNzgsLTAuMDcyMjcgLTAuMDkxOCwtMC4wMDk4IEMgMTIuMjU0NjY3LDIyLjk3NDQyMiA4LjgyMDc3OTYsMTkuODAzMjMxIDguNTE5NTMxMywxNS44OTI1NzggOC4zNjUzNjgyLDEzLjg5MTEwNSA5LjIyODUzNzMsMTIuMTA2MjM3IDEwLjYzNjcxOSwxMC43OTY4NzUgMTIuMDQ0OSw5LjQ4NzUxMyAxMy45OTc5OTksOC42NTc3MTcgMTYuMDA3ODEyLDguNTc4MTI1IGwgMC4wOTE4LC0wLjAwMzkxIHogbSAwLDEuNTAxOTUzMiBMIDE2LDkuODk4NDM3NSBjIC0xLjY0ODU4OCwwLjA2NTI4NyAtMy4yNDU3NjEsMC43NDI5Mzg1IC00LjQwMjM0NCwxLjgxODM1OTUgLTEuMTU2NTgyLDEuMDc1NDIxIC0xLjg3MTY1MDYsMi41NTM1MzggLTEuNzQ0MTQwNCw0LjIwODk4NCAwLjI0Nzc4ODQsMy4yMTY2NjkgMy4wNTM2NDE0LDUuODA5NTAxIDYuMTQwNjI1NCw2LjE0NjQ4NSBsIDAuMDg5ODQsMC4wMDk4IDAuMDIxNDgsLTAuMTgxNjQgLTAuMDkxOCwtMC4wMDk4IGMgLTIuOTk4MzQ0LC0wLjMyNzMwOCAtNS43MzgyNzMsLTIuODU5Nzk4IC01Ljk3ODUxNiwtNS45Nzg1MTYgLTAuMTIyODY0NSwtMS41OTUxNDIgMC41NjQyOTgsLTMuMDE4MTE3IDEuNjg3NSwtNC4wNjI1IDEuMTIzMjAyLC0xLjA0NDM4MiAyLjY4MTgzOSwtMS43MDYwMzcgNC4yODUxNTYsLTEuNzY5NTMxIGwgMC4wOTE4LC0wLjAwMzkgeiBtIDAsMS41MDM5MDY3IC0wLjA5MTgsMC4wMDIgYyAtMS4yNDIwOTUsMC4wNDkxOSAtMi40NDQ4LDAuNTYwNjUyIC0zLjMxNjQwNiwxLjM3MTA5MyAtMC44NzE2MDYsMC44MTA0NDIgLTEuNDEyNjE5LDEuOTI0NzEzIC0xLjMxNjQwNiwzLjE3MzgyOSAwLjE4Njc4MywyLjQyNDczMiAyLjMwMDY0Myw0LjM3NjkxMyA0LjYyNjk1Myw0LjYzMDg1OSBsIDAuMDg5ODQsMC4wMDk4IDAuMDIxNDgsLTAuMTgxNjQgLTAuMDkxOCwtMC4wMDk4IGMgLTIuMjM3NjkyLC0wLjI0NDI3MiAtNC4yODU2MDQsLTIuMTM2MDgzIC00LjQ2NDg0NCwtNC40NjI4OSAtMC4wOTE1NywtMS4xODg4MjYgMC40MjE1MzIsLTIuMjQ3OTMzIDEuMjU5NzY2LC0zLjAyNzM0NCAwLjgzODIzNCwtMC43Nzk0MTEgMi4wMDIzODMsLTEuMjcyOTE2IDMuMTk5MjE4LC0xLjMyMDMxMyBsIDAuMDkxOCwtMC4wMDM5IHogbSAwLDEuNTAxOTUzIC0wLjA5MTgsMC4wMDM5IGMgLTAuODM1NjEzLDAuMDMzMDkgLTEuNjQzODMxLDAuMzc0NDUyIC0yLjIzMDQ2OSwwLjkxOTkyMiAtMC41ODY2MzcsMC41NDU0NyAtMC45NTE2MzYsMS4yOTk3NzggLTAuODg2NzE4LDIuMTQyNTc4IDAuMTI1NzgsMS42MzI4MjEgMS41NDU2NzMsMi45NDIzNyAzLjExMTMyOCwzLjExMzI4MSAtMC4wMDI2LC0yLjhlLTQgLTAuMDA1MywyLjg2ZS00IC0wLjAwNzgsMCAwLjAwMyw2LjAzZS00IDAuMDA2NiwwLjAwMTcgMC4wMDk4LDAuMDAyIDAuMDAzMSwzLjA4ZS00IDAuMDA2NywxLjJlLTUgMC4wMDk4LDAgbCAwLjA3ODEzLDAuMDA5OCAwLjAyMTQ4LC0wLjE4MTY0MSAtMC4wOTE4LC0wLjAwOTggYyAtMS40NzcwMTUsLTAuMTYxMjM1IC0yLjgzMDk4NCwtMS40MTIzOTYgLTIuOTQ5MjE5LC0yLjk0NzI2NiAtMC4wNjAyNywtMC43ODI0OTYgMC4yNzY4MjIsLTEuNDc5NzA5IDAuODMwMDc4LC0xLjk5NDE0MSAwLjU1MzI1NywtMC41MTQ0MzEgMS4zMjI5MzksLTAuODQxNzQ4IDIuMTEzMjgxLC0wLjg3MzA0NiBsIDAuMDkxOCwtMC4wMDM5IHogbSAwLDEuNTAxOTUzIEwgMTYsMTQuNDA2MjUgYyAtMC40MjkxMTcsMC4wMTY5OSAtMC44NDI4NzMsMC4xOTIxNjYgLTEuMTQ0NTMxLDAuNDcyNjU2IC0wLjMwMTY1OSwwLjI4MDQ5MSAtMC40OTA2NDgsMC42NzA5NTUgLTAuNDU3MDMxLDEuMTA3NDIyIDAuMDY0NzcsMC44NDA4ODYgMC43OTA3MjksMS41MTE3MzIgMS41OTU3MDMsMS41OTk2MDkgbCAwLjA4OTg0LDAuMDA5OCAwLjAyMTQ4LC0wLjE4MTY0MSAtMC4wOTE4LC0wLjAwOTggYyAtMC43MTYzNTcsLTAuMDc4MiAtMS4zNzYzNjIsLTAuNjg4NjgxIC0xLjQzMzU5NCwtMS40MzE2NDEgLTAuMDI4OTcsLTAuMzc2MTc5IDAuMTMyMTA1LC0wLjcxMTQ3NyAwLjQwMDM5MSwtMC45NjA5MzcgMC4yNjgyODYsLTAuMjQ5NDYxIDAuNjQzNDg1LC0wLjQwODYyNyAxLjAyNzM0MywtMC40MjM4MjggbCAwLjA5MTgsLTAuMDAzOSB6IiAgICBzdHlsZT0iYmFzZWxpbmUtc2hpZnQ6YmFzZWxpbmU7Y2xpcC1ydWxlOm5vbnplcm87ZGlzcGxheTppbmxpbmU7b3ZlcmZsb3c6dmlzaWJsZTt2ZWN0b3ItZWZmZWN0Om5vbmU7ZmlsbDojMjIyMjIyO2ZpbGwtcnVsZTpub256ZXJvO3N0cm9rZS1saW5lY2FwOmJ1dHQ7c3Ryb2tlLWxpbmVqb2luOm1pdGVyO2VuYWJsZS1iYWNrZ3JvdW5kOmFjY3VtdWxhdGU7c3RvcC1jb2xvcjojMDAwMDAwO3N0b3Atb3BhY2l0eToxIiAgICBpZD0icGF0aDI3IiAvPjwvc3ZnPg==');}.icon-jakevan{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHhtbDpzcGFjZT0icHJlc2VydmUiIHN0eWxlPSJjbGlwLXJ1bGU6ZXZlbm9kZDtmaWxsLXJ1bGU6ZXZlbm9kZDtzdHJva2UtbGluZWpvaW46cm91bmQ7c3Ryb2tlLW1pdGVybGltaXQ6MiIgdmlld0JveD0iMCAwIDMyIDMyIj48cGF0aCBkPSJNMTcuODggMTQuNjhIMTIuOWwtLjQzLTEuNjNIOS41OGwtLjQ1IDEuNjNINi41bDIuODktOC43NGgzLjJsMi44OSA4LjY0di04LjZoMi40djMuNzhjLjEtLjIuMjItLjM4LjM1LS41Ny4xMy0uMi4yNi0uMzcuMzktLjU0bDEuODYtMi42N2g3Ljh2MS44OUgyNS40djEuMzdoMi42NXYxLjg4SDI1LjR2MS42NWgyLjg2djEuOTFoLTcuOTNsLTEuNzUtMy4zMi0uNy40MXptNS4xMy04LjU5LTIuNyAzLjc5IDIuNyA0Ljc0em0tMTEuMDUgNS4wMy0uMzgtMS40M2ExMzYuODYgMTM2Ljg2IDAgMCAwLS40LTEuNTVMMTEgNy4zOGExNy43NiAxNy43NiAwIDAgMS0uMzYgMS42bC0uMTguNzEtLjM5IDEuNDN6bS04LjU4IDYuM2E1Ljc0IDUuNzQgMCAwIDEtMS4yNC0uMTN2LTEuODNsLjQxLjA4Yy4xNS4wMy4zLjA1LjQ3LjA1LjMgMCAuNTEtLjA2LjY3LS4xN2EuOTIuOTIgMCAwIDAgLjM0LS41MmMuMDYtLjIzLjEtLjUyLjEtLjg2VjUuOThoMi40djcuODVjMCAuODgtLjEzIDEuNTctLjQgMi4xLS4yNi41Mi0uNjMuOS0xLjEgMS4xNC0uNDguMjMtMS4wMy4zNS0xLjY1LjM1WiIgc3R5bGU9ImZpbGw6Y3VycmVudENvbG9yO3N0cm9rZS13aWR0aDouMDE4NDM5MiIgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoMS40IC42Nikgc2NhbGUoLjk2MDUwMTM0KSIvPjxwYXRoIGQ9Ik0yMi44MiAyMi4yN2gtNC4wNmwtLjM3LTEuNEgxNS45bC0uMzkgMS40aC0yLjI2bDIuNDktNy41M2gyLjc1bDIuNDkgNy40NHYtNy40MWgyLjdsMi43NyA1LjIxaC4wM2E0MS4xIDQxLjEgMCAwIDEtLjA3LTEuODJ2LTMuMzloMS44M3Y3LjVoLTIuN2wtMi43OS01LjI4aC0uMDRhMTIuODMgMTIuODMgMCAwIDEgLjA4IDEuMjZsLjAyLjY0em0tNC44Ni0zLjA3LS4zMy0xLjIzYTg5LjA3IDg5LjA3IDAgMCAwLS4zNS0xLjM0bC0uMTQtLjY1YTE1LjA0IDE1LjA0IDAgMCAxLS4zMSAxLjM3bC0uMTYuNjItLjMzIDEuMjN6bS0zLjg1LTQuNDMtMi41IDcuNUg5LjJsLTIuNS03LjVoMi4zMmwxLjA0IDMuOGExNS4wMyAxNS4wMyAwIDAgMSAuMzYgMS43NiA3LjYxIDcuNjEgMCAwIDEgLjItMS4ybC4xNC0uNTQgMS4wNi0zLjgyeiIgc3R5bGU9ImZpbGw6Y3VycmVudENvbG9yO3N0cm9rZS13aWR0aDouMDE1OTg4NCIgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoMS40IC42Nikgc2NhbGUoLjk2MDUwMTM0KSIvPjxwYXRoIGQ9Ik0xMS45IDI0LjIxYzAgLjQtLjA3LjcyLS4yLjk5LS4xNS4yNi0uMzYuNDYtLjYzLjYtLjI4LjEzLS42Mi4yLTEuMDMuMkg5LjJ2LTMuNWguOTdjLjM4IDAgLjcuMDYuOTYuMTkuMjUuMTMuNDUuMzIuNTguNTguMTQuMjUuMi41Ny4yLjk0em0tLjI2LjAxYzAtLjMzLS4wNS0uNjEtLjE2LS44M2ExLjEgMS4xIDAgMCAwLS41MS0uNTEgMS45NSAxLjk1IDAgMCAwLS44Ny0uMTdoLS42NnYzLjA3aC42Yy41MyAwIC45My0uMTMgMS4yLS4zOS4yNy0uMjYuNC0uNjUuNC0xLjE3ek0xNC4yNyAyNmgtMS45NXYtMy41aDEuOTV2LjIyaC0xLjd2MS4zMmgxLjZ2LjIzaC0xLjZ2MS41aDEuN3ptMS4yOC0zLjVjLjI4IDAgLjUyLjAyLjcuMDhhLjguOCAwIDAgMSAuNDQuM2MuMS4xNC4xNC4zMy4xNC41N2EuOS45IDAgMCAxLS4xLjQ1Ljg3Ljg3IDAgMCAxLS4yNy4zMmMtLjEyLjA4LS4yNS4xNC0uNC4xOGwuOTggMS42aC0uM2wtLjkyLTEuNTNoLS44OVYyNmgtLjI1di0zLjV6bS0uMDMuMjFoLS41OXYxLjU1aC43MWMuMyAwIC41Mi0uMDcuNjktLjIxLjE2LS4xNC4yNC0uMzQuMjQtLjYgMC0uMjgtLjA5LS40Ny0uMjYtLjU4LS4xNy0uMS0uNDMtLjE2LS43OS0uMTZ6bTUuNTctLjIyTDIwLjEyIDI2aC0uMjVsLS43Ni0yLjY1LS4wNS0uMTYtLjA0LS4xNGExOC44IDE4LjggMCAwIDEtLjA2LS4yNCAyMC42IDIwLjYgMCAwIDEtLjExLjQ4TDE4LjA5IDI2aC0uMjVsLS45Ni0zLjVoLjI2bC42NyAyLjQ3YTI3LjM2IDI3LjM2IDAgMCAxIC4wOS4zNWwuMDQuMTcuMDMuMTUuMDMtLjE2YTQuODMgNC44MyAwIDAgMSAuMTQtLjUzbC43LTIuNDZoLjI1bC43MyAyLjQ4YTExLjk4IDExLjk4IDAgMCAxIC4xMy41M2wuMDQuMTVhMTEuMDIgMTEuMDIgMCAwIDEgLjE1LS42OGwuNjktMi40OHpNMjMuMjYgMjZoLTEuOTV2LTMuNWgxLjk1di4yMmgtMS43djEuMzJoMS42di4yM2gtMS42djEuNWgxLjd6bTEuMjgtMy41Yy4yOCAwIC41Mi4wMi43MS4wOGEuOC44IDAgMCAxIC40My4zYy4xLjE0LjE0LjMzLjE0LjU3YS45LjkgMCAwIDEtLjEuNDUuODcuODcgMCAwIDEtLjI3LjMyYy0uMTEuMDgtLjI1LjE0LS40LjE4bC45OCAxLjZoLS4zbC0uOTItMS41M2gtLjg4VjI2aC0uMjZ2LTMuNXptLS4wMi4yMWgtLjZ2MS41NWguNzJjLjI5IDAgLjUxLS4wNy42OC0uMjEuMTYtLjE0LjI0LS4zNC4yNC0uNiAwLS4yOC0uMDgtLjQ3LS4yNi0uNTgtLjE3LS4xLS40My0uMTYtLjc4LS4xNnpNMjYuNSAyNmgtLjI1di0zLjVoMS45NXYuMjJoLTEuN3YxLjQ5aDEuNnYuMjJoLTEuNnoiIHN0eWxlPSJmaWxsOmN1cnJlbnRDb2xvcjtzdHJva2Utd2lkdGg6LjAxMDEwNjgiIHRyYW5zZm9ybT0idHJhbnNsYXRlKDEuNCAuNjYpIHNjYWxlKC45NjA1MDEzNCkiLz48L3N2Zz4=');}.icon-user-square{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0yMDgsMzRINDhBMTQsMTQsMCwwLDAsMzQsNDhWMjA4YTE0LDE0LDAsMCwwLDE0LDE0SDIwOGExNCwxNCwwLDAsMCwxNC0xNFY0OEExNCwxNCwwLDAsMCwyMDgsMzRaTTk0LDEyMGEzNCwzNCwwLDEsMSwzNCwzNEEzNCwzNCwwLDAsMSw5NCwxMjBaTTY1Ljc3LDIxMGE2Ni40Myw2Ni40MywwLDAsMSwyMC43Ny0yOS4zNiw2Niw2NiwwLDAsMSw4Mi45MiwwQTY2LjQzLDY2LjQzLDAsMCwxLDE5MC4yMywyMTBaTTIxMCwyMDhhMiwyLDAsMCwxLTIsMmgtNS4xN2E3Ny44NSw3Ny44NSwwLDAsMC00OS4zOC01MS43MSw0Niw0NiwwLDEsMC01MC45LDBBNzcuODUsNzcuODUsMCwwLDAsNTMuMTcsMjEwSDQ4YTIsMiwwLDAsMS0yLTJWNDhhMiwyLDAsMCwxLDItMkgyMDhhMiwyLDAsMCwxLDIsMloiLz48L3N2Zz4=');}.icon-chat-teardrop{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0xMzIsMjZhOTguMTEsOTguMTEsMCwwLDAtOTgsOTh2ODRhMTQsMTQsMCwwLDAsMTQsMTRoODRhOTgsOTgsMCwwLDAsMC0xOTZabTAsMTg0SDQ4YTIsMiwwLDAsMS0yLTJWMTI0YTg2LDg2LDAsMSwxLDg2LDg2WiIvPjwvc3ZnPg==');}.icon-house-simple{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0yMTcuOSwxMTAuMWwtODAtODBhMTQsMTQsMCwwLDAtMTkuOCwwbC04MCw4MEExMy45MiwxMy45MiwwLDAsMCwzNCwxMjB2OTZhNiw2LDAsMCwwLDYsNkgyMTZhNiw2LDAsMCwwLDYtNlYxMjBBMTMuOTIsMTMuOTIsMCwwLDAsMjE3LjksMTEwLjFaTTIxMCwyMTBINDZWMTIwYTIsMiwwLDAsMSwuNTgtMS40Mmw4MC04MGEyLDIsMCwwLDEsMi44NCwwbDgwLDgwQTIsMiwwLDAsMSwyMTAsMTIwWiIvPjwvc3ZnPg==');}.icon-caret-left{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0xNjQuMjQsMjAzLjc2YTYsNiwwLDEsMS04LjQ4LDguNDhsLTgwLTgwYTYsNiwwLDAsMSwwLTguNDhsODAtODBhNiw2LDAsMCwxLDguNDgsOC40OEw4OC40OSwxMjhaIi8+PC9zdmc+');}.icon-chat{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0yMTYsNTBINDBBMTQsMTQsMCwwLDAsMjYsNjRWMjI0YTEzLjg4LDEzLjg4LDAsMCwwLDguMDksMTIuNjlBMTQuMTEsMTQuMTEsMCwwLDAsNDAsMjM4YTEzLjg3LDEzLjg3LDAsMCwwLDktMy4zMWwuMDYtLjA1TDgyLjIzLDIwNkgyMTZhMTQsMTQsMCwwLDAsMTQtMTRWNjRBMTQsMTQsMCwwLDAsMjE2LDUwWm0yLDE0MmEyLDIsMCwwLDEtMiwySDgwYTYsNiwwLDAsMC0zLjkyLDEuNDZMNDEuMjYsMjI1LjUzQTIsMiwwLDAsMSwzOCwyMjRWNjRhMiwyLDAsMCwxLDItMkgyMTZhMiwyLDAsMCwxLDIsMloiLz48L3N2Zz4=');}.icon-envelope{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0yMjQsNTBIMzJhNiw2LDAsMCwwLTYsNlYxOTJhMTQsMTQsMCwwLDAsMTQsMTRIMjE2YTE0LDE0LDAsMCwwLDE0LTE0VjU2QTYsNiwwLDAsMCwyMjQsNTBabS05Niw4NS44Nkw0Ny40Miw2MkgyMDguNThaTTEwMS42NywxMjgsMzgsMTg2LjM2VjY5LjY0Wm04Ljg4LDguMTRMMTI0LDE0OC40MmE2LDYsMCwwLDAsOC4xLDBsMTMuNC0xMi4yOEwyMDguNTgsMTk0SDQ3LjQzWk0xNTQuMzMsMTI4LDIxOCw2OS42NFYxODYuMzZaIi8+PC9zdmc+');}.icon-caret-right{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0xODAuMjQsMTMyLjI0bC04MCw4MGE2LDYsMCwwLDEtOC40OC04LjQ4TDE2Ny41MSwxMjgsOTEuNzYsNTIuMjRhNiw2LDAsMCwxLDguNDgtOC40OGw4MCw4MEE2LDYsMCwwLDEsMTgwLjI0LDEzMi4yNFoiLz48L3N2Zz4=');}.icon-calendar{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0yMDgsMzRIMTgyVjI0YTYsNiwwLDAsMC0xMiwwVjM0SDg2VjI0YTYsNiwwLDAsMC0xMiwwVjM0SDQ4QTE0LDE0LDAsMCwwLDM0LDQ4VjIwOGExNCwxNCwwLDAsMCwxNCwxNEgyMDhhMTQsMTQsMCwwLDAsMTQtMTRWNDhBMTQsMTQsMCwwLDAsMjA4LDM0Wk00OCw0Nkg3NFY1NmE2LDYsMCwwLDAsMTIsMFY0Nmg4NFY1NmE2LDYsMCwwLDAsMTIsMFY0NmgyNmEyLDIsMCwwLDEsMiwyVjgySDQ2VjQ4QTIsMiwwLDAsMSw0OCw0NlpNMjA4LDIxMEg0OGEyLDIsMCwwLDEtMi0yVjk0SDIxMFYyMDhBMiwyLDAsMCwxLDIwOCwyMTBabS05OC05MHY2NGE2LDYsMCwwLDEtMTIsMFYxMjkuNzFsLTcuMzIsMy42NmE2LDYsMCwxLDEtNS4zNi0xMC43NGwxNi04QTYsNiwwLDAsMSwxMTAsMTIwWm01OS41NywyOS4yNUwxNDgsMTc4aDIwYTYsNiwwLDAsMSwwLDEySDEzNmE2LDYsMCwwLDEtNC44LTkuNkwxNjAsMTQyYTEwLDEwLDAsMSwwLTE2LjY1LTExQTYsNiwwLDEsMSwxMzMsMTI1YTIyLDIyLDAsMSwxLDM2LjYyLDI0LjI2WiIvPjwvc3ZnPg==');}.icon-shuffle{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0yMzYuMjQsMTc5Ljc2YTYsNiwwLDAsMSwwLDguNDhsLTI0LDI0YTYsNiwwLDAsMS04LjQ4LTguNDhMMjE3LjUyLDE5MEgyMDAuOTRhNzAuMTYsNzAuMTYsMCwwLDEtNTctMjkuMzFsLTQxLjcxLTU4LjRBNTguMTEsNTguMTEsMCwwLDAsNTUuMDYsNzhIMzJhNiw2LDAsMCwxLDAtMTJINTUuMDZhNzAuMTYsNzAuMTYsMCwwLDEsNTcsMjkuMzFsNDEuNzEsNTguNEE1OC4xMSw1OC4xMSwwLDAsMCwyMDAuOTQsMTc4aDE2LjU4bC0xMy43Ni0xMy43NmE2LDYsMCwwLDEsOC40OC04LjQ4Wm0tOTIuMDYtNzQuNDFhNS45MSw1LjkxLDAsMCwwLDMuNDgsMS4xMiw2LDYsMCwwLDAsNC44OS0yLjUxbDEuMTktMS42N0E1OC4xMSw1OC4xMSwwLDAsMSwyMDAuOTQsNzhoMTYuNThMMjAzLjc2LDkxLjc2YTYsNiwwLDEsMCw4LjQ4LDguNDhsMjQtMjRhNiw2LDAsMCwwLDAtOC40OGwtMjQtMjRhNiw2LDAsMCwwLTguNDgsOC40OEwyMTcuNTIsNjZIMjAwLjk0YTcwLjE2LDcwLjE2LDAsMCwwLTU3LDI5LjMxTDE0Mi43OCw5N0E2LDYsMCwwLDAsMTQ0LjE4LDEwNS4zNVptLTMyLjM2LDQ1LjNhNiw2LDAsMCwwLTguMzcsMS4zOWwtMS4xOSwxLjY3QTU4LjExLDU4LjExLDAsMCwxLDU1LjA2LDE3OEgzMmE2LDYsMCwwLDAsMCwxMkg1NS4wNmE3MC4xNiw3MC4xNiwwLDAsMCw1Ny0yOS4zMWwxLjE5LTEuNjdBNiw2LDAsMCwwLDExMS44MiwxNTAuNjVaIi8+PC9zdmc+');}.icon-sort-descending{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik00MiwxMjhhNiw2LDAsMCwxLDYtNmg3MmE2LDYsMCwwLDEsMCwxMkg0OEE2LDYsMCwwLDEsNDIsMTI4Wm02LTU4aDU2YTYsNiwwLDAsMCwwLTEySDQ4YTYsNiwwLDAsMCwwLDEyWk0xODQsMTg2SDQ4YTYsNiwwLDAsMCwwLDEySDE4NGE2LDYsMCwwLDAsMC0xMlpNMjI4LjI0LDgzLjc2bC00MC00MGE2LDYsMCwwLDAtOC40OCwwbC00MCw0MGE2LDYsMCwwLDAsOC40OCw4LjQ4TDE3OCw2Mi40OVYxNDRhNiw2LDAsMCwwLDEyLDBWNjIuNDlsMjkuNzYsMjkuNzVhNiw2LDAsMCwwLDguNDgtOC40OFoiLz48L3N2Zz4=');}.icon-sort-ascending{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0xMjYsMTI4YTYsNiwwLDAsMS02LDZINDhhNiw2LDAsMCwxLDAtMTJoNzJBNiw2LDAsMCwxLDEyNiwxMjhaTTQ4LDcwSDE4NGE2LDYsMCwwLDAsMC0xMkg0OGE2LDYsMCwwLDAsMCwxMlptNTYsMTE2SDQ4YTYsNiwwLDAsMCwwLDEyaDU2YTYsNiwwLDAsMCwwLTEyWm0xMjQuMjQtMjIuMjRhNiw2LDAsMCwwLTguNDgsMEwxOTAsMTkzLjUxVjExMmE2LDYsMCwwLDAtMTIsMHY4MS41MWwtMjkuNzYtMjkuNzVhNiw2LDAsMCwwLTguNDgsOC40OGw0MCw0MGE2LDYsMCwwLDAsOC40OCwwbDQwLTQwQTYsNiwwLDAsMCwyMjguMjQsMTYzLjc2WiIvPjwvc3ZnPg==');}.icon-arrow-elbow-left-down{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0yMzgsNzJhNiw2LDAsMCwxLTYsNkg5NFYyMDEuNTFsMzcuNzYtMzcuNzVhNiw2LDAsMCwxLDguNDgsOC40OGwtNDgsNDhhNiw2LDAsMCwxLTguNDgsMGwtNDgtNDhhNiw2LDAsMCwxLDguNDgtOC40OEw4MiwyMDEuNTFWNzJhNiw2LDAsMCwxLDYtNkgyMzJBNiw2LDAsMCwxLDIzOCw3MloiLz48L3N2Zz4=');}.icon-arrow-elbow-right-down{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0yMjguMjQsMTY0LjI0bC00OCw0OGE2LDYsMCwwLDEtOC40OCwwbC00OC00OGE2LDYsMCwxLDEsOC40OC04LjQ4TDE3MCwxOTMuNTFWNzBIMzJhNiw2LDAsMCwxLDAtMTJIMTc2YTYsNiwwLDAsMSw2LDZWMTkzLjUxbDM3Ljc2LTM3Ljc1YTYsNiwwLDAsMSw4LjQ4LDguNDhaIi8+PC9zdmc+');}.icon-heart{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0xNzgsNDJjLTIxLDAtMzkuMjYsOS40Ny01MCwyNS4zNEMxMTcuMjYsNTEuNDcsOTksNDIsNzgsNDJhNjAuMDcsNjAuMDcsMCwwLDAtNjAsNjBjMCwyOS4yLDE4LjIsNTkuNTksNTQuMSw5MC4zMWEzMzQuNjgsMzM0LjY4LDAsMCwwLDUzLjA2LDM3LDYsNiwwLDAsMCw1LjY4LDAsMzM0LjY4LDMzNC42OCwwLDAsMCw1My4wNi0zN0MyMTkuOCwxNjEuNTksMjM4LDEzMS4yLDIzOCwxMDJBNjAuMDcsNjAuMDcsMCwwLDAsMTc4LDQyWk0xMjgsMjE3LjExQzExMS41OSwyMDcuNjQsMzAsMTU3LjcyLDMwLDEwMkE0OC4wNSw0OC4wNSwwLDAsMSw3OCw1NGMyMC4yOCwwLDM3LjMxLDEwLjgzLDQ0LjQ1LDI4LjI3YTYsNiwwLDAsMCwxMS4xLDBDMTQwLjY5LDY0LjgzLDE1Ny43Miw1NCwxNzgsNTRhNDguMDUsNDguMDUsMCwwLDEsNDgsNDhDMjI2LDE1Ny43MiwxNDQuNDEsMjA3LjY0LDEyOCwyMTcuMTFaIi8+PC9zdmc+');}.icon-dots-three{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0xMzgsMTI4YTEwLDEwLDAsMSwxLTEwLTEwQTEwLDEwLDAsMCwxLDEzOCwxMjhaTTYwLDExOGExMCwxMCwwLDEsMCwxMCwxMEExMCwxMCwwLDAsMCw2MCwxMThabTEzNiwwYTEwLDEwLDAsMSwwLDEwLDEwQTEwLDEwLDAsMCwwLDE5NiwxMThaIi8+PC9zdmc+');}.icon-logo-jakevan{--icon:url('data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiIHN0YW5kYWxvbmU9Im5vIj8+PHN2ZyAgICB3aWR0aD0iMzIiICAgIGhlaWdodD0iMzIiICAgIHZpZXdCb3g9IjAgMCAzMiAzMiIgICAgdmVyc2lvbj0iMS4xIiAgICB4bWw6c3BhY2U9InByZXNlcnZlIiAgICBzdHlsZT0iY2xpcC1ydWxlOmV2ZW5vZGQ7ZmlsbC1ydWxlOmV2ZW5vZGQ7c3Ryb2tlLWxpbmVjYXA6c3F1YXJlO3N0cm9rZS1saW5lam9pbjpyb3VuZDtzdHJva2UtbWl0ZXJsaW1pdDoxLjUiICAgIGlkPSJzdmcxNCIgICAgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIiAgICB4bWxuczpzdmc9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48ZGVmcyAgICBpZD0iZGVmczE0IiAvPjxwYXRoICAgIGQ9Ik0gMTYuNTgwMDc4LDIuMTMyODEyNSBDIDguODY0ODQ1LDIuMTMyODEyNSAxLjQ0MDYwMjEsOC4xNDY2MjE2IDIuMDMzMjAzMSwxNS44Mzk4NDQgMi41OTQ0NTg4LDIzLjEyNjY2NiA4Ljk3MTIwMTMsMjkuMDI1MTU1IDE1Ljk3MDcwMywyOS43ODcxMDkgMjQuMjIxMjI0LDMwLjY4NTI0IDMwLjQ0MDkxMSwyMy44MzQyNzcgMjkuOTc0NjA5LDE1Ljc5ODgyOCAyOS41Mjc4MTMsOC4wOTk4NjU2IDI0LjI5MTU2LDIuMTMyODEyNSAxNi41ODAwNzgsMi4xMzI4MTI1IFogbSAwLDAuNjA3NDIxOSBoIDAuMDMxMjUgViAyOS4yMzI0MjIgYyAtMC4xOTAzMjMsLTAuMDEyMTggLTAuMzgxNTIxLC0wLjAyNzgzIC0wLjU3NDIxOSwtMC4wNDg4MyBDIDkuMzEzMDQ1NywyOC40NTE2MTcgMy4xNzc4Nzc1LDIyLjc5MzA0NCAyLjYzODY3MTksMTUuNzkyOTY5IDIuMDcyNDU2LDguNDQyMDE1IDkuMjA4MTAwOSwyLjc0MDIzNDQgMTYuNTgwMDc4LDIuNzQwMjM0NCBaIG0gLTAuNDkyMTg3LDEuMTQ0NTMxMiAtMC4wOTE4LDAuMDAzOTEgQyAxMi43MjE1Niw0LjAxODM0MTMgOS41NDY0NzQzLDUuMzY0Njg3NyA3LjI1LDcuNSA0Ljk1MzUyNTcsOS42MzUzMTIzIDMuNTM2MzY0NywxMi41NjQ5OCAzLjc4OTA2MjUsMTUuODQ1NzAzIDQuMjgwODY2OCwyMi4yMzAwMzIgOS44NjA1ODc5LDI3LjM4MzYwOCAxNS45OTAyMzQsMjguMDUyNzM0IGwgMC4wODk4NCwwLjAwOTggMC4wMTk1MywtMC4xODE2NDEgLTAuMDg5ODQsLTAuMDA5OCBDIDkuOTY4NzM4OSwyNy4yMTE2NDMgNC40NTQ5NjM4LDIyLjExNjQ4MSAzLjk3MDcwMzEsMTUuODMwMDc4IDMuNzIyNjQ5MSwxMi42MDk2NDQgNS4xMTE4OTc5LDkuNzM5MDQ3NyA3LjM3NSw3LjYzNDc2NTYgOS42MzgxMDIxLDUuNTMwNDgzNiAxMi43NzQ2MjUsNC4xOTgxOTcgMTYuMDAzOTA2LDQuMDcwMzEyNSBsIDAuMDg5ODQsLTAuMDAzOTEgeiBtIDAsMS41MDE5NTMxIC0wLjA5MTgsMC4wMDM5MSBDIDEzLjEyODA1NCw1LjUwNDE5NzEgMTAuMzQ3NDM0LDYuNjg0MzQ1NCA4LjMzNTkzNzUsOC41NTQ2ODc1IDYuMzI0NDQxMSwxMC40MjUwMyA1LjA4MTMzNDEsMTIuOTkwODI3IDUuMzAyNzM0NCwxNS44NjUyMzQgNS43MzM1MzM2LDIxLjQ1NzY1NiAxMC42MjEyNjUsMjUuOTcwNTQ4IDE1Ljk5MDIzNCwyNi41NTY2NDEgbCAwLjA4OTg0LDAuMDA5OCAwLjAxOTUzLC0wLjE3OTY4NyAtMC4wODk4NCwtMC4wMDk4IEMgMTAuNzI5NDE5LDI1LjgwMDUzMiA1LjkwNzYzMDcsMjEuMzQ2MDU2IDUuNDg0Mzc1LDE1Ljg1MTU2MyA1LjI2NzYxODQsMTMuMDM3NDQyIDYuNDgyODEzMywxMC41MjY4MTIgOC40NjA5Mzc1LDguNjg3NSAxMC40MzkwNjIsNi44NDgxODgzIDEzLjE4MTEyLDUuNjg0MDUyOCAxNi4wMDM5MDYsNS41NzIyNjU2IGwgMC4wODk4NCwtMC4wMDM5MSB6IG0gMCwxLjUwMzkwNjMgLTAuMDkxOCwwLjAwMTk1IEMgMTMuNTM0NTQsNi45OTAwNjYzIDExLjE0ODQsOC4wMDQwMDcyIDkuNDIxODc1LDkuNjA5Mzc1IDcuNjk1MzQ5NywxMS4yMTQ3NDMgNi42MjgyNTU4LDEzLjQxNjY4IDYuODE4MzU5NCwxNS44ODQ3NjYgNy4xODgxNTUxLDIwLjY4NTI2OSAxMS4zODE5NiwyNC41NTk0NDQgMTUuOTkwMjM0LDI1LjA2MjUgbCAwLjA4OTg0LDAuMDA5OCAwLjAxOTUzLC0wLjE4MTY0MSAtMC4wODk4NCwtMC4wMDk4IEMgMTEuNDkwMTE0LDI0LjM4NzQ3NyA3LjM2MjI1MjIsMjAuNTczNjcxIDcsMTUuODcxMDk0IDYuODE0NTQwMSwxMy40NjMyOTcgNy44NTM3MjE5LDExLjMxNjUyNSA5LjU0Njg3NSw5Ljc0MjE4NzUgYyAxLjY5MzE1MywtMS41NzQzMzc0IDQuMDQwNzMsLTIuNTcwMzI2IDYuNDU3MDMxLC0yLjY2NjAxNTYgbCAwLjA4OTg0LC0wLjAwMzkxIHogbSAwLDEuNTAxOTUzMSAtMC4wOTE4LDAuMDAzOTEgYyAtMi4wNTUwNzMsMC4wODEzODQgLTQuMDQ2NzI4LDAuOTI1MjMzMSAtNS40ODgyODEsMi4yNjU2MjQ5IC0xLjQ0MTU1NDcsMS4zNDAzOTMgLTIuMzMyNjM1NCwzLjE4MjM3OSAtMi4xNzM4Mjg2LDUuMjQ0MTQxIDAuMzA2Mzk5NCwzLjk3NzUyIDMuNzU3OTY0Niw3LjE3NDg3NyA3LjU3MDMxMjYsNy42MzQ3NjYgbCAtMC4wMDIsMC4wMTc1OCAwLjA4Nzg5LDAuMDA3OCBoIDAuMDAyIGwgMC4wMDk4LDAuMDAyIDAuMDgwMDgsMC4wMDk4IDAuMDIxNDgsLTAuMTgxNjQxIGggLTAuMDAzOSB2IC0wLjAwMiBsIC0wLjA4OTg0LC0wLjAwOTggaCAtMC4wMDIgQyAxMi4yNDk0NzYsMjIuOTczNTY1IDguODE2ODE1NSwxOS44MDI1NzYgOC41MTU2MTcsMTUuODkyNTcxIDguMzYxNDU0LDEzLjg5MTA5OCA5LjIyNDYyMjUsMTIuMTA2MjMgMTAuNjMyODA1LDEwLjc5Njg2OCAxMi4wNDA5ODUsOS40ODc1MDU0IDEzLjk5NDA4NCw4LjY1NzcwOTcgMTYuMDAzODk4LDguNTc4MTE3NyBsIDAuMDg5ODQsLTAuMDAzOTEgeiBtIDAsMS41MDE5NTMyIC0wLjA5MTgsMC4wMDM5MSBjIC0xLjY0ODU4MiwwLjA2NTI4NyAtMy4yNDU3NjYsMC43NDI5NDI3IC00LjQwMjM0NCwxLjgxODM1OTcgLTEuMTU2NTc4LDEuMDc1NDE3IC0xLjg3MTY1MDMsMi41NTM1NDUgLTEuNzQ0MTQwNiw0LjIwODk4NCAwLjI0NTM5NDYsMy4xODU1OSAzLjAwMzEwMzYsNS43NDQzODEgNi4wNTQ2ODc2LDYuMTIxMDk0IGwgLTAuMDAyLDAuMDE3NTggMC4wODc4OSwwLjAwNzggaCAwLjAwMiBsIDAuMDg3ODksMC4wMDk4IGggMC4wMDIgbCAwLjAyMTQ4LC0wLjE4MTY0IGggLTAuMDAzOSBsIC0wLjA4Nzg5LC0wLjAwOTggaCAtMC4wMDIgLTAuMDAyIGMgLTIuOTk3NjIzLC0wLjMyODE1NiAtNS43MzYzNjgsLTIuODYwNDMzIC01Ljk3NjU2MiwtNS45Nzg1MTYgLTAuMTIyODY1OSwtMS41OTUxNSAwLjU2NDI5NCwtMy4wMTgxMTMgMS42ODc1LC00LjA2MjUgMS4xMjMyMDYsLTEuMDQ0Mzg2IDIuNjgxODMzLC0xLjcwNjAzNyA0LjI4NTE1NiwtMS43Njk1MzEgbCAwLjA4OTg0LC0wLjAwMzkgeiBtIDQuMTIxMDkzLDEuMzIwMzEyNyBoIDEuNDY2Nzk3IGwgMS4zMjgxMjUsMy45NjI4OSB2IC0zLjk0NzI2NSBoIDEuMTAzNTE2IHYgMS43MzYzMjggYyAwLjA0NjM5LC0wLjA4NzQyIDAuMDk5ODksLTAuMTc2MjEyIDAuMTYwMTU2LC0wLjI2MzY3MiAwLjA2MDMxLC0wLjA4NzQ2IDAuMTIwMjk0LC0wLjE2OTY4NyAwLjE3NzczNCwtMC4yNDgwNDcgbCAwLjg1MzUxNiwtMS4yMjQ2MDkgaCAzLjU3ODEyNSB2IDAuODY3MTg3IGggLTEuMzE2NDA2IHYgMC42MjY5NTMgaCAxLjIxODc1IHYgMC44NjcxODggaCAtMS4yMTg3NSB2IDAuNzUzOTA2IGggMS4zMTY0MDYgdiAwLjg4MDg2IGggLTMuNjM4NjcyIGwgLTAuODA0Njg3LC0xLjUyNzM0NCAtMC4zMjYxNzIsMC4xOTE0MDYgdiAxLjMzNTkzOCBoIC0yLjI4OTA2MyBsIC0wLjIwMTE3MiwtMC43NDgwNDcgaCAtMS4zMjAzMTIgbCAtMC4yMDcwMzEsMC43NDgwNDcgaCAtMS4yMDcwMzIgeiBtIC0yLjQxNDA2MiwwLjAxNTYzIGggMS4xMDM1MTUgdiAzLjYwNTQ2OSBjIDEwZS03LDAuNDAwOTA1IC0wLjA2MTE3LDAuNzIyMzU1IC0wLjE4MzU5MywwLjk2Mjg5IC0wLjEyMjQyMiwwLjI0MDUzNSAtMC4yOTA4MTMsMC40MTQwMTEgLTAuNTA3ODEzLDAuNTIxNDg0IC0wLjIxNjk5OSwwLjEwNzUxMyAtMC40NjgyNTUsMC4xNjAxNTcgLTAuNzUzOTA2LDAuMTYwMTU3IC0wLjEyNDI1NiwwIC0wLjIzNDQ2NSwtMC4wMDU3IC0wLjMyODEyNSwtMC4wMTc1OCAtMC4wOTM2NiwtMC4wMTE3NyAtMC4xNzM1NzgsLTAuMDI0NDYgLTAuMjQyMTg4LC0wLjAzOTA2IFYgMTUuNTgzOTkgYyAwLjA1OTM2LDAuMDEwODYgMC4xMjI2NzQsMC4wMjM0MSAwLjE4OTQ1NCwwLjAzNzExIDAuMDY2NzcsMC4wMTM3IDAuMTM4OTM2LDAuMDIxNDggMC4yMTY3OTYsMC4wMjE0OCAwLjEzMTY3NiwwIDAuMjMzMzYxLC0wLjAyNzIzIDAuMzA2NjQxLC0wLjA4MDA4IDAuMDczMjMsLTAuMDUyODYgMC4xMjQ2MTcsLTAuMTMyNjExIDAuMTU0Mjk3LC0wLjIzODI4MSAwLjAyOTY4LC0wLjEwNTY3IDAuMDQ0OTIsLTAuMjM3OTE5IDAuMDQ0OTIsLTAuMzk2NDg1IHogbSA4LjY2Nzk2OSwwLjA1NDY5IC0xLjI0NDE0MSwxLjczNjMyOCAxLjI0NDE0MSwyLjE3NTc4MiB6IG0gLTEwLjM3NSwwLjExMzI4MiAtMC4wOTE4LDAuMDAyIGMgLTEuMjQyMDk1LDAuMDQ5MTkgLTIuNDQ0OCwwLjU2MDY1MiAtMy4zMTY0MDYsMS4zNzEwOTMgLTAuODcxNjA4LDAuODEwNDQyIC0xLjQxMjYyLDEuOTI0NzEyIC0xLjMxNjQwNywzLjE3MzgyOSAwLjE4NDM5LDIuMzkzNjU0IDIuMjUwMTk1LDQuMzEyMDIyIDQuNTQxMDE2LDQuNjA1NDY4IGwgLTAuMDAyLDAuMDE3NTggMC4wODc4OSwwLjAwNzggaCAwLjAwMiBsIDAuMDg3ODksMC4wMDk4IGggMC4wMDIgbCAwLjAxOTUzLC0wLjE3OTY4NyBoIC0wLjAwMiB2IC0wLjAwMiBsIC0wLjA3ODEzLC0wLjAwNzggLTAuMDA5OCwtMC4wMDIgaCAtMC4wMDIgLTAuMDAyIGMgLTIuMjM2OTYxLC0wLjI0NTEyMSAtNC4yODM3LC0yLjEzNjczMSAtNC40NjI4OSwtNC40NjI4OSAtMC4wOTE1NywtMS4xODg4MjYgMC40MjE1MzEsLTIuMjQ3OTMzIDEuMjU5NzY2LC0zLjAyNzM0NCAwLjgzODIzNCwtMC43Nzk0MTEgMi4wMDIzODIsLTEuMjcyOTE2IDMuMTk5MjE4LC0xLjMyMDMxMyBsIDAuMDg5ODQsLTAuMDAzOSB6IG0gNC44NjEzMjgsMC40NzQ2MDkgYyAtMC4wMTY3MSwwLjA5MTExIC0wLjAzOTcyLDAuMjAzOTI4IC0wLjA3MDMxLDAuMzM3ODkxIC0wLjAzMDYsMC4xMzM5MjIgLTAuMDYxMjgsMC4yNjUyNjkgLTAuMDkzNzUsMC4zOTY0ODQgLTAuMDMyNDIsMC4xMzExNzUgLTAuMDYxODUsMC4yNDA2NjUgLTAuMDg1OTQsMC4zMjgxMjUgbCAtMC4xNzU3ODIsMC42NTYyNSBoIDAuODY1MjM1IGwgLTAuMTczODI4LC0wLjY1NjI1IGMgLTAuMDE4NTEsLTAuMDcxMDYgLTAuMDQ2ODEsLTAuMTcyNTI5IC0wLjA4MjAzLC0wLjMwNDY4OCAtMC4wMzUyNiwtMC4xMzIxMTggLTAuMDY5MjEsLTAuMjY4OTM1IC0wLjEwMzUxNSwtMC40MTAxNTYgLTAuMDM0MywtMC4xNDEyMjEgLTAuMDYxNTMsLTAuMjU2NTQ2IC0wLjA4MDA4LC0wLjM0NzY1NiB6IG0gLTQuODYxMzI4LDEuMDI3MzQ0IC0wLjA5MTgsMC4wMDM5IGMgLTAuODM1NjA4LDAuMDMzMDkgLTEuNjQzODM2LDAuMzc0NDU2IC0yLjIzMDQ2OSwwLjkxOTkyMiAtMC41ODY2MzMsMC41NDU0NjYgLTAuOTUxNjM1LDEuMjk5Nzg2IC0wLjg4NjcxOSwyLjE0MjU3OCAwLjEyMzIwNSwxLjU5OTM3OCAxLjQ5ODEyOCwyLjg1OTQ2NyAzLjAyNTM5MSwzLjA3MjI2NSBsIC0wLjAwMzksMC4wMzMyIDAuMDg5ODQsMC4wMDk4IDAuMDgyMDMsMC4wMDc4IDAuMDA3OCwwLjAwMiBoIDAuMDAyIGwgMC4wMTk1MywtMC4xODE2NDEgaCAtMC4wMDIgbCAtMC4wODc4OSwtMC4wMDk4IGggLTAuMDAyIC0wLjAwMiBjIC0xLjQ3NjMzNywtMC4xNjIwNDEgLTIuODI5MDc2LC0xLjQxMjk5NiAtMi45NDcyNjUsLTIuOTQ3MjY2IC0wLjA2MDI3LC0wLjc4MjUwMyAwLjI3NjgxNywtMS40Nzk3MDUgMC44MzAwNzgsLTEuOTk0MTQxIDAuNTUzMjYxLC0wLjUxNDQzNSAxLjMyMjkzMywtMC44NDE3NDggMi4xMTMyODEsLTAuODczMDQ2IGwgMC4wODk4NCwtMC4wMDM5IHogbSAwLDEuNTAxOTUzIC0wLjA5MTgsMC4wMDM5IGMgLTAuNDI5MTE4LDAuMDE2OTkgLTAuODQyODczLDAuMTkyMTY2IC0xLjE0NDUzMSwwLjQ3MjY1NiAtMC4zMDE2NiwwLjI4MDQ5MSAtMC40OTA2NDgsMC42NzA5NTUgLTAuNDU3MDMyLDEuMTA3NDIyIDAuMDYyMTksMC44MDczNiAwLjc0MzgwMSwxLjQzMDI1NyAxLjUwOTc2NiwxLjU1ODU5NCBsIC0wLjAwMzksMC4wMzMyIDAuMDg5ODQsMC4wMDc4IGggMC4wMDIgbCAwLjA4Nzg5LDAuMDA5OCAwLjAyMTQ4LC0wLjE3OTY4NyBoIC0wLjAwMiB2IC0wLjAwMiBsIC0wLjA3ODEzLC0wLjAwNzggLTAuMDA5OCwtMC4wMDIgaCAtMC4wMDIgLTAuMDAyIGMgLTAuNzE1Njc1LC0wLjA3OTAxIC0xLjM3NDQ1NSwtMC42ODkyOSAtMS40MzE2NCwtMS40MzE2NDEgLTAuMDI4OTcsLTAuMzc2MTc5IDAuMTMyMTA0LC0wLjcxMTQ3NyAwLjQwMDM5MSwtMC45NjA5MzcgMC4yNjgyODYsLTAuMjQ5NDYxIDAuNjQzNDg1LC0wLjQwODYyNyAxLjAyNzM0MywtMC40MjM4MjggbCAwLjA4OTg0LC0wLjAwMzkgeiBtIDcuMDQxMDE1LDAuODQ5NjA5IGggMS4yNjE3MTkgbCAxLjE0MjU3OCwzLjQxNDA2MyB2IC0zLjQwMDM5MSBoIDEuMjM2MzI4IGwgMS4yNzUzOTEsMi4zOTI1NzggaCAwLjAxMzY3IGMgLTAuMDA0NywtMC4wNzUzNyAtMC4wMDg5LC0wLjE2NDAzMiAtMC4wMTM2NywtMC4yNjM2NzIgLTAuMDA0OCwtMC4wOTk2NCAtMC4wMDk3LC0wLjIwMDcxMyAtMC4wMTM2NywtMC4zMDI3MzQgLTAuMDA0LC0wLjEwMjAyIC0wLjAwNTksLTAuMTkxMDUxIC0wLjAwNTksLTAuMjY5NTMxIHYgLTEuNTU2NjQxIGggMC44NDM3NSB2IDMuNDQxNDA2IGggLTEuMjQyMTg4IGwgLTEuMjc5Mjk3LC0yLjQyMzgyOCBoIC0wLjAyMTQ4IGMgMC4wMDgsMC4wNzM3NyAwLjAxNTA4LDAuMTYyMDg4IDAuMDIxNDgsMC4yNjU2MjUgMC4wMDY0LDAuMTAzNTc5IDAuMDEyODgsMC4yMDg4OTEgMC4wMTc1OCwwLjMxNjQwNiAwLjAwNDgsMC4xMDc0NzQgMC4wMDc4LDAuMjA0NzA2IDAuMDA3OCwwLjI5MTAxNiB2IDEuNTUwNzgxIEggMjQuNTEzNjcyIEwgMjQuMzM5ODQ0LDE4LjA2MjUgaCAtMS4xMzY3MTkgbCAtMC4xNzc3MzQsMC42NDQ1MzEgaCAtMS4wMzkwNjMgeiBtIC00LjE1NDI5NywwLjAxMzY3IGggMS4wNjI1IGwgMC40NzY1NjMsMS43NDQxNDEgYyAwLjAxNzU0LDAuMDY1ODkgMC4wMzkzNywwLjE1MTEwNyAwLjA2MjUsMC4yNTM5MDYgMC4wMjMxNywwLjEwMjc1OCAwLjA0NDQ4LDAuMjA0NjYxIDAuMDY0NDUsMC4zMDY2NCAwLjAxOTk3LDAuMTAyMDIgMC4wMzIzMSwwLjE4NTYyIDAuMDM3MTEsMC4yNSAwLjAwNjQsLTAuMDY0MzggMC4wMTc2MSwtMC4xNDc2MjUgMC4wMzUxNiwtMC4yNDgwNDYgMC4wMTc1OSwtMC4xMDA0MjEgMC4wMzcwNCwtMC4yMDE1MzUgMC4wNTg1OSwtMC4zMDI3MzUgMC4wMjE2LC0wLjEwMTI0MSAwLjA0MTM4LC0wLjE4NDA2IDAuMDYwNTUsLTAuMjUgbCAwLjQ4NjMyOCwtMS43NTM5MDYgaCAxLjA2MDU0NyBsIC0xLjE0ODQzNywzLjQ0MTQwNiBoIC0xLjExMzI4MiB6IG0gNC43OTEwMTYsMC41NTI3MzQgYyAtMC4wMTQyOSwwLjA3ODQ0IC0wLjAzNDIxLDAuMTc1NjY5IC0wLjA2MDU1LDAuMjkxMDE2IC0wLjAyNjM0LDAuMTE1MzQ3IC0wLjA1NDA2LDAuMjMwNzgyIC0wLjA4MjAzLDAuMzQzNzUgLTAuMDI3OTMsMC4xMTI5NjkgLTAuMDUxNDcsMC4yMDU5MiAtMC4wNzIyNywwLjI4MTI1IGwgLTAuMTUyMzQ0LDAuNTY0NDUzIGggMC43NDYwOTQgbCAtMC4xNTAzOSwtMC41NjQ0NTMgYyAtMC4wMTU5MiwtMC4wNjEyMiAtMC4wMzk4OCwtMC4xNDc5NzEgLTAuMDcwMzEsLTAuMjYxNzE5IC0wLjAzMDI3LC0wLjExMzc4OSAtMC4wNjAyOSwtMC4yMzE5MzUgLTAuMDg5ODQsLTAuMzUzNTE1IC0wLjAyOTU2LC0wLjEyMTYyIC0wLjA1MjM1LC0wLjIyMjM0MiAtMC4wNjgzNiwtMC4zMDA3ODIgeiBtIC0zLjY0NjQ4NCwyLjk5MjE4OCBIIDIwLjU2MjUgYyAwLjE3NDc3NSwwIDAuMzIwNjU4LDAuMDI5NjQgMC40Mzk0NTMsMC4wODk4NCAwLjExODc1NCwwLjA2MDIgMC4yMTAyNTUsMC4xNDg1NTYgMC4yNzE0ODQsMC4yNjU2MjUgMC4wNjEyNywwLjExNzA2OSAwLjA5MTgsMC4yNjE4NjQgMC4wOTE4LDAuNDMzNTkzIDAsMC4xNzk4MDcgLTAuMDMzMDEsMC4zMzEzIC0wLjA5NzY2LDAuNDUzMTI1IC0wLjA2NDYxLDAuMTIxODI2IC0wLjE2MDQ3NywwLjIxMzc2MSAtMC4yODcxMDksMC4yNzUzOTEgLTAuMTI2NTksMC4wNjE2NyAtMC4yODM5NjUsMC4wOTE4IC0wLjQ3MDcwMywwLjA5MTggSCAyMC4xMTkxNCBaIG0gMS40MzU1NDYsMCBoIDAuODk2NDg1IHYgMC4xMDE1NjIgaCAtMC43ODEyNSB2IDAuNjExMzI4IEggMjIuNDA2MjUgViAxOS42MjUgaCAtMC43MzYzMjggdiAwLjY5MzM1OSBoIDAuNzgxMjUgdiAwLjEwMTU2MyBoIC0wLjg5NjQ4NSB6IG0gMS4wODM5ODUsMCBoIDAuMzk4NDM3IGMgMC4xMzAwMDgsMCAwLjIzNzIyOSwwLjAxMzA5IDAuMzI0MjE5LDAuMDQxMDIgMC4wODcwMywwLjAyNzg4IDAuMTUzMTY2LDAuMDc0NzggMC4xOTcyNjYsMC4xMzg2NzIgMC4wNDQwMiwwLjA2Mzg0IDAuMDY2NDEsMC4xNDkxOTEgMC4wNjY0MSwwLjI1NzgxMyAwLDAuMDgxNDggLTAuMDE0NjIsMC4xNTI1ODcgLTAuMDQ0OTIsMC4yMTA5MzcgLTAuMDMwMywwLjA1ODM1IC0wLjA3MjAyLDAuMTA1MTc4IC0wLjEyNSwwLjE0MjU3OCAtMC4wNTMxLDAuMDM3NDggLTAuMTE0MTU0LDAuMDY2MTMgLTAuMTgzNTk0LDAuMDg1OTQgbCAwLjQ0OTIxOSwwLjczMjQyMiBIIDIzLjU4Mzk5IGwgLTAuNDIzODI4LC0wLjY5OTIxOSBoIC0wLjQwNjI1IHYgMC42OTkyMTkgaCAtMC4xMTUyMzQgeiBtIDEuMDA3ODEyLDAgaCAwLjExOTE0MSBsIDAuMzA4NTk0LDEuMTM0NzY1IGMgMC4wMDgxLDAuMDMwMDkgMC4wMTQzOCwwLjA1ODc5IDAuMDIxNDgsMC4wODU5NCAwLjAwNywwLjAyNzE1IDAuMDEzNTMsMC4wNTI4MSAwLjAxOTUzLDAuMDc4MTMgMC4wMDYsMC4wMjUzNCAwLjAxMTk4LDAuMDUwMzUgMC4wMTc1OCwwLjA3NDIyIDAuMDA1NiwwLjAyMzgzIDAuMDExMTIsMC4wNDgwNCAwLjAxNTYzLDAuMDcyMjcgMC4wMDUyLC0wLjAyNDI0IDAuMDEwNDMsLTAuMDQ5MjUgMC4wMTU2MywtMC4wNzQyMiAwLjAwNTIsLTAuMDI0OTMgMC4wMTEyOCwtMC4wNDg1NSAwLjAxNzU4LC0wLjA3NDIyIDAuMDA2NCwtMC4wMjU3MSAwLjAxMjMzLC0wLjA1MjU2IDAuMDE5NTMsLTAuMDgwMDggMC4wMDcsLTAuMDI3NTIgMC4wMTYzOSwtMC4wNTc3OSAwLjAyNTM5LC0wLjA4Nzg5IGwgMC4zMjAzMTMsLTEuMTI4OTA2IGggMC4xMTUyMzQgbCAwLjMzMzk4NSwxLjEzNjcxOSBjIDAuMDA5LDAuMDMxNTMgMC4wMTc5OSwwLjA1OTk3IDAuMDI1MzksMC4wODc4OSAwLjAwNzUsMC4wMjc4OSAwLjAxMzUzLDAuMDU0NzQgMC4wMTk1MywwLjA4MDA4IDAuMDA1OSwwLjAyNTMgMC4wMTIyOCwwLjA1MDM1IDAuMDE3NTgsMC4wNzQyMiAwLjAwNTIsMC4wMjM4MyAwLjAxMDQzLDAuMDQ4MDggMC4wMTU2MywwLjA3MjI3IDAuMDA2LC0wLjAzMzAxIDAuMDExMTgsLTAuMDY1NzIgMC4wMTc1OCwtMC4wOTc2NiAwLjAwNjMsLTAuMDMxOSAwLjAxNDQ0LC0wLjA2NDM5IDAuMDIzNDQsLTAuMDk5NjEgMC4wMDksLTAuMDM1MjYgMC4wMiwtMC4wNzUzNyAwLjAzMTI1LC0wLjExNzE4NyBsIDAuMzE0NDUzLC0xLjEzNjcxOSBoIDAuMTE5MTQxIGwgLTAuNDQ3MjY2LDEuNjA5Mzc1IGggLTAuMTExMzI4IGwgLTAuMzUxNTYyLC0xLjIxNDg0NCBjIC0wLjAwODMsLTAuMDI1NjcgLTAuMDE1MDgsLTAuMDUxMDkgLTAuMDIxNDgsLTAuMDc0MjIgLTAuMDA2MywtMC4wMjMxMyAtMC4wMTE4OCwtMC4wNDM1NCAtMC4wMTc1OCwtMC4wNjQ0NSAtMC4wMDU2LC0wLjAyMDkyIC0wLjAxMDczLC0wLjA0MTg1IC0wLjAxNTYzLC0wLjA2MDU1IC0wLjAwNDgsLTAuMDE4NjkgLTAuMDA4NywtMC4wMzU0IC0wLjAxMTcyLC0wLjA1MDc4IC0wLjAwMjksMC4wMTUzOCAtMC4wMDY0LDAuMDMxNjEgLTAuMDA5OCwwLjA0ODgzIC0wLjAwMzQsMC4wMTcyNiAtMC4wMDcyLDAuMDM0MzggLTAuMDExNzIsMC4wNTI3MyAtMC4wMDQ1LDAuMDE4MzMgLTAuMDA4NCwwLjAzODc0IC0wLjAxMzY3LDAuMDU4NTkgLTAuMDA1MiwwLjAxOTgxIC0wLjAxMTU4LDAuMDM5MjcgLTAuMDE3NTgsMC4wNjA1NSBsIC0wLjM0OTYwOSwxLjI0NDE0MSBoIC0wLjExMzI4MSB6IG0gMi4wMzUxNTcsMCBoIDAuODk2NDg0IHYgMC4xMDE1NjIgaCAtMC43NzkyOTcgdiAwLjYxMTMyOCBoIDAuNzM2MzI4IFYgMTkuNjI1IGggLTAuNzM2MzI4IHYgMC42OTMzNTkgaCAwLjc3OTI5NyB2IDAuMTAxNTYzIGggLTAuODk2NDg0IHogbSAxLjA4Mzk4NCwwIGggMC4zOTg0MzcgYyAwLjEyOTkyNiwwIDAuMjM5MTQyLDAuMDEzMDkgMC4zMjYxNzIsMC4wNDEwMiAwLjA4Njk5LDAuMDI3ODggMC4xNTEyMTMsMC4wNzQ3OCAwLjE5NTMxMywwLjEzODY3MiAwLjA0NDAyLDAuMDYzODQgMC4wNjY0MSwwLjE0OTE5MSAwLjA2NjQxLDAuMjU3ODEzIDAsMC4wODE0OCAtMC4wMTQ2MiwwLjE1MjU4NyAtMC4wNDQ5MiwwLjIxMDkzNyAtMC4wMzAyNiwwLjA1ODM1IC0wLjA3MjAyLDAuMTA1MTc4IC0wLjEyNSwwLjE0MjU3OCAtMC4wNTMwNiwwLjAzNzQ4IC0wLjExNDE1MywwLjA2NjEzIC0wLjE4MzU5NCwwLjA4NTk0IGwgMC40NDkyMTksMC43MzI0MjIgaCAtMC4xMzY3MTkgbCAtMC40MjE4NzUsLTAuNjk5MjE5IGggLTAuNDA4MjAzIHYgMC42OTkyMTkgaCAtMC4xMTUyMzQgeiBtIDEuMTgxNjQxLDAgaCAwLjg5NjQ4NCB2IDAuMTAxNTYyIEggMjguMDYyNSB2IDAuNjg3NSBoIDAuNzM4MjgxIHYgMC4wOTk2MSBIIDI4LjA2MjUgdiAwLjcyMDcwMyBoIC0wLjExNTIzNCB6IG0gLTcuNzEyODkxLDAuMDk5NjEgdiAxLjQxMDE1NiBoIDAuMjcxNDg0IGMgMC4yNDcyNjEsMCAwLjQzMjE4MywtMC4wNjA0IDAuNTU0Njg4LC0wLjE3OTY4NyAwLjEyMjU0OSwtMC4xMTkyNDIgMC4xODM1OTQsLTAuMjk4NTQyIDAuMTgzNTk0LC0wLjUzNzEwOSAwLC0wLjE1MzM1OCAtMC4wMjUzNiwtMC4yODAwNTQgLTAuMDc2MTcsLTAuMzgyODEzIC0wLjA1MDc3LC0wLjEwMjc1OCAtMC4xMjk3OTMsLTAuMTgwNjcyIC0wLjIzNDM3NSwtMC4yMzI0MjIgLTAuMTA0NTgyLC0wLjA1MTcxIC0wLjIzNTg4MiwtMC4wNzgxMyAtMC4zOTY0ODUsLTAuMDc4MTMgeiBtIDIuNTE5NTMxLDAgdiAwLjcwODk4NSBoIDAuMzI2MTcyIGMgMC4xMzM3NiwwIDAuMjM3NDcsLTAuMDMwNjQgMC4zMTI1LC0wLjA5Mzc1IDAuMDc1MTYsLTAuMDYzMTUgMC4xMTMyODEsLTAuMTUzMzUgMC4xMTMyODEsLTAuMjcxNDg1IDAsLTAuMTI5OTQ0IC0wLjAzOTk4LC0wLjIyMDMyMSAtMC4xMTkxNCwtMC4yNjk1MzEgLTAuMDc5MjQsLTAuMDQ5MTcgLTAuMTk5MjI0LC0wLjA3NDIyIC0wLjM2MTMyOCwtMC4wNzQyMiB6IG0gNC4xMjY5NTMsMCB2IDAuNzA4OTg1IGggMC4zMjYxNzIgYyAwLjEzMzc1OSwwIDAuMjM5NDIzLC0wLjAzMDY0IDAuMzE0NDUzLC0wLjA5Mzc1IDAuMDc1MTIsLTAuMDYzMTUgMC4xMTEzMjgsLTAuMTUzMzUgMC4xMTEzMjgsLTAuMjcxNDg1IDAsLTAuMTI5OTQ0IC0wLjAzODA4LC0wLjIyMDMyMSAtMC4xMTcxODcsLTAuMjY5NTMxIC0wLjA3OTI0LC0wLjA0OTE3IC0wLjIwMTE3OCwtMC4wNzQyMiAtMC4zNjMyODEsLTAuMDc0MjIgeiIgICAgc3R5bGU9ImJhc2VsaW5lLXNoaWZ0OmJhc2VsaW5lO2NsaXAtcnVsZTpub256ZXJvO2Rpc3BsYXk6aW5saW5lO292ZXJmbG93OnZpc2libGU7dmVjdG9yLWVmZmVjdDpub25lO2ZpbGw6IzIyMjIyMjtmaWxsLXJ1bGU6bm9uemVybztzdHJva2UtbGluZWNhcDpidXR0O3N0cm9rZS1saW5lam9pbjptaXRlcjtlbmFibGUtYmFja2dyb3VuZDphY2N1bXVsYXRlO3N0b3AtY29sb3I6IzAwMDAwMDtzdG9wLW9wYWNpdHk6MSIgICAgaWQ9InBhdGgxNyIgLz48L3N2Zz4=');}.icon-sign-out{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0xMTgsMjE2YTYsNiwwLDAsMS02LDZINDhhNiw2LDAsMCwxLTYtNlY0MGE2LDYsMCwwLDEsNi02aDY0YTYsNiwwLDAsMSwwLDEySDU0VjIxMGg1OEE2LDYsMCwwLDEsMTE4LDIxNlptMTEwLjI0LTkyLjI0LTQwLTQwYTYsNiwwLDAsMC04LjQ4LDguNDhMMjA5LjUxLDEyMkgxMTJhNiw2LDAsMCwwLDAsMTJoOTcuNTFsLTI5Ljc1LDI5Ljc2YTYsNiwwLDEsMCw4LjQ4LDguNDhsNDAtNDBBNiw2LDAsMCwwLDIyOC4yNCwxMjMuNzZaIi8+PC9zdmc+');}.icon-arrow-clockwise{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0yMzgsNTZ2NDhhNiw2LDAsMCwxLTYsNkgxODRhNiw2LDAsMCwxLDAtMTJoMzIuNTVsLTMwLjM4LTI3LjhjLS4wNi0uMDYtLjEyLS4xMy0uMTktLjE5YTgyLDgyLDAsMSwwLTEuNywxMTcuNjUsNiw2LDAsMCwxLDguMjQsOC43M0E5My40Niw5My40NiwwLDAsMSwxMjgsMjIyaC0xLjI4QTk0LDk0LDAsMSwxLDE5NC4zNyw2MS40TDIyNiw5MC4zNVY1NmE2LDYsMCwxLDEsMTIsMFoiLz48L3N2Zz4=');}.icon-x-square{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0yMDgsMzRINDhBMTQsMTQsMCwwLDAsMzQsNDhWMjA4YTE0LDE0LDAsMCwwLDE0LDE0SDIwOGExNCwxNCwwLDAsMCwxNC0xNFY0OEExNCwxNCwwLDAsMCwyMDgsMzRabTIsMTc0YTIsMiwwLDAsMS0yLDJINDhhMiwyLDAsMCwxLTItMlY0OGEyLDIsMCwwLDEsMi0ySDIwOGEyLDIsMCwwLDEsMiwyWk0xNjQuMjQsMTAwLjI0LDEzNi40OCwxMjhsMjcuNzYsMjcuNzZhNiw2LDAsMSwxLTguNDgsOC40OEwxMjgsMTM2LjQ4bC0yNy43NiwyNy43NmE2LDYsMCwwLDEtOC40OC04LjQ4TDExOS41MiwxMjgsOTEuNzYsMTAwLjI0YTYsNiwwLDAsMSw4LjQ4LTguNDhMMTI4LDExOS41MmwyNy43Ni0yNy43NmE2LDYsMCwwLDEsOC40OCw4LjQ4WiIvPjwvc3ZnPg==');}.icon-eye-closed{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0yMjkuMjEsMTY1YTYsNiwwLDAsMS0xMC40Miw2bC0yMC0zNS4wOGExMjIsMTIyLDAsMCwxLTM5LDE4LjA5bDYuMTcsMzdhNiw2LDAsMCwxLTQuOTMsNi45MSw2Ljg1LDYuODUsMCwwLDEtMSwuMDgsNiw2LDAsMCwxLTUuOTEtNUwxNDgsMTU2LjQ0YTEyOC44NiwxMjguODYsMCwwLDEtNDAsMEwxMDEuOTIsMTkzQTYsNiwwLDAsMSw5NiwxOThhNi44NSw2Ljg1LDAsMCwxLTEtLjA4QTYsNiwwLDAsMSw5MC4wOCwxOTFsNi4xNy0zN2ExMjIsMTIyLDAsMCwxLTM5LTE4LjA5TDM3LjIxLDE3MWE2LDYsMCwxLDEtMTAuNDItNmwyMC44NS0zNi40OGExNTIsMTUyLDAsMCwxLTIwLjMxLTIwLjc3LDYsNiwwLDAsMSw5LjM0LTcuNTRDNTMuNTQsMTIxLjExLDgzLjA3LDE0NiwxMjgsMTQ2czc0LjQ2LTI0Ljg5LDkxLjMzLTQ1Ljc3YTYsNiwwLDAsMSw5LjM0LDcuNTQsMTUyLDE1MiwwLDAsMS0yMC4zMSwyMC43N1oiLz48L3N2Zz4=');}.icon-hourglass{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0xOTgsNzUuNjRWNDBhMTQsMTQsMCwwLDAtMTQtMTRINzJBMTQsMTQsMCwwLDAsNTgsNDBWNzZhMTQuMDYsMTQuMDYsMCwwLDAsNS42LDExLjJMMTE4LDEyOCw2My42LDE2OC44QTE0LjA2LDE0LjA2LDAsMCwwLDU4LDE4MHYzNmExNCwxNCwwLDAsMCwxNCwxNEgxODRhMTQsMTQsMCwwLDAsMTQtMTRWMTgwLjM2YTE0LjA4LDE0LjA4LDAsMCwwLTUuNTYtMTEuMTdMMTM4LDEyOGw1NC40OS00MS4xOUExNC4wOCwxNC4wOCwwLDAsMCwxOTgsNzUuNjRaTTE4NiwxODAuMzZWMjE2YTIsMiwwLDAsMS0yLDJINzJhMiwyLDAsMCwxLTItMlYxODBhMiwyLDAsMCwxLC44LTEuNkwxMjgsMTM1LjUxbDU3LjIyLDQzLjI1QTIsMiwwLDAsMSwxODYsMTgwLjM2Wm0wLTEwNC43MmEyLDIsMCwwLDEtLjc5LDEuNkwxMjgsMTIwLjQ5LDcwLjgsNzcuNkEyLDIsMCwwLDEsNzAsNzZWNDBhMiwyLDAsMCwxLDItMkgxODRhMiwyLDAsMCwxLDIsMloiLz48L3N2Zz4=');}.icon-star-half-fi{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0yMzkuMTgsOTcuMjZBMTYuMzgsMTYuMzgsMCwwLDAsMjI0LjkyLDg2bC01OS00Ljc2TDE0My4xNCwyNi4xNWExNi4zNiwxNi4zNiwwLDAsMC0zMC4yNywwTDkwLjExLDgxLjIzLDMxLjA4LDg2YTE2LjQ2LDE2LjQ2LDAsMCwwLTkuMzcsMjguODZsNDUsMzguODNMNTMsMjExLjc1YTE2LjQsMTYuNCwwLDAsMCwyNC41LDE3LjgyTDEyOCwxOTguNDlsNTAuNTMsMzEuMDhBMTYuNCwxNi40LDAsMCwwLDIwMywyMTEuNzVsLTEzLjc2LTU4LjA3LDQ1LTM4LjgzQTE2LjQzLDE2LjQzLDAsMCwwLDIzOS4xOCw5Ny4yNlptLTE1LjM0LDUuNDctNDguNyw0MmE4LDgsMCwwLDAtMi41Niw3LjkxbDE0Ljg4LDYyLjhhLjM3LjM3LDAsMCwxLS4xNy40OGMtLjE4LjE0LS4yMy4xMS0uMzgsMGwtNTQuNzItMzMuNjVBOCw4LDAsMCwwLDEyOCwxODEuMVYzMmMuMjQsMCwuMjcuMDguMzUuMjZMMTUzLDkxLjg2YTgsOCwwLDAsMCw2Ljc1LDQuOTJsNjMuOTEsNS4xNmMuMTYsMCwuMjUsMCwuMzQuMjlTMjI0LDEwMi42MywyMjMuODQsMTAyLjczWiIvPjwvc3ZnPg==');}.icon-star-fi{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0yMzQuMjksMTE0Ljg1bC00NSwzOC44M0wyMDMsMjExLjc1YTE2LjQsMTYuNCwwLDAsMS0yNC41LDE3LjgyTDEyOCwxOTguNDksNzcuNDcsMjI5LjU3QTE2LjQsMTYuNCwwLDAsMSw1MywyMTEuNzVsMTMuNzYtNTguMDctNDUtMzguODNBMTYuNDYsMTYuNDYsMCwwLDEsMzEuMDgsODZsNTktNC43NiwyMi43Ni01NS4wOGExNi4zNiwxNi4zNiwwLDAsMSwzMC4yNywwbDIyLjc1LDU1LjA4LDU5LDQuNzZhMTYuNDYsMTYuNDYsMCwwLDEsOS4zNywyOC44NloiLz48L3N2Zz4=');}.icon-heart-fi{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0yNDAsMTAyYzAsNzAtMTAzLjc5LDEyNi42Ni0xMDguMjEsMTI5YTgsOCwwLDAsMS03LjU4LDBDMTE5Ljc5LDIyOC42NiwxNiwxNzIsMTYsMTAyQTYyLjA3LDYyLjA3LDAsMCwxLDc4LDQwYzIwLjY1LDAsMzguNzMsOC44OCw1MCwyMy44OUMxMzkuMjcsNDguODgsMTU3LjM1LDQwLDE3OCw0MEE2Mi4wNyw2Mi4wNywwLDAsMSwyNDAsMTAyWiIvPjwvc3ZnPg==');}
\ No newline at end of file
+.icon-google-logo{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0yMjIsMTI4YTk0LDk0LDAsMSwxLTIxLjQ5LTU5LjgyLDYsNiwwLDEsMS05LjI1LDcuNjRBODIsODIsMCwxLDAsMjA5Ljc4LDEzNEgxMjhhNiw2LDAsMCwxLDAtMTJoODhBNiw2LDAsMCwxLDIyMiwxMjhaIi8+PC9zdmc+');}.icon-apple-logo{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0yMTkuNCwxNjcuODRDMjAxLjcxLDE1NS42OSwxOTgsMTM1LjEyLDE5OCwxMjBjMC0xOC40MiwxMy44Ni0zNC4yOSwyMi4xMi00Mi4xMmE2LDYsMCwwLDAsMC04LjcxQzIwOCw1Ny43LDE4Ny4wNyw1MCwxNjgsNTBhNzAuMjMsNzAuMjMsMCwwLDAtNDAsMTIuNTUsNjkuNiw2OS42LDAsMCwwLTg5LjMxLDguMDhBNzIuNjMsNzIuNjMsMCwwLDAsMTgsMTIzLjM1YTEyNS4xMSwxMjUuMTEsMCwwLDAsMzkuNTMsODguMzNBMzcuODUsMzcuODUsMCwwLDAsODMuNiwyMjJoODcuN0EzNy44MywzNy44MywwLDAsMCwxOTksMjEwLjA3YTEyMi42LDEyMi42LDAsMCwwLDE3LjU0LTI0LjJjNi41NS0xMiw1Ljc3LTEzLjc1LDUtMTUuNDhBNi4wNyw2LjA3LDAsMCwwLDIxOS40LDE2Ny44NFptLTI5LjIzLDM0QTI1LjgyLDI1LjgyLDAsMCwxLDE3MS4zLDIxMEg4My42QTI1Ljg1LDI1Ljg1LDAsMCwxLDY1Ljc4LDIwMywxMTMuMjEsMTEzLjIxLDAsMCwxLDMwLDEyM2E2MC41NSw2MC41NSwwLDAsMSwxNy4yMS00NEE1Ni44Miw1Ni44MiwwLDAsMSw4OCw2MmguODFhNTcuMzUsNTcuMzUsMCwwLDEsMzUuNDQsMTIuNzEsNiw2LDAsMCwwLDcuNSwwQTU3LjM5LDU3LjM5LDAsMCwxLDE2OCw2MmMxMy44OSwwLDI4LjgxLDQuNjgsMzkuMTEsMTItOS40NCwxMC4xNC0yMS4xLDI2LjU5LTIxLjEsNDYsMCwyMy43OCw3LjgxLDQyLjYsMjIuNjYsNTQuNzdBMTA3LjMzLDEwNy4zMywwLDAsMSwxOTAuMTcsMjAxLjg5Wm0tNjAtMTcxLjM5QTM4LDM4LDAsMCwxLDE2NywyaDFhNiw2LDAsMCwxLDAsMTJoLTFhMjYsMjYsMCwwLDAtMjUuMTgsMTkuNSw2LDYsMCwxLDEtMTEuNjItM1oiLz48L3N2Zz4=');}.icon-check-circle{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0xNzIuMjQsOTkuNzZhNiw2LDAsMCwxLDAsOC40OGwtNTYsNTZhNiw2LDAsMCwxLTguNDgsMGwtMjQtMjRhNiw2LDAsMCwxLDguNDgtOC40OEwxMTIsMTUxLjUxbDUxLjc2LTUxLjc1QTYsNiwwLDAsMSwxNzIuMjQsOTkuNzZaTTIzMCwxMjhBMTAyLDEwMiwwLDEsMSwxMjgsMjYsMTAyLjEyLDEwMi4xMiwwLDAsMSwyMzAsMTI4Wm0tMTIsMGE5MCw5MCwwLDEsMC05MCw5MEE5MC4xLDkwLjEsMCwwLDAsMjE4LDEyOFoiLz48L3N2Zz4=');}.icon-cloud-slash{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik01Mi40NCwzNkE2LDYsMCwwLDAsNDMuNTYsNDRsNDAuMTgsNDQuMmMtLjQ1Ljg3LS45LDEuNzUtMS4zMiwyLjY0QTYyLDYyLDAsMSwwLDcyLDIxNGg4OGE4NS4yMyw4NS4yMywwLDAsMCwzMi4zNS02LjNMMjAzLjU2LDIyMGE2LDYsMCwwLDAsOC44OC04LjA4Wk0xNjAsMjAySDcyYTUwLDUwLDAsMSwxLDUuOS05OS42NEE4Ni4yNSw4Ni4yNSwwLDAsMCw3NCwxMjhhNiw2LDAsMCwwLDEyLDAsNzMuOTIsNzMuOTIsMCwwLDEsNi40NC0zMC4ybDkxLjIyLDEwMC4zNEE3My42NSw3My42NSwwLDAsMSwxNjAsMjAyWm04Ni03NGE4NS44NSw4NS44NSwwLDAsMS0yMS44NSw1Ny4yNyw2LDYsMCwwLDEtNC40NywyLDYsNiwwLDAsMS00LjQ3LTEwLDc0LDc0LDAsMCwwLTk5LTEwOC45Miw2LDYsMCwxLDEtNy4xMS05LjY3QTg2LDg2LDAsMCwxLDI0NiwxMjhaIi8+PC9zdmc+');}.icon-exclamation-mark{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0xNDIsMjAwYTE0LDE0LDAsMSwxLTE0LTE0QTE0LDE0LDAsMCwxLDE0MiwyMDBabS0xNC00MmE2LDYsMCwwLDAsNi02VjQ4YTYsNiwwLDAsMC0xMiwwVjE1MkE2LDYsMCwwLDAsMTI4LDE1OFoiLz48L3N2Zz4=');}.icon-cloud-arrow-down{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0yNDYsMTI4YTg1LjI3LDg1LjI3LDAsMCwxLTE3LjIsNTEuNiw2LDYsMCwxLDEtOS42LTcuMkE3NCw3NCwwLDEsMCw4NiwxMjhhNiw2LDAsMCwxLTEyLDAsODUuNTQsODUuNTQsMCwwLDEsMy45MS0yNS42NEE1MC42OCw1MC42OCwwLDAsMCw3MiwxMDJhNTAsNTAsMCwwLDAsMCwxMDBIOTZhNiw2LDAsMCwxLDAsMTJINzJBNjIsNjIsMCwxLDEsODIuNDMsOTAuODgsODYsODYsMCwwLDEsMjQ2LDEyOFptLTY2LjI0LDQzLjc2TDE1OCwxOTMuNTFWMTI4YTYsNiwwLDAsMC0xMiwwdjY1LjUxbC0yMS43Ni0yMS43NWE2LDYsMCwwLDAtOC40OCw4LjQ4bDMyLDMyYTYsNiwwLDAsMCw4LjQ4LDBsMzItMzJhNiw2LDAsMCwwLTguNDgtOC40OFoiLz48L3N2Zz4=');}.icon-caret-down{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0yMTIuMjQsMTAwLjI0bC04MCw4MGE2LDYsMCwwLDEtOC40OCwwbC04MC04MGE2LDYsMCwwLDEsOC40OC04LjQ4TDEyOCwxNjcuNTFsNzUuNzYtNzUuNzVhNiw2LDAsMCwxLDguNDgsOC40OFoiLz48L3N2Zz4=');}.icon-cloud-arrow-up{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0xODguMjQsMTY0LjI0YTYsNiwwLDAsMS04LjQ4LDBMMTU4LDE0Mi40OVYyMDhhNiw2LDAsMCwxLTEyLDBWMTQyLjQ5bC0yMS43NiwyMS43NWE2LDYsMCwwLDEtOC40OC04LjQ4bDMyLTMyYTYsNiwwLDAsMSw4LjQ4LDBsMzIsMzJBNiw2LDAsMCwxLDE4OC4yNCwxNjQuMjRaTTE2MCw0MkE4Ni4xLDg2LjEsMCwwLDAsODIuNDMsOTAuODgsNjIsNjIsMCwxLDAsNzIsMjE0aDQwYTYsNiwwLDAsMCwwLTEySDcyYTUwLDUwLDAsMCwxLDAtMTAwLDUwLjY4LDUwLjY4LDAsMCwxLDUuOTEuMzZBODUuNTQsODUuNTQsMCwwLDAsNzQsMTI4YTYsNiwwLDAsMCwxMiwwLDc0LDc0LDAsMSwxLDEwMy42LDY3Ljg1LDYsNiwwLDAsMCw0LjgsMTFBODYsODYsMCwwLDAsMTYwLDQyWiIvPjwvc3ZnPg==');}.icon-cloud-check{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0xNjAsNDJBODYuMTEsODYuMTEsMCwwLDAsODIuNDMsOTAuODgsNjIsNjIsMCwxLDAsNzIsMjE0aDg4YTg2LDg2LDAsMCwwLDAtMTcyWm0wLDE2MEg3MmE1MCw1MCwwLDAsMSwwLTEwMCw1MC42Nyw1MC42NywwLDAsMSw1LjkxLjM1QTg1LjYxLDg1LjYxLDAsMCwwLDc0LDEyOGE2LDYsMCwwLDAsMTIsMCw3NCw3NCwwLDEsMSw3NCw3NFptMzYuMjQtOTQuMjRhNiw2LDAsMCwxLDAsOC40OGwtNDgsNDhhNiw2LDAsMCwxLTguNDgsMGwtMjQtMjRhNiw2LDAsMCwxLDguNDgtOC40OEwxNDQsMTUxLjUxbDQzLjc2LTQzLjc1QTYsNiwwLDAsMSwxOTYuMjQsMTA3Ljc2WiIvPjwvc3ZnPg==');}.icon-cloud-warning{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0xNjAsNDJBODYuMTEsODYuMTEsMCwwLDAsODIuNDMsOTAuODgsNjIsNjIsMCwxLDAsNzIsMjE0aDg4YTg2LDg2LDAsMCwwLDAtMTcyWm0wLDE2MEg3MmE1MCw1MCwwLDAsMSwwLTEwMCw1MC42Nyw1MC42NywwLDAsMSw1LjkxLjM1QTg1LjYxLDg1LjYxLDAsMCwwLDc0LDEyOGE2LDYsMCwwLDAsMTIsMCw3NCw3NCwwLDEsMSw3NCw3NFptLTYtNzRWODhhNiw2LDAsMCwxLDEyLDB2NDBhNiw2LDAsMCwxLTEyLDBabTE2LDM2YTEwLDEwLDAsMSwxLTEwLTEwQTEwLDEwLDAsMCwxLDE3MCwxNjRaIi8+PC9zdmc+');}.icon-syncing{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHhtbDpzcGFjZT0icHJlc2VydmUiIHdpZHRoPSIzMiIgaGVpZ2h0PSIzMiIgZmlsbD0iY3VycmVudENvbG9yIiB2aWV3Qm94PSIwIDAgMjU2IDI1NiI+PHBhdGggaWQ9InJlZnJlc2giIGQ9Ik0xNjAuMDQ3IDEyMi44NzVhMzAuNzg0IDMwLjc4NCAwIDAgMC0yMS43NSA4Ljc5N2MtMi44NDIgMy4wMDMtLjQ2NyA0Ljk3MSAxLjMxMiAzLjE1NiAxMS4wNDMtMTAuNzg2IDI4LjcxLTEwLjY4IDM5LjYyNS4yMzRsNy4yMDMgNy4yMDRoLTEyLjg3NWMtMy4zNDcuMDA4LTMuMTY1IDMuODc1IDAgMy44NzVoMTYuMTFjMi4wNjIgMCAyLjU0LTEuNDE4IDIuNTYyLTQuOTdsLjA5NC0xNC45MjFjLjAyLTMuMjktMy40MzctMy4xNjUtMy40MzcgMHYxMi44NmwtNy4yMDMtNy4xODhhMzAuNzY4IDMwLjc2OCAwIDAgMC0yMS42NDEtOS4wNDd6bS0yOS41OTQgMzkuNzk3Yy0yLjA2MiAwLTIuNTI0IDEuNDAyLTIuNTQ3IDQuOTUzbC0uMDk0IDE0LjkyMmMtLjAyIDMuMjkgMy40MjIgMy4xNjQgMy40MjIgMHYtMTIuODZsNy4yMDMgNy4yMDRjMTEuOTU2IDExLjk1NSAzMS4zMTIgMTIuMDY0IDQzLjQwNy4yNSAyLjg0Mi0zLjAwMy40NTEtNC45ODgtMS4zMjgtMy4xNzItMTEuMDQzIDEwLjc4Ni0yOC43MSAxMC42OC0zOS42MjUtLjIzNWwtNy4xODgtNy4yMDNoMTIuODZjMy4zNDctLjAwOCAzLjE2NS0zLjg2IDAtMy44NmgtMTYuMTF6Ii8+PHBhdGggZD0iTTE2MCA0NGE4NC4xMSA4NC4xMSAwIDAgMC03Ni40MSA0OS4xMkE2MC43MSA2MC43MSAwIDAgMCA3MiA5MmE2MCA2MCAwIDAgMCAwIDEyMGg4OGE4NCA4NCAwIDAgMCAwLTE2OFptMCAxNjBINzJhNTIgNTIgMCAxIDEgOC41NS0xMDMuM0E4My42NiA4My42NiAwIDAgMCA3NiAxMjhhNCA0IDAgMCAwIDggMCA3NiA3NiAwIDEgMSA3NiA3NloiLz48L3N2Zz4=');}.icon-cloud-x{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0xNjAsNDJBODYuMTEsODYuMTEsMCwwLDAsODIuNDMsOTAuODgsNjIsNjIsMCwxLDAsNzIsMjE0aDg4YTg2LDg2LDAsMCwwLDAtMTcyWm0wLDE2MEg3MmE1MCw1MCwwLDAsMSwwLTEwMCw1MC42Nyw1MC42NywwLDAsMSw1LjkxLjM1QTg1LjYxLDg1LjYxLDAsMCwwLDc0LDEyOGE2LDYsMCwwLDAsMTIsMCw3NCw3NCwwLDEsMSw3NCw3NFptMjguMjQtODUuNzZMMTY4LjQ4LDEzNmwxOS43NiwxOS43NmE2LDYsMCwxLDEtOC40OCw4LjQ4TDE2MCwxNDQuNDhsLTE5Ljc2LDE5Ljc2YTYsNiwwLDAsMS04LjQ4LTguNDhMMTUxLjUyLDEzNmwtMTkuNzYtMTkuNzZhNiw2LDAsMCwxLDguNDgtOC40OEwxNjAsMTI3LjUybDE5Ljc2LTE5Ljc2YTYsNiwwLDAsMSw4LjQ4LDguNDhaIi8+PC9zdmc+');}.icon-arrows-clockwise{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0yMjIsNDhWOTZhNiw2LDAsMCwxLTYsNkgxNjhhNiw2LDAsMCwxLDAtMTJoMzMuNTJMMTgzLjQ3LDcyYTgxLjUxLDgxLjUxLDAsMCwwLTU3LjUzLTI0aC0uNDZBODEuNSw4MS41LDAsMCwwLDY4LjE5LDcxLjI4YTYsNiwwLDEsMS04LjM4LTguNTgsOTMuMzgsOTMuMzgsMCwwLDEsNjUuNjctMjYuNzZIMTI2YTkzLjQ1LDkzLjQ1LDAsMCwxLDY2LDI3LjUzbDE4LDE4VjQ4YTYsNiwwLDAsMSwxMiwwWk0xODcuODEsMTg0LjcyYTgxLjUsODEuNSwwLDAsMS01Ny4yOSwyMy4zNGgtLjQ2YTgxLjUxLDgxLjUxLDAsMCwxLTU3LjUzLTI0TDU0LjQ4LDE2Nkg4OGE2LDYsMCwwLDAsMC0xMkg0MGE2LDYsMCwwLDAtNiw2djQ4YTYsNiwwLDAsMCwxMiwwVjE3NC40OGwxOCwxOC4wNWE5My40NSw5My40NSwwLDAsMCw2NiwyNy41M2guNTJhOTMuMzgsOTMuMzgsMCwwLDAsNjUuNjctMjYuNzYsNiw2LDAsMSwwLTguMzgtOC41OFoiLz48L3N2Zz4=');}.icon-share-fat{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0yMzYuMjQsMTA3Ljc2bC04MC04MEE2LDYsMCwwLDAsMTQ2LDMyVjc0LjJjLTU0LjQ4LDMuNTktMTIwLjM5LDU1LTEyNy45MywxMjAuNjZhMTAsMTAsMCwwLDAsMTcuMjMsOGgwQzQ2LjU2LDE5MC44NSw4NywxNTIuNiwxNDYsMTUwLjEzVjE5MmE2LDYsMCwwLDAsMTAuMjQsNC4yNGw4MC04MEE2LDYsMCwwLDAsMjM2LjI0LDEwNy43NlpNMTU4LDE3Ny41MlYxNDRhNiw2LDAsMCwwLTYtNmMtMjcuNzMsMC01NC43Niw3LjI1LTgwLjMyLDIxLjU1YTE5My4zOCwxOTMuMzgsMCwwLDAtNDAuODEsMzAuNjVjNC43LTI2LjU2LDIwLjE2LTUyLDQ0LTcyLjI3Qzk4LjQ3LDk3Ljk0LDEyNy4yOSw4NiwxNTIsODZhNiw2LDAsMCwwLDYtNlY0Ni40OUwyMjMuNTEsMTEyWiIvPjwvc3ZnPg==');}.icon-trash{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0yMTYsNTBIMTc0VjQwYTIyLDIyLDAsMCwwLTIyLTIySDEwNEEyMiwyMiwwLDAsMCw4Miw0MFY1MEg0MGE2LDYsMCwwLDAsMCwxMkg1MFYyMDhhMTQsMTQsMCwwLDAsMTQsMTRIMTkyYTE0LDE0LDAsMCwwLDE0LTE0VjYyaDEwYTYsNiwwLDAsMCwwLTEyWk05NCw0MGExMCwxMCwwLDAsMSwxMC0xMGg0OGExMCwxMCwwLDAsMSwxMCwxMFY1MEg5NFpNMTk0LDIwOGEyLDIsMCwwLDEtMiwySDY0YTIsMiwwLDAsMS0yLTJWNjJIMTk0Wk0xMTAsMTA0djY0YTYsNiwwLDAsMS0xMiwwVjEwNGE2LDYsMCwwLDEsMTIsMFptNDgsMHY2NGE2LDYsMCwwLDEtMTIsMFYxMDRhNiw2LDAsMCwxLDEyLDBaIi8+PC9zdmc+');}.icon-star{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0yMzcuMjgsOTcuODdBMTQuMTgsMTQuMTgsMCwwLDAsMjI0Ljc2LDg4bC02MC4yNS00Ljg3LTIzLjIyLTU2LjJhMTQuMzcsMTQuMzcsMCwwLDAtMjYuNTgsMEw5MS40OSw4My4xMSwzMS4yNCw4OGExNC4xOCwxNC4xOCwwLDAsMC0xMi41Miw5Ljg5QTE0LjQzLDE0LjQzLDAsMCwwLDIzLDExMy4zMkw2OSwxNTIuOTNsLTE0LDU5LjI1YTE0LjQsMTQuNCwwLDAsMCw1LjU5LDE1LDE0LjEsMTQuMSwwLDAsMCwxNS45MS42TDEyOCwxOTYuMTJsNTEuNTgsMzEuNzFhMTQuMSwxNC4xLDAsMCwwLDE1LjkxLS42LDE0LjQsMTQuNCwwLDAsMCw1LjU5LTE1bC0xNC01OS4yNUwyMzMsMTEzLjMyQTE0LjQzLDE0LjQzLDAsMCwwLDIzNy4yOCw5Ny44N1ptLTEyLjE0LDYuMzctNDguNjksNDJhNiw2LDAsMCwwLTEuOTIsNS45MmwxNC44OCw2Mi43OWEyLjM1LDIuMzUsMCwwLDEtLjk1LDIuNTcsMi4yNCwyLjI0LDAsMCwxLTIuNi4xTDEzMS4xNCwxODRhNiw2LDAsMCwwLTYuMjgsMEw3MC4xNCwyMTcuNjFhMi4yNCwyLjI0LDAsMCwxLTIuNi0uMSwyLjM1LDIuMzUsMCwwLDEtMS0yLjU3bDE0Ljg4LTYyLjc5YTYsNiwwLDAsMC0xLjkyLTUuOTJsLTQ4LjY5LTQyYTIuMzcsMi4zNywwLDAsMS0uNzMtMi42NSwyLjI4LDIuMjgsMCwwLDEsMi4wNy0xLjY1bDYzLjkyLTUuMTZhNiw2LDAsMCwwLDUuMDYtMy42OWwyNC42My01OS42YTIuMzUsMi4zNSwwLDAsMSw0LjM4LDBsMjQuNjMsNTkuNmE2LDYsMCwwLDAsNS4wNiwzLjY5bDYzLjkyLDUuMTZhMi4yOCwyLjI4LDAsMCwxLDIuMDcsMS42NUEyLjM3LDIuMzcsMCwwLDEsMjI1LjE0LDEwNC4yNFoiLz48L3N2Zz4=');}.icon-alphabetical{--icon:url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMzIiIGhlaWdodD0iMzIiIGZpbGw9ImN1cnJlbnRDb2xvciIgdmVyc2lvbj0iMS4xIiB2aWV3Qm94PSIwIDAgMTgzLjc4IDE4NC4wNiIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48cGF0aCBkPSJtNTkuNTg2IDY5Ljc0MmMtMC44NTEzIDAtMS40NjEgMC4xOTY1Ni0xLjgzNjYgMC41OTcxOC0wLjM1MDU0IDAuMzc1NTgtMC41Mjk1OCAxLjAyMjktMC41Mjk1OCAxLjk0OTNzMC4xNzkwMyAxLjU5MzcgMC41Mjk1OCAxLjk5NDRjMC4zNzU1OCAwLjM3NTU4IDAuOTg1MjkgMC41NjMzOCAxLjgzNjYgMC41NjMzOGg3LjAxOTdsLTEyLjQyOCAzNC4zNjZoLTIuMTA3Yy0wLjg1MTMgMC0xLjQ2MSAwLjE5NjU2LTEuODM2NiAwLjU5NzE4LTAuMzUwNTQgMC4zNzU1OC0wLjUyOTU3IDEuMDM0MS0wLjUyOTU3IDEuOTYwNiAwIDAuOTI2NDQgMC4xNzkwMyAxLjU4MjUgMC41Mjk1NyAxLjk4MyAwLjM3NTU4IDAuMzc1NTkgMC45ODUyOSAwLjU2MzM4IDEuODM2NiAwLjU2MzM4aDEyLjU1MmMwLjg1MTMgMCAxLjQ1MjItMC4xODc3OSAxLjgwMjgtMC41NjMzOCAwLjM3NTU4LTAuNDAwNjIgMC41NjMzNy0xLjA1NjYgMC41NjMzNy0xLjk4MyAwLTAuOTI2NDUtMC4xODc3OS0xLjU4NS0wLjU2MzM3LTEuOTYwNi0wLjM1MDU0LTAuNDAwNjItMC45NTE0Ny0wLjU5NzE4LTEuODAyOC0wLjU5NzE4aC00LjU1MjFsMy4xMjExLTguOTM0OWgxOC4yMmwzLjA3NiA4LjkzNDloLTUuMDcwNGMtMC44NTEzIDAtMS40NjEgMC4xOTY1Ni0xLjgzNjYgMC41OTcxOC0wLjM1MDU0IDAuMzc1NTgtMC41Mjk1OCAxLjAzNDEtMC41Mjk1OCAxLjk2MDYgMCAwLjkyNjQ0IDAuMTc5MDMgMS41ODI1IDAuNTI5NTggMS45ODMgMC4zNzU1OCAwLjM3NTU5IDAuOTg1MjkgMC41NjMzOCAxLjgzNjYgMC41NjMzOGgxMy4yOTZjMC44NTEzIDAgMS40NTIyLTAuMTg3NzkgMS44MDI4LTAuNTYzMzggMC4zNzU1OC0wLjQwMDYyIDAuNTYzMzctMS4wNTY2IDAuNTYzMzctMS45ODMgMC0wLjkyNjQ1LTAuMTg3NzktMS41ODUtMC41NjMzNy0xLjk2MDYtMC4zNTA1NC0wLjQwMDYyLTAuOTUxNDctMC41OTcxOC0xLjgwMjgtMC41OTcxOGgtMi4yODczbC0xMy4yNjItMzcuMDM2Yy0wLjMwMDQ3LTAuODUxMy0wLjc1OTk0LTEuNDYxLTEuMzg1OS0xLjgzNjYtMC42MDA5My0wLjQwMDYyLTEuNDA5Ny0wLjU5NzE4LTIuNDExMy0wLjU5NzE4em00NC4xNDYgMGMtMC44NTEzIDAtMS40NzIzIDAuMTk2NTYtMS44NDc4IDAuNTk3MTgtMC4zNTA1NSAwLjM3NTU4LTAuNTE4MyAxLjAyMjktMC41MTgzIDEuOTQ5M3YxMS45MWMwIDAuODc2MzMgMC4yMDUzMiAxLjUwNjEgMC42MzA5OCAxLjg4MTcgMC40MjU2NiAwLjM3NTU4IDEuMTU5MyAwLjU2MzM3IDIuMTg1OSAwLjU2MzM3czEuNzQ5LTAuMTg3NzkgMi4xNzQ3LTAuNTYzMzdjMC40MjU2OS0wLjM3NTU4IDAuNjQyMjYtMS4wMDUzIDAuNjQyMjYtMS44ODE3di05LjM1MTdoMTguODUxbC0yNC43NTQgMzUuMzAxYy0wLjM1MDU0IDAuNTI1ODItMC41MTgzMSAxLjA3MTctMC41MTgzMSAxLjYyMjYgMCAwLjkyNjQ1IDAuMTY3NzcgMS41ODI1IDAuNTE4MzEgMS45ODMxIDAuMzc1NTggMC4zNzU1OCAwLjk5NjU0IDAuNTYzMzggMS44NDc4IDAuNTYzMzhoMjguNzY2YzAuODUxMyAwIDEuNDUyMi0wLjE4NzggMS44MDI4LTAuNTYzMzggMC4zNzU1OC0wLjQwMDYyIDAuNTYzMzgtMS4wNTY2IDAuNTYzMzgtMS45ODMxdi0xMi42NjVjMC0wLjg3NjMzLTAuMjE2NTgtMS40OTQ4LTAuNjQyMjUtMS44NzA0LTAuNDI1NjYtMC4zNzU1OC0xLjE0OC0wLjU2MzM4LTIuMTc0Ny0wLjU2MzM4LTEuMDI2NiAwLTEuNzQ5IDAuMTg3NzktMi4xNzQ3IDAuNTYzMzgtMC40MjU2NiAwLjM3NTU4LTAuNjQyMjQgMC45OTQwMi0wLjY0MjI0IDEuODcwNHYxMC4xMDdoLTE5Ljk3OGwyNC45MDEtMzUuNDU5YzAuMjUwMzktMC4zNTA1NCAwLjM3MTgzLTAuODM4ODMgMC4zNzE4My0xLjQ2NDggMC0wLjkyNjQ1LTAuMTg3OC0xLjU3MzctMC41NjMzOC0xLjk0OTMtMC4zNTA1NS0wLjQwMDYyLTAuOTUxNDctMC41OTcxOC0xLjgwMjgtMC41OTcxOHptLTMxLjc1MiA1LjEwNDJoMC43MDk4NWw2Ljk4NTkgMjAuMzE1aC0xNC43MTZ6bS0zNy43MjMtNDkuMTgzYy00LjczNDIgMC04LjYzMTMgMy44OTctOC42MzEzIDguNjMxM3YxMTUuNDdjMCA0LjczNDIgMy44OTcgOC42MzEzIDguNjMxMyA4LjYzMTNoMTE1LjI2YzQuNzM0MiAwIDguNjQyMS0zLjg5NyA4LjY0MjEtOC42MzEzdi0xMTUuNDdjMC00LjczNDItMy45MDgyLTguNjMxMy04LjY0MjEtOC42MzEzem0wIDUuNzI0aDExNS4yNmMxLjY1OCAwIDIuOTA3IDEuMjQ5MSAyLjkwNyAyLjkwNzF2MTE1LjQ3YzAgMS42NTgtMS4yNDkxIDIuOTA3LTIuOTA3IDIuOTA3aC0xMTUuMjZjLTEuNjU4IDAtMi44OTU4LTEuMjQ5MS0yLjg5NTgtMi45MDd2LTExNS40N2MwLTEuNjU4IDEuMjM3OC0yLjkwNzEgMi44OTU4LTIuOTA3MXoiIGZpbGw9ImN1cnJlbnRDb2xvciIgc3Ryb2tlLXdpZHRoPSIuNzIxMTQiLz48L3N2Zz4=');}.icon-scribble{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0yMDQuMjUsMTg4LjI0YTE2LjYzLDE2LjYzLDAsMCwwLDAsMjMuNTIsNiw2LDAsMSwxLTguNDgsOC40OCwyOC42MSwyOC42MSwwLDAsMSwwLTQwLjQ4bDkuMzctOS4zOGExNi42MywxNi42MywwLDAsMC0yMy41Mi0yMy41MWwtNjYuNzUsNjYuNzVhMjguNjMsMjguNjMsMCwwLDEtNDAuNDktNDAuNDlsOTguNzYtOTguNzVhMTYuNjMsMTYuNjMsMCwwLDAtMjMuNTItMjMuNTFMODIuODYsMTE3LjYyQTI4LjYzLDI4LjYzLDAsMCwxLDQyLjM3LDc3LjEzTDgzLjc1LDM1Ljc2YTYsNiwwLDEsMSw4LjQ5LDguNDhMNTAuODYsODUuNjJhMTYuNjMsMTYuNjMsMCwwLDAsMjMuNTIsMjMuNTFsNjYuNzUtNjYuNzVhMjguNjMsMjguNjMsMCwwLDEsNDAuNDksNDAuNDlMODIuODYsMTgxLjYyYTE2LjYzLDE2LjYzLDAsMCwwLDIzLjUyLDIzLjUxbDY2Ljc2LTY2Ljc1YTI4LjYzLDI4LjYzLDAsMCwxLDQwLjQ5LDQwLjQ5WiIvPjwvc3ZnPg==');}.icon-brackets-angle{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik04NS4wNiw0My4yMiwzMS4xMSwxMjhsNTQsODQuNzhhNiw2LDAsMCwxLTEuODQsOC4yOCw2LDYsMCwwLDEtOC4yOC0xLjg0bC01Ni04OGE2LDYsMCwwLDEsMC02LjQ0bDU2LTg4YTYsNiwwLDAsMSwxMC4xMiw2LjQ0Wm0xNTIsODEuNTYtNTYtODhhNiw2LDAsMSwwLTEwLjEyLDYuNDRMMjI0Ljg5LDEyOGwtNTMuOTUsODQuNzhhNiw2LDAsMCwwLDEuODQsOC4yOCw2LDYsMCwwLDAsOC4yOC0xLjg0bDU2LTg4QTYsNiwwLDAsMCwyMzcuMDYsMTI0Ljc4WiIvPjwvc3ZnPg==');}.icon-brain{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0yNDYsMTI0YTU0LjEzLDU0LjEzLDAsMCwwLTMyLTQ5LjMzVjcyYTQ2LDQ2LDAsMCwwLTg2LTIyLjY3QTQ2LDQ2LDAsMCwwLDQyLDcydjIuNjdhNTQsNTQsMCwwLDAsMCw5OC42M1YxNzZhNDYsNDYsMCwwLDAsODYsMjIuNjdBNDYsNDYsMCwwLDAsMjE0LDE3NnYtMi43QTU0LjA3LDU0LjA3LDAsMCwwLDI0NiwxMjRaTTg4LDIxMGEzNCwzNCwwLDAsMS0zNC0zMi45NEE1My42Nyw1My42NywwLDAsMCw2NCwxNzhoOGE2LDYsMCwwLDAsMC0xMkg2NEE0Miw0MiwwLDAsMSw1MCw4NC4zOWE2LDYsMCwwLDAsNC01LjY2VjcyYTM0LDM0LDAsMCwxLDY4LDB2NzMuMDVBNDUuODksNDUuODksMCwwLDAsODgsMTMwYTYsNiwwLDAsMCwwLDEyLDM0LDM0LDAsMCwxLDAsNjhabTEwNC00NGgtOGE2LDYsMCwwLDAsMCwxMmg4YTUzLjY3LDUzLjY3LDAsMCwwLDEwLS45NEEzNCwzNCwwLDEsMSwxNjgsMTQyYTYsNiwwLDAsMCwwLTEyLDQ1Ljg5LDQ1Ljg5LDAsMCwwLTM0LDE1LjA1VjcyYTM0LDM0LDAsMCwxLDY4LDB2Ni43M2E2LDYsMCwwLDAsNCw1LjY2QTQyLDQyLDAsMCwxLDE5MiwxNjZabTE0LTU0YTYsNiwwLDAsMS02LDZoLTRhMzQsMzQsMCwwLDEtMzQtMzRWODBhNiw2LDAsMCwxLDEyLDB2NGEyMiwyMiwwLDAsMCwyMiwyMmg0QTYsNiwwLDAsMSwyMDYsMTEyWk02MCwxMThINTZhNiw2LDAsMCwxLDAtMTJoNEEyMiwyMiwwLDAsMCw4Miw4NFY4MGE2LDYsMCwwLDEsMTIsMHY0QTM0LDM0LDAsMCwxLDYwLDExOFoiLz48L3N2Zz4=');}.icon-palette{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0xOTkuMzcsNTUuMzFBMTAxLjMyLDEwMS4zMiwwLDAsMCwxMjgsMjZoLTFBMTAyLDEwMiwwLDAsMCwyNiwxMjhjMCw0Mi4wOSwyNi4wNyw3Ny40NCw2OCw5Mi4yNkEzMC4yMSwzMC4yMSwwLDAsMCwxMDQuMTEsMjIyLDMwLjA2LDMwLjA2LDAsMCwwLDEzNCwxOTJhMTgsMTgsMCwwLDEsMTgtMThoNDYuMjFhMjkuODIsMjkuODIsMCwwLDAsMjkuMjUtMjMuMzFBMTAyLjcxLDEwMi43MSwwLDAsMCwyMzAsMTI3LjExLDEwMS4yNSwxMDEuMjUsMCwwLDAsMTk5LjM3LDU1LjMxWk0yMTUuNzYsMTQ4YTE3Ljg5LDE3Ljg5LDAsMCwxLTE3LjU1LDE0SDE1MmEzMCwzMCwwLDAsMC0zMCwzMCwxOCwxOCwwLDAsMS0yNCwxN0M2MSwxOTUuODYsMzgsMTY0Ljg1LDM4LDEyOGE5MCw5MCwwLDAsMSw4OS4wNy05MEgxMjhhOTAuMzQsOTAuMzQsMCwwLDEsOTAsODkuMjJBOTAuNDYsOTAuNDYsMCwwLDEsMjE1Ljc2LDE0OFpNMTM4LDc2YTEwLDEwLDAsMSwxLTEwLTEwQTEwLDEwLDAsMCwxLDEzOCw3NlpNOTQsMTAwQTEwLDEwLDAsMSwxLDg0LDkwLDEwLDEwLDAsMCwxLDk0LDEwMFptMCw1NmExMCwxMCwwLDEsMS0xMC0xMEExMCwxMCwwLDAsMSw5NCwxNTZabTg4LTU2YTEwLDEwLDAsMSwxLTEwLTEwQTEwLDEwLDAsMCwxLDE4MiwxMDBaIi8+PC9zdmc+');}.icon-pen-nib{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0yNDYsOTIuNjhhMTMuOTQsMTMuOTQsMCwwLDAtNC4xLTkuOUwxNzMuMjEsMTQuMWExNCwxNCwwLDAsMC0xOS44LDBMMTI0LjY4LDQyLjgzLDY2LjIyLDY0Ljc2YTE0LDE0LDAsMCwwLTguOSwxMC44TDM0LjA4LDIxNUE2LDYsMCwwLDAsNDAsMjIyYTYuNjEsNi42MSwwLDAsMCwxLS4wOGwxMzkuNDQtMjMuMjRhMTQsMTQsMCwwLDAsMTAuODEtOC45bDIxLjkyLTU4LjQ2LDI4Ljc0LTI4Ljc0QTEzLjkyLDEzLjkyLDAsMCwwLDI0Niw5Mi42OFptLTY2LDkyLjg5YTIsMiwwLDAsMS0xLjU0LDEuMjdMNTcuNDksMjA3bDUyLjg3LTUyLjg4YTI2LDI2LDAsMSwwLTguNDgtOC40OEw0OSwxOTguNTNsMjAuMTctMTIxQTIsMiwwLDAsMSw3MC40Myw3Nmw1Ni4wNi0yMUwyMDEsMTI5LjUxWk0xMTAsMTMyYTE0LDE0LDAsMSwxLDE0LDE0QTE0LDE0LDAsMCwxLDExMCwxMzJaTTIzMy40MSw5NC4xLDIwOCwxMTkuNTEsMTM2LjQ4LDQ4LDE2MS45LDIyLjU4YTIsMiwwLDAsMSwyLjgzLDBsNjguNjgsNjguNjlhMiwyLDAsMCwxLDAsMi44M1oiLz48L3N2Zz4=');}.icon-question{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0xMzgsMTgwYTEwLDEwLDAsMSwxLTEwLTEwQTEwLDEwLDAsMCwxLDEzOCwxODBaTTEyOCw3NGMtMjEsMC0zOCwxNS4yNS0zOCwzNHY0YTYsNiwwLDAsMCwxMiwwdi00YzAtMTIuMTMsMTEuNjYtMjIsMjYtMjJzMjYsOS44NywyNiwyMi0xMS42NiwyMi0yNiwyMmE2LDYsMCwwLDAtNiw2djhhNiw2LDAsMCwwLDEyLDB2LTIuNDJjMTguMTEtMi41OCwzMi0xNi42NiwzMi0zMy41OEMxNjYsODkuMjUsMTQ5LDc0LDEyOCw3NFptMTAyLDU0QTEwMiwxMDIsMCwxLDEsMTI4LDI2LDEwMi4xMiwxMDIuMTIsMCwwLDEsMjMwLDEyOFptLTEyLDBhOTAsOTAsMCwxLDAtOTAsOTBBOTAuMSw5MC4xLDAsMCwwLDIxOCwxMjhaIi8+PC9zdmc+');}.icon-city{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0yNDAsMjEwSDIzMFY4OGE2LDYsMCwwLDAtNi02SDE2MGE2LDYsMCwwLDAtNiw2djQySDEwMlY0MGE2LDYsMCwwLDAtNi02SDMyYTYsNiwwLDAsMC02LDZWMjEwSDE2YTYsNiwwLDAsMCwwLDEySDI0MGE2LDYsMCwwLDAsMC0xMlpNMTY2LDk0aDUyVjIxMEgxNjZabS0xMiw0OHY2OEgxMDJWMTQyWk0zOCw0Nkg5MFYyMTBIMzhaTTcwLDcyVjg4YTYsNiwwLDAsMS0xMiwwVjcyYTYsNiwwLDAsMSwxMiwwWm0wLDQ4djE2YTYsNiwwLDAsMS0xMiwwVjEyMGE2LDYsMCwwLDEsMTIsMFptMCw0OHYxNmE2LDYsMCwwLDEtMTIsMFYxNjhhNiw2LDAsMCwxLDEyLDBabTUyLDE2VjE2OGE2LDYsMCwwLDEsMTIsMHYxNmE2LDYsMCwwLDEtMTIsMFptNjQsMFYxNjhhNiw2LDAsMCwxLDEyLDB2MTZhNiw2LDAsMCwxLTEyLDBabTAtNDhWMTIwYTYsNiwwLDAsMSwxMiwwdjE2YTYsNiwwLDAsMS0xMiwwWiIvPjwvc3ZnPg==');}.icon-folder{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0yMTYsNzRIMTMwLjQ5bC0yNy45LTI3LjlhMTMuOTQsMTMuOTQsMCwwLDAtOS45LTQuMUg0MEExNCwxNCwwLDAsMCwyNiw1NlYyMDAuNjJBMTMuMzksMTMuMzksMCwwLDAsMzkuMzgsMjE0SDIxNi44OUExMy4xMiwxMy4xMiwwLDAsMCwyMzAsMjAwLjg5Vjg4QTE0LDE0LDAsMCwwLDIxNiw3NFpNNDAsNTRIOTIuNjlhMiwyLDAsMCwxLDEuNDEuNTlMMTEzLjUxLDc0SDM4VjU2QTIsMiwwLDAsMSw0MCw1NFpNMjE4LDIwMC44OWExLjExLDEuMTEsMCwwLDEtMS4xMSwxLjExSDM5LjM4QTEuNCwxLjQsMCwwLDEsMzgsMjAwLjYyVjg2SDIxNmEyLDIsMCwwLDEsMiwyWiIvPjwvc3ZnPg==');}.icon-hash{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0yMjQsOTBIMTczbDguODktNDguOTNhNiw2LDAsMSwwLTExLjgtMi4xNEwxNjAuODEsOTBIMTA5bDguODktNDguOTNhNiw2LDAsMCwwLTExLjgtMi4xNEw5Ni44MSw5MEg0OGE2LDYsMCwwLDAsMCwxMkg5NC42M2wtOS40Niw1MkgzMmE2LDYsMCwwLDAsMCwxMkg4M0w3NC4xLDIxNC45M2E2LDYsMCwwLDAsNC44Myw3QTUuNjQsNS42NCwwLDAsMCw4MCwyMjJhNiw2LDAsMCwwLDUuODktNC45M0w5NS4xOSwxNjZIMTQ3bC04Ljg5LDQ4LjkzYTYsNiwwLDAsMCw0LjgzLDcsNS42NCw1LjY0LDAsMCwwLDEuMDguMSw2LDYsMCwwLDAsNS44OS00LjkzTDE1OS4xOSwxNjZIMjA4YTYsNiwwLDAsMCwwLTEySDE2MS4zN2w5LjQ2LTUySDIyNGE2LDYsMCwwLDAsMC0xMlptLTc0LjgzLDY0SDk3LjM3bDkuNDYtNTJoNTEuOFoiLz48L3N2Zz4=');}.icon-shapes{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik02OS42OSw2Mi4xYTYsNiwwLDAsMC0xMS4zOCwwbC00MCwxMjBBNiw2LDAsMCwwLDI0LDE5MGg4MGE2LDYsMCwwLDAsNS42OS03LjlaTTMyLjMyLDE3OCw2NCw4M2wzMS42OCw5NVpNMjA2LDc2YTUwLDUwLDAsMSwwLTUwLDUwQTUwLjA2LDUwLjA2LDAsMCwwLDIwNiw3NlptLTg4LDBhMzgsMzgsMCwxLDEsMzgsMzhBMzgsMzgsMCwwLDEsMTE4LDc2Wm0xMDYsNzBIMTM2YTYsNiwwLDAsMC02LDZ2NTZhNiw2LDAsMCwwLDYsNmg4OGE2LDYsMCwwLDAsNi02VjE1MkE2LDYsMCwwLDAsMjI0LDE0NlptLTYsNTZIMTQyVjE1OGg3NloiLz48L3N2Zz4=');}.icon-diamonds-four{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0xMjMuNzYsMTA4LjI0YTYsNiwwLDAsMCw4LjQ4LDBsNDAtNDBhNiw2LDAsMCwwLDAtOC40OGwtNDAtNDBhNiw2LDAsMCwwLTguNDgsMGwtNDAsNDBhNiw2LDAsMCwwLDAsOC40OFpNMTI4LDMyLjQ5LDE1OS41MSw2NCwxMjgsOTUuNTEsOTYuNDksNjRabTQuMjQsMTE1LjI3YTYsNiwwLDAsMC04LjQ4LDBsLTQwLDQwYTYsNiwwLDAsMCwwLDguNDhsNDAsNDBhNiw2LDAsMCwwLDguNDgsMGw0MC00MGE2LDYsMCwwLDAsMC04LjQ4Wk0xMjgsMjIzLjUxLDk2LjQ5LDE5MiwxMjgsMTYwLjQ5LDE1OS41MSwxOTJabTEwOC4yNC05OS43NS00MC00MGE2LDYsMCwwLDAtOC40OCwwbC00MCw0MGE2LDYsMCwwLDAsMCw4LjQ4bDQwLDQwYTYsNiwwLDAsMCw4LjQ4LDBsNDAtNDBBNiw2LDAsMCwwLDIzNi4yNCwxMjMuNzZaTTE5MiwxNTkuNTEsMTYwLjQ5LDEyOCwxOTIsOTYuNDksMjIzLjUxLDEyOFptLTgzLjc2LTM1Ljc1LTQwLTQwYTYsNiwwLDAsMC04LjQ4LDBsLTQwLDQwYTYsNiwwLDAsMCwwLDguNDhsNDAsNDBhNiw2LDAsMCwwLDguNDgsMGw0MC00MEE2LDYsMCwwLDAsMTA4LjI0LDEyMy43NlpNNjQsMTU5LjUxLDMyLjQ5LDEyOCw2NCw5Ni40OSw5NS41MSwxMjhaIi8+PC9zdmc+');}.icon-crosshair-simple{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0xMjgsMjZBMTAyLDEwMiwwLDEsMCwyMzAsMTI4LDEwMi4xMiwxMDIuMTIsMCwwLDAsMTI4LDI2Wm02LDE5MS44VjE4NGE2LDYsMCwwLDAtMTIsMHYzMy44QTkwLjE1LDkwLjE1LDAsMCwxLDM4LjIsMTM0SDcyYTYsNiwwLDAsMCwwLTEySDM4LjJBOTAuMTUsOTAuMTUsMCwwLDEsMTIyLDM4LjJWNzJhNiw2LDAsMCwwLDEyLDBWMzguMkE5MC4xNSw5MC4xNSwwLDAsMSwyMTcuOCwxMjJIMTg0YTYsNiwwLDAsMCwwLDEyaDMzLjhBOTAuMTUsOTAuMTUsMCwwLDEsMTM0LDIxNy44WiIvPjwvc3ZnPg==');}.icon-circle-notch{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0yMzAsMTI4YTEwMiwxMDIsMCwwLDEtMjA0LDBjMC00MC4xOCwyMy4zNS03Ni44Niw1OS41LTkzLjQ1YTYsNiwwLDAsMSw1LDEwLjlDNTguNjEsNjAuMDksMzgsOTIuNDksMzgsMTI4YTkwLDkwLDAsMCwwLDE4MCwwYzAtMzUuNTEtMjAuNjEtNjcuOTEtNTIuNS04Mi41NWE2LDYsMCwwLDEsNS0xMC45QzIwNi42NSw1MS4xNCwyMzAsODcuODIsMjMwLDEyOFoiLz48L3N2Zz4=');}.icon-cards-three{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0yMDgsOTBINDhhMTQsMTQsMCwwLDAtMTQsMTR2OTZhMTQsMTQsMCwwLDAsMTQsMTRIMjA4YTE0LDE0LDAsMCwwLDE0LTE0VjEwNEExNCwxNCwwLDAsMCwyMDgsOTBabTIsMTEwYTIsMiwwLDAsMS0yLDJINDhhMiwyLDAsMCwxLTItMlYxMDRhMiwyLDAsMCwxLDItMkgyMDhhMiwyLDAsMCwxLDIsMlpNNTAsNjRhNiw2LDAsMCwxLDYtNkgyMDBhNiw2LDAsMCwxLDAsMTJINTZBNiw2LDAsMCwxLDUwLDY0Wk02NiwzMmE2LDYsMCwwLDEsNi02SDE4NGE2LDYsMCwwLDEsMCwxMkg3MkE2LDYsMCwwLDEsNjYsMzJaIi8+PC9zdmc+');}.icon-logo{--icon:url('data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiIHN0YW5kYWxvbmU9Im5vIj8+PHN2ZyAgICB3aWR0aD0iMzIiICAgIGhlaWdodD0iMzIiICAgIHZpZXdCb3g9IjAgMCAzMiAzMiIgICAgdmVyc2lvbj0iMS4xIiAgICB4bWw6c3BhY2U9InByZXNlcnZlIiAgICBzdHlsZT0iY2xpcC1ydWxlOmV2ZW5vZGQ7ZmlsbC1ydWxlOmV2ZW5vZGQ7c3Ryb2tlLWxpbmVjYXA6c3F1YXJlO3N0cm9rZS1saW5lam9pbjpyb3VuZDtzdHJva2UtbWl0ZXJsaW1pdDoxLjUiICAgIGlkPSJzdmcxNCIgICAgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIiAgICB4bWxuczpzdmc9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48ZGVmcyAgICBpZD0iZGVmczE0IiAvPjxwYXRoICAgIGQ9Ik0gMTYuNTgwMDc4LDIuMTMyODEyNSBDIDguODY0ODQ0OSwyLjEzMjgxMjUgMS40NDA2MDIxLDguMTQ2NjIxOCAyLjAzMzIwMzEsMTUuODM5ODQ0IDIuNTk0NDU4OCwyMy4xMjY2NjYgOC45NzEyMDEyLDI5LjAyNTE1NSAxNS45NzA3MDMsMjkuNzg3MTA5IDI0LjIyMTIyNCwzMC42ODUyNCAzMC40NDA5MTEsMjMuODM0Mjc3IDI5Ljk3NDYwOSwxNS43OTg4MjggMjkuNTI3ODEzLDguMDk5ODY1NSAyNC4yOTE1NiwyLjEzMjgxMjUgMTYuNTgwMDc4LDIuMTMyODEyNSBaIG0gMCwwLjYwNzQyMTkgYyAwLjAxMDQ2LDAgMC4wMjA4MywwIDAuMDMxMjUsMCBWIDI5LjIzMjQyMiBjIC0wLjE5MDMyMywtMC4wMTIxOCAtMC4zODE1MjEsLTAuMDI3ODMgLTAuNTc0MjE5LC0wLjA0ODgzIEMgOS4zMTMwNDUzLDI4LjQ1MTYxNSAzLjE3Nzg3NzUsMjIuNzkzMDQ0IDIuNjM4NjcxOSwxNS43OTI5NjkgMi4wNzI0NTYsOC40NDIwMTUzIDkuMjA4MTAwOCwyLjc0MDIzNDQgMTYuNTgwMDc4LDIuNzQwMjM0NCBaIE0gMTYuMDkxNzk3LDMuODg0NzY1NiAxNiwzLjg4ODY3MTkgQyAxMi43MjU0NTQsNC4wMTgzNDg5IDkuNTUyMzM3OSw1LjM2NDY4MzggNy4yNTU4NTk0LDcuNSA0Ljk1OTM4MDksOS42MzUzMTYyIDMuNTQwMjcwMywxMi41NjQ5NzIgMy43OTI5Njg4LDE1Ljg0NTcwMyA0LjI4NDc3MzksMjIuMjMwMDQ1IDkuODY0NDgxMiwyNy4zODM2MDYgMTUuOTk0MTQxLDI4LjA1MjczNCBsIDAuMDg5ODQsMC4wMDk4IDAuMDIxNDgsLTAuMTgxNjQxIC0wLjA5MTgsLTAuMDA5OCBDIDkuOTcyNjc0OSwyNy4yMTE2NDQgNC40NTg4NjkxLDIyLjExNjQ2OCAzLjk3NDYwOTQsMTUuODMwMDc4IDMuNzI2NTU1OSwxMi42MDk2NTEgNS4xMTU4MDg0LDkuNzM5MDQzNyA3LjM3ODkwNjIsNy42MzQ3NjU2IDkuNjQyMDA0MSw1LjUzMDQ4NzUgMTIuNzc4NTM5LDQuMTk4MTk2OCAxNi4wMDc4MTIsNC4wNzAzMTI1IGwgMC4wOTE4LC0wLjAwMzkxIHogbSAwLDEuNTAxOTUzMSBMIDE2LDUuMzkwNjI1IEMgMTMuMTMxOTQ5LDUuNTA0MjA0NyAxMC4zNTMyOTgsNi42ODQzNDE1IDguMzQxNzk2OSw4LjU1NDY4NzUgNi4zMzAyOTYyLDEwLjQyNTAzMyA1LjA4NzE5MjksMTIuOTkwODE5IDUuMzA4NTkzNywxNS44NjUyMzQgNS43MzkzOTQsMjEuNDU3NjY5IDEwLjYyNTE2MSwyNS45NzA1NDcgMTUuOTk0MTQxLDI2LjU1NjY0MSBsIDAuMDg5ODQsMC4wMDk4IDAuMDIxNDgsLTAuMTc5Njg3IC0wLjA5MTgsLTAuMDA5OCBDIDEwLjczMzM1NCwyNS44MDA1MzMgNS45MTM0ODkyLDIxLjM0NjA0NiA1LjQ5MDIzNDQsMTUuODUxNTYzIDUuMjczNDc4NCwxMy4wMzc0NTEgNi40ODY3MjM3LDEwLjUyNjgwOCA4LjQ2NDg0MzgsOC42ODc1IDEwLjQ0Mjk2NCw2Ljg0ODE5MjIgMTMuMTg1MDMyLDUuNjg0MDUyNiAxNi4wMDc4MTIsNS41NzIyNjU2IGwgMC4wOTE4LC0wLjAwMzkxIHogbSAwLDEuNTAzOTA2MyBMIDE2LDYuODkyNTc4MSBjIC0yLjQ2MTU3NywwLjA5NzQ4MyAtNC44NDU3MjgsMS4xMTE0MTc0IC02LjU3MjI2NTYsMi43MTY3OTY5IC0xLjcyNjUzOCwxLjYwNTM4IC0yLjc5NTU3NDEsMy44MDcyODIgLTIuNjA1NDY4OCw2LjI3NTM5MSAwLjM2OTc5ODYsNC44MDA1NCA0LjU2MzUzMzQsOC42NzQ2NzQgOS4xNzE4NzU0LDkuMTc3NzM0IGwgMC4wODk4NCwwLjAwOTggMC4wMjE0OCwtMC4xODE2NDEgLTAuMDkxOCwtMC4wMDk4IEMgMTEuNDk0MDM3LDI0LjM4NzQ4MSA3LjM2NjE1NTcsMjAuNTczNjM0IDcuMDAzOTA2MiwxNS44NzEwOTQgNi44MTg0NDgxLDEzLjQ2MzMyIDcuODU3NjQwNSwxMS4zMTY1MTMgOS41NTA3ODEzLDkuNzQyMTg3NSAxMS4yNDM5MjIsOC4xNjc4NjE4IDEzLjU5MTUyOSw3LjE3MTg2MDggMTYuMDA3ODEyLDcuMDc2MTcxOSBsIDAuMDkxOCwtMC4wMDM5MSB6IG0gMCwxLjUwMTk1MzEgTCAxNiw4LjM5NjQ4NDQgYyAtMi4wNTUwNzMsMC4wODEzODQgLTQuMDQ0Nzc1LDAuOTI1MjMzNCAtNS40ODYzMjgsMi4yNjU2MjQ2IC0xLjQ0MTU1MzUsMS4zNDAzOTMgLTIuMzM0NTg4MSwzLjE4MjM3OSAtMi4xNzU3ODE0LDUuMjQ0MTQxIDAuMzA4NzkyLDQuMDA4NTc5IDMuODA4NjA1NCw3LjI0MDEzNiA3LjY1NjI1MDQsNy42NjAxNTYgbCAwLjA4OTg0LDAuMDA5OCAwLjAwNzgsLTAuMDY4MzYgdiAtMC4wMDIgbCAwLjAwMiwtMC4wMDk4IGMgOS40OWUtNCwtMC4wMDM0IDAuMDAzNSwtMC4wMDYyIDAuMDAzOSwtMC4wMDk4IDYuNDJlLTQsLTAuMDA2NyA4LjAyZS00LC0wLjAxMzAxIDAsLTAuMDE5NTMgbCAwLjAwNzgsLTAuMDcyMjcgLTAuMDkxOCwtMC4wMDk4IEMgMTIuMjU0NjY3LDIyLjk3NDQyMiA4LjgyMDc3OTYsMTkuODAzMjMxIDguNTE5NTMxMywxNS44OTI1NzggOC4zNjUzNjgyLDEzLjg5MTEwNSA5LjIyODUzNzMsMTIuMTA2MjM3IDEwLjYzNjcxOSwxMC43OTY4NzUgMTIuMDQ0OSw5LjQ4NzUxMyAxMy45OTc5OTksOC42NTc3MTcgMTYuMDA3ODEyLDguNTc4MTI1IGwgMC4wOTE4LC0wLjAwMzkxIHogbSAwLDEuNTAxOTUzMiBMIDE2LDkuODk4NDM3NSBjIC0xLjY0ODU4OCwwLjA2NTI4NyAtMy4yNDU3NjEsMC43NDI5Mzg1IC00LjQwMjM0NCwxLjgxODM1OTUgLTEuMTU2NTgyLDEuMDc1NDIxIC0xLjg3MTY1MDYsMi41NTM1MzggLTEuNzQ0MTQwNCw0LjIwODk4NCAwLjI0Nzc4ODQsMy4yMTY2NjkgMy4wNTM2NDE0LDUuODA5NTAxIDYuMTQwNjI1NCw2LjE0NjQ4NSBsIDAuMDg5ODQsMC4wMDk4IDAuMDIxNDgsLTAuMTgxNjQgLTAuMDkxOCwtMC4wMDk4IGMgLTIuOTk4MzQ0LC0wLjMyNzMwOCAtNS43MzgyNzMsLTIuODU5Nzk4IC01Ljk3ODUxNiwtNS45Nzg1MTYgLTAuMTIyODY0NSwtMS41OTUxNDIgMC41NjQyOTgsLTMuMDE4MTE3IDEuNjg3NSwtNC4wNjI1IDEuMTIzMjAyLC0xLjA0NDM4MiAyLjY4MTgzOSwtMS43MDYwMzcgNC4yODUxNTYsLTEuNzY5NTMxIGwgMC4wOTE4LC0wLjAwMzkgeiBtIDAsMS41MDM5MDY3IC0wLjA5MTgsMC4wMDIgYyAtMS4yNDIwOTUsMC4wNDkxOSAtMi40NDQ4LDAuNTYwNjUyIC0zLjMxNjQwNiwxLjM3MTA5MyAtMC44NzE2MDYsMC44MTA0NDIgLTEuNDEyNjE5LDEuOTI0NzEzIC0xLjMxNjQwNiwzLjE3MzgyOSAwLjE4Njc4MywyLjQyNDczMiAyLjMwMDY0Myw0LjM3NjkxMyA0LjYyNjk1Myw0LjYzMDg1OSBsIDAuMDg5ODQsMC4wMDk4IDAuMDIxNDgsLTAuMTgxNjQgLTAuMDkxOCwtMC4wMDk4IGMgLTIuMjM3NjkyLC0wLjI0NDI3MiAtNC4yODU2MDQsLTIuMTM2MDgzIC00LjQ2NDg0NCwtNC40NjI4OSAtMC4wOTE1NywtMS4xODg4MjYgMC40MjE1MzIsLTIuMjQ3OTMzIDEuMjU5NzY2LC0zLjAyNzM0NCAwLjgzODIzNCwtMC43Nzk0MTEgMi4wMDIzODMsLTEuMjcyOTE2IDMuMTk5MjE4LC0xLjMyMDMxMyBsIDAuMDkxOCwtMC4wMDM5IHogbSAwLDEuNTAxOTUzIC0wLjA5MTgsMC4wMDM5IGMgLTAuODM1NjEzLDAuMDMzMDkgLTEuNjQzODMxLDAuMzc0NDUyIC0yLjIzMDQ2OSwwLjkxOTkyMiAtMC41ODY2MzcsMC41NDU0NyAtMC45NTE2MzYsMS4yOTk3NzggLTAuODg2NzE4LDIuMTQyNTc4IDAuMTI1NzgsMS42MzI4MjEgMS41NDU2NzMsMi45NDIzNyAzLjExMTMyOCwzLjExMzI4MSAtMC4wMDI2LC0yLjhlLTQgLTAuMDA1MywyLjg2ZS00IC0wLjAwNzgsMCAwLjAwMyw2LjAzZS00IDAuMDA2NiwwLjAwMTcgMC4wMDk4LDAuMDAyIDAuMDAzMSwzLjA4ZS00IDAuMDA2NywxLjJlLTUgMC4wMDk4LDAgbCAwLjA3ODEzLDAuMDA5OCAwLjAyMTQ4LC0wLjE4MTY0MSAtMC4wOTE4LC0wLjAwOTggYyAtMS40NzcwMTUsLTAuMTYxMjM1IC0yLjgzMDk4NCwtMS40MTIzOTYgLTIuOTQ5MjE5LC0yLjk0NzI2NiAtMC4wNjAyNywtMC43ODI0OTYgMC4yNzY4MjIsLTEuNDc5NzA5IDAuODMwMDc4LC0xLjk5NDE0MSAwLjU1MzI1NywtMC41MTQ0MzEgMS4zMjI5MzksLTAuODQxNzQ4IDIuMTEzMjgxLC0wLjg3MzA0NiBsIDAuMDkxOCwtMC4wMDM5IHogbSAwLDEuNTAxOTUzIEwgMTYsMTQuNDA2MjUgYyAtMC40MjkxMTcsMC4wMTY5OSAtMC44NDI4NzMsMC4xOTIxNjYgLTEuMTQ0NTMxLDAuNDcyNjU2IC0wLjMwMTY1OSwwLjI4MDQ5MSAtMC40OTA2NDgsMC42NzA5NTUgLTAuNDU3MDMxLDEuMTA3NDIyIDAuMDY0NzcsMC44NDA4ODYgMC43OTA3MjksMS41MTE3MzIgMS41OTU3MDMsMS41OTk2MDkgbCAwLjA4OTg0LDAuMDA5OCAwLjAyMTQ4LC0wLjE4MTY0MSAtMC4wOTE4LC0wLjAwOTggYyAtMC43MTYzNTcsLTAuMDc4MiAtMS4zNzYzNjIsLTAuNjg4NjgxIC0xLjQzMzU5NCwtMS40MzE2NDEgLTAuMDI4OTcsLTAuMzc2MTc5IDAuMTMyMTA1LC0wLjcxMTQ3NyAwLjQwMDM5MSwtMC45NjA5MzcgMC4yNjgyODYsLTAuMjQ5NDYxIDAuNjQzNDg1LC0wLjQwODYyNyAxLjAyNzM0MywtMC40MjM4MjggbCAwLjA5MTgsLTAuMDAzOSB6IiAgICBzdHlsZT0iYmFzZWxpbmUtc2hpZnQ6YmFzZWxpbmU7Y2xpcC1ydWxlOm5vbnplcm87ZGlzcGxheTppbmxpbmU7b3ZlcmZsb3c6dmlzaWJsZTt2ZWN0b3ItZWZmZWN0Om5vbmU7ZmlsbDojMjIyMjIyO2ZpbGwtcnVsZTpub256ZXJvO3N0cm9rZS1saW5lY2FwOmJ1dHQ7c3Ryb2tlLWxpbmVqb2luOm1pdGVyO2VuYWJsZS1iYWNrZ3JvdW5kOmFjY3VtdWxhdGU7c3RvcC1jb2xvcjojMDAwMDAwO3N0b3Atb3BhY2l0eToxIiAgICBpZD0icGF0aDI3IiAvPjwvc3ZnPg==');}.icon-jakevan{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHhtbDpzcGFjZT0icHJlc2VydmUiIHN0eWxlPSJjbGlwLXJ1bGU6ZXZlbm9kZDtmaWxsLXJ1bGU6ZXZlbm9kZDtzdHJva2UtbGluZWpvaW46cm91bmQ7c3Ryb2tlLW1pdGVybGltaXQ6MiIgdmlld0JveD0iMCAwIDMyIDMyIj48cGF0aCBkPSJNMTcuODggMTQuNjhIMTIuOWwtLjQzLTEuNjNIOS41OGwtLjQ1IDEuNjNINi41bDIuODktOC43NGgzLjJsMi44OSA4LjY0di04LjZoMi40djMuNzhjLjEtLjIuMjItLjM4LjM1LS41Ny4xMy0uMi4yNi0uMzcuMzktLjU0bDEuODYtMi42N2g3Ljh2MS44OUgyNS40djEuMzdoMi42NXYxLjg4SDI1LjR2MS42NWgyLjg2djEuOTFoLTcuOTNsLTEuNzUtMy4zMi0uNy40MXptNS4xMy04LjU5LTIuNyAzLjc5IDIuNyA0Ljc0em0tMTEuMDUgNS4wMy0uMzgtMS40M2ExMzYuODYgMTM2Ljg2IDAgMCAwLS40LTEuNTVMMTEgNy4zOGExNy43NiAxNy43NiAwIDAgMS0uMzYgMS42bC0uMTguNzEtLjM5IDEuNDN6bS04LjU4IDYuM2E1Ljc0IDUuNzQgMCAwIDEtMS4yNC0uMTN2LTEuODNsLjQxLjA4Yy4xNS4wMy4zLjA1LjQ3LjA1LjMgMCAuNTEtLjA2LjY3LS4xN2EuOTIuOTIgMCAwIDAgLjM0LS41MmMuMDYtLjIzLjEtLjUyLjEtLjg2VjUuOThoMi40djcuODVjMCAuODgtLjEzIDEuNTctLjQgMi4xLS4yNi41Mi0uNjMuOS0xLjEgMS4xNC0uNDguMjMtMS4wMy4zNS0xLjY1LjM1WiIgc3R5bGU9ImZpbGw6Y3VycmVudENvbG9yO3N0cm9rZS13aWR0aDouMDE4NDM5MiIgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoMS40IC42Nikgc2NhbGUoLjk2MDUwMTM0KSIvPjxwYXRoIGQ9Ik0yMi44MiAyMi4yN2gtNC4wNmwtLjM3LTEuNEgxNS45bC0uMzkgMS40aC0yLjI2bDIuNDktNy41M2gyLjc1bDIuNDkgNy40NHYtNy40MWgyLjdsMi43NyA1LjIxaC4wM2E0MS4xIDQxLjEgMCAwIDEtLjA3LTEuODJ2LTMuMzloMS44M3Y3LjVoLTIuN2wtMi43OS01LjI4aC0uMDRhMTIuODMgMTIuODMgMCAwIDEgLjA4IDEuMjZsLjAyLjY0em0tNC44Ni0zLjA3LS4zMy0xLjIzYTg5LjA3IDg5LjA3IDAgMCAwLS4zNS0xLjM0bC0uMTQtLjY1YTE1LjA0IDE1LjA0IDAgMCAxLS4zMSAxLjM3bC0uMTYuNjItLjMzIDEuMjN6bS0zLjg1LTQuNDMtMi41IDcuNUg5LjJsLTIuNS03LjVoMi4zMmwxLjA0IDMuOGExNS4wMyAxNS4wMyAwIDAgMSAuMzYgMS43NiA3LjYxIDcuNjEgMCAwIDEgLjItMS4ybC4xNC0uNTQgMS4wNi0zLjgyeiIgc3R5bGU9ImZpbGw6Y3VycmVudENvbG9yO3N0cm9rZS13aWR0aDouMDE1OTg4NCIgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoMS40IC42Nikgc2NhbGUoLjk2MDUwMTM0KSIvPjxwYXRoIGQ9Ik0xMS45IDI0LjIxYzAgLjQtLjA3LjcyLS4yLjk5LS4xNS4yNi0uMzYuNDYtLjYzLjYtLjI4LjEzLS42Mi4yLTEuMDMuMkg5LjJ2LTMuNWguOTdjLjM4IDAgLjcuMDYuOTYuMTkuMjUuMTMuNDUuMzIuNTguNTguMTQuMjUuMi41Ny4yLjk0em0tLjI2LjAxYzAtLjMzLS4wNS0uNjEtLjE2LS44M2ExLjEgMS4xIDAgMCAwLS41MS0uNTEgMS45NSAxLjk1IDAgMCAwLS44Ny0uMTdoLS42NnYzLjA3aC42Yy41MyAwIC45My0uMTMgMS4yLS4zOS4yNy0uMjYuNC0uNjUuNC0xLjE3ek0xNC4yNyAyNmgtMS45NXYtMy41aDEuOTV2LjIyaC0xLjd2MS4zMmgxLjZ2LjIzaC0xLjZ2MS41aDEuN3ptMS4yOC0zLjVjLjI4IDAgLjUyLjAyLjcuMDhhLjguOCAwIDAgMSAuNDQuM2MuMS4xNC4xNC4zMy4xNC41N2EuOS45IDAgMCAxLS4xLjQ1Ljg3Ljg3IDAgMCAxLS4yNy4zMmMtLjEyLjA4LS4yNS4xNC0uNC4xOGwuOTggMS42aC0uM2wtLjkyLTEuNTNoLS44OVYyNmgtLjI1di0zLjV6bS0uMDMuMjFoLS41OXYxLjU1aC43MWMuMyAwIC41Mi0uMDcuNjktLjIxLjE2LS4xNC4yNC0uMzQuMjQtLjYgMC0uMjgtLjA5LS40Ny0uMjYtLjU4LS4xNy0uMS0uNDMtLjE2LS43OS0uMTZ6bTUuNTctLjIyTDIwLjEyIDI2aC0uMjVsLS43Ni0yLjY1LS4wNS0uMTYtLjA0LS4xNGExOC44IDE4LjggMCAwIDEtLjA2LS4yNCAyMC42IDIwLjYgMCAwIDEtLjExLjQ4TDE4LjA5IDI2aC0uMjVsLS45Ni0zLjVoLjI2bC42NyAyLjQ3YTI3LjM2IDI3LjM2IDAgMCAxIC4wOS4zNWwuMDQuMTcuMDMuMTUuMDMtLjE2YTQuODMgNC44MyAwIDAgMSAuMTQtLjUzbC43LTIuNDZoLjI1bC43MyAyLjQ4YTExLjk4IDExLjk4IDAgMCAxIC4xMy41M2wuMDQuMTVhMTEuMDIgMTEuMDIgMCAwIDEgLjE1LS42OGwuNjktMi40OHpNMjMuMjYgMjZoLTEuOTV2LTMuNWgxLjk1di4yMmgtMS43djEuMzJoMS42di4yM2gtMS42djEuNWgxLjd6bTEuMjgtMy41Yy4yOCAwIC41Mi4wMi43MS4wOGEuOC44IDAgMCAxIC40My4zYy4xLjE0LjE0LjMzLjE0LjU3YS45LjkgMCAwIDEtLjEuNDUuODcuODcgMCAwIDEtLjI3LjMyYy0uMTEuMDgtLjI1LjE0LS40LjE4bC45OCAxLjZoLS4zbC0uOTItMS41M2gtLjg4VjI2aC0uMjZ2LTMuNXptLS4wMi4yMWgtLjZ2MS41NWguNzJjLjI5IDAgLjUxLS4wNy42OC0uMjEuMTYtLjE0LjI0LS4zNC4yNC0uNiAwLS4yOC0uMDgtLjQ3LS4yNi0uNTgtLjE3LS4xLS40My0uMTYtLjc4LS4xNnpNMjYuNSAyNmgtLjI1di0zLjVoMS45NXYuMjJoLTEuN3YxLjQ5aDEuNnYuMjJoLTEuNnoiIHN0eWxlPSJmaWxsOmN1cnJlbnRDb2xvcjtzdHJva2Utd2lkdGg6LjAxMDEwNjgiIHRyYW5zZm9ybT0idHJhbnNsYXRlKDEuNCAuNjYpIHNjYWxlKC45NjA1MDEzNCkiLz48L3N2Zz4=');}.icon-user-square{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0yMDgsMzRINDhBMTQsMTQsMCwwLDAsMzQsNDhWMjA4YTE0LDE0LDAsMCwwLDE0LDE0SDIwOGExNCwxNCwwLDAsMCwxNC0xNFY0OEExNCwxNCwwLDAsMCwyMDgsMzRaTTk0LDEyMGEzNCwzNCwwLDEsMSwzNCwzNEEzNCwzNCwwLDAsMSw5NCwxMjBaTTY1Ljc3LDIxMGE2Ni40Myw2Ni40MywwLDAsMSwyMC43Ny0yOS4zNiw2Niw2NiwwLDAsMSw4Mi45MiwwQTY2LjQzLDY2LjQzLDAsMCwxLDE5MC4yMywyMTBaTTIxMCwyMDhhMiwyLDAsMCwxLTIsMmgtNS4xN2E3Ny44NSw3Ny44NSwwLDAsMC00OS4zOC01MS43MSw0Niw0NiwwLDEsMC01MC45LDBBNzcuODUsNzcuODUsMCwwLDAsNTMuMTcsMjEwSDQ4YTIsMiwwLDAsMS0yLTJWNDhhMiwyLDAsMCwxLDItMkgyMDhhMiwyLDAsMCwxLDIsMloiLz48L3N2Zz4=');}.icon-chat-teardrop{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0xMzIsMjZhOTguMTEsOTguMTEsMCwwLDAtOTgsOTh2ODRhMTQsMTQsMCwwLDAsMTQsMTRoODRhOTgsOTgsMCwwLDAsMC0xOTZabTAsMTg0SDQ4YTIsMiwwLDAsMS0yLTJWMTI0YTg2LDg2LDAsMSwxLDg2LDg2WiIvPjwvc3ZnPg==');}.icon-caret-left{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0xNjQuMjQsMjAzLjc2YTYsNiwwLDEsMS04LjQ4LDguNDhsLTgwLTgwYTYsNiwwLDAsMSwwLTguNDhsODAtODBhNiw2LDAsMCwxLDguNDgsOC40OEw4OC40OSwxMjhaIi8+PC9zdmc+');}.icon-chat{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0yMTYsNTBINDBBMTQsMTQsMCwwLDAsMjYsNjRWMjI0YTEzLjg4LDEzLjg4LDAsMCwwLDguMDksMTIuNjlBMTQuMTEsMTQuMTEsMCwwLDAsNDAsMjM4YTEzLjg3LDEzLjg3LDAsMCwwLDktMy4zMWwuMDYtLjA1TDgyLjIzLDIwNkgyMTZhMTQsMTQsMCwwLDAsMTQtMTRWNjRBMTQsMTQsMCwwLDAsMjE2LDUwWm0yLDE0MmEyLDIsMCwwLDEtMiwySDgwYTYsNiwwLDAsMC0zLjkyLDEuNDZMNDEuMjYsMjI1LjUzQTIsMiwwLDAsMSwzOCwyMjRWNjRhMiwyLDAsMCwxLDItMkgyMTZhMiwyLDAsMCwxLDIsMloiLz48L3N2Zz4=');}.icon-envelope{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0yMjQsNTBIMzJhNiw2LDAsMCwwLTYsNlYxOTJhMTQsMTQsMCwwLDAsMTQsMTRIMjE2YTE0LDE0LDAsMCwwLDE0LTE0VjU2QTYsNiwwLDAsMCwyMjQsNTBabS05Niw4NS44Nkw0Ny40Miw2MkgyMDguNThaTTEwMS42NywxMjgsMzgsMTg2LjM2VjY5LjY0Wm04Ljg4LDguMTRMMTI0LDE0OC40MmE2LDYsMCwwLDAsOC4xLDBsMTMuNC0xMi4yOEwyMDguNTgsMTk0SDQ3LjQzWk0xNTQuMzMsMTI4LDIxOCw2OS42NFYxODYuMzZaIi8+PC9zdmc+');}.icon-sun-dim{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0xMjIsNDBWMzJhNiw2LDAsMCwxLDEyLDB2OGE2LDYsMCwwLDEtMTIsMFptNjgsODhhNjIsNjIsMCwxLDEtNjItNjJBNjIuMDcsNjIuMDcsMCwwLDEsMTkwLDEyOFptLTEyLDBhNTAsNTAsMCwxLDAtNTAsNTBBNTAuMDYsNTAuMDYsMCwwLDAsMTc4LDEyOFpNNTkuNzYsNjguMjRhNiw2LDAsMSwwLDguNDgtOC40OGwtOC04YTYsNiwwLDAsMC04LjQ4LDguNDhabTAsMTE5LjUyLTgsOGE2LDYsMCwxLDAsOC40OCw4LjQ4bDgtOGE2LDYsMCwxLDAtOC40OC04LjQ4Wm0xMzYtMTM2LTgsOGE2LDYsMCwxLDAsOC40OCw4LjQ4bDgtOGE2LDYsMCwwLDAtOC40OC04LjQ4Wm0uNDgsMTM2YTYsNiwwLDAsMC04LjQ4LDguNDhsOCw4YTYsNiwwLDAsMCw4LjQ4LTguNDhaTTQwLDEyMkgzMmE2LDYsMCwwLDAsMCwxMmg4YTYsNiwwLDAsMCwwLTEyWm04OCw4OGE2LDYsMCwwLDAtNiw2djhhNiw2LDAsMCwwLDEyLDB2LThBNiw2LDAsMCwwLDEyOCwyMTBabTk2LTg4aC04YTYsNiwwLDAsMCwwLDEyaDhhNiw2LDAsMCwwLDAtMTJaIi8+PC9zdmc+');}.icon-moon{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0yMzIuMTMsMTQzLjY0YTYsNiwwLDAsMC02LTEuNDlBOTAuMDcsOTAuMDcsMCwwLDEsMTEzLjg2LDI5Ljg1YTYsNiwwLDAsMC03LjQ5LTcuNDhBMTAyLjg4LDEwMi44OCwwLDAsMCw1NC40OCw1OC42OCwxMDIsMTAyLDAsMCwwLDE5Ny4zMiwyMDEuNTJhMTAyLjg4LDEwMi44OCwwLDAsMCwzNi4zMS01MS44OUE2LDYsMCwwLDAsMjMyLjEzLDE0My42NFptLTQyLDQ4LjI5YTkwLDkwLDAsMCwxLTEyNi0xMjZBOTAuOSw5MC45LDAsMCwxLDk5LjY1LDM3LjY2LDEwMi4wNiwxMDIuMDYsMCwwLDAsMjE4LjM0LDE1Ni4zNSw5MC45LDkwLjksMCwwLDEsMTkwLjEsMTkxLjkzWiIvPjwvc3ZnPg==');}.icon-logo-jakevan{--icon:url('data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiIHN0YW5kYWxvbmU9Im5vIj8+PHN2ZyAgICB3aWR0aD0iMzIiICAgIGhlaWdodD0iMzIiICAgIHZpZXdCb3g9IjAgMCAzMiAzMiIgICAgdmVyc2lvbj0iMS4xIiAgICB4bWw6c3BhY2U9InByZXNlcnZlIiAgICBzdHlsZT0iY2xpcC1ydWxlOmV2ZW5vZGQ7ZmlsbC1ydWxlOmV2ZW5vZGQ7c3Ryb2tlLWxpbmVjYXA6c3F1YXJlO3N0cm9rZS1saW5lam9pbjpyb3VuZDtzdHJva2UtbWl0ZXJsaW1pdDoxLjUiICAgIGlkPSJzdmcxNCIgICAgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIiAgICB4bWxuczpzdmc9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48ZGVmcyAgICBpZD0iZGVmczE0IiAvPjxwYXRoICAgIGQ9Ik0gMTYuNTgwMDc4LDIuMTMyODEyNSBDIDguODY0ODQ1LDIuMTMyODEyNSAxLjQ0MDYwMjEsOC4xNDY2MjE2IDIuMDMzMjAzMSwxNS44Mzk4NDQgMi41OTQ0NTg4LDIzLjEyNjY2NiA4Ljk3MTIwMTMsMjkuMDI1MTU1IDE1Ljk3MDcwMywyOS43ODcxMDkgMjQuMjIxMjI0LDMwLjY4NTI0IDMwLjQ0MDkxMSwyMy44MzQyNzcgMjkuOTc0NjA5LDE1Ljc5ODgyOCAyOS41Mjc4MTMsOC4wOTk4NjU2IDI0LjI5MTU2LDIuMTMyODEyNSAxNi41ODAwNzgsMi4xMzI4MTI1IFogbSAwLDAuNjA3NDIxOSBoIDAuMDMxMjUgViAyOS4yMzI0MjIgYyAtMC4xOTAzMjMsLTAuMDEyMTggLTAuMzgxNTIxLC0wLjAyNzgzIC0wLjU3NDIxOSwtMC4wNDg4MyBDIDkuMzEzMDQ1NywyOC40NTE2MTcgMy4xNzc4Nzc1LDIyLjc5MzA0NCAyLjYzODY3MTksMTUuNzkyOTY5IDIuMDcyNDU2LDguNDQyMDE1IDkuMjA4MTAwOSwyLjc0MDIzNDQgMTYuNTgwMDc4LDIuNzQwMjM0NCBaIG0gLTAuNDkyMTg3LDEuMTQ0NTMxMiAtMC4wOTE4LDAuMDAzOTEgQyAxMi43MjE1Niw0LjAxODM0MTMgOS41NDY0NzQzLDUuMzY0Njg3NyA3LjI1LDcuNSA0Ljk1MzUyNTcsOS42MzUzMTIzIDMuNTM2MzY0NywxMi41NjQ5OCAzLjc4OTA2MjUsMTUuODQ1NzAzIDQuMjgwODY2OCwyMi4yMzAwMzIgOS44NjA1ODc5LDI3LjM4MzYwOCAxNS45OTAyMzQsMjguMDUyNzM0IGwgMC4wODk4NCwwLjAwOTggMC4wMTk1MywtMC4xODE2NDEgLTAuMDg5ODQsLTAuMDA5OCBDIDkuOTY4NzM4OSwyNy4yMTE2NDMgNC40NTQ5NjM4LDIyLjExNjQ4MSAzLjk3MDcwMzEsMTUuODMwMDc4IDMuNzIyNjQ5MSwxMi42MDk2NDQgNS4xMTE4OTc5LDkuNzM5MDQ3NyA3LjM3NSw3LjYzNDc2NTYgOS42MzgxMDIxLDUuNTMwNDgzNiAxMi43NzQ2MjUsNC4xOTgxOTcgMTYuMDAzOTA2LDQuMDcwMzEyNSBsIDAuMDg5ODQsLTAuMDAzOTEgeiBtIDAsMS41MDE5NTMxIC0wLjA5MTgsMC4wMDM5MSBDIDEzLjEyODA1NCw1LjUwNDE5NzEgMTAuMzQ3NDM0LDYuNjg0MzQ1NCA4LjMzNTkzNzUsOC41NTQ2ODc1IDYuMzI0NDQxMSwxMC40MjUwMyA1LjA4MTMzNDEsMTIuOTkwODI3IDUuMzAyNzM0NCwxNS44NjUyMzQgNS43MzM1MzM2LDIxLjQ1NzY1NiAxMC42MjEyNjUsMjUuOTcwNTQ4IDE1Ljk5MDIzNCwyNi41NTY2NDEgbCAwLjA4OTg0LDAuMDA5OCAwLjAxOTUzLC0wLjE3OTY4NyAtMC4wODk4NCwtMC4wMDk4IEMgMTAuNzI5NDE5LDI1LjgwMDUzMiA1LjkwNzYzMDcsMjEuMzQ2MDU2IDUuNDg0Mzc1LDE1Ljg1MTU2MyA1LjI2NzYxODQsMTMuMDM3NDQyIDYuNDgyODEzMywxMC41MjY4MTIgOC40NjA5Mzc1LDguNjg3NSAxMC40MzkwNjIsNi44NDgxODgzIDEzLjE4MTEyLDUuNjg0MDUyOCAxNi4wMDM5MDYsNS41NzIyNjU2IGwgMC4wODk4NCwtMC4wMDM5MSB6IG0gMCwxLjUwMzkwNjMgLTAuMDkxOCwwLjAwMTk1IEMgMTMuNTM0NTQsNi45OTAwNjYzIDExLjE0ODQsOC4wMDQwMDcyIDkuNDIxODc1LDkuNjA5Mzc1IDcuNjk1MzQ5NywxMS4yMTQ3NDMgNi42MjgyNTU4LDEzLjQxNjY4IDYuODE4MzU5NCwxNS44ODQ3NjYgNy4xODgxNTUxLDIwLjY4NTI2OSAxMS4zODE5NiwyNC41NTk0NDQgMTUuOTkwMjM0LDI1LjA2MjUgbCAwLjA4OTg0LDAuMDA5OCAwLjAxOTUzLC0wLjE4MTY0MSAtMC4wODk4NCwtMC4wMDk4IEMgMTEuNDkwMTE0LDI0LjM4NzQ3NyA3LjM2MjI1MjIsMjAuNTczNjcxIDcsMTUuODcxMDk0IDYuODE0NTQwMSwxMy40NjMyOTcgNy44NTM3MjE5LDExLjMxNjUyNSA5LjU0Njg3NSw5Ljc0MjE4NzUgYyAxLjY5MzE1MywtMS41NzQzMzc0IDQuMDQwNzMsLTIuNTcwMzI2IDYuNDU3MDMxLC0yLjY2NjAxNTYgbCAwLjA4OTg0LC0wLjAwMzkxIHogbSAwLDEuNTAxOTUzMSAtMC4wOTE4LDAuMDAzOTEgYyAtMi4wNTUwNzMsMC4wODEzODQgLTQuMDQ2NzI4LDAuOTI1MjMzMSAtNS40ODgyODEsMi4yNjU2MjQ5IC0xLjQ0MTU1NDcsMS4zNDAzOTMgLTIuMzMyNjM1NCwzLjE4MjM3OSAtMi4xNzM4Mjg2LDUuMjQ0MTQxIDAuMzA2Mzk5NCwzLjk3NzUyIDMuNzU3OTY0Niw3LjE3NDg3NyA3LjU3MDMxMjYsNy42MzQ3NjYgbCAtMC4wMDIsMC4wMTc1OCAwLjA4Nzg5LDAuMDA3OCBoIDAuMDAyIGwgMC4wMDk4LDAuMDAyIDAuMDgwMDgsMC4wMDk4IDAuMDIxNDgsLTAuMTgxNjQxIGggLTAuMDAzOSB2IC0wLjAwMiBsIC0wLjA4OTg0LC0wLjAwOTggaCAtMC4wMDIgQyAxMi4yNDk0NzYsMjIuOTczNTY1IDguODE2ODE1NSwxOS44MDI1NzYgOC41MTU2MTcsMTUuODkyNTcxIDguMzYxNDU0LDEzLjg5MTA5OCA5LjIyNDYyMjUsMTIuMTA2MjMgMTAuNjMyODA1LDEwLjc5Njg2OCAxMi4wNDA5ODUsOS40ODc1MDU0IDEzLjk5NDA4NCw4LjY1NzcwOTcgMTYuMDAzODk4LDguNTc4MTE3NyBsIDAuMDg5ODQsLTAuMDAzOTEgeiBtIDAsMS41MDE5NTMyIC0wLjA5MTgsMC4wMDM5MSBjIC0xLjY0ODU4MiwwLjA2NTI4NyAtMy4yNDU3NjYsMC43NDI5NDI3IC00LjQwMjM0NCwxLjgxODM1OTcgLTEuMTU2NTc4LDEuMDc1NDE3IC0xLjg3MTY1MDMsMi41NTM1NDUgLTEuNzQ0MTQwNiw0LjIwODk4NCAwLjI0NTM5NDYsMy4xODU1OSAzLjAwMzEwMzYsNS43NDQzODEgNi4wNTQ2ODc2LDYuMTIxMDk0IGwgLTAuMDAyLDAuMDE3NTggMC4wODc4OSwwLjAwNzggaCAwLjAwMiBsIDAuMDg3ODksMC4wMDk4IGggMC4wMDIgbCAwLjAyMTQ4LC0wLjE4MTY0IGggLTAuMDAzOSBsIC0wLjA4Nzg5LC0wLjAwOTggaCAtMC4wMDIgLTAuMDAyIGMgLTIuOTk3NjIzLC0wLjMyODE1NiAtNS43MzYzNjgsLTIuODYwNDMzIC01Ljk3NjU2MiwtNS45Nzg1MTYgLTAuMTIyODY1OSwtMS41OTUxNSAwLjU2NDI5NCwtMy4wMTgxMTMgMS42ODc1LC00LjA2MjUgMS4xMjMyMDYsLTEuMDQ0Mzg2IDIuNjgxODMzLC0xLjcwNjAzNyA0LjI4NTE1NiwtMS43Njk1MzEgbCAwLjA4OTg0LC0wLjAwMzkgeiBtIDQuMTIxMDkzLDEuMzIwMzEyNyBoIDEuNDY2Nzk3IGwgMS4zMjgxMjUsMy45NjI4OSB2IC0zLjk0NzI2NSBoIDEuMTAzNTE2IHYgMS43MzYzMjggYyAwLjA0NjM5LC0wLjA4NzQyIDAuMDk5ODksLTAuMTc2MjEyIDAuMTYwMTU2LC0wLjI2MzY3MiAwLjA2MDMxLC0wLjA4NzQ2IDAuMTIwMjk0LC0wLjE2OTY4NyAwLjE3NzczNCwtMC4yNDgwNDcgbCAwLjg1MzUxNiwtMS4yMjQ2MDkgaCAzLjU3ODEyNSB2IDAuODY3MTg3IGggLTEuMzE2NDA2IHYgMC42MjY5NTMgaCAxLjIxODc1IHYgMC44NjcxODggaCAtMS4yMTg3NSB2IDAuNzUzOTA2IGggMS4zMTY0MDYgdiAwLjg4MDg2IGggLTMuNjM4NjcyIGwgLTAuODA0Njg3LC0xLjUyNzM0NCAtMC4zMjYxNzIsMC4xOTE0MDYgdiAxLjMzNTkzOCBoIC0yLjI4OTA2MyBsIC0wLjIwMTE3MiwtMC43NDgwNDcgaCAtMS4zMjAzMTIgbCAtMC4yMDcwMzEsMC43NDgwNDcgaCAtMS4yMDcwMzIgeiBtIC0yLjQxNDA2MiwwLjAxNTYzIGggMS4xMDM1MTUgdiAzLjYwNTQ2OSBjIDEwZS03LDAuNDAwOTA1IC0wLjA2MTE3LDAuNzIyMzU1IC0wLjE4MzU5MywwLjk2Mjg5IC0wLjEyMjQyMiwwLjI0MDUzNSAtMC4yOTA4MTMsMC40MTQwMTEgLTAuNTA3ODEzLDAuNTIxNDg0IC0wLjIxNjk5OSwwLjEwNzUxMyAtMC40NjgyNTUsMC4xNjAxNTcgLTAuNzUzOTA2LDAuMTYwMTU3IC0wLjEyNDI1NiwwIC0wLjIzNDQ2NSwtMC4wMDU3IC0wLjMyODEyNSwtMC4wMTc1OCAtMC4wOTM2NiwtMC4wMTE3NyAtMC4xNzM1NzgsLTAuMDI0NDYgLTAuMjQyMTg4LC0wLjAzOTA2IFYgMTUuNTgzOTkgYyAwLjA1OTM2LDAuMDEwODYgMC4xMjI2NzQsMC4wMjM0MSAwLjE4OTQ1NCwwLjAzNzExIDAuMDY2NzcsMC4wMTM3IDAuMTM4OTM2LDAuMDIxNDggMC4yMTY3OTYsMC4wMjE0OCAwLjEzMTY3NiwwIDAuMjMzMzYxLC0wLjAyNzIzIDAuMzA2NjQxLC0wLjA4MDA4IDAuMDczMjMsLTAuMDUyODYgMC4xMjQ2MTcsLTAuMTMyNjExIDAuMTU0Mjk3LC0wLjIzODI4MSAwLjAyOTY4LC0wLjEwNTY3IDAuMDQ0OTIsLTAuMjM3OTE5IDAuMDQ0OTIsLTAuMzk2NDg1IHogbSA4LjY2Nzk2OSwwLjA1NDY5IC0xLjI0NDE0MSwxLjczNjMyOCAxLjI0NDE0MSwyLjE3NTc4MiB6IG0gLTEwLjM3NSwwLjExMzI4MiAtMC4wOTE4LDAuMDAyIGMgLTEuMjQyMDk1LDAuMDQ5MTkgLTIuNDQ0OCwwLjU2MDY1MiAtMy4zMTY0MDYsMS4zNzEwOTMgLTAuODcxNjA4LDAuODEwNDQyIC0xLjQxMjYyLDEuOTI0NzEyIC0xLjMxNjQwNywzLjE3MzgyOSAwLjE4NDM5LDIuMzkzNjU0IDIuMjUwMTk1LDQuMzEyMDIyIDQuNTQxMDE2LDQuNjA1NDY4IGwgLTAuMDAyLDAuMDE3NTggMC4wODc4OSwwLjAwNzggaCAwLjAwMiBsIDAuMDg3ODksMC4wMDk4IGggMC4wMDIgbCAwLjAxOTUzLC0wLjE3OTY4NyBoIC0wLjAwMiB2IC0wLjAwMiBsIC0wLjA3ODEzLC0wLjAwNzggLTAuMDA5OCwtMC4wMDIgaCAtMC4wMDIgLTAuMDAyIGMgLTIuMjM2OTYxLC0wLjI0NTEyMSAtNC4yODM3LC0yLjEzNjczMSAtNC40NjI4OSwtNC40NjI4OSAtMC4wOTE1NywtMS4xODg4MjYgMC40MjE1MzEsLTIuMjQ3OTMzIDEuMjU5NzY2LC0zLjAyNzM0NCAwLjgzODIzNCwtMC43Nzk0MTEgMi4wMDIzODIsLTEuMjcyOTE2IDMuMTk5MjE4LC0xLjMyMDMxMyBsIDAuMDg5ODQsLTAuMDAzOSB6IG0gNC44NjEzMjgsMC40NzQ2MDkgYyAtMC4wMTY3MSwwLjA5MTExIC0wLjAzOTcyLDAuMjAzOTI4IC0wLjA3MDMxLDAuMzM3ODkxIC0wLjAzMDYsMC4xMzM5MjIgLTAuMDYxMjgsMC4yNjUyNjkgLTAuMDkzNzUsMC4zOTY0ODQgLTAuMDMyNDIsMC4xMzExNzUgLTAuMDYxODUsMC4yNDA2NjUgLTAuMDg1OTQsMC4zMjgxMjUgbCAtMC4xNzU3ODIsMC42NTYyNSBoIDAuODY1MjM1IGwgLTAuMTczODI4LC0wLjY1NjI1IGMgLTAuMDE4NTEsLTAuMDcxMDYgLTAuMDQ2ODEsLTAuMTcyNTI5IC0wLjA4MjAzLC0wLjMwNDY4OCAtMC4wMzUyNiwtMC4xMzIxMTggLTAuMDY5MjEsLTAuMjY4OTM1IC0wLjEwMzUxNSwtMC40MTAxNTYgLTAuMDM0MywtMC4xNDEyMjEgLTAuMDYxNTMsLTAuMjU2NTQ2IC0wLjA4MDA4LC0wLjM0NzY1NiB6IG0gLTQuODYxMzI4LDEuMDI3MzQ0IC0wLjA5MTgsMC4wMDM5IGMgLTAuODM1NjA4LDAuMDMzMDkgLTEuNjQzODM2LDAuMzc0NDU2IC0yLjIzMDQ2OSwwLjkxOTkyMiAtMC41ODY2MzMsMC41NDU0NjYgLTAuOTUxNjM1LDEuMjk5Nzg2IC0wLjg4NjcxOSwyLjE0MjU3OCAwLjEyMzIwNSwxLjU5OTM3OCAxLjQ5ODEyOCwyLjg1OTQ2NyAzLjAyNTM5MSwzLjA3MjI2NSBsIC0wLjAwMzksMC4wMzMyIDAuMDg5ODQsMC4wMDk4IDAuMDgyMDMsMC4wMDc4IDAuMDA3OCwwLjAwMiBoIDAuMDAyIGwgMC4wMTk1MywtMC4xODE2NDEgaCAtMC4wMDIgbCAtMC4wODc4OSwtMC4wMDk4IGggLTAuMDAyIC0wLjAwMiBjIC0xLjQ3NjMzNywtMC4xNjIwNDEgLTIuODI5MDc2LC0xLjQxMjk5NiAtMi45NDcyNjUsLTIuOTQ3MjY2IC0wLjA2MDI3LC0wLjc4MjUwMyAwLjI3NjgxNywtMS40Nzk3MDUgMC44MzAwNzgsLTEuOTk0MTQxIDAuNTUzMjYxLC0wLjUxNDQzNSAxLjMyMjkzMywtMC44NDE3NDggMi4xMTMyODEsLTAuODczMDQ2IGwgMC4wODk4NCwtMC4wMDM5IHogbSAwLDEuNTAxOTUzIC0wLjA5MTgsMC4wMDM5IGMgLTAuNDI5MTE4LDAuMDE2OTkgLTAuODQyODczLDAuMTkyMTY2IC0xLjE0NDUzMSwwLjQ3MjY1NiAtMC4zMDE2NiwwLjI4MDQ5MSAtMC40OTA2NDgsMC42NzA5NTUgLTAuNDU3MDMyLDEuMTA3NDIyIDAuMDYyMTksMC44MDczNiAwLjc0MzgwMSwxLjQzMDI1NyAxLjUwOTc2NiwxLjU1ODU5NCBsIC0wLjAwMzksMC4wMzMyIDAuMDg5ODQsMC4wMDc4IGggMC4wMDIgbCAwLjA4Nzg5LDAuMDA5OCAwLjAyMTQ4LC0wLjE3OTY4NyBoIC0wLjAwMiB2IC0wLjAwMiBsIC0wLjA3ODEzLC0wLjAwNzggLTAuMDA5OCwtMC4wMDIgaCAtMC4wMDIgLTAuMDAyIGMgLTAuNzE1Njc1LC0wLjA3OTAxIC0xLjM3NDQ1NSwtMC42ODkyOSAtMS40MzE2NCwtMS40MzE2NDEgLTAuMDI4OTcsLTAuMzc2MTc5IDAuMTMyMTA0LC0wLjcxMTQ3NyAwLjQwMDM5MSwtMC45NjA5MzcgMC4yNjgyODYsLTAuMjQ5NDYxIDAuNjQzNDg1LC0wLjQwODYyNyAxLjAyNzM0MywtMC40MjM4MjggbCAwLjA4OTg0LC0wLjAwMzkgeiBtIDcuMDQxMDE1LDAuODQ5NjA5IGggMS4yNjE3MTkgbCAxLjE0MjU3OCwzLjQxNDA2MyB2IC0zLjQwMDM5MSBoIDEuMjM2MzI4IGwgMS4yNzUzOTEsMi4zOTI1NzggaCAwLjAxMzY3IGMgLTAuMDA0NywtMC4wNzUzNyAtMC4wMDg5LC0wLjE2NDAzMiAtMC4wMTM2NywtMC4yNjM2NzIgLTAuMDA0OCwtMC4wOTk2NCAtMC4wMDk3LC0wLjIwMDcxMyAtMC4wMTM2NywtMC4zMDI3MzQgLTAuMDA0LC0wLjEwMjAyIC0wLjAwNTksLTAuMTkxMDUxIC0wLjAwNTksLTAuMjY5NTMxIHYgLTEuNTU2NjQxIGggMC44NDM3NSB2IDMuNDQxNDA2IGggLTEuMjQyMTg4IGwgLTEuMjc5Mjk3LC0yLjQyMzgyOCBoIC0wLjAyMTQ4IGMgMC4wMDgsMC4wNzM3NyAwLjAxNTA4LDAuMTYyMDg4IDAuMDIxNDgsMC4yNjU2MjUgMC4wMDY0LDAuMTAzNTc5IDAuMDEyODgsMC4yMDg4OTEgMC4wMTc1OCwwLjMxNjQwNiAwLjAwNDgsMC4xMDc0NzQgMC4wMDc4LDAuMjA0NzA2IDAuMDA3OCwwLjI5MTAxNiB2IDEuNTUwNzgxIEggMjQuNTEzNjcyIEwgMjQuMzM5ODQ0LDE4LjA2MjUgaCAtMS4xMzY3MTkgbCAtMC4xNzc3MzQsMC42NDQ1MzEgaCAtMS4wMzkwNjMgeiBtIC00LjE1NDI5NywwLjAxMzY3IGggMS4wNjI1IGwgMC40NzY1NjMsMS43NDQxNDEgYyAwLjAxNzU0LDAuMDY1ODkgMC4wMzkzNywwLjE1MTEwNyAwLjA2MjUsMC4yNTM5MDYgMC4wMjMxNywwLjEwMjc1OCAwLjA0NDQ4LDAuMjA0NjYxIDAuMDY0NDUsMC4zMDY2NCAwLjAxOTk3LDAuMTAyMDIgMC4wMzIzMSwwLjE4NTYyIDAuMDM3MTEsMC4yNSAwLjAwNjQsLTAuMDY0MzggMC4wMTc2MSwtMC4xNDc2MjUgMC4wMzUxNiwtMC4yNDgwNDYgMC4wMTc1OSwtMC4xMDA0MjEgMC4wMzcwNCwtMC4yMDE1MzUgMC4wNTg1OSwtMC4zMDI3MzUgMC4wMjE2LC0wLjEwMTI0MSAwLjA0MTM4LC0wLjE4NDA2IDAuMDYwNTUsLTAuMjUgbCAwLjQ4NjMyOCwtMS43NTM5MDYgaCAxLjA2MDU0NyBsIC0xLjE0ODQzNywzLjQ0MTQwNiBoIC0xLjExMzI4MiB6IG0gNC43OTEwMTYsMC41NTI3MzQgYyAtMC4wMTQyOSwwLjA3ODQ0IC0wLjAzNDIxLDAuMTc1NjY5IC0wLjA2MDU1LDAuMjkxMDE2IC0wLjAyNjM0LDAuMTE1MzQ3IC0wLjA1NDA2LDAuMjMwNzgyIC0wLjA4MjAzLDAuMzQzNzUgLTAuMDI3OTMsMC4xMTI5NjkgLTAuMDUxNDcsMC4yMDU5MiAtMC4wNzIyNywwLjI4MTI1IGwgLTAuMTUyMzQ0LDAuNTY0NDUzIGggMC43NDYwOTQgbCAtMC4xNTAzOSwtMC41NjQ0NTMgYyAtMC4wMTU5MiwtMC4wNjEyMiAtMC4wMzk4OCwtMC4xNDc5NzEgLTAuMDcwMzEsLTAuMjYxNzE5IC0wLjAzMDI3LC0wLjExMzc4OSAtMC4wNjAyOSwtMC4yMzE5MzUgLTAuMDg5ODQsLTAuMzUzNTE1IC0wLjAyOTU2LC0wLjEyMTYyIC0wLjA1MjM1LC0wLjIyMjM0MiAtMC4wNjgzNiwtMC4zMDA3ODIgeiBtIC0zLjY0NjQ4NCwyLjk5MjE4OCBIIDIwLjU2MjUgYyAwLjE3NDc3NSwwIDAuMzIwNjU4LDAuMDI5NjQgMC40Mzk0NTMsMC4wODk4NCAwLjExODc1NCwwLjA2MDIgMC4yMTAyNTUsMC4xNDg1NTYgMC4yNzE0ODQsMC4yNjU2MjUgMC4wNjEyNywwLjExNzA2OSAwLjA5MTgsMC4yNjE4NjQgMC4wOTE4LDAuNDMzNTkzIDAsMC4xNzk4MDcgLTAuMDMzMDEsMC4zMzEzIC0wLjA5NzY2LDAuNDUzMTI1IC0wLjA2NDYxLDAuMTIxODI2IC0wLjE2MDQ3NywwLjIxMzc2MSAtMC4yODcxMDksMC4yNzUzOTEgLTAuMTI2NTksMC4wNjE2NyAtMC4yODM5NjUsMC4wOTE4IC0wLjQ3MDcwMywwLjA5MTggSCAyMC4xMTkxNCBaIG0gMS40MzU1NDYsMCBoIDAuODk2NDg1IHYgMC4xMDE1NjIgaCAtMC43ODEyNSB2IDAuNjExMzI4IEggMjIuNDA2MjUgViAxOS42MjUgaCAtMC43MzYzMjggdiAwLjY5MzM1OSBoIDAuNzgxMjUgdiAwLjEwMTU2MyBoIC0wLjg5NjQ4NSB6IG0gMS4wODM5ODUsMCBoIDAuMzk4NDM3IGMgMC4xMzAwMDgsMCAwLjIzNzIyOSwwLjAxMzA5IDAuMzI0MjE5LDAuMDQxMDIgMC4wODcwMywwLjAyNzg4IDAuMTUzMTY2LDAuMDc0NzggMC4xOTcyNjYsMC4xMzg2NzIgMC4wNDQwMiwwLjA2Mzg0IDAuMDY2NDEsMC4xNDkxOTEgMC4wNjY0MSwwLjI1NzgxMyAwLDAuMDgxNDggLTAuMDE0NjIsMC4xNTI1ODcgLTAuMDQ0OTIsMC4yMTA5MzcgLTAuMDMwMywwLjA1ODM1IC0wLjA3MjAyLDAuMTA1MTc4IC0wLjEyNSwwLjE0MjU3OCAtMC4wNTMxLDAuMDM3NDggLTAuMTE0MTU0LDAuMDY2MTMgLTAuMTgzNTk0LDAuMDg1OTQgbCAwLjQ0OTIxOSwwLjczMjQyMiBIIDIzLjU4Mzk5IGwgLTAuNDIzODI4LC0wLjY5OTIxOSBoIC0wLjQwNjI1IHYgMC42OTkyMTkgaCAtMC4xMTUyMzQgeiBtIDEuMDA3ODEyLDAgaCAwLjExOTE0MSBsIDAuMzA4NTk0LDEuMTM0NzY1IGMgMC4wMDgxLDAuMDMwMDkgMC4wMTQzOCwwLjA1ODc5IDAuMDIxNDgsMC4wODU5NCAwLjAwNywwLjAyNzE1IDAuMDEzNTMsMC4wNTI4MSAwLjAxOTUzLDAuMDc4MTMgMC4wMDYsMC4wMjUzNCAwLjAxMTk4LDAuMDUwMzUgMC4wMTc1OCwwLjA3NDIyIDAuMDA1NiwwLjAyMzgzIDAuMDExMTIsMC4wNDgwNCAwLjAxNTYzLDAuMDcyMjcgMC4wMDUyLC0wLjAyNDI0IDAuMDEwNDMsLTAuMDQ5MjUgMC4wMTU2MywtMC4wNzQyMiAwLjAwNTIsLTAuMDI0OTMgMC4wMTEyOCwtMC4wNDg1NSAwLjAxNzU4LC0wLjA3NDIyIDAuMDA2NCwtMC4wMjU3MSAwLjAxMjMzLC0wLjA1MjU2IDAuMDE5NTMsLTAuMDgwMDggMC4wMDcsLTAuMDI3NTIgMC4wMTYzOSwtMC4wNTc3OSAwLjAyNTM5LC0wLjA4Nzg5IGwgMC4zMjAzMTMsLTEuMTI4OTA2IGggMC4xMTUyMzQgbCAwLjMzMzk4NSwxLjEzNjcxOSBjIDAuMDA5LDAuMDMxNTMgMC4wMTc5OSwwLjA1OTk3IDAuMDI1MzksMC4wODc4OSAwLjAwNzUsMC4wMjc4OSAwLjAxMzUzLDAuMDU0NzQgMC4wMTk1MywwLjA4MDA4IDAuMDA1OSwwLjAyNTMgMC4wMTIyOCwwLjA1MDM1IDAuMDE3NTgsMC4wNzQyMiAwLjAwNTIsMC4wMjM4MyAwLjAxMDQzLDAuMDQ4MDggMC4wMTU2MywwLjA3MjI3IDAuMDA2LC0wLjAzMzAxIDAuMDExMTgsLTAuMDY1NzIgMC4wMTc1OCwtMC4wOTc2NiAwLjAwNjMsLTAuMDMxOSAwLjAxNDQ0LC0wLjA2NDM5IDAuMDIzNDQsLTAuMDk5NjEgMC4wMDksLTAuMDM1MjYgMC4wMiwtMC4wNzUzNyAwLjAzMTI1LC0wLjExNzE4NyBsIDAuMzE0NDUzLC0xLjEzNjcxOSBoIDAuMTE5MTQxIGwgLTAuNDQ3MjY2LDEuNjA5Mzc1IGggLTAuMTExMzI4IGwgLTAuMzUxNTYyLC0xLjIxNDg0NCBjIC0wLjAwODMsLTAuMDI1NjcgLTAuMDE1MDgsLTAuMDUxMDkgLTAuMDIxNDgsLTAuMDc0MjIgLTAuMDA2MywtMC4wMjMxMyAtMC4wMTE4OCwtMC4wNDM1NCAtMC4wMTc1OCwtMC4wNjQ0NSAtMC4wMDU2LC0wLjAyMDkyIC0wLjAxMDczLC0wLjA0MTg1IC0wLjAxNTYzLC0wLjA2MDU1IC0wLjAwNDgsLTAuMDE4NjkgLTAuMDA4NywtMC4wMzU0IC0wLjAxMTcyLC0wLjA1MDc4IC0wLjAwMjksMC4wMTUzOCAtMC4wMDY0LDAuMDMxNjEgLTAuMDA5OCwwLjA0ODgzIC0wLjAwMzQsMC4wMTcyNiAtMC4wMDcyLDAuMDM0MzggLTAuMDExNzIsMC4wNTI3MyAtMC4wMDQ1LDAuMDE4MzMgLTAuMDA4NCwwLjAzODc0IC0wLjAxMzY3LDAuMDU4NTkgLTAuMDA1MiwwLjAxOTgxIC0wLjAxMTU4LDAuMDM5MjcgLTAuMDE3NTgsMC4wNjA1NSBsIC0wLjM0OTYwOSwxLjI0NDE0MSBoIC0wLjExMzI4MSB6IG0gMi4wMzUxNTcsMCBoIDAuODk2NDg0IHYgMC4xMDE1NjIgaCAtMC43NzkyOTcgdiAwLjYxMTMyOCBoIDAuNzM2MzI4IFYgMTkuNjI1IGggLTAuNzM2MzI4IHYgMC42OTMzNTkgaCAwLjc3OTI5NyB2IDAuMTAxNTYzIGggLTAuODk2NDg0IHogbSAxLjA4Mzk4NCwwIGggMC4zOTg0MzcgYyAwLjEyOTkyNiwwIDAuMjM5MTQyLDAuMDEzMDkgMC4zMjYxNzIsMC4wNDEwMiAwLjA4Njk5LDAuMDI3ODggMC4xNTEyMTMsMC4wNzQ3OCAwLjE5NTMxMywwLjEzODY3MiAwLjA0NDAyLDAuMDYzODQgMC4wNjY0MSwwLjE0OTE5MSAwLjA2NjQxLDAuMjU3ODEzIDAsMC4wODE0OCAtMC4wMTQ2MiwwLjE1MjU4NyAtMC4wNDQ5MiwwLjIxMDkzNyAtMC4wMzAyNiwwLjA1ODM1IC0wLjA3MjAyLDAuMTA1MTc4IC0wLjEyNSwwLjE0MjU3OCAtMC4wNTMwNiwwLjAzNzQ4IC0wLjExNDE1MywwLjA2NjEzIC0wLjE4MzU5NCwwLjA4NTk0IGwgMC40NDkyMTksMC43MzI0MjIgaCAtMC4xMzY3MTkgbCAtMC40MjE4NzUsLTAuNjk5MjE5IGggLTAuNDA4MjAzIHYgMC42OTkyMTkgaCAtMC4xMTUyMzQgeiBtIDEuMTgxNjQxLDAgaCAwLjg5NjQ4NCB2IDAuMTAxNTYyIEggMjguMDYyNSB2IDAuNjg3NSBoIDAuNzM4MjgxIHYgMC4wOTk2MSBIIDI4LjA2MjUgdiAwLjcyMDcwMyBoIC0wLjExNTIzNCB6IG0gLTcuNzEyODkxLDAuMDk5NjEgdiAxLjQxMDE1NiBoIDAuMjcxNDg0IGMgMC4yNDcyNjEsMCAwLjQzMjE4MywtMC4wNjA0IDAuNTU0Njg4LC0wLjE3OTY4NyAwLjEyMjU0OSwtMC4xMTkyNDIgMC4xODM1OTQsLTAuMjk4NTQyIDAuMTgzNTk0LC0wLjUzNzEwOSAwLC0wLjE1MzM1OCAtMC4wMjUzNiwtMC4yODAwNTQgLTAuMDc2MTcsLTAuMzgyODEzIC0wLjA1MDc3LC0wLjEwMjc1OCAtMC4xMjk3OTMsLTAuMTgwNjcyIC0wLjIzNDM3NSwtMC4yMzI0MjIgLTAuMTA0NTgyLC0wLjA1MTcxIC0wLjIzNTg4MiwtMC4wNzgxMyAtMC4zOTY0ODUsLTAuMDc4MTMgeiBtIDIuNTE5NTMxLDAgdiAwLjcwODk4NSBoIDAuMzI2MTcyIGMgMC4xMzM3NiwwIDAuMjM3NDcsLTAuMDMwNjQgMC4zMTI1LC0wLjA5Mzc1IDAuMDc1MTYsLTAuMDYzMTUgMC4xMTMyODEsLTAuMTUzMzUgMC4xMTMyODEsLTAuMjcxNDg1IDAsLTAuMTI5OTQ0IC0wLjAzOTk4LC0wLjIyMDMyMSAtMC4xMTkxNCwtMC4yNjk1MzEgLTAuMDc5MjQsLTAuMDQ5MTcgLTAuMTk5MjI0LC0wLjA3NDIyIC0wLjM2MTMyOCwtMC4wNzQyMiB6IG0gNC4xMjY5NTMsMCB2IDAuNzA4OTg1IGggMC4zMjYxNzIgYyAwLjEzMzc1OSwwIDAuMjM5NDIzLC0wLjAzMDY0IDAuMzE0NDUzLC0wLjA5Mzc1IDAuMDc1MTIsLTAuMDYzMTUgMC4xMTEzMjgsLTAuMTUzMzUgMC4xMTEzMjgsLTAuMjcxNDg1IDAsLTAuMTI5OTQ0IC0wLjAzODA4LC0wLjIyMDMyMSAtMC4xMTcxODcsLTAuMjY5NTMxIC0wLjA3OTI0LC0wLjA0OTE3IC0wLjIwMTE3OCwtMC4wNzQyMiAtMC4zNjMyODEsLTAuMDc0MjIgeiIgICAgc3R5bGU9ImJhc2VsaW5lLXNoaWZ0OmJhc2VsaW5lO2NsaXAtcnVsZTpub256ZXJvO2Rpc3BsYXk6aW5saW5lO292ZXJmbG93OnZpc2libGU7dmVjdG9yLWVmZmVjdDpub25lO2ZpbGw6IzIyMjIyMjtmaWxsLXJ1bGU6bm9uemVybztzdHJva2UtbGluZWNhcDpidXR0O3N0cm9rZS1saW5lam9pbjptaXRlcjtlbmFibGUtYmFja2dyb3VuZDphY2N1bXVsYXRlO3N0b3AtY29sb3I6IzAwMDAwMDtzdG9wLW9wYWNpdHk6MSIgICAgaWQ9InBhdGgxNyIgLz48L3N2Zz4=');}.icon-x{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0yMDQuMjQsMTk1Ljc2YTYsNiwwLDEsMS04LjQ4LDguNDhMMTI4LDEzNi40OSw2MC4yNCwyMDQuMjRhNiw2LDAsMCwxLTguNDgtOC40OEwxMTkuNTEsMTI4LDUxLjc2LDYwLjI0YTYsNiwwLDAsMSw4LjQ4LTguNDhMMTI4LDExOS41MWw2Ny43Ni02Ny43NWE2LDYsMCwwLDEsOC40OCw4LjQ4TDEzNi40OSwxMjhaIi8+PC9zdmc+');}.icon-loading{--icon:url('data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiIHN0YW5kYWxvbmU9Im5vIj8+PHN2ZyAgICB3aWR0aD0iMzIiICAgIGhlaWdodD0iMzIiICAgIHZpZXdCb3g9IjAgMCAzMiAzMiIgICAgdmVyc2lvbj0iMS4xIiAgICB4bWw6c3BhY2U9InByZXNlcnZlIiAgICBzdHlsZT0iY2xpcC1ydWxlOmV2ZW5vZGQ7ZmlsbC1ydWxlOmV2ZW5vZGQ7c3Ryb2tlLWxpbmVjYXA6cm91bmQ7c3Ryb2tlLWxpbmVqb2luOnJvdW5kO3N0cm9rZS1taXRlcmxpbWl0OjEuNSIgICAgaWQ9InN2ZzEwIiAgICB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciICAgIHhtbG5zOnN2Zz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPjxkZWZzICAgIGlkPSJkZWZzMTAiIC8+PHBhdGggICAgaWQ9InBhdGgxMSIgICAgc3R5bGU9ImJhc2VsaW5lLXNoaWZ0OmJhc2VsaW5lO2Rpc3BsYXk6aW5saW5lO292ZXJmbG93OnZpc2libGU7dmVjdG9yLWVmZmVjdDpub25lO2ZpbGw6IzIyMjIyMjtlbmFibGUtYmFja2dyb3VuZDphY2N1bXVsYXRlO3N0b3AtY29sb3I6IzAwMDAwMDtzdG9wLW9wYWNpdHk6MSIgICAgZD0ibSAxNi42MjEwOTQsMS4xNDI1NzgxIGMgLTguMjY2MzIzMiwwIC0xNi4yMjA4NjczOCw2LjQ0MjgwOTUgLTE1LjU4NTkzNzgsMTQuNjg1NTQ2OSAwLjYwMTM0NTUsNy44MDczMDggNy40MzQxMjY0LDE0LjEyNjk4IDE0LjkzMzU5MzgsMTQuOTQzMzU5IDguODM5ODQ1LDAuOTYyMjgzIDE1LjUwNTQ2OSwtNi4zNzY5MTkgMTUuMDA1ODU5LC0xNC45ODYzMjggQyAzMC40OTU5LDcuNTM2MjY4NCAyNC44ODMzOTcsMS4xNDI1NzgxIDE2LjYyMTA5NCwxLjE0MjU3ODEgWiBtIDAsMC42NTAzOTA3IEMgMjYuNDg4Nzg2LDEuODAzODY0NSAyOS43MTQ1MTgsOS41OTM1ODMzIDMwLjMwMjczNCwxNS44MDQ2ODggMzEuMTQxOTgyLDI0LjY2NjM2NSAyMi4xNjA0NTksMzEuMTY4MDc3IDE2LjAzOTA2MiwzMC4xMjUgOC44OTUxMzI3LDI4LjkwNzY4MSAyLjI2MTMxNDIsMjMuMjc5Mzc2IDEuNjgzNTkzOCwxNS43NzkyOTcgMS4wNzY5MzM4LDcuOTAzMjc1NCA4LjcyMjU0NTEsMS43ODQyNjk5IDE2LjYyMTA5NCwxLjc5Mjk2ODggWiBtIC0wLjA2NDQ1LDEuMjE4NzUgYyAtMy42MTAwODMsMCAtNy4xNTQ3OTk1LDEuNDAxMDY4NyAtOS43MzA0NjkxLDMuNzAzMTI1IEMgNC4yNTA1MDIzLDkuMDE2OTAwMiAyLjY0MjAzNzIsMTIuMjI2Mjk1IDIuOTE5OTIxOSwxNS44MzM5ODQgMy40NDY5MzUsMjIuNjc1NzEyIDkuNDI4OTY0OSwyOC4xOTg5ODUgMTUuOTk4MDQ3LDI4LjkxNDA2MiAyMy43MTQyNTYsMjkuNzU0MDIzIDI5LjUzMTYwMywyMy4zMzE3IDI5LjA5NTcwMywxNS44MjAzMTIgMjguNjc3OTQ4LDguNjIxMzk1MyAyMy43NzY2ODYsMy4wMTE3MTg4IDE2LjU1NjY0MSwzLjAxMTcxODggWiBtIDAsMC4xOTUzMTI0IGMgNy4xMTkxMzQsMCAxMS45MzI3MSw1LjUwODEzNzMgMTIuMzQ1NzAzLDEyLjYyNDk5OTggQyAyOS4zMzIwNjIsMjMuMjM2ODk2IDIzLjYxODk1OCwyOS41NDU5OTggMTYuMDE5NTMxLDI4LjcxODc1IDkuNTQ1NDMyMSwyOC4wMTQwMTIgMy42MzQxNjM3LDIyLjU1NTE0MyAzLjExNTIzNDQsMTUuODE4MzU5IDIuODQyNDU2MywxMi4yNzY5NjcgNC40MTg0MTA5LDkuMTI4MzE2OSA2Ljk1NzAzMTIsNi44NTkzNzUgOS40OTU2NTE2LDQuNTkwNDMzMSAxMi45OTcwOTMsMy4yMDcwMzEyIDE2LjU1NjY0MSwzLjIwNzAzMTIgWiBtIC0wLjA3MDMxLDEuNDE2MDE1NyBjIC0zLjE2MTk3MywwIC02LjI2MzUwOSwxLjIyNTgxMzkgLTguNTE5NTMxMSwzLjI0MjE4NzUgQyA1LjcxMDc2OTEsOS44ODE2MDggNC4zMDE0NTQyLDEyLjY5NDU4OSA0LjU0NDkyMTksMTUuODU1NDY5IDUuMDA2NTYyNCwyMS44NDg1NTQgMTAuMjQ0MTc4LDI2LjY4NjE1OSAxNS45OTgwNDcsMjcuMzEyNSAyMi43NTcwMTMsMjguMDQ4MjYxIDI3Ljg1NDQ1MSwyMi40MjA5MzYgMjcuNDcyNjU2LDE1Ljg0MTc5NyAyNy4xMDY4MjQsOS41Mzc2MDI1IDIyLjgxMDE2LDQuNjIzMDQ2OSAxNi40ODYzMjgsNC42MjMwNDY5IFogbSAwLDAuMTk1MzEyNSBjIDYuMjIyOTIsMCAxMC40Mjk5NDYsNC44MTMwMTM4IDEwLjc5MTAxNiwxMS4wMzUxNTY2IDAuMzc1NjEzLDYuNDcyNjE1IC00LjYxNzU4NCwxMS45ODY3MiAtMTEuMjU5NzY2LDExLjI2MzY3MiBDIDEwLjM1ODY4NSwyNi41MDExODYgNS4xOTE4MzgxLDIxLjcyNzk4NSA0LjczODI4MTIsMTUuODM5ODQ0IDQuNDk5OTIwMSwxMi43NDUyNjIgNS44NzY3MzE1LDkuOTk0OTc3OCA4LjA5NTcwMzEsOC4wMTE3MTg4IDEwLjMxNDY3NSw2LjAyODQ1OTUgMTMuMzc0ODksNC44MTgzNTk0IDE2LjQ4NjMyOCw0LjgxODM1OTQgWiBtIC0wLjA2ODM2LDEuNDE2MDE1NiBjIC0yLjcxMzg3NywwIC01LjM3NjExOCwxLjA1MjUxNjQgLTcuMzEyNTAwMiwyLjc4MzIwMzEgLTEuOTM2MzgyOCwxLjczMDY4NjkgLTMuMTQ2NTUxNyw0LjE0NTMxMTkgLTIuOTM3NSw2Ljg1OTM3NDkgMC4zOTYyNjk5LDUuMTQ0NDMgNC44ODk0NDQyLDkuMjk0NDI5IDkuODI4MTI1Miw5LjgzMjAzMSA1LjgwMTc0OSwwLjYzMTU2MiAxMC4xNzkyNTcsLTQuMTk4ODI4IDkuODUxNTYyLC05Ljg0NTcwMyBDIDI1LjUzMzc1LDEwLjQ1MzgyMiAyMS44NDU2MTYsNi4yMzQzNzUgMTYuNDE3OTc0LDYuMjM0Mzc1IFogbSAwLDAuMTk1MzEyNSBjIDUuMzI2NzMsMCA4LjkyNTIyNiw0LjExNzkwNTUgOS4yMzQzNzUsOS40NDUzMTI1IDAuMzIxNTEzLDUuNTQwMzUxIC0zLjk0OTgwMSwxMC4yNTk0NzQgLTkuNjM0NzY2LDkuNjQwNjI1IEMgMTEuMTczODc1LDI0Ljk4ODM2MiA2Ljc0OTUxNDMsMjAuOTAwODE0IDYuMzYxMzI4MSwxNS44NjEzMjggNi4xNTczODMxLDEzLjIxMzU2MyA3LjMzNTA0MzEsMTAuODU5NjgyIDkuMjM0Mzc1LDkuMTYyMTA5NCAxMS4xMzM3MDcsNy40NjQ1MzcyIDEzLjc1NDYyOCw2LjQyOTY4NzUgMTYuNDE3OTY5LDYuNDI5Njg3NSBaIG0gLTAuMDY4MzYsMS40MTYwMTU2IGMgLTIuMjY1Nzc1LDAgLTQuNDg4NzI5LDAuODc5MjE5NiAtNi4xMDU0NjgsMi4zMjQyMTg5IC0xLjYxNjc0MDgsMS40NDQ5OTkgLTIuNjI3NzYwNywzLjQ2MTI2OSAtMi40NTMxMjU0LDUuNzI4NTE2IDAuMzMwODk4Niw0LjI5NTc2OCA0LjA4MTU5NjQsNy43NjAxMiA4LjIwNTA3ODQsOC4yMDg5ODQgNC44NDQ1MjUsMC41MjczNiA4LjUwMDE1NiwtMy41MDYwOTcgOC4yMjY1NjIsLTguMjIwNzAzIEMgMjMuOTYwNjcyLDExLjM3MTk5NiAyMC44ODEwNiw3Ljg0NTcwMzEgMTYuMzQ5NjE0LDcuODQ1NzAzMSBaIG0gMCwwLjE5NTMxMjUgYyA0LjQzMDUzNCwwIDcuNDIyNDYxLDMuNDIyNzk5NCA3LjY3OTY4OCw3Ljg1NTQ2ODQgMC4yNjc0MTIsNC42MDgwODIgLTMuMjgzOTc4LDguNTMyMjI2IC04LjAxMTcxOSw4LjAxNzU3OCBDIDExLjk4OTA3NSwyMy40NzU1MzggOC4zMDcxODk5LDIwLjA3NTU5MyA3Ljk4NDM3NSwxNS44ODQ3NjYgNy44MTQ4NDYzLDEzLjY4MzgxOSA4Ljc5NTMxMDUsMTEuNzI2MzM4IDEwLjM3NSwxMC4zMTQ0NTMgMTEuOTU0Njg5LDguOTAyNTY4OSAxNC4xMzQzNyw4LjA0MTAxNTYgMTYuMzQ5NjA5LDguMDQxMDE1NiBaIG0gLTAuMDY4MzYsMS40MTYwMTU2IGMgLTEuODE3NjcyLDAgLTMuNjAxMzQyLDAuNzAzOTY4OCAtNC44OTg0MzgsMS44NjMyODA4IC0xLjI5NzA5NSwxLjE1OTMxIC0yLjEwODk2ODMsMi43NzkxODUgLTEuOTY4NzQ5NSw0LjU5OTYxIDAuMjY1NTI2OSwzLjQ0NzExMSAzLjI3Mzc1MDUsNi4yMjU4MTMgNi41ODIwMzE1LDYuNTg1OTM3IDMuODg3Mjk1LDAuNDIzMTYgNi44MjMwMDgsLTIuODE1MzE4IDYuNjAzNTE1LC02LjU5NzY1NiBDIDIyLjM4OTU0MSwxMi4yODgyMjIgMTkuOTE2NDk1LDkuNDU3MDMxMSAxNi4yODEyNSw5LjQ1NzAzMTIgWiBtIDAsMC4xOTUzMTI2IGMgMy41MzQzMzMsMCA1LjkxNzc0MiwyLjcyNzY5NjIgNi4xMjMwNDcsNi4yNjU2MjUyIDAuMjEzMzExLDMuNjc1ODE0IC0yLjYxNjIwOCw2LjgwMzAyNSAtNi4zODY3MTksNi4zOTI1NzggLTMuMjEzMjk4LC0wLjM0OTc4NSAtNi4xNTA3NTk3LC0zLjA2MjEzIC02LjQwODIwMywtNi40MDQyOTcgLTAuMTM1MTEyMiwtMS43NTQxMjcgMC42NDQyNTIsLTMuMzEzMjU3IDEuOTA0Mjk3LC00LjQzOTQ1MyAxLjI2MDA0NSwtMS4xMjYxOTYgMy4wMDA0NDEsLTEuODE0NDUzMyA0Ljc2NzU3OCwtMS44MTQ0NTMyIHogbSAtMC4wNzAzMSwxLjQxNjAxNTIgYyAtMS4zNjk1NzIsMCAtMi43MTIsMC41MzA2NzUgLTMuNjg5NDU0LDEuNDA0Mjk3IC0wLjk3NzQ1MywwLjg3MzYyMiAtMS41OTAxNzcsMi4wOTUxNDUgLTEuNDg0Mzc1LDMuNDY4NzUgMC4yMDAxNTYsMi41OTg0NTIgMi40NjU5LDQuNjg5NTUxIDQuOTU4OTg1LDQuOTYwOTM4IDIuOTMwMDcsMC4zMTg5NTggNS4xNDM5MDgsLTIuMTIyNTg3IDQuOTc4NTE1LC00Ljk3MjY1NiAtMC4xNTgxNDUsLTIuNzI1MjQ0IC0yLjAyNDYyMiwtNC44NjEzMjkgLTQuNzYzNjcxLC00Ljg2MTMyOSB6IG0gMCwwLjE5NTMxMyBjIDIuNjM4MTM1LDAgNC40MTQ5NzUsMi4wMzQ1NDQgNC41NjgzNTksNC42Nzc3MzQgMC4xNTkyMTEsMi43NDM1NDYgLTEuOTUwMzg2LDUuMDczODI0IC00Ljc2MzY3Miw0Ljc2NzU3OCAtMi4zOTgxMDIsLTAuMjYxMDQ3IC00LjU5MTEzMSwtMi4yODc3NDEgLTQuNzgzMjAzLC00Ljc4MTI1IC0wLjEwMDY5NiwtMS4zMDczMDggMC40Nzk1MTksLTIuNDcwMDM5IDEuNDE5OTIyLC0zLjMxMDU0NiAwLjk0MDQwMywtMC44NDA1MDggMi4yMzk1NTcsLTEuMzUzNTE2IDMuNTU4NTk0LC0xLjM1MzUxNiB6IG0gLTAuMDY4MzYsMS40MTYwMTYgYyAtMC45MjE0NzIsMCAtMS44MjI2NTcsMC4zNTU0MjUgLTIuNDgwNDY5LDAuOTQzMzU5IC0wLjY1NzgxMSwwLjU4NzkzNCAtMS4wNzMzMzksMS40MTUwMSAtMS4wMDE5NTMsMi4zNDE3OTcgMC4xMzQ3ODUsMS43NDk3OTIgMS42NTYwOTUsMy4xNTMyOTEgMy4zMzM5ODUsMy4zMzU5MzcgMS45NzI4NDYsMC4yMTQ3NTkgMy40NjY3NiwtMS40MzE4MDkgMy4zNTU0NjgsLTMuMzQ5NjA5IC0wLjEwNjIyNCwtMS44MzA1MDMgLTEuMzY0MTc3LC0zLjI3MTQ4NyAtMy4yMDcwMzEsLTMuMjcxNDg0IHogbSAwLDAuMTk1MzEyIGMgMS43NDE5NDIsMCAyLjkxMjIwOSwxLjMzOTQ0IDMuMDEzNjcyLDMuMDg3ODkxIDAuMTA1MTEsMS44MTEyNzYgLTEuMjg0NTYyLDMuMzQ2NTc3IC0zLjE0MDYyNSwzLjE0NDUzMSAtMS41ODI5MDcsLTAuMTcyMzA3IC0zLjAzMzQ1NSwtMS41MTMzNTUgLTMuMTYwMTU2LC0zLjE1ODIwMyAtMC4wNjYyOCwtMC44NjA0OSAwLjMxNDc4NSwtMS42MjQ4NjggMC45MzU1NDcsLTIuMTc5Njg4IDAuNjIwNzQ5LC0wLjU1NDgxOSAxLjQ4MDYyLC0wLjg5NDUzMSAyLjM1MTU1NiwtMC44OTQ1MzEgeiBtIC0wLjA2ODM2LDEuNDE2MDE2IGMgLTAuNDczMzY5LDAgLTAuOTM1MjcxLDAuMTgyMTI5IC0xLjI3MzQzOCwwLjQ4NDM3NSAtMC4zMzgxNjcsMC4zMDIyNDYgLTAuNTU0NTQ2LDAuNzMwOTY5IC0wLjUxNzU3OCwxLjIxMDkzNyAwLjA2OTQxLDAuOTAxMTMzIDAuODQ4MjQ5LDEuNjE4OTgxIDEuNzEwOTM4LDEuNzEyODkxIDEuMDE1NjE2LDAuMTEwNTU3IDEuNzg5NjE0LC0wLjc0MTAzMSAxLjczMjQyMSwtMS43MjY1NjMgLTAuMDU0MywtMC45MzU3NjYgLTAuNzA1NjkxLC0xLjY4MTY0IC0xLjY1MjM0MywtMS42ODE2NCB6IG0gMCwwLjE5NTMxMiBjIDAuODQ1NzQsMCAxLjQwNzQ5LDAuNjQ0MzMzIDEuNDU3MDMxLDEuNDk4MDQ3IDAuMDUxMDEsMC44NzkwMDggLTAuNjE2NzkzLDEuNjE5MzI5IC0xLjUxNTYyNSwxLjUyMTQ4NCAtMC43Njc3MDYsLTAuMDgzNTcgLTEuNDc1NzgsLTAuNzM4OTY3IC0xLjUzNzEwOSwtMS41MzUxNTYgLTAuMDMxODYsLTAuNDEzNjcxIDAuMTUwMDU1LC0wLjc3OTY5NyAwLjQ1MTE3MiwtMS4wNDg4MjggMC4zMDExMTYsLTAuMjY5MTMxIDAuNzIxNjk4LC0wLjQzNTU0NyAxLjE0NDUzMSwtMC40MzU1NDcgeiIgLz48L3N2Zz4=');}.icon-house-simple{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0yMTcuOSwxMTAuMWwtODAtODBhMTQsMTQsMCwwLDAtMTkuOCwwbC04MCw4MEExMy45MiwxMy45MiwwLDAsMCwzNCwxMjB2OTZhNiw2LDAsMCwwLDYsNkgyMTZhNiw2LDAsMCwwLDYtNlYxMjBBMTMuOTIsMTMuOTIsMCwwLDAsMjE3LjksMTEwLjFaTTIxMCwyMTBINDZWMTIwYTIsMiwwLDAsMSwuNTgtMS40Mmw4MC04MGEyLDIsMCwwLDEsMi44NCwwbDgwLDgwQTIsMiwwLDAsMSwyMTAsMTIwWiIvPjwvc3ZnPg==');}.icon-house{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0yMTcuOSwxMTAuMWwtODAtODBhMTQsMTQsMCwwLDAtMTkuOCwwbC04MCw4MEExMy45MiwxMy45MiwwLDAsMCwzNCwxMjB2OTZhNiw2LDAsMCwwLDYsNmg2NGE2LDYsMCwwLDAsNi02VjE1OGgzNnY1OGE2LDYsMCwwLDAsNiw2aDY0YTYsNiwwLDAsMCw2LTZWMTIwQTEzLjkyLDEzLjkyLDAsMCwwLDIxNy45LDExMC4xWk0yMTAsMjEwSDE1OFYxNTJhNiw2LDAsMCwwLTYtNkgxMDRhNiw2LDAsMCwwLTYsNnY1OEg0NlYxMjBhMiwyLDAsMCwxLC41OC0xLjQybDgwLTgwYTIsMiwwLDAsMSwyLjg0LDBsODAsODBBMiwyLDAsMCwxLDIxMCwxMjBaIi8+PC9zdmc+');}.icon-x-circle{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0xNjQuMjQsMTAwLjI0LDEzNi40OCwxMjhsMjcuNzYsMjcuNzZhNiw2LDAsMSwxLTguNDgsOC40OEwxMjgsMTM2LjQ4bC0yNy43NiwyNy43NmE2LDYsMCwwLDEtOC40OC04LjQ4TDExOS41MiwxMjgsOTEuNzYsMTAwLjI0YTYsNiwwLDAsMSw4LjQ4LTguNDhMMTI4LDExOS41MmwyNy43Ni0yNy43NmE2LDYsMCwwLDEsOC40OCw4LjQ4Wk0yMzAsMTI4QTEwMiwxMDIsMCwxLDEsMTI4LDI2LDEwMi4xMiwxMDIuMTIsMCwwLDEsMjMwLDEyOFptLTEyLDBhOTAsOTAsMCwxLDAtOTAsOTBBOTAuMSw5MC4xLDAsMCwwLDIxOCwxMjhaIi8+PC9zdmc+');}.icon-plus-square{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0yMDgsMzRINDhBMTQsMTQsMCwwLDAsMzQsNDhWMjA4YTE0LDE0LDAsMCwwLDE0LDE0SDIwOGExNCwxNCwwLDAsMCwxNC0xNFY0OEExNCwxNCwwLDAsMCwyMDgsMzRabTIsMTc0YTIsMiwwLDAsMS0yLDJINDhhMiwyLDAsMCwxLTItMlY0OGEyLDIsMCwwLDEsMi0ySDIwOGEyLDIsMCwwLDEsMiwyWm0tMzYtODBhNiw2LDAsMCwxLTYsNkgxMzR2MzRhNiw2LDAsMCwxLTEyLDBWMTM0SDg4YTYsNiwwLDAsMSwwLTEyaDM0Vjg4YTYsNiwwLDAsMSwxMiwwdjM0aDM0QTYsNiwwLDAsMSwxNzQsMTI4WiIvPjwvc3ZnPg==');}.icon-infinity{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0yNDYsMTI4YTU0LDU0LDAsMCwxLTkyLjE4LDM4LjE4LDMuMDcsMy4wNywwLDAsMS0uMjUtLjI2bC02MC02Ny43NGE0Miw0MiwwLDEsMCwwLDU5LjY0bDguNTctOS42N2E2LDYsMCwxLDEsOSw4bC04LjY5LDkuODFhMy4wNywzLjA3LDAsMCwxLS4yNS4yNiw1NCw1NCwwLDEsMSwwLTc2LjM2LDMuMDcsMy4wNywwLDAsMSwuMjUuMjZsNjAsNjcuNzRhNDIsNDIsMCwxLDAsMC01OS42NGwtOC41Nyw5LjY3YTYsNiwwLDEsMS05LThsOC42OS05LjgxYTMuMDcsMy4wNywwLDAsMSwuMjUtLjI2QTU0LDU0LDAsMCwxLDI0NiwxMjhaIi8+PC9zdmc+');}.icon-arrow-counter-clockwise{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0yMjIsMTI4YTk0LDk0LDAsMCwxLTkyLjc0LDk0SDEyOGE5My40Myw5My40MywwLDAsMS02NC41LTI1LjY1LDYsNiwwLDEsMSw4LjI0LTguNzJBODIsODIsMCwxLDAsNzAsNzBsLS4xOS4xOUwzOS40NCw5OEg3MmE2LDYsMCwwLDEsMCwxMkgyNGE2LDYsMCwwLDEtNi02VjU2YTYsNiwwLDAsMSwxMiwwVjkwLjM0TDYxLjYzLDYxLjRBOTQsOTQsMCwwLDEsMjIyLDEyOFoiLz48L3N2Zz4=');}.icon-magnifying-glass{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0yMjguMjQsMjE5Ljc2bC01MS4zOC01MS4zOGE4Ni4xNSw4Ni4xNSwwLDEsMC04LjQ4LDguNDhsNTEuMzgsNTEuMzhhNiw2LDAsMCwwLDguNDgtOC40OFpNMzgsMTEyYTc0LDc0LDAsMSwxLDc0LDc0QTc0LjA5LDc0LjA5LDAsMCwxLDM4LDExMloiLz48L3N2Zz4=');}.icon-floppy-disk{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0yMTcuOSw3My40MiwxODIuNTgsMzguMWExMy45LDEzLjksMCwwLDAtOS44OS00LjFINDhBMTQsMTQsMCwwLDAsMzQsNDhWMjA4YTE0LDE0LDAsMCwwLDE0LDE0SDIwOGExNCwxNCwwLDAsMCwxNC0xNFY4My4zMUExMy45LDEzLjksMCwwLDAsMjE3LjksNzMuNDJaTTE3MCwyMTBIODZWMTUyYTIsMiwwLDAsMSwyLTJoODBhMiwyLDAsMCwxLDIsMlptNDAtMmEyLDIsMCwwLDEtMiwySDE4MlYxNTJhMTQsMTQsMCwwLDAtMTQtMTRIODhhMTQsMTQsMCwwLDAtMTQsMTR2NThINDhhMiwyLDAsMCwxLTItMlY0OGEyLDIsMCwwLDEsMi0ySDE3Mi42OWEyLDIsMCwwLDEsMS40MS41OEwyMDkuNDIsODEuOWEyLDIsMCwwLDEsLjU4LDEuNDFaTTE1OCw3MmE2LDYsMCwwLDEtNiw2SDk2YTYsNiwwLDAsMSwwLTEyaDU2QTYsNiwwLDAsMSwxNTgsNzJaIi8+PC9zdmc+');}.icon-calendar{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0yMDgsMzRIMTgyVjI0YTYsNiwwLDAsMC0xMiwwVjM0SDg2VjI0YTYsNiwwLDAsMC0xMiwwVjM0SDQ4QTE0LDE0LDAsMCwwLDM0LDQ4VjIwOGExNCwxNCwwLDAsMCwxNCwxNEgyMDhhMTQsMTQsMCwwLDAsMTQtMTRWNDhBMTQsMTQsMCwwLDAsMjA4LDM0Wk00OCw0Nkg3NFY1NmE2LDYsMCwwLDAsMTIsMFY0Nmg4NFY1NmE2LDYsMCwwLDAsMTIsMFY0NmgyNmEyLDIsMCwwLDEsMiwyVjgySDQ2VjQ4QTIsMiwwLDAsMSw0OCw0NlpNMjA4LDIxMEg0OGEyLDIsMCwwLDEtMi0yVjk0SDIxMFYyMDhBMiwyLDAsMCwxLDIwOCwyMTBabS05OC05MHY2NGE2LDYsMCwwLDEtMTIsMFYxMjkuNzFsLTcuMzIsMy42NmE2LDYsMCwxLDEtNS4zNi0xMC43NGwxNi04QTYsNiwwLDAsMSwxMTAsMTIwWm01OS41NywyOS4yNUwxNDgsMTc4aDIwYTYsNiwwLDAsMSwwLDEySDEzNmE2LDYsMCwwLDEtNC44LTkuNkwxNjAsMTQyYTEwLDEwLDAsMSwwLTE2LjY1LTExQTYsNiwwLDEsMSwxMzMsMTI1YTIyLDIyLDAsMSwxLDM2LjYyLDI0LjI2WiIvPjwvc3ZnPg==');}.icon-clock-clockwise{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0xMzQsODB2NDQuNmwzNy4wOSwyMi4yNWE2LDYsMCwwLDEtNi4xOCwxMC4zbC00MC0yNEE2LDYsMCwwLDEsMTIyLDEyOFY4MGE2LDYsMCwwLDEsMTIsMFptOTAtMjJhNiw2LDAsMCwwLTYsNlY4Ny4zNmMtNy40OC04LjgzLTE0Ljk0LTE3LjEzLTIzLjUzLTI1LjgzYTk0LDk0LDAsMSwwLTEuOTUsMTM0LjgzLDYsNiwwLDAsMC04LjI0LTguNzJBODIsODIsMCwxLDEsMTg2LDcwYzkuMjQsOS4zNiwxNy4xOCwxOC4zLDI1LjMxLDI4SDE4NGE2LDYsMCwwLDAsMCwxMmg0MGE2LDYsMCwwLDAsNi02VjY0QTYsNiwwLDAsMCwyMjQsNThaIi8+PC9zdmc+');}.icon-shuffle{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0yMzYuMjQsMTc5Ljc2YTYsNiwwLDAsMSwwLDguNDhsLTI0LDI0YTYsNiwwLDAsMS04LjQ4LTguNDhMMjE3LjUyLDE5MEgyMDAuOTRhNzAuMTYsNzAuMTYsMCwwLDEtNTctMjkuMzFsLTQxLjcxLTU4LjRBNTguMTEsNTguMTEsMCwwLDAsNTUuMDYsNzhIMzJhNiw2LDAsMCwxLDAtMTJINTUuMDZhNzAuMTYsNzAuMTYsMCwwLDEsNTcsMjkuMzFsNDEuNzEsNTguNEE1OC4xMSw1OC4xMSwwLDAsMCwyMDAuOTQsMTc4aDE2LjU4bC0xMy43Ni0xMy43NmE2LDYsMCwwLDEsOC40OC04LjQ4Wm0tOTIuMDYtNzQuNDFhNS45MSw1LjkxLDAsMCwwLDMuNDgsMS4xMiw2LDYsMCwwLDAsNC44OS0yLjUxbDEuMTktMS42N0E1OC4xMSw1OC4xMSwwLDAsMSwyMDAuOTQsNzhoMTYuNThMMjAzLjc2LDkxLjc2YTYsNiwwLDEsMCw4LjQ4LDguNDhsMjQtMjRhNiw2LDAsMCwwLDAtOC40OGwtMjQtMjRhNiw2LDAsMCwwLTguNDgsOC40OEwyMTcuNTIsNjZIMjAwLjk0YTcwLjE2LDcwLjE2LDAsMCwwLTU3LDI5LjMxTDE0Mi43OCw5N0E2LDYsMCwwLDAsMTQ0LjE4LDEwNS4zNVptLTMyLjM2LDQ1LjNhNiw2LDAsMCwwLTguMzcsMS4zOWwtMS4xOSwxLjY3QTU4LjExLDU4LjExLDAsMCwxLDU1LjA2LDE3OEgzMmE2LDYsMCwwLDAsMCwxMkg1NS4wNmE3MC4xNiw3MC4xNiwwLDAsMCw1Ny0yOS4zMWwxLjE5LTEuNjdBNiw2LDAsMCwwLDExMS44MiwxNTAuNjVaIi8+PC9zdmc+');}.icon-sort-descending{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik00MiwxMjhhNiw2LDAsMCwxLDYtNmg3MmE2LDYsMCwwLDEsMCwxMkg0OEE2LDYsMCwwLDEsNDIsMTI4Wm02LTU4aDU2YTYsNiwwLDAsMCwwLTEySDQ4YTYsNiwwLDAsMCwwLDEyWk0xODQsMTg2SDQ4YTYsNiwwLDAsMCwwLDEySDE4NGE2LDYsMCwwLDAsMC0xMlpNMjI4LjI0LDgzLjc2bC00MC00MGE2LDYsMCwwLDAtOC40OCwwbC00MCw0MGE2LDYsMCwwLDAsOC40OCw4LjQ4TDE3OCw2Mi40OVYxNDRhNiw2LDAsMCwwLDEyLDBWNjIuNDlsMjkuNzYsMjkuNzVhNiw2LDAsMCwwLDguNDgtOC40OFoiLz48L3N2Zz4=');}.icon-sort-ascending{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0xMjYsMTI4YTYsNiwwLDAsMS02LDZINDhhNiw2LDAsMCwxLDAtMTJoNzJBNiw2LDAsMCwxLDEyNiwxMjhaTTQ4LDcwSDE4NGE2LDYsMCwwLDAsMC0xMkg0OGE2LDYsMCwwLDAsMCwxMlptNTYsMTE2SDQ4YTYsNiwwLDAsMCwwLDEyaDU2YTYsNiwwLDAsMCwwLTEyWm0xMjQuMjQtMjIuMjRhNiw2LDAsMCwwLTguNDgsMEwxOTAsMTkzLjUxVjExMmE2LDYsMCwwLDAtMTIsMHY4MS41MWwtMjkuNzYtMjkuNzVhNiw2LDAsMCwwLTguNDgsOC40OGw0MCw0MGE2LDYsMCwwLDAsOC40OCwwbDQwLTQwQTYsNiwwLDAsMCwyMjguMjQsMTYzLjc2WiIvPjwvc3ZnPg==');}.icon-arrow-elbow-left-down{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0yMzgsNzJhNiw2LDAsMCwxLTYsNkg5NFYyMDEuNTFsMzcuNzYtMzcuNzVhNiw2LDAsMCwxLDguNDgsOC40OGwtNDgsNDhhNiw2LDAsMCwxLTguNDgsMGwtNDgtNDhhNiw2LDAsMCwxLDguNDgtOC40OEw4MiwyMDEuNTFWNzJhNiw2LDAsMCwxLDYtNkgyMzJBNiw2LDAsMCwxLDIzOCw3MloiLz48L3N2Zz4=');}.icon-arrow-elbow-right-down{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0yMjguMjQsMTY0LjI0bC00OCw0OGE2LDYsMCwwLDEtOC40OCwwbC00OC00OGE2LDYsMCwxLDEsOC40OC04LjQ4TDE3MCwxOTMuNTFWNzBIMzJhNiw2LDAsMCwxLDAtMTJIMTc2YTYsNiwwLDAsMSw2LDZWMTkzLjUxbDM3Ljc2LTM3Ljc1YTYsNiwwLDAsMSw4LjQ4LDguNDhaIi8+PC9zdmc+');}.icon-caret-right{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0xODAuMjQsMTMyLjI0bC04MCw4MGE2LDYsMCwwLDEtOC40OC04LjQ4TDE2Ny41MSwxMjgsOTEuNzYsNTIuMjRhNiw2LDAsMCwxLDguNDgtOC40OGw4MCw4MEE2LDYsMCwwLDEsMTgwLjI0LDEzMi4yNFoiLz48L3N2Zz4=');}.icon-heart{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0xNzgsNDJjLTIxLDAtMzkuMjYsOS40Ny01MCwyNS4zNEMxMTcuMjYsNTEuNDcsOTksNDIsNzgsNDJhNjAuMDcsNjAuMDcsMCwwLDAtNjAsNjBjMCwyOS4yLDE4LjIsNTkuNTksNTQuMSw5MC4zMWEzMzQuNjgsMzM0LjY4LDAsMCwwLDUzLjA2LDM3LDYsNiwwLDAsMCw1LjY4LDAsMzM0LjY4LDMzNC42OCwwLDAsMCw1My4wNi0zN0MyMTkuOCwxNjEuNTksMjM4LDEzMS4yLDIzOCwxMDJBNjAuMDcsNjAuMDcsMCwwLDAsMTc4LDQyWk0xMjgsMjE3LjExQzExMS41OSwyMDcuNjQsMzAsMTU3LjcyLDMwLDEwMkE0OC4wNSw0OC4wNSwwLDAsMSw3OCw1NGMyMC4yOCwwLDM3LjMxLDEwLjgzLDQ0LjQ1LDI4LjI3YTYsNiwwLDAsMCwxMS4xLDBDMTQwLjY5LDY0LjgzLDE1Ny43Miw1NCwxNzgsNTRhNDguMDUsNDguMDUsMCwwLDEsNDgsNDhDMjI2LDE1Ny43MiwxNDQuNDEsMjA3LjY0LDEyOCwyMTcuMTFaIi8+PC9zdmc+');}.icon-dots-three{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0xMzgsMTI4YTEwLDEwLDAsMSwxLTEwLTEwQTEwLDEwLDAsMCwxLDEzOCwxMjhaTTYwLDExOGExMCwxMCwwLDEsMCwxMCwxMEExMCwxMCwwLDAsMCw2MCwxMThabTEzNiwwYTEwLDEwLDAsMSwwLDEwLDEwQTEwLDEwLDAsMCwwLDE5NiwxMThaIi8+PC9zdmc+');}.icon-star-half-fi{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0yMzkuMTgsOTcuMjZBMTYuMzgsMTYuMzgsMCwwLDAsMjI0LjkyLDg2bC01OS00Ljc2TDE0My4xNCwyNi4xNWExNi4zNiwxNi4zNiwwLDAsMC0zMC4yNywwTDkwLjExLDgxLjIzLDMxLjA4LDg2YTE2LjQ2LDE2LjQ2LDAsMCwwLTkuMzcsMjguODZsNDUsMzguODNMNTMsMjExLjc1YTE2LjQsMTYuNCwwLDAsMCwyNC41LDE3LjgyTDEyOCwxOTguNDlsNTAuNTMsMzEuMDhBMTYuNCwxNi40LDAsMCwwLDIwMywyMTEuNzVsLTEzLjc2LTU4LjA3LDQ1LTM4LjgzQTE2LjQzLDE2LjQzLDAsMCwwLDIzOS4xOCw5Ny4yNlptLTE1LjM0LDUuNDctNDguNyw0MmE4LDgsMCwwLDAtMi41Niw3LjkxbDE0Ljg4LDYyLjhhLjM3LjM3LDAsMCwxLS4xNy40OGMtLjE4LjE0LS4yMy4xMS0uMzgsMGwtNTQuNzItMzMuNjVBOCw4LDAsMCwwLDEyOCwxODEuMVYzMmMuMjQsMCwuMjcuMDguMzUuMjZMMTUzLDkxLjg2YTgsOCwwLDAsMCw2Ljc1LDQuOTJsNjMuOTEsNS4xNmMuMTYsMCwuMjUsMCwuMzQuMjlTMjI0LDEwMi42MywyMjMuODQsMTAyLjczWiIvPjwvc3ZnPg==');}.icon-star-fi{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0yMzQuMjksMTE0Ljg1bC00NSwzOC44M0wyMDMsMjExLjc1YTE2LjQsMTYuNCwwLDAsMS0yNC41LDE3LjgyTDEyOCwxOTguNDksNzcuNDcsMjI5LjU3QTE2LjQsMTYuNCwwLDAsMSw1MywyMTEuNzVsMTMuNzYtNTguMDctNDUtMzguODNBMTYuNDYsMTYuNDYsMCwwLDEsMzEuMDgsODZsNTktNC43NiwyMi43Ni01NS4wOGExNi4zNiwxNi4zNiwwLDAsMSwzMC4yNywwbDIyLjc1LDU1LjA4LDU5LDQuNzZhMTYuNDYsMTYuNDYsMCwwLDEsOS4zNywyOC44NloiLz48L3N2Zz4=');}.icon-heart-fi{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0yNDAsMTAyYzAsNzAtMTAzLjc5LDEyNi42Ni0xMDguMjEsMTI5YTgsOCwwLDAsMS03LjU4LDBDMTE5Ljc5LDIyOC42NiwxNiwxNzIsMTYsMTAyQTYyLjA3LDYyLjA3LDAsMCwxLDc4LDQwYzIwLjY1LDAsMzguNzMsOC44OCw1MCwyMy44OUMxMzkuMjcsNDguODgsMTU3LjM1LDQwLDE3OCw0MEE2Mi4wNyw2Mi4wNywwLDAsMSwyNDAsMTAyWiIvPjwvc3ZnPg==');}
\ No newline at end of file
diff --git a/assets/js/concise/CRUD.js b/assets/js/concise/CRUD.js
index d399866..d97155b 100644
--- a/assets/js/concise/CRUD.js
+++ b/assets/js/concise/CRUD.js
@@ -737,6 +737,9 @@
if (!item) return;
item.dataset.itemId.split(',').forEach(itemId => {
let field = this.forms.getField(e.target);
+ if (['repeater', 'tag-list'].includes(field.dataset.fieldType)) {
+ return;
+ }
let name = field.dataset.field;
let value = this.forms.getFieldValue(e.target);
this.updateItem(itemId, name, value);
@@ -762,7 +765,6 @@
2000
);
}
-
cancelBackup() {
window.debouncer.cancel(`changes-${this.content}`);
}
@@ -1170,7 +1172,7 @@
await this.handleBackup();
}
const changes = await this.changesStore.getAll();
-
+ console.log('Saving Changes: ', changes);
if (changes.length === 0) return;
if (title === '') {
diff --git a/assets/js/concise/FormController.js b/assets/js/concise/FormController.js
index 0d7e7ca..07185b6 100644
--- a/assets/js/concise/FormController.js
+++ b/assets/js/concise/FormController.js
@@ -73,7 +73,7 @@
},
tagList: {
tagList: '.field.tag-list', //querySelectorAll
- input: '.tag-input-row',
+ input: '.row',
add: '.add-tag',
remove: '.remove-tag',
label: '.tag-label',
@@ -325,6 +325,7 @@
if (e.target.closest('[data-ignore]') || this.isRestoring) return;
let field = this.getField(e.target);
+
//Dependencies
if (this.dependencies.has(field.dataset.field)) {
let dependency = this.dependencies.get(field.dataset.field);
@@ -333,6 +334,11 @@
});
}
+ if (Object.hasOwn(field.dataset, 'repeater-id') || Object.hasOwn(field.dataset,'tag-list-id')) {
+ this.updateCollectionField(field);
+ return;
+ }
+
let form = this.getForm(e.target);
this.updateItem(field.dataset.field, this.getFieldValue(e.target), form);
}
@@ -377,9 +383,6 @@
if (this.subscribers.size > 0) {
e.preventDefault();
- console.log('Cancelling scheduled backup and manually backing up');
-
-
if (form.options.cache) {
this.cancelBackup();
@@ -608,8 +611,11 @@
this.removeQuantityListeners(item.element);
break;
}
+
+ if (check.has(item.id)) {
+ check.delete(item.id);
+ }
});
- check.delete(item.id);
}
}
@@ -790,6 +796,7 @@
}
}
checkForRepeaters(form) {
+
if (!form.querySelector(this.selectors.repeater.repeater)) return;
form.querySelectorAll(this.selectors.repeater.repeater).forEach(repeater => {
@@ -802,7 +809,7 @@
sortable: false,
};
- if (!config.ui.addButton) return;
+ if (!config.ui.add) return;
let template = repeater.querySelector('template');
this.templates.define(
@@ -814,9 +821,10 @@
setup({el, refs, manyRefs, data}) {
let index = config.ui.items?.children?.length??0;
el.dataset.index = index;
+
+
manyRefs.inputs?.forEach(input => {
- let wrapper = el.closest('[data-field]');
- window.prefixInput(input, `${el.dataset.fieldName}:${index}:`, wrapper);
+ window.prefixInput(input, `${data.repeater.dataset.fieldName}:${index}:`, el);
});
}
},
@@ -845,13 +853,18 @@
}
handleRepeaterClick(e) {
if (e.target.matches(this.selectors.repeater.add)) {
+ console.log('Add Repeater Row');
this.addRepeaterRow(e.target.closest('[data-repeater-id]'));
} else if (e.target.matches(this.selectors.repeater.remove)) {
- this.removeRepeaterRow(e.target);
+ console.log('Remove Repeater Row');
+ this.removeRepeaterRow(e.target.closest('[data-index]'));
}
}
addRepeaterRow(repeater) {
- repeater.append(this.templates.create(repeater.dataset.repeaterId));
+ let data = {};
+ data.repeater = repeater;
+ repeater.append(this.templates.create(repeater.dataset.repeaterId, data));
+ this.initializeFields(repeater, this.getField(repeater).config??{});
this.a11y.announce('Row added');
}
removeRepeaterRow(row) {
@@ -982,6 +995,8 @@
config.ui.items.append(newItem);
config.ui.inputs[0]?.focus();
+ this.updateCollectionField(tagList);
+
this.a11y.announce('Item added');
}
removeTagListItem(tag) {
@@ -1207,6 +1222,28 @@
);
});
});
+
+
+ this.updateCollectionField(container);
+ }
+
+ /**
+ * Update the entire repeater/tagList field data
+ * Call this whenever rows are added, removed, or reordered
+ */
+ updateCollectionField(element) {
+ const field = element.closest('[data-field]');
+ if (!field) return;
+
+ const fieldType = field.dataset.fieldType;
+ if (!['repeater', 'tag-list'].includes(fieldType)) return;
+
+ const form = this.getForm(element);
+ if (!form) return;
+
+ // Get all current data for the collection
+ const value = this.getFieldValue(field.querySelector('input, select, textarea'));
+ this.updateItem(field.dataset.field, value, form);
}
/**********************************************************************
VALIDATION
diff --git a/assets/js/concise/PopulateForm.js b/assets/js/concise/PopulateForm.js
index 73bab0b..64b3f65 100644
--- a/assets/js/concise/PopulateForm.js
+++ b/assets/js/concise/PopulateForm.js
@@ -169,6 +169,11 @@
window.removeChildren(grid);
ids.forEach(id => {
let data = this.data.images[id]??{};
+ data.field = {
+ config: {
+ showMeta: true
+ }
+ };
data.id = id;
grid.append(this.templates.create('uploadItem', data));
});
diff --git a/assets/js/concise/TaxonomySelector.js b/assets/js/concise/TaxonomySelector.js
index 222545d..8faa8d7 100644
--- a/assets/js/concise/TaxonomySelector.js
+++ b/assets/js/concise/TaxonomySelector.js
@@ -184,7 +184,7 @@
},
favourites: '.favourite-terms',
field: {
- toggle: 'button.taxonomy-toggle, [data-filter="taxonomy"]',
+ toggle: 'button.selector-toggle, [data-filter="taxonomy"]',
value: 'input[type="hidden"]',
selected: '.selected-items',
dropdown: {
diff --git a/assets/js/concise/UploadManager.js b/assets/js/concise/UploadManager.js
index 7b87e85..733767b 100644
--- a/assets/js/concise/UploadManager.js
+++ b/assets/js/concise/UploadManager.js
@@ -109,7 +109,7 @@
break;
}
if (refs.details) {
- if (Object.hasOwn(data.field.config, 'showMeta') && !data.field.config.showMeta) {
+ if (Object.hasOwn(data, 'field') && Object.hasOwn(data.field,'config') && Object.hasOwn(data.field.config, 'showMeta') && !data.field.config.showMeta) {
refs.details.remove();
} else {
if(Object.hasOwn(data, 'id')) {
diff --git a/assets/js/concise/quill.js b/assets/js/concise/quill.js
index cab9cf5..823e193 100644
--- a/assets/js/concise/quill.js
+++ b/assets/js/concise/quill.js
@@ -94,15 +94,30 @@
h1: function() { this.quill.format('header', 1); },
h2: function() { this.quill.format('header', 2); },
h3: function() { this.quill.format('header', 3); },
- 'jvb_bold': function() {this.quill.format('bold', true)},
- 'jvb_italic': function() {this.quill.format('italic', true)},
- 'jvb_strike': function() {this.quill.format('strike', true)},
- 'jvb_underline': function() {this.quill.format('underline', true)},
+ 'jvb_bold': function() {
+ const format = this.quill.getFormat();
+ this.quill.format('bold', !format.bold);
+ },
+ 'jvb_italic': function() {
+ const format = this.quill.getFormat();
+ this.quill.format('italic', !format.italic);
+ },
+ 'jvb_strike': function() {
+ const format = this.quill.getFormat();
+ this.quill.format('strike', !format.strike);
+ },
+ 'jvb_underline': function() {
+ const format = this.quill.getFormat();
+ this.quill.format('underline', !format.underline);
+ },
'jvb_align': function(value) {
- this.quill.format('align', value === this.quill.getFormat().list ? false : value);
+ const format = this.quill.getFormat();
+ this.quill.format('align', value === format.align ? false : value);
},
'jvb_list': function(value) {
- this.quill.format('list', value === this.quill.getFormat().list ? false : value);
+ // value will be either "bullet" or "ordered" depending on which button was clicked
+ const format = this.quill.getFormat();
+ this.quill.format('list', value === format.list ? false : value);
},
'jvb_link': function(value) {
if (value) {
@@ -166,6 +181,8 @@
}
},
'jvb_image': function() {
+ const objectID = textarea.dataset.postId || textarea.closest('form')?.dataset.postId;
+
const input = document.createElement('input');
input.setAttribute('type', 'file');
input.setAttribute('accept', 'image/jpeg,image/png,image/gif,image/webp');
@@ -217,7 +234,7 @@
this.quill.insertEmbed(range.index, 'image', result.url);
} catch (error) {
- this.handleError('Upload error:', error);
+ console.error('Upload error:', error);
this.quill.insertText(range.index, 'Failed to upload image. Please try again.', {
'color': '#f00',
'italic': true
@@ -244,9 +261,40 @@
instances.push(quill);
quill.on('selection-change', function(range) {
+ if (!range) return;
+
+ const format = quill.getFormat(range);
+
+ // Update button states
+ const formatButtons = {
+ 'ql-jvb_bold': 'bold',
+ 'ql-jvb_italic': 'italic',
+ 'ql-jvb_underline': 'underline',
+ 'ql-jvb_strike': 'strike'
+ };
+
+ Object.entries(formatButtons).forEach(([buttonClass, formatName]) => {
+ const button = toolbar.querySelector(`.${buttonClass}`);
+ if (button) {
+ button.classList.toggle('active', !!format[formatName]);
+ }
+ });
+
+ // Update list button states
+ toolbar.querySelectorAll('.ql-jvb_list').forEach(button => {
+ const value = button.getAttribute('value');
+ button.classList.toggle('ql-active', format.list === value);
+ });
+
+ // Update alignment button states
+ toolbar.querySelectorAll('.ql-jvb_align').forEach(button => {
+ const value = button.getAttribute('value');
+ button.classList.toggle('ql-active', format.align === value);
+ });
+
const alignmentTools = toolbar.querySelector('.ql-align');
if (alignmentTools) {
- if (range && range.length === 0) {
+ if (range.length === 0) {
// Get the focused element
const [leaf] = this.quill.getLeaf(range.index);
if (leaf && leaf.domNode && leaf.domNode.tagName === 'IMG') {
diff --git a/assets/js/min/crud.min.js b/assets/js/min/crud.min.js
index e3b1009..4856ebe 100644
--- a/assets/js/min/crud.min.js
+++ b/assets/js/min/crud.min.js
@@ -1 +1 @@
-(()=>{class e{constructor(){this.container=document.querySelector(".crud[data-content]:not([data-ignore])"),this.container&&(this.content=this.container.dataset.content,this.endpoint=this.container.dataset.endpoint??"content",this.singular=this.container.dataset.singular,this.plural=this.container.dataset.plural,this.queue=window.jvbQueue,this.a11y=window.jvbA11y,this.error=window.jvbError,this.populate=window.jvbPopulate,this.cache=new window.jvbCache(this.content),this.activeItem=null,this.isTimeline=!1,this.isPopulating=!1,this.changes=new Map,this.items=new Map,this.init())}init(){this.initElements(),this.initListeners(),this.defineTemplates();let e=this.initSettings();this.initStore(e),this.checkHideFilters(),this.initIntegrations(),this.initUploader(),this.initModals()}defineTemplates(){const e=window.jvbTemplates,t=this,s=(e,s,i)=>{e.dataset.itemId=i.id;let a=s.checkbox.closest(".preview");window.prefixInput(s.checkbox,`select-${i.id}`,a,!0),s.checkbox.value=i.id,s.checkbox.checked=t.selected.has(parseInt(i.id)),s.selectLabel&&(s.selectLabel.htmlFor=`select-${i.id}`),s.edit&&(s.edit.dataset.id=i.id),s.trash&&(s.trash.dataset.id=i.id)},i=function(e,t,s){if(s?.fields?.post_thumbnail){const e=s.images[s.fields.post_thumbnail]??{};t.img.src=e.medium??"",t.img.alt=e.alt??s.fields.post_title??""}};e.define("gridView",{refs:{img:"img",checkbox:".select-item",selectLabel:"label.select-item-label",edit:'[data-action="edit"]',trash:'[data-action="trash"]'},setup({el:e,refs:t,manyRefs:a,data:l}){s(e,t,l),i(0,t,l)}}),e.define("listView",{refs:{img:"img",checkbox:".select-item",selectLabel:"label.select-item-label",edit:'[data-action="edit"]',trash:'[data-action="trash"]'},manyRefs:{attrs:"[data-attr]",fields:"[data-field]"},setup({el:e,refs:t,manyRefs:a,data:l}){s(e,t,l),i(0,t,l),a?.attrs?.forEach((e=>{const t=l[e.dataset.attr];t&&""!==t?e.textContent=t:e.remove()})),a?.fields?.forEach((e=>{const t=l.fields?.[e.dataset.field];t&&""!==t?"DIV"===e.tagName?e.innerHTML=t:e.textContent=t:e.remove()}))}});let a={};this.isTimeline&&(a.sharedRow="tr.shared",a.point="tr.timeline-point"),e.define("tableView",{refs:{checkbox:".select-item",selectLabel:"label.select-item-label",...a},manyRefs:{inputs:"input,select,textarea",status:'input[name="post_status"]',selectors:'[data-type="selector"]',fields:"[data-field]"},setup({el:e,refs:i,manyRefs:a,data:l}){if(s(e,i,l),a?.inputs?.forEach((e=>{let t=e.closest("[data-field]");window.prefixInput(e,`${l.id}-`,t)})),a?.status?.forEach((e=>{e.value===l.status&&(e.checked=!0)})),t.isTimeline)i.sharedRow&&(i.sharedRow.querySelectorAll("input,select,textarea").forEach((e=>{let t=e.closest("[data-field]");window.prefixInput(e,`${l.id}-`,t)})),t.populate.populate(i.sharedRow,l),i.sharedRow.querySelectorAll('input[name="post_status"]').forEach((e=>{e.value===l.status&&(e.checked=!0)}))),i.point&&l.fields?.timeline&&(Object.entries(l.fields.timeline).forEach((([s,a],n)=>{const o=i.point.cloneNode(!0);o.dataset.index=`${n}`,o.dataset.itemId=a.id,o.querySelectorAll("input,select,textarea").forEach((e=>{let t=e.closest("[data-field]");window.prefixInput(e,`${a.id}-`,t)})),t.populate.populate(o,{fields:a,images:l.images,taxonomies:l.taxonomies});const d=l.images?.[a.post_thumbnail];d&&o.querySelector(".field.upload")?.setAttribute("title",d["image-title"]??""),e.insertBefore(o,i.point)})),i.point.remove());else if(void 0!==t.ui.table.form?.dataset.edit)a?.inputs?.forEach((e=>{let t=e.closest("[data-field]");window.prefixInput(e,`${l.id}-`,t)})),a?.status?.forEach((e=>{e.value===l.status&&(e.checked=!0)})),t.populate.populate(e,l);else{const e=Object.hasOwn(l,"fields")?l.fields:l;a?.fields?.forEach((t=>{if(Object.hasOwn(e,t.dataset.field)&&""!==e[t.dataset.field]){let s=e[t.dataset.field],i=e.children[0];i&&(i.textContent="date"===t.dataset.field?window.formatTimeAgo(s):s)}}))}a?.selectors?.forEach((e=>e.setAttribute("data-lazy","")))}}),e.define("emptyState"),e.define("bulkItem",{refs:{checkbox:"input",img:"img",label:"label"},setup({el:e,refs:t,manyRefs:s,data:i}){t.checkbox&&(t.checkbox.id=`bulk_${i.id}`,t.checkbox.value=i.id,t.checkbox.checked=!0,t.checkbox.name="selected[]");let a=i?.images[i?.fields?.post_thumnbail]??{};t.img&&Object.keys(a).length>0&&(t.img.src=a.medium??"",t.img.alt=a.alt??""),t.label&&(t.label.title=item.fields.post_title)}}),e.define("trashOptions"),e.define("notTrashOptions"),e.define("contentTable")}initElements(){this.allowedFilters=["status","orderby","order","search","date-filter","dateFrom","dateTo"],this.selectors={buttons:{create:".create-item",clearFilters:'[data-action="clear-filters"]'},views:{grid:'input[data-view="grid"]',list:'input[data-view="list"]',table:'input[data-view="table"]'},modals:{create:{modal:"dialog.create",form:"dialog.create form",h2:"dialog.create h2"},edit:{modal:"dialog.edit",form:"dialog.edit form",h2:"dialog.edit h2"},bulkEdit:{modal:"dialog.bulkEdit",selected:"dialog.bulkEdit .selected",h2:"dialog.bulkEdit h2 span",form:"dialog.bulkEdit form"},date:{modal:"dialog.date-range",start:"dialog.date-range .date-start",end:"dialog.date-range .date-end",month:"dialog.date-range .month-select"}},grid:`.${this.content}.item-grid`,table:{nav:"#vertical",form:"form.table",table:"form.table table",body:"form.table body",head:"form.table thead",foot:"form.table tfoot",selectedColumns:".all-filters .multi-select",columns:"thead th"},bulk:{action:".bulk-action-select",count:".bulk-controls .selected-count",control:".bulk-controls .bulk-actions",select:".bulk-controls select",selectAll:".select-all"},filters:{container:"details.all-filters",search:'.all-filters input[type="search"]',status:{all:'[name="status"]#all',publish:'[name="status"]#publish',draft:'[name="status"]#draft',trash:'[name="status"]#trash'},orderby:{date:'[name="orderby"]#date',alphabetical:'[name="orderby"]#alphabetical'},order:{asc:'[name="order"][value="asc"]',desc:'[name="order"][value="desc"]'},date:'[data-filter="date"]'},uploader:"details.uploader"},this.ui=window.uiFromSelectors(this.selectors);const e=document.querySelectorAll('[data-filter="taxonomies"]');e.length>0&&(this.ui.filters.taxonomies={},e.forEach((e=>{const t=e.dataset.taxonomy;this.ui.filters.taxonomies[t]=e,this.allowedFilters.push(`tax_${t}`)}))),this.isTimeline=!!document.querySelector("[data-timeline]")}initUploader(){this.ui.uploader&&(window.jvbUploads.scanFields(this.ui.uploader),window.jvbUploads.subscribe(((e,t)=>{"sent-to-queue"===e&&t===this.ui.uploader.dataset.uploader&&window.debouncer.schedule("crud-complete",(()=>{this.store.clearCache()}))})))}initModals(){this.modals={};for(let[e,t]of Object.entries(this.ui.modals))t.modal&&(this.modals[e]=new window.jvbModal(t.modal),this.modals[e].subscribe(((t,s)=>{if("modal-close"===t){const t=this.ui.modals[e].form.dataset.formId;t&&this.forms.clearForm(t),this.resetForm(this.ui.modals[e].form),"date"===e&&this.handleCustomDateSelection(),["edit","bulkEdit","create"].includes(e)&&window.debouncer.timeouts.has(`save-${this.content}`)&&this.scheduleSave(0)}})))}initStore(e){let t={...this.defaults,...e};const s=window.jvbStore.register(this.content,[{storeName:this.content,keyPath:"id",endpoint:this.endpoint??"content",headers:{action_nonce:window.auth.getNonce("dash")},indexes:[{name:"id",keyPath:"id"},{name:"status",keyPath:"status"},{name:"date",keyPath:"date"},{name:"modified",keyPath:"modified"},{name:"title",keyPath:"title"}],filters:t,ignore:["content","user"],TTL:36e5,showLoading:!0},{storeName:"changes",keyPath:"id"}]);this.changesStore=s.changes,this.store=s[this.content],this.store.subscribe(((e,t)=>{if("data-loaded"===e)this.render(),this.selectionHandler.collectItems()})),this.changesStore.subscribe(((e,t)=>{if("data-ready"===e){let e=this.changesStore.getAll();e.length>0&&(e.forEach((e=>{this.changes.set(e.id,e)})),this.savePosts("",!1).then((()=>{})))}}))}initIntegrations(){this.selected=new Set,this.selectionHandler=new window.jvbHandleSelection(this.container,{selectAll:{checkbox:"#select-all",label:".bulk-select label",span:".bulk-select label span"},wrapper:{wrapper:".wrap"},item:{idAttribute:"itemId"}}),this.selectionHandler.subscribe(((e,t)=>{this.selected=new Set([...t.selectedItems].map((e=>parseInt(e)))),this.ui.bulk.control.hidden=0===this.selected.size,this.ui.bulk.count.hidden=0===this.selected.size,this.ui.bulk.count.textContent=`${this.selected.size} ${this.plural} selected`})),this.forms=window.jvbForm,this.queue.subscribe(((e,t)=>{if(["image_upload","video_upload","document_upload"].includes(t.type)&&"operation-status"===e&&"completed"===t.status&&this.store.clearCache(),"operation-status"===e&&"completed"===t.status&&"uploads/groups"===t.endpoint&&(console.log("Cleared local cache. Refresh to see changes"),this.store.clearCache()),"operation-status"===e&&"completed"===t.status&&"content_update"===t.type){if(console.log("Cleared local cache. Refresh to see changes"),this.store.clearCache(),!t.result||!t.result.posts)return void console.warn("Content update completed but no result.posts",t);const e=Object.keys(t.result.posts).filter((e=>!0===t.result.posts[e]?.success));if(0===e.length)return;this.changesStore.deleteMany(e),e.forEach((e=>this.changes.delete(e)))}}))}initSettings(){this.defaults={content:this.content,user:window.auth.getUser(),page:1,status:"all",orderby:"date",order:"desc",search:""};let e={},t=this.container.dataset.view??"grid";this.view=this.cache.get("view")??t,this.view!==t&&(this.ui.views[this.view].checked=!0),this.status=this.cache.get("status")??this.defaults.status,this.status!==this.defaults.status&&(this.ui.filters.status[this.status].checked=!0,e.status=this.status),this.orderby=this.cache.get("orderby")??this.defaults.orderby,this.orderby!==this.defaults.orderby&&(this.ui.filters.orderby[this.orderby].checked=!0,e.orderBy=this.orderby),this.order=this.cache.get("order")??this.defaults.order,this.order!==this.defaults.order&&(this.ui.filters.order[this.order].checked=!0,e.order=this.order),this.ui.filters.taxonomies&&Object.entries(this.ui.filters.taxonomies).forEach((([t,s])=>{const i=`tax_${t}`,a=this.cache.get(i);a&&(s.value=a,e[i]=a)}));let s=this.cache.get("tabNav")??"horizontal";this.ui.table.nav&&"vertical"===s&&(this.ui.table.nav.checked=!0);let i={showFilters:{element:this.ui.filters.container,default:"closed"},showUploader:{element:this.ui.uploader,default:"open"}};for(let[e,t]of Object.entries(i))if(t.element){let s=this.cache.get(e)??t.default;t.element.open="open"===s,t.element.addEventListener("toggle",(()=>{this.cache.set(e,t.element.open?"open":"closed")}))}return e}initListeners(){this.changeHandler=this.handleChange.bind(this),this.clickHandler=this.handleClick.bind(this),this.inputHandler=this.handleInput.bind(this),this.submitHandler=this.handleModalSubmit.bind(this),document.addEventListener("change",this.changeHandler),document.addEventListener("click",this.clickHandler),this.ui.filters.search&&this.ui.filters.search.addEventListener("input",this.inputHandler);for(let[e,t]of Object.entries(this.ui.modals))t.form&&t.form.addEventListener("submit",this.submitHandler)}handleModalSubmit(e){e.preventDefault();const t=e.target.closest("dialog");if(!t)return;let s=`Saving changes for multiple ${this.plural}`;t.classList.contains("edit")?s="Saving your edits...":t.classList.contains("create")&&(s=`Creating your new ${this.singular}`),this.scheduleSave(0)}handleChange(e){const t=e.target.closest("[data-item-id]"),s=e.target.matches("[data-filter]"),i=e.target.matches(".bulk-action-select"),a=e.target.matches("[data-view]");if(t||s||i||a)if(this.isPopulating||!t||e.target.closest("[data-ignore], .select-item")){if(a)return this.items.clear(),void this.handleViewChange(e.target);if(i)this.handleBulkAction(e.target);else if(s)this.handleFilterChange(e.target);else if("table"===this.view){if(e.target.matches("details.multi-select"))return void this.toggleColumn(e.target.id,e.target.checked);e.target.matches(this.selectors.table.nav)&&(this.tabNav=e.target.checked,this.cache.set("tabNav",e.target.checked?"vertical":"horizontal"))}}else this.handleItemUpdate(e)}handleBulkAction(e){if(e.value.startsWith("tax-")){const t=e.options[e.selectedIndex],s=t.dataset.taxonomy,i=t.dataset.single,a=t.dataset.plural;return window.jvbSelector.openEmpty(s,i,a,(e=>this.handleBulkTaxonomy(e))),void(e.value="")}switch(e.value){case"edit":this.openBulkEditModal();break;case"publish":case"trash":case"delete":this.setBulkStatus(e.value);break;case"draft":case"restore":this.setBulkStatus("draft")}}handleBulkTaxonomy(e){e.termIds.length&&this.selected.size&&(this.selected.forEach((t=>{const s=this.store.get(t);if(!s)return;const i=(s.taxonomies?.[e.taxonomy]||[]).map((e=>e.id)),a=[...new Set([...i,...e.termIds])];this.updateItem(t,e.taxonomy,a)})),this.savePosts(`Adding ${e.terms.length} ${e.taxonomy} to ${this.selected.size} ${this.plural}...`).then((()=>{})),this.selectionHandler.clearSelection())}handleItemUpdate(e){let t=window.targetCheck(e,"[data-item-id]");t&&t.dataset.itemId.split(",").forEach((t=>{let s=this.forms.getField(e.target).dataset.field,i=this.forms.getFieldValue(e.target);this.updateItem(t,s,i)}))}updateItem(e,t,s){this.changes.has(e)||this.changes.set(e,{id:e,content:this.content}),this.changes.get(e)[t]=s,this.scheduleBackup(),this.scheduleSave()}scheduleBackup(){window.debouncer.schedule(`changes-${this.content}`,(async()=>{this.changes.size>0&&await this.handleBackup()}),2e3)}cancelBackup(){window.debouncer.cancel(`changes-${this.content}`)}async handleBackup(){const e=Array.from(this.changes.values());this.changes.clear();const t=e.map((e=>e.id)),s=await Promise.all(t.map((e=>this.changesStore.get(e)))),i=e.map(((e,t)=>s[t]?window.deepMerge(s[t],e):e));await this.changesStore.saveMany(i)}scheduleSave(e=1e4){window.debouncer.schedule(`save-${this.content}`,(async()=>{this.changes.size>0&&(this.cancelBackup(),await this.handleBackup()),await this.savePosts("",!1)}),e)}handleFilterChange(e){let t=e.dataset.filter;return"date"===t&&"custom"===e.value?(e.value="",void this.modals.date.handleOpen()):"date"===t&&""!==e.value?(this.setFilter("date-filter",e.value),this.deleteFilter("dateFrom"),this.deleteFilter("dateTo"),void this.checkHideFilters()):("taxonomies"===t&&(t=`tax_${e.dataset.taxonomy}`),void this.setFilter(t,e.value))}checkHideFilters(){const e=this.store.filters,t=Object.entries(e).some((([e,t])=>!["content","user","page"].includes(e)&&(this.defaults[e]!==t&&""!==t&&null!==t)));this.ui.buttons.clearFilters.hidden=!t}clearAllFilters(){let e=this.store.filters;this.store.clearFilters();for(let[t,s]of Object.entries(e))this.cache.remove(t),this.deleteFilter(t,s);this.a11y.announce("All filters cleared")}handleCustomDateSelection(){if(this.ui.modals.date.month&&this.ui.modals.date.month.value){const[e,t]=this.ui.modals.date.month.value.split("-"),s=`${e}-${t}-01`,i=new Date(e,parseInt(t),0).getDate(),a=`${e}-${t}-${String(i).padStart(2,"0")}`;this.setFilter("dateFrom",s),this.setFilter("dateTo",a),this.deleteFilter("date-filter"),this.ui.modals.date.month.value=""}else this.ui.modals.date.start&&this.ui.modals.date.start.value&&this.ui.modals.date.end&&this.ui.modals.date.end.value&&(this.setFilter("dateFrom",this.ui.modals.date.start.value),this.setFilter("dateTo",this.ui.modals.date.end.value),this.deleteFilter("date-filter"),this.ui.modals.date.start.value="",this.ui.modals.date.end.value="");this.checkHideFilters()}handleViewChange(e){this.view=e.dataset.view,this.cache.set("view",this.view),this.render()}handleClick(e){if(e.target.matches(".clear-search"))return void this.deleteFilter("search","");const t=e.target.closest("[data-action]");return t?(e.preventDefault(),void this.handleActionButton(t)):e.target.matches(".apply-date-filter")?(this.handleCustomDateSelection(),void this.modals.date.handleClose()):void(e.target.matches(this.selectors.buttons.create)&&this.openCreateModal())}openCreateModal(){this.forms.registerForm(this.ui.modals.create.form,{cache:!1}),this.ui.modals.create.modal.dataset.itemId=window.generateID("new"),this.modals.create.handleOpen()}handleActionButton(e){const t=e.dataset.id;switch(e.dataset.action){case"edit":this.openEditModal(t);break;case"delete":confirm("Delete this item? This cannot be undone")&&(this.updateItem(t,"post_status","delete"),window.fade(e.closest(".item"),!1),this.savePosts(`Permanently deleting ${this.singular}...`).then((()=>{})),this.store.delete(t));break;case"trash":"trash"===this.status?confirm("Delete this item? This cannot be undone")&&(this.updateItem(t,"post_status","delete"),window.fade(e.closest(".item"),!1),this.savePosts(`Permanently deleting ${this.singular}...`).then((()=>{})),this.store.delete(t)):(this.updateItem(t,"post_status","trash"),window.fade(e.closest(".item"),!1),this.savePosts(`Sending ${this.singular} to trash...`).then((()=>{})));break;case"bulk-edit":this.selected.size>0&&this.openBulkEditModal();break;case"bulk-delete":this.handleBulkDelete();break;case"refresh":this.store.clearCache(),this.store.fetch();break;case"clear-filters":this.clearAllFilters()}}handleBulkDelete(){let e="trash"===this.status;if(this.selected.size>0&&confirm(`${e?"Permanently delete":"Send"} ${this.selected.size} ${1===this.selected.size?this.singular:this.plural}${e?"":"to trash"}?`)){this.selected.forEach((t=>{this.store.delete(t),this.updateItem(t,"post_status",e?"delete":"trash")}));let t=e?`Permanently deleting ${this.selected.size} ${1===this.selected.size?this.singular:this.plural}`:`Sending ${this.selected.size} ${1===this.selected.size?this.singular:this.plural} to trash`;this.savePosts(t).then((()=>{})),this.selectionHandler.clearSelection()}}handleInput(e){e.preventDefault(),e.stopPropagation();let t=e.target.value.trim(),s=`${this.content}-search`;0!==t.length?window.debouncer.schedule(s,(()=>{this.a11y.announce(`Searching for "${t}"...`),this.store.setFilters({search:t,page:1})}),300):this.deleteFilter("search","")}handleKeys(e){if(this.tabNav&&"Tab"===e.key){e.preventDefault();const t=e.target.closest("[data-field]"),s=e.target.closest("tr");if(!t||!s)return;const i=t.dataset.field,a=e.shiftKey;let l=this.findNextEditableRow(s,a);l||(l=this.wrapToRow(s,a)),l&&this.focusFieldInRow(l,i,a)}}findNextEditableRow(e,t=!1){let s=t?e.previousElementSibling:e.nextElementSibling;for(;s&&!this.isEditableRow(s);)s=t?s.previousElementSibling:s.nextElementSibling;return s}wrapToRow(e,t=!1){if(this.isTimeline){const s=e.closest("tbody");if(!s)return null;const i=Array.from(s.querySelectorAll("tr")).filter((e=>this.isEditableRow(e)));return t?i[i.length-1]:i[0]}{if(!this.ui.table.body)return null;const e=Array.from(this.ui.table.body.querySelectorAll("tr")).filter((e=>this.isEditableRow(e)));return t?e[e.length-1]:e[0]}}isEditableRow(e){return!e.closest("thead")&&!e.closest("tfoot")&&(this.isTimeline?e.classList.contains("shared")||e.classList.contains("timeline-point"):!!e.dataset.itemId)}focusFieldInRow(e,t,s=!1){const i=e.querySelector(`[data-field="${t}"]`);if(!i)return;const a=this.findFocusableInput(i);if(a){a.focus(),a.select&&"text"===a.type&&a.select();const e=s?"next":"previous";this.a11y?.announce(`Moved to ${t} in ${e} row`)}}findFocusableInput(e){const t=['input:not([type="hidden"]):not([disabled])',"textarea:not([disabled])","select:not([disabled])","button:not([disabled])"];for(const s of t){const t=e.querySelector(s);if(t)return t}return null}openEditModal(e){let t=this.store.get(parseInt(e));t&&(this.activeItem=t.id,this.ui.modals.edit.modal.dataset.itemId=e,this.ui.modals.edit.modal.dataset.content=this.content,this.ui.modals.edit.h2.textContent=`Editing ${""===t.fields.post_title?this.singular:t.fields.post_title}`,this.ui.modals.edit.form.dataset.formId=`edit-${e}`,this.forms.registerForm(this.ui.modals.edit.form,{cache:!1}),this.isPopulating=!0,this.populate.populate(this.ui.modals.edit.form,t),this.isPopulating=!1,this.modals.edit.handleOpen())}openBulkEditModal(){window.removeChildren(this.ui.modals.bulkEdit.selected),this.ui.modals.edit.form.reset(),window.chunkIt(this.selected,(t=>{let s=this.store.get(parseInt(t));if(s)return e.push(s.id),window.jvbTemplates.create("bulkItem",s)}),(e=>this.ui.modals.bulkEdit.selected.append(e))).then((()=>{}));let e=Array.from(this.selected).map((e=>this.store.get(parseInt(e)))).filter(Boolean);this.ui.modals.bulkEdit.modal.dataset.itemId=e.join(","),this.ui.modals.bulkEdit.h2&&(this.ui.modals.bulkEdit.h2.textContent=this.selected.size),this.modals.bulkEdit.handleOpen(),this.forms.registerForm(this.ui.modals.bulkEdit.form,{cache:!1}),this.isPopulating=!0,this.populate.populate(this.ui.modals.edit.form,item),this.isPopulating=!1}async savePosts(e="",t=!1){this.changes.size>0&&(this.cancelBackup(),await this.handleBackup());const s=await this.changesStore.getAll();if(0===s.length)return;""===e&&(e=`Saving ${s.length} ${1===s.length?this.singular:this.plural}`);let i={},a=[];s.forEach((e=>{let t=e.id;const{id:s,...l}=e;i[t]=l,e.post_status&&this.shouldRemoveItemUI(e.post_status)&&a.push(t)})),a.length>0&&this.removeItems(a);let l={endpoint:this.endpoint,headers:{action_nonce:window.auth.getNonce("dash")},data:{posts:i},delay:t,popup:"Saving changes",title:e};this.queue.addToQueue(l)}setBulkStatus(e){if(!["publish","draft","trash","delete"].includes(e))return;let t,s=[];if(this.selected.forEach((t=>{s.push(t),this.updateItem(t,"post_status",e)})),"delete"===e)t="Deleting";else t=window.uppercaseFirst(e)+"ing";this.shouldRemoveItemUI(e)&&this.removeItems(s),this.selectionHandler.clearSelection(),this.savePosts(`${t} ${s.length} ${1===s.length?this.singular:this.plural}...`).then((()=>{}))}render(){const e=this.store.getFiltered();if(0!==e.length){switch(this.view){case"grid":this.renderGrid(e);break;case"table":this.renderTable(e).then((()=>{}));break;case"list":this.renderList(e)}this.updateUI()}else this.renderEmpty()}updateUI(){if(this.ui.bulk.action){let e=!1,t=this.ui.bulk.action.querySelector('[value="edit"]'),s=this.status;"trash"===s&&t?(window.removeChildren(this.ui.bulk.action),e=window.jvbTemplates.create("trashOptions")):"trash"===s||t||(window.removeChildren(this.ui.bulk.action),e=window.jvbTemplates.create("notTrashOptions")),e&&e.querySelectorAll("option").forEach(((e,t)=>{0===t&&(e.checked=!0),this.ui.bulk.action.append(e)})),this.ui.bulk.action.value=""}this.selected.size>0&&this.selectionHandler.updateSelectionUI()}renderEmpty(){this.toggleTable(!1),window.removeChildren(this.ui.grid);const e=window.jvbTemplates.create("emptyState");e&&(this.ui.grid.append(e),this.a11y.announceItems(0,!1,!1))}toggleTable(e=!0){if(this.ui.table.selectedColumns&&(this.ui.table.selectedColumns.hidden=!e),e&&!this.ui.table.form){let e=window.jvbTemplates.create("contentTable");this.container.append(e),this.ui.table=window.uiFromSelectors(this.selectors.table),this.ui.table.columns=this.container.querySelectorAll(this.selectors.table.columns)}this.ui.table.form&&(this.ui.table.form.hidden=!e,e||this.forms.clearForm(this.ui.table.form.dataset.formId),this.ui.table.body&&window.removeChildren(this.ui.table.body)),this.keyHandler=this.handleKeys.bind(this),e?document.addEventListener("keydown",this.keyHandler):document.removeEventListener("keydown",this.keyHandler)}renderGrid(e){window.removeChildren(this.ui.grid),this.toggleTable(!1),this.ui.grid.classList.remove("list-view"),this.ui.grid.classList.add("grid-view"),window.chunkIt(e,(e=>this.renderGridItem(e)),(e=>this.ui.grid.append(e))).then((()=>{}))}renderList(e){window.removeChildren(this.ui.grid),this.toggleTable(!1),this.ui.grid.classList.remove("grid-view"),this.ui.grid.classList.add("list-view"),window.chunkIt(e,(e=>this.renderListItem(e)),(e=>this.ui.grid.append(e))).then((()=>{}))}async renderTable(e){this.toggleTable(),window.removeChildren(this.ui.grid),await window.chunkIt(e,(e=>this.renderTableItem(e)),(e=>{this.ui.table.body?this.ui.table.body.append(e):this.ui.table.table.insertBefore(e,this.ui.table.foot)}),5),requestAnimationFrame((()=>{window.jvbSelector?.scanExistingFields(this.ui.table.table)}))}renderGridItem(e){let t=window.jvbTemplates.create("gridView",e);return this.items.set(e.id,t),t}renderListItem(e){let t=window.jvbTemplates.create("listView",e);return this.items.set(e.id,t),t}renderTableItem(e){let t=window.jvbTemplates.create("tableView",e);return this.items.set(e.id,t),t}toggleColumn(e,t){this.ui.table.table.querySelectorAll(`.${e}`).forEach((e=>{e.hidden=!t}))}shouldRemoveItemUI(e){return"all"===this.status&&!["publish","draft"].includes(e)||e!==this.store.filters.status}removeItems(e){e.forEach((e=>{if(this.items.has(e)){let t=this.items.get(e);t&&window.fade(t,!1)}}))}setFilters(e){for(let[t,s]of Object.entries(e)){if(!this.allowedFilters.includes(t)){delete e[t];continue}this.cache.set(t,s);let i=this.findFilterEl(t);this.setElValue(i,s)}this.store.setFilters(e)}setFilter(e,t){if(!this.allowedFilters.includes(e))return;this.cache.set(e,t),"status"===e&&(this.status=t),"orderby"===e&&(this.orderby=t),"order"===e&&(this.order=t);let s=this.findFilterEl(e,t);this.setElValue(s,t),this.store.setFilter(e,t)}deleteFilter(e,t){if(!this.allowedFilters.includes(e))return;if(Object.hasOwn(this.defaults,e))return void this.setFilter(e,this.defaults[e]);let s=this.findFilterEl(e,t);this.setElValue(s,!1),this.cache.remove(e),this.setFilter(e,"")}setElValue(e,t){if(e){if(!t)return["SELECT","TEXTAREA"].includes(e.tagName)&&(e.value=""),["text","search"].includes(e.type)&&(e.value=""),void("radio"===e.type&&(e.checked=!1));["SELECT","TEXTAREA"].includes(e.tagName)&&(e.value=t),["text","search"].includes(e.type)&&(e.value=t),"radio"===e.type&&(e.checked=!0)}}findFilterEl(e,t){if(["date-filter","dateFrom","dateTo"].includes(e)){switch(e){case"date-filter":e="month";break;case"dateFrom":e="start";break;case"dateTo":e="end"}return this.ui.modals.date[e]}if(e.includes("tax_")){const t=e.replace("tax_",""),s=this.ui.filters.taxonomies?.[t];return s||(console.warn("Taxonomy filter element not found:",t),null)}if(!Object.hasOwn(this.ui.filters,e))return console.warn("Filter el not found: ",e),!1;let s=this.ui.filters[e];if("object"==typeof s){if(!Object.hasOwn(this.ui.filters[e],t))return!1;s=this.ui.filters[e][t]}return s}resetForm(e){e.querySelectorAll('input[type="hidden"], input[type="text"], input[type="number"], input[type="email"], input[type="url"], textarea').forEach((e=>{e.value=""})),e.querySelectorAll('input[type="checkbox"], input[type="radio"]').forEach((e=>{e.checked=!1})),e.querySelectorAll("select").forEach((e=>{e.selectedIndex=0})),e.querySelectorAll(".selected-items").forEach((e=>{window.removeChildren(e)})),e.querySelectorAll(".item-grid.preview").forEach((e=>{window.removeChildren(e)}))}destroy(){window.debouncer.cancel(`changes-${this.content}`),this.changes.size>0&&(this.changesStore.saveMany(this.changes).then((()=>{})),this.changes.clear()),this.timelineSortables&&(this.timelineSortables.forEach((e=>e.destroy())),this.timelineSortables=[]);for(let[e,t]of Object.entries(this.ui.modals))t.form&&t.form.removeEventListener("submit",this.submitHandler);document.removeEventListener("click",this.clickHandler),document.removeEventListener("change",this.changeHandler),this.ui.filters.search&&this.ui.filters.search.removeEventListener("input",this.handleInput)}}document.addEventListener("DOMContentLoaded",(async function(){window.auth.subscribe((t=>{if("auth-loaded"===t){let t=document.querySelector("[data-content]");t&&!Object.hasOwn(t.dataset,"ignore")&&(window.crudManager=new e({content:t.dataset.content}))}}))}))})();
\ No newline at end of file
+(()=>{class e{constructor(){this.container=document.querySelector(".crud[data-content]:not([data-ignore])"),this.container&&(this.content=this.container.dataset.content,this.endpoint=this.container.dataset.endpoint??"content",this.singular=this.container.dataset.singular,this.plural=this.container.dataset.plural,this.queue=window.jvbQueue,this.a11y=window.jvbA11y,this.error=window.jvbError,this.populate=window.jvbPopulate,this.cache=new window.jvbCache(this.content),this.activeItem=null,this.isTimeline=!1,this.isPopulating=!1,this.changes=new Map,this.items=new Map,this.init())}init(){this.initElements(),this.initListeners(),this.defineTemplates();let e=this.initSettings();this.initStore(e),this.checkHideFilters(),this.initIntegrations(),this.initUploader(),this.initModals()}defineTemplates(){const e=window.jvbTemplates,t=this,s=(e,s,i)=>{e.dataset.itemId=i.id;let a=s.checkbox.closest(".preview");window.prefixInput(s.checkbox,`select-${i.id}`,a,!0),s.checkbox.value=i.id,s.checkbox.checked=t.selected.has(parseInt(i.id)),s.selectLabel&&(s.selectLabel.htmlFor=`select-${i.id}`),s.edit&&(s.edit.dataset.id=i.id),s.trash&&(s.trash.dataset.id=i.id)},i=function(e,t,s){if(s?.fields?.post_thumbnail){const e=s.images[s.fields.post_thumbnail]??{};t.img.src=e.medium??"",t.img.alt=e.alt??s.fields.post_title??""}};e.define("gridView",{refs:{img:"img",checkbox:".select-item",selectLabel:"label.select-item-label",edit:'[data-action="edit"]',trash:'[data-action="trash"]'},setup({el:e,refs:t,manyRefs:a,data:l}){s(e,t,l),i(0,t,l)}}),e.define("listView",{refs:{img:"img",checkbox:".select-item",selectLabel:"label.select-item-label",edit:'[data-action="edit"]',trash:'[data-action="trash"]'},manyRefs:{attrs:"[data-attr]",fields:"[data-field]"},setup({el:e,refs:t,manyRefs:a,data:l}){s(e,t,l),i(0,t,l),a?.attrs?.forEach((e=>{const t=l[e.dataset.attr];t&&""!==t?e.textContent=t:e.remove()})),a?.fields?.forEach((e=>{const t=l.fields?.[e.dataset.field];t&&""!==t?"DIV"===e.tagName?e.innerHTML=t:e.textContent=t:e.remove()}))}});let a={};this.isTimeline&&(a.sharedRow="tr.shared",a.point="tr.timeline-point"),e.define("tableView",{refs:{checkbox:".select-item",selectLabel:"label.select-item-label",...a},manyRefs:{inputs:"input,select,textarea",status:'input[name="post_status"]',selectors:'[data-type="selector"]',fields:"[data-field]"},setup({el:e,refs:i,manyRefs:a,data:l}){if(s(e,i,l),a?.inputs?.forEach((e=>{let t=e.closest("[data-field]");window.prefixInput(e,`${l.id}-`,t)})),a?.status?.forEach((e=>{e.value===l.status&&(e.checked=!0)})),t.isTimeline)i.sharedRow&&(i.sharedRow.querySelectorAll("input,select,textarea").forEach((e=>{let t=e.closest("[data-field]");window.prefixInput(e,`${l.id}-`,t)})),t.populate.populate(i.sharedRow,l),i.sharedRow.querySelectorAll('input[name="post_status"]').forEach((e=>{e.value===l.status&&(e.checked=!0)}))),i.point&&l.fields?.timeline&&(Object.entries(l.fields.timeline).forEach((([s,a],n)=>{const o=i.point.cloneNode(!0);o.dataset.index=`${n}`,o.dataset.itemId=a.id,o.querySelectorAll("input,select,textarea").forEach((e=>{let t=e.closest("[data-field]");window.prefixInput(e,`${a.id}-`,t)})),t.populate.populate(o,{fields:a,images:l.images,taxonomies:l.taxonomies});const d=l.images?.[a.post_thumbnail];d&&o.querySelector(".field.upload")?.setAttribute("title",d["image-title"]??""),e.insertBefore(o,i.point)})),i.point.remove());else if(void 0!==t.ui.table.form?.dataset.edit)a?.inputs?.forEach((e=>{let t=e.closest("[data-field]");window.prefixInput(e,`${l.id}-`,t)})),a?.status?.forEach((e=>{e.value===l.status&&(e.checked=!0)})),t.populate.populate(e,l);else{const e=Object.hasOwn(l,"fields")?l.fields:l;a?.fields?.forEach((t=>{if(Object.hasOwn(e,t.dataset.field)&&""!==e[t.dataset.field]){let s=e[t.dataset.field],i=e.children[0];i&&(i.textContent="date"===t.dataset.field?window.formatTimeAgo(s):s)}}))}a?.selectors?.forEach((e=>e.setAttribute("data-lazy","")))}}),e.define("emptyState"),e.define("bulkItem",{refs:{checkbox:"input",img:"img",label:"label"},setup({el:e,refs:t,manyRefs:s,data:i}){t.checkbox&&(t.checkbox.id=`bulk_${i.id}`,t.checkbox.value=i.id,t.checkbox.checked=!0,t.checkbox.name="selected[]");let a=i?.images[i?.fields?.post_thumnbail]??{};t.img&&Object.keys(a).length>0&&(t.img.src=a.medium??"",t.img.alt=a.alt??""),t.label&&(t.label.title=item.fields.post_title)}}),e.define("trashOptions"),e.define("notTrashOptions"),e.define("contentTable")}initElements(){this.allowedFilters=["status","orderby","order","search","date-filter","dateFrom","dateTo"],this.selectors={buttons:{create:".create-item",clearFilters:'[data-action="clear-filters"]'},views:{grid:'input[data-view="grid"]',list:'input[data-view="list"]',table:'input[data-view="table"]'},modals:{create:{modal:"dialog.create",form:"dialog.create form",h2:"dialog.create h2"},edit:{modal:"dialog.edit",form:"dialog.edit form",h2:"dialog.edit h2"},bulkEdit:{modal:"dialog.bulkEdit",selected:"dialog.bulkEdit .selected",h2:"dialog.bulkEdit h2 span",form:"dialog.bulkEdit form"},date:{modal:"dialog.date-range",start:"dialog.date-range .date-start",end:"dialog.date-range .date-end",month:"dialog.date-range .month-select"}},grid:`.${this.content}.item-grid`,table:{nav:"#vertical",form:"form.table",table:"form.table table",body:"form.table body",head:"form.table thead",foot:"form.table tfoot",selectedColumns:".all-filters .multi-select",columns:"thead th"},bulk:{action:".bulk-action-select",count:".bulk-controls .selected-count",control:".bulk-controls .bulk-actions",select:".bulk-controls select",selectAll:".select-all"},filters:{container:"details.all-filters",search:'.all-filters input[type="search"]',status:{all:'[name="status"]#all',publish:'[name="status"]#publish',draft:'[name="status"]#draft',trash:'[name="status"]#trash'},orderby:{date:'[name="orderby"]#date',alphabetical:'[name="orderby"]#alphabetical'},order:{asc:'[name="order"][value="asc"]',desc:'[name="order"][value="desc"]'},date:'[data-filter="date"]'},uploader:"details.uploader"},this.ui=window.uiFromSelectors(this.selectors);const e=document.querySelectorAll('[data-filter="taxonomies"]');e.length>0&&(this.ui.filters.taxonomies={},e.forEach((e=>{const t=e.dataset.taxonomy;this.ui.filters.taxonomies[t]=e,this.allowedFilters.push(`tax_${t}`)}))),this.isTimeline=!!document.querySelector("[data-timeline]")}initUploader(){this.ui.uploader&&(window.jvbUploads.scanFields(this.ui.uploader),window.jvbUploads.subscribe(((e,t)=>{"sent-to-queue"===e&&t===this.ui.uploader.dataset.uploader&&window.debouncer.schedule("crud-complete",(()=>{this.store.clearCache()}))})))}initModals(){this.modals={};for(let[e,t]of Object.entries(this.ui.modals))t.modal&&(this.modals[e]=new window.jvbModal(t.modal),this.modals[e].subscribe(((t,s)=>{if("modal-close"===t){const t=this.ui.modals[e].form.dataset.formId;t&&this.forms.clearForm(t),this.resetForm(this.ui.modals[e].form),"date"===e&&this.handleCustomDateSelection(),["edit","bulkEdit","create"].includes(e)&&window.debouncer.timeouts.has(`save-${this.content}`)&&this.scheduleSave(0)}})))}initStore(e){let t={...this.defaults,...e};const s=window.jvbStore.register(this.content,[{storeName:this.content,keyPath:"id",endpoint:this.endpoint??"content",headers:{action_nonce:window.auth.getNonce("dash")},indexes:[{name:"id",keyPath:"id"},{name:"status",keyPath:"status"},{name:"date",keyPath:"date"},{name:"modified",keyPath:"modified"},{name:"title",keyPath:"title"}],filters:t,ignore:["content","user"],TTL:36e5,showLoading:!0},{storeName:"changes",keyPath:"id"}]);this.changesStore=s.changes,this.store=s[this.content],this.store.subscribe(((e,t)=>{if("data-loaded"===e)this.render(),this.selectionHandler.collectItems()})),this.changesStore.subscribe(((e,t)=>{if("data-ready"===e){let e=this.changesStore.getAll();e.length>0&&(e.forEach((e=>{this.changes.set(e.id,e)})),this.savePosts("",!1).then((()=>{})))}}))}initIntegrations(){this.selected=new Set,this.selectionHandler=new window.jvbHandleSelection(this.container,{selectAll:{checkbox:"#select-all",label:".bulk-select label",span:".bulk-select label span"},wrapper:{wrapper:".wrap"},item:{idAttribute:"itemId"}}),this.selectionHandler.subscribe(((e,t)=>{this.selected=new Set([...t.selectedItems].map((e=>parseInt(e)))),this.ui.bulk.control.hidden=0===this.selected.size,this.ui.bulk.count.hidden=0===this.selected.size,this.ui.bulk.count.textContent=`${this.selected.size} ${this.plural} selected`})),this.forms=window.jvbForm,this.queue.subscribe(((e,t)=>{if(["image_upload","video_upload","document_upload"].includes(t.type)&&"operation-status"===e&&"completed"===t.status&&this.store.clearCache(),"operation-status"===e&&"completed"===t.status&&"uploads/groups"===t.endpoint&&(console.log("Cleared local cache. Refresh to see changes"),this.store.clearCache()),"operation-status"===e&&"completed"===t.status&&"content_update"===t.type){if(console.log("Cleared local cache. Refresh to see changes"),this.store.clearCache(),!t.result||!t.result.posts)return void console.warn("Content update completed but no result.posts",t);const e=Object.keys(t.result.posts).filter((e=>!0===t.result.posts[e]?.success));if(0===e.length)return;this.changesStore.deleteMany(e),e.forEach((e=>this.changes.delete(e)))}}))}initSettings(){this.defaults={content:this.content,user:window.auth.getUser(),page:1,status:"all",orderby:"date",order:"desc",search:""};let e={},t=this.container.dataset.view??"grid";this.view=this.cache.get("view")??t,this.view!==t&&(this.ui.views[this.view].checked=!0),this.status=this.cache.get("status")??this.defaults.status,this.status!==this.defaults.status&&(this.ui.filters.status[this.status].checked=!0,e.status=this.status),this.orderby=this.cache.get("orderby")??this.defaults.orderby,this.orderby!==this.defaults.orderby&&(this.ui.filters.orderby[this.orderby].checked=!0,e.orderBy=this.orderby),this.order=this.cache.get("order")??this.defaults.order,this.order!==this.defaults.order&&(this.ui.filters.order[this.order].checked=!0,e.order=this.order),this.ui.filters.taxonomies&&Object.entries(this.ui.filters.taxonomies).forEach((([t,s])=>{const i=`tax_${t}`,a=this.cache.get(i);a&&(s.value=a,e[i]=a)}));let s=this.cache.get("tabNav")??"horizontal";this.ui.table.nav&&"vertical"===s&&(this.ui.table.nav.checked=!0);let i={showFilters:{element:this.ui.filters.container,default:"closed"},showUploader:{element:this.ui.uploader,default:"open"}};for(let[e,t]of Object.entries(i))if(t.element){let s=this.cache.get(e)??t.default;t.element.open="open"===s,t.element.addEventListener("toggle",(()=>{this.cache.set(e,t.element.open?"open":"closed")}))}return e}initListeners(){this.changeHandler=this.handleChange.bind(this),this.clickHandler=this.handleClick.bind(this),this.inputHandler=this.handleInput.bind(this),this.submitHandler=this.handleModalSubmit.bind(this),document.addEventListener("change",this.changeHandler),document.addEventListener("click",this.clickHandler),this.ui.filters.search&&this.ui.filters.search.addEventListener("input",this.inputHandler);for(let[e,t]of Object.entries(this.ui.modals))t.form&&t.form.addEventListener("submit",this.submitHandler)}handleModalSubmit(e){e.preventDefault();const t=e.target.closest("dialog");if(!t)return;let s=`Saving changes for multiple ${this.plural}`;t.classList.contains("edit")?s="Saving your edits...":t.classList.contains("create")&&(s=`Creating your new ${this.singular}`),this.scheduleSave(0)}handleChange(e){const t=e.target.closest("[data-item-id]"),s=e.target.matches("[data-filter]"),i=e.target.matches(".bulk-action-select"),a=e.target.matches("[data-view]");if(t||s||i||a)if(this.isPopulating||!t||e.target.closest("[data-ignore], .select-item")){if(a)return this.items.clear(),void this.handleViewChange(e.target);if(i)this.handleBulkAction(e.target);else if(s)this.handleFilterChange(e.target);else if("table"===this.view){if(e.target.matches("details.multi-select"))return void this.toggleColumn(e.target.id,e.target.checked);e.target.matches(this.selectors.table.nav)&&(this.tabNav=e.target.checked,this.cache.set("tabNav",e.target.checked?"vertical":"horizontal"))}}else this.handleItemUpdate(e)}handleBulkAction(e){if(e.value.startsWith("tax-")){const t=e.options[e.selectedIndex],s=t.dataset.taxonomy,i=t.dataset.single,a=t.dataset.plural;return window.jvbSelector.openEmpty(s,i,a,(e=>this.handleBulkTaxonomy(e))),void(e.value="")}switch(e.value){case"edit":this.openBulkEditModal();break;case"publish":case"trash":case"delete":this.setBulkStatus(e.value);break;case"draft":case"restore":this.setBulkStatus("draft")}}handleBulkTaxonomy(e){e.termIds.length&&this.selected.size&&(this.selected.forEach((t=>{const s=this.store.get(t);if(!s)return;const i=(s.taxonomies?.[e.taxonomy]||[]).map((e=>e.id)),a=[...new Set([...i,...e.termIds])];this.updateItem(t,e.taxonomy,a)})),this.savePosts(`Adding ${e.terms.length} ${e.taxonomy} to ${this.selected.size} ${this.plural}...`).then((()=>{})),this.selectionHandler.clearSelection())}handleItemUpdate(e){let t=window.targetCheck(e,"[data-item-id]");t&&t.dataset.itemId.split(",").forEach((t=>{let s=this.forms.getField(e.target);if(["repeater","tag-list"].includes(s.dataset.fieldType))return;let i=s.dataset.field,a=this.forms.getFieldValue(e.target);this.updateItem(t,i,a)}))}updateItem(e,t,s){this.changes.has(e)||this.changes.set(e,{id:e,content:this.content}),this.changes.get(e)[t]=s,this.scheduleBackup(),this.scheduleSave()}scheduleBackup(){window.debouncer.schedule(`changes-${this.content}`,(async()=>{this.changes.size>0&&await this.handleBackup()}),2e3)}cancelBackup(){window.debouncer.cancel(`changes-${this.content}`)}async handleBackup(){const e=Array.from(this.changes.values());this.changes.clear();const t=e.map((e=>e.id)),s=await Promise.all(t.map((e=>this.changesStore.get(e)))),i=e.map(((e,t)=>s[t]?window.deepMerge(s[t],e):e));await this.changesStore.saveMany(i)}scheduleSave(e=1e4){window.debouncer.schedule(`save-${this.content}`,(async()=>{this.changes.size>0&&(this.cancelBackup(),await this.handleBackup()),await this.savePosts("",!1)}),e)}handleFilterChange(e){let t=e.dataset.filter;return"date"===t&&"custom"===e.value?(e.value="",void this.modals.date.handleOpen()):"date"===t&&""!==e.value?(this.setFilter("date-filter",e.value),this.deleteFilter("dateFrom"),this.deleteFilter("dateTo"),void this.checkHideFilters()):("taxonomies"===t&&(t=`tax_${e.dataset.taxonomy}`),void this.setFilter(t,e.value))}checkHideFilters(){const e=this.store.filters,t=Object.entries(e).some((([e,t])=>!["content","user","page"].includes(e)&&(this.defaults[e]!==t&&""!==t&&null!==t)));this.ui.buttons.clearFilters.hidden=!t}clearAllFilters(){let e=this.store.filters;this.store.clearFilters();for(let[t,s]of Object.entries(e))this.cache.remove(t),this.deleteFilter(t,s);this.a11y.announce("All filters cleared")}handleCustomDateSelection(){if(this.ui.modals.date.month&&this.ui.modals.date.month.value){const[e,t]=this.ui.modals.date.month.value.split("-"),s=`${e}-${t}-01`,i=new Date(e,parseInt(t),0).getDate(),a=`${e}-${t}-${String(i).padStart(2,"0")}`;this.setFilter("dateFrom",s),this.setFilter("dateTo",a),this.deleteFilter("date-filter"),this.ui.modals.date.month.value=""}else this.ui.modals.date.start&&this.ui.modals.date.start.value&&this.ui.modals.date.end&&this.ui.modals.date.end.value&&(this.setFilter("dateFrom",this.ui.modals.date.start.value),this.setFilter("dateTo",this.ui.modals.date.end.value),this.deleteFilter("date-filter"),this.ui.modals.date.start.value="",this.ui.modals.date.end.value="");this.checkHideFilters()}handleViewChange(e){this.view=e.dataset.view,this.cache.set("view",this.view),this.render()}handleClick(e){if(e.target.matches(".clear-search"))return void this.deleteFilter("search","");const t=e.target.closest("[data-action]");return t?(e.preventDefault(),void this.handleActionButton(t)):e.target.matches(".apply-date-filter")?(this.handleCustomDateSelection(),void this.modals.date.handleClose()):void(e.target.matches(this.selectors.buttons.create)&&this.openCreateModal())}openCreateModal(){this.forms.registerForm(this.ui.modals.create.form,{cache:!1}),this.ui.modals.create.modal.dataset.itemId=window.generateID("new"),this.modals.create.handleOpen()}handleActionButton(e){const t=e.dataset.id;switch(e.dataset.action){case"edit":this.openEditModal(t);break;case"delete":confirm("Delete this item? This cannot be undone")&&(this.updateItem(t,"post_status","delete"),window.fade(e.closest(".item"),!1),this.savePosts(`Permanently deleting ${this.singular}...`).then((()=>{})),this.store.delete(t));break;case"trash":"trash"===this.status?confirm("Delete this item? This cannot be undone")&&(this.updateItem(t,"post_status","delete"),window.fade(e.closest(".item"),!1),this.savePosts(`Permanently deleting ${this.singular}...`).then((()=>{})),this.store.delete(t)):(this.updateItem(t,"post_status","trash"),window.fade(e.closest(".item"),!1),this.savePosts(`Sending ${this.singular} to trash...`).then((()=>{})));break;case"bulk-edit":this.selected.size>0&&this.openBulkEditModal();break;case"bulk-delete":this.handleBulkDelete();break;case"refresh":this.store.clearCache(),this.store.fetch();break;case"clear-filters":this.clearAllFilters()}}handleBulkDelete(){let e="trash"===this.status;if(this.selected.size>0&&confirm(`${e?"Permanently delete":"Send"} ${this.selected.size} ${1===this.selected.size?this.singular:this.plural}${e?"":"to trash"}?`)){this.selected.forEach((t=>{this.store.delete(t),this.updateItem(t,"post_status",e?"delete":"trash")}));let t=e?`Permanently deleting ${this.selected.size} ${1===this.selected.size?this.singular:this.plural}`:`Sending ${this.selected.size} ${1===this.selected.size?this.singular:this.plural} to trash`;this.savePosts(t).then((()=>{})),this.selectionHandler.clearSelection()}}handleInput(e){e.preventDefault(),e.stopPropagation();let t=e.target.value.trim(),s=`${this.content}-search`;0!==t.length?window.debouncer.schedule(s,(()=>{this.a11y.announce(`Searching for "${t}"...`),this.store.setFilters({search:t,page:1})}),300):this.deleteFilter("search","")}handleKeys(e){if(this.tabNav&&"Tab"===e.key){e.preventDefault();const t=e.target.closest("[data-field]"),s=e.target.closest("tr");if(!t||!s)return;const i=t.dataset.field,a=e.shiftKey;let l=this.findNextEditableRow(s,a);l||(l=this.wrapToRow(s,a)),l&&this.focusFieldInRow(l,i,a)}}findNextEditableRow(e,t=!1){let s=t?e.previousElementSibling:e.nextElementSibling;for(;s&&!this.isEditableRow(s);)s=t?s.previousElementSibling:s.nextElementSibling;return s}wrapToRow(e,t=!1){if(this.isTimeline){const s=e.closest("tbody");if(!s)return null;const i=Array.from(s.querySelectorAll("tr")).filter((e=>this.isEditableRow(e)));return t?i[i.length-1]:i[0]}{if(!this.ui.table.body)return null;const e=Array.from(this.ui.table.body.querySelectorAll("tr")).filter((e=>this.isEditableRow(e)));return t?e[e.length-1]:e[0]}}isEditableRow(e){return!e.closest("thead")&&!e.closest("tfoot")&&(this.isTimeline?e.classList.contains("shared")||e.classList.contains("timeline-point"):!!e.dataset.itemId)}focusFieldInRow(e,t,s=!1){const i=e.querySelector(`[data-field="${t}"]`);if(!i)return;const a=this.findFocusableInput(i);if(a){a.focus(),a.select&&"text"===a.type&&a.select();const e=s?"next":"previous";this.a11y?.announce(`Moved to ${t} in ${e} row`)}}findFocusableInput(e){const t=['input:not([type="hidden"]):not([disabled])',"textarea:not([disabled])","select:not([disabled])","button:not([disabled])"];for(const s of t){const t=e.querySelector(s);if(t)return t}return null}openEditModal(e){let t=this.store.get(parseInt(e));t&&(this.activeItem=t.id,this.ui.modals.edit.modal.dataset.itemId=e,this.ui.modals.edit.modal.dataset.content=this.content,this.ui.modals.edit.h2.textContent=`Editing ${""===t.fields.post_title?this.singular:t.fields.post_title}`,this.ui.modals.edit.form.dataset.formId=`edit-${e}`,this.forms.registerForm(this.ui.modals.edit.form,{cache:!1}),this.isPopulating=!0,this.populate.populate(this.ui.modals.edit.form,t),this.isPopulating=!1,this.modals.edit.handleOpen())}openBulkEditModal(){window.removeChildren(this.ui.modals.bulkEdit.selected),this.ui.modals.edit.form.reset(),window.chunkIt(this.selected,(t=>{let s=this.store.get(parseInt(t));if(s)return e.push(s.id),window.jvbTemplates.create("bulkItem",s)}),(e=>this.ui.modals.bulkEdit.selected.append(e))).then((()=>{}));let e=Array.from(this.selected).map((e=>this.store.get(parseInt(e)))).filter(Boolean);this.ui.modals.bulkEdit.modal.dataset.itemId=e.join(","),this.ui.modals.bulkEdit.h2&&(this.ui.modals.bulkEdit.h2.textContent=this.selected.size),this.modals.bulkEdit.handleOpen(),this.forms.registerForm(this.ui.modals.bulkEdit.form,{cache:!1}),this.isPopulating=!0,this.populate.populate(this.ui.modals.edit.form,item),this.isPopulating=!1}async savePosts(e="",t=!1){this.changes.size>0&&(this.cancelBackup(),await this.handleBackup());const s=await this.changesStore.getAll();if(console.log("Saving Changes: ",s),0===s.length)return;""===e&&(e=`Saving ${s.length} ${1===s.length?this.singular:this.plural}`);let i={},a=[];s.forEach((e=>{let t=e.id;const{id:s,...l}=e;i[t]=l,e.post_status&&this.shouldRemoveItemUI(e.post_status)&&a.push(t)})),a.length>0&&this.removeItems(a);let l={endpoint:this.endpoint,headers:{action_nonce:window.auth.getNonce("dash")},data:{posts:i},delay:t,popup:"Saving changes",title:e};this.queue.addToQueue(l)}setBulkStatus(e){if(!["publish","draft","trash","delete"].includes(e))return;let t,s=[];if(this.selected.forEach((t=>{s.push(t),this.updateItem(t,"post_status",e)})),"delete"===e)t="Deleting";else t=window.uppercaseFirst(e)+"ing";this.shouldRemoveItemUI(e)&&this.removeItems(s),this.selectionHandler.clearSelection(),this.savePosts(`${t} ${s.length} ${1===s.length?this.singular:this.plural}...`).then((()=>{}))}render(){const e=this.store.getFiltered();if(0!==e.length){switch(this.view){case"grid":this.renderGrid(e);break;case"table":this.renderTable(e).then((()=>{}));break;case"list":this.renderList(e)}this.updateUI()}else this.renderEmpty()}updateUI(){if(this.ui.bulk.action){let e=!1,t=this.ui.bulk.action.querySelector('[value="edit"]'),s=this.status;"trash"===s&&t?(window.removeChildren(this.ui.bulk.action),e=window.jvbTemplates.create("trashOptions")):"trash"===s||t||(window.removeChildren(this.ui.bulk.action),e=window.jvbTemplates.create("notTrashOptions")),e&&e.querySelectorAll("option").forEach(((e,t)=>{0===t&&(e.checked=!0),this.ui.bulk.action.append(e)})),this.ui.bulk.action.value=""}this.selected.size>0&&this.selectionHandler.updateSelectionUI()}renderEmpty(){this.toggleTable(!1),window.removeChildren(this.ui.grid);const e=window.jvbTemplates.create("emptyState");e&&(this.ui.grid.append(e),this.a11y.announceItems(0,!1,!1))}toggleTable(e=!0){if(this.ui.table.selectedColumns&&(this.ui.table.selectedColumns.hidden=!e),e&&!this.ui.table.form){let e=window.jvbTemplates.create("contentTable");this.container.append(e),this.ui.table=window.uiFromSelectors(this.selectors.table),this.ui.table.columns=this.container.querySelectorAll(this.selectors.table.columns)}this.ui.table.form&&(this.ui.table.form.hidden=!e,e||this.forms.clearForm(this.ui.table.form.dataset.formId),this.ui.table.body&&window.removeChildren(this.ui.table.body)),this.keyHandler=this.handleKeys.bind(this),e?document.addEventListener("keydown",this.keyHandler):document.removeEventListener("keydown",this.keyHandler)}renderGrid(e){window.removeChildren(this.ui.grid),this.toggleTable(!1),this.ui.grid.classList.remove("list-view"),this.ui.grid.classList.add("grid-view"),window.chunkIt(e,(e=>this.renderGridItem(e)),(e=>this.ui.grid.append(e))).then((()=>{}))}renderList(e){window.removeChildren(this.ui.grid),this.toggleTable(!1),this.ui.grid.classList.remove("grid-view"),this.ui.grid.classList.add("list-view"),window.chunkIt(e,(e=>this.renderListItem(e)),(e=>this.ui.grid.append(e))).then((()=>{}))}async renderTable(e){this.toggleTable(),window.removeChildren(this.ui.grid),await window.chunkIt(e,(e=>this.renderTableItem(e)),(e=>{this.ui.table.body?this.ui.table.body.append(e):this.ui.table.table.insertBefore(e,this.ui.table.foot)}),5),requestAnimationFrame((()=>{window.jvbSelector?.scanExistingFields(this.ui.table.table)}))}renderGridItem(e){let t=window.jvbTemplates.create("gridView",e);return this.items.set(e.id,t),t}renderListItem(e){let t=window.jvbTemplates.create("listView",e);return this.items.set(e.id,t),t}renderTableItem(e){let t=window.jvbTemplates.create("tableView",e);return this.items.set(e.id,t),t}toggleColumn(e,t){this.ui.table.table.querySelectorAll(`.${e}`).forEach((e=>{e.hidden=!t}))}shouldRemoveItemUI(e){return"all"===this.status&&!["publish","draft"].includes(e)||e!==this.store.filters.status}removeItems(e){e.forEach((e=>{if(this.items.has(e)){let t=this.items.get(e);t&&window.fade(t,!1)}}))}setFilters(e){for(let[t,s]of Object.entries(e)){if(!this.allowedFilters.includes(t)){delete e[t];continue}this.cache.set(t,s);let i=this.findFilterEl(t);this.setElValue(i,s)}this.store.setFilters(e)}setFilter(e,t){if(!this.allowedFilters.includes(e))return;this.cache.set(e,t),"status"===e&&(this.status=t),"orderby"===e&&(this.orderby=t),"order"===e&&(this.order=t);let s=this.findFilterEl(e,t);this.setElValue(s,t),this.store.setFilter(e,t)}deleteFilter(e,t){if(!this.allowedFilters.includes(e))return;if(Object.hasOwn(this.defaults,e))return void this.setFilter(e,this.defaults[e]);let s=this.findFilterEl(e,t);this.setElValue(s,!1),this.cache.remove(e),this.setFilter(e,"")}setElValue(e,t){if(e){if(!t)return["SELECT","TEXTAREA"].includes(e.tagName)&&(e.value=""),["text","search"].includes(e.type)&&(e.value=""),void("radio"===e.type&&(e.checked=!1));["SELECT","TEXTAREA"].includes(e.tagName)&&(e.value=t),["text","search"].includes(e.type)&&(e.value=t),"radio"===e.type&&(e.checked=!0)}}findFilterEl(e,t){if(["date-filter","dateFrom","dateTo"].includes(e)){switch(e){case"date-filter":e="month";break;case"dateFrom":e="start";break;case"dateTo":e="end"}return this.ui.modals.date[e]}if(e.includes("tax_")){const t=e.replace("tax_",""),s=this.ui.filters.taxonomies?.[t];return s||(console.warn("Taxonomy filter element not found:",t),null)}if(!Object.hasOwn(this.ui.filters,e))return console.warn("Filter el not found: ",e),!1;let s=this.ui.filters[e];if("object"==typeof s){if(!Object.hasOwn(this.ui.filters[e],t))return!1;s=this.ui.filters[e][t]}return s}resetForm(e){e.querySelectorAll('input[type="hidden"], input[type="text"], input[type="number"], input[type="email"], input[type="url"], textarea').forEach((e=>{e.value=""})),e.querySelectorAll('input[type="checkbox"], input[type="radio"]').forEach((e=>{e.checked=!1})),e.querySelectorAll("select").forEach((e=>{e.selectedIndex=0})),e.querySelectorAll(".selected-items").forEach((e=>{window.removeChildren(e)})),e.querySelectorAll(".item-grid.preview").forEach((e=>{window.removeChildren(e)}))}destroy(){window.debouncer.cancel(`changes-${this.content}`),this.changes.size>0&&(this.changesStore.saveMany(this.changes).then((()=>{})),this.changes.clear()),this.timelineSortables&&(this.timelineSortables.forEach((e=>e.destroy())),this.timelineSortables=[]);for(let[e,t]of Object.entries(this.ui.modals))t.form&&t.form.removeEventListener("submit",this.submitHandler);document.removeEventListener("click",this.clickHandler),document.removeEventListener("change",this.changeHandler),this.ui.filters.search&&this.ui.filters.search.removeEventListener("input",this.handleInput)}}document.addEventListener("DOMContentLoaded",(async function(){window.auth.subscribe((t=>{if("auth-loaded"===t){let t=document.querySelector("[data-content]");t&&!Object.hasOwn(t.dataset,"ignore")&&(window.crudManager=new e({content:t.dataset.content}))}}))}))})();
\ No newline at end of file
diff --git a/assets/js/min/form.min.js b/assets/js/min/form.min.js
index 7789077..7a14b09 100644
--- a/assets/js/min/form.min.js
+++ b/assets/js/min/form.min.js
@@ -1 +1 @@
-(()=>{class e{constructor(){this.a11y=window.jvbA11y,this.error=window.jvbError,this.queue=window.jvbQueue,this.populate=window.jvbPopulate,this.changes=new Map,this.forms=new Map,this.inputs=new Map,this.repeaters=new Map,this.tagLists=new Map,this.charLimits=new Map,this.quantityFields=new Map,this.quillInstances=new Map,this.dependencies=new Map,this.subscribers=new Set,this.isRestoring=!1,this.hasListeners=!1,this.summaryTemplate=!1,this.init()}init(){this.templates=window.jvbTemplates,this.defineSummaryTemplate(),this.initElements(),this.initListeners(),this.initStore(),this.initValidators()}initElements(){this.inputSelectors="input, textarea, select",this.selectors={tabs:{nav:"nav.tabs",sections:".tab.content",progress:{progress:".progress",fill:".progress .fill",details:".progress .details",icon:".progress .icon"},buttons:"nav.tabs button"},dependsOn:"[data-depends-on]",forms:{status:{status:".fstatus",message:".fstatus .message",icon:".fstatus .icon",actions:".fstatus .actions"}},inputs:this.inputSelectors,fields:{field:".field",label:"label",success:".success",error:".success",message:".validation-message"},repeater:{repeater:".repeater",header:".repeater-row-header",remove:".remove-row",add:".add-repeater-row",template:"template",items:".repeater-items",inputs:this.inputSelectors},tagList:{tagList:".field.tag-list",input:".tag-input-row",add:".add-tag",remove:".remove-tag",label:".tag-label",items:".tag-items",inputs:this.inputSelectors,value:'input[type="hidden"]'},tag:{label:".tag-label"},number:{number:".field div.quantity",increase:"button.increase",decrease:"button.decrease",input:'input[type="number"]'},limits:{hasLimit:"[data-limit]",limit:".limit",current:".current"}}}initListeners(){this.clickHandler=this.handleClick.bind(this),this.changeHandler=this.handleChange.bind(this),this.blurHandler=this.handleBlur.bind(this),this.inputHandler=this.handleInput.bind(this),this.submitHandler=this.handleSubmit.bind(this),this.quantityClick=this.handleQuantityClick.bind(this),this.repeaterClick=this.handleRepeaterClick.bind(this),this.tagListClick=this.handleTagListClick.bind(this),this.tagListInput=this.handleTagListInput.bind(this)}addFormListeners(e){e.addEventListener("click",this.clickHandler),e.addEventListener("change",this.changeHandler),e.addEventListener("input",this.inputHandler),e.addEventListener("blur",this.blurHandler),e.addEventListener("submit",this.submitHandler)}removeFormListeners(e){e.removeEventListener("click",this.clickHandler),e.removeEventListener("change",this.changeHandler),e.removeEventListener("input",this.inputHandler),e.removeEventListener("blur",this.blurHandler),e.removeEventListener("submit",this.submitHandler)}initStore(){const e=window.jvbStore.register("forms",{storeName:"forms",keyPath:"id",indexes:[{name:"src",keyPath:"src"},{name:"timestamp",keyPath:"timestamp"},{name:"formType",keyPath:"type"}],TTL:1008e4});this.store=e.forms,this.store.subscribe(((e,t)=>{if("data-ready"===e){let e=this.store.getFiltered().filter((e=>e.src===window.location.pathname));for(let t of e)this.showPendingNotification(t.id,t.changes)}else"operation-status"===e&&"completed"===t.status&&t.config&&this.store.delete(t.config.id)}))}showPendingNotification(e,t){let s=this.forms.get(e);if(!s)return;let i=s.element;if(!i)return void console.warn(`Form element not found for: ${e}`);const a=document.createElement("div");a.className="pendingChanges",a.innerHTML=`\n\t\t\t<p>We noticed unsaved changes from last time. Would you like to restore them?</p>\n <button class="restore" type="button" data-form-id="${e}">Restore</button>\n <button class="discard" type="button" data-form-id="${e}">Discard</button>`,i.insertBefore(a,s.ui.status.status),a.querySelector(".restore").addEventListener("click",(async()=>{this.isRestoring=!0;let e={fields:t};this.populate.populate(i,e),this.a11y.announce("Previous changes restored"),this.isRestoring=!1,a.remove()})),a.querySelector(".discard").addEventListener("click",(async()=>{await this.store.delete(e),this.a11y.announce("Previous changes discarded"),a.remove()}))}initValidators(){this.validators={email:{pattern:/^[^\s@]+@[^\s@]+\.[^\s@]+$/,message:"Please enter a valid email address"},url:{pattern:/^https?:\/\/.+\..+/,message:"Please enter a valid URL starting with https://"},phone:{pattern:/^[\d\s\-+().]+$/,message:"Please enter a valid phone number"},number:{test:(e,t)=>{const s=parseFloat(e);if(isNaN(s))return"Please enter a valid number";const i=t.dataset.min,a=t.dataset.max;return void 0!==i&&s<parseFloat(i)?`Value must be at least ${i}`:!(void 0!==a&&s>parseFloat(a))||`Value must be at most ${a}`}},text:{test:(e,t)=>{const s=t.dataset.minlength,i=t.dataset.maxlength;return s&&e.length<parseInt(s)?`Must be at least ${s} characters`:!(i&&e.length>parseInt(i))||`Must be no more than ${i} characters`}}}}validateField(e){const t=this.performValidation(e);return this.updateValidationUI(e,t),t.isValid}performValidation(e){const t=e.closest(".field"),s=this.getFieldCheckedValue(e);if(!s&&!e.required)return{isValid:!0,message:""};if(e.required)if("checkbox"===e.type){if(!e.checked)return{isValid:!1,message:"This field is required"}}else if("radio"===e.type){const t=document.querySelectorAll(`input[name="${e.name}"]`);if(!Array.from(t).some((e=>e.checked)))return{isValid:!1,message:"Please select an option"}}else if(!s)return{isValid:!1,message:"This field is required"};if(e.checkValidity&&!e.checkValidity())return{isValid:!1,message:e.validationMessage};if(s&&Object.hasOwn(t.dataset,"pattern")){if(!new RegExp(t.dataset.pattern).test(s))return{isValid:!1,message:t.dataset.validationMessage||"Invalid format"}}if(Object.hasOwn(t.dataset,"validate")||e.type){const i=this.validators[t.dataset.validate||e.type];if(i&&i.pattern&&!i.pattern.test(s))return{isValid:!1,message:i.message};if(i&&i.test){const e=i.test(s,t);if(!0!==e)return{isValid:!1,message:e}}}return{isValid:!0,message:""}}updateValidationUI(e,t){t.isValid?this.showSuccess(e,t.message):this.showError(e,t.message)}handleClick(e){let t=this.getForm(e.target);if(!t)return;const s=window.targetCheck(e,"[data-action]");if(s){switch(s.dataset.action){case"clear-form":this.store.delete(t.id),t.element.reset(),t.ui.status.status.hidden=!0,this.a11y.announce("Form cleared, starting fresh");break;case"dismiss-restore":t.ui.status.status.hidden=!0}}}handleChange(e){if(e.target.closest("[data-ignore]")||this.isRestoring)return;let t=this.getField(e.target);if(this.dependencies.has(t.dataset.field)){this.dependencies.get(t.dataset.field).items.forEach((e=>{this.checkFieldDependency(e,t.dataset.field)}))}let s=this.getForm(e.target);this.updateItem(t.dataset.field,this.getFieldValue(e.target),s)}handleBlur(e){if(e.target.closest("[data-ignore]")||this.isRestoring)return;let t=this.getForm(e.target);if(!t)return;let s=this.getField(e.target).dataset.field;window.debouncer.cancel(`form:${t.id}:validate:${s}`),this.validateField(e.target),this.updateItem(s,this.getFieldValue(e.target),t)}handleInput(e){let t=this.getForm(e.target);if(!t)return;let s=this.getField(e.target);if(!s)return;const i=e.target,a=s.dataset.field;this.showFormStatus(t.id,"pending"),window.debouncer.schedule(`form:${t.id}:validate:${a}`,(()=>this.validateField(i)),500)}async handleSubmit(e){let t=this.getForm(e.target);if(t){if(this.subscribers.size>0)if(e.preventDefault(),console.log("Cancelling scheduled backup and manually backing up"),t.options.cache){this.cancelBackup(),await this.backup();const e=await this.store.get(t.id);this.notify("form-submit",{config:t,data:e.changes})}else this.notify("form-submit",{config:t,data:this.changes.get(t.id)?.changes??{}});if(t.options.showSummary){const e=await this.store.get(t.id);this.showSummary({config:t,changes:e?.changes})}}}updateItem(e,t,s){this.changes.has(s.id)||this.changes.set(s.id,{id:s.id,timestamp:Date.now(),src:window.location.pathname,changes:{}});let i=this.changes.get(s.id);i.changes[e]=t,this.changes.set(s.id,i),s.options.cache&&this.scheduleBackup()}scheduleBackup(){window.debouncer.schedule("form_changes",(async()=>{this.changes.size>0&&await this.backup()}),2e3)}cancelBackup(){window.debouncer.cancel("form_changes")}async backup(){const e=new Map;for(let[t,s]of this.changes.entries()){const i=await this.store.get(t);i?e.set(t,{...i,...s,changes:{...i.changes,...s.changes},timestamp:Date.now()}):e.set(t,s)}await this.store.saveMany(e);for(let e of this.changes.keys())this.showFormStatus(e,"autosaved");this.changes.clear()}saveCache(e){if(!this.changes.has(e))return;let t=this.changes.get(e);0!==t.size&&(this.store.save(t).then((()=>{})),this.changes.delete(e))}registerForm(e,t){if(Object.hasOwn(e.dataset,"formId")&&this.forms.has(e.dataset.formId))return;Object.hasOwn(e.dataset,"formId")||(e.dataset.formId=window.generateID("form_"));const s=e.dataset.formId;this.addFormListeners(e);const i={element:e,id:s,status:"",options:{autoUpload:t.autoUpload??!1,imageMeta:t.imageMeta??!0,delay:t.delay??1500,endpoint:t.save??e.dataset.save??"",showStatus:t.showStatus??!0,showSummary:t.showSummary??!1,cache:t.cache??!0,ignore:t.ignore??[]},ui:window.uiFromSelectors(this.selectors.forms,e)};return this.initializeFields(e,i),this.forms.set(s,i),i}clearForm(e){const t=this.forms.get(e);if(!t)return;t.unsubscribeTabs&&t.unsubscribeTabs(),t.tabs&&window.jvbTabs.removeTab(t.element),t.cache&&this.changes.has(e)&&this.saveCache(e);for(let[t,s]of this.inputs.entries())s.form===e&&this.inputs.delete(t);if(this.dependencies.forEach(((t,s)=>{t.items=t.items.filter((t=>t.form!==e)),0===t.items.length&&this.dependencies.delete(s)})),Object.hasOwn(t,"hasQuill")&&this.quillInstances.has(e)){this.quillInstances.get(e).forEach((e=>{e.disable(),e.off("text-change"),e.off("selection-change");const t=e.container.parentElement,s=t?.querySelector(".ql-toolbar");if(s&&s.remove(),e.setText(""),t&&t.classList.contains("editor-container")){const e=t.nextElementSibling;"TEXTAREA"===e?.tagName&&(e.style.display=""),t.remove()}})),this.quillInstances.delete(e)}let s={repeater:this.repeaters,tagList:this.tagLists,charLimit:this.charLimits,quantity:this.quantityFields};for(let[t,i]of Object.entries(s)){if(0===i.size)continue;let s=Array.from(i.values()).filter((t=>t.form===e));s.length>0&&(s.forEach((e=>{switch(t){case"repeater":this.removeRepeaterListeners(e.element);break;case"tagList":this.removeTagListListeners(e.element);break;case"charLimit":this.removeCharacterLimitListeners(e.element);break;case"quantity":this.removeQuantityListeners(e.element)}})),i.delete(item.id))}this.removeFormListeners(t.element),this.forms.delete(e),window.debouncer.cancel("form_changes")}defineSummaryTemplate(){this.summaryTemplate=!0;let e=this;this.templates.define("formSummary",{refs:{result:".result",h3:"h3",p:"p"},setup({el:t,refs:s,manyRefs:i,data:a}){const r=["sendAll",...a.config.options.ignore??[]];for(let[i,n]of Object.entries(a.changes)){if(r.includes(i)||e.isEmptyValue(n))continue;let a=Array.from(e.inputs.values()).find((e=>e.field?.dataset.field===i));if(!a)continue;let l=s.result.cloneNode(!0),o=l.querySelector("h3"),d=l.querySelector("p");const c=a.field?.querySelector("legend");o.textContent=c?c.textContent.replace("*","").trim():a.ui.label?.textContent.replace("*","").trim();const u=e.formatValueForSummary(n,a);u instanceof HTMLElement?d.replaceWith(u):d.textContent=u,t.append(l)}let n=a.config?.element?.querySelectorAll("[data-upload-field]");n&&n.forEach((e=>{let i=e.querySelector("h2")?.textContent??"Upload:",a=e.querySelectorAll(".item-grid.preview img"),r=s.result.cloneNode(!0);if(a){let e=s.result.cloneNode(!0),n=r.querySelector("h3"),l=r.querySelector("p");l?.remove(),n&&(n.textContent=i),a.forEach((t=>{t=t.cloneNode(!0),e.append(t)})),t.append(e)}})),s.result?.remove(),a.config.element.after(t),window.fade(a.config.element,!1)}})}initializeFields(e,t=null){const s={"[data-editor]":()=>this.checkForQuill(e,t),"div.quantity":()=>this.checkForQuantity(e),".repeater":()=>this.checkForRepeaters(e,t),".field.tag-list":()=>this.checkForTagLists(e),"[data-depends-on]":()=>this.checkForConditionalFields(e),"[data-limit]":()=>this.checkForCharacterLimits(e),"[data-uploader],[data-upload-field]":()=>this.checkForImageUploads(e,t),"nav.tabs":()=>this.checkForTabs(e,t),'[data-type="selector"]':()=>this.checkForSelectors(e)};for(const[t,i]of Object.entries(s))e.querySelector(t)&&i();Array.from(e.querySelectorAll(this.inputSelectors)).map((e=>{this.getItem(e,t?.id)}))}checkForQuill(e,t){if(!e.querySelector("[data-editor]"))return;t&&!Object.hasOwn(t,"hasQuill")&&(t.hasQuill=!0,this.forms.set(t.id,t)),this.quillInstances.has(t.id)||this.quillInstances.set(t.id,new Set);window.jvbQuill(e).forEach((e=>{this.quillInstances.get(t.id).add(e)}))}checkForQuantity(e){e.querySelector(this.selectors.number.number)&&e.querySelectorAll(this.selectors.number.number).forEach((t=>{let s={id:window.generateID("quant"),form:e.dataset.formId,ui:window.uiFromSelectors(this.selectors.number,t),element:t};t.dataset.numId=s.id,this.quantityFields.set(s.id,s),this.addQuantityListeners(t)}))}addQuantityListeners(e){e.addEventListener("click",this.quantityClick)}removeQuantityListeners(e){e.removeEventListener("click",this.quantityClick)}handleQuantityClick(e){let t=this.quantityFields.get(e.target.closest("[data-num-id]")?.dataset.numId);if(!t)return;let s=0;if(t.increase.contains(e.target)?s++:t.decrease.contains(e.target)&&s--,0===s)return;this.getField(e.target);let i=t.input.step;i=Math.max(i,1),e.ctrlKey&&e.shiftKey?i*=50:e.ctrlKey?i*=5:e.shiftKey&&(i*=10);let a=""===t.input.value?0:parseFloat(t.input.value);t.input.value=a+i*s,a=parseFloat(t.input.value),t.input.min&&a<t.input.min?(t.input.value=t.input.min,t.decrease.disabled=!0):t.input.max&&a>t.input.max?(t.input.value=t.input.max,t.increase.disabled=!0):(t.decrease.disabled&&(t.decrease.disabled=!1),t.increase.disabled&&(t.increase.disabled=!1))}checkForRepeaters(e){e.querySelector(this.selectors.repeater.repeater)&&e.querySelectorAll(this.selectors.repeater.repeater).forEach((t=>{let s={id:t.querySelector("template").className??window.generateID("repeater"),ui:window.uiFromSelectors(this.selectors.repeater,t),form:e.dataset.formId,element:t,field:this.getField(t),sortable:!1};if(!s.ui.addButton)return;let i=t.querySelector("template");this.templates.define(i.className,{manyRefs:{inputs:this.inputSelectors},setup({el:e,refs:t,manyRefs:i,data:a}){let r=s.ui.items?.children?.length??0;e.dataset.index=r,i.inputs?.forEach((t=>{let s=e.closest("[data-field]");window.prefixInput(t,`${e.dataset.fieldName}:${r}:`,s)}))}}),window.Sortable&&(s.sortable=new Sortable(t,{handle:this.selectors.repeater.header,animation:150,onEnd:()=>{this.reindexList(t)}})),t.dataset.repeaterId=s.id,this.addRepeaterListeners(t),this.repeaters.set(s.id,s)}))}addRepeaterListeners(e){e.addEventListener("click",this.repeaterClick)}removeRepeaterListeners(e){e.removeEventListener("click",this.repeaterClick)}handleRepeaterClick(e){e.target.matches(this.selectors.repeater.add)?this.addRepeaterRow(e.target.closest("[data-repeater-id]")):e.target.matches(this.selectors.repeater.remove)&&this.removeRepeaterRow(e.target)}addRepeaterRow(e){e.append(this.templates.create(e.dataset.repeaterId)),this.a11y.announce("Row added")}removeRepeaterRow(e){let t=e.closest("[data-repeater-id]");e.remove(),this.reindexList(t),this.a11y.announce("Row removed")}checkForTagLists(e){e.querySelectorAll(this.selectors.tagList.tagList)?.forEach((t=>{let s={id:t.querySelector("template").className??window.generateID("tagList"),ui:window.uiFromSelectors(this.selectors.tagList,t),element:t,form:e.dataset.formId,format:t.dataset.tagFormat??"first_field"};if(!s.ui.input||!s.ui.add||!s.ui.items)return;t.dataset.tagListId=s.id;let i=t.querySelector("template");this.templates.define(i.className,{refs:{label:this.selectors.tagList.label},manyRefs:{inputs:this.inputSelectors},setup({el:e,refs:t,manyRefs:i,data:a}){let r=s.ui.items?.children?.length??0;e.dataset.index=r,i.inputs?.forEach((t=>{let s=window.closest(".tag-item");window.prefixInput(t,`${e.dataset.fieldName}:${r}:`,s)})),t.label&&(t.label.textContent=a.label)}}),this.tagLists.set(s.id,s),this.addTagListListeners(t)}))}addTagListListeners(e){e.addEventListener("click",this.tagListClick),e.addEventListener("keypress",this.tagListInput,{passive:!0})}removeTagListListeners(e){e.removeEventListener("click",this.tagListClick),e.removeEventListener("keypress",this.tagListInput)}handleTagListClick(e){e.target.matches(this.selectors.tagList.add)?this.addTagListItem(e.target.closest("[data-tag-list-id]")):e.target.matches(this.selectors.tagList.remove)&&this.removeTagListItem(e.target.closest(this.selectors.tagList.remove))}addTagListItem(e){let t=this.tagLists.get(e.dataset.tagListId);if(!t)return;let s,i={},a=!1;for(let e of t.ui.inputs){this.validateField(e);const t=e.name.replace("new_",""),s=this.getFieldValue(e);s&&(a=!0),i[t]=s,["checkbox","radio"].includes(e.type)?e.checked=!1:e.value="",this.clearValidation(e)}if(!a)return this.a11y.announce("Please fill in at least one field"),void t.ui.inputs[0].focus();switch(t.format){case"first_field":s=Object.values(i)[0];break;case"all_fields":s=Object.values(i).join(", ");break;default:if(format.includes("{")){let e=t.format;for(const[t,s]of Object.entries(i))e=e.replace(`{${t}}`,s)}else s=i[t.format]??Object.values(i)[0]}let r=this.templates.create(e.dataset.tagListId,{label:s});const n=t.ui.items?.children?.length??0;r?.querySelectorAll("input[type=hidden]")?.forEach((e=>{const s=e.dataset.field;e.name=`${t.element.field}:${n}:${s}`,e.value=i[s]||""})),t.ui.items.append(r),t.ui.inputs[0]?.focus(),this.a11y.announce("Item added")}removeTagListItem(e){let t=e.closest("[data-tag-list-id]");e.remove(),this.reindexList(t),this.a11y.announce("Item removed")}handleTagListInput(e){let t=e.target,s=t.closest("[data-tag-list-id]");if(!s)return;let i=this.tagLists.get(s.dataset.tagListId);if(i&&"Enter"===e.key)if(t===i.ui.inputs[i.ui.inputs.length-1])e.preventDefault(),this.addTagListItem(t.closest("[data-tag-list-id]"));else{e.preventDefault();let s=i.ui.inputs.indexOf(t);i.ui.inputs[s+1].focus()}}checkForConditionalFields(e){e.querySelectorAll(this.selectors.dependsOn).forEach((t=>{const s=t.dataset.dependsOn,i=t.dataset.dependsValue,a=t.dataset.dependsOperatior??"==";if(!this.dependencies.has(s)){let e=document.querySelector(`[field="${s}"]`);e&&this.dependencies.set(s,{element:e,items:[]})}let r=this.dependencies.get(s);r.items.push({field:t,form:e.dataset.formId,requiredValue:i,operator:a}),this.dependencies.set(s,r),this.checkFieldDependency(r,s)}))}checkFieldDependency(e,t){const s=this.dependencies.get(t);if(!s)return;const i=this.getFieldCheckedValue(s.element),a=this.evaluateCondition(i,e.requiredValue,e.operator);this.toggleFieldVisibility(e.field,a)}evaluateCondition(e,t,s){const i=String(e||""),a=String(t||"");switch(s){case"==":default:return i===a;case"!=":return i!==a;case">":return parseFloat(i)>parseFloat(a);case"<":return parseFloat(i)<parseFloat(a);case">=":return parseFloat(i)>=parseFloat(a);case"<=":return parseFloat(i)<=parseFloat(a);case"contains":return i.includes(a);case"empty":return""===i;case"not_empty":return""!==i}}toggleFieldVisibility(e,t){const s=e.closest(".field, fieldset");s&&(s.hidden=!t,s.querySelectorAll("input, select, textarea").forEach((e=>{e.disabled=!t,!t&&e.hasAttribute("required")?(e.dataset.wasRequired="true",e.removeAttribute("required")):t&&"true"===e.dataset.wasRequired&&(e.setAttribute("required",""),delete e.dataset.wasRequired)})))}checkForCharacterLimits(e){e.querySelector(this.selectors.limits.hasLimit)&&(this.countUpdaters=this.updateCount.bind(this),e.querySelectorAll(`${this.selectors.limits.hasLimit}`).forEach((t=>{let s=window.generateID("limit");t.dataset.charLimitId=s;let i={element:t,form:e.dataset.formId,ui:window.uiFromSelectors(this.selectors.limits,t.closest(".field"))};i.ui.limit.textContent=t.dataset.limit,this.charLimits.set(s,i),this.addCharacterLimitListeners(t)})))}addCharacterLimitListeners(e){e.addEventListener("input",this.countUpdaters,{passive:!0})}removeCharacterLimitListeners(e){e.removeEventListener("input",this.countUpdaters,{passive:!0})}updateCount(e){let t=e.target,s=this.charLimits.get(t.dataset.charLimitId);if(!s)return;let i=t.value.length,a=t.dataset.limit;s.ui.current&&(s.ui.current.textContent=i,s.ui.current.classList.toggle("exceeded",i>=a)),i>a&&(t.value=t.value.slice(0,a))}checkForImageUploads(e,t){window.jvbUploads.scanFields(e,t.options.autoUpload,t.options.imageMeta)}checkForTabs(e,t){window.jvbTabs&&e.querySelector("nav.tabs")&&(t.tabs=window.jvbTabs.registerTab(e,{preCheck:(e,s)=>this.validateStep(e,t)}),t.ui.tabs=window.uiFromSelectors(this.selectors.tabs,e),t.ui.tabs.sections=Array.from(e.querySelectorAll(this.selectors.tabs.sections)),t.ui.tabs.inputs={},t.ui.tabs.sections.forEach((e=>{t.ui.tabs.inputs[e.dataset.tab]=Array.from(e.querySelectorAll(this.inputs))})),t.ui.tabs.buttons=Array.from(e.querySelectorAll(this.selectors.tabs.buttons)),t.unsubscribeTabs=window.jvbTabs.subscribe(((e,s)=>{if("tab-switched"===e&&t.ui.tabs.progress){const e=t.ui.tabs.sections.filter((e=>e.dataset.tab===s.current))[0]??!1;if(!e)return;const i=e.dataset.step,a=t.ui.sections.length;window.showProgress(t.ui.tabs.progress,i,a)}})),this.forms.set(t.id,t))}validateStep(e,t){const s=e.closest("[data-form-id]")?.dataset.formId;if(!s)return!0;if(!this.forms.get(s))return!0;return Array.from(this.inputs.values()).filter((t=>t&&t.form===s&&t.section===e.dataset.tab&&!t.element.closest("[hidden]"))).every((e=>!0===this.validateField(e.element)))}checkForSelectors(e){window.jvbSelector&&window.jvbSelector.scanExistingFields(e)}reindexList(e){const t=e.dataset.field||e.dataset.repeaterId||e.dataset.tagListId;Array.from(e.children).forEach(((e,s)=>{e.dataset.index=`${s}`;e.querySelectorAll("input, select, textarea").forEach((i=>{if("file"===i.type)return;i.dataset.field||i.name.split(":").pop();window.prefixInput(i,`${t}:${s}:`,e)}))}))}clearValidation(e){let t=this.getField(e);if(!t)return;let s=this.getItem(e);s&&(t.classList.remove("has-error","has-success"),s.ui.success&&(s.ui.success.hidden=!0),s.ui.error&&(s.ui.error.hidden=!0),s.ui.message&&(s.ui.message.hidden=!0,s.ui.message.textContent=""))}showError(e,t="Invalid field"){let s=this.getField(e);if(!s)return;let i=this.getItem(e);i&&(s.classList.remove("has-success"),s.classList.add("has-error"),i.ui.success&&(i.ui.success.hidden=!0),i.ui.error&&(i.ui.error.hidden=!0),i.ui.message&&(i.ui.message.hidden=!1,i.ui.message.textContent=t))}showSuccess(e,t=""){let s=this.getField(e);if(!s)return;let i=this.getItem(e);i&&(s.classList.remove("has-error"),s.classList.add("has-success"),i.ui.success&&(i.ui.success.hidden=!1),i.ui.error&&(i.ui.error.hidden=!0),i.ui.message&&(i.ui.message.hidden=""===t,i.ui.message.textContent=t))}handleFormSuccess(e,t){if(e.querySelectorAll(".error-message").forEach((e=>e.remove())),e.querySelectorAll(".field-error").forEach((e=>e.classList.remove("field-error"))),e.classList.add("form-success"),t.message){const s=document.createElement("div");s.className="form-success-message success-message",s.textContent=t.message,e.insertBefore(s,e.firstChild);const i=window.getIcon?.("check-circle");i&&(i.classList.add("success-icon"),s.prepend(i))}if(t.title||t.description){const s=document.createElement("div");if(s.className="success-box",t.title){const e=document.createElement("h3");e.textContent=t.title,s.appendChild(e)}if(t.description){(Array.isArray(t.description)?t.description:[t.description]).forEach((e=>{const t=document.createElement("p");t.textContent=e,s.appendChild(t)}))}e.insertBefore(s,e.firstChild)}if(e.dataset.formId){this.store.delete(e.dataset.formId).catch((e=>{console.warn("Failed to clear form cache:",e)}));const t=this.forms.get(e.dataset.formId);t&&(t.isDirty=!1,t.lastSaved=Date.now(),t.data={})}window.jvbA11y&&window.jvbA11y.announce(t.message||"Form submitted successfully")}handleFormError(e,t){if(e.querySelectorAll(".error-message").forEach((e=>e.remove())),e.querySelectorAll(".field-error, .has-error").forEach((e=>{e.classList.remove("field-error","has-error")})),e.querySelectorAll(".field").forEach((e=>{this.clearValidation(e)})),t.field){const s=e.querySelector(`[data-field="${t.field}"]`);if(s){this.showError(s,t.message),this.touchedFields.add(t.field),s.scrollIntoView({behavior:"smooth",block:"center"});const e=s.querySelector("input, textarea, select");e&&e.focus()}}else{const s=document.createElement("div");s.className="form-error error-message",s.textContent=t.message;const i=window.getIcon?.("close-circle");i&&(i.classList.add("error-icon"),s.prepend(i)),e.insertBefore(s,e.firstChild),e.scrollIntoView({behavior:"smooth",block:"start"})}if(window.jvbA11y){const e=t.field?`Error in ${t.field}: ${t.message}`:`Form error: ${t.message}`;window.jvbA11y.announce(e)}e.dispatchEvent(new CustomEvent("jvb-form-error",{detail:t}))}showFormStatus(e,t,s=""){let i=this.forms.get(e);i&&i.options.showStatus&&i.ui?.status?.status&&i.status!==t&&(i.status=t,i.ui.status.status.hidden=!1,i.ui.status.status.classList.toggle("loading",["uploading","saving"].includes(t)),i.ui.status.message.textContent=""===s?this.getDefaultMessage(t):s,i.ui.status.icon.className="icon icon-"+this.getDefaultIcon(t),setTimeout((()=>i.ui.status.status.hidden=!0),"submitted"===t?3e3:1e4))}getDefaultMessage(e){return{saving:"Saving changes...",autosaved:"Changes saved locally. Submit form to send to server.",uploading:"Uploading your form to server",submitted:"Successfully sent to server",pending:"Unsaved changes",restored:"Welcome back! We've restored your previous entry.",error:"Failed to save changes. Refresh and try again?",offline:"Changes will be saved when online"}[e]??e}getDefaultIcon(e){return{autosaved:"check-circle",submitted:"check-circle",restored:"history",error:"close-circle",offline:"cloud-slash",pending:"exclamation-mark"}[e]??""}showSummary(e){let t=this.templates.create("formSummary",e);e.config.element.after(t),window.fade(e.config.element,!1)}getForm(e){let t=e.closest("[data-form-id]").dataset.formId;if(!t)return!1;let s=this.forms.get(t);return s||!1}getField(e){return e.closest("[data-field]")}getFieldType(e){let t=this.getField(e);if(t)return t.dataset.fieldType}getFieldValue(e){let t=this.getFieldType(e),s=this.getItem(e),i=s.field?.dataset.field??!1;if(!i)return!1;switch(t){case"repeater":return this.getRepeaterValue(e,s);case"tag-list":return this.getTagListValue(e,s);case"group":break;case"location":return this.getLocationValue(e,s);case"selector":case"upload":return this.getHiddenInputValue(e,s,i);case"true-false":return"1"===e.value||"on"===e.value||"true"===e.value;case"checkbox":return e.name.endsWith("[]")?this.getCheckboxGroupValue(e,s):e.checked?e.value:"";default:return e.value}}getCheckboxGroupValue(e,t){return t.checkboxGroup||(t.checkboxGroup=t.field?.querySelectorAll(`input[type="checkbox"][name="${e.name}"]`),this.saveItem(t)),Array.from(t.checkboxGroup).filter((e=>e.checked)).map((e=>e.value))}getFieldCheckedValue(e){if("checkbox"===e.type){return"true-false"===this.getFieldType(e)?e.checked:e.checked?e.value:""}if("radio"===e.type){const t=document.querySelectorAll(`input[name="${e.name}"]`),s=Array.from(t).find((e=>e.checked));return s?s.value:""}return this.getFieldValue(e)}isEmptyValue(e){return null==e||""===e||(!(!Array.isArray(e)||0!==e.length)||"object"==typeof e&&0===Object.keys(e).length)}getRepeaterValue(e,t){t.container||(t.container=t.field?.querySelector(".repeater-items"),this.saveItem(t));let s=[];return Array.from(t.container.children).forEach((e=>{let t={};e.querySelectorAll("[data-field]").forEach((e=>{t[e.dataset.field]=this.getFieldValue(e)})),s.push(t)})),s}getTagListValue(e,t){t.container||(t.container=t.field?.querySelector(".tag-items"),this.saveItem(t));let s=[];return Array.from(t.container.children).forEach((e=>{let t=e.querySelectorAll('input[type="hidden"]'),i={};t.forEach((e=>{i[e.dataset.field]=e.value})),s.push(i)})),s}getLocationValue(e,t){t.values||(t.values=Array.from(t.field?.querySelectorAll("[data-location-field]")),this.saveItem(t));let s={};return t.values.forEach((e=>{s[e.dataset.locationField]=e.value})),s}getHiddenInputValue(e,t,s){return t.value||(t.value=t.field?.querySelector(`input[type=hidden][name="${s}"]`),this.saveItem(t)),t.value.value}formatValueForSummary(e,t){const s=this.getFieldType(t.element);if(this.isEmptyValue(e))return"";switch(s){case"repeater":return this.formatRepeaterForSummary(e,t);case"tag-list":return this.formatTagListForSummary(e,t);case"location":return this.formatLocationForSummary(e);case"true-false":return e?"Yes":"No";case"checkbox":return Array.isArray(e)?this.formatCheckboxGroupForSummary(e,t):this.getDisplayLabel(t,e);case"selector":case"upload":return this.formatHiddenFieldForSummary(e,t,s);default:return"string"==typeof e?this.getDisplayLabel(t,e):"string"==typeof e&&e.includes("\n")?this.convertLineBreaks(e):e}}formatCheckboxGroupForSummary(e,t){return e.map((e=>this.getDisplayLabel(t,e))).join(", ")}convertLineBreaks(e){const t=document.createElement("span");return t.innerHTML=e.split("\n").join("<br>"),t}formatRepeaterForSummary(e,t){const s=document.createElement("div");return s.className="summary-repeater",e.forEach(((e,i)=>{const a=document.createElement("div");a.className="summary-repeater-row";const r=document.createElement("strong");r.textContent=`Entry ${i+1}:`,a.appendChild(r);const n=document.createElement("ul");n.className="summary-repeater-fields";for(const[s,i]of Object.entries(e)){if(this.isEmptyValue(i))continue;const e=document.createElement("li"),a=t.field?.querySelector(`[data-field="${s}"]`),r=a?.closest(".field")?.querySelector("label")?.textContent.replace("*","").trim()||s;e.innerHTML=`<span class="field-label">${r}:</span> <span class="field-value">${i}</span>`,n.appendChild(e)}a.appendChild(n),s.appendChild(a)})),s}formatTagListForSummary(e,t){const s=document.createElement("div");s.className="summary-taglist";const i=document.createElement("ul");return i.className="summary-tags",e.forEach((e=>{const t=document.createElement("li");t.className="summary-tag";const s=Object.values(e).find((e=>!this.isEmptyValue(e)))||"",a=Object.entries(e).filter((([e,t])=>!this.isEmptyValue(t)));a.length>1?t.textContent=a.map((([e,t])=>t)).join(", "):t.textContent=s,i.appendChild(t)})),s.appendChild(i),s}formatLocationForSummary(e){const t=[];return e.street&&t.push(e.street),e.city&&t.push(e.city),e.province&&t.push(e.province),e.postal_code&&t.push(e.postal_code),e.country&&t.push(e.country),t.length>0?t.join(", "):e.address||""}formatHiddenFieldForSummary(e,t,s){if("upload"===s){const s=t.field?.querySelector("[data-upload-field]");if(s){const e=s.querySelectorAll(".item-grid.preview img");if(e.length>0){const t=document.createElement("div");return t.className="summary-uploads",e.forEach((e=>{const s=e.cloneNode(!0);s.style.maxWidth="100px",s.style.maxHeight="100px",t.appendChild(s)})),t}}return`${e.split(",").length} file(s) uploaded`}return e}getDisplayLabel(e,t){if(!e.element)return t;const s=e.element.type;if("radio"===s){const s=e.field.querySelectorAll(`input[type="radio"][name="${e.element.name}"]`),i=Array.from(s).find((e=>e.value===t));if(i){const t=i.closest("label")||e.field.querySelector(`label[for="${i.id}"]`);if(t)return t.textContent.replace("*","").trim()}}if("checkbox"===s&&"true-false"!==this.getFieldType(e.element)){const s=e.field.querySelector(`input[type="checkbox"][value="${t}"]`);if(s){const t=s.closest("label")||e.field.querySelector(`label[for="${s.id}"]`);if(t){const e=t.querySelector("span");return e?e.textContent.trim():t.textContent.replace("*","").trim()}}}return t}getItem(e,t=null){const s=Object.hasOwn(e.dataset,"ref");let i=s?e.dataset.ref:window.generateID("input");if(s||(e.dataset.ref=i),!this.inputs.has(i)){t||(t=e.closest("[data-form-id]")?.dataset.formId??!1);let s=this.getField(e);this.inputs.set(i,{id:i,element:e,form:t,field:s,section:e.closest("[data-tab]")?.dataset.tab??!1,ui:window.uiFromSelectors(this.selectors.fields,s)})}return this.inputs.get(i)}saveItem(e){this.inputs.set(e.id,e)}subscribe(e){return this.subscribers.add(e),()=>this.subscribers.delete(e)}notify(e,t){this.subscribers.forEach((s=>{try{s(e,t)}catch(e){console.error("HandleSelection subscriber error:",e)}}))}destroy(){this.forms.size>0&&(Array.from(this.forms.values()).forEach((e=>{this.removeFormListeners(e)})),this.forms.clear()),this.repeaters.size>0&&(Array.from(this.repeaters.values()).forEach((e=>{this.removeRepeaterListeners(e.element),e.sortable?.destroy()})),this.repeaters.clear()),this.quantityFields.size>0&&(Array.from(this.quantityFields.values()).forEach((e=>{this.removeQuantityListeners(e.element)})),this.quantityFields.clear()),this.tagLists.size>0&&(Array.from(this.tagLists.values()).forEach((e=>{this.removeTagListListeners(e.element)})),this.tagLists.clear()),this.charLimits.size>0&&Array.from(this.charLimits.values()).forEach((e=>{e.removeEventListener("input",this.countUpdaters)})),this.inputs.clear(),this.forms.clear(),this.charLimits.clear()}}document.addEventListener("DOMContentLoaded",(async function(){window.auth.subscribe((t=>{"auth-loaded"===t&&(window.jvbForm=new e)}))}))})();
\ No newline at end of file
+(()=>{class e{constructor(){this.a11y=window.jvbA11y,this.error=window.jvbError,this.queue=window.jvbQueue,this.populate=window.jvbPopulate,this.changes=new Map,this.forms=new Map,this.inputs=new Map,this.repeaters=new Map,this.tagLists=new Map,this.charLimits=new Map,this.quantityFields=new Map,this.quillInstances=new Map,this.dependencies=new Map,this.subscribers=new Set,this.isRestoring=!1,this.hasListeners=!1,this.summaryTemplate=!1,this.init()}init(){this.templates=window.jvbTemplates,this.defineSummaryTemplate(),this.initElements(),this.initListeners(),this.initStore(),this.initValidators()}initElements(){this.inputSelectors="input, textarea, select",this.selectors={tabs:{nav:"nav.tabs",sections:".tab.content",progress:{progress:".progress",fill:".progress .fill",details:".progress .details",icon:".progress .icon"},buttons:"nav.tabs button"},dependsOn:"[data-depends-on]",forms:{status:{status:".fstatus",message:".fstatus .message",icon:".fstatus .icon",actions:".fstatus .actions"}},inputs:this.inputSelectors,fields:{field:".field",label:"label",success:".success",error:".success",message:".validation-message"},repeater:{repeater:".repeater",header:".repeater-row-header",remove:".remove-row",add:".add-repeater-row",template:"template",items:".repeater-items",inputs:this.inputSelectors},tagList:{tagList:".field.tag-list",input:".row",add:".add-tag",remove:".remove-tag",label:".tag-label",items:".tag-items",inputs:this.inputSelectors,value:'input[type="hidden"]'},tag:{label:".tag-label"},number:{number:".field div.quantity",increase:"button.increase",decrease:"button.decrease",input:'input[type="number"]'},limits:{hasLimit:"[data-limit]",limit:".limit",current:".current"}}}initListeners(){this.clickHandler=this.handleClick.bind(this),this.changeHandler=this.handleChange.bind(this),this.blurHandler=this.handleBlur.bind(this),this.inputHandler=this.handleInput.bind(this),this.submitHandler=this.handleSubmit.bind(this),this.quantityClick=this.handleQuantityClick.bind(this),this.repeaterClick=this.handleRepeaterClick.bind(this),this.tagListClick=this.handleTagListClick.bind(this),this.tagListInput=this.handleTagListInput.bind(this)}addFormListeners(e){e.addEventListener("click",this.clickHandler),e.addEventListener("change",this.changeHandler),e.addEventListener("input",this.inputHandler),e.addEventListener("blur",this.blurHandler),e.addEventListener("submit",this.submitHandler)}removeFormListeners(e){e.removeEventListener("click",this.clickHandler),e.removeEventListener("change",this.changeHandler),e.removeEventListener("input",this.inputHandler),e.removeEventListener("blur",this.blurHandler),e.removeEventListener("submit",this.submitHandler)}initStore(){const e=window.jvbStore.register("forms",{storeName:"forms",keyPath:"id",indexes:[{name:"src",keyPath:"src"},{name:"timestamp",keyPath:"timestamp"},{name:"formType",keyPath:"type"}],TTL:1008e4});this.store=e.forms,this.store.subscribe(((e,t)=>{if("data-ready"===e){let e=this.store.getFiltered().filter((e=>e.src===window.location.pathname));for(let t of e)this.showPendingNotification(t.id,t.changes)}else"operation-status"===e&&"completed"===t.status&&t.config&&this.store.delete(t.config.id)}))}showPendingNotification(e,t){let s=this.forms.get(e);if(!s)return;let i=s.element;if(!i)return void console.warn(`Form element not found for: ${e}`);const a=document.createElement("div");a.className="pendingChanges",a.innerHTML=`\n\t\t\t<p>We noticed unsaved changes from last time. Would you like to restore them?</p>\n <button class="restore" type="button" data-form-id="${e}">Restore</button>\n <button class="discard" type="button" data-form-id="${e}">Discard</button>`,i.insertBefore(a,s.ui.status.status),a.querySelector(".restore").addEventListener("click",(async()=>{this.isRestoring=!0;let e={fields:t};this.populate.populate(i,e),this.a11y.announce("Previous changes restored"),this.isRestoring=!1,a.remove()})),a.querySelector(".discard").addEventListener("click",(async()=>{await this.store.delete(e),this.a11y.announce("Previous changes discarded"),a.remove()}))}initValidators(){this.validators={email:{pattern:/^[^\s@]+@[^\s@]+\.[^\s@]+$/,message:"Please enter a valid email address"},url:{pattern:/^https?:\/\/.+\..+/,message:"Please enter a valid URL starting with https://"},phone:{pattern:/^[\d\s\-+().]+$/,message:"Please enter a valid phone number"},number:{test:(e,t)=>{const s=parseFloat(e);if(isNaN(s))return"Please enter a valid number";const i=t.dataset.min,a=t.dataset.max;return void 0!==i&&s<parseFloat(i)?`Value must be at least ${i}`:!(void 0!==a&&s>parseFloat(a))||`Value must be at most ${a}`}},text:{test:(e,t)=>{const s=t.dataset.minlength,i=t.dataset.maxlength;return s&&e.length<parseInt(s)?`Must be at least ${s} characters`:!(i&&e.length>parseInt(i))||`Must be no more than ${i} characters`}}}}validateField(e){const t=this.performValidation(e);return this.updateValidationUI(e,t),t.isValid}performValidation(e){const t=e.closest(".field"),s=this.getFieldCheckedValue(e);if(!s&&!e.required)return{isValid:!0,message:""};if(e.required)if("checkbox"===e.type){if(!e.checked)return{isValid:!1,message:"This field is required"}}else if("radio"===e.type){const t=document.querySelectorAll(`input[name="${e.name}"]`);if(!Array.from(t).some((e=>e.checked)))return{isValid:!1,message:"Please select an option"}}else if(!s)return{isValid:!1,message:"This field is required"};if(e.checkValidity&&!e.checkValidity())return{isValid:!1,message:e.validationMessage};if(s&&Object.hasOwn(t.dataset,"pattern")){if(!new RegExp(t.dataset.pattern).test(s))return{isValid:!1,message:t.dataset.validationMessage||"Invalid format"}}if(Object.hasOwn(t.dataset,"validate")||e.type){const i=this.validators[t.dataset.validate||e.type];if(i&&i.pattern&&!i.pattern.test(s))return{isValid:!1,message:i.message};if(i&&i.test){const e=i.test(s,t);if(!0!==e)return{isValid:!1,message:e}}}return{isValid:!0,message:""}}updateValidationUI(e,t){t.isValid?this.showSuccess(e,t.message):this.showError(e,t.message)}handleClick(e){let t=this.getForm(e.target);if(!t)return;const s=window.targetCheck(e,"[data-action]");if(s){switch(s.dataset.action){case"clear-form":this.store.delete(t.id),t.element.reset(),t.ui.status.status.hidden=!0,this.a11y.announce("Form cleared, starting fresh");break;case"dismiss-restore":t.ui.status.status.hidden=!0}}}handleChange(e){if(e.target.closest("[data-ignore]")||this.isRestoring)return;let t=this.getField(e.target);if(this.dependencies.has(t.dataset.field)){this.dependencies.get(t.dataset.field).items.forEach((e=>{this.checkFieldDependency(e,t.dataset.field)}))}if(Object.hasOwn(t.dataset,"repeater-id")||Object.hasOwn(t.dataset,"tag-list-id"))return void this.updateCollectionField(t);let s=this.getForm(e.target);this.updateItem(t.dataset.field,this.getFieldValue(e.target),s)}handleBlur(e){if(e.target.closest("[data-ignore]")||this.isRestoring)return;let t=this.getForm(e.target);if(!t)return;let s=this.getField(e.target).dataset.field;window.debouncer.cancel(`form:${t.id}:validate:${s}`),this.validateField(e.target),this.updateItem(s,this.getFieldValue(e.target),t)}handleInput(e){let t=this.getForm(e.target);if(!t)return;let s=this.getField(e.target);if(!s)return;const i=e.target,a=s.dataset.field;this.showFormStatus(t.id,"pending"),window.debouncer.schedule(`form:${t.id}:validate:${a}`,(()=>this.validateField(i)),500)}async handleSubmit(e){let t=this.getForm(e.target);if(t){if(this.subscribers.size>0)if(e.preventDefault(),t.options.cache){this.cancelBackup(),await this.backup();const e=await this.store.get(t.id);this.notify("form-submit",{config:t,data:e.changes})}else this.notify("form-submit",{config:t,data:this.changes.get(t.id)?.changes??{}});if(t.options.showSummary){const e=await this.store.get(t.id);this.showSummary({config:t,changes:e?.changes})}}}updateItem(e,t,s){this.changes.has(s.id)||this.changes.set(s.id,{id:s.id,timestamp:Date.now(),src:window.location.pathname,changes:{}});let i=this.changes.get(s.id);i.changes[e]=t,this.changes.set(s.id,i),s.options.cache&&this.scheduleBackup()}scheduleBackup(){window.debouncer.schedule("form_changes",(async()=>{this.changes.size>0&&await this.backup()}),2e3)}cancelBackup(){window.debouncer.cancel("form_changes")}async backup(){const e=new Map;for(let[t,s]of this.changes.entries()){const i=await this.store.get(t);i?e.set(t,{...i,...s,changes:{...i.changes,...s.changes},timestamp:Date.now()}):e.set(t,s)}await this.store.saveMany(e);for(let e of this.changes.keys())this.showFormStatus(e,"autosaved");this.changes.clear()}saveCache(e){if(!this.changes.has(e))return;let t=this.changes.get(e);0!==t.size&&(this.store.save(t).then((()=>{})),this.changes.delete(e))}registerForm(e,t){if(Object.hasOwn(e.dataset,"formId")&&this.forms.has(e.dataset.formId))return;Object.hasOwn(e.dataset,"formId")||(e.dataset.formId=window.generateID("form_"));const s=e.dataset.formId;this.addFormListeners(e);const i={element:e,id:s,status:"",options:{autoUpload:t.autoUpload??!1,imageMeta:t.imageMeta??!0,delay:t.delay??1500,endpoint:t.save??e.dataset.save??"",showStatus:t.showStatus??!0,showSummary:t.showSummary??!1,cache:t.cache??!0,ignore:t.ignore??[]},ui:window.uiFromSelectors(this.selectors.forms,e)};return this.initializeFields(e,i),this.forms.set(s,i),i}clearForm(e){const t=this.forms.get(e);if(!t)return;t.unsubscribeTabs&&t.unsubscribeTabs(),t.tabs&&window.jvbTabs.removeTab(t.element),t.cache&&this.changes.has(e)&&this.saveCache(e);for(let[t,s]of this.inputs.entries())s.form===e&&this.inputs.delete(t);if(this.dependencies.forEach(((t,s)=>{t.items=t.items.filter((t=>t.form!==e)),0===t.items.length&&this.dependencies.delete(s)})),Object.hasOwn(t,"hasQuill")&&this.quillInstances.has(e)){this.quillInstances.get(e).forEach((e=>{e.disable(),e.off("text-change"),e.off("selection-change");const t=e.container.parentElement,s=t?.querySelector(".ql-toolbar");if(s&&s.remove(),e.setText(""),t&&t.classList.contains("editor-container")){const e=t.nextElementSibling;"TEXTAREA"===e?.tagName&&(e.style.display=""),t.remove()}})),this.quillInstances.delete(e)}let s={repeater:this.repeaters,tagList:this.tagLists,charLimit:this.charLimits,quantity:this.quantityFields};for(let[t,i]of Object.entries(s)){if(0===i.size)continue;let s=Array.from(i.values()).filter((t=>t.form===e));s.length>0&&s.forEach((e=>{switch(t){case"repeater":this.removeRepeaterListeners(e.element);break;case"tagList":this.removeTagListListeners(e.element);break;case"charLimit":this.removeCharacterLimitListeners(e.element);break;case"quantity":this.removeQuantityListeners(e.element)}i.has(e.id)&&i.delete(e.id)}))}this.removeFormListeners(t.element),this.forms.delete(e),window.debouncer.cancel("form_changes")}defineSummaryTemplate(){this.summaryTemplate=!0;let e=this;this.templates.define("formSummary",{refs:{result:".result",h3:"h3",p:"p"},setup({el:t,refs:s,manyRefs:i,data:a}){const r=["sendAll",...a.config.options.ignore??[]];for(let[i,n]of Object.entries(a.changes)){if(r.includes(i)||e.isEmptyValue(n))continue;let a=Array.from(e.inputs.values()).find((e=>e.field?.dataset.field===i));if(!a)continue;let l=s.result.cloneNode(!0),o=l.querySelector("h3"),d=l.querySelector("p");const c=a.field?.querySelector("legend");o.textContent=c?c.textContent.replace("*","").trim():a.ui.label?.textContent.replace("*","").trim();const u=e.formatValueForSummary(n,a);u instanceof HTMLElement?d.replaceWith(u):d.textContent=u,t.append(l)}let n=a.config?.element?.querySelectorAll("[data-upload-field]");n&&n.forEach((e=>{let i=e.querySelector("h2")?.textContent??"Upload:",a=e.querySelectorAll(".item-grid.preview img"),r=s.result.cloneNode(!0);if(a){let e=s.result.cloneNode(!0),n=r.querySelector("h3"),l=r.querySelector("p");l?.remove(),n&&(n.textContent=i),a.forEach((t=>{t=t.cloneNode(!0),e.append(t)})),t.append(e)}})),s.result?.remove(),a.config.element.after(t),window.fade(a.config.element,!1)}})}initializeFields(e,t=null){const s={"[data-editor]":()=>this.checkForQuill(e,t),"div.quantity":()=>this.checkForQuantity(e),".repeater":()=>this.checkForRepeaters(e,t),".field.tag-list":()=>this.checkForTagLists(e),"[data-depends-on]":()=>this.checkForConditionalFields(e),"[data-limit]":()=>this.checkForCharacterLimits(e),"[data-uploader],[data-upload-field]":()=>this.checkForImageUploads(e,t),"nav.tabs":()=>this.checkForTabs(e,t),'[data-type="selector"]':()=>this.checkForSelectors(e)};for(const[t,i]of Object.entries(s))e.querySelector(t)&&i();Array.from(e.querySelectorAll(this.inputSelectors)).map((e=>{this.getItem(e,t?.id)}))}checkForQuill(e,t){if(!e.querySelector("[data-editor]"))return;t&&!Object.hasOwn(t,"hasQuill")&&(t.hasQuill=!0,this.forms.set(t.id,t)),this.quillInstances.has(t.id)||this.quillInstances.set(t.id,new Set);window.jvbQuill(e).forEach((e=>{this.quillInstances.get(t.id).add(e)}))}checkForQuantity(e){e.querySelector(this.selectors.number.number)&&e.querySelectorAll(this.selectors.number.number).forEach((t=>{let s={id:window.generateID("quant"),form:e.dataset.formId,ui:window.uiFromSelectors(this.selectors.number,t),element:t};t.dataset.numId=s.id,this.quantityFields.set(s.id,s),this.addQuantityListeners(t)}))}addQuantityListeners(e){e.addEventListener("click",this.quantityClick)}removeQuantityListeners(e){e.removeEventListener("click",this.quantityClick)}handleQuantityClick(e){let t=this.quantityFields.get(e.target.closest("[data-num-id]")?.dataset.numId);if(!t)return;let s=0;if(t.increase.contains(e.target)?s++:t.decrease.contains(e.target)&&s--,0===s)return;this.getField(e.target);let i=t.input.step;i=Math.max(i,1),e.ctrlKey&&e.shiftKey?i*=50:e.ctrlKey?i*=5:e.shiftKey&&(i*=10);let a=""===t.input.value?0:parseFloat(t.input.value);t.input.value=a+i*s,a=parseFloat(t.input.value),t.input.min&&a<t.input.min?(t.input.value=t.input.min,t.decrease.disabled=!0):t.input.max&&a>t.input.max?(t.input.value=t.input.max,t.increase.disabled=!0):(t.decrease.disabled&&(t.decrease.disabled=!1),t.increase.disabled&&(t.increase.disabled=!1))}checkForRepeaters(e){e.querySelector(this.selectors.repeater.repeater)&&e.querySelectorAll(this.selectors.repeater.repeater).forEach((t=>{let s={id:t.querySelector("template").className??window.generateID("repeater"),ui:window.uiFromSelectors(this.selectors.repeater,t),form:e.dataset.formId,element:t,field:this.getField(t),sortable:!1};if(!s.ui.add)return;let i=t.querySelector("template");this.templates.define(i.className,{manyRefs:{inputs:this.inputSelectors},setup({el:e,refs:t,manyRefs:i,data:a}){let r=s.ui.items?.children?.length??0;e.dataset.index=r,i.inputs?.forEach((t=>{window.prefixInput(t,`${a.repeater.dataset.fieldName}:${r}:`,e)}))}}),window.Sortable&&(s.sortable=new Sortable(t,{handle:this.selectors.repeater.header,animation:150,onEnd:()=>{this.reindexList(t)}})),t.dataset.repeaterId=s.id,this.addRepeaterListeners(t),this.repeaters.set(s.id,s)}))}addRepeaterListeners(e){e.addEventListener("click",this.repeaterClick)}removeRepeaterListeners(e){e.removeEventListener("click",this.repeaterClick)}handleRepeaterClick(e){e.target.matches(this.selectors.repeater.add)?(console.log("Add Repeater Row"),this.addRepeaterRow(e.target.closest("[data-repeater-id]"))):e.target.matches(this.selectors.repeater.remove)&&(console.log("Remove Repeater Row"),this.removeRepeaterRow(e.target.closest("[data-index]")))}addRepeaterRow(e){let t={};t.repeater=e,e.append(this.templates.create(e.dataset.repeaterId,t)),this.initializeFields(e,this.getField(e).config??{}),this.a11y.announce("Row added")}removeRepeaterRow(e){let t=e.closest("[data-repeater-id]");e.remove(),this.reindexList(t),this.a11y.announce("Row removed")}checkForTagLists(e){e.querySelectorAll(this.selectors.tagList.tagList)?.forEach((t=>{let s={id:t.querySelector("template").className??window.generateID("tagList"),ui:window.uiFromSelectors(this.selectors.tagList,t),element:t,form:e.dataset.formId,format:t.dataset.tagFormat??"first_field"};if(!s.ui.input||!s.ui.add||!s.ui.items)return;t.dataset.tagListId=s.id;let i=t.querySelector("template");this.templates.define(i.className,{refs:{label:this.selectors.tagList.label},manyRefs:{inputs:this.inputSelectors},setup({el:e,refs:t,manyRefs:i,data:a}){let r=s.ui.items?.children?.length??0;e.dataset.index=r,i.inputs?.forEach((t=>{let s=window.closest(".tag-item");window.prefixInput(t,`${e.dataset.fieldName}:${r}:`,s)})),t.label&&(t.label.textContent=a.label)}}),this.tagLists.set(s.id,s),this.addTagListListeners(t)}))}addTagListListeners(e){e.addEventListener("click",this.tagListClick),e.addEventListener("keypress",this.tagListInput,{passive:!0})}removeTagListListeners(e){e.removeEventListener("click",this.tagListClick),e.removeEventListener("keypress",this.tagListInput)}handleTagListClick(e){e.target.matches(this.selectors.tagList.add)?this.addTagListItem(e.target.closest("[data-tag-list-id]")):e.target.matches(this.selectors.tagList.remove)&&this.removeTagListItem(e.target.closest(this.selectors.tagList.remove))}addTagListItem(e){let t=this.tagLists.get(e.dataset.tagListId);if(!t)return;let s,i={},a=!1;for(let e of t.ui.inputs){this.validateField(e);const t=e.name.replace("new_",""),s=this.getFieldValue(e);s&&(a=!0),i[t]=s,["checkbox","radio"].includes(e.type)?e.checked=!1:e.value="",this.clearValidation(e)}if(!a)return this.a11y.announce("Please fill in at least one field"),void t.ui.inputs[0].focus();switch(t.format){case"first_field":s=Object.values(i)[0];break;case"all_fields":s=Object.values(i).join(", ");break;default:if(format.includes("{")){let e=t.format;for(const[t,s]of Object.entries(i))e=e.replace(`{${t}}`,s)}else s=i[t.format]??Object.values(i)[0]}let r=this.templates.create(e.dataset.tagListId,{label:s});const n=t.ui.items?.children?.length??0;r?.querySelectorAll("input[type=hidden]")?.forEach((e=>{const s=e.dataset.field;e.name=`${t.element.field}:${n}:${s}`,e.value=i[s]||""})),t.ui.items.append(r),t.ui.inputs[0]?.focus(),this.updateCollectionField(e),this.a11y.announce("Item added")}removeTagListItem(e){let t=e.closest("[data-tag-list-id]");e.remove(),this.reindexList(t),this.a11y.announce("Item removed")}handleTagListInput(e){let t=e.target,s=t.closest("[data-tag-list-id]");if(!s)return;let i=this.tagLists.get(s.dataset.tagListId);if(i&&"Enter"===e.key)if(t===i.ui.inputs[i.ui.inputs.length-1])e.preventDefault(),this.addTagListItem(t.closest("[data-tag-list-id]"));else{e.preventDefault();let s=i.ui.inputs.indexOf(t);i.ui.inputs[s+1].focus()}}checkForConditionalFields(e){e.querySelectorAll(this.selectors.dependsOn).forEach((t=>{const s=t.dataset.dependsOn,i=t.dataset.dependsValue,a=t.dataset.dependsOperatior??"==";if(!this.dependencies.has(s)){let e=document.querySelector(`[field="${s}"]`);e&&this.dependencies.set(s,{element:e,items:[]})}let r=this.dependencies.get(s);r.items.push({field:t,form:e.dataset.formId,requiredValue:i,operator:a}),this.dependencies.set(s,r),this.checkFieldDependency(r,s)}))}checkFieldDependency(e,t){const s=this.dependencies.get(t);if(!s)return;const i=this.getFieldCheckedValue(s.element),a=this.evaluateCondition(i,e.requiredValue,e.operator);this.toggleFieldVisibility(e.field,a)}evaluateCondition(e,t,s){const i=String(e||""),a=String(t||"");switch(s){case"==":default:return i===a;case"!=":return i!==a;case">":return parseFloat(i)>parseFloat(a);case"<":return parseFloat(i)<parseFloat(a);case">=":return parseFloat(i)>=parseFloat(a);case"<=":return parseFloat(i)<=parseFloat(a);case"contains":return i.includes(a);case"empty":return""===i;case"not_empty":return""!==i}}toggleFieldVisibility(e,t){const s=e.closest(".field, fieldset");s&&(s.hidden=!t,s.querySelectorAll("input, select, textarea").forEach((e=>{e.disabled=!t,!t&&e.hasAttribute("required")?(e.dataset.wasRequired="true",e.removeAttribute("required")):t&&"true"===e.dataset.wasRequired&&(e.setAttribute("required",""),delete e.dataset.wasRequired)})))}checkForCharacterLimits(e){e.querySelector(this.selectors.limits.hasLimit)&&(this.countUpdaters=this.updateCount.bind(this),e.querySelectorAll(`${this.selectors.limits.hasLimit}`).forEach((t=>{let s=window.generateID("limit");t.dataset.charLimitId=s;let i={element:t,form:e.dataset.formId,ui:window.uiFromSelectors(this.selectors.limits,t.closest(".field"))};i.ui.limit.textContent=t.dataset.limit,this.charLimits.set(s,i),this.addCharacterLimitListeners(t)})))}addCharacterLimitListeners(e){e.addEventListener("input",this.countUpdaters,{passive:!0})}removeCharacterLimitListeners(e){e.removeEventListener("input",this.countUpdaters,{passive:!0})}updateCount(e){let t=e.target,s=this.charLimits.get(t.dataset.charLimitId);if(!s)return;let i=t.value.length,a=t.dataset.limit;s.ui.current&&(s.ui.current.textContent=i,s.ui.current.classList.toggle("exceeded",i>=a)),i>a&&(t.value=t.value.slice(0,a))}checkForImageUploads(e,t){window.jvbUploads.scanFields(e,t.options.autoUpload,t.options.imageMeta)}checkForTabs(e,t){window.jvbTabs&&e.querySelector("nav.tabs")&&(t.tabs=window.jvbTabs.registerTab(e,{preCheck:(e,s)=>this.validateStep(e,t)}),t.ui.tabs=window.uiFromSelectors(this.selectors.tabs,e),t.ui.tabs.sections=Array.from(e.querySelectorAll(this.selectors.tabs.sections)),t.ui.tabs.inputs={},t.ui.tabs.sections.forEach((e=>{t.ui.tabs.inputs[e.dataset.tab]=Array.from(e.querySelectorAll(this.inputs))})),t.ui.tabs.buttons=Array.from(e.querySelectorAll(this.selectors.tabs.buttons)),t.unsubscribeTabs=window.jvbTabs.subscribe(((e,s)=>{if("tab-switched"===e&&t.ui.tabs.progress){const e=t.ui.tabs.sections.filter((e=>e.dataset.tab===s.current))[0]??!1;if(!e)return;const i=e.dataset.step,a=t.ui.sections.length;window.showProgress(t.ui.tabs.progress,i,a)}})),this.forms.set(t.id,t))}validateStep(e,t){const s=e.closest("[data-form-id]")?.dataset.formId;if(!s)return!0;if(!this.forms.get(s))return!0;return Array.from(this.inputs.values()).filter((t=>t&&t.form===s&&t.section===e.dataset.tab&&!t.element.closest("[hidden]"))).every((e=>!0===this.validateField(e.element)))}checkForSelectors(e){window.jvbSelector&&window.jvbSelector.scanExistingFields(e)}reindexList(e){const t=e.dataset.field||e.dataset.repeaterId||e.dataset.tagListId;Array.from(e.children).forEach(((e,s)=>{e.dataset.index=`${s}`;e.querySelectorAll("input, select, textarea").forEach((i=>{if("file"===i.type)return;i.dataset.field||i.name.split(":").pop();window.prefixInput(i,`${t}:${s}:`,e)}))})),this.updateCollectionField(e)}updateCollectionField(e){const t=e.closest("[data-field]");if(!t)return;const s=t.dataset.fieldType;if(!["repeater","tag-list"].includes(s))return;const i=this.getForm(e);if(!i)return;const a=this.getFieldValue(t.querySelector("input, select, textarea"));this.updateItem(t.dataset.field,a,i)}clearValidation(e){let t=this.getField(e);if(!t)return;let s=this.getItem(e);s&&(t.classList.remove("has-error","has-success"),s.ui.success&&(s.ui.success.hidden=!0),s.ui.error&&(s.ui.error.hidden=!0),s.ui.message&&(s.ui.message.hidden=!0,s.ui.message.textContent=""))}showError(e,t="Invalid field"){let s=this.getField(e);if(!s)return;let i=this.getItem(e);i&&(s.classList.remove("has-success"),s.classList.add("has-error"),i.ui.success&&(i.ui.success.hidden=!0),i.ui.error&&(i.ui.error.hidden=!0),i.ui.message&&(i.ui.message.hidden=!1,i.ui.message.textContent=t))}showSuccess(e,t=""){let s=this.getField(e);if(!s)return;let i=this.getItem(e);i&&(s.classList.remove("has-error"),s.classList.add("has-success"),i.ui.success&&(i.ui.success.hidden=!1),i.ui.error&&(i.ui.error.hidden=!0),i.ui.message&&(i.ui.message.hidden=""===t,i.ui.message.textContent=t))}handleFormSuccess(e,t){if(e.querySelectorAll(".error-message").forEach((e=>e.remove())),e.querySelectorAll(".field-error").forEach((e=>e.classList.remove("field-error"))),e.classList.add("form-success"),t.message){const s=document.createElement("div");s.className="form-success-message success-message",s.textContent=t.message,e.insertBefore(s,e.firstChild);const i=window.getIcon?.("check-circle");i&&(i.classList.add("success-icon"),s.prepend(i))}if(t.title||t.description){const s=document.createElement("div");if(s.className="success-box",t.title){const e=document.createElement("h3");e.textContent=t.title,s.appendChild(e)}if(t.description){(Array.isArray(t.description)?t.description:[t.description]).forEach((e=>{const t=document.createElement("p");t.textContent=e,s.appendChild(t)}))}e.insertBefore(s,e.firstChild)}if(e.dataset.formId){this.store.delete(e.dataset.formId).catch((e=>{console.warn("Failed to clear form cache:",e)}));const t=this.forms.get(e.dataset.formId);t&&(t.isDirty=!1,t.lastSaved=Date.now(),t.data={})}window.jvbA11y&&window.jvbA11y.announce(t.message||"Form submitted successfully")}handleFormError(e,t){if(e.querySelectorAll(".error-message").forEach((e=>e.remove())),e.querySelectorAll(".field-error, .has-error").forEach((e=>{e.classList.remove("field-error","has-error")})),e.querySelectorAll(".field").forEach((e=>{this.clearValidation(e)})),t.field){const s=e.querySelector(`[data-field="${t.field}"]`);if(s){this.showError(s,t.message),this.touchedFields.add(t.field),s.scrollIntoView({behavior:"smooth",block:"center"});const e=s.querySelector("input, textarea, select");e&&e.focus()}}else{const s=document.createElement("div");s.className="form-error error-message",s.textContent=t.message;const i=window.getIcon?.("close-circle");i&&(i.classList.add("error-icon"),s.prepend(i)),e.insertBefore(s,e.firstChild),e.scrollIntoView({behavior:"smooth",block:"start"})}if(window.jvbA11y){const e=t.field?`Error in ${t.field}: ${t.message}`:`Form error: ${t.message}`;window.jvbA11y.announce(e)}e.dispatchEvent(new CustomEvent("jvb-form-error",{detail:t}))}showFormStatus(e,t,s=""){let i=this.forms.get(e);i&&i.options.showStatus&&i.ui?.status?.status&&i.status!==t&&(i.status=t,i.ui.status.status.hidden=!1,i.ui.status.status.classList.toggle("loading",["uploading","saving"].includes(t)),i.ui.status.message.textContent=""===s?this.getDefaultMessage(t):s,i.ui.status.icon.className="icon icon-"+this.getDefaultIcon(t),setTimeout((()=>i.ui.status.status.hidden=!0),"submitted"===t?3e3:1e4))}getDefaultMessage(e){return{saving:"Saving changes...",autosaved:"Changes saved locally. Submit form to send to server.",uploading:"Uploading your form to server",submitted:"Successfully sent to server",pending:"Unsaved changes",restored:"Welcome back! We've restored your previous entry.",error:"Failed to save changes. Refresh and try again?",offline:"Changes will be saved when online"}[e]??e}getDefaultIcon(e){return{autosaved:"check-circle",submitted:"check-circle",restored:"history",error:"close-circle",offline:"cloud-slash",pending:"exclamation-mark"}[e]??""}showSummary(e){let t=this.templates.create("formSummary",e);e.config.element.after(t),window.fade(e.config.element,!1)}getForm(e){let t=e.closest("[data-form-id]").dataset.formId;if(!t)return!1;let s=this.forms.get(t);return s||!1}getField(e){return e.closest("[data-field]")}getFieldType(e){let t=this.getField(e);if(t)return t.dataset.fieldType}getFieldValue(e){let t=this.getFieldType(e),s=this.getItem(e),i=s.field?.dataset.field??!1;if(!i)return!1;switch(t){case"repeater":return this.getRepeaterValue(e,s);case"tag-list":return this.getTagListValue(e,s);case"group":break;case"location":return this.getLocationValue(e,s);case"selector":case"upload":return this.getHiddenInputValue(e,s,i);case"true-false":return"1"===e.value||"on"===e.value||"true"===e.value;case"checkbox":return e.name.endsWith("[]")?this.getCheckboxGroupValue(e,s):e.checked?e.value:"";default:return e.value}}getCheckboxGroupValue(e,t){return t.checkboxGroup||(t.checkboxGroup=t.field?.querySelectorAll(`input[type="checkbox"][name="${e.name}"]`),this.saveItem(t)),Array.from(t.checkboxGroup).filter((e=>e.checked)).map((e=>e.value))}getFieldCheckedValue(e){if("checkbox"===e.type){return"true-false"===this.getFieldType(e)?e.checked:e.checked?e.value:""}if("radio"===e.type){const t=document.querySelectorAll(`input[name="${e.name}"]`),s=Array.from(t).find((e=>e.checked));return s?s.value:""}return this.getFieldValue(e)}isEmptyValue(e){return null==e||""===e||(!(!Array.isArray(e)||0!==e.length)||"object"==typeof e&&0===Object.keys(e).length)}getRepeaterValue(e,t){t.container||(t.container=t.field?.querySelector(".repeater-items"),this.saveItem(t));let s=[];return Array.from(t.container.children).forEach((e=>{let t={};e.querySelectorAll("[data-field]").forEach((e=>{t[e.dataset.field]=this.getFieldValue(e)})),s.push(t)})),s}getTagListValue(e,t){t.container||(t.container=t.field?.querySelector(".tag-items"),this.saveItem(t));let s=[];return Array.from(t.container.children).forEach((e=>{let t=e.querySelectorAll('input[type="hidden"]'),i={};t.forEach((e=>{i[e.dataset.field]=e.value})),s.push(i)})),s}getLocationValue(e,t){t.values||(t.values=Array.from(t.field?.querySelectorAll("[data-location-field]")),this.saveItem(t));let s={};return t.values.forEach((e=>{s[e.dataset.locationField]=e.value})),s}getHiddenInputValue(e,t,s){return t.value||(t.value=t.field?.querySelector(`input[type=hidden][name="${s}"]`),this.saveItem(t)),t.value.value}formatValueForSummary(e,t){const s=this.getFieldType(t.element);if(this.isEmptyValue(e))return"";switch(s){case"repeater":return this.formatRepeaterForSummary(e,t);case"tag-list":return this.formatTagListForSummary(e,t);case"location":return this.formatLocationForSummary(e);case"true-false":return e?"Yes":"No";case"checkbox":return Array.isArray(e)?this.formatCheckboxGroupForSummary(e,t):this.getDisplayLabel(t,e);case"selector":case"upload":return this.formatHiddenFieldForSummary(e,t,s);default:return"string"==typeof e?this.getDisplayLabel(t,e):"string"==typeof e&&e.includes("\n")?this.convertLineBreaks(e):e}}formatCheckboxGroupForSummary(e,t){return e.map((e=>this.getDisplayLabel(t,e))).join(", ")}convertLineBreaks(e){const t=document.createElement("span");return t.innerHTML=e.split("\n").join("<br>"),t}formatRepeaterForSummary(e,t){const s=document.createElement("div");return s.className="summary-repeater",e.forEach(((e,i)=>{const a=document.createElement("div");a.className="summary-repeater-row";const r=document.createElement("strong");r.textContent=`Entry ${i+1}:`,a.appendChild(r);const n=document.createElement("ul");n.className="summary-repeater-fields";for(const[s,i]of Object.entries(e)){if(this.isEmptyValue(i))continue;const e=document.createElement("li"),a=t.field?.querySelector(`[data-field="${s}"]`),r=a?.closest(".field")?.querySelector("label")?.textContent.replace("*","").trim()||s;e.innerHTML=`<span class="field-label">${r}:</span> <span class="field-value">${i}</span>`,n.appendChild(e)}a.appendChild(n),s.appendChild(a)})),s}formatTagListForSummary(e,t){const s=document.createElement("div");s.className="summary-taglist";const i=document.createElement("ul");return i.className="summary-tags",e.forEach((e=>{const t=document.createElement("li");t.className="summary-tag";const s=Object.values(e).find((e=>!this.isEmptyValue(e)))||"",a=Object.entries(e).filter((([e,t])=>!this.isEmptyValue(t)));a.length>1?t.textContent=a.map((([e,t])=>t)).join(", "):t.textContent=s,i.appendChild(t)})),s.appendChild(i),s}formatLocationForSummary(e){const t=[];return e.street&&t.push(e.street),e.city&&t.push(e.city),e.province&&t.push(e.province),e.postal_code&&t.push(e.postal_code),e.country&&t.push(e.country),t.length>0?t.join(", "):e.address||""}formatHiddenFieldForSummary(e,t,s){if("upload"===s){const s=t.field?.querySelector("[data-upload-field]");if(s){const e=s.querySelectorAll(".item-grid.preview img");if(e.length>0){const t=document.createElement("div");return t.className="summary-uploads",e.forEach((e=>{const s=e.cloneNode(!0);s.style.maxWidth="100px",s.style.maxHeight="100px",t.appendChild(s)})),t}}return`${e.split(",").length} file(s) uploaded`}return e}getDisplayLabel(e,t){if(!e.element)return t;const s=e.element.type;if("radio"===s){const s=e.field.querySelectorAll(`input[type="radio"][name="${e.element.name}"]`),i=Array.from(s).find((e=>e.value===t));if(i){const t=i.closest("label")||e.field.querySelector(`label[for="${i.id}"]`);if(t)return t.textContent.replace("*","").trim()}}if("checkbox"===s&&"true-false"!==this.getFieldType(e.element)){const s=e.field.querySelector(`input[type="checkbox"][value="${t}"]`);if(s){const t=s.closest("label")||e.field.querySelector(`label[for="${s.id}"]`);if(t){const e=t.querySelector("span");return e?e.textContent.trim():t.textContent.replace("*","").trim()}}}return t}getItem(e,t=null){const s=Object.hasOwn(e.dataset,"ref");let i=s?e.dataset.ref:window.generateID("input");if(s||(e.dataset.ref=i),!this.inputs.has(i)){t||(t=e.closest("[data-form-id]")?.dataset.formId??!1);let s=this.getField(e);this.inputs.set(i,{id:i,element:e,form:t,field:s,section:e.closest("[data-tab]")?.dataset.tab??!1,ui:window.uiFromSelectors(this.selectors.fields,s)})}return this.inputs.get(i)}saveItem(e){this.inputs.set(e.id,e)}subscribe(e){return this.subscribers.add(e),()=>this.subscribers.delete(e)}notify(e,t){this.subscribers.forEach((s=>{try{s(e,t)}catch(e){console.error("HandleSelection subscriber error:",e)}}))}destroy(){this.forms.size>0&&(Array.from(this.forms.values()).forEach((e=>{this.removeFormListeners(e)})),this.forms.clear()),this.repeaters.size>0&&(Array.from(this.repeaters.values()).forEach((e=>{this.removeRepeaterListeners(e.element),e.sortable?.destroy()})),this.repeaters.clear()),this.quantityFields.size>0&&(Array.from(this.quantityFields.values()).forEach((e=>{this.removeQuantityListeners(e.element)})),this.quantityFields.clear()),this.tagLists.size>0&&(Array.from(this.tagLists.values()).forEach((e=>{this.removeTagListListeners(e.element)})),this.tagLists.clear()),this.charLimits.size>0&&Array.from(this.charLimits.values()).forEach((e=>{e.removeEventListener("input",this.countUpdaters)})),this.inputs.clear(),this.forms.clear(),this.charLimits.clear()}}document.addEventListener("DOMContentLoaded",(async function(){window.auth.subscribe((t=>{"auth-loaded"===t&&(window.jvbForm=new e)}))}))})();
\ No newline at end of file
diff --git a/assets/js/min/populate.min.js b/assets/js/min/populate.min.js
index 3d59b82..250edba 100644
--- a/assets/js/min/populate.min.js
+++ b/assets/js/min/populate.min.js
@@ -1 +1 @@
-(()=>{class e{constructor(){this.templates=window.jvbTemplates,this.formHelper=window.jvbForm,this.defineTemplates(),this.data=null,this.form=null}populate(e,t={}){if(this.data=t,this.mergeRootData(),this.form=e,this.formHelper||(this.formHelper=window.jvbForm),this.formHelper){if(Object.hasOwn(this.data,"fields")&&0!==Object.keys(this.data.fields).length)for(let[t,i]of Object.entries(this.data.fields)){let a=e.querySelector(`[data-field="${t}"]`);a&&this.populateField(a,t,i)}}else requestAnimationFrame((()=>{this.populate(e,t)}))}mergeRootData(){["status","date","modified"].forEach((e=>{this.data.fields[`post_${e}`]=this.data[e]}))}populateField(e,t,i){let a=this.formHelper.getFieldType(e);if(!a||this.isEmptyValue(t)||this.isEmptyValue(i))return;const l={repeater:this.populateRepeater.bind(this),"tag-list":this.populateTagList.bind(this),location:this.populateLocation.bind(this),selector:this.populateTaxonomy.bind(this),user:this.populateUser.bind(this),upload:this.populateUpload.bind(this),set:this.populateMultiValue.bind(this),checkbox:this.populateMultiValue.bind(this),select:this.populateSingleValue.bind(this),radio:this.populateSingleValue.bind(this),"true-false":this.populateBoolean.bind(this),date:this.populateDate.bind(this),time:this.populateDate.bind(this),datetime:this.populateDate.bind(this),number:this.populateNumber.bind(this),textarea:this.populateTextarea.bind(this)};Object.hasOwn(l,a)?l[a](e,t,i):this.populateText(e,t,i)}populateRepeater(e,t,i){if(!i||!Array.isArray(i))return;const a=e.querySelector(".repeater-items");let l=e.querySelector("template")?.className??!1;a&&l&&(window.removeChildren(a),i.forEach(((e,t)=>{e.index=t;const i=this.templates.create(l,e);let o=i.querySelectorAll(".field");this.populate(o,e),a.append(i)})))}populateTagList(e,t,i){if(!i||!Array.isArray(i))return;const a=e.querySelector(".tag-items");let l=e.querySelector("template")?.className??!1;a&&l&&(window.removeChildren(a),i.forEach(((e,t)=>{e.index=t;const i=this.templates.create(l,e);let o=i.querySelectorAll(".field");this.populate(o,e),a.append(i)})))}populateLocation(e,t,i){["address","lat","lng","street","city","province","postal_code","country"].forEach((t=>{if(Object.hasOwn(i,t)){let a=e.querySelector(`[data-location-field="${t}"]`);a&&(a.value=String(i[t]||""))}}))}populateTaxonomy(e,t,i){let a=this.splitIDs(i);if(0===a.length)return;const l=e.querySelector(`input[type="hidden"][name="${t}"]`);l&&(l.value=a.join(","),window.jvbSelector&&requestAnimationFrame((()=>{window.jvbSelector.updateFieldFromInput(l)})))}populateUser(e,t,i){this.populateTaxonomy(e,t,i)}populateUpload(e,t,i){if("timeline"===t||e.dataset.subtype&&"timeline"===e.dataset.subtype)return void this.populateTimelineGallery(e,t,i);if(this.isEmptyValue(i))return;const a=this.splitIDs(i);if(0===a.length)return;const l=e.querySelector('input[type="hidden"]');l&&(l.value=a.join(","));const o=e.querySelector(".item-grid");e.querySelector(".file-upload-container").hidden=a.length>0,e.querySelector(".progress")?.remove(),o&&(window.removeChildren(o),a.forEach((e=>{let t=this.data.images[e]??{};t.id=e,o.append(this.templates.create("uploadItem",t))}))),this.populateUploadMeta(e,t,i)}populateUploadMeta(e,t,i){const a=e.querySelector('[data-field="image_data"]');if(!a)return;let l=this.data.images[i]??!1;if(!l)return;a.dataset.attachmentId=l.id,a.setAttribute("data-ignore","");const o=["image-title","image-alt-text","image-caption"];for(const e of o){const t=a.querySelector(`[data-field="${e}"] input, [data-field="${e}"] textarea`);t&&""!==l[e]&&(t.value=l[e])}}populateTimelineGallery(e,t,i){if(!i||!Array.isArray(i)||0===i.length)return;let a=e.querySelector(".item-grid");if(e.querySelector(".file-upload-container").hidden=i.length>0,a){window.removeChildren(a),e.querySelector(".progress")?.remove();for(let e of i){let t=this.templates.create("timelineItem",e);t&&a.append(t)}}}populateMultiValue(e,t,i){if("string"==typeof i)try{i=JSON.parse(i)}catch(e){i=i.split(",").map((e=>e.trim()))}Array.isArray(i)||(i=[String(i)]);let a=e.querySelector(`select[name="${t}"]`);if(a&&a.multiple)for(let e of a.options)e.selected=i.includes(e.value);else e.querySelectorAll(`[type="checkbox"][name=${t}]`).forEach((e=>{e.checked=i.includes(e.value)}))}populateSingleValue(e,t,i){i=String(i||"");let a=e.querySelector(`select[name="${t}"]`);if(a)return void(a.value=i);let l=e.querySelector(`[name="${t}"][value="${i}"]`);l&&(l.checked=!0)}populateBoolean(e,t,i){const a=e.querySelector(`[name="${t}"], input[type="checkbox"]`);a&&(a.checked=Boolean(i))}populateDate(e,t,i){const a=e.querySelector(`[name="${t}"], input`);if(a){"object"==typeof i&&Object.hasOwn(i,"date")&&(i=i.date);try{const e=new Date(i);if(!isNaN(e.getTime()))switch(a.type){case"date":a.value=e.toISOString().split("T")[0];break;case"time":a.value=e.toTimeString().slice(0,5);break;case"datetime-local":a.value=e.toISOString().slice(0,16);break;default:a.value=i}}catch(e){a.value=i}}}populateNumber(e,t,i){const a=e.querySelector(`[name="${t}"], input[type="number"]`);a&&(a.value=Number(i)||0)}populateTextarea(e,t,i){let a=e.querySelector("textarea");a.dataset.editor?(a.value=String(i||""),a.dispatchEvent(new Event("change",{bubbles:!0}))):this.populateText(e,t,i)}populateText(e,t,i){let a=e.querySelector(`[name="${t}"], input, textarea`);a&&"file"!==a.type&&(a.value=String(i||""))}getFormHelper(){window.requestAnimationFrame((()=>{this.formHelper=window.jvbForm}))}splitIDs(e){return String(e).split(",").map((e=>parseInt(e.trim()))).filter((e=>!isNaN(e)&&e>0))}isEmptyValue(e){return null==e||""===e||(!(!Array.isArray(e)||0!==e.length)||"object"==typeof e&&0===Object.keys(e).length)}defineTemplates(){const e=this.templates,t=this;e.define("timelineItem",{refs:{select:'[name="select-item"]',video:"video",file:".select-item span",img:"img",details:"details[data-field]",imgAlt:'[name="image-alt-text"]',imgTitle:'[name="image-title"]',imgDesc:'[name="image-caption"]'},manyRefs:{fields:".field"},setup({el:e,refs:i,manyRefs:a,data:l}){if(e.dataset.itemId=l.id,i.select){let e=i.select.closest(".preview");window.prefixInput(i.select,`${l.id}-`,e)}i.video&&i.video.remove(),i.file&&i.file.remove();let o=t.data.images[l.post_thumbnail]??!1;if(i.img&&o&&(i.img.src=o.medium||o.small||o.large||"",i.img.title=o["image-title"]??"",i.img.alt=o["image-alt-text"]??""),i.details){let e=t.data.images[l.post_thumbnail];i.details.setAttribute("data-ignore",""),i.details.dataset.attachmentId=l.post_thumbnail,Object.hasOwn(e,"image-alt-text")&&i.alt&&(i.alt.value=e["image-alt-text"]),(Object.hasOwn(e,"image-title")||Object.hasOwn(l,"file"))&&i.title&&(i.title.value=e["image-title"]||l.file.name),Object.hasOwn(e,"image-caption")&&i.description&&(i.description.value=e["image-caption"])}if(a.fields)for(let e of a.fields){if("group"===e.dataset.fieldType)continue;if("post_thumbnail"===e.dataset.field){e.remove();continue}let i=e.dataset.field,a=l[i]??"";t.isEmptyValue(a)||t.populateField(e,i,a);const o=e.querySelector('input:not([type="file"])');o&&window.prefixInput(o,`[${l.id}]`,e)}}})}}document.addEventListener("DOMContentLoaded",(function(){window.auth.subscribe((t=>{"auth-loaded"===t&&(window.jvbPopulate=new e)}))}))})();
\ No newline at end of file
+(()=>{class e{constructor(){this.templates=window.jvbTemplates,this.formHelper=window.jvbForm,this.defineTemplates(),this.data=null,this.form=null}populate(e,t={}){if(this.data=t,this.mergeRootData(),this.form=e,this.formHelper||(this.formHelper=window.jvbForm),this.formHelper){if(Object.hasOwn(this.data,"fields")&&0!==Object.keys(this.data.fields).length)for(let[t,i]of Object.entries(this.data.fields)){let a=e.querySelector(`[data-field="${t}"]`);a&&this.populateField(a,t,i)}}else requestAnimationFrame((()=>{this.populate(e,t)}))}mergeRootData(){["status","date","modified"].forEach((e=>{this.data.fields[`post_${e}`]=this.data[e]}))}populateField(e,t,i){let a=this.formHelper.getFieldType(e);if(!a||this.isEmptyValue(t)||this.isEmptyValue(i))return;const l={repeater:this.populateRepeater.bind(this),"tag-list":this.populateTagList.bind(this),location:this.populateLocation.bind(this),selector:this.populateTaxonomy.bind(this),user:this.populateUser.bind(this),upload:this.populateUpload.bind(this),set:this.populateMultiValue.bind(this),checkbox:this.populateMultiValue.bind(this),select:this.populateSingleValue.bind(this),radio:this.populateSingleValue.bind(this),"true-false":this.populateBoolean.bind(this),date:this.populateDate.bind(this),time:this.populateDate.bind(this),datetime:this.populateDate.bind(this),number:this.populateNumber.bind(this),textarea:this.populateTextarea.bind(this)};Object.hasOwn(l,a)?l[a](e,t,i):this.populateText(e,t,i)}populateRepeater(e,t,i){if(!i||!Array.isArray(i))return;const a=e.querySelector(".repeater-items");let l=e.querySelector("template")?.className??!1;a&&l&&(window.removeChildren(a),i.forEach(((e,t)=>{e.index=t;const i=this.templates.create(l,e);let o=i.querySelectorAll(".field");this.populate(o,e),a.append(i)})))}populateTagList(e,t,i){if(!i||!Array.isArray(i))return;const a=e.querySelector(".tag-items");let l=e.querySelector("template")?.className??!1;a&&l&&(window.removeChildren(a),i.forEach(((e,t)=>{e.index=t;const i=this.templates.create(l,e);let o=i.querySelectorAll(".field");this.populate(o,e),a.append(i)})))}populateLocation(e,t,i){["address","lat","lng","street","city","province","postal_code","country"].forEach((t=>{if(Object.hasOwn(i,t)){let a=e.querySelector(`[data-location-field="${t}"]`);a&&(a.value=String(i[t]||""))}}))}populateTaxonomy(e,t,i){let a=this.splitIDs(i);if(0===a.length)return;const l=e.querySelector(`input[type="hidden"][name="${t}"]`);l&&(l.value=a.join(","),window.jvbSelector&&requestAnimationFrame((()=>{window.jvbSelector.updateFieldFromInput(l)})))}populateUser(e,t,i){this.populateTaxonomy(e,t,i)}populateUpload(e,t,i){if("timeline"===t||e.dataset.subtype&&"timeline"===e.dataset.subtype)return void this.populateTimelineGallery(e,t,i);if(this.isEmptyValue(i))return;const a=this.splitIDs(i);if(0===a.length)return;const l=e.querySelector('input[type="hidden"]');l&&(l.value=a.join(","));const o=e.querySelector(".item-grid");e.querySelector(".file-upload-container").hidden=a.length>0,e.querySelector(".progress")?.remove(),o&&(window.removeChildren(o),a.forEach((e=>{let t=this.data.images[e]??{};t.field={config:{showMeta:!0}},t.id=e,o.append(this.templates.create("uploadItem",t))}))),this.populateUploadMeta(e,t,i)}populateUploadMeta(e,t,i){const a=e.querySelector('[data-field="image_data"]');if(!a)return;let l=this.data.images[i]??!1;if(!l)return;a.dataset.attachmentId=l.id,a.setAttribute("data-ignore","");const o=["image-title","image-alt-text","image-caption"];for(const e of o){const t=a.querySelector(`[data-field="${e}"] input, [data-field="${e}"] textarea`);t&&""!==l[e]&&(t.value=l[e])}}populateTimelineGallery(e,t,i){if(!i||!Array.isArray(i)||0===i.length)return;let a=e.querySelector(".item-grid");if(e.querySelector(".file-upload-container").hidden=i.length>0,a){window.removeChildren(a),e.querySelector(".progress")?.remove();for(let e of i){let t=this.templates.create("timelineItem",e);t&&a.append(t)}}}populateMultiValue(e,t,i){if("string"==typeof i)try{i=JSON.parse(i)}catch(e){i=i.split(",").map((e=>e.trim()))}Array.isArray(i)||(i=[String(i)]);let a=e.querySelector(`select[name="${t}"]`);if(a&&a.multiple)for(let e of a.options)e.selected=i.includes(e.value);else e.querySelectorAll(`[type="checkbox"][name=${t}]`).forEach((e=>{e.checked=i.includes(e.value)}))}populateSingleValue(e,t,i){i=String(i||"");let a=e.querySelector(`select[name="${t}"]`);if(a)return void(a.value=i);let l=e.querySelector(`[name="${t}"][value="${i}"]`);l&&(l.checked=!0)}populateBoolean(e,t,i){const a=e.querySelector(`[name="${t}"], input[type="checkbox"]`);a&&(a.checked=Boolean(i))}populateDate(e,t,i){const a=e.querySelector(`[name="${t}"], input`);if(a){"object"==typeof i&&Object.hasOwn(i,"date")&&(i=i.date);try{const e=new Date(i);if(!isNaN(e.getTime()))switch(a.type){case"date":a.value=e.toISOString().split("T")[0];break;case"time":a.value=e.toTimeString().slice(0,5);break;case"datetime-local":a.value=e.toISOString().slice(0,16);break;default:a.value=i}}catch(e){a.value=i}}}populateNumber(e,t,i){const a=e.querySelector(`[name="${t}"], input[type="number"]`);a&&(a.value=Number(i)||0)}populateTextarea(e,t,i){let a=e.querySelector("textarea");a.dataset.editor?(a.value=String(i||""),a.dispatchEvent(new Event("change",{bubbles:!0}))):this.populateText(e,t,i)}populateText(e,t,i){let a=e.querySelector(`[name="${t}"], input, textarea`);a&&"file"!==a.type&&(a.value=String(i||""))}getFormHelper(){window.requestAnimationFrame((()=>{this.formHelper=window.jvbForm}))}splitIDs(e){return String(e).split(",").map((e=>parseInt(e.trim()))).filter((e=>!isNaN(e)&&e>0))}isEmptyValue(e){return null==e||""===e||(!(!Array.isArray(e)||0!==e.length)||"object"==typeof e&&0===Object.keys(e).length)}defineTemplates(){const e=this.templates,t=this;e.define("timelineItem",{refs:{select:'[name="select-item"]',video:"video",file:".select-item span",img:"img",details:"details[data-field]",imgAlt:'[name="image-alt-text"]',imgTitle:'[name="image-title"]',imgDesc:'[name="image-caption"]'},manyRefs:{fields:".field"},setup({el:e,refs:i,manyRefs:a,data:l}){if(e.dataset.itemId=l.id,i.select){let e=i.select.closest(".preview");window.prefixInput(i.select,`${l.id}-`,e)}i.video&&i.video.remove(),i.file&&i.file.remove();let o=t.data.images[l.post_thumbnail]??!1;if(i.img&&o&&(i.img.src=o.medium||o.small||o.large||"",i.img.title=o["image-title"]??"",i.img.alt=o["image-alt-text"]??""),i.details){let e=t.data.images[l.post_thumbnail];i.details.setAttribute("data-ignore",""),i.details.dataset.attachmentId=l.post_thumbnail,Object.hasOwn(e,"image-alt-text")&&i.alt&&(i.alt.value=e["image-alt-text"]),(Object.hasOwn(e,"image-title")||Object.hasOwn(l,"file"))&&i.title&&(i.title.value=e["image-title"]||l.file.name),Object.hasOwn(e,"image-caption")&&i.description&&(i.description.value=e["image-caption"])}if(a.fields)for(let e of a.fields){if("group"===e.dataset.fieldType)continue;if("post_thumbnail"===e.dataset.field){e.remove();continue}let i=e.dataset.field,a=l[i]??"";t.isEmptyValue(a)||t.populateField(e,i,a);const o=e.querySelector('input:not([type="file"])');o&&window.prefixInput(o,`[${l.id}]`,e)}}})}}document.addEventListener("DOMContentLoaded",(function(){window.auth.subscribe((t=>{"auth-loaded"===t&&(window.jvbPopulate=new e)}))}))})();
\ No newline at end of file
diff --git a/assets/js/min/quill.min.js b/assets/js/min/quill.min.js
index d030666..0a6fbba 100644
--- a/assets/js/min/quill.min.js
+++ b/assets/js/min/quill.min.js
@@ -1 +1 @@
-window.jvbQuill=function(t){const n=t.querySelectorAll("textarea[data-editor=true]"),e=[];return n.forEach((t=>{let n,i,l;if(t.parentNode.querySelector(".editor-container"))n=t.parentNode.querySelector(".editor-container"),i=n.querySelector(".editor"),l=n.querySelector(".toolbar");else{n=document.createElement("div"),n.className="editor-container",i=document.createElement("div"),i.className="editor",l=document.createElement("div"),l.className="toolbar";const e=!0===t.dataset.allowimage?`<button type="button" class="ql-jvb_image">\n ${window.getIcon("image")}\n </button>`:"";l.id=`toolbar-${t.id}`,l.innerHTML=`\n <span class="ql-formats">\n <button type="button" class="ql-p">\n <i class="icon icon-paragraph"></i>\n </button>\n <button type="button" class="ql-h1">\n <i class="icon icon-text-h-one"></i>\n </button>\n <button type="button" class="ql-h2">\n <i class="icon icon-text-h-two"></i>\n </button>\n <button type="button" class="ql-h3">\n <i class="icon icon-text-h-three"></i>\n </button>\n </span>\n <span class="ql-formats">\n <button type="button" class="ql-jvb_bold">\n <i class="icon icon-text-b-fi"></i>\n </button>\n <button type="button" class="ql-jvb_italic">\n <i class="icon icon-text-italic"></i>\n </button>\n <button type="button" class="ql-jvb_underline">\n <i class="icon icon-text-underline"></i>\n </button>\n <button type="button" class="ql-jvb_strike">\n <i class="icon icon-text-strikethrough"></i>\n </button>\n </span>\n <span class="ql-formats">\n <button type="button" class="ql-jvb_list" value="bullet">\n <i class="icon icon-list-dashes"></i>\n </button>\n <button type="button" class="ql-jvb_list" value="ordered">\n <i class="icon icon-list-numbers"></i>\n </button>\n </span>\n <span class="ql-formats">\n <button type="button" class="ql-jvb_align" value="left">\n <i class="icon icon-text-align-left"></i>\n </button>\n <button type="button" class="ql-jvb_align" value="center">\n <i class="icon icon-text-align-center"></i>\n </button>\n <button type="button" class="ql-jvb_align" value="right">\n <i class="icon icon-text-align-right"></i>\n </button>\n </span>\n <span class="ql-formats">\n <button type="button" class="ql-jvb_link">\n <i class="icon icon-link"></i>\n </button>\n ${e}\n </span>\n `,n.appendChild(l),n.appendChild(i),t.parentNode.insertBefore(n,t),t.style.display="none",i.innerHTML=t.value}const o=new Quill(i,{theme:"snow",modules:{toolbar:{container:l,handlers:{p:function(){this.quill.format("header",!1)},h1:function(){this.quill.format("header",1)},h2:function(){this.quill.format("header",2)},h3:function(){this.quill.format("header",3)},jvb_bold:function(){this.quill.format("bold",!0)},jvb_italic:function(){this.quill.format("italic",!0)},jvb_strike:function(){this.quill.format("strike",!0)},jvb_underline:function(){this.quill.format("underline",!0)},jvb_align:function(t){this.quill.format("align",t!==this.quill.getFormat().list&&t)},jvb_list:function(t){this.quill.format("list",t!==this.quill.getFormat().list&&t)},jvb_link:function(t){if(t){const t=this.quill.getSelection();if(null==t||0===t.length)return;const n=this.quill.getFormat(t).link,e=document.createElement("dialog");e.className="quill-link-modal",e.innerHTML=`\n <div class="quill-link-modal-content ">\n <label for="link">Enter URL</label>\n <input type="url" id="link" placeholder="Enter URL" value="${n||""}" />\n <div class="buttons">\n <button type="button" class="save">Save</button>\n ${n?'<button type="button" class="remove">Remove</button>':""}\n <button type="button" class="cancel">Cancel</button>\n </div>\n </div>\n `,document.body.appendChild(e),e.showModal();const i=e.querySelector("input");i.focus(),e.querySelector(".save").addEventListener("click",(()=>{const t=i.value;t&&this.quill.format("link",t),e.remove()}));const l=e.querySelector(".remove");l&&l.addEventListener("click",(()=>{this.quill.format("link",!1),e.remove()})),e.querySelector(".cancel").addEventListener("click",(()=>{e.remove()})),i.addEventListener("keyup",(t=>{if("Enter"===t.key){const t=i.value;t&&this.quill.format("link",t),e.remove()}}))}},jvb_image:function(){const t=document.createElement("input");t.setAttribute("type","file"),t.setAttribute("accept","image/jpeg,image/png,image/gif,image/webp"),t.style.display="none",document.body.appendChild(t),t.onchange=async n=>{const e=n.target.files?.[0];if(!e)return;if(e.size>5242880)return this.quill.insertText(i.index,"File too large. Maximum size is 5MB",{color:"#f00",italic:!0},!0),void t.remove();const i=this.quill.getSelection(!0),l=new FormData;l.append("image",e),objectID&&l.append("post_id",objectID);try{const t=await fetch(`${jvbSettings.api}uploads/`,{method:"POST",headers:{"X-WP-Nonce":window.auth.getNonce()},body:l});if(!t.ok)throw new Error("Upload failed");const n=await t.json();this.quill.insertEmbed(i.index,"image",n.url)}catch(t){this.handleError("Upload error:",t),this.quill.insertText(i.index,"Failed to upload image. Please try again.",{color:"#f00",italic:!0},!0)}finally{t.remove()}},t.click()}}},history:{delay:2e3,maxStack:500},clipboard:{matchVisual:!1}}});e.push(o),o.on("selection-change",(function(t){const n=l.querySelector(".ql-align");if(n){if(t&&0===t.length){const[e]=this.quill.getLeaf(t.index);if(e&&e.domNode&&"IMG"===e.domNode.tagName)return void(n.style.display="inline-block")}n.style.display="none"}})),o.on("text-change",(()=>{t.value=o.root.innerHTML,t.dispatchEvent(new Event("change",{bubbles:!0}))}))})),e};
\ No newline at end of file
+window.jvbQuill=function(t){const n=t.querySelectorAll("textarea[data-editor=true]"),e=[];return n.forEach((t=>{let n,i,l;if(t.parentNode.querySelector(".editor-container"))n=t.parentNode.querySelector(".editor-container"),i=n.querySelector(".editor"),l=n.querySelector(".toolbar");else{n=document.createElement("div"),n.className="editor-container",i=document.createElement("div"),i.className="editor",l=document.createElement("div"),l.className="toolbar";const e=!0===t.dataset.allowimage?`<button type="button" class="ql-jvb_image">\n ${window.getIcon("image")}\n </button>`:"";l.id=`toolbar-${t.id}`,l.innerHTML=`\n <span class="ql-formats">\n <button type="button" class="ql-p">\n <i class="icon icon-paragraph"></i>\n </button>\n <button type="button" class="ql-h1">\n <i class="icon icon-text-h-one"></i>\n </button>\n <button type="button" class="ql-h2">\n <i class="icon icon-text-h-two"></i>\n </button>\n <button type="button" class="ql-h3">\n <i class="icon icon-text-h-three"></i>\n </button>\n </span>\n <span class="ql-formats">\n <button type="button" class="ql-jvb_bold">\n <i class="icon icon-text-b-fi"></i>\n </button>\n <button type="button" class="ql-jvb_italic">\n <i class="icon icon-text-italic"></i>\n </button>\n <button type="button" class="ql-jvb_underline">\n <i class="icon icon-text-underline"></i>\n </button>\n <button type="button" class="ql-jvb_strike">\n <i class="icon icon-text-strikethrough"></i>\n </button>\n </span>\n <span class="ql-formats">\n <button type="button" class="ql-jvb_list" value="bullet">\n <i class="icon icon-list-dashes"></i>\n </button>\n <button type="button" class="ql-jvb_list" value="ordered">\n <i class="icon icon-list-numbers"></i>\n </button>\n </span>\n <span class="ql-formats">\n <button type="button" class="ql-jvb_align" value="left">\n <i class="icon icon-text-align-left"></i>\n </button>\n <button type="button" class="ql-jvb_align" value="center">\n <i class="icon icon-text-align-center"></i>\n </button>\n <button type="button" class="ql-jvb_align" value="right">\n <i class="icon icon-text-align-right"></i>\n </button>\n </span>\n <span class="ql-formats">\n <button type="button" class="ql-jvb_link">\n <i class="icon icon-link"></i>\n </button>\n ${e}\n </span>\n `,n.appendChild(l),n.appendChild(i),t.parentNode.insertBefore(n,t),t.style.display="none",i.innerHTML=t.value}const o=new Quill(i,{theme:"snow",modules:{toolbar:{container:l,handlers:{p:function(){this.quill.format("header",!1)},h1:function(){this.quill.format("header",1)},h2:function(){this.quill.format("header",2)},h3:function(){this.quill.format("header",3)},jvb_bold:function(){const t=this.quill.getFormat();this.quill.format("bold",!t.bold)},jvb_italic:function(){const t=this.quill.getFormat();this.quill.format("italic",!t.italic)},jvb_strike:function(){const t=this.quill.getFormat();this.quill.format("strike",!t.strike)},jvb_underline:function(){const t=this.quill.getFormat();this.quill.format("underline",!t.underline)},jvb_align:function(t){const n=this.quill.getFormat();this.quill.format("align",t!==n.align&&t)},jvb_list:function(t){const n=this.quill.getFormat();this.quill.format("list",t!==n.list&&t)},jvb_link:function(t){if(t){const t=this.quill.getSelection();if(null==t||0===t.length)return;const n=this.quill.getFormat(t).link,e=document.createElement("dialog");e.className="quill-link-modal",e.innerHTML=`\n <div class="quill-link-modal-content ">\n <label for="link">Enter URL</label>\n <input type="url" id="link" placeholder="Enter URL" value="${n||""}" />\n <div class="buttons">\n <button type="button" class="save">Save</button>\n ${n?'<button type="button" class="remove">Remove</button>':""}\n <button type="button" class="cancel">Cancel</button>\n </div>\n </div>\n `,document.body.appendChild(e),e.showModal();const i=e.querySelector("input");i.focus(),e.querySelector(".save").addEventListener("click",(()=>{const t=i.value;t&&this.quill.format("link",t),e.remove()}));const l=e.querySelector(".remove");l&&l.addEventListener("click",(()=>{this.quill.format("link",!1),e.remove()})),e.querySelector(".cancel").addEventListener("click",(()=>{e.remove()})),i.addEventListener("keyup",(t=>{if("Enter"===t.key){const t=i.value;t&&this.quill.format("link",t),e.remove()}}))}},jvb_image:function(){const n=t.dataset.postId||t.closest("form")?.dataset.postId,e=document.createElement("input");e.setAttribute("type","file"),e.setAttribute("accept","image/jpeg,image/png,image/gif,image/webp"),e.style.display="none",document.body.appendChild(e),e.onchange=async t=>{const i=t.target.files?.[0];if(!i)return;if(i.size>5242880)return this.quill.insertText(l.index,"File too large. Maximum size is 5MB",{color:"#f00",italic:!0},!0),void e.remove();const l=this.quill.getSelection(!0),o=new FormData;o.append("image",i),n&&o.append("post_id",n);try{const t=await fetch(`${jvbSettings.api}uploads/`,{method:"POST",headers:{"X-WP-Nonce":window.auth.getNonce()},body:o});if(!t.ok)throw new Error("Upload failed");const n=await t.json();this.quill.insertEmbed(l.index,"image",n.url)}catch(t){console.error("Upload error:",t),this.quill.insertText(l.index,"Failed to upload image. Please try again.",{color:"#f00",italic:!0},!0)}finally{e.remove()}},e.click()}}},history:{delay:2e3,maxStack:500},clipboard:{matchVisual:!1}}});e.push(o),o.on("selection-change",(function(t){if(!t)return;const n=o.getFormat(t);Object.entries({"ql-jvb_bold":"bold","ql-jvb_italic":"italic","ql-jvb_underline":"underline","ql-jvb_strike":"strike"}).forEach((([t,e])=>{const i=l.querySelector(`.${t}`);i&&i.classList.toggle("active",!!n[e])})),l.querySelectorAll(".ql-jvb_list").forEach((t=>{const e=t.getAttribute("value");t.classList.toggle("ql-active",n.list===e)})),l.querySelectorAll(".ql-jvb_align").forEach((t=>{const e=t.getAttribute("value");t.classList.toggle("ql-active",n.align===e)}));const e=l.querySelector(".ql-align");if(e){if(0===t.length){const[n]=this.quill.getLeaf(t.index);if(n&&n.domNode&&"IMG"===n.domNode.tagName)return void(e.style.display="inline-block")}e.style.display="none"}})),o.on("text-change",(()=>{t.value=o.root.innerHTML,t.dispatchEvent(new Event("change",{bubbles:!0}))}))})),e};
\ No newline at end of file
diff --git a/assets/js/min/selector.min.js b/assets/js/min/selector.min.js
index 7c70e08..1eb59f2 100644
--- a/assets/js/min/selector.min.js
+++ b/assets/js/min/selector.min.js
@@ -1 +1 @@
-(()=>{class e{constructor(){this.container=document.querySelector("dialog#jvb-selector"),this.container&&(this.a11y=window.jvbA11y,this.error=window.jvbError,this.subscribers=new Set,this.fields=new Map,this.selectedTerms=new Map,this.batchFetch=new Set,this.activeField=null,this.isInitializing=!0,this.lazyInit=!1,this.messageText={},this.init())}init(){this.initStore(),this.initElements(),this.defineTemplates(),this.initModal(),this.scanExistingFields(),this.initListeners(),this.needsCreator()&&window.jvbTaxCreator&&(this.creator=new window.jvbTaxCreator(this)),this.isInitializing=!1,this.batchFetchTaxonomies().then((()=>{}))}initStore(){const e=window.jvbStore.register("taxonomies",{storeName:"terms",keyPath:"id",showLoading:!1,indexes:[{name:"taxonomy",keyPath:"taxonomy"},{name:"parent",keyPath:"parent"},{name:"slug",keyPath:"slug"},{name:"count",keyPath:"count"}],endpoint:"terms",TTL:12e4,filters:{taxonomy:"",page:1,search:"",parent:0},required:"taxonomy",delayFetch:!0});this.store=e.terms,this.store.subscribe(this.handleStoreEvent.bind(this))}defineTemplates(){const e=window.jvbTemplates,t=this;e.define("emptyState"),e.define("selectedTerm",{refs:{name:".item-name",btn:"button"},setup({el:e,refs:t,manyRefs:s,data:i}){e.dataset.id=i.id,e.dataset.taxonomy=i.taxonomy,t.name&&(t.name.textContent=i.path),t.button&&(t.button.title=`Remove ${i.name}`)}}),e.define("termListItem",{refs:{checkbox:"input",label:"label",name:"span, .term-name"},setup({el:e,refs:s,manyRefs:i,data:r}){e.dataset.id=r.id;let a=t.currentField(),n=t.selectedTerms.get(t.activeField).has(r.id),o=a.limit>0&&t.selectedTerms.get(t.activeField).size>=a.limit;if(s.checkbox&&(s.checkbox.dataset.id=r.id,s.checkbox.id=`${a.id}-${r.id}`,s.checkbox.name=`${a.id}-${a.taxonomy}-select`,s.checkbox.value=r.id,s.checkbox.disabled=!n&&o,s.checkbox.checked=n),s.label&&(s.label.htmlFor=`${a.id}-${r.id}`,s.label.title=r.path??r.name,s.label.dataset.path=r.path),s.name&&(s.name.textContent=r.show?r.path:r.name),r.hasChildren){let t={plural:a.plural,name:r.name};const s=window.jvbTemplates.create("termChildrenToggle",t);e.append(s)}}}),e.define("termChildrenToggle",{setup({el:e,refs:t,manyRefs:s,data:i}){e.ariaLabel=`View ${i.plural} nested under ${i.name}`}}),e.define("termBreadcrumb",{setup({el:e,refs:t,manyRefs:s,data:i}){e.dataset.id=i.id,e.textContent=i.name,e.title=i.name}}),e.define("autocompleteItem",{setup({el:e,refs:t,manyRefs:s,data:i}){e.dataset.id=i.id,e.textContent=i.path||i.name,e.title=`Select ${i.name}`}})}initElements(){this.selectors={search:{input:'[type="search"]',clear:".clear-search",container:".search-wrapper",results:".search-results"},create:{button:"button.submit-term",span:".submit-term span"},terms:{list:".items-container",wrap:".items-wrap",sentinel:".scroll-sentinel"},nav:{nav:"nav.term-navigation",back:".back-to-parent",child:".toggle-children",pathLevel:".path-level"},message:{message:"p.message",text:"p.message span"},selected:".selected-items",modal:{title:"#modal-title",content:".modal-content",count:".selection-count"},favourites:".favourite-terms",field:{toggle:'button.taxonomy-toggle, [data-filter="taxonomy"]',value:'input[type="hidden"]',selected:".selected-items",dropdown:{list:".search-results",wrapper:".auto-wrapper"},create:{button:".auto-wrapper .submit-term",span:".auto-wrapper button span"},search:"input[data-autocomplete]",message:{message:"p.message",text:"p.message span"}}},this.ui=window.uiFromSelectors(this.selectors,this.container)}initListeners(){this.observer=new IntersectionObserver((e=>{e.forEach((e=>{e.isIntersecting&&this.nextPage()}))}),{root:this.ui.terms.sentinel,threshold:.5}),this.clickHandler=this.handleClick.bind(this),this.changeHandler=this.handleChange.bind(this),this.inputHandler=this.handleInput.bind(this),this.focusHandler=this.handleFocus.bind(this),this.blurHandler=this.handleBlur.bind(this),document.addEventListener("click",this.clickHandler),document.addEventListener("change",this.changeHandler),document.addEventListener("input",this.inputHandler),document.addEventListener("focus",this.focusHandler,!0),document.addEventListener("blur",this.blurHandler,!0)}handleClick(e){if(!this.container.contains(e.target)&&!e.target.closest('[data-type="selector"], [data-field-type="selector"]'))return;const t=this.getFieldId(e.target)||this.activeField,s=this.fields.get(t);if(!t||!s)return;if(this.creator){window.targetCheck(e,this.selectors.create.button)&&this.maybeCreateTerm(e).then((()=>{}))}const i=window.targetCheck(e,".item.autocomplete");if(i){let e=parseInt(i.dataset.id);return this.addSelected(e,t),this.scheduleHideDropdown(t,6e3),void(s.ui.search&&(s.ui.search.value=""))}if(window.targetCheck(e,this.selectors.field.toggle))return e.preventDefault(),void this.openModal(t);const r=window.targetCheck(e,".remove-term");if(r){const e=r.closest("[data-id]").dataset.id??!1;return void(t&&e&&this.removeSelected(parseInt(e),t))}if(e.target.matches(".modal-close"))return this.updateFieldValue(t),void this.modal?.handleClose();if(window.targetCheck(e,this.selectors.nav.back))return void this.navigateToParent();if(window.targetCheck(e,this.selectors.nav.child)){const t=e.target.closest("li"),s=parseInt(t.dataset.id);return void(s&&this.navigateTo(s))}const a=window.targetCheck(e,this.selectors.nav.pathLevel);if(a){const e=parseInt(a.dataset.id)??0;return void this.navigateTo(e)}if(window.targetCheck(e,this.selectors.field.dropdown))return void this.scheduleHideDropdown(t);if(window.targetCheck(e,this.selectors.search.clear)){const e=this.currentField();e&&e.ui.search&&(e.ui.search.value="",this.store.setFilters({search:"",page:1,parent:this.store.filters.parent||0})),this.ui.search.input&&(this.ui.search.input.value="")}}handleChange(e){if(!this.container.contains(e.target)&&!e.target.closest('[data-type="selector"], [data-field-type="selector"]'))return;if(!["checkbox","button"].includes(e.target.type))return;e.preventDefault(),e.stopPropagation();const t=parseInt(e.target.dataset.id);let s=this.getFieldId(e.target);e.target.checked?this.addSelected(t,s):this.removeSelected(t,s)}handleInput(e){if(!this.container.contains(e.target)&&!e.target.closest('[data-type="selector"], [data-field-type="selector"]'))return;let t=this.getFieldId(e.target)??this.activeField;if(!t)return;const s=this.fields.get(t);if(!s)return;if(["checkbox","button"].includes(e.target.type))return;e.preventDefault(),e.stopPropagation(),this.container.open||this.setField(t);let i=e.target.value.trim();this.setMessage(s,!0,`Searching for "${i}" in ${s.plural??"items"}`),window.debouncer.schedule(`${t}-search`,(async()=>{this.container.open&&window.removeChildren(this.ui.terms.list),await this.store.setFilters({taxonomy:s.taxonomy,search:i,page:1,parent:i?0:this.store.filters.parent||0})}),100)}setField(e){const t=this.fields.get(e);t?(this.activeField=e,this.setMessage(t,!0,`Loading ${t.plural}...`),this.resetFilters({taxonomy:t.taxonomy})):console.error("No field found...")}resetFilters(e){Object.hasOwn(e,"taxonomy")&&(e={page:1,search:"",parent:0,...e},this.store.setFilters(e))}handleFocus(e){if(!this.container.contains(e.target)&&!e.target.closest('[data-type="selector"], [data-field-type="selector"]'))return;const t=this.getFieldId(e.target);if(!t)return;const s=this.fields.get(t);s&&(s.hasAutocomplete||s.hasSearch)&&(window.debouncer.cancel(`${t}-search-results`),this.container.open||this.setField(t))}handleBlur(e){if(!this.container.contains(e.target)&&!e.target.closest('[data-type="selector"], [data-field-type="selector"]'))return;const t=this.getFieldId(e.target);if(!t)return;const s=this.fields.get(t);s&&s.hasAutocomplete&&!this.container.open&&(e.relatedTarget&&s.ui.dropdown.wrapper?.contains(e.relatedTarget)||this.scheduleHideDropdown(t))}scheduleHideDropdown(e,t=1500){const s=this.fields.get(e);s&&window.debouncer.schedule(`${e}-search-results`,(()=>{this.container.open||(this.activeField=null),s.ui.dropdown.wrapper&&(s.ui.dropdown.wrapper.hidden=!0)}),t)}initModal(){this.modalID="dialog#jvb-selector",this.container=document.querySelector(this.modalID),this.modal=new window.jvbModal(this.container,{handleForm:!1,open:null}),this.modal.subscribe(((e,t)=>{if("modal-close"===e)this.closeModal()}))}toggleModal(e,t=!0){this.fields.get(e)&&(t?this.openModal(e):this.closeModal())}openModal(e){const t=this.fields.get(e);if(!t)return;this.setField(e),this.ui.modal.title.textContent=t.isFilter?`Filter by ${t.singular}`:`Select ${t.plural}`,this.ui.search.container&&(this.ui.search.container.hidden=!t.canSearch),this.creator&&this.creator.handleOpen(t);let s=`Opened ${t.singular} selection. Choose from checkboxes, or search to filter results.`;window.removeChildren(this.ui.selected),window.removeChildren(this.ui.terms.list),this.modal.handleOpen(),this.a11y.announce(s)}openEmpty(e,t,s,i){this.emptyCallback=i;const r=`empty-${e}-${Date.now()}`;this.fields.has(r)||(this.fields.set(r,{id:r,taxonomy:e,singular:t,plural:s,canSearch:!0,canCreate:!1,hasAutocomplete:!1,isFilter:!1,isEmpty:!0,limit:0,ui:{},element:null,value:null,toggle:null,checked:!0}),this.selectedTerms.set(r,new Set)),this.setField(r),this.ui.modal.title.textContent=`Add to ${s}`,this.ui.search?.container&&(this.ui.search.container.hidden=!1),window.removeChildren(this.ui.selected),window.removeChildren(this.ui.terms.list),this.modal.handleOpen()}closeModal(){const e=this.fields.get(this.activeField);if(!e)return;if(this.updateFieldValue(this.activeField),this.observer.unobserve(this.ui.terms.sentinel),window.removeChildren(this.ui.terms.list),e.isEmpty&&this.emptyCallback){const t=Array.from(this.selectedTerms.get(this.activeField)||[]),s=t.map((e=>this.store.get(e))).filter(Boolean);this.emptyCallback({taxonomy:e.taxonomy,termIds:t,terms:s}),this.fields.delete(this.activeField),this.selectedTerms.delete(this.activeField),this.emptyCallback=null,this.bulkAssignmentTaxonomy=null}else this.notify("selected-terms",{terms:this.selectedTerms.get(this.activeField),taxonomy:e.taxonomy});this.activeField=null;let t=`Closed ${e.singular} selector.`;this.a11y.announce(t)}navigateToParent(){const e=this.store.filters.parent;if(0===e)return;let t=this.store.get(parseInt(e));if(!t)return void this.navigateTo(0);let s=t.parent;this.navigateTo(parseInt(s))}navigateTo(e=0){e=parseInt(e)??0,this.store.setFilters({parent:e,page:1}),window.removeChildren(this.ui.terms.list),this.updateBreadcrumbs(e)}nextPage(){let e=this.store.filters.page,t=Math.min(e++,this.store.lastResponse.total);this.store.setFilters({page:t})}prevPage(){let e=this.store.filters.page,t=Math.max(e-1,1);this.store.setFilters({page:t})}addTermToModal(e){const t=this.store.get(e);if(!t)return;this.currentField()&&(this.ui.selected.querySelector(`[data-id="${e}"]`)||this.ui.selected.append(this.getSelectedTermUI(t)))}getSelectedTermUI(e,t=!0){return window.jvbTemplates.create("selectedTerm",e)}scanExistingFields(e=document.body){e.querySelectorAll('[data-type="selector"], [data-field-type="selector"]').forEach((e=>{try{e.dataset.lazy?this.lazyInit=!0:this.registerField(e)}catch(t){this.error.log(t,{component:"TaxonomySelector",action:"scanExistingFields",container:e.dataset.name})}})),this.lazyInit&&this.initObserver(e)}unregisterFields(e){e.querySelectorAll('[data-type="selector"],[data-field-type="selector"]').forEach((e=>{this.fields.delete(e.dataset.fieldId)}))}initObserver(e){this.lazyObserver=new IntersectionObserver((e=>{e.forEach((e=>{e.isIntersecting&&e.target.dataset.lazy&&(delete e.target.dataset.lazy,this.registerField(e.target),this.lazyObserver.unobserve(e.target))}))}),{rootMargin:"50px"}),e.querySelectorAll('[data-type="selector"][data-lazy], [data-field-type="selector"][data-lazy]').forEach((e=>{this.lazyObserver.observe(e)}))}registerField(e,t={}){if(e.dataset.fieldId&&this.fields.has(e.dataset.fieldId))return e.dataset.fieldId;let s=e.querySelector('input[type="hidden"]');if(!s&&!Object.hasOwn(e.dataset,"filter"))return;"fieldId"in e.dataset||(e.dataset.fieldId=window.generateID("selector"));const i=e.dataset.fieldId;let r=this.selectors.field;const a=Object.hasOwn(e.dataset,"filter")&&"taxonomy"===e.dataset.filter;let n=a?e:e.querySelector("button.taxonomy-toggle");if(0===Object.keys(t).length){if(!n)return;t={taxonomy:n.dataset.taxonomy,single:n.dataset.single,plural:n.dataset.plural,search:Object.hasOwn(n.dataset,"search"),autocomplete:Object.hasOwn(n.dataset,"autocomplete"),creatable:Object.hasOwn(n.dataset,"creatable")}}else Object.hasOwn(t,"toggle")&&(n=document.querySelector(t.toggle),r.toggle=t.toggle);const o={id:i,value:s,element:e,taxonomy:t.taxonomy??!1,singular:t.single??"",plural:t.plural??"",name:e.dataset.field,canSearch:t.search??!1,limit:t.limit??0,hasAutocomplete:t.autocomplete??!1,canCreate:t.creatable??!1,isRequired:t.required??!1,isFilter:a,toggle:n,create:{button:null,span:null},selectors:r,ui:window.uiFromSelectors(r,e),checked:!1};if(a&&!o.ui.toggle&&(o.ui.toggle=e),o.taxonomy)return o.singular&&o.plural||(console.warn("TaxonomySelector: Field missing singular/plural labels",e),o.singular=o.taxonomy.replace("jvb_",""),o.plural=o.singular+"s"),this.fields.set(i,o),this.setSelectedFromValue(i,s),this.isInitializing&&this.batchFetch.add(o.taxonomy),null!==e.offsetParent?this.updateFieldUI(i):requestIdleCallback((()=>{null!==e.offsetParent&&this.updateFieldUI(i)}),{timeout:2e3}),i;console.error("TaxonomySelector: Field missing taxonomy",e)}setSelectedFromValue(e,t){if(!e)return;let s=this.fields.get(e);if(!s)return;if(!t&&!s.isFilter)return;let i=new Set;t&&t.value.trim().split(",").map((e=>parseInt(e.trim()))).filter((e=>!isNaN(e))).forEach((e=>i.add(e))),this.selectedTerms.set(e,i)}addSelected(e,t=null){t||(t=this.activeField);const s=this.fields.get(t),i=this.store.get(e);if(!s||!i)return;const r=this.selectedTerms.get(t);0!==s.limit&&r.size>=s.limit||(r.add(parseInt(e)),this.container.open||s.isFilter||this.updateFieldValue(t),this.addTermToDisplay(e,t),this.checkLimits(t))}removeSelected(e,t=null){t||(t=this.activeField);const s=this.fields.get(t),i=this.store.get(e);if(!s||!i)return;this.selectedTerms.get(t).delete(parseInt(e));const r=!!s.ui.selected&&s.ui.selected.querySelector(`[data-id="${e}"]`);if(r&&r.remove(),this.container.open){let t=!!this.ui.selected&&this.ui.selected.querySelector(`[data-id="${e}"]`);t&&t.remove();let s=this.ui.terms.list.querySelector(`[type=checkbox][data-id="${e}"]`);s&&(s.checked=!1)}this.container.open||s.isFilter||this.updateFieldValue(t),this.checkLimits(t)}updateFieldValue(e){const t=this.fields.get(e);if(!t)return;let s=Array.from(this.selectedTerms.get(e));t.ui.value&&(t.ui.value.value=s.join(",")??"",t.ui.value.dispatchEvent(new Event("change",{bubbles:!0})))}checkLimits(e){if(!this.container.open)return;const t=this.fields.get(e);if(!t||!t.isFilter||0===t.limit)return;const s=this.selectedTerms.get(e).size>=t.limit;this.setCheckboxes(s)}updateFieldFromInput(e){const t=this.getFieldId(e);if(!t)return;this.fields.get(t)&&(this.setSelectedFromValue(t,e),this.updateFieldUI(t))}updateFieldUI(e){const t=this.fields.get(e);let s=this.selectedTerms.get(e)??new Set;t&&!t.isFilter&&0!==s.size&&Array.from(s).forEach((t=>{this.addTermToDisplay(t,e)}))}updateFieldsForTaxonomy(e){let t=Array.from(this.fields.values()).filter((t=>t.taxonomy===e));const s=Array.from(this.store.data.values()).some((t=>t&&t.taxonomy===e));t.forEach((e=>{e.toggle&&(e.toggle.disabled=!s&&!e.canCreate,e.toggle.title=s?`Select ${e.plural}`:`No ${e.singular} available`,e.checked=!0)}))}showModalTerms(e=!1){const t=this.currentField(),s=this.store.getFiltered();if(0===s.length)return(this.store.filters.page??1)&&window.removeChildren(this.ui.terms.list),this.setMessage(t,!0,""===this.store.filters.search?`No matching ${t.plural}.`:`No ${t.plural} found.`,!1),void(this.ui.terms.sentinel&&this.observer.unobserve(this.ui.terms.sentinel));this.setCreateButton(t,!0),this.ui.terms.sentinel&&(this.store.lastResponse?.has_more?this.observer.observe(this.ui.terms.sentinel):this.observer.unobserve(this.ui.terms.sentinel));const i=this.store.filters.parent??0;this.ui.nav.back.hidden=0===i,window.chunkIt(s,(t=>this.createTermElement({show:e,...t})),(e=>this.ui.terms.list.append(e)),10).then((()=>{})),s.length>0&&this.setMessage(t,!1)}createTermElement(e){return e&&e.name?window.jvbTemplates.create("termListItem",e):null}showAutocompleteTerms(){const e=this.currentField();if(!e||!e.hasAutocomplete||!e.ui.dropdown?.list)return;const t=e.ui.dropdown.list,s=this.currentTerms();window.removeChildren(t),0===s.length?this.setMessage(e,!0,`No ${e.plural} found.`,!1):(window.chunkIt(s,(e=>this.createAutocompleteTerm(e)),(e=>t.append(e))).then((()=>{})),this.setMessage(e,!1)),this.setCreateButton(e,!0),e.ui.dropdown.wrapper&&(e.ui.dropdown.wrapper.hidden=!1)}createAutocompleteTerm(e){return window.jvbTemplates.create("autocompleteItem",e)}addTermToDisplay(e,t){const s=this.store.get(e),i=this.fields.get(t);if(!s||!i)return;if(i.ui.selected&&i.ui.selected.querySelector(`[data-id="${e}"]`))return;let r=this.getSelectedTermUI(s);if(i.ui.selected&&i.ui.selected.append(r),this.container.open){this.addTermToModal(e);const t=this.ui.terms.list.querySelector(`input[value="${e}"]`);t&&(t.checked=!0)}}updateBreadcrumbs(e){const t=this.ui.nav.nav;if(!t)return;const s=Array.from(t.children).find((t=>parseInt(t.dataset.id)===e));if(s){let e=s.nextElementSibling;for(;e;){const t=e;e=e.nextElementSibling,t.remove()}}else{const s=this.store.get(e);if(!s)return;const i=window.jvbTemplates.create("termBreadcrumb",s);t.append(i)}}updateSelectionCount(){if(!this.container.open)return;const e=this.fields.get(this.activeField);if(e&&this.ui.modal.count){const t=this.selectedTerms.get(this.activeField).size;this.ui.modal.count.textContent=e.limit>0?`${t} of ${e.limit} ${e.plural} selected`:`${t} ${e.plural} selected`}}checkRendered(e,t){if(e)return Object.hasOwn(e,t.taxonomy)||(e[t.taxonomy]=new Map),e[t.taxonomy].has(t.id)}currentField(){return this.fields.get(this.activeField)??!1}currentTerms(){return this.store.getFiltered()}needsCreator(){return Array.from(this.fields.values()).some((e=>e.canCreate||e.hasAutocomplete))}getFieldId(e){if(e.dataset.fieldId)return e.dataset.fieldId;const t=e.closest("[data-field-id]");return t?.dataset.fieldId||null}setCheckboxes(e){this.ui.terms.list.querySelectorAll("input[type=checkbox]").forEach((t=>{t.checked||(t.disabled=e)}))}handleStoreEvent(e,t){const s={"data-loaded":()=>this.handleDataLoaded(),"filters-changed":()=>this.handleFiltersChanged(t),"fetch-error":()=>this.handleFetchError()};try{s[e]?.(t)}catch(t){console.error(`Error handling store event "${e}":`,t)}}handleDataLoaded(){const e=this.store.filters.taxonomy;if(e){e.split(",").map((e=>e.trim())).forEach((e=>this.updateFieldsForTaxonomy(e)))}this.container.open?this.showResults():this.activeField&&this.showResults(!0)}showResults(e=!1){const t=this.store.getFiltered(),s=this.store.filters,i=s.search&&s.search.length>0;this.notify("terms-loaded",{terms:t,filters:s}),!this.activeField&&e||(this.setMessage(this.currentField(),!1),e?this.showAutocompleteTerms():this.showModalTerms(i),this.a11y.announce(t.length))}handleFiltersChanged(e){}handleFetchError(e){const t=this.currentField(),s=t?`Failed to load ${t.plural}`:"Failed to load data";this.setMessage(t,!0,s,!1),console.error("Store fetch error:",e)}async batchFetchTaxonomies(){if(0===this.batchFetch.size)return;const e=Array.from(this.batchFetch);this.batchFetch.clear();try{await this.store.setFilters({taxonomy:e.join(","),page:1,search:"",parent:0})}catch(e){console.error("Failed to batch fetch taxonomies:",e)}}preloadTaxonomy(e){this.store.setFilters({taxonomy:e,page:1,search:"",parent:0})}setCreateButton(e,t=!0){if(!e.canCreate||!this.creator)return;const s=this.container.open?this.ui:e.ui;if(!s.create?.button||!s.create?.span)return;const i=s.create.button;i.hidden=!t;const r=s.create.span,a=this.container.open?s.search.input:s.search;if(!a)return;let n=(this.currentTerms()??[]).map((e=>e.name)),o=a.value;const l=t&&o.length>=2&&!n.includes(o);i.hidden=!l,l&&(r.textContent=a.value??"")}async maybeCreateTerm(e){const t=this.currentField();if(!t)return;window.debouncer.cancel(`${t.id}-search-results`);let s={taxonomy:t.taxonomy,parent:this.store.filters.parent??0};if(this.container.open&&""===this.ui.search.input.value?(s.parent=this.creator.ui.parent.value??s.parent,s.name=this.creator.ui.name.value??!1):s.name=this.container.open?this.ui.search.input.value:t.ui.search.value,void 0!==s.parent&&s.name){this.setMessage(t,!0,`Creating "${s.name}"...`),this.setCreateButton(t,!1),this.container.open?window.removeChildren(this.ui.terms.list):(t.ui.search.disabled=!0,t.ui.dropdown.wrapper&&(t.ui.dropdown.wrapper.hidden=!1));let e=await this.creator.handleTermCreation(s);if(e){if(this.setMessage(t,!0,`"${e.name}" created!`,!1),this.addSelected(e.id,t.id),this.updateFieldValue(t.id),!this.container.open&&t.ui.dropdown.list){window.removeChildren(t.ui.dropdown.list);const s=this.createAutocompleteTerm(e);s&&(s.classList.add("newly-created"),t.ui.dropdown.list.append(s))}this.scheduleHideDropdown(t.id,300),this.setMessage(t,!1)}else this.setMessage(t,!1),!this.container.open&&t.ui.dropdown.wrapper&&(t.ui.dropdown.wrapper.hidden=!0);this.container.open||(t.ui.search.disabled=!1,t.ui.search.value="")}}setMessage(e,t=!0,s="",i=!0){const r=this.container.open||e.isFilter?this.ui:e.isFilter?null:e.ui;if(!r?.message?.message)return;s=""===s?`No ${e.plural??"items"} found.`:s;const a=r.message.message,n=r.message.text;a.hidden=!t,t?s&&n&&(i&&window.typeLoop&&n?(this.messageText[e.id]&&(this.messageText[e.id](),delete this.messageText[e.id]),this.messageText[e.id]=window.typeLoop(n,s)):n.textContent=s):this.messageText[e.id]&&(this.messageText[e.id](),delete this.messageText[e.id])}subscribe(e){return this.subscribers.add(e),()=>this.subscribers.delete(e)}notify(e,t={}){this.subscribers.forEach((s=>{try{s(e,t)}catch(e){console.error("Subscriber error:",e)}}))}destroy(){this.fields.forEach(((e,t)=>{window.debouncer.cancel(`${t}-search`),window.debouncer.cancel(`${t}-search-results`)})),Object.keys(this.messageText).forEach((e=>{this.messageText[e]&&this.messageText[e]()})),this.messageText={},this.ui.terms?.sentinel&&this.observer?.unobserve(this.ui.terms.sentinel),this.observer?.disconnect(),this.lazyObserver?.disconnect(),document.removeEventListener("click",this.clickHandler),document.removeEventListener("change",this.changeHandler),document.removeEventListener("input",this.inputHandler),document.removeEventListener("focus",this.focusHandler,!0),document.removeEventListener("blur",this.blurHandler,!0),this.subscribers.clear(),this.fields.clear(),this.selectedTerms.clear(),this.batchFetch.clear(),this.creator&&(this.creator.destroy(),this.creator=null),this.store&&(this.store=null)}}document.addEventListener("DOMContentLoaded",(function(){window.auth.subscribe((t=>{"auth-loaded"===t&&(window.jvbSelector=new e)}))}))})();
\ No newline at end of file
+(()=>{class e{constructor(){this.container=document.querySelector("dialog#jvb-selector"),this.container&&(this.a11y=window.jvbA11y,this.error=window.jvbError,this.subscribers=new Set,this.fields=new Map,this.selectedTerms=new Map,this.batchFetch=new Set,this.activeField=null,this.isInitializing=!0,this.lazyInit=!1,this.messageText={},this.init())}init(){this.initStore(),this.initElements(),this.defineTemplates(),this.initModal(),this.scanExistingFields(),this.initListeners(),this.needsCreator()&&window.jvbTaxCreator&&(this.creator=new window.jvbTaxCreator(this)),this.isInitializing=!1,this.batchFetchTaxonomies().then((()=>{}))}initStore(){const e=window.jvbStore.register("taxonomies",{storeName:"terms",keyPath:"id",showLoading:!1,indexes:[{name:"taxonomy",keyPath:"taxonomy"},{name:"parent",keyPath:"parent"},{name:"slug",keyPath:"slug"},{name:"count",keyPath:"count"}],endpoint:"terms",TTL:12e4,filters:{taxonomy:"",page:1,search:"",parent:0},required:"taxonomy",delayFetch:!0});this.store=e.terms,this.store.subscribe(this.handleStoreEvent.bind(this))}defineTemplates(){const e=window.jvbTemplates,t=this;e.define("emptyState"),e.define("selectedTerm",{refs:{name:".item-name",btn:"button"},setup({el:e,refs:t,manyRefs:s,data:i}){e.dataset.id=i.id,e.dataset.taxonomy=i.taxonomy,t.name&&(t.name.textContent=i.path),t.button&&(t.button.title=`Remove ${i.name}`)}}),e.define("termListItem",{refs:{checkbox:"input",label:"label",name:"span, .term-name"},setup({el:e,refs:s,manyRefs:i,data:r}){e.dataset.id=r.id;let a=t.currentField(),n=t.selectedTerms.get(t.activeField).has(r.id),o=a.limit>0&&t.selectedTerms.get(t.activeField).size>=a.limit;if(s.checkbox&&(s.checkbox.dataset.id=r.id,s.checkbox.id=`${a.id}-${r.id}`,s.checkbox.name=`${a.id}-${a.taxonomy}-select`,s.checkbox.value=r.id,s.checkbox.disabled=!n&&o,s.checkbox.checked=n),s.label&&(s.label.htmlFor=`${a.id}-${r.id}`,s.label.title=r.path??r.name,s.label.dataset.path=r.path),s.name&&(s.name.textContent=r.show?r.path:r.name),r.hasChildren){let t={plural:a.plural,name:r.name};const s=window.jvbTemplates.create("termChildrenToggle",t);e.append(s)}}}),e.define("termChildrenToggle",{setup({el:e,refs:t,manyRefs:s,data:i}){e.ariaLabel=`View ${i.plural} nested under ${i.name}`}}),e.define("termBreadcrumb",{setup({el:e,refs:t,manyRefs:s,data:i}){e.dataset.id=i.id,e.textContent=i.name,e.title=i.name}}),e.define("autocompleteItem",{setup({el:e,refs:t,manyRefs:s,data:i}){e.dataset.id=i.id,e.textContent=i.path||i.name,e.title=`Select ${i.name}`}})}initElements(){this.selectors={search:{input:'[type="search"]',clear:".clear-search",container:".search-wrapper",results:".search-results"},create:{button:"button.submit-term",span:".submit-term span"},terms:{list:".items-container",wrap:".items-wrap",sentinel:".scroll-sentinel"},nav:{nav:"nav.term-navigation",back:".back-to-parent",child:".toggle-children",pathLevel:".path-level"},message:{message:"p.message",text:"p.message span"},selected:".selected-items",modal:{title:"#modal-title",content:".modal-content",count:".selection-count"},favourites:".favourite-terms",field:{toggle:'button.selector-toggle, [data-filter="taxonomy"]',value:'input[type="hidden"]',selected:".selected-items",dropdown:{list:".search-results",wrapper:".auto-wrapper"},create:{button:".auto-wrapper .submit-term",span:".auto-wrapper button span"},search:"input[data-autocomplete]",message:{message:"p.message",text:"p.message span"}}},this.ui=window.uiFromSelectors(this.selectors,this.container)}initListeners(){this.observer=new IntersectionObserver((e=>{e.forEach((e=>{e.isIntersecting&&this.nextPage()}))}),{root:this.ui.terms.sentinel,threshold:.5}),this.clickHandler=this.handleClick.bind(this),this.changeHandler=this.handleChange.bind(this),this.inputHandler=this.handleInput.bind(this),this.focusHandler=this.handleFocus.bind(this),this.blurHandler=this.handleBlur.bind(this),document.addEventListener("click",this.clickHandler),document.addEventListener("change",this.changeHandler),document.addEventListener("input",this.inputHandler),document.addEventListener("focus",this.focusHandler,!0),document.addEventListener("blur",this.blurHandler,!0)}handleClick(e){if(!this.container.contains(e.target)&&!e.target.closest('[data-type="selector"], [data-field-type="selector"]'))return;const t=this.getFieldId(e.target)||this.activeField,s=this.fields.get(t);if(!t||!s)return;if(this.creator){window.targetCheck(e,this.selectors.create.button)&&this.maybeCreateTerm(e).then((()=>{}))}const i=window.targetCheck(e,".item.autocomplete");if(i){let e=parseInt(i.dataset.id);return this.addSelected(e,t),this.scheduleHideDropdown(t,6e3),void(s.ui.search&&(s.ui.search.value=""))}if(window.targetCheck(e,this.selectors.field.toggle))return e.preventDefault(),void this.openModal(t);const r=window.targetCheck(e,".remove-term");if(r){const e=r.closest("[data-id]").dataset.id??!1;return void(t&&e&&this.removeSelected(parseInt(e),t))}if(e.target.matches(".modal-close"))return this.updateFieldValue(t),void this.modal?.handleClose();if(window.targetCheck(e,this.selectors.nav.back))return void this.navigateToParent();if(window.targetCheck(e,this.selectors.nav.child)){const t=e.target.closest("li"),s=parseInt(t.dataset.id);return void(s&&this.navigateTo(s))}const a=window.targetCheck(e,this.selectors.nav.pathLevel);if(a){const e=parseInt(a.dataset.id)??0;return void this.navigateTo(e)}if(window.targetCheck(e,this.selectors.field.dropdown))return void this.scheduleHideDropdown(t);if(window.targetCheck(e,this.selectors.search.clear)){const e=this.currentField();e&&e.ui.search&&(e.ui.search.value="",this.store.setFilters({search:"",page:1,parent:this.store.filters.parent||0})),this.ui.search.input&&(this.ui.search.input.value="")}}handleChange(e){if(!this.container.contains(e.target)&&!e.target.closest('[data-type="selector"], [data-field-type="selector"]'))return;if(!["checkbox","button"].includes(e.target.type))return;e.preventDefault(),e.stopPropagation();const t=parseInt(e.target.dataset.id);let s=this.getFieldId(e.target);e.target.checked?this.addSelected(t,s):this.removeSelected(t,s)}handleInput(e){if(!this.container.contains(e.target)&&!e.target.closest('[data-type="selector"], [data-field-type="selector"]'))return;let t=this.getFieldId(e.target)??this.activeField;if(!t)return;const s=this.fields.get(t);if(!s)return;if(["checkbox","button"].includes(e.target.type))return;e.preventDefault(),e.stopPropagation(),this.container.open||this.setField(t);let i=e.target.value.trim();this.setMessage(s,!0,`Searching for "${i}" in ${s.plural??"items"}`),window.debouncer.schedule(`${t}-search`,(async()=>{this.container.open&&window.removeChildren(this.ui.terms.list),await this.store.setFilters({taxonomy:s.taxonomy,search:i,page:1,parent:i?0:this.store.filters.parent||0})}),100)}setField(e){const t=this.fields.get(e);t?(this.activeField=e,this.setMessage(t,!0,`Loading ${t.plural}...`),this.resetFilters({taxonomy:t.taxonomy})):console.error("No field found...")}resetFilters(e){Object.hasOwn(e,"taxonomy")&&(e={page:1,search:"",parent:0,...e},this.store.setFilters(e))}handleFocus(e){if(!this.container.contains(e.target)&&!e.target.closest('[data-type="selector"], [data-field-type="selector"]'))return;const t=this.getFieldId(e.target);if(!t)return;const s=this.fields.get(t);s&&(s.hasAutocomplete||s.hasSearch)&&(window.debouncer.cancel(`${t}-search-results`),this.container.open||this.setField(t))}handleBlur(e){if(!this.container.contains(e.target)&&!e.target.closest('[data-type="selector"], [data-field-type="selector"]'))return;const t=this.getFieldId(e.target);if(!t)return;const s=this.fields.get(t);s&&s.hasAutocomplete&&!this.container.open&&(e.relatedTarget&&s.ui.dropdown.wrapper?.contains(e.relatedTarget)||this.scheduleHideDropdown(t))}scheduleHideDropdown(e,t=1500){const s=this.fields.get(e);s&&window.debouncer.schedule(`${e}-search-results`,(()=>{this.container.open||(this.activeField=null),s.ui.dropdown.wrapper&&(s.ui.dropdown.wrapper.hidden=!0)}),t)}initModal(){this.modalID="dialog#jvb-selector",this.container=document.querySelector(this.modalID),this.modal=new window.jvbModal(this.container,{handleForm:!1,open:null}),this.modal.subscribe(((e,t)=>{if("modal-close"===e)this.closeModal()}))}toggleModal(e,t=!0){this.fields.get(e)&&(t?this.openModal(e):this.closeModal())}openModal(e){const t=this.fields.get(e);if(!t)return;this.setField(e),this.ui.modal.title.textContent=t.isFilter?`Filter by ${t.singular}`:`Select ${t.plural}`,this.ui.search.container&&(this.ui.search.container.hidden=!t.canSearch),this.creator&&this.creator.handleOpen(t);let s=`Opened ${t.singular} selection. Choose from checkboxes, or search to filter results.`;window.removeChildren(this.ui.selected),window.removeChildren(this.ui.terms.list),this.modal.handleOpen(),this.a11y.announce(s)}openEmpty(e,t,s,i){this.emptyCallback=i;const r=`empty-${e}-${Date.now()}`;this.fields.has(r)||(this.fields.set(r,{id:r,taxonomy:e,singular:t,plural:s,canSearch:!0,canCreate:!1,hasAutocomplete:!1,isFilter:!1,isEmpty:!0,limit:0,ui:{},element:null,value:null,toggle:null,checked:!0}),this.selectedTerms.set(r,new Set)),this.setField(r),this.ui.modal.title.textContent=`Add to ${s}`,this.ui.search?.container&&(this.ui.search.container.hidden=!1),window.removeChildren(this.ui.selected),window.removeChildren(this.ui.terms.list),this.modal.handleOpen()}closeModal(){const e=this.fields.get(this.activeField);if(!e)return;if(this.updateFieldValue(this.activeField),this.observer.unobserve(this.ui.terms.sentinel),window.removeChildren(this.ui.terms.list),e.isEmpty&&this.emptyCallback){const t=Array.from(this.selectedTerms.get(this.activeField)||[]),s=t.map((e=>this.store.get(e))).filter(Boolean);this.emptyCallback({taxonomy:e.taxonomy,termIds:t,terms:s}),this.fields.delete(this.activeField),this.selectedTerms.delete(this.activeField),this.emptyCallback=null,this.bulkAssignmentTaxonomy=null}else this.notify("selected-terms",{terms:this.selectedTerms.get(this.activeField),taxonomy:e.taxonomy});this.activeField=null;let t=`Closed ${e.singular} selector.`;this.a11y.announce(t)}navigateToParent(){const e=this.store.filters.parent;if(0===e)return;let t=this.store.get(parseInt(e));if(!t)return void this.navigateTo(0);let s=t.parent;this.navigateTo(parseInt(s))}navigateTo(e=0){e=parseInt(e)??0,this.store.setFilters({parent:e,page:1}),window.removeChildren(this.ui.terms.list),this.updateBreadcrumbs(e)}nextPage(){let e=this.store.filters.page,t=Math.min(e++,this.store.lastResponse.total);this.store.setFilters({page:t})}prevPage(){let e=this.store.filters.page,t=Math.max(e-1,1);this.store.setFilters({page:t})}addTermToModal(e){const t=this.store.get(e);if(!t)return;this.currentField()&&(this.ui.selected.querySelector(`[data-id="${e}"]`)||this.ui.selected.append(this.getSelectedTermUI(t)))}getSelectedTermUI(e,t=!0){return window.jvbTemplates.create("selectedTerm",e)}scanExistingFields(e=document.body){e.querySelectorAll('[data-type="selector"], [data-field-type="selector"]').forEach((e=>{try{e.dataset.lazy?this.lazyInit=!0:this.registerField(e)}catch(t){this.error.log(t,{component:"TaxonomySelector",action:"scanExistingFields",container:e.dataset.name})}})),this.lazyInit&&this.initObserver(e)}unregisterFields(e){e.querySelectorAll('[data-type="selector"],[data-field-type="selector"]').forEach((e=>{this.fields.delete(e.dataset.fieldId)}))}initObserver(e){this.lazyObserver=new IntersectionObserver((e=>{e.forEach((e=>{e.isIntersecting&&e.target.dataset.lazy&&(delete e.target.dataset.lazy,this.registerField(e.target),this.lazyObserver.unobserve(e.target))}))}),{rootMargin:"50px"}),e.querySelectorAll('[data-type="selector"][data-lazy], [data-field-type="selector"][data-lazy]').forEach((e=>{this.lazyObserver.observe(e)}))}registerField(e,t={}){if(e.dataset.fieldId&&this.fields.has(e.dataset.fieldId))return e.dataset.fieldId;let s=e.querySelector('input[type="hidden"]');if(!s&&!Object.hasOwn(e.dataset,"filter"))return;"fieldId"in e.dataset||(e.dataset.fieldId=window.generateID("selector"));const i=e.dataset.fieldId;let r=this.selectors.field;const a=Object.hasOwn(e.dataset,"filter")&&"taxonomy"===e.dataset.filter;let n=a?e:e.querySelector("button.taxonomy-toggle");if(0===Object.keys(t).length){if(!n)return;t={taxonomy:n.dataset.taxonomy,single:n.dataset.single,plural:n.dataset.plural,search:Object.hasOwn(n.dataset,"search"),autocomplete:Object.hasOwn(n.dataset,"autocomplete"),creatable:Object.hasOwn(n.dataset,"creatable")}}else Object.hasOwn(t,"toggle")&&(n=document.querySelector(t.toggle),r.toggle=t.toggle);const o={id:i,value:s,element:e,taxonomy:t.taxonomy??!1,singular:t.single??"",plural:t.plural??"",name:e.dataset.field,canSearch:t.search??!1,limit:t.limit??0,hasAutocomplete:t.autocomplete??!1,canCreate:t.creatable??!1,isRequired:t.required??!1,isFilter:a,toggle:n,create:{button:null,span:null},selectors:r,ui:window.uiFromSelectors(r,e),checked:!1};if(a&&!o.ui.toggle&&(o.ui.toggle=e),o.taxonomy)return o.singular&&o.plural||(console.warn("TaxonomySelector: Field missing singular/plural labels",e),o.singular=o.taxonomy.replace("jvb_",""),o.plural=o.singular+"s"),this.fields.set(i,o),this.setSelectedFromValue(i,s),this.isInitializing&&this.batchFetch.add(o.taxonomy),null!==e.offsetParent?this.updateFieldUI(i):requestIdleCallback((()=>{null!==e.offsetParent&&this.updateFieldUI(i)}),{timeout:2e3}),i;console.error("TaxonomySelector: Field missing taxonomy",e)}setSelectedFromValue(e,t){if(!e)return;let s=this.fields.get(e);if(!s)return;if(!t&&!s.isFilter)return;let i=new Set;t&&t.value.trim().split(",").map((e=>parseInt(e.trim()))).filter((e=>!isNaN(e))).forEach((e=>i.add(e))),this.selectedTerms.set(e,i)}addSelected(e,t=null){t||(t=this.activeField);const s=this.fields.get(t),i=this.store.get(e);if(!s||!i)return;const r=this.selectedTerms.get(t);0!==s.limit&&r.size>=s.limit||(r.add(parseInt(e)),this.container.open||s.isFilter||this.updateFieldValue(t),this.addTermToDisplay(e,t),this.checkLimits(t))}removeSelected(e,t=null){t||(t=this.activeField);const s=this.fields.get(t),i=this.store.get(e);if(!s||!i)return;this.selectedTerms.get(t).delete(parseInt(e));const r=!!s.ui.selected&&s.ui.selected.querySelector(`[data-id="${e}"]`);if(r&&r.remove(),this.container.open){let t=!!this.ui.selected&&this.ui.selected.querySelector(`[data-id="${e}"]`);t&&t.remove();let s=this.ui.terms.list.querySelector(`[type=checkbox][data-id="${e}"]`);s&&(s.checked=!1)}this.container.open||s.isFilter||this.updateFieldValue(t),this.checkLimits(t)}updateFieldValue(e){const t=this.fields.get(e);if(!t)return;let s=Array.from(this.selectedTerms.get(e));t.ui.value&&(t.ui.value.value=s.join(",")??"",t.ui.value.dispatchEvent(new Event("change",{bubbles:!0})))}checkLimits(e){if(!this.container.open)return;const t=this.fields.get(e);if(!t||!t.isFilter||0===t.limit)return;const s=this.selectedTerms.get(e).size>=t.limit;this.setCheckboxes(s)}updateFieldFromInput(e){const t=this.getFieldId(e);if(!t)return;this.fields.get(t)&&(this.setSelectedFromValue(t,e),this.updateFieldUI(t))}updateFieldUI(e){const t=this.fields.get(e);let s=this.selectedTerms.get(e)??new Set;t&&!t.isFilter&&0!==s.size&&Array.from(s).forEach((t=>{this.addTermToDisplay(t,e)}))}updateFieldsForTaxonomy(e){let t=Array.from(this.fields.values()).filter((t=>t.taxonomy===e));const s=Array.from(this.store.data.values()).some((t=>t&&t.taxonomy===e));t.forEach((e=>{e.toggle&&(e.toggle.disabled=!s&&!e.canCreate,e.toggle.title=s?`Select ${e.plural}`:`No ${e.singular} available`,e.checked=!0)}))}showModalTerms(e=!1){const t=this.currentField(),s=this.store.getFiltered();if(0===s.length)return(this.store.filters.page??1)&&window.removeChildren(this.ui.terms.list),this.setMessage(t,!0,""===this.store.filters.search?`No matching ${t.plural}.`:`No ${t.plural} found.`,!1),void(this.ui.terms.sentinel&&this.observer.unobserve(this.ui.terms.sentinel));this.setCreateButton(t,!0),this.ui.terms.sentinel&&(this.store.lastResponse?.has_more?this.observer.observe(this.ui.terms.sentinel):this.observer.unobserve(this.ui.terms.sentinel));const i=this.store.filters.parent??0;this.ui.nav.back.hidden=0===i,window.chunkIt(s,(t=>this.createTermElement({show:e,...t})),(e=>this.ui.terms.list.append(e)),10).then((()=>{})),s.length>0&&this.setMessage(t,!1)}createTermElement(e){return e&&e.name?window.jvbTemplates.create("termListItem",e):null}showAutocompleteTerms(){const e=this.currentField();if(!e||!e.hasAutocomplete||!e.ui.dropdown?.list)return;const t=e.ui.dropdown.list,s=this.currentTerms();window.removeChildren(t),0===s.length?this.setMessage(e,!0,`No ${e.plural} found.`,!1):(window.chunkIt(s,(e=>this.createAutocompleteTerm(e)),(e=>t.append(e))).then((()=>{})),this.setMessage(e,!1)),this.setCreateButton(e,!0),e.ui.dropdown.wrapper&&(e.ui.dropdown.wrapper.hidden=!1)}createAutocompleteTerm(e){return window.jvbTemplates.create("autocompleteItem",e)}addTermToDisplay(e,t){const s=this.store.get(e),i=this.fields.get(t);if(!s||!i)return;if(i.ui.selected&&i.ui.selected.querySelector(`[data-id="${e}"]`))return;let r=this.getSelectedTermUI(s);if(i.ui.selected&&i.ui.selected.append(r),this.container.open){this.addTermToModal(e);const t=this.ui.terms.list.querySelector(`input[value="${e}"]`);t&&(t.checked=!0)}}updateBreadcrumbs(e){const t=this.ui.nav.nav;if(!t)return;const s=Array.from(t.children).find((t=>parseInt(t.dataset.id)===e));if(s){let e=s.nextElementSibling;for(;e;){const t=e;e=e.nextElementSibling,t.remove()}}else{const s=this.store.get(e);if(!s)return;const i=window.jvbTemplates.create("termBreadcrumb",s);t.append(i)}}updateSelectionCount(){if(!this.container.open)return;const e=this.fields.get(this.activeField);if(e&&this.ui.modal.count){const t=this.selectedTerms.get(this.activeField).size;this.ui.modal.count.textContent=e.limit>0?`${t} of ${e.limit} ${e.plural} selected`:`${t} ${e.plural} selected`}}checkRendered(e,t){if(e)return Object.hasOwn(e,t.taxonomy)||(e[t.taxonomy]=new Map),e[t.taxonomy].has(t.id)}currentField(){return this.fields.get(this.activeField)??!1}currentTerms(){return this.store.getFiltered()}needsCreator(){return Array.from(this.fields.values()).some((e=>e.canCreate||e.hasAutocomplete))}getFieldId(e){if(e.dataset.fieldId)return e.dataset.fieldId;const t=e.closest("[data-field-id]");return t?.dataset.fieldId||null}setCheckboxes(e){this.ui.terms.list.querySelectorAll("input[type=checkbox]").forEach((t=>{t.checked||(t.disabled=e)}))}handleStoreEvent(e,t){const s={"data-loaded":()=>this.handleDataLoaded(),"filters-changed":()=>this.handleFiltersChanged(t),"fetch-error":()=>this.handleFetchError()};try{s[e]?.(t)}catch(t){console.error(`Error handling store event "${e}":`,t)}}handleDataLoaded(){const e=this.store.filters.taxonomy;if(e){e.split(",").map((e=>e.trim())).forEach((e=>this.updateFieldsForTaxonomy(e)))}this.container.open?this.showResults():this.activeField&&this.showResults(!0)}showResults(e=!1){const t=this.store.getFiltered(),s=this.store.filters,i=s.search&&s.search.length>0;this.notify("terms-loaded",{terms:t,filters:s}),!this.activeField&&e||(this.setMessage(this.currentField(),!1),e?this.showAutocompleteTerms():this.showModalTerms(i),this.a11y.announce(t.length))}handleFiltersChanged(e){}handleFetchError(e){const t=this.currentField(),s=t?`Failed to load ${t.plural}`:"Failed to load data";this.setMessage(t,!0,s,!1),console.error("Store fetch error:",e)}async batchFetchTaxonomies(){if(0===this.batchFetch.size)return;const e=Array.from(this.batchFetch);this.batchFetch.clear();try{await this.store.setFilters({taxonomy:e.join(","),page:1,search:"",parent:0})}catch(e){console.error("Failed to batch fetch taxonomies:",e)}}preloadTaxonomy(e){this.store.setFilters({taxonomy:e,page:1,search:"",parent:0})}setCreateButton(e,t=!0){if(!e.canCreate||!this.creator)return;const s=this.container.open?this.ui:e.ui;if(!s.create?.button||!s.create?.span)return;const i=s.create.button;i.hidden=!t;const r=s.create.span,a=this.container.open?s.search.input:s.search;if(!a)return;let n=(this.currentTerms()??[]).map((e=>e.name)),o=a.value;const l=t&&o.length>=2&&!n.includes(o);i.hidden=!l,l&&(r.textContent=a.value??"")}async maybeCreateTerm(e){const t=this.currentField();if(!t)return;window.debouncer.cancel(`${t.id}-search-results`);let s={taxonomy:t.taxonomy,parent:this.store.filters.parent??0};if(this.container.open&&""===this.ui.search.input.value?(s.parent=this.creator.ui.parent.value??s.parent,s.name=this.creator.ui.name.value??!1):s.name=this.container.open?this.ui.search.input.value:t.ui.search.value,void 0!==s.parent&&s.name){this.setMessage(t,!0,`Creating "${s.name}"...`),this.setCreateButton(t,!1),this.container.open?window.removeChildren(this.ui.terms.list):(t.ui.search.disabled=!0,t.ui.dropdown.wrapper&&(t.ui.dropdown.wrapper.hidden=!1));let e=await this.creator.handleTermCreation(s);if(e){if(this.setMessage(t,!0,`"${e.name}" created!`,!1),this.addSelected(e.id,t.id),this.updateFieldValue(t.id),!this.container.open&&t.ui.dropdown.list){window.removeChildren(t.ui.dropdown.list);const s=this.createAutocompleteTerm(e);s&&(s.classList.add("newly-created"),t.ui.dropdown.list.append(s))}this.scheduleHideDropdown(t.id,300),this.setMessage(t,!1)}else this.setMessage(t,!1),!this.container.open&&t.ui.dropdown.wrapper&&(t.ui.dropdown.wrapper.hidden=!0);this.container.open||(t.ui.search.disabled=!1,t.ui.search.value="")}}setMessage(e,t=!0,s="",i=!0){const r=this.container.open||e.isFilter?this.ui:e.isFilter?null:e.ui;if(!r?.message?.message)return;s=""===s?`No ${e.plural??"items"} found.`:s;const a=r.message.message,n=r.message.text;a.hidden=!t,t?s&&n&&(i&&window.typeLoop&&n?(this.messageText[e.id]&&(this.messageText[e.id](),delete this.messageText[e.id]),this.messageText[e.id]=window.typeLoop(n,s)):n.textContent=s):this.messageText[e.id]&&(this.messageText[e.id](),delete this.messageText[e.id])}subscribe(e){return this.subscribers.add(e),()=>this.subscribers.delete(e)}notify(e,t={}){this.subscribers.forEach((s=>{try{s(e,t)}catch(e){console.error("Subscriber error:",e)}}))}destroy(){this.fields.forEach(((e,t)=>{window.debouncer.cancel(`${t}-search`),window.debouncer.cancel(`${t}-search-results`)})),Object.keys(this.messageText).forEach((e=>{this.messageText[e]&&this.messageText[e]()})),this.messageText={},this.ui.terms?.sentinel&&this.observer?.unobserve(this.ui.terms.sentinel),this.observer?.disconnect(),this.lazyObserver?.disconnect(),document.removeEventListener("click",this.clickHandler),document.removeEventListener("change",this.changeHandler),document.removeEventListener("input",this.inputHandler),document.removeEventListener("focus",this.focusHandler,!0),document.removeEventListener("blur",this.blurHandler,!0),this.subscribers.clear(),this.fields.clear(),this.selectedTerms.clear(),this.batchFetch.clear(),this.creator&&(this.creator.destroy(),this.creator=null),this.store&&(this.store=null)}}document.addEventListener("DOMContentLoaded",(function(){window.auth.subscribe((t=>{"auth-loaded"===t&&(window.jvbSelector=new e)}))}))})();
\ No newline at end of file
diff --git a/assets/js/min/uploader.min.js b/assets/js/min/uploader.min.js
index ae043d6..2f2fb1b 100644
--- a/assets/js/min/uploader.min.js
+++ b/assets/js/min/uploader.min.js
@@ -1 +1 @@
-(()=>{class e{constructor(){this.a11y=window.jvbA11y,this.queue=window.jvbQueue,this.error=window.jvbError,this.templates=window.jvbTemplates,this.subscribers=new Set,this.initStores(),this.initWorker(),this.fields=new Map,this.uploads=new Map,this.groups=new Map,this.selected=new Map,this.selectionHandlers=new Map,this.sortables=new Map,this.changes=new Map,this.previewUrls=new Set,this.initElements(),this.initListeners(),this.defineTemplates()}defineTemplates(){const e=this.templates,t=this;e.define("uploadItem",{refs:{select:'[name="select-item"]',featured:'[name="featured"]',img:"img",video:"video",file:"label > span",details:"details",alt:'[name="image-alt-text"]',title:'[name="image-title"]',description:'[name="image-caption"]'},manyRefs:{inputs:"input, select, textarea"},setup({el:e,refs:s,manyRefs:i,data:r}){let a,o,l,d=!1;switch(Object.hasOwn(r,"file")?(e.dataset.uploadId=r.uploadId,a=t.getSubtypeFromMime(r.file.type)||"image",o="document"!==a&&t.createPreviewUrl(r.file),d=o,l=r.file.name||""):(e.dataset.id=r.id,a=t.getSubtypeFromURL(r.medium??r.src),o=r.medium??r.src,l=r["image-alt-text"]??""),e.dataset.subtype=a,s.featured&&(s.featured.value=r.uploadId),a){case"image":s.img&&(s.img.src=o,s.img.alt=l,d&&(s.img.dataset.previewUrl=d)),s.video&&s.video.remove(),s.file&&s.file.remove();break;case"video":s.video&&(s.video.src=o,s.video.alt=l,d&&(s.video.dataset.previewUrl=d)),s.img&&s.img.remove(),s.file&&s.file.remove();break;case"document":if(s.preview){let e=r.file.name.split(".").pop()?.toLowerCase()??"",t={pdf:"file-pdf",csv:"file-csv",doc:"file-doc",docx:"file-doc",txt:"file-txt",xls:"file-xls",xlsx:"file-xls"},i=window.getIcon(t[e]??"file");s.preview.innerText=r.file.name??r.title,s.preview.prepend(i)}s.img&&s.img.remove(),s.video&&s.video.remove()}if(s.details&&(Object.hasOwn(r.field.config,"showMeta")&&!r.field.config.showMeta?s.details.remove():(Object.hasOwn(r,"id")?s.details.dataset.attachmentId=r.id:Object.hasOwn(r,"uploadId")&&(s.details.dataset.uploadId=r.uploadId),s.details.setAttribute("data-ignore",""),"image"!==a&&s.alt?s.alt.closest(".field")?.remove():Object.hasOwn(r,"image-alt-text")&&s.alt&&(s.alt.value=r["image-alt-text"]),(Object.hasOwn(r,"title")||Object.hasOwn(r,"file"))&&s.title&&(s.title.value=r.title||r.file.name),Object.hasOwn(r,"image-caption")&&s.description&&(s.description.value=r["image-caption"]))),e.draggable="single"!==e.dataset.mode,i.inputs)for(let t of i.inputs){let s=t.closest("[data-field]")??e;window.prefixInput(t,`${r.id??r.uploadId}-`,s)}}}),e.define("imageGroup",{refs:{selectAll:"[data-select-all]",fields:".fields",details:"details",grid:".item-grid"},setup({el:t,refs:s,manyRefs:i,data:r}){if(t.dataset.groupId=r.groupId,s.selectAll){let e=s.selectAll.closest(".field");window.prefixInput(s.selectAll,`select-all-${r.groupId}`,e,!0)}let a=e.create("groupMetadata",{groupId:r.groupId});a?s.fields.append(a):s.details.remove(),s.grid&&(s.grid.dataset.groupId=r.groupId)}}),e.define("groupMetadata",{manyRefs:{inputs:"input,textarea,select"},setup({el:e,refs:t,manyRefs:s,data:i}){t.inputs&&t.inputs.forEach((e=>{let t=e.closest("[data-field]");window.prefixInput(e,`${i.groupId}-`,t)}))}}),e.define("restoreNotification",{refs:{details:".details",wrap:".wrap"},setup({el:t,refs:s,manyRefs:i,data:r}){if(s.details){let e=r.bySource.size>1?` across ${r.bySource.size} pages`:"",t=r.pendingUploads.length>1?"uploads":"upload";s.details.textContent=`${r.pendingUploads.length} ${t} can be recovered${e}`}if(!s.wrap)return void console.warn("No wrap element in template");let a=1;for(const[t,i]of r.bySource){let r={index:a,isCurrent:t===window.location.href,src:t,uploads:i};s.wrap.append(e.create("restoreField",r)),a++}}}),e.define("restoreField",{refs:{h3:"h3",a:"h3 a",grid:".item-grid"},async setup({el:e,refs:s,manyRefs:i,data:r}){let a=t.registerField(e,!1,!1,`recovery_${r.index}`);r.isCurrent?(e.open=!0,s.a?.remove(),s.h3&&(s.h3.textContent="From this page:")):s.a&&(s.a.href=r.src,s.a.title="Navigate to page and restore",s.a.textContent=r.src);let o=[...new Set(r.uploads.map((e=>e.group??"preview")))];for(let e of o){let i="preview"===e||t.stores.groups.get(e);if(!i)continue;let o=await t.createGroupElement(e,a),l=o.querySelector(".item-grid"),d=r.uploads.filter((t=>t.group===("preview"===e)?null:e));for(const[e,t]of Object.entries(i.fields??{})){let s=o.querySelector(`input[name*="${e}"]`);s&&(s.value=t)}for(let e of d){let s=await t.createUpload(e.id,t.formatFile(e),a);l.append(s)}s.grid.append(o)}}})}initStores(){const{uploads:e,groups:t}=window.jvbStore.register("uploads",[{storeName:"uploads",keyPath:"id",indexes:[{name:"field",keyPath:"field"},{name:"status",keyPath:"status"},{name:"group",keyPath:"group"},{name:"src",keyPath:"src"}]},{storeName:"groups",keyPath:"id",indexes:[{name:"field",keyPath:"field"},{name:"src",keyPath:"src"}]}]);this.stores={uploads:e,groups:t,ready:[]},this.stores.uploads.subscribe(this.handleStores.bind(this,"uploads")),this.stores.groups.subscribe(this.handleStores.bind(this,"groups")),this.queue.subscribe(((e,t)=>{if(("operation-status"===e||"cancel-operation"===e)&&["image_upload","video_upload","document_upload"].includes(t.type)){let s=(t.data instanceof FormData?this.stores.uploads.formDataToObject(t.data):t.data).upload_ids;if(!s||0===s.length)return;if("cancel-operation"===e)return this.handleOperationCancelled(s);this.setBulkUpload(s,"status",t.status).then((()=>{})),"completed"===t.status&&s.forEach((e=>{this.removeUpload(e).then((()=>{}))}))}}))}storesReady(){return 2===this.stores.ready.length}handleStores(e,t){"data-ready"===t&&(this.stores.ready.push(e),this.storesReady()&&this.checkRecovery().then((()=>{})))}initWorker(){this.worker=null,this.workerState={worker:null,tasks:new Map,restart:{count:0,max:3},settings:{timeout:3e3,maxConcurrent:3,restartAfterTimeout:!0}}}initElements(){this.selectors={fields:{field:"[data-upload-field]",input:'input[type="file"]',dropZone:".file-upload-container",preview:".preview-wrap",grid:".item-grid.preview",progress:{progress:".file-upload-container .progress",fill:".file-upload-container .progress .fill",details:".file-upload-container .progress .details",icon:".file-upload-container .progress .icon"},selectAll:"[data-select-all]",actions:".selection-actions",count:".selected .info",hidden:'input[type="hidden"]'},groups:{container:".group-display",grid:".item-grid.groups",empty:".empty-group",header:".sidebar .header"},group:{item:".upload-group",actions:".selection-actions",selectAll:'[name="select-all-group"]',count:".group-header .info",fields:"details .fields",grid:".item-grid.group",total:".group-content .group-count"},items:{item:".item.upload",checkbox:'[name="select-item"]',featured:'[name="featured"]',image:"img",details:"details",progress:{progress:".progress",fill:".fill",details:".details",icon:".icon"}}}}initListeners(){this.clickHandler=this.handleClick.bind(this),this.changeHandler=this.handleChange.bind(this),this.dragEnterHandler=this.handleDragEnter.bind(this),this.dragLeaveHandler=this.handleDragLeave.bind(this),this.dragOverHandler=this.handleDragOver.bind(this),this.dropHandler=this.handleDrop.bind(this),document.addEventListener("click",this.clickHandler),document.addEventListener("change",this.changeHandler),document.addEventListener("dragenter",this.dragEnterHandler),document.addEventListener("dragleave",this.dragLeaveHandler),document.addEventListener("dragover",this.dragOverHandler),document.addEventListener("drop",this.dropHandler),window.addEventListener("beforeunload",(()=>{this.cleanupAllPreviewUrls()}))}async setUpload(e,t){const s={...{id:e,attachment:null,group:null,field:null,src:window.location.href,blob:null,status:"local_processing",operationId:null,fields:{}},...t};return Object.preventExtensions(s),await this.stores.uploads.save(s),s}createPreviewUrl(e){const t=URL.createObjectURL(e);return this.previewUrls.add(t),t}revokePreviewUrl(e){e?.startsWith("blob:")&&(URL.revokeObjectURL(e),this.previewUrls.delete(e))}formatFile(e){return e.blob?new File([e.blob],e.fields.originalName||"file",{type:e.fields.type||e.blob.type,lastModified:e.fields.lastModified||Date.now()}):null}handleClick(e){let t=window.targetCheck(e,this.selectors.fields.dropZone);t&&!e.target.matches("input, button, a")&&t.querySelector(this.selectors.fields.input)?.click();const s=window.targetCheck(e,"[data-action]");s&&this.handleAction(s)}handleAction(e){const t=e.dataset.action,s=this.getFieldIdFromElement(e);switch(t){case"add-to-group":this.handleAddToGroup(s).then((()=>{}));break;case"delete-group":this.handleDeleteGroup(e);break;case"delete-upload":case"remove-from-group":this.handleRemoveItem(e).then((()=>{}));break;case"upload":this.queueUploads("uploads/groups",s).then((()=>{}));break;case"restore":this.handleRestoreSelected().then((()=>{}));break;case"restore-all":this.handleRestoreAll().then((()=>{}));break;case"clear-cache":this.handleClearCache().then((()=>{}))}}handleChange(e){let t=this.getFieldIdFromElement(e.target);if(t)if(e.target.matches(this.selectors.fields.input)){const s=Array.from(e.target.files);s.length>0&&this.processFiles(t,s).then((()=>{}))}else e.target.matches(this.selectors.items.checkbox)||e.target.matches(this.selectors.items.featured)||e.target.matches('[name*="select-"]')||("post_group"===this.fields.get(t).config.destination?this.handleGroupMetaChange(e.target):this.queueUploadMeta(e));else{e.target.closest("[data-upload-id], [data-attachment-id]")&&this.queueUploadMeta(e)}}handleGroupMetaChange(e){const t=e.dataset.groupId;if(!t)return;const s=e.name;if(!s)return;const i=e.value,r=s.replace(`${t}[`,"").replace(`${t}_`,"").replace("]","");window.debouncer.schedule(`group-meta-${t}-${r}`,(async()=>{const e=this.stores.groups.get(t);e&&(e.fields||(e.fields={}),e.fields[r]=i,await this.setGroup(t,e))}),300)}handleDragEnter(e){if(!e.dataTransfer.types.includes("Files"))return;const t=e.target.closest(this.selectors.fields.dropZone);t&&(e.preventDefault(),t.classList.add("dragover"))}handleDragLeave(e){const t=e.target.closest(this.selectors.fields.dropZone);t&&!t.contains(e.relatedTarget)&&t.classList.remove("dragover")}handleDragOver(e){if(!e.dataTransfer.types.includes("Files"))return;e.target.closest(this.selectors.fields.dropZone)&&(e.preventDefault(),e.dataTransfer.dropEffect="copy")}handleDrop(e){const t=e.target.closest(this.selectors.fields.dropZone);if(!t)return;e.preventDefault(),t.classList.remove("dragover"),t.classList.add("uploading");const s=Array.from(e.dataTransfer.files);if(0===s.length)return;const i=this.getFieldIdFromElement(t);i&&(this.processFiles(i,s).then((()=>{this.updateHandlerItems(i)})),this.a11y.announce(`${s.length} file(s) dropped for upload`))}async queueUploads(e,t){let s=new FormData;const i=this.fields.get(t);if(!i)return;let r=this.stores.uploads.filterByIndex({field:t});if(0===r.length)return;const[a,o]=["uploads"===e,"uploads/groups"===e];let l,d,n,u,p;s.append("fieldId",i.id),s.append("content",i.config.content),a&&(s.append("mode",i.config.mode),s.append("field_name",i.config.name),s.append("fieldId",i.id),s.append("field_type",i.config.type),s.append("subtype",i.config.subtype),s.append("item_id",i.config.itemID),s.append("destination",i.config.destination)),o?({posts:l,uploadMap:d,files:n}=this.collectGroups(t)):a&&({uploadMap:d,files:n}=this.collectUploads(t)),o&&s.append("posts",JSON.stringify(l)),n.forEach((e=>{s.append("files[]",e)})),s.append("upload_ids",JSON.stringify(d)),a?(u=`Uploading ${r.length} file${r.length>1?"s":""} to server...`,p=`Uploading ${r.length} file${r.length>1?"s":""}...`):o&&(u=`Creating ${l.length} ${i.config.content}${l.length>1?"s":""} from uploads...`,p=`Creating ${l.length} post${l.length>1?"s":""}...`),await this.setBulkUpload(r,"status","queued");let c=this.sendToQueue(e,s,u,p);if("uploads/groups"===e){let e=i.element.closest("details");e&&(e.open=!1)}return c?(i.operationId=c,await this.setBulkUpload(r,"operationId",c),await this.setBulkUpload(r,"status","uploading"),await this.setBulkGroup(t,"operationId",c),this.fields.set(i.id,i),this.notify("sent-to-queue",{field:i,operation:c})):await this.setBulkUpload(r,"status","failed"),c}async sendToQueue(e,t,s="",i="",r=!1){""===i&&(i=s);const a={endpoint:e,method:"POST",data:t,title:s,popup:i,canMerge:r,sendNow:"uploads/groups"===e,headers:{action_nonce:window.auth.getNonce("dash")},append:"_upload"};try{return await this.queue.addToQueue(a)}catch(e){return this.error.log(e,{component:"UploadManager",action:"sentToQueue"}),!1}}collectGroups(e){let t=this.stores.uploads.filterByIndex({field:e}),s=[],i=[],r=[];const a=this.stores.groups.filterByIndex({field:e}).filter((e=>{const t=this.getGroupUploadsInOrder(e);return t.length>0&&t.some((e=>this.formatFile(e)))}));for(const e of a){const t=this.groups.get(e.id)?.element,a={images:[],fields:this.collectGroupFieldsFromDOM(t,e.id)},o=this.getGroupUploadsInOrder(e);for(const t of o){const s=this.formatFile(t);if(s){r.push(s);const o={upload_id:t.id,index:i.length},l=this.uploads.get(t.id),d=l?.element?.querySelector(`input[name="${e.id}_featured"]`);d?.checked&&(a.fields.featured=t.id),a.images.push(o),i.push(t.id)}}a.images.length>0&&s.push(a)}const o=t.filter((e=>!e.group));for(const e of o){const t={images:[],fields:{}},a=this.formatFile(e);if(a){r.push(a);const s={upload_id:e.id,index:i.length};t.images.push(s),i.push(e.id)}t.images.length>0&&s.push(t)}return{posts:s,uploadMap:i,files:r}}getGroupUploadsInOrder(e){return e.uploads&&0!==e.uploads.length?e.uploads.map((e=>this.stores.uploads.get(e))).filter(Boolean):[]}collectGroupFieldsFromDOM(e,t){if(!e)return{};const s={};return e.querySelectorAll("input, textarea, select").forEach((e=>{const i=e.name.replace(`${t}[`,"").replace(`${t}_`,"").replace("]","");["featured","select-all"].some((e=>i.includes(e)))||e.value&&(s[i]=e.value)})),s}collectUploads(e){let t=this.stores.uploads.filterByIndex({field:e});if(0===t.length)return;let s=[],i=[];for(const e of t){const t=this.formatFile(e);t&&(i.push(t),s.push(e.id))}return{uploadMap:s,files:i}}queueUploadMeta(e){let t=e.target.closest("[data-attachment-id]")?.dataset.attachmentId,s=!1;if(!t&&(t=e.target.closest("[data-upload-id]")?.dataset.uploadId,s=!0,!t))return;if(!this.changes.has(t)){let e={};s?e.uploadId=t:e.attachmentId=t,this.changes.set(t,e)}let i=e.target.closest("[data-field]").dataset.field;this.changes.get(t)[i]=e.target.value,this.scheduleSave()}scheduleSave(){window.debouncer.schedule("upload-meta",(async()=>{if(this.changes.size>0){let e={};for(let[t,s]of this.changes.entries())console.log(t,s),e[t]=s;let t={user:window.auth.getUser(),items:e};await this.sendToQueue("uploads/meta",t,"Uploading Meta","Uploading Meta",!0),this.changes.clear()}}),2e3)}scanFields(e,t=!0,s=!0){e.querySelectorAll(this.selectors.fields.field).forEach((e=>this.registerField(e,t,s)))}registerField(e,t=!0,s=!0,i=null){const r={element:e,id:i||this.determineFieldId(e),config:this.extractFieldConfig(e,t,s),uploads:new Set,operationId:null,groups:[],ui:window.uiFromSelectors(this.selectors.fields,e),groupUI:window.uiFromSelectors(this.selectors.groups,e)};return this.fields.set(r.id,r),e.dataset.uploader=r.id,this.getSelectionHandler(r.id),"single"!==r.config.type&&this.initSortable(r.id),r.id}extractFieldConfig(e,t,s){return{autoUpload:t,showMeta:s,destination:e.dataset.destination||"meta",content:this.extractFieldContent(e),mode:e.dataset.mode||"direct",type:e.dataset.type||"single",name:e.dataset.field,itemID:this.extractFieldItemId(e)??0,maxFiles:parseInt(e.dataset.maxFiles)??25,subType:e.dataset.subtype??"image"}}extractFieldContent(e){return e.dataset.content||e.closest("dialog")?.dataset.content||e.closest("form")?.dataset.save||null}extractFieldItemId(e){return e.dataset.itemId||e.closest("dialog")?.dataset.itemId||null}determineFieldId(e){let t=this.extractFieldContent(e);t=null===t?"":t+"_";let s=this.extractFieldItemId(e);s=null===s?"":s+"_";return`${t}${s}${e.dataset.field||""}`}getFieldIdFromElement(e){const t=e.closest(this.selectors.fields.field);return t?.dataset.uploader||null}updateFieldProgress(e,t,s,i){const r=this.fields.get(e);r&&window.showProgress(r.ui.progress,t,s,i)}getWorker(){return this.workerState.worker||"undefined"==typeof OffscreenCanvas||(this.workerState.worker=new Worker("worker.js"),this.workerState.worker.onmessage=e=>this.handleWorkerMessage(e),this.workerState.worker.onerror=e=>this.handleWorkerError(e)),this.workerState.worker}handleWorkerMessage(e){const{id:t,blob:s}=e.data,i=this.workerState.tasks.get(t);i&&(clearTimeout(i.timeoutId),i.resolve(s),this.workerState.tasks.delete(t))}handleWorkerError(e){this.workerState.tasks.forEach((t=>{clearTimeout(t.timeoutId),t.reject(e)})),this.workerState.tasks.clear(),this.restartWorker()}restartWorker(){this.workerState.worker&&(this.workerState.worker.terminate(),this.workerState.worker=null),this.workerState.restart.count++}async processImages(e,t=2200,s=2200){const i=[],r=[...e],a=this.workerState.settings.maxConcurrent,o=async()=>{for(;r.length>0;){const e=r.shift(),a=await this.processImage(e.file,t,s);i.push({uploadId:e.uploadId,blob:a})}};return await Promise.all(Array.from({length:Math.min(a,e.length)},(()=>o()))),i}async processImage(e,t=2200,s=2200,i=3e3){if("undefined"==typeof OffscreenCanvas)return this.resizeImage(e,t,s);try{return await this.withTimeout(this.workerImage(e,t,s),i)}catch(i){return this.resizeImage(e,t,s)}}withTimeout(e,t){return Promise.race([e,new Promise(((e,s)=>setTimeout((()=>s(new Error("Timeout"))),t)))])}async workerImage(e,t=2200,s=2200){const{settings:i,restart:r}=this.workerState;if(r.count>=r.max)throw new Error("Worker max restarts exceeded");const a=await createImageBitmap(e);let{width:o,height:l}=a;if(o>t||l>s){const e=Math.min(t/o,s/l);o=Math.round(o*e),l=Math.round(l*e)}const d=this.getWorker(),n=crypto.randomUUID();return new Promise(((t,s)=>{const r=setTimeout((()=>{this.workerState.tasks.delete(n),i.restartAfterTimeout&&this.restartWorker(),s(new Error("Timeout"))}),i.timeout);this.workerState.tasks.set(n,{resolve:t,reject:s,timeoutId:r}),d.postMessage({id:n,imageBitmap:a,width:o,height:l,type:e.type,quality:.9},[a])}))}resizeImage(e,t,s){return new Promise((i=>{const r=new Image;r.onload=()=>{URL.revokeObjectURL(r.src);let{width:a,height:o}=r;if(a>t||o>s){const e=Math.min(t/a,s/o);a=Math.round(a*e),o=Math.round(o*e)}const l=document.createElement("canvas");l.width=a,l.height=o,l.getContext("2d").drawImage(r,0,0,a,o),l.toBlob(i,e.type,.9)},r.src=URL.createObjectURL(e)}))}async processFiles(e,t){let s=this.fields.get(e);if(!s)return;s.groupUI.container&&(s.groupUI.container.hidden=!1);const i=t.length;let r=0;this.updateFieldProgress(e,0,i,"Processing files...");const a=await Promise.all(t.map((async t=>{const s=window.generateID("upload"),i=await this.setUpload(s,{id:s,field:e,status:"local_processing",fields:{originalName:t.name,originalSize:t.size,type:t.type,lastModified:t.lastModified}}),r=await this.createUpload(s,t,e);return this.uploads.set(s,{element:r,ui:window.uiFromSelectors(this.selectors.items,r)}),await this.addToGroup(s,null),{uploadId:s,upload:i,file:t}}))),o=a.filter((e=>e.file.type.startsWith("image/"))),l=a.filter((e=>!e.file.type.startsWith("image/"))),d=await this.processImages(o.map((e=>({file:e.file,uploadId:e.uploadId}))));for(const{uploadId:t,blob:s}of d){const a=o.find((e=>e.uploadId===t));a&&(a.upload.blob=s,a.upload.fields.size=s.size,a.upload.status="queued",await this.setUpload(t,a.upload),r++,this.updateFieldProgress(e,r,i,"Processing files..."))}for(const{uploadId:t,upload:s,file:a}of l)s.blob=a,s.status="queued",await this.setUpload(t,s),r++,this.updateFieldProgress(e,r,i,"Processing files...");this.maybeLockUploads(e),s.config.autoUpload&&"post_group"!==s.config.destination&&await this.queueUploads("uploads",e)}async checkRecovery(){const e=this.stores.uploads.filterByIndex({status:["local_processing","queued","uploading"]}),t=Array.from(this.stores.groups.data.values());for(const e of t){this.stores.uploads.filterByIndex({group:e.id}).length>0||await this.stores.groups.delete(e.id)}if(0===e.length)return;const s=new Map;e.forEach((e=>{const t=e.src||"unknown";s.has(t)||s.set(t,[]),s.get(t).push(e)}));let i={bySource:s,pendingUploads:e};document.body.append(this.templates.create("restoreNotification",i));let r=document.querySelector("dialog.restore-uploads");this.restoreModal=new window.jvbModal(r),this.restoreSelection=new window.jvbHandleSelection(r,{wrapper:{wrapper:".restore-field",id:"selection"},items:".item-grid.restore",selectAll:{bulkControls:".selection-actions",checkbox:"#select-all-restore",count:".selection-count"}}),this.restoreModal.handleOpen()}async handleRestoreSelected(){if(!this.restoreSelection)return;let e=Array.from(this.restoreSelection.selectedItems);0!==e.length&&await this.restoreSelectedUploads(e)}async handleRestoreAll(){if(!this.restoreModal)return;const e=Array.from(this.restoreModal.modal.querySelectorAll(".item.upload")).map((e=>e.dataset.uploadId));await this.restoreSelectedUploads(e)}async restoreSelectedUploads(e){let t=window.location.href,s=Array.from(this.stores.uploads.data.values()).filter((s=>e.includes(s.id)&&s.src===t)),i=[...new Set(s.map((e=>e.group)))].filter(Boolean),r=s[0].field;if(!document.querySelector(`[data-uploader="${r}"]`))return void console.log("No field found for "+r);let a=this.fields.get(r);a.groupUI.container&&(a.groupUI.container.hidden=!1);let o=[];for(let e of i){let t=this.stores.groups.get(e);await this.createGroup(r,e);let i=this.groups.get(e),a=s.filter((t=>t.group===e));if(t&&this.groups.has(e)){let e=t.fields;for(const[t,s]of Object.entries(e)){let e=i.element.querySelector(`input[name*="${t}"]`);e&&(e.value=s)}}else e=null;for(let t of a){let s=await this.createUpload(t.id,this.formatFile(t),r);this.uploads.set(t.id,{element:s,ui:window.uiFromSelectors(this.selectors.items,s)}),await this.addToGroup(t.id,e),o.push(t.id)}}let l=s.filter((e=>!o.includes(e.id)));for(let e of l){let t=await this.createUpload(e.id,this.formatFile(e),r);this.uploads.set(e.id,{element:t,ui:window.uiFromSelectors(this.selectors.items,t)}),await this.addToGroup(e.id,null)}this.cleanupRestore()}cleanupRestore(){this.restoreModal.handleClose(),this.restoreSelection.destroy(),this.restoreSelection=null,this.restoreModal.destroy(),this.restoreModal.modal.remove(),this.restoreModal=null}getStatusText(e){return{received:"Image Received",local_processing:"Processing Image...",queued:"Waiting to upload...",uploading:"Uploading to Server",pending:"Successfully sent to server. In line for further processing.",processing:"Processing on server...",completed:"Upload complete!",failed:"Upload failed (will retry)",failed_permanent:"Upload failed permanently"}[e]||e}getStatusProgress(e){return{local_processing:28,queued:50,uploading:66,pending:75,processing:89,completed:100}[e]??0}async createUpload(e,t,s){let i=this.fields.get(s);if(!i)return null;let r={uploadId:e,file:t,field:i};return this.templates.create("uploadItem",r)}getSubtypeFromURL(e){const t=e.split("?")[0].toLowerCase();return[".webp",".jpg",".jpeg",".png",".gif",".svg"].some((e=>t.endsWith(e)))?"image":[".mp4",".ogg",".mov",".webm",".avi"].some((e=>t.endsWith(e)))?"video":"document"}getSubtypeFromMime(e){return e.startsWith("image/")?"image":e.startsWith("video/")?"video":"document"}async handleRemoveItem(e){const t=e.closest(this.selectors.items.item);if(!t)return;const s=t.dataset.uploadId;confirm("Remove this item?")&&(await this.removeUpload(s),this.a11y.announce("Item removed"))}async setBulkUpload(e,t,s){const i=Array.from(e).map((async e=>{if("string"==typeof e&&(e=await this.stores.uploads.get(e)),e)return"status"===t&&await this.setUploadStatus(e,s),e[t]=s,this.stores.uploads.save(e)}));await Promise.all(i)}async setUploadStatus(e,t){"string"==typeof e&&(e=await this.stores.uploads.get(e)),e&&e.progress&&window.showProgress(e.progress,this.getStatusProgress(t),100,this.getStatusText(t),this.queue.icons[t]??"")}async removeUpload(e){let t=this.stores.uploads.get(e);if(!t)return;if(t.group){let s=this.stores.groups.get(t.group);s.uploads=s.uploads.filter((t=>t!==e)),0===s.uploads.length?await this.removeGroup(s.id,!1):await this.stores.groups.save(s)}await this.clearUpload(e),this.maybeLockUploads(t.field);let s=this.selectionHandlers.get(t.field);s&&s.deselect(e),this.a11y.announce("Upload removed")}async clearUpload(e){const t=this.uploads.get(e);if(t&&(this.revokePreviewUrl(t.preview),t.element)){const e=t.element.dataset.previewUrl;this.revokePreviewUrl(e),t.element.remove()}this.uploads.delete(e),await this.stores.uploads.delete(e)}async handleAddToGroup(e){const t=this.selected.get(e);if(!t||0===t.size)return;let s=await this.createGroup(e);s&&(await Promise.all(Array.from(t).map((e=>this.addToGroup(e,s)))),this.selectionHandlers.get(e)?.clearSelection(),this.a11y.announce(`Created group with ${t.size} items`))}async createGroup(e,t=null){let s=this.fields.get(e);if(!s)return;t||(t=window.generateID("group"));const i=this.createGroupElement(t,e);if(!i)return null;const r=s.groupUI.empty;r?.nextSibling?s.groupUI.grid.insertBefore(i,r.nextSibling):s.groupUI.grid.append(i);const a=i.querySelector(".item-grid");a&&(a.dataset.groupId=t,this.createSortable(e,a,t));let o=this.stores.groups.data.has(t)?this.stores.groups.data.get(t):{};return await this.setGroup(t,{...o,id:t,field:e}),t}createGroupElement(e,t=null){let s={groupId:e,fieldId:t},i=this.templates.create("imageGroup",s);return this.groups.set(e,{element:i,ui:window.uiFromSelectors(this.selectors.group,i)}),this.getSelectionHandler(t)?.addWrapper(i),i}async setGroup(e,t){const s={...{id:e,src:window.location.href,uploads:[],operationId:null,field:null,fields:{}},...t};Object.preventExtensions(s),await this.stores.groups.save(s)}async setBulkGroup(e,t,s){let i=this.stores.groups.filterByIndex({field:e});if(0===i.length)return;let r=i.map((e=>{e[t]=s,this.stores.groups.save(e)}));await Promise.all(r)}async addToGroup(e,t=null){const s=this.stores.uploads.get(e),i=this.uploads.get(e);if(!s||!i)return;const r=this.fields.get(s.field);if(!r)return;if(null!==i.element?.parentElement&&(!t&&null===s.group||t===s.group))return void this.handleReorder(s.field,t);if(s.group){const t=this.stores.groups.get(s.group);t&&(t.uploads=t.uploads.filter((t=>t!==e)),0===t.uploads.length?await this.removeGroup(t.id,!1):await this.stores.groups.save(t))}i.ui.checkbox&&(i.ui.checkbox.checked=!1);const a=this.selectionHandlers.get(s.field);if(a&&a.isSelected(e)&&a.deselect(e),this.selected.get(s.field)?.has(e)&&this.selected.get(s.field).delete(e),i.ui.featured&&(i.ui.featured.hidden=!t),t){i.ui.featured&&(i.ui.featured.name=`${t}_featured`);let r=this.stores.groups.get(t);r&&(r.uploads.push(e),s.group=t,await this.stores.groups.save(r))}else s.group=null;let o=t?this.groups.get(t)?.ui.grid:r.ui.grid;o&&(o.append(i.element),t&&await this.handleReorder(s.field,t)),await this.stores.uploads.save(s)}handleDeleteGroup(e){const t=e.closest(this.selectors.group.item);if(!t)return;let s=t.dataset.groupId;if(!confirm("Delete this group? Items will be moved back to the upload area."))return;let i=this.stores.uploads.filterByIndex({group:s});Promise.all(i.map((e=>this.addToGroup(e.id,null)))).then((()=>{this.removeGroup(s,!1).then((()=>{})),this.a11y.announce("Group deleted. Items returned to upload area")}))}async removeGroup(e,t=!0){let s=this.groups.get(e),i=this.stores.groups.get(e);if(!i)return;let r=!0;t&&i.uploads.length>0&&(r=window.confirm("Keep uploads in this group?")),await Promise.all(i.uploads.map((e=>r?this.addToGroup(e,null):this.removeUpload(e))));if(this.fields.get(i.field)){const t=this.getGroupKey(i.field,e),r=this.selectionHandlers.get(t);r?.destroy&&r.destroy(),this.selectionHandlers.get(i.field)?.removeWrapper(s.element);const a=this.sortables.get(t);a?.destroy&&a.destroy(),this.sortables.delete(t)}s?.element&&s.element.remove(),this.groups.delete(e),await this.stores.groups.delete(e),this.a11y.announce("Group removed")}maybeLockUploads(e){let t=this.fields.get(e);if(!t||!t.ui.dropZone)return;let s=this.stores.uploads.filterByIndex({field:e}).length,i=t.config.maxFiles??25;t.ui.dropZone.hidden=s>=i}async handleOperationCancelled(e){0!==e.length&&e.forEach((e=>{this.removeUpload(e)}))}getGroupKey(e,t=null){return t?`${e}_${t}`:`${e}`}getSelectionHandler(e){let t=this.getGroupKey(e);if(!this.selectionHandlers.has(t)){let s=this.fields.get(e);if(!s)return;if("post_group"!==s.config.destination)return;let i=new window.jvbHandleSelection(s.element,{selectAll:{checkbox:this.selectors.fields.selectAll,count:this.selectors.fields.count,bulkControls:this.selectors.fields.actions},item:{item:this.selectors.items.item,checkbox:this.selectors.items.checkbox,idAttribute:"uploadId"},wrapper:{wrapper:".preview-wrap, .upload-group",id:"groupId"}});i.subscribe(((t,s)=>{this.selected.set(e,s.selectedItems)})),this.selectionHandlers.set(t,i)}return this.selectionHandlers.get(t)}updateHandlerItems(e){let t=this.getSelectionHandler(e);t&&t.collectItems()}initSortable(e){if(!window.Sortable)return;const t=this.fields.get(e);t&&(!Sortable._multiDragMounted&&Sortable.MultiDrag&&(Sortable.mount(new Sortable.MultiDrag),Sortable._multiDragMounted=!0),this.createSortable(e,t.ui.grid,null),this.initEmptyGroupDropZone(e))}createSortable(e,t,s){if(!t)return null;const i=this.getGroupKey(e,s);if(this.sortables.has(i))return this.sortables.get(i);const r=new Sortable(t,{animation:150,draggable:".item",multiDrag:!0,selectedClass:"selected",avoidImplicitDeselect:!0,group:{name:e,pull:!0,put:!0},dragClass:"dragging",ignore:".empty-group",onStart:t=>{const s=t.item,i=s?.dataset.uploadId,r=this.selected.get(e);if(i&&(!r||!r.has(i))){const t=this.selectionHandlers.get(e);t&&t.select(i)}},onEnd:t=>this.sortableDrop(t,e)});return this.sortables.set(i,r),r}initEmptyGroupDropZone(e){const t=this.fields.get(e),s=t?.groupUI?.empty;s&&(s.addEventListener("dragover",(e=>{e.preventDefault(),e.stopPropagation(),e.dataTransfer.dropEffect="move",s.classList.add("drag-over")})),s.addEventListener("dragleave",(e=>{s.contains(e.relatedTarget)||s.classList.remove("drag-over")})),s.addEventListener("drop",(async t=>{t.preventDefault(),t.stopPropagation(),s.classList.remove("drag-over");const i=this.selected.get(e);if(!i||0===i.size)return;const r=await this.createGroup(e);r&&(await Promise.all(Array.from(i).map((e=>this.addToGroup(e,r)))),this.selectionHandlers.get(e)?.clearSelection())})))}async sortableDrop(e,t){const s=e.to,i=(e.items?.length>0?Array.from(e.items):[e.item]).map((e=>e.dataset.uploadId)).filter(Boolean);if(0===i.length)return;const r=s.dataset.groupId||null;for(const e of i)await this.addToGroup(e,r);await this.handleReorder(t,r),this.selectionHandlers.get(t)?.clearSelection()}handleReorder(e,t=null){let s=t?this.groups.get(t)?.ui.grid:this.fields.get(e)?.ui.grid;if(!s)return void console.log("Couldn't Reorder items...");let i=Array.from(s.children).filter((e=>e.matches(this.selectors.items.item)&&!e.classList.contains("ghost"))).map((e=>e.dataset.uploadId)).filter((e=>e));if(t){let e=this.stores.groups.get(t);e&&(e.uploads=i,this.stores.groups.save(e).then((()=>{})))}else{let t=this.fields.get(e)?.ui.hidden;t&&(t.value=i.join(","))}this.a11y.announce("Items reordered")}subscribe(e){return this.subscribers.add(e),()=>this.subscribers.delete(e)}notify(e,t={}){this.subscribers.forEach((s=>{try{s(e,t)}catch(e){console.error("Subscriber error:",e)}}))}destroy(){this.subscribers.clear(),this.previewUrls.forEach((e=>{this.revokePreviewUrl(e)})),this.previewUrls.clear()}cleanupAllPreviewUrls(){this.previewUrls.forEach((e=>this.revokePreviewUrl(e))),this.previewUrls.clear()}async handleClearCache(){const e=window.location.href,t=this.stores.uploads.filterByIndex({src:e}),s=this.stores.groups.filterByIndex({src:e});await Promise.all([...t.map((e=>this.clearUpload(e.id))),...s.map((e=>(this.groups.get(e.id)?.element?.remove(),this.groups.delete(e.id),this.stores.groups.delete(e.id))))]),this.restoreModal&&this.cleanupRestore(),this.a11y.announce("Cache cleared for this page")}async getFilesForForm(e){const t=e.querySelectorAll(this.selectors.fields.field),s=[];for(const e of t){const t=this.determineFieldId(e),i=e.dataset.field,r=this.stores.uploads.filterByIndex({field:t});for(const e of r){const t=this.formatFile(e);t&&s.push({file:t,fieldName:i,uploadId:e.id,meta:e.fields||{}})}}return s}async clearFieldFromStores(e){const t=this.stores.uploads.filterByIndex({field:e}),s=this.stores.groups.filterByIndex({field:e});await Promise.all(t.map((e=>this.clearUpload(e.id)))),await Promise.all(s.map((e=>(this.groups.get(e.id)?.element?.remove(),this.groups.delete(e.id),this.stores.groups.delete(e.id)))))}}document.addEventListener("DOMContentLoaded",(async function(){window.auth.subscribe((t=>{"auth-loaded"===t&&(window.jvbUploads=new e)}))}))})();
\ No newline at end of file
+(()=>{class e{constructor(){this.a11y=window.jvbA11y,this.queue=window.jvbQueue,this.error=window.jvbError,this.templates=window.jvbTemplates,this.subscribers=new Set,this.initStores(),this.initWorker(),this.fields=new Map,this.uploads=new Map,this.groups=new Map,this.selected=new Map,this.selectionHandlers=new Map,this.sortables=new Map,this.changes=new Map,this.previewUrls=new Set,this.initElements(),this.initListeners(),this.defineTemplates()}defineTemplates(){const e=this.templates,t=this;e.define("uploadItem",{refs:{select:'[name="select-item"]',featured:'[name="featured"]',img:"img",video:"video",file:"label > span",details:"details",alt:'[name="image-alt-text"]',title:'[name="image-title"]',description:'[name="image-caption"]'},manyRefs:{inputs:"input, select, textarea"},setup({el:e,refs:s,manyRefs:i,data:r}){let a,o,l,d=!1;switch(Object.hasOwn(r,"file")?(e.dataset.uploadId=r.uploadId,a=t.getSubtypeFromMime(r.file.type)||"image",o="document"!==a&&t.createPreviewUrl(r.file),d=o,l=r.file.name||""):(e.dataset.id=r.id,a=t.getSubtypeFromURL(r.medium??r.src),o=r.medium??r.src,l=r["image-alt-text"]??""),e.dataset.subtype=a,s.featured&&(s.featured.value=r.uploadId),a){case"image":s.img&&(s.img.src=o,s.img.alt=l,d&&(s.img.dataset.previewUrl=d)),s.video&&s.video.remove(),s.file&&s.file.remove();break;case"video":s.video&&(s.video.src=o,s.video.alt=l,d&&(s.video.dataset.previewUrl=d)),s.img&&s.img.remove(),s.file&&s.file.remove();break;case"document":if(s.preview){let e=r.file.name.split(".").pop()?.toLowerCase()??"",t={pdf:"file-pdf",csv:"file-csv",doc:"file-doc",docx:"file-doc",txt:"file-txt",xls:"file-xls",xlsx:"file-xls"},i=window.getIcon(t[e]??"file");s.preview.innerText=r.file.name??r.title,s.preview.prepend(i)}s.img&&s.img.remove(),s.video&&s.video.remove()}if(s.details&&(Object.hasOwn(r,"field")&&Object.hasOwn(r.field,"config")&&Object.hasOwn(r.field.config,"showMeta")&&!r.field.config.showMeta?s.details.remove():(Object.hasOwn(r,"id")?s.details.dataset.attachmentId=r.id:Object.hasOwn(r,"uploadId")&&(s.details.dataset.uploadId=r.uploadId),s.details.setAttribute("data-ignore",""),"image"!==a&&s.alt?s.alt.closest(".field")?.remove():Object.hasOwn(r,"image-alt-text")&&s.alt&&(s.alt.value=r["image-alt-text"]),(Object.hasOwn(r,"title")||Object.hasOwn(r,"file"))&&s.title&&(s.title.value=r.title||r.file.name),Object.hasOwn(r,"image-caption")&&s.description&&(s.description.value=r["image-caption"]))),e.draggable="single"!==e.dataset.mode,i.inputs)for(let t of i.inputs){let s=t.closest("[data-field]")??e;window.prefixInput(t,`${r.id??r.uploadId}-`,s)}}}),e.define("imageGroup",{refs:{selectAll:"[data-select-all]",fields:".fields",details:"details",grid:".item-grid"},setup({el:t,refs:s,manyRefs:i,data:r}){if(t.dataset.groupId=r.groupId,s.selectAll){let e=s.selectAll.closest(".field");window.prefixInput(s.selectAll,`select-all-${r.groupId}`,e,!0)}let a=e.create("groupMetadata",{groupId:r.groupId});a?s.fields.append(a):s.details.remove(),s.grid&&(s.grid.dataset.groupId=r.groupId)}}),e.define("groupMetadata",{manyRefs:{inputs:"input,textarea,select"},setup({el:e,refs:t,manyRefs:s,data:i}){t.inputs&&t.inputs.forEach((e=>{let t=e.closest("[data-field]");window.prefixInput(e,`${i.groupId}-`,t)}))}}),e.define("restoreNotification",{refs:{details:".details",wrap:".wrap"},setup({el:t,refs:s,manyRefs:i,data:r}){if(s.details){let e=r.bySource.size>1?` across ${r.bySource.size} pages`:"",t=r.pendingUploads.length>1?"uploads":"upload";s.details.textContent=`${r.pendingUploads.length} ${t} can be recovered${e}`}if(!s.wrap)return void console.warn("No wrap element in template");let a=1;for(const[t,i]of r.bySource){let r={index:a,isCurrent:t===window.location.href,src:t,uploads:i};s.wrap.append(e.create("restoreField",r)),a++}}}),e.define("restoreField",{refs:{h3:"h3",a:"h3 a",grid:".item-grid"},async setup({el:e,refs:s,manyRefs:i,data:r}){let a=t.registerField(e,!1,!1,`recovery_${r.index}`);r.isCurrent?(e.open=!0,s.a?.remove(),s.h3&&(s.h3.textContent="From this page:")):s.a&&(s.a.href=r.src,s.a.title="Navigate to page and restore",s.a.textContent=r.src);let o=[...new Set(r.uploads.map((e=>e.group??"preview")))];for(let e of o){let i="preview"===e||t.stores.groups.get(e);if(!i)continue;let o=await t.createGroupElement(e,a),l=o.querySelector(".item-grid"),d=r.uploads.filter((t=>t.group===("preview"===e)?null:e));for(const[e,t]of Object.entries(i.fields??{})){let s=o.querySelector(`input[name*="${e}"]`);s&&(s.value=t)}for(let e of d){let s=await t.createUpload(e.id,t.formatFile(e),a);l.append(s)}s.grid.append(o)}}})}initStores(){const{uploads:e,groups:t}=window.jvbStore.register("uploads",[{storeName:"uploads",keyPath:"id",indexes:[{name:"field",keyPath:"field"},{name:"status",keyPath:"status"},{name:"group",keyPath:"group"},{name:"src",keyPath:"src"}]},{storeName:"groups",keyPath:"id",indexes:[{name:"field",keyPath:"field"},{name:"src",keyPath:"src"}]}]);this.stores={uploads:e,groups:t,ready:[]},this.stores.uploads.subscribe(this.handleStores.bind(this,"uploads")),this.stores.groups.subscribe(this.handleStores.bind(this,"groups")),this.queue.subscribe(((e,t)=>{if(("operation-status"===e||"cancel-operation"===e)&&["image_upload","video_upload","document_upload"].includes(t.type)){let s=(t.data instanceof FormData?this.stores.uploads.formDataToObject(t.data):t.data).upload_ids;if(!s||0===s.length)return;if("cancel-operation"===e)return this.handleOperationCancelled(s);this.setBulkUpload(s,"status",t.status).then((()=>{})),"completed"===t.status&&s.forEach((e=>{this.removeUpload(e).then((()=>{}))}))}}))}storesReady(){return 2===this.stores.ready.length}handleStores(e,t){"data-ready"===t&&(this.stores.ready.push(e),this.storesReady()&&this.checkRecovery().then((()=>{})))}initWorker(){this.worker=null,this.workerState={worker:null,tasks:new Map,restart:{count:0,max:3},settings:{timeout:3e3,maxConcurrent:3,restartAfterTimeout:!0}}}initElements(){this.selectors={fields:{field:"[data-upload-field]",input:'input[type="file"]',dropZone:".file-upload-container",preview:".preview-wrap",grid:".item-grid.preview",progress:{progress:".file-upload-container .progress",fill:".file-upload-container .progress .fill",details:".file-upload-container .progress .details",icon:".file-upload-container .progress .icon"},selectAll:"[data-select-all]",actions:".selection-actions",count:".selected .info",hidden:'input[type="hidden"]'},groups:{container:".group-display",grid:".item-grid.groups",empty:".empty-group",header:".sidebar .header"},group:{item:".upload-group",actions:".selection-actions",selectAll:'[name="select-all-group"]',count:".group-header .info",fields:"details .fields",grid:".item-grid.group",total:".group-content .group-count"},items:{item:".item.upload",checkbox:'[name="select-item"]',featured:'[name="featured"]',image:"img",details:"details",progress:{progress:".progress",fill:".fill",details:".details",icon:".icon"}}}}initListeners(){this.clickHandler=this.handleClick.bind(this),this.changeHandler=this.handleChange.bind(this),this.dragEnterHandler=this.handleDragEnter.bind(this),this.dragLeaveHandler=this.handleDragLeave.bind(this),this.dragOverHandler=this.handleDragOver.bind(this),this.dropHandler=this.handleDrop.bind(this),document.addEventListener("click",this.clickHandler),document.addEventListener("change",this.changeHandler),document.addEventListener("dragenter",this.dragEnterHandler),document.addEventListener("dragleave",this.dragLeaveHandler),document.addEventListener("dragover",this.dragOverHandler),document.addEventListener("drop",this.dropHandler),window.addEventListener("beforeunload",(()=>{this.cleanupAllPreviewUrls()}))}async setUpload(e,t){const s={...{id:e,attachment:null,group:null,field:null,src:window.location.href,blob:null,status:"local_processing",operationId:null,fields:{}},...t};return Object.preventExtensions(s),await this.stores.uploads.save(s),s}createPreviewUrl(e){const t=URL.createObjectURL(e);return this.previewUrls.add(t),t}revokePreviewUrl(e){e?.startsWith("blob:")&&(URL.revokeObjectURL(e),this.previewUrls.delete(e))}formatFile(e){return e.blob?new File([e.blob],e.fields.originalName||"file",{type:e.fields.type||e.blob.type,lastModified:e.fields.lastModified||Date.now()}):null}handleClick(e){let t=window.targetCheck(e,this.selectors.fields.dropZone);t&&!e.target.matches("input, button, a")&&t.querySelector(this.selectors.fields.input)?.click();const s=window.targetCheck(e,"[data-action]");s&&this.handleAction(s)}handleAction(e){const t=e.dataset.action,s=this.getFieldIdFromElement(e);switch(t){case"add-to-group":this.handleAddToGroup(s).then((()=>{}));break;case"delete-group":this.handleDeleteGroup(e);break;case"delete-upload":case"remove-from-group":this.handleRemoveItem(e).then((()=>{}));break;case"upload":this.queueUploads("uploads/groups",s).then((()=>{}));break;case"restore":this.handleRestoreSelected().then((()=>{}));break;case"restore-all":this.handleRestoreAll().then((()=>{}));break;case"clear-cache":this.handleClearCache().then((()=>{}))}}handleChange(e){let t=this.getFieldIdFromElement(e.target);if(t)if(e.target.matches(this.selectors.fields.input)){const s=Array.from(e.target.files);s.length>0&&this.processFiles(t,s).then((()=>{}))}else e.target.matches(this.selectors.items.checkbox)||e.target.matches(this.selectors.items.featured)||e.target.matches('[name*="select-"]')||("post_group"===this.fields.get(t).config.destination?this.handleGroupMetaChange(e.target):this.queueUploadMeta(e));else{e.target.closest("[data-upload-id], [data-attachment-id]")&&this.queueUploadMeta(e)}}handleGroupMetaChange(e){const t=e.dataset.groupId;if(!t)return;const s=e.name;if(!s)return;const i=e.value,r=s.replace(`${t}[`,"").replace(`${t}_`,"").replace("]","");window.debouncer.schedule(`group-meta-${t}-${r}`,(async()=>{const e=this.stores.groups.get(t);e&&(e.fields||(e.fields={}),e.fields[r]=i,await this.setGroup(t,e))}),300)}handleDragEnter(e){if(!e.dataTransfer.types.includes("Files"))return;const t=e.target.closest(this.selectors.fields.dropZone);t&&(e.preventDefault(),t.classList.add("dragover"))}handleDragLeave(e){const t=e.target.closest(this.selectors.fields.dropZone);t&&!t.contains(e.relatedTarget)&&t.classList.remove("dragover")}handleDragOver(e){if(!e.dataTransfer.types.includes("Files"))return;e.target.closest(this.selectors.fields.dropZone)&&(e.preventDefault(),e.dataTransfer.dropEffect="copy")}handleDrop(e){const t=e.target.closest(this.selectors.fields.dropZone);if(!t)return;e.preventDefault(),t.classList.remove("dragover"),t.classList.add("uploading");const s=Array.from(e.dataTransfer.files);if(0===s.length)return;const i=this.getFieldIdFromElement(t);i&&(this.processFiles(i,s).then((()=>{this.updateHandlerItems(i)})),this.a11y.announce(`${s.length} file(s) dropped for upload`))}async queueUploads(e,t){let s=new FormData;const i=this.fields.get(t);if(!i)return;let r=this.stores.uploads.filterByIndex({field:t});if(0===r.length)return;const[a,o]=["uploads"===e,"uploads/groups"===e];let l,d,n,u,p;s.append("fieldId",i.id),s.append("content",i.config.content),a&&(s.append("mode",i.config.mode),s.append("field_name",i.config.name),s.append("fieldId",i.id),s.append("field_type",i.config.type),s.append("subtype",i.config.subtype),s.append("item_id",i.config.itemID),s.append("destination",i.config.destination)),o?({posts:l,uploadMap:d,files:n}=this.collectGroups(t)):a&&({uploadMap:d,files:n}=this.collectUploads(t)),o&&s.append("posts",JSON.stringify(l)),n.forEach((e=>{s.append("files[]",e)})),s.append("upload_ids",JSON.stringify(d)),a?(u=`Uploading ${r.length} file${r.length>1?"s":""} to server...`,p=`Uploading ${r.length} file${r.length>1?"s":""}...`):o&&(u=`Creating ${l.length} ${i.config.content}${l.length>1?"s":""} from uploads...`,p=`Creating ${l.length} post${l.length>1?"s":""}...`),await this.setBulkUpload(r,"status","queued");let c=this.sendToQueue(e,s,u,p);if("uploads/groups"===e){let e=i.element.closest("details");e&&(e.open=!1)}return c?(i.operationId=c,await this.setBulkUpload(r,"operationId",c),await this.setBulkUpload(r,"status","uploading"),await this.setBulkGroup(t,"operationId",c),this.fields.set(i.id,i),this.notify("sent-to-queue",{field:i,operation:c})):await this.setBulkUpload(r,"status","failed"),c}async sendToQueue(e,t,s="",i="",r=!1){""===i&&(i=s);const a={endpoint:e,method:"POST",data:t,title:s,popup:i,canMerge:r,sendNow:"uploads/groups"===e,headers:{action_nonce:window.auth.getNonce("dash")},append:"_upload"};try{return await this.queue.addToQueue(a)}catch(e){return this.error.log(e,{component:"UploadManager",action:"sentToQueue"}),!1}}collectGroups(e){let t=this.stores.uploads.filterByIndex({field:e}),s=[],i=[],r=[];const a=this.stores.groups.filterByIndex({field:e}).filter((e=>{const t=this.getGroupUploadsInOrder(e);return t.length>0&&t.some((e=>this.formatFile(e)))}));for(const e of a){const t=this.groups.get(e.id)?.element,a={images:[],fields:this.collectGroupFieldsFromDOM(t,e.id)},o=this.getGroupUploadsInOrder(e);for(const t of o){const s=this.formatFile(t);if(s){r.push(s);const o={upload_id:t.id,index:i.length},l=this.uploads.get(t.id),d=l?.element?.querySelector(`input[name="${e.id}_featured"]`);d?.checked&&(a.fields.featured=t.id),a.images.push(o),i.push(t.id)}}a.images.length>0&&s.push(a)}const o=t.filter((e=>!e.group));for(const e of o){const t={images:[],fields:{}},a=this.formatFile(e);if(a){r.push(a);const s={upload_id:e.id,index:i.length};t.images.push(s),i.push(e.id)}t.images.length>0&&s.push(t)}return{posts:s,uploadMap:i,files:r}}getGroupUploadsInOrder(e){return e.uploads&&0!==e.uploads.length?e.uploads.map((e=>this.stores.uploads.get(e))).filter(Boolean):[]}collectGroupFieldsFromDOM(e,t){if(!e)return{};const s={};return e.querySelectorAll("input, textarea, select").forEach((e=>{const i=e.name.replace(`${t}[`,"").replace(`${t}_`,"").replace("]","");["featured","select-all"].some((e=>i.includes(e)))||e.value&&(s[i]=e.value)})),s}collectUploads(e){let t=this.stores.uploads.filterByIndex({field:e});if(0===t.length)return;let s=[],i=[];for(const e of t){const t=this.formatFile(e);t&&(i.push(t),s.push(e.id))}return{uploadMap:s,files:i}}queueUploadMeta(e){let t=e.target.closest("[data-attachment-id]")?.dataset.attachmentId,s=!1;if(!t&&(t=e.target.closest("[data-upload-id]")?.dataset.uploadId,s=!0,!t))return;if(!this.changes.has(t)){let e={};s?e.uploadId=t:e.attachmentId=t,this.changes.set(t,e)}let i=e.target.closest("[data-field]").dataset.field;this.changes.get(t)[i]=e.target.value,this.scheduleSave()}scheduleSave(){window.debouncer.schedule("upload-meta",(async()=>{if(this.changes.size>0){let e={};for(let[t,s]of this.changes.entries())console.log(t,s),e[t]=s;let t={user:window.auth.getUser(),items:e};await this.sendToQueue("uploads/meta",t,"Uploading Meta","Uploading Meta",!0),this.changes.clear()}}),2e3)}scanFields(e,t=!0,s=!0){e.querySelectorAll(this.selectors.fields.field).forEach((e=>this.registerField(e,t,s)))}registerField(e,t=!0,s=!0,i=null){const r={element:e,id:i||this.determineFieldId(e),config:this.extractFieldConfig(e,t,s),uploads:new Set,operationId:null,groups:[],ui:window.uiFromSelectors(this.selectors.fields,e),groupUI:window.uiFromSelectors(this.selectors.groups,e)};return this.fields.set(r.id,r),e.dataset.uploader=r.id,this.getSelectionHandler(r.id),"single"!==r.config.type&&this.initSortable(r.id),r.id}extractFieldConfig(e,t,s){return{autoUpload:t,showMeta:s,destination:e.dataset.destination||"meta",content:this.extractFieldContent(e),mode:e.dataset.mode||"direct",type:e.dataset.type||"single",name:e.dataset.field,itemID:this.extractFieldItemId(e)??0,maxFiles:parseInt(e.dataset.maxFiles)??25,subType:e.dataset.subtype??"image"}}extractFieldContent(e){return e.dataset.content||e.closest("dialog")?.dataset.content||e.closest("form")?.dataset.save||null}extractFieldItemId(e){return e.dataset.itemId||e.closest("dialog")?.dataset.itemId||null}determineFieldId(e){let t=this.extractFieldContent(e);t=null===t?"":t+"_";let s=this.extractFieldItemId(e);s=null===s?"":s+"_";return`${t}${s}${e.dataset.field||""}`}getFieldIdFromElement(e){const t=e.closest(this.selectors.fields.field);return t?.dataset.uploader||null}updateFieldProgress(e,t,s,i){const r=this.fields.get(e);r&&window.showProgress(r.ui.progress,t,s,i)}getWorker(){return this.workerState.worker||"undefined"==typeof OffscreenCanvas||(this.workerState.worker=new Worker("worker.js"),this.workerState.worker.onmessage=e=>this.handleWorkerMessage(e),this.workerState.worker.onerror=e=>this.handleWorkerError(e)),this.workerState.worker}handleWorkerMessage(e){const{id:t,blob:s}=e.data,i=this.workerState.tasks.get(t);i&&(clearTimeout(i.timeoutId),i.resolve(s),this.workerState.tasks.delete(t))}handleWorkerError(e){this.workerState.tasks.forEach((t=>{clearTimeout(t.timeoutId),t.reject(e)})),this.workerState.tasks.clear(),this.restartWorker()}restartWorker(){this.workerState.worker&&(this.workerState.worker.terminate(),this.workerState.worker=null),this.workerState.restart.count++}async processImages(e,t=2200,s=2200){const i=[],r=[...e],a=this.workerState.settings.maxConcurrent,o=async()=>{for(;r.length>0;){const e=r.shift(),a=await this.processImage(e.file,t,s);i.push({uploadId:e.uploadId,blob:a})}};return await Promise.all(Array.from({length:Math.min(a,e.length)},(()=>o()))),i}async processImage(e,t=2200,s=2200,i=3e3){if("undefined"==typeof OffscreenCanvas)return this.resizeImage(e,t,s);try{return await this.withTimeout(this.workerImage(e,t,s),i)}catch(i){return this.resizeImage(e,t,s)}}withTimeout(e,t){return Promise.race([e,new Promise(((e,s)=>setTimeout((()=>s(new Error("Timeout"))),t)))])}async workerImage(e,t=2200,s=2200){const{settings:i,restart:r}=this.workerState;if(r.count>=r.max)throw new Error("Worker max restarts exceeded");const a=await createImageBitmap(e);let{width:o,height:l}=a;if(o>t||l>s){const e=Math.min(t/o,s/l);o=Math.round(o*e),l=Math.round(l*e)}const d=this.getWorker(),n=crypto.randomUUID();return new Promise(((t,s)=>{const r=setTimeout((()=>{this.workerState.tasks.delete(n),i.restartAfterTimeout&&this.restartWorker(),s(new Error("Timeout"))}),i.timeout);this.workerState.tasks.set(n,{resolve:t,reject:s,timeoutId:r}),d.postMessage({id:n,imageBitmap:a,width:o,height:l,type:e.type,quality:.9},[a])}))}resizeImage(e,t,s){return new Promise((i=>{const r=new Image;r.onload=()=>{URL.revokeObjectURL(r.src);let{width:a,height:o}=r;if(a>t||o>s){const e=Math.min(t/a,s/o);a=Math.round(a*e),o=Math.round(o*e)}const l=document.createElement("canvas");l.width=a,l.height=o,l.getContext("2d").drawImage(r,0,0,a,o),l.toBlob(i,e.type,.9)},r.src=URL.createObjectURL(e)}))}async processFiles(e,t){let s=this.fields.get(e);if(!s)return;s.groupUI.container&&(s.groupUI.container.hidden=!1);const i=t.length;let r=0;this.updateFieldProgress(e,0,i,"Processing files...");const a=await Promise.all(t.map((async t=>{const s=window.generateID("upload"),i=await this.setUpload(s,{id:s,field:e,status:"local_processing",fields:{originalName:t.name,originalSize:t.size,type:t.type,lastModified:t.lastModified}}),r=await this.createUpload(s,t,e);return this.uploads.set(s,{element:r,ui:window.uiFromSelectors(this.selectors.items,r)}),await this.addToGroup(s,null),{uploadId:s,upload:i,file:t}}))),o=a.filter((e=>e.file.type.startsWith("image/"))),l=a.filter((e=>!e.file.type.startsWith("image/"))),d=await this.processImages(o.map((e=>({file:e.file,uploadId:e.uploadId}))));for(const{uploadId:t,blob:s}of d){const a=o.find((e=>e.uploadId===t));a&&(a.upload.blob=s,a.upload.fields.size=s.size,a.upload.status="queued",await this.setUpload(t,a.upload),r++,this.updateFieldProgress(e,r,i,"Processing files..."))}for(const{uploadId:t,upload:s,file:a}of l)s.blob=a,s.status="queued",await this.setUpload(t,s),r++,this.updateFieldProgress(e,r,i,"Processing files...");this.maybeLockUploads(e),s.config.autoUpload&&"post_group"!==s.config.destination&&await this.queueUploads("uploads",e)}async checkRecovery(){const e=this.stores.uploads.filterByIndex({status:["local_processing","queued","uploading"]}),t=Array.from(this.stores.groups.data.values());for(const e of t){this.stores.uploads.filterByIndex({group:e.id}).length>0||await this.stores.groups.delete(e.id)}if(0===e.length)return;const s=new Map;e.forEach((e=>{const t=e.src||"unknown";s.has(t)||s.set(t,[]),s.get(t).push(e)}));let i={bySource:s,pendingUploads:e};document.body.append(this.templates.create("restoreNotification",i));let r=document.querySelector("dialog.restore-uploads");this.restoreModal=new window.jvbModal(r),this.restoreSelection=new window.jvbHandleSelection(r,{wrapper:{wrapper:".restore-field",id:"selection"},items:".item-grid.restore",selectAll:{bulkControls:".selection-actions",checkbox:"#select-all-restore",count:".selection-count"}}),this.restoreModal.handleOpen()}async handleRestoreSelected(){if(!this.restoreSelection)return;let e=Array.from(this.restoreSelection.selectedItems);0!==e.length&&await this.restoreSelectedUploads(e)}async handleRestoreAll(){if(!this.restoreModal)return;const e=Array.from(this.restoreModal.modal.querySelectorAll(".item.upload")).map((e=>e.dataset.uploadId));await this.restoreSelectedUploads(e)}async restoreSelectedUploads(e){let t=window.location.href,s=Array.from(this.stores.uploads.data.values()).filter((s=>e.includes(s.id)&&s.src===t)),i=[...new Set(s.map((e=>e.group)))].filter(Boolean),r=s[0].field;if(!document.querySelector(`[data-uploader="${r}"]`))return void console.log("No field found for "+r);let a=this.fields.get(r);a.groupUI.container&&(a.groupUI.container.hidden=!1);let o=[];for(let e of i){let t=this.stores.groups.get(e);await this.createGroup(r,e);let i=this.groups.get(e),a=s.filter((t=>t.group===e));if(t&&this.groups.has(e)){let e=t.fields;for(const[t,s]of Object.entries(e)){let e=i.element.querySelector(`input[name*="${t}"]`);e&&(e.value=s)}}else e=null;for(let t of a){let s=await this.createUpload(t.id,this.formatFile(t),r);this.uploads.set(t.id,{element:s,ui:window.uiFromSelectors(this.selectors.items,s)}),await this.addToGroup(t.id,e),o.push(t.id)}}let l=s.filter((e=>!o.includes(e.id)));for(let e of l){let t=await this.createUpload(e.id,this.formatFile(e),r);this.uploads.set(e.id,{element:t,ui:window.uiFromSelectors(this.selectors.items,t)}),await this.addToGroup(e.id,null)}this.cleanupRestore()}cleanupRestore(){this.restoreModal.handleClose(),this.restoreSelection.destroy(),this.restoreSelection=null,this.restoreModal.destroy(),this.restoreModal.modal.remove(),this.restoreModal=null}getStatusText(e){return{received:"Image Received",local_processing:"Processing Image...",queued:"Waiting to upload...",uploading:"Uploading to Server",pending:"Successfully sent to server. In line for further processing.",processing:"Processing on server...",completed:"Upload complete!",failed:"Upload failed (will retry)",failed_permanent:"Upload failed permanently"}[e]||e}getStatusProgress(e){return{local_processing:28,queued:50,uploading:66,pending:75,processing:89,completed:100}[e]??0}async createUpload(e,t,s){let i=this.fields.get(s);if(!i)return null;let r={uploadId:e,file:t,field:i};return this.templates.create("uploadItem",r)}getSubtypeFromURL(e){const t=e.split("?")[0].toLowerCase();return[".webp",".jpg",".jpeg",".png",".gif",".svg"].some((e=>t.endsWith(e)))?"image":[".mp4",".ogg",".mov",".webm",".avi"].some((e=>t.endsWith(e)))?"video":"document"}getSubtypeFromMime(e){return e.startsWith("image/")?"image":e.startsWith("video/")?"video":"document"}async handleRemoveItem(e){const t=e.closest(this.selectors.items.item);if(!t)return;const s=t.dataset.uploadId;confirm("Remove this item?")&&(await this.removeUpload(s),this.a11y.announce("Item removed"))}async setBulkUpload(e,t,s){const i=Array.from(e).map((async e=>{if("string"==typeof e&&(e=await this.stores.uploads.get(e)),e)return"status"===t&&await this.setUploadStatus(e,s),e[t]=s,this.stores.uploads.save(e)}));await Promise.all(i)}async setUploadStatus(e,t){"string"==typeof e&&(e=await this.stores.uploads.get(e)),e&&e.progress&&window.showProgress(e.progress,this.getStatusProgress(t),100,this.getStatusText(t),this.queue.icons[t]??"")}async removeUpload(e){let t=this.stores.uploads.get(e);if(!t)return;if(t.group){let s=this.stores.groups.get(t.group);s.uploads=s.uploads.filter((t=>t!==e)),0===s.uploads.length?await this.removeGroup(s.id,!1):await this.stores.groups.save(s)}await this.clearUpload(e),this.maybeLockUploads(t.field);let s=this.selectionHandlers.get(t.field);s&&s.deselect(e),this.a11y.announce("Upload removed")}async clearUpload(e){const t=this.uploads.get(e);if(t&&(this.revokePreviewUrl(t.preview),t.element)){const e=t.element.dataset.previewUrl;this.revokePreviewUrl(e),t.element.remove()}this.uploads.delete(e),await this.stores.uploads.delete(e)}async handleAddToGroup(e){const t=this.selected.get(e);if(!t||0===t.size)return;let s=await this.createGroup(e);s&&(await Promise.all(Array.from(t).map((e=>this.addToGroup(e,s)))),this.selectionHandlers.get(e)?.clearSelection(),this.a11y.announce(`Created group with ${t.size} items`))}async createGroup(e,t=null){let s=this.fields.get(e);if(!s)return;t||(t=window.generateID("group"));const i=this.createGroupElement(t,e);if(!i)return null;const r=s.groupUI.empty;r?.nextSibling?s.groupUI.grid.insertBefore(i,r.nextSibling):s.groupUI.grid.append(i);const a=i.querySelector(".item-grid");a&&(a.dataset.groupId=t,this.createSortable(e,a,t));let o=this.stores.groups.data.has(t)?this.stores.groups.data.get(t):{};return await this.setGroup(t,{...o,id:t,field:e}),t}createGroupElement(e,t=null){let s={groupId:e,fieldId:t},i=this.templates.create("imageGroup",s);return this.groups.set(e,{element:i,ui:window.uiFromSelectors(this.selectors.group,i)}),this.getSelectionHandler(t)?.addWrapper(i),i}async setGroup(e,t){const s={...{id:e,src:window.location.href,uploads:[],operationId:null,field:null,fields:{}},...t};Object.preventExtensions(s),await this.stores.groups.save(s)}async setBulkGroup(e,t,s){let i=this.stores.groups.filterByIndex({field:e});if(0===i.length)return;let r=i.map((e=>{e[t]=s,this.stores.groups.save(e)}));await Promise.all(r)}async addToGroup(e,t=null){const s=this.stores.uploads.get(e),i=this.uploads.get(e);if(!s||!i)return;const r=this.fields.get(s.field);if(!r)return;if(null!==i.element?.parentElement&&(!t&&null===s.group||t===s.group))return void this.handleReorder(s.field,t);if(s.group){const t=this.stores.groups.get(s.group);t&&(t.uploads=t.uploads.filter((t=>t!==e)),0===t.uploads.length?await this.removeGroup(t.id,!1):await this.stores.groups.save(t))}i.ui.checkbox&&(i.ui.checkbox.checked=!1);const a=this.selectionHandlers.get(s.field);if(a&&a.isSelected(e)&&a.deselect(e),this.selected.get(s.field)?.has(e)&&this.selected.get(s.field).delete(e),i.ui.featured&&(i.ui.featured.hidden=!t),t){i.ui.featured&&(i.ui.featured.name=`${t}_featured`);let r=this.stores.groups.get(t);r&&(r.uploads.push(e),s.group=t,await this.stores.groups.save(r))}else s.group=null;let o=t?this.groups.get(t)?.ui.grid:r.ui.grid;o&&(o.append(i.element),t&&await this.handleReorder(s.field,t)),await this.stores.uploads.save(s)}handleDeleteGroup(e){const t=e.closest(this.selectors.group.item);if(!t)return;let s=t.dataset.groupId;if(!confirm("Delete this group? Items will be moved back to the upload area."))return;let i=this.stores.uploads.filterByIndex({group:s});Promise.all(i.map((e=>this.addToGroup(e.id,null)))).then((()=>{this.removeGroup(s,!1).then((()=>{})),this.a11y.announce("Group deleted. Items returned to upload area")}))}async removeGroup(e,t=!0){let s=this.groups.get(e),i=this.stores.groups.get(e);if(!i)return;let r=!0;t&&i.uploads.length>0&&(r=window.confirm("Keep uploads in this group?")),await Promise.all(i.uploads.map((e=>r?this.addToGroup(e,null):this.removeUpload(e))));if(this.fields.get(i.field)){const t=this.getGroupKey(i.field,e),r=this.selectionHandlers.get(t);r?.destroy&&r.destroy(),this.selectionHandlers.get(i.field)?.removeWrapper(s.element);const a=this.sortables.get(t);a?.destroy&&a.destroy(),this.sortables.delete(t)}s?.element&&s.element.remove(),this.groups.delete(e),await this.stores.groups.delete(e),this.a11y.announce("Group removed")}maybeLockUploads(e){let t=this.fields.get(e);if(!t||!t.ui.dropZone)return;let s=this.stores.uploads.filterByIndex({field:e}).length,i=t.config.maxFiles??25;t.ui.dropZone.hidden=s>=i}async handleOperationCancelled(e){0!==e.length&&e.forEach((e=>{this.removeUpload(e)}))}getGroupKey(e,t=null){return t?`${e}_${t}`:`${e}`}getSelectionHandler(e){let t=this.getGroupKey(e);if(!this.selectionHandlers.has(t)){let s=this.fields.get(e);if(!s)return;if("post_group"!==s.config.destination)return;let i=new window.jvbHandleSelection(s.element,{selectAll:{checkbox:this.selectors.fields.selectAll,count:this.selectors.fields.count,bulkControls:this.selectors.fields.actions},item:{item:this.selectors.items.item,checkbox:this.selectors.items.checkbox,idAttribute:"uploadId"},wrapper:{wrapper:".preview-wrap, .upload-group",id:"groupId"}});i.subscribe(((t,s)=>{this.selected.set(e,s.selectedItems)})),this.selectionHandlers.set(t,i)}return this.selectionHandlers.get(t)}updateHandlerItems(e){let t=this.getSelectionHandler(e);t&&t.collectItems()}initSortable(e){if(!window.Sortable)return;const t=this.fields.get(e);t&&(!Sortable._multiDragMounted&&Sortable.MultiDrag&&(Sortable.mount(new Sortable.MultiDrag),Sortable._multiDragMounted=!0),this.createSortable(e,t.ui.grid,null),this.initEmptyGroupDropZone(e))}createSortable(e,t,s){if(!t)return null;const i=this.getGroupKey(e,s);if(this.sortables.has(i))return this.sortables.get(i);const r=new Sortable(t,{animation:150,draggable:".item",multiDrag:!0,selectedClass:"selected",avoidImplicitDeselect:!0,group:{name:e,pull:!0,put:!0},dragClass:"dragging",ignore:".empty-group",onStart:t=>{const s=t.item,i=s?.dataset.uploadId,r=this.selected.get(e);if(i&&(!r||!r.has(i))){const t=this.selectionHandlers.get(e);t&&t.select(i)}},onEnd:t=>this.sortableDrop(t,e)});return this.sortables.set(i,r),r}initEmptyGroupDropZone(e){const t=this.fields.get(e),s=t?.groupUI?.empty;s&&(s.addEventListener("dragover",(e=>{e.preventDefault(),e.stopPropagation(),e.dataTransfer.dropEffect="move",s.classList.add("drag-over")})),s.addEventListener("dragleave",(e=>{s.contains(e.relatedTarget)||s.classList.remove("drag-over")})),s.addEventListener("drop",(async t=>{t.preventDefault(),t.stopPropagation(),s.classList.remove("drag-over");const i=this.selected.get(e);if(!i||0===i.size)return;const r=await this.createGroup(e);r&&(await Promise.all(Array.from(i).map((e=>this.addToGroup(e,r)))),this.selectionHandlers.get(e)?.clearSelection())})))}async sortableDrop(e,t){const s=e.to,i=(e.items?.length>0?Array.from(e.items):[e.item]).map((e=>e.dataset.uploadId)).filter(Boolean);if(0===i.length)return;const r=s.dataset.groupId||null;for(const e of i)await this.addToGroup(e,r);await this.handleReorder(t,r),this.selectionHandlers.get(t)?.clearSelection()}handleReorder(e,t=null){let s=t?this.groups.get(t)?.ui.grid:this.fields.get(e)?.ui.grid;if(!s)return void console.log("Couldn't Reorder items...");let i=Array.from(s.children).filter((e=>e.matches(this.selectors.items.item)&&!e.classList.contains("ghost"))).map((e=>e.dataset.uploadId)).filter((e=>e));if(t){let e=this.stores.groups.get(t);e&&(e.uploads=i,this.stores.groups.save(e).then((()=>{})))}else{let t=this.fields.get(e)?.ui.hidden;t&&(t.value=i.join(","))}this.a11y.announce("Items reordered")}subscribe(e){return this.subscribers.add(e),()=>this.subscribers.delete(e)}notify(e,t={}){this.subscribers.forEach((s=>{try{s(e,t)}catch(e){console.error("Subscriber error:",e)}}))}destroy(){this.subscribers.clear(),this.previewUrls.forEach((e=>{this.revokePreviewUrl(e)})),this.previewUrls.clear()}cleanupAllPreviewUrls(){this.previewUrls.forEach((e=>this.revokePreviewUrl(e))),this.previewUrls.clear()}async handleClearCache(){const e=window.location.href,t=this.stores.uploads.filterByIndex({src:e}),s=this.stores.groups.filterByIndex({src:e});await Promise.all([...t.map((e=>this.clearUpload(e.id))),...s.map((e=>(this.groups.get(e.id)?.element?.remove(),this.groups.delete(e.id),this.stores.groups.delete(e.id))))]),this.restoreModal&&this.cleanupRestore(),this.a11y.announce("Cache cleared for this page")}async getFilesForForm(e){const t=e.querySelectorAll(this.selectors.fields.field),s=[];for(const e of t){const t=this.determineFieldId(e),i=e.dataset.field,r=this.stores.uploads.filterByIndex({field:t});for(const e of r){const t=this.formatFile(e);t&&s.push({file:t,fieldName:i,uploadId:e.id,meta:e.fields||{}})}}return s}async clearFieldFromStores(e){const t=this.stores.uploads.filterByIndex({field:e}),s=this.stores.groups.filterByIndex({field:e});await Promise.all(t.map((e=>this.clearUpload(e.id)))),await Promise.all(s.map((e=>(this.groups.get(e.id)?.element?.remove(),this.groups.delete(e.id),this.stores.groups.delete(e.id)))))}}document.addEventListener("DOMContentLoaded",(async function(){window.auth.subscribe((t=>{"auth-loaded"===t&&(window.jvbUploads=new e)}))}))})();
\ No newline at end of file
diff --git a/base/membership.php b/base/membership.php
index 4b436ec..11752bc 100644
--- a/base/membership.php
+++ b/base/membership.php
@@ -13,6 +13,7 @@
* Example:
* [
* 'member_content' => true,
+ * 'invitable' => true,
* 'can_invite' => ['artist' => ['artist']],
* 'member_verified' => true,
* 'notifications' => true,
diff --git a/base/options.php b/base/options.php
index 70f5fb1..44a43c5 100644
--- a/base/options.php
+++ b/base/options.php
@@ -1,6 +1,6 @@
<?php
/**
- * Custom options for the site can be set here. They will need to be handled by the child plugin, but you can make use of the MetaManager.php this way
+ * Custom options for the site can be set here. They will need to be handled by the child plugin, but you can make use of the Meta.php this way
*/
$options = apply_filters('jvb_options', []);
diff --git a/base/taxonomies.php b/base/taxonomies.php
index ddc7716..6143668 100644
--- a/base/taxonomies.php
+++ b/base/taxonomies.php
@@ -15,7 +15,7 @@
* - approve_new = (bool) if true, admin/verified users need to approve before 'live'
* - track_changes = (bool) if true, table is created to track historical changes
* - for_content = (array) of post type slugs, as defined in JVB_CONTENT
- * - fields = (array) of custom field definitions, from inc/managers/MetaManager.php
+ * - fields = (array) of custom field definitions, from inc/managers/Meta.php
* -> add use_in_stats (bool) to use the field in user statistics
*/
$taxonomies = apply_filters('jvb_taxonomy', []);
diff --git a/build/summary/render.php b/build/summary/render.php
index ca5cde1..f0e00c7 100644
--- a/build/summary/render.php
+++ b/build/summary/render.php
@@ -1,6 +1,8 @@
<?php
use JVBase\managers\Cache;
+use JVBase\meta\Meta;
+use JVBase\meta\Render;
if (!defined('ABSPATH')) {
exit; // Exit if accessed directly
@@ -39,7 +41,7 @@
}
ob_start();
- $meta = new JVBase\meta\MetaManager((int)$current->ID, 'post');
+ $meta = Meta::forPost($current->ID);
$artist = jvbContentFromUser((int)$current->post_author);
$sections = JVB_CONTENT[jvbNoBase($current->post_type)]['sections']??[];
@@ -85,7 +87,7 @@
</li>
</ul>
<?php endif; ?>
- <?php $styles = $meta->getValue('top_styles');
+ <?php $styles = $meta->get('top_styles');
if (!empty($styles)) {
?>
<ul class="term-list style">
@@ -117,10 +119,10 @@
</summary>
<div class="columns stack-small">
<div class="column">
- <?php $meta->render('render', 'image_portrait'); ?>
+ <?= Render::renderFrom($meta, 'image_portrait'); ?>
</div>
<div class="column">
- <?php $meta->render('render', 'short_bio'); ?>
+ <?= Render::renderFrom($meta, 'short_bio'); ?>
</div>
</div>
<div id="styles">
@@ -136,15 +138,15 @@
</div>
<div id="about">
- <?php $meta->render('render', 'bio')?>
+ <?= Render::renderFrom($meta, 'bio')?>
</div>
</details>
</section>
<section id="contact" class="">
<h2>Contact <?=$artist['name']?></h2>
<?php
- echo jvbRenderContactInfo($current->ID, $meta);
- echo jvbRenderLinks($current->ID, $meta);
+ echo jvbRenderContactInfo($current->ID, 'post');
+ echo jvbRenderLinks($current->ID, 'post');
?>
</section>
<?php
@@ -160,23 +162,14 @@
$cache = Cache::for('shop_bio', WEEK_IN_SECONDS)->connect('taxonomy');
$key = $current->term_id;
$cached = $cache->get($key);
- $cached = false;
if ($cached !== false) {
return $cached;
}
ob_start();
- $meta = new JVBase\meta\MetaManager($current->term_id, 'term');
- $rating = $meta->getValue('average_rating');
- $opened = $meta->getValue('established');
- $about = $meta->getValue('bio');
- $location = $meta->getValue('location');
- $hours = $meta->getValue('hours');
- $specialty = $meta->getValue('specialties');
- $awards = $meta->getValue('awards');
- $reviews = $meta->getValue('reviews');
-
+ $meta = Meta::forTerm($current->term_id);
+ $fields = $meta->getAll(['average_rating', 'established', 'bio','location','hours','specialties','awards','reviews']);
?>
<nav id="shop" class="on-this-page index">
<label>Jump to:
@@ -186,35 +179,35 @@
</label>
<ul>
<li><a href="#top" title="Back to Top"><?=jvbIcon('caret-circle-up')?></a></li> <?php
- if ($rating !== 'none') {
+ if ($fields['rating'] !== 'none') {
?>
<li><a href="#rating">Rating</a></li>
<?php
- } elseif ($opened !== '') {
+ } elseif ($fields['opened'] !== '') {
?>
<li><a href="#opened">Opened</a></li>
<?php
- } elseif ($location !== '') {
+ } elseif ($fields['location'] !== '') {
?>
<li><a href="#location">Location</a></li>
<?php
- } elseif ($about !== '') {
+ } elseif ($fields['about'] !== '') {
?>
<li><a href="#about">About</a></li>
<?php
- } elseif ($hours !== '') {
+ } elseif ($fields['hours'] !== '') {
?>
<li><a href="#hours">Hours</a></li>
<?php
- } elseif ($specialty !== '') {
+ } elseif ($fields['specialties'] !== '') {
?>
<li><a href="#specialties">Specialties</a></li>
<?php
- } elseif ($awards !== '') {
+ } elseif ($fields['awards'] !== '') {
?>
<li><a href="#awards">Awards</a></li>
<?php
- } elseif ($reviews !== '') {
+ } elseif ($fields['reviews'] !== '') {
?>
<li><a href="#reviews">Reviews</a></li>
<?php
@@ -227,17 +220,17 @@
<header id="top">
<div class="columns stack-small">
<div class="column">
- <?=jvbFormatImage($meta->getValue('image'))?>
+ <?=jvbFormatImage($meta->get('image'))?>
</div>
<div class="column">
<h1>
- <small><?= (get_term((int)$meta->getValue('city'), BASE.'city')) ?
- get_term((int)$meta->getValue('city'), BASE.'city')->name :
+ <small><?= (get_term((int)$meta->get('city'), BASE.'city')) ?
+ get_term((int)$meta->get('city'), BASE.'city')->name :
'Edmonton'?>'s Best Tattoo Shops</small>
<?=$current->name?>
</h1>
- <?= jvbFormatRating($current->term_id, $meta) ?>
- <?php $meta->render('render', 'slogan'); ?>
+ <?= jvbFormatRating($current->term_id, 'term') ?>
+ <?= Render::renderFrom($meta, 'slogan'); ?>
</div>
</div>
</header>
@@ -247,33 +240,33 @@
<h2>Learn More About <?=$current->name?></h2>
</summary>
<div class="map">
- <?php $meta->render('render', 'location'); ?>
+ <?= Render::renderFrom($meta, 'location'); ?>
</div>
<div class="short-bio">
- <?php $meta->render('render', 'short_bio'); ?>
+ <?= Render::renderFrom($meta, 'short_bio'); ?>
</div>
<div class="contact">
<h3>Contact:</h3>
<?php
- echo jvbRenderContactInfo($current->term_id, $meta);
- echo jvbRenderLinks($current->term_id, $meta);
+ echo jvbRenderContactInfo($current->term_id, 'term');
+ echo jvbRenderLinks($current->term_id, 'term');
?>
</div>
<div id="about">
- <?php $meta->render('render', 'bio')?>
+ <?= Render::renderFrom($meta, 'bio')?>
</div>
</details>
</section>
<section id="contact" class="">
<h2>Contact </h2>
<?php
- echo jvbRenderContactInfo($current->term_id, $meta);
- echo jvbRenderLinks($current->term_id, $meta);
+ echo jvbRenderContactInfo($current->term_id, 'term');
+ echo jvbRenderLinks($current->term_id, 'term');
?>
</section>
- <?= jvbRenderHours($current->term_id, $meta)?>
+ <?= jvbRenderHours($current->term_id, 'term')?>
<?php
@@ -306,7 +299,7 @@
$title = '';
}
- $meta = new JVBase\meta\MetaManager($current->ID, 'term');
+ $meta = Meta::forTerm($current->ID);
$fields = JVB_TAXONOMY[$tax]['fields']??[];
?>
diff --git a/cleanup.php b/cleanup.php
index 461c845..da041ce 100644
--- a/cleanup.php
+++ b/cleanup.php
@@ -210,7 +210,7 @@
BASE . 'gallery'
];
- //TODO: Dynamically use MetaManager to get any image or gallery fields
+ //TODO: Dynamically use Meta.php to get any image or gallery fields
foreach ($meta_fields as $meta_key) {
$meta_value = get_post_meta($post_id, $meta_key, true);
diff --git a/inc/EmbedGenerator.php b/inc/EmbedGenerator.php
index 63c1f11..1d6e9cd 100644
--- a/inc/EmbedGenerator.php
+++ b/inc/EmbedGenerator.php
@@ -1,7 +1,7 @@
<?php
namespace JVBase;
-use JVBase\meta\MetaManager;
+use JVBase\meta\Meta;
use WP_User;
if (!defined('ABSPATH')) {
@@ -16,7 +16,7 @@
protected int $user_id;
protected int $profileID;
protected WP_User $user_data;
- protected MetaManager $meta;
+ protected Meta $meta;
protected string $base_url = 'https://edmonton.ink';
protected string $badge_url;
@@ -30,7 +30,8 @@
$this->user_id = $user_id;
$this->profileID = get_user_meta($user_id, BASE . 'link', true);
$this->user_data = get_userdata($user_id);
- $this->meta = new MetaManager($this->profileID, 'post');
+
+ $this->meta = Meta::forPost($this->profileID);
// Set badge URL - this would be your badge image path
$this->badge_url = JVB_URL . 'assets/images/badges/edmonton-ink-badge.png';
@@ -169,7 +170,7 @@
protected function getArtistStyles():array
{
$styles = [];
- $top_styles = $this->meta->getValue('top_style');
+ $top_styles = $this->meta->get('top_style');
if (!empty($top_styles)) {
$style_ids = explode(',', $top_styles);
diff --git a/inc/blocks/FAQBlock.php b/inc/blocks/FAQBlock.php
index 6697669..d16032a 100644
--- a/inc/blocks/FAQBlock.php
+++ b/inc/blocks/FAQBlock.php
@@ -2,8 +2,6 @@
namespace JVBase\blocks;
use JVBase\managers\Cache;
-use JVBase\forms\TaxonomySelector;
-use JVBase\meta\MetaManager;
use WP_Block;
use WP_Query;
diff --git a/inc/blocks/FormBlock.php b/inc/blocks/FormBlock.php
index 3e8a892..216857d 100644
--- a/inc/blocks/FormBlock.php
+++ b/inc/blocks/FormBlock.php
@@ -2,11 +2,8 @@
namespace JVBase\blocks;
use JVBase\managers\Cache;
-use JVBase\meta\MetaManager;
-use JVBase\managers\CloudflareTurnstile;
-use Exception;
+use JVBase\meta\Form;
use JVBase\utility\Features;
-use WP_Block;
if (!defined('ABSPATH')) {
exit; // Exit if accessed directly
@@ -40,7 +37,7 @@
// Initialize forms from filter
$this->forms = $this->registerForms();
$this->form_contact = apply_filters('jvb_form_contact', '');
-
+
// Hook into the CustomBlocks render system
add_filter('jvb_render_block_jvb_forms', [$this, 'render'], 10, 2);
@@ -181,7 +178,6 @@
$this->renderTurnstile();
$this->renderFormEnd($type, $form_id);
echo '</div>';
-
return ob_get_clean();
}
@@ -294,17 +290,14 @@
return;
}
- // Create MetaManager instance for form rendering
- $meta = new MetaManager();
-
// If sections are defined, render in sections
if (!empty($form_config['sections'])) {
- $this->renderSections($type, $meta);
+ $this->renderSections($type);
} else {
echo jvbFormStatus();
// Render fields directly
foreach ($form_config['fields'] as $field_name => $field_config) {
- $meta->render('form', $field_name, $field_config);
+ echo Form::render($field_name, null, $field_config);
}
$submit_text = $form_config['submit'] ?? 'Submit';
echo '<button type="submit" class="button primary">' . esc_html($submit_text) . '</button>';
@@ -314,7 +307,7 @@
/**
* Render form sections
*/
- protected function renderSections(string $type, MetaManager $meta): void
+ protected function renderSections(string $type): void
{
$form_config = $this->forms[$type];
$sections = $form_config['sections'];
@@ -369,7 +362,7 @@
});
foreach ($section_fields as $field_name => $field_config) {
- $meta->render('form', $field_name, $field_config);
+ echo Form::render($field_name, null, $field_config);
}
// Add step navigation buttons
diff --git a/inc/blocks/GlossaryBlock.php b/inc/blocks/GlossaryBlock.php
index 696c21d..c5c1a4f 100644
--- a/inc/blocks/GlossaryBlock.php
+++ b/inc/blocks/GlossaryBlock.php
@@ -91,7 +91,7 @@
$glossary = [];
if ($posts->have_posts()) {
foreach($posts->posts as $post) {
-// $meta = new MetaManager($post, 'post');
+// $meta = Meta::forPost($post);
// $fields = $meta->getAll();
// $glossary[$fields['post_slug']] = $fields;
$glossary[$post->post_name] = [
diff --git a/inc/blocks/MenuBlock.php b/inc/blocks/MenuBlock.php
index 88fd26a..679f017 100644
--- a/inc/blocks/MenuBlock.php
+++ b/inc/blocks/MenuBlock.php
@@ -3,7 +3,9 @@
use JVBase\managers\Cache;
use JVBase\forms\TaxonomySelector;
-use JVBase\meta\MetaManager;
+use JVBase\meta\Form;
+use JVBase\meta\Meta;
+use JVBase\meta\Render;
use WP_Block;
use WP_Query;
@@ -104,8 +106,8 @@
protected function getSections():array
{
if (!$this->sections) {
- $options = new MetaManager(null, 'options');
- $sections = $options->getValue('menu_section_order');
+ $options = Meta::forOptions('options');
+ $sections = $options->get('menu_section_order');
if (!is_array($sections)) {
$sections = [];
}
@@ -153,7 +155,7 @@
}
protected function renderMenuItem(int $ID, string $slug, string $postType = 'menu_item') {
- $meta = new MetaManager($ID, 'post');
+ $meta = Meta::forPost($ID);
$values = $meta->getAll([
'post_title',
'_square_catalog_id',
@@ -197,13 +199,12 @@
<p class="price"><?= $priceRange ?></p>
</div>
<div class="description">
- <?php $meta->render('render', 'post_excerpt')?>
+ <?= Render::renderFrom($meta, 'post_excerpt')?>
</div>
<div class="info row end">
<?php
if (empty($variations)) {
- $meta->render(
- 'form',
+ Form::renderFrom($meta,
$ID.'|cart_quantity',
[
'type' => 'number',
@@ -227,8 +228,7 @@
foreach ($variations as $index =>$row) {
jvbDump($index, 'index');
jvbDump($row, 'row');
- $meta->render(
- 'form',
+ Form::renderFrom($meta,
'quantity-'.$index,
[
'type' => 'number',
diff --git a/inc/blocks/TimelineBlock.php b/inc/blocks/TimelineBlock.php
index 5c21fdd..09d1b14 100644
--- a/inc/blocks/TimelineBlock.php
+++ b/inc/blocks/TimelineBlock.php
@@ -2,7 +2,7 @@
namespace JVBase\blocks;
use JVBase\managers\Cache;
-use JVBase\meta\MetaManager;
+use JVBase\meta\Meta;
use JVBase\utility\Features;
use WP_Block;
@@ -87,7 +87,7 @@
}
protected function renderHeader():void
{
- $meta = new MetaManager($this->parentID, 'post');
+ $meta = Meta::forPost($this->parentID);
$sharedFields = JVB()->routes('content')->getTimelineSharedFields($this->content);
$fields = $meta->getAll($sharedFields);
$extra = $meta->getAll();
@@ -174,7 +174,7 @@
$uniqueFields = JVB()->routes('content')->getTimelineUniqueFields($this->content);
foreach ($all as $i => $ID) {
- $meta = new MetaManager($ID, 'post');
+ $meta = Meta::forPost($ID);
$fields = $meta->getAll($uniqueFields);
$plural = ($i>1) ? 's': '';
diff --git a/inc/forms/PostSelector.php b/inc/forms/PostSelector.php
index 6f4ed7c..5fa2026 100644
--- a/inc/forms/PostSelector.php
+++ b/inc/forms/PostSelector.php
@@ -264,7 +264,7 @@
}
/**
- * Render post selector field for MetaForm integration
+ * Render post selector field for Meta's Form integration
*
* @param string $name Field name
* @param mixed $value Current value
diff --git a/inc/helpers/all.php b/inc/helpers/all.php
index 59ef251..3931fc3 100644
--- a/inc/helpers/all.php
+++ b/inc/helpers/all.php
@@ -32,12 +32,14 @@
*/
function jvb_do_once():void
{
+
// delete_option(BASE.'do_these_once');
$options = get_option(BASE.'do_these_once', []);
foreach ($options as $option => $callback) {
// delete_option($option);
if (!get_option($option, false)) {
+ error_log('Calling do once: '.$option);
$callback();
update_option($option, true);
}
diff --git a/inc/helpers/crud.php b/inc/helpers/crud.php
index cd36f41..e213573 100644
--- a/inc/helpers/crud.php
+++ b/inc/helpers/crud.php
@@ -5,6 +5,7 @@
}
use JVBase\managers\Cache;
+use JVBase\meta\Form;
/**
* For whatever reason, after much testing, it seems that
@@ -346,8 +347,6 @@
echo $nav;
$fields = jvbGetFields($postType);
-
- $meta = new JVBase\meta\MetaManager($ID, $contentType);
?>
<form class="jvb-form" id="bio" data-form-id="bio-<?=$ID?>" data-save="bio"
data-object-id="<?=$ID?>" data-content-type="<?=$postType?>">
@@ -383,10 +382,10 @@
if (array_key_exists('role', $config)) {
$user = get_userdata($ID);
if (in_array($config['role'], $user->roles)) {
- $meta->render('form', $field, $config);
+ echo Form::render($field, null, $config);
}
} else {
- $meta->render('form', $field, $config);
+ echo Form::render($field, null, $config);
}
}
?>
diff --git a/inc/helpers/formatting.php b/inc/helpers/formatting.php
index 6f7f077..38d6941 100644
--- a/inc/helpers/formatting.php
+++ b/inc/helpers/formatting.php
@@ -1,6 +1,7 @@
<?php
use JVBase\managers\Cache;
+use JVBase\meta\Meta;
use JVBase\utility\Image;
if (!defined('ABSPATH')) {
@@ -68,11 +69,10 @@
/**
* @param int $ID
- * @param JVBase\meta\MetaManager|null $meta
- *
+ * @param string $type 'post', 'user', or 'term'
* @return string
*/
-function jvbFormatRating(int $ID, JVBase\meta\MetaManager|null $meta = null):string
+function jvbFormatRating(int $ID, string $type = 'post'):string
{
$cache = Cache::for('rating', WEEK_IN_SECONDS)->connect('post')->connect('taxonomy')->connect('user');
@@ -81,21 +81,21 @@
return $cached;
}
- if (!$meta) {
- if (term_exists((int)$ID)) {
- $type = 'term';
- } elseif (get_post_status((int)$ID)) {
- $type = 'post';
- } else {
- $type = 'user';
- }
- $meta = new JVBase\meta\MetaManager($ID, $type);
- }
+ $meta = match ($type) {
+ 'term' => Meta::forTerm($ID),
+ 'post' => Meta::forPost($ID),
+ 'user' => Meta::forUser($ID),
+ default => false
+ };
+ if (!$type) {
+ return '';
+ }
+
$out = '';
- $avg = $meta->getValue('average_rating');
+ $avg = $meta->get('average_rating');
- $total = $meta->getValue('total_ratings');
+ $total = $meta->get('total_ratings');
if ($avg !== 'none') {
$out .= jvbFormatStarRating($avg, (int)$total);
}
diff --git a/inc/helpers/forms.php b/inc/helpers/forms.php
index c993381..6ef4899 100644
--- a/inc/helpers/forms.php
+++ b/inc/helpers/forms.php
@@ -1,10 +1,12 @@
<?php
+use JVBase\meta\Form;
+
if (!defined('ABSPATH')) {
exit;
}
-function jvbRenderForm(string $endpoint, array $fields, JVBase\meta\MetaManager $meta, array $options = [], bool $return = false):mixed
+function jvbRenderForm(string $endpoint, array $fields, array $options = [], bool $return = false):mixed
{
ob_start();
?>
@@ -18,7 +20,7 @@
}
foreach ($fields as $field => $config) {
- $meta->render('form', $field, $config);
+ echo Form::render($field, null, $config);
}
?>
<?= (jvbCheck('submit', $options)) ? '<button type="submit">'.jvbIcon('floppy-disk').'Save</button>' : '' ?>
diff --git a/inc/helpers/members.php b/inc/helpers/members.php
index 26c7fa2..d62b383 100644
--- a/inc/helpers/members.php
+++ b/inc/helpers/members.php
@@ -1,7 +1,7 @@
<?php
use JVBase\managers\Cache;
-use JVBase\meta\MetaManager;
+use JVBase\meta\Meta;
if (!defined('ABSPATH')) {
exit;
@@ -174,7 +174,7 @@
}
$id = (int) get_user_meta($userID, BASE.'link', true);
- $meta = new MetaManager($id,'post');
+ $meta = Meta::forPost($id);
$artist = $meta->getAll(['first_name','type','city','shop']);
$artist['id'] = $id;
$artist['display_name'] = $user->display_name;
diff --git a/inc/helpers/renderFields.php b/inc/helpers/renderFields.php
index 4930917..6554579 100644
--- a/inc/helpers/renderFields.php
+++ b/inc/helpers/renderFields.php
@@ -6,8 +6,8 @@
use JVBase\forms\TaxonomySelector;
use JVBase\managers\Cache;
-use JVBase\meta\MetaForm;
-use JVBase\meta\MetaManager;
+use JVBase\meta\Form;
+use JVBase\meta\Meta;
/**
* Outputs a toggle text that visually changes based on selection, like a switch
@@ -59,11 +59,10 @@
/**
* @param int $ID
- * @param MetaManager|null $meta
- *
+ * @param string $type
* @return string
*/
-function jvbRenderLinks(int $ID, MetaManager|null $meta = null):string
+function jvbRenderLinks(int $ID, string $type =''):string
{
$cache = Cache::for('user_links', WEEK_IN_SECONDS)->connect('post')->connect('taxonomy')->connect('user');
$cached = $cache->get($ID);
@@ -71,11 +70,20 @@
return $cached;
}
- if (!$meta) {
- $meta = jvbGetMeta($ID);
- }
+ $meta = match($type){
+ 'post' => Meta::forPost($ID),
+ 'term' => Meta::forTerm($ID),
+ 'user' => Meta::forUser($ID),
+ default => false
+ };
+ if (!$meta) {
+ $meta = jvbGetMeta($ID);
+ }
+ if (!$meta) {
+ return '';
+ }
- $links = $meta->getValue('links');
+ $links = $meta->get('links');
$out = '';
if (!empty($links)) {
@@ -135,11 +143,11 @@
/**
* @param int $ID
- * @param MetaManager|null $meta
+ * @param string $type
*
* @return string
*/
-function jvbRenderContactInfo(int $ID, MetaManager|null $meta = null):string
+function jvbRenderContactInfo(int $ID, string $type = ''):string
{
$cache = Cache::for('contact', WEEK_IN_SECONDS)->connect('post')->connect('taxonomy');
@@ -147,17 +155,26 @@
if($cached){
return $cached;
}
- if (!$meta) {
- $meta = jvbGetMeta($ID);
- }
+ $meta = match($type){
+ 'post' => Meta::forPost($ID),
+ 'term' => Meta::forTerm($ID),
+ 'user' => Meta::forUser($ID),
+ default => false
+ };
+ if (!$meta) {
+ $meta = jvbGetMeta($ID);
+ }
+ if (!$meta) {
+ return '';
+ }
- $preference = $meta->getValue('public_contact');
+ $preference = $meta->get('public_contact');
$preference = (is_array($preference)) ? $preference : explode(',', $preference);
$out = '';
if (!empty($preference)) {
$out = '<ul class="contact">';
- $phone = $meta->getValue('phone');
+ $phone = $meta->get('phone');
foreach ($preference as $p) {
$link = $label = false;
switch ($p) {
@@ -170,7 +187,7 @@
$label = jvbIcon('phone').'<span>Call</span>';
break;
case 'email':
- $link = 'mailto:'.$meta->getValue('email').'?subject='.rawurlencode('Contact from edmonton.ink').'&body='.rawurlencode('Hey,
+ $link = 'mailto:'.$meta->get('email').'?subject='.rawurlencode('Contact from edmonton.ink').'&body='.rawurlencode('Hey,
I found you on edmonton.ink, and I wanted to reach out!');
$label = jvbIcon('envelope').'<span>Email</span>';
break;
@@ -190,17 +207,26 @@
/**
* @param int $ID
- * @param MetaManager|null $meta
+ * @param string $type
* @return string
*/
-function jvbRenderSpecialtyField(int $ID, MetaManager|null $meta = null):string
+function jvbRenderSpecialtyField(int $ID, string $type = ''):string
{
- if (!$meta) {
- $meta = jvbGetMeta($ID);
- }
+ $meta = match($type){
+ 'post' => Meta::forPost($ID),
+ 'term' => Meta::forTerm($ID),
+ 'user' => Meta::forUser($ID),
+ default => false
+ };
+ if (!$meta) {
+ $meta = jvbGetMeta($ID);
+ }
+ if (!$meta) {
+ return '';
+ }
$out = '';
- $specialties = $meta->getValue('specialties');
+ $specialties = $meta->get('specialties');
if (!empty($specialties)) {
foreach ($specialties as $specialty) {
$out .= '<li><b>'.$specialty['specialty'].'</b>';
@@ -220,17 +246,26 @@
/**
* @param int $ID
- * @param MetaManager|null $meta
+ * @param string $type = ''
* @return string
*/
-function jvbRenderAwardsField(int $ID, MetaManager|null $meta = null):string
+function jvbRenderAwardsField(int $ID, string $type = ''):string
{
- if (!$meta) {
- $meta = jvbGetMeta($ID);
- }
+ $meta = match($type){
+ 'post' => Meta::forPost($ID),
+ 'term' => Meta::forTerm($ID),
+ 'user' => Meta::forUser($ID),
+ default => false
+ };
+ if (!$meta) {
+ $meta = jvbGetMeta($ID);
+ }
+ if (!$meta) {
+ return '';
+ }
$out = '';
- $awards = $meta->getValue('awards');
+ $awards = $meta->get('awards');
if (!empty($awards)) {
foreach ($awards as $award) {
$out .= '<li><b>'.$award['name'].'</b>';
@@ -248,17 +283,26 @@
/**
* @param int $ID
- * @param MetaManager|null $meta
+ * @param string $type
* @return string
*/
-function jvbRenderReviewsField(int $ID, MetaManager|null $meta = null):string
+function jvbRenderReviewsField(int $ID, string $type = ''):string
{
- if (!$meta) {
- $meta = jvbGetMeta($ID);
- }
+ $meta = match($type){
+ 'post' => Meta::forPost($ID),
+ 'term' => Meta::forTerm($ID),
+ 'user' => Meta::forUser($ID),
+ default => false
+ };
+ if (!$meta) {
+ $meta = jvbGetMeta($ID);
+ }
+ if (!$meta) {
+ return '';
+ }
$out = '';
- $reviews = $meta->getValue('reviews');
+ $reviews = $meta->get('reviews');
if (!empty($reviews)) {
foreach ($reviews as $review) {
if ($review['review'] === '') {
@@ -298,19 +342,20 @@
return $out;
}
-function jvbGetMeta(int $ID) {
- if (is_tax()) {
- $type = 'term';
- } elseif (is_singular()) {
- $type = 'post';
+function jvbGetMeta(int $ID):Meta|false {
+ if (term_exists($ID)) {
+ return Meta::forTerm($ID);
+ } elseif (get_post_status($ID)) {
+ return Meta::forPost($ID);
+ } elseif (get_userdata($ID)) {
+ return Meta::forUser($ID);
} else {
- $type = 'user';
- }
- return new JVBase\meta\MetaManager($ID, $type);
+ return false;
+ }
}
-function jvbRenderTermList(array|bool|WP_Error $terms, string $label = '') {
+function jvbRenderTermList(array|bool|WP_Error $terms, string $label = ''):string {
if (!$terms || is_wp_error($terms) || empty($terms)) {
return '';
}
@@ -468,10 +513,7 @@
</div>
</template>
<template class="uploadItem">
- <?php
- $form = new MetaForm();
- $form->renderImagePreview();
- ?>
+ <?= Form::renderImagePreview() ?>
</template>
<template class="restoreNotification">
<dialog class="restore-uploads">
@@ -554,7 +596,6 @@
function jvbImageMeta(int|null $ID = null, string $title = '', string $alt = '', string $caption = '', array $fields = []):string
{
- $form = new MetaForm();
$dataID = ($ID) ? ['image-id' => $ID] : false;
$addID = ($ID) ? '-'.$ID : '';
@@ -580,7 +621,7 @@
]
];
- return $form->render('image_data', null, $config, false, true);
+ return Form::render('image_data', null, $config, false, true);
}
diff --git a/inc/helpers/time.php b/inc/helpers/time.php
index 7670726..241dc1c 100644
--- a/inc/helpers/time.php
+++ b/inc/helpers/time.php
@@ -1,6 +1,7 @@
<?php
use JVBase\managers\Cache;
+use JVBase\meta\Meta;
if (!defined('ABSPATH')) {
exit;
@@ -135,11 +136,11 @@
/**
* @param int $ID
- * @param JVBase\Meta\MetaManager $meta
+ * @param string $type
*
* @return string
*/
-function jvbRenderHours(int $ID, JVBase\Meta\MetaManager $meta):string
+function jvbRenderHours(int $ID, string $type = ''):string
{
$cache = Cache::for('hours_display', WEEK_IN_SECONDS)->connect('taxonomy')->connect('post')->connect('user');
@@ -149,20 +150,22 @@
return $cached;
}
+ $meta = match($type){
+ 'post' => Meta::forPost($ID),
+ 'term' => Meta::forTerm($ID),
+ 'user' => Meta::forUser($ID),
+ default => false
+ };
if (!$meta) {
- if (term_exists($ID)) {
- $type = 'term';
- } elseif (get_post_status($ID)) {
- $type = 'post';
- } else {
- $type = 'user';
- }
- $meta = new JVBase\meta\MetaManager($ID, $type);
+ $meta = jvbGetMeta($ID);
+ }
+ if (!$meta) {
+ return '';
}
- $hours = $meta->getValue('hours');
- $byAppt = $meta->getValue('by_appointment');
- $walkins = $meta->getValue('walkins');
+ $hours = $meta->get('hours');
+ $byAppt = $meta->get('by_appointment');
+ $walkins = $meta->get('walkins');
$out = '';
diff --git a/inc/helpers/ui.php b/inc/helpers/ui.php
index e97bbfa..4da00a5 100644
--- a/inc/helpers/ui.php
+++ b/inc/helpers/ui.php
@@ -1,5 +1,6 @@
<?php
+use JVBase\meta\Form;
use JVBase\utility\Features;
use JVBase\utility\Image;
@@ -212,24 +213,7 @@
*/
function jvbSearch(string $placeholder = 'Search...', string $id = 'search'):string
{
- $id = sanitize_title($id);
- return sprintf(
- '<div class="search-container row start nowrap">
- <input type="search" id="%s" placeholder="%s">
- <button
- title="Clear Search"
- type="button"
- class="clear-search"
- aria-label="Clear search"
- onclick="this.previousElementSibling.value = \'\'; this.previousElementSibling.focus();"
- >%s</button>
- <button type="button" title="Search" class="toggle search" aria-label="Toggles search input visually" onclick="this.parentNode.classList.toggle(\'open\');this.previousElementSibling.previousElementSibling.focus();">%s</button>
- </div>',
- $id,
- $placeholder,
- jvbIcon('x', ['title'=> 'Clear Search']),
- jvbIcon('magnifying-glass')
- );
+ return Form::search($placeholder, $id);
}
@@ -436,24 +420,30 @@
return $out;
}
-function jvbRenderProgressBar(string $inside ='', $top = false, $icon = true)
+function jvbRenderProgressBar(string $inside ='', $top = false, $icon = true, $return = false):string
{
+
$top = $top ? ' abs top' : '';
- ?>
- <div class="progress<?=$top?>">
+ $bar = sprintf(
+ '<div class="progress%s">
<div class="bar">
<div class="fill"></div>
</div>
<div class="row btw">
- <?php if ($icon) { ?>
- <i class="icon"></i>
- <?php } ?>
+ %s
<div class="details">
- <?=$inside?>
+ %s
</div>
</div>
- </div>
- <?php
+ </div>',
+ $top,
+ ($icon) ? '<i class="icon"></i>': '',
+ $inside
+ );
+ if (!$return) {
+ echo $bar;
+ }
+ return $bar;
}
function jvbFormStatus(string $message = '') {
diff --git a/inc/importers/JaneAppSalesImporter.php b/inc/importers/JaneAppSalesImporter.php
index 92c5c9a..53c54a1 100644
--- a/inc/importers/JaneAppSalesImporter.php
+++ b/inc/importers/JaneAppSalesImporter.php
@@ -1,6 +1,5 @@
<?php
-
-namespace JVBase\managers;
+namespace JVBase\importers;
use WP_Error;
@@ -13,7 +12,7 @@
*
* Imports sales/treatment data from JaneApp CSV exports and updates referral tracking
*/
-class JaneSalesImporter
+class JaneAppSalesImporter
{
protected $wpdb;
protected string $jane_clients_table;
diff --git a/inc/integrations/Cloudflare.php b/inc/integrations/Cloudflare.php
index 8b2bc13..2dad696 100644
--- a/inc/integrations/Cloudflare.php
+++ b/inc/integrations/Cloudflare.php
@@ -1,7 +1,6 @@
<?php
namespace JVBase\integrations;
-use JVBase\meta\MetaManager;
use WP_Error;
if (!defined('ABSPATH')) {
exit;
diff --git a/inc/integrations/Facebook.php b/inc/integrations/Facebook.php
index cfaa600..197ca61 100644
--- a/inc/integrations/Facebook.php
+++ b/inc/integrations/Facebook.php
@@ -5,8 +5,8 @@
*/
namespace JVBase\integrations;
-use JVBase\meta\MetaManager;
use Exception;
+use JVBase\meta\Meta;
use WP_Error;
use WP_Post;
@@ -524,18 +524,18 @@
private function createFacebookEvent(array $data): array
{
$post = get_post($data['post_id']);
- $meta = new MetaManager($post->ID, 'post');
+ $meta = Meta::forPost($post->ID);
$event_data = [
'name' => $post->post_title,
'description' => $this->formatPostContent($post),
- 'start_time' => $meta->getValue('event_start_date'),
- 'end_time' => $meta->getValue('event_end_date'),
+ 'start_time' => $meta->get('event_start_date'),
+ 'end_time' => $meta->get('event_end_date'),
'access_token' => $this->page_access_token
];
// Add location if available
- $location = $meta->getValue('event_location');
+ $location = $meta->get('event_location');
if ($location) {
$event_data['location'] = $location;
}
diff --git a/inc/integrations/GoogleMyBusiness.php b/inc/integrations/GoogleMyBusiness.php
index 2e97d01..9e86377 100644
--- a/inc/integrations/GoogleMyBusiness.php
+++ b/inc/integrations/GoogleMyBusiness.php
@@ -1,7 +1,7 @@
<?php
namespace JVBase\integrations;
-use JVBase\meta\MetaManager;
+use JVBase\meta\Meta;
use WP_Error;
if (!defined('ABSPATH')) {
exit;
@@ -282,7 +282,7 @@
}
$postID = $data['post_id'];
- $meta = new MetaManager($postID, 'post');
+ $meta = Meta::forPost($postID);
$fields = [
'start_date',
'end_date',
@@ -318,7 +318,7 @@
$result = $this->updatePost($fields["_{$this->service_name}_item_id"], $data);
} else {
$result = $this->createPost($data);
- $meta->updateValue("_{$this->service_name}_item_id", $result['name']);
+ $meta->set("_{$this->service_name}_item_id", $result['name']);
}
return [
@@ -339,7 +339,7 @@
}
$postID = $data['post_id'];
- $meta = new MetaManager($postID, 'post');
+ $meta = Meta::forPost($postID);
$fields = [
'post_excerpt',
'post_title',
@@ -371,7 +371,7 @@
$result = $this->updatePost($fields["_{$this->service_name}_item_id"], $data);
} else {
$result = $this->createPost($data);
- $meta->updateValue("_{$this->service_name}_item_id", $result['name']);
+ $meta->set("_{$this->service_name}_item_id", $result['name']);
}
return [
@@ -393,7 +393,7 @@
}
$postID = $data['post_id'];
- $meta = new MetaManager($postID, 'post');
+ $meta = Meta::forPost($postID);
$fields = [
'post_excerpt',
'post_title',
@@ -422,7 +422,7 @@
$result = $this->updatePost($fields["_{$this->service_name}_item_id"], $data);
} else {
$result = $this->createPost($data);
- $meta->updateValue("_{$this->service_name}_item_id", $result['name']);
+ $meta->set("_{$this->service_name}_item_id", $result['name']);
}
return [
@@ -2470,7 +2470,7 @@
protected function collectMenu(array $menu_items): array
{
- $defaultMeta = new MetaManager($this->userID, 'integrations');
+ $defaultMeta = Meta::forOptions($this->userID.'_integrations');
$defaults = ['menu_name', 'menu_description', 'default_section', 'cuisines', 'source_url', 'language', 'default_currency'];
$defaults = $defaultMeta->getAll($defaults);
@@ -2531,7 +2531,7 @@
protected function buildMenuItem(\WP_Post $item, array $defaults):array
{
- $meta = new MetaManager($item->ID, 'post');
+ $meta = Meta::forPost($item->ID);
$fields = $this->mappedMenuFields($item->post_type);
$values = $meta->getAll(array_values($fields));
@@ -2660,8 +2660,8 @@
protected function getMenuSectionsOrder(array $sections_map):array
{
- $optionsMeta = new MetaManager(null, 'options');
- $sectionOrder = $optionsMeta->getValue('menu_section_order');
+ $optionsMeta = Meta::forOptions('options');
+ $sectionOrder = $optionsMeta->get('menu_section_order');
// Build final GMB menu structure
$ordered = [];
@@ -2697,8 +2697,8 @@
// Collect cuisines from individual items if specified
foreach ($menu_items as $item) {
- $meta = new MetaManager($item->ID, 'post');
- $item_cuisines = $meta->getValue('cuisines');
+ $meta = Meta::forPost($item->ID);
+ $item_cuisines = $meta->get('cuisines');
if (!empty($item_cuisines)) {
$item_cuisines = is_array($item_cuisines) ?
diff --git a/inc/integrations/Helcim.php b/inc/integrations/Helcim.php
index ca486cd..a70ac16 100644
--- a/inc/integrations/Helcim.php
+++ b/inc/integrations/Helcim.php
@@ -1,7 +1,7 @@
<?php
namespace JVBase\integrations;
-use JVBase\meta\MetaManager;
+use JVBase\meta\Meta;
use Exception;
use WP_Error;
use WP_REST_Request;
@@ -666,7 +666,7 @@
$post = get_post($post_id);
if (!$post) continue;
- $meta = new MetaManager($post_id, 'post');
+ $meta = Meta::forPost($post_id);
$field_map = $this->field_mappings[$post->post_type] ?? [];
// Prepare product data for Helcim
@@ -675,12 +675,12 @@
'description' => $post->post_content,
'productCode' => get_post_meta($post_id, BASE . '_helcim_product_code', true) ?: 'WP-' . $post_id,
'type' => $content_type,
- 'price' => floatval($meta->getValue('price')) * 100, // Convert to cents
- 'taxable' => (bool)$meta->getValue('is_taxable'),
+ 'price' => floatval($meta->get('price')) * 100, // Convert to cents
+ 'taxable' => (bool)$meta->get('is_taxable'),
];
// Handle variations
- $variations = $meta->getValue('product_variations');
+ $variations = $meta->get('product_variations');
if (!empty($variations)) {
$product_data['variations'] = $this->prepareVariations($variations);
}
@@ -838,7 +838,7 @@
if ($post_id) {
// Update meta data
- $meta = new MetaManager($post_id, 'post');
+ $meta = Meta::forPost($post_id);
$meta->setAll([
'price' => $product['price'] / 100, // Convert from cents
'_helcim_product_id' => $product['productId'],
@@ -1228,8 +1228,8 @@
$post_id = intval($item['id'] ?? 0);
if (!$post_id) continue;
- $meta = new MetaManager($post_id, 'post');
- $price = floatval($meta->getValue('price'));
+ $meta = Meta::forPost($post_id);
+ $price = floatval($meta->get('price'));
$quantity = intval($item['quantity'] ?? 1);
$total += ($price * $quantity * 100); // Convert to cents
@@ -1254,12 +1254,12 @@
if (!$post_id) continue;
$post = get_post($post_id);
- $meta = new MetaManager($post_id, 'post');
+ $meta = Meta::forPost($post_id);
$line_items[] = [
'description' => $post->post_title,
'quantity' => intval($item['quantity'] ?? 1),
- 'price' => floatval($meta->getValue('price')) * 100,
+ 'price' => floatval($meta->get('price')) * 100,
'productCode' => get_post_meta($post_id, BASE . '_helcim_product_code', true) ?: 'WP-' . $post_id
];
}
diff --git a/inc/integrations/Integrations.php b/inc/integrations/Integrations.php
index 08c375f..e059b5e 100644
--- a/inc/integrations/Integrations.php
+++ b/inc/integrations/Integrations.php
@@ -3,8 +3,8 @@
use Exception;
use JVBase\managers\Cache;
-use JVBase\managers\UploadManager;
-use JVBase\meta\MetaManager;
+use JVBase\meta\Form;
+use JVBase\meta\Meta;
use JVBase\managers\ErrorHandler;
use WP_Error;
use WP_Post;
@@ -2081,20 +2081,11 @@
*/
protected function mapFieldsToService(int $postID, array $mapping): array
{
- $meta_manager = new MetaManager($postID, 'post');
- $post = get_post($postID);
+ $meta_manager = Meta::forPost($postID);
$service_data = [];
foreach ($mapping as $wp_field => $service_field) {
- $value = null;
-
- // Check if it's a post field
- if (property_exists($post, $wp_field)) {
- $value = $post->$wp_field;
- } else {
- // It's a meta field
- $value = $meta_manager->getValue($wp_field);
- }
+ $value = $meta_manager->get($wp_field);
if ($value !== null && $value !== '') {
$this->setNestedValue($service_data, $service_field, $value);
@@ -2169,7 +2160,7 @@
protected function getSyncFields(int $postID, string $type, array $additional = []):array
{
- $meta = new MetaManager($postID, $type);
+ $meta = new Meta($postID, $type);
$fieldsToCheck = [
'share_to_' . $this->service_name,
'_keep_synced_' . $this->service_name,
@@ -2930,7 +2921,7 @@
return '';
}
- $meta = new MetaManager($this->userID, 'integrations');
+ $meta = Meta::forOptions($this->userID.'_integrations');
$is_connected = $this->isSetUp();
$credentials = $this->getCredentials();
@@ -3007,7 +2998,7 @@
$config['value'] = $credentials[$name]??'';
$config['autocomplete'] = 'off';
$config['base'] = $this->service_name.'_';
- $meta->render('form', $name, $config);
+ Form::render($name, null, $config);
}
}
if ($this->handleWebhooks) {
@@ -3038,7 +3029,7 @@
$config['value'] = $credentials[$name]??'';
$config['base'] = $this->service_name.'_';
$config['autocomplete'] = 'off';
- $meta->render('form', $name, $config);
+ Form::render($name,null, $config);
}
?>
</details>
@@ -3277,7 +3268,7 @@
if (empty($types)) {
return;
}
- $meta = new MetaManager($this->userID, 'integrations');
+ $meta = Meta::forOptions($this->userID.'_integrations');
?>
<form>
<h1><?= $this->title?> Defaults:</h1>
@@ -3289,7 +3280,7 @@
$config['base'] = $this->service_name.'_';
$config['autocomplete'] = 'off';
- $meta->render('form', $name, $config);
+ echo Form::render($name, null, $config);
}
foreach ($this->syncPostTypes as $type) {
$config = JVB_CONTENT[$type];
@@ -3309,7 +3300,7 @@
$c['hint'] = $c['description'];
unset($c['description']);
}
- $meta->render('form', $name, $c);
+ echo Form::render($name, null, $c);
}
?>
</details>
diff --git a/inc/integrations/Square.php b/inc/integrations/Square.php
index 4b9dd2a..e0c73c5 100644
--- a/inc/integrations/Square.php
+++ b/inc/integrations/Square.php
@@ -1,8 +1,8 @@
<?php
namespace JVBase\integrations;
-use JVBase\meta\MetaForm;
-use JVBase\meta\MetaManager;
+use JVBase\meta\Form;
+use JVBase\meta\Meta;
use Exception;
use JVBase\registry\PostTypeRegistrar;
use WP_Error;
@@ -857,7 +857,6 @@
if (is_singular(BASE.'dash') || is_post_type_archive(BASE.'dash')) {
return $actions;
}
- $meta = new MetaForm();
$form = '<aside id="cart" class="right main">
<form id="checkout" data-form-id="checkout" data-save="checkout">';
@@ -874,33 +873,33 @@
'description' => 'Securely checkout with your name, email, and payments processed by Square.',
'content' => '<div class="checkout-section">
<h3>Customer Information</h3>
- '.$meta->return('cart_name', null, [
+ '.Form::render('cart_name', null, [
'type' => 'text',
'label' => 'Your Name',
'required' => true,
'autocomplete' => 'name'
]).
- $meta->return('cart_email', null, [
+ Form::render('cart_email', null, [
'type' => 'email',
'label' => 'Your Email',
'required' => true,
'autocomplete'=> 'email',
]).
- $meta->return('cart_phone', null, [
+ Form::render('cart_phone', null, [
'type' => 'tel',
'label' => 'Your Phone',
'required' => true,
'autocomplete'=> 'phone'
]).'
<h3>Pickup Details</h3>'.
- $meta->return('pickup_time', null, [
+ Form::render('pickup_time', null, [
'type' => 'datetime',
'label' => 'Pickup Type',
'min' => '11:00',
'max' => '20:00',
'required' => true,
]).
- $meta->return('special_instructions', null, [
+ Form::render('special_instructions', null, [
'type' => 'textarea',
'label' => 'Special Instructions',
'quill' => true,
@@ -1319,7 +1318,7 @@
return new WP_Error('post_not_found', "Post $postID not found");
}
- $meta = new MetaManager($postID, 'post');
+ $meta = Meta::forPost($postID);
$post_type = get_post_type($postID);
// Get existing Square catalog ID if it exists
@@ -1344,10 +1343,10 @@
}
// Add variations
- $variations = $meta->getValue('product_variations');
+ $variations = $meta->get('product_variations');
if (empty($variations)) {
// Create default variation if none exist
- $price = floatval($meta->getValue('price') ?: 0);
+ $price = floatval($meta->get('price') ?: 0);
$catalog_object['item_data']['variations'][] = [
'type' => 'ITEM_VARIATION',
'id' => $existing_square_id ? null : '#'.BASE.'menu_item_' . $postID . '_var_default',
@@ -1400,7 +1399,7 @@
}
// Add modifiers if they exist
- $modifiers = $meta->getValue('modifiers');
+ $modifiers = $meta->get('modifiers');
if (!empty($modifiers)) {
$modifier_ids = [];
foreach ($modifiers as $modifier) {
@@ -1416,7 +1415,7 @@
}
// Add tax settings
- $tax_ids = $meta->getValue('tax_ids');
+ $tax_ids = $meta->get('tax_ids');
if (!empty($tax_ids)) {
$catalog_object['item_data']['tax_ids'] = $tax_ids;
}
@@ -2008,7 +2007,7 @@
if ($wp_order_id) {
// Update the post meta
- $meta = new MetaManager($wp_order_id, 'post');
+ $meta = Meta::forPost($wp_order_id);
$updates = [
'status' => $state,
'updated_at' => current_time('mysql')
@@ -2436,7 +2435,7 @@
*/
private function mapSquareFieldsToWordPress(int $post_id, array $item): void
{
- $meta = new MetaManager($post_id, 'post');
+ $meta = Meta::forPost($post_id);
$field_map = $this->getFieldMapping(get_post_type($post_id));
$values_to_save = [];
@@ -3589,10 +3588,9 @@
}
// Save all order meta
- $meta = new MetaManager($order_post_id, 'post');
+ $meta = Meta::forPost($order_post_id);
$fields = $this->getSquarePostConfig('_sq_orders')['fields'];
unset($fields['post_title']);
- $meta->setFieldConfig($fields);
$meta->setAll([
'square_order_id' => $order_data['square_order_id'],
diff --git a/inc/managers/Cache.php b/inc/managers/Cache.php
index e55e758..6460971 100644
--- a/inc/managers/Cache.php
+++ b/inc/managers/Cache.php
@@ -32,7 +32,7 @@
add_action('save_post', [self::class, 'onPostChange'], 10, 2);
add_action('delete_post', [self::class, 'onPostDelete']);
- // Post meta updates, now handled via MetaManager.php?
+ // Post meta updates, now handled via Meta.php?
// add_action('updated_post_meta', [self::class, 'onPostMetaChange'], 10, 2);
// add_action('added_post_meta', [self::class, 'onPostMetaChange'], 10, 2);
// add_action('deleted_post_meta', [self::class, 'onPostMetaDelete'], 10, 2);
diff --git a/inc/managers/CustomTable.php b/inc/managers/CustomTable.php
new file mode 100644
index 0000000..893e5ad
--- /dev/null
+++ b/inc/managers/CustomTable.php
@@ -0,0 +1,747 @@
+<?php
+namespace JVBase\managers;
+
+use Exception;
+
+if (!defined('ABSPATH')) {
+ exit;
+}
+
+/**
+ * Custom Table Helper
+ *
+ * Provides consistent interface for CRUD operations on custom tables
+ * Used by routes that interact with custom tables defined in CheckCustomTables.php
+ *
+ * @example
+ * $table = new CustomTable('favourites');
+ * $result = $table->insert(['user_id' => 1, 'type' => 'tattoo', 'target_id' => 123]);
+ */
+class CustomTable
+{
+ protected \wpdb $wpdb;
+ protected string $tableName;
+ protected string $fullTableName;
+ protected bool $useTransactions;
+
+ /** @var array<string, self> Instance cache for fluent interface */
+ protected static array $instances = [];
+
+ /**
+ * Fluent factory method
+ *
+ * @param string $tableName Table name without prefix/BASE
+ * @return self
+ *
+ * @example CustomTable::for('favourites')->insert($data);
+ */
+ public static function for(string $tableName): self
+ {
+ if (!isset(self::$instances[$tableName])) {
+ self::$instances[$tableName] = new self($tableName);
+ }
+
+ return self::$instances[$tableName];
+ }
+
+ /**
+ * Clear instance cache (useful for testing)
+ */
+ public static function clearCache(): void
+ {
+ self::$instances = [];
+ }
+
+ /**
+ * @param string $tableName Table name without prefix/BASE (e.g., 'favourites', 'notifications')
+ * @param bool $useTransactions Whether to auto-wrap operations in transactions
+ */
+ public function __construct(string $tableName, bool $useTransactions = false)
+ {
+ global $wpdb;
+ $this->wpdb = $wpdb;
+ $this->tableName = $tableName;
+ $this->fullTableName = $wpdb->prefix . BASE . $tableName;
+ $this->useTransactions = $useTransactions;
+ }
+
+ // =========================================================================
+ // FLUENT QUERY BUILDER
+ // =========================================================================
+
+ /** @var array Query builder state */
+ protected array $builder = [];
+
+ /**
+ * Start a fluent query - set WHERE conditions
+ *
+ * @param array $conditions Associative array of column => value
+ * @return self
+ *
+ * @example CustomTable::for('favourites')->where(['user_id' => 1])->get();
+ */
+ public function where(array $conditions): self
+ {
+ $this->builder['where'] = $conditions;
+ return $this;
+ }
+
+ /**
+ * Set ORDER BY
+ *
+ * @param string $column Column to order by
+ * @param string $direction ASC or DESC
+ * @return self
+ */
+ public function orderBy(string $column, string $direction = 'DESC'): self
+ {
+ $this->builder['orderby'] = $column;
+ $this->builder['order'] = strtoupper($direction);
+ return $this;
+ }
+
+ /**
+ * Set LIMIT
+ *
+ * @param int $limit Number of records
+ * @param int $offset Optional offset
+ * @return self
+ */
+ public function limit(int $limit, int $offset = 0): self
+ {
+ $this->builder['limit'] = $limit;
+ $this->builder['offset'] = $offset;
+ return $this;
+ }
+
+ /**
+ * Execute the built query and get results
+ *
+ * @param string $output OBJECT, ARRAY_A, or ARRAY_N
+ * @return array
+ */
+ public function getResults(string $output = OBJECT): array
+ {
+ $results = $this->getMany($this->builder, $output);
+ $this->resetBuilder();
+ return $results;
+ }
+
+ /**
+ * Execute the built query and get first result
+ *
+ * @param string $output OBJECT, ARRAY_A, or ARRAY_N
+ * @return object|array|null
+ */
+ public function first(string $output = OBJECT): object|array|null
+ {
+ $this->builder['limit'] = 1;
+ $results = $this->getMany($this->builder, $output);
+ $this->resetBuilder();
+ return $results[0] ?? null;
+ }
+
+ /**
+ * Count records with current builder state
+ *
+ * @return int
+ */
+ public function countResults(): int
+ {
+ $where = $this->builder['where'] ?? [];
+ $count = $this->count($where);
+ $this->resetBuilder();
+ return $count;
+ }
+
+ /**
+ * Check if any records exist with current builder state
+ *
+ * @return bool
+ */
+ public function existsInQuery(): bool
+ {
+ return $this->countResults() > 0;
+ }
+
+ /**
+ * Delete records matching current builder state
+ *
+ * @return int|false Number of deleted rows
+ */
+ public function deleteResults(): int|false
+ {
+ $where = $this->builder['where'] ?? [];
+ $result = $this->delete($where);
+ $this->resetBuilder();
+ return $result;
+ }
+
+ /**
+ * Update records matching current builder state
+ *
+ * @param array $data Data to update
+ * @return int|false Number of updated rows
+ */
+ public function updateResults(array $data): int|false
+ {
+ $where = $this->builder['where'] ?? [];
+ $result = $this->update($data, $where);
+ $this->resetBuilder();
+ return $result;
+ }
+
+ /**
+ * Reset query builder state
+ */
+ protected function resetBuilder(): void
+ {
+ $this->builder = [];
+ }
+
+ // =========================================================================
+ // CREATE OPERATIONS
+ // =========================================================================
+
+ /**
+ * Insert a single record
+ *
+ * @param array $data Associative array of column => value
+ * @param array|null $format Optional array of format strings (%d, %s, %f)
+ * @return int|false Insert ID on success, false on failure
+ *
+ * @example
+ * $id = $table->insert([
+ * 'user_id' => 1,
+ * 'type' => 'tattoo',
+ * 'target_id' => 123,
+ * 'date_added' => current_time('mysql')
+ * ]);
+ */
+ public function insert(array $data, ?array $format = null): int|false
+ {
+ // Auto-add created_at if column exists and not provided
+ if (!isset($data['created_at']) && $this->hasColumn('created_at')) {
+ $data['created_at'] = current_time('mysql');
+ }
+
+ $result = $this->wpdb->insert(
+ $this->fullTableName,
+ $data,
+ $format
+ );
+
+ if ($result === false) {
+ $this->logError('insert', $data);
+ return false;
+ }
+
+ return $this->wpdb->insert_id;
+ }
+
+ /**
+ * Alias for insert() - more semantic for fluent interface
+ *
+ * @param array $data Data to insert
+ * @return int|false Insert ID on success
+ *
+ * @example CustomTable::for('favourites')->create(['user_id' => 1]);
+ */
+ public function create(array $data): int|false
+ {
+ return $this->insert($data);
+ }
+
+ /**
+ * Find or create a record
+ *
+ * @param array $searchData Data to search for
+ * @param array $createData Optional additional data for creation
+ * @return array ['id' => int, 'created' => bool, 'record' => object]
+ *
+ * @example
+ * $result = CustomTable::for('favourites')->findOrCreate(
+ * ['user_id' => 1, 'target_id' => 123],
+ * ['type' => 'tattoo']
+ * );
+ * // Returns: ['id' => 456, 'created' => false, 'record' => object]
+ */
+ public function findOrCreate(array $searchData, array $createData = []): array
+ {
+ $record = $this->get($searchData);
+
+ if ($record) {
+ return [
+ 'id' => $record->id ?? 0,
+ 'created' => false,
+ 'record' => $record
+ ];
+ }
+
+ $data = array_merge($searchData, $createData);
+ $id = $this->insert($data);
+
+ return [
+ 'id' => $id,
+ 'created' => true,
+ 'record' => $this->get(['id' => $id])
+ ];
+ }
+
+ /**
+ * Bulk insert multiple records efficiently
+ *
+ * @param array $rows Array of associative arrays
+ * @param array $columns Column names (must be same for all rows)
+ * @return int|false Number of rows inserted, false on failure
+ *
+ * @example
+ * $count = $table->bulkInsert([
+ * ['user_id' => 1, 'type' => 'tattoo', 'target_id' => 123],
+ * ['user_id' => 1, 'type' => 'tattoo', 'target_id' => 456],
+ * ], ['user_id', 'type', 'target_id']);
+ */
+ public function bulkInsert(array $rows, array $columns): int|false
+ {
+ if (empty($rows)) {
+ return 0;
+ }
+
+ // Auto-add created_at if column exists
+ if ($this->hasColumn('created_at') && !in_array('created_at', $columns)) {
+ $columns[] = 'created_at';
+ $now = current_time('mysql');
+ foreach ($rows as &$row) {
+ $row['created_at'] = $now;
+ }
+ }
+
+ $placeholders = [];
+ $values = [];
+
+ foreach ($rows as $row) {
+ $row_placeholders = [];
+ foreach ($columns as $column) {
+ $value = $row[$column] ?? null;
+ $values[] = $value;
+ $row_placeholders[] = $this->getPlaceholder($value);
+ }
+ $placeholders[] = "(" . implode(',', $row_placeholders) . ")";
+ }
+
+ $columns_escaped = array_map(function($col) {
+ return "`{$col}`";
+ }, $columns);
+
+ $query = "INSERT INTO {$this->fullTableName}
+ (" . implode(',', $columns_escaped) . ")
+ VALUES " . implode(',', $placeholders);
+
+ $result = $this->wpdb->query($this->wpdb->prepare($query, $values));
+
+ if ($result === false) {
+ $this->logError('bulkInsert', ['rows' => count($rows)]);
+ return false;
+ }
+
+ return $result;
+ }
+
+ // =========================================================================
+ // READ OPERATIONS
+ // =========================================================================
+
+ /**
+ * Get a single record
+ *
+ * @param array $where Associative array of column => value conditions
+ * @param string $output OBJECT, ARRAY_A, or ARRAY_N
+ * @return object|array|null
+ *
+ * @example
+ * $fav = $table->get(['user_id' => 1, 'target_id' => 123]);
+ */
+ public function get(array $where, string $output = OBJECT): object|array|null
+ {
+ $query = "SELECT * FROM {$this->fullTableName} WHERE " . $this->buildWhereClause($where);
+ $values = array_values($where);
+
+ return $this->wpdb->get_row($this->wpdb->prepare($query, $values), $output);
+ }
+
+ /**
+ * Get multiple records
+ *
+ * @param array $args Query arguments: where, orderby, order, limit, offset
+ * @param string $output OBJECT, ARRAY_A, or ARRAY_N
+ * @return array
+ *
+ * @example
+ * $favs = $table->getMany([
+ * 'where' => ['user_id' => 1],
+ * 'orderby' => 'date_added',
+ * 'order' => 'DESC',
+ * 'limit' => 20
+ * ]);
+ */
+ public function getMany(array $args = [], string $output = OBJECT): array
+ {
+ $query = "SELECT * FROM {$this->fullTableName}";
+ $values = [];
+
+ // WHERE clause
+ if (!empty($args['where'])) {
+ $query .= " WHERE " . $this->buildWhereClause($args['where']);
+ $values = array_merge($values, array_values($args['where']));
+ }
+
+ // ORDER BY
+ if (!empty($args['orderby'])) {
+ $orderby = sanitize_sql_orderby($args['orderby']);
+ $order = (!empty($args['order']) && strtoupper($args['order']) === 'ASC') ? 'ASC' : 'DESC';
+ $query .= " ORDER BY {$orderby} {$order}";
+ }
+
+ // LIMIT
+ if (!empty($args['limit'])) {
+ $limit = absint($args['limit']);
+ $offset = !empty($args['offset']) ? absint($args['offset']) : 0;
+ $query .= " LIMIT {$offset}, {$limit}";
+ }
+
+ if (empty($values)) {
+ return $this->wpdb->get_results($query, $output);
+ }
+
+ return $this->wpdb->get_results($this->wpdb->prepare($query, $values), $output);
+ }
+
+ /**
+ * Count records
+ *
+ * @param array $where Associative array of column => value conditions
+ * @return int
+ */
+ public function count(array $where = []): int
+ {
+ $query = "SELECT COUNT(*) FROM {$this->fullTableName}";
+ $values = [];
+
+ if (!empty($where)) {
+ $query .= " WHERE " . $this->buildWhereClause($where);
+ $values = array_values($where);
+ }
+
+ if (empty($values)) {
+ return (int) $this->wpdb->get_var($query);
+ }
+
+ return (int) $this->wpdb->get_var($this->wpdb->prepare($query, $values));
+ }
+
+ /**
+ * Check if record exists
+ *
+ * @param array $where Associative array of column => value conditions
+ * @return bool
+ */
+ public function exists(array $where): bool
+ {
+ return $this->count($where) > 0;
+ }
+
+ // =========================================================================
+ // UPDATE OPERATIONS
+ // =========================================================================
+
+ /**
+ * Update records
+ *
+ * @param array $data Data to update (column => value)
+ * @param array $where Where conditions (column => value)
+ * @param array|null $format Optional format for data
+ * @param array|null $where_format Optional format for where
+ * @return int|false Number of rows updated, false on failure
+ *
+ * @example
+ * $updated = $table->update(
+ * ['status' => 'read'],
+ * ['id' => 123, 'user_id' => 1]
+ * );
+ */
+ public function update(array $data, array $where, ?array $format = null, ?array $where_format = null): int|false
+ {
+ // Auto-update updated_at if column exists and not provided
+ if (!isset($data['updated_at']) && $this->hasColumn('updated_at')) {
+ $data['updated_at'] = current_time('mysql');
+ }
+
+ $result = $this->wpdb->update(
+ $this->fullTableName,
+ $data,
+ $where,
+ $format,
+ $where_format
+ );
+
+ if ($result === false) {
+ $this->logError('update', ['data' => $data, 'where' => $where]);
+ }
+
+ return $result;
+ }
+
+ // =========================================================================
+ // DELETE OPERATIONS
+ // =========================================================================
+
+ /**
+ * Delete records
+ *
+ * @param array $where Where conditions (column => value)
+ * @param array|null $where_format Optional format for where
+ * @return int|false Number of rows deleted, false on failure
+ *
+ * @example
+ * $deleted = $table->delete(['id' => 123]);
+ */
+ public function delete(array $where, ?array $where_format = null): int|false
+ {
+ $result = $this->wpdb->delete(
+ $this->fullTableName,
+ $where,
+ $where_format
+ );
+
+ if ($result === false) {
+ $this->logError('delete', ['where' => $where]);
+ }
+
+ return $result;
+ }
+
+ // =========================================================================
+ // RAW QUERY OPERATIONS
+ // =========================================================================
+
+ /**
+ * Execute a raw query with automatic table name substitution
+ *
+ * @param string $query SQL query (use {table} as placeholder)
+ * @param array $values Values for prepare()
+ * @return mixed Query result
+ *
+ * @example
+ * $results = $table->query(
+ * "SELECT * FROM {table} WHERE user_id = %d AND status IN (%s, %s)",
+ * [1, 'pending', 'active']
+ * );
+ */
+ public function query(string $query, array $values = []): mixed
+ {
+ $query = str_replace('{table}', $this->fullTableName, $query);
+
+ if (empty($values)) {
+ return $this->wpdb->query($query);
+ }
+
+ return $this->wpdb->query($this->wpdb->prepare($query, $values));
+ }
+
+ /**
+ * Get results from raw query
+ *
+ * @param string $query SQL query (use {table} as placeholder)
+ * @param array $values Values for prepare()
+ * @param string $output OBJECT, ARRAY_A, or ARRAY_N
+ * @return array
+ */
+ public function queryResults(string $query, array $values = [], string $output = OBJECT): array
+ {
+ $query = str_replace('{table}', $this->fullTableName, $query);
+
+ if (empty($values)) {
+ return $this->wpdb->get_results($query, $output);
+ }
+
+ return $this->wpdb->get_results($this->wpdb->prepare($query, $values), $output);
+ }
+
+ /**
+ * Get single value from query
+ *
+ * @param string $query SQL query (use {table} as placeholder)
+ * @param array $values Values for prepare()
+ * @return mixed
+ */
+ public function queryVar(string $query, array $values = []): mixed
+ {
+ $query = str_replace('{table}', $this->fullTableName, $query);
+
+ if (empty($values)) {
+ return $this->wpdb->get_var($query);
+ }
+
+ return $this->wpdb->get_var($this->wpdb->prepare($query, $values));
+ }
+
+ // =========================================================================
+ // TRANSACTION HELPERS
+ // =========================================================================
+
+ /**
+ * Start a transaction
+ */
+ public function startTransaction(): void
+ {
+ $this->wpdb->query('START TRANSACTION');
+ }
+
+ /**
+ * Commit a transaction
+ */
+ public function commit(): void
+ {
+ $this->wpdb->query('COMMIT');
+ }
+
+ /**
+ * Rollback a transaction
+ */
+ public function rollback(): void
+ {
+ $this->wpdb->query('ROLLBACK');
+ }
+
+ /**
+ * Execute callback within a transaction
+ *
+ * @param callable $callback Function to execute
+ * @return mixed Returns callback result
+ * @throws Exception Rolls back on exception
+ *
+ * @example
+ * $result = $table->transaction(function() use ($table) {
+ * $table->insert(['user_id' => 1, ...]);
+ * $table->update(['status' => 'active'], ['id' => 123]);
+ * return true;
+ * });
+ */
+ public function transaction(callable $callback): mixed
+ {
+ $this->startTransaction();
+
+ try {
+ $result = $callback($this);
+ $this->commit();
+ return $result;
+ } catch (Exception $e) {
+ $this->rollback();
+ $this->logError('transaction', ['error' => $e->getMessage()]);
+ throw $e;
+ }
+ }
+
+ // =========================================================================
+ // UTILITY METHODS
+ // =========================================================================
+
+ /**
+ * Get the full table name (with prefix)
+ */
+ public function getFullTableName(): string
+ {
+ return $this->fullTableName;
+ }
+
+ /**
+ * Get last insert ID
+ */
+ public function getInsertId(): int
+ {
+ return $this->wpdb->insert_id;
+ }
+
+ /**
+ * Get last error
+ */
+ public function getLastError(): string
+ {
+ return $this->wpdb->last_error;
+ }
+
+ /**
+ * Get number of affected rows from last query
+ */
+ public function getAffectedRows(): int
+ {
+ return $this->wpdb->rows_affected;
+ }
+
+ // =========================================================================
+ // PRIVATE HELPERS
+ // =========================================================================
+
+ /**
+ * Build WHERE clause from associative array
+ */
+ private function buildWhereClause(array $where): string
+ {
+ $conditions = [];
+ foreach ($where as $column => $value) {
+ $column_safe = esc_sql($column);
+ if ($value === null) {
+ $conditions[] = "`{$column_safe}` IS NULL";
+ } else {
+ $conditions[] = "`{$column_safe}` = " . $this->getPlaceholder($value);
+ }
+ }
+ return implode(' AND ', $conditions);
+ }
+
+ /**
+ * Get appropriate placeholder for value type
+ */
+ private function getPlaceholder(mixed $value): string
+ {
+ if (is_int($value)) {
+ return '%d';
+ } elseif (is_float($value)) {
+ return '%f';
+ } else {
+ return '%s';
+ }
+ }
+
+ /**
+ * Check if table has a specific column
+ */
+ private function hasColumn(string $column): bool
+ {
+ static $cache = [];
+
+ if (!isset($cache[$this->tableName])) {
+ $columns = $this->wpdb->get_col("DESCRIBE {$this->fullTableName}");
+ $cache[$this->tableName] = array_flip($columns);
+ }
+
+ return isset($cache[$this->tableName][$column]);
+ }
+
+ /**
+ * Log database errors
+ */
+ private function logError(string $operation, array $context = []): void
+ {
+ if (function_exists('JVB')) {
+ JVB()->error()->log(
+ $this->tableName,
+ "CustomTable {$operation} failed: " . $this->wpdb->last_error,
+ $context,
+ 'error'
+ );
+ } else {
+ error_log("[CustomTable:{$this->tableName}] {$operation} failed: " . $this->wpdb->last_error);
+ }
+ }
+}
diff --git a/inc/managers/DashboardManager.php b/inc/managers/DashboardManager.php
index 5a25ea0..c2867aa 100644
--- a/inc/managers/DashboardManager.php
+++ b/inc/managers/DashboardManager.php
@@ -1,8 +1,10 @@
<?php
namespace JVBase\managers;
-use JVBase\forms\TaxonomySelector;use JVBase\managers\CRUD;
-use JVBase\meta\MetaManager;
+use JVBase\forms\TaxonomySelector;
+use JVBase\managers\CRUD;
+use JVBase\meta\Form;
+use JVBase\meta\Meta;
use JVBase\utility\Features;
use JVBase\ui\Navigation;
use WP_User;
@@ -871,7 +873,7 @@
echo '<h2>What would you like to do today?</h2>';
- echo '<ul>';
+ echo '<ul class="dashboard">';
foreach ($pages as $slug => $page) {
if ($page === 'dash') {
continue;
@@ -1127,7 +1129,6 @@
$jvb_everything = array_merge(JVB_CONTENT, JVB_TAXONOMY);
foreach ($jvb_everything as $type => $settings) {
- $meta = new MetaManager(null, 'form');
$fields = jvbGetFields($type);
?>
<template class="<?= $type ?>Table">
@@ -1197,7 +1198,7 @@
<?php
$config['type'] = 'text';
$config['description'] = '';
- $meta->render('form', $n, $config);
+ Form::render($n, null, $config);
?>
</td>
<?php
@@ -1212,10 +1213,9 @@
echo jvbNewModal(
'edit-modal '.$type,
'Edit '.ucfirst($type),
- $meta->renderForm('admin', [], $fields)
+ jvbRenderForm('admin', $fields)
);
}
-
return ob_get_clean();
}
diff --git a/inc/managers/DirectoryManager.php b/inc/managers/DirectoryManager.php
index 541ca2c..dc591ef 100644
--- a/inc/managers/DirectoryManager.php
+++ b/inc/managers/DirectoryManager.php
@@ -38,7 +38,6 @@
}
add_action('init', [$this, 'registerDirectories']);
- jvb_register_do_once('directories_registered', [$this, 'activate']);
add_action('render_block', [$this, 'renderBlock'], 99999, 3);
}
@@ -66,7 +65,7 @@
'public' => true,
'menu_icon' => jvbCSSIcon('list-dashes'),
'publicly_queryable' => true,
- 'show_in_menu' => false,
+ 'show_in_menu' => true,
'show_in_admin_bar' => false,
'has_archive' => true,
'hierarchical' => true,
@@ -227,6 +226,33 @@
}
}
+ protected function buildDirectoryList():array
+ {
+ $saved = get_option(BASE.'directory_list', []);
+ if (empty($saved)) {
+ $all = new WP_Query([
+ 'post_type' => BASE.'directory',
+ 'post_status' => 'publish',
+ 'posts_per_page' => -1,
+ ]);
+ foreach($all->posts as $post) {
+ $saved[$post->post_name] = [
+ 'slug' => $post->post_name,
+ 'title' => $post->post_title,
+ 'ID' => $post->ID,
+ 'url' => get_the_permalink($post->ID),
+ 'page' => $post->post_title,
+ 'description' => $this->getConfigFromType($post->post_name)['description']??'',
+ 'type' => get_post_meta($post->ID, self::$type,true),
+ 'extra' => $this->getConfigFromType($post->post_name)['directory_extra']??[],
+ ];
+ }
+ update_option(BASE.'directory_list', $saved);
+ wp_reset_postdata();
+ }
+ return $saved;
+ }
+
public function getDirectoryPageIDs():array
{
if (empty($this->directoryPageIDs)) {
@@ -237,7 +263,7 @@
public function getDirectoryList():array
{
if (empty($this->directoryList)) {
- $this->directoryList = get_option(BASE.'directory_list', []);
+ $this->directoryList = $this->buildDirectoryList();
}
return $this->directoryList;
}
diff --git a/inc/managers/InvitationsManager.php b/inc/managers/InvitationsManager.php
new file mode 100644
index 0000000..ebf3c92
--- /dev/null
+++ b/inc/managers/InvitationsManager.php
@@ -0,0 +1,737 @@
+<?php
+namespace JVBase\managers;
+
+use Exception;
+use JVBase\managers\queue\executors\InvitationExecutor;
+use JVBase\managers\queue\TypeConfig;
+use JVBase\utility\Features;
+use WP_Error;
+
+if (!defined('ABSPATH')) {
+ exit;
+}
+
+class InvitationsManager
+{
+ protected array $inviteConfig;
+ protected CustomTable $table;
+ protected int $expiryDays = 14;
+ public function __construct()
+ {
+ $this->setInviteConfig();
+ $this->table = CustomTable::for('invitations');
+ add_action('init', [$this, 'registerInvitationExecutors'], 5);
+
+ add_action('user_register', [$this, 'checkInvitation']);
+ add_filter('jvbLoginLabels', [$this, 'modifyLoginLabels'], 10, 2);
+ add_action('jvb_daily_maintenance', [$this, 'cleanupExpiredInvitations']);
+ add_filter(BASE . 'handle_bulk_operation', [$this, 'processOperation'], 10, 3);
+ }
+
+ protected function setInviteConfig():void
+ {
+ $this->inviteConfig = get_option(BASE.'invitation_config', [
+ 'roles' => [],
+ 'terms' => []
+ ]);
+ }
+
+ public function invitableTerms():array
+ {
+ return $this->inviteConfig['terms'];
+ }
+
+ public function invitableRoles():array
+ {
+ return $this->inviteConfig['roles'];
+ }
+
+ public function getInviteConfig():array
+ {
+ return $this->inviteConfig;
+ }
+
+
+ /**
+ * Register invitation operation types with queue TypeRegistry
+ */
+ public function registerInvitationExecutors(): void
+ {
+ $registry = JVB()->queue()->registry();
+ $executor = new InvitationExecutor();
+
+ // Create invitations - chunked at 20
+ $registry->register('invitation_create', new TypeConfig(
+ executor: $executor,
+ chunkKey: 'invitations',
+ chunkSize: 20
+ ));
+
+ // Resend invitations - chunked at 10
+ $registry->register('invitation_resend', new TypeConfig(
+ executor: $executor,
+ chunkKey: 'invitations',
+ chunkSize: 10
+ ));
+
+ // Revoke invitations
+ $registry->register('invitation_revoke', new TypeConfig(
+ executor: $executor
+ ));
+ }
+
+ /**
+ * Build invite types from JVB constants
+ * Combines JVB_MEMBERSHIP['can_invite'] and invitable taxonomies
+ */
+ protected function buildInviteTypes(): array
+ {
+ $types = [];
+
+ // Global invitations from JVB_MEMBERSHIP
+ if (!empty(JVB_MEMBERSHIP['can_invite'])) {
+ foreach (JVB_MEMBERSHIP['can_invite'] as $role => $canInvite) {
+ $types[$role] = [
+ 'can_invite' => $canInvite,
+ 'to_terms' => []
+ ];
+ }
+ }
+
+ // Term invitations from invitable content taxonomies
+ foreach (JVB_TAXONOMY as $taxonomy => $config) {
+ if (Features::forTaxonomy($taxonomy)->has('invitable') &&
+ Features::forTaxonomy($taxonomy)->has('is_content') &&
+ Features::forTaxonomy($taxonomy)->has('is_ownable')) {
+
+ $forContent = $config['for_content'] ?? [];
+ foreach ($forContent as $content) {
+ // Find which user roles can create this content
+ foreach (JVB_USER as $role => $userConfig) {
+ $creatable = Features::forUser($role)->getCreatableContent();
+ if (in_array($content, $creatable)) {
+ if (!isset($types[$role])) {
+ $types[$role] = [
+ 'can_invite' => [],
+ 'to_terms' => []
+ ];
+ }
+ if (!in_array($taxonomy, $types[$role]['to_terms'])) {
+ $types[$role]['to_terms'][] = $taxonomy;
+ }
+ }
+ }
+ }
+ }
+ }
+
+ return $types;
+ }
+
+ /******************************************************************
+ * UTILITY
+ ******************************************************************/
+ public function canInviteToTerm(int $userID, string $taxonomy, int $termID):bool
+ {
+ $taxonomy = jvbNoBase($taxonomy);
+
+ // Check if taxonomy is invitable
+ if (!in_array($taxonomy, $this->invitableTerms())) {
+ return false;
+ }
+
+ // User must be owner or manager of the term
+ return JVB()->roles()->isManager($userID, $termID);
+ }
+
+ /**
+ * Check if user can send global invitations
+ */
+ public function canInviteGlobally(int $userID, string $targetRole): bool
+ {
+ $userRole = jvbUserRole($userID);
+ $allowedRoles = $this->inviteConfig['roles'][$userRole] ?? [];
+
+ return in_array($targetRole, $allowedRoles);
+ }
+
+ public function validateInvitations(int $userID, array $invites): array
+ {
+ $validated = [];
+
+ foreach ($invites as $invite) {
+ $sanitized = $this->sanitizeInvitation($invite);
+ if (!$sanitized) {
+ continue;
+ }
+
+ // Check permissions
+ if ($sanitized['to_term'] && $sanitized['taxonomy']) {
+ // Term invitation - check management capability
+ if (!$this->canInviteToTerm($userID, $sanitized['taxonomy'], $sanitized['to_term'])) {
+ continue;
+ }
+ } else {
+ // Global invitation - check if allowed to invite this role
+ if (!$this->canInviteGlobally($userID, $sanitized['invited_role'])) {
+ continue;
+ }
+ }
+
+ $validated[] = $sanitized;
+ }
+
+ return $validated;
+ }
+
+ /**
+ * Sanitize single invitation
+ */
+ protected function sanitizeInvitation(array $invite): ?array
+ {
+ $email = sanitize_email($invite['email'] ?? '');
+ $name = sanitize_text_field($invite['name'] ?? '');
+ $role = $invite['role'] ?? '';
+
+ if (!is_email($email) || empty($name) || empty($role)) {
+ return null;
+ }
+
+ // Check if user already exists
+ if (email_exists($email)) {
+ return null;
+ }
+
+ $sanitized = [
+ 'email' => $email,
+ 'name' => $name,
+ 'invited_role' => $role,
+ 'to_term' => isset($invite['to_term']) ? (int) $invite['to_term'] : null,
+ 'taxonomy' => isset($invite['taxonomy']) ? jvbNoBase($invite['taxonomy']) : null
+ ];
+
+ // Validate term if provided
+ if ($sanitized['to_term'] && $sanitized['taxonomy']) {
+ $term = get_term($sanitized['to_term'], BASE . $sanitized['taxonomy']);
+ if (!$term || is_wp_error($term)) {
+ $sanitized['to_term'] = null;
+ $sanitized['taxonomy'] = null;
+ }
+ }
+
+ return $sanitized;
+ }
+
+ /**************************************************************************
+ * QUEUE
+ **************************************************************************/
+ /**
+ * Process bulk invitations (called by queue)
+ */
+ public function processOperation(WP_Error|array $result, object $operation, array $data): array|WP_Error
+ {
+ if ($operation->type !== 'invitation_create') {
+ return $result;
+ }
+
+ return $this->processInvitations($data, $operation->user_id);
+ }
+
+ /**
+ * Process invitations with transaction support
+ */
+ protected function processInvitations(array $data, int $userID): array
+ {
+ $invitations = $data['invitations'] ?? [];
+
+ $results = [
+ 'success' => [],
+ 'failed' => []
+ ];
+
+ $this->table->startTransaction();
+
+ try {
+ foreach ($invitations as $invite) {
+ $result = $this->createInvitation(
+ $invite['name'],
+ $invite['email'],
+ $userID,
+ $invite['invited_role'],
+ $invite['to_term'],
+ $invite['taxonomy'],
+ false // Don't send email yet
+ );
+
+ if (is_wp_error($result)) {
+ $results['failed'][] = [
+ 'email' => $invite['email'],
+ 'name' => $invite['name'],
+ 'reason' => $result->get_error_message()
+ ];
+ } else {
+ $results['success'][] = array_merge($result, [
+ 'email' => $invite['email'],
+ 'name' => $invite['name']
+ ]);
+ }
+ }
+
+ if (!empty($results['success'])) {
+ $this->table->commit();
+
+ // Send emails
+ foreach ($results['success'] as $invitation) {
+ $terms = [];
+ if ($invitation['to_term'] && $invitation['taxonomy']) {
+ $terms[$invitation['taxonomy']] = $invitation['to_term'];
+ }
+
+ $this->sendInvitationEmail(
+ $invitation['name'],
+ $invitation['email'],
+ $invitation['token'],
+ $userID,
+ $terms,
+ $invitation['invited_role']
+ );
+ }
+ } else {
+ $this->table->rollback();
+ }
+
+ return [
+ 'success' => count($results['success']) > 0,
+ 'results' => $results
+ ];
+
+ } catch (Exception $e) {
+ $this->table->rollback();
+
+ JVB()->error()->log(
+ 'invitation_create',
+ 'Error processing invitations: ' . $e->getMessage(),
+ ['user_id' => $userID],
+ );
+
+ return [
+ 'success' => false,
+ 'result' => [
+ 'failed' => $invitations,
+ 'error' => $e->getMessage()
+ ]
+ ];
+ }
+ }
+
+ /**
+ * Create or update an invitation
+ */
+ public function createInvitation(
+ string $name,
+ string $email,
+ int $inviterID,
+ string $invitedRole,
+ ?int $termID = null,
+ ?string $taxonomy = null,
+ bool $sendEmail = true
+ ): WP_Error|array {
+
+ $email = sanitize_email($email);
+ if (!is_email($email)) {
+ return new WP_Error('invalid_email', 'Invalid email address');
+ }
+
+ if (email_exists($email)) {
+ return new WP_Error('user_exists', 'User already registered');
+ }
+
+ // Check for existing invitation
+ $existing = $this->table->get([
+ 'email' => $email,
+ 'invited_role' => $invitedRole
+ ]);
+
+ $token = wp_generate_password(32, false);
+ $expiresAt = date('Y-m-d H:i:s', strtotime("+{$this->expiryDays} days"));
+
+ if ($existing) {
+ // Update existing
+ $inviters = json_decode($existing->inviters, true) ?: [];
+
+ $inviterExists = false;
+ foreach ($inviters as &$inviter) {
+ if ($inviter['user_id'] == $inviterID) {
+ $inviterExists = true;
+ $inviter['invited_at'] = current_time('mysql');
+ break;
+ }
+ }
+
+ if (!$inviterExists) {
+ $inviters[] = [
+ 'user_id' => $inviterID,
+ 'invited_at' => current_time('mysql')
+ ];
+ }
+
+ $updateData = [
+ 'inviters' => json_encode($inviters),
+ 'status' => 'pending',
+ 'expires_at' => $expiresAt
+ ];
+
+ if ($termID && $taxonomy) {
+ $updateData['to_' . $taxonomy] = $termID;
+ }
+
+ if ($existing->status === 'expired') {
+ $updateData['invitation_token'] = $token;
+ } else {
+ $token = $existing->invitation_token;
+ }
+
+ $this->table->update($updateData, ['id' => $existing->id]);
+ $invitationID = $existing->id;
+
+ } else {
+ // Create new
+ $insertData = [
+ 'name' => sanitize_text_field($name),
+ 'email' => $email,
+ 'invitation_token' => $token,
+ 'invited_role' => $invitedRole,
+ 'status' => 'pending',
+ 'inviters' => json_encode([[
+ 'user_id' => $inviterID,
+ 'invited_at' => current_time('mysql')
+ ]]),
+ 'expires_at' => $expiresAt
+ ];
+
+ if ($termID && $taxonomy) {
+ $insertData['to_' . $taxonomy] = $termID;
+ }
+
+ $invitationID = $this->table->insert($insertData);
+ }
+
+ if ($sendEmail) {
+ $terms = [];
+ if ($termID && $taxonomy) {
+ $terms[$taxonomy] = $termID;
+ }
+ $this->sendInvitationEmail($name, $email, $token, $inviterID, $terms, $invitedRole);
+ }
+
+ return [
+ 'id' => $invitationID,
+ 'token' => $token,
+ 'expires_at' => $expiresAt,
+ 'to_term' => $termID,
+ 'taxonomy' => $taxonomy,
+ 'invited_role' => $invitedRole
+ ];
+ }
+
+ /**
+ * Send invitation email
+ */
+ public function sendInvitationEmail(
+ string $name,
+ string $email,
+ string $token,
+ int $inviterID,
+ array $terms,
+ string $role
+ ): void {
+ $inviterName = jvbGetUsername($inviterID);
+ $siteName = get_bloginfo('name');
+
+ $subject = apply_filters('jvbInvitationSubject',
+ sprintf("%s invited you to join %s!", $inviterName, $siteName),
+ $inviterName
+ );
+
+ $signupUrl = add_query_arg([
+ 'invite' => $token,
+ 'email' => urlencode($email),
+ 'name' => urlencode($name),
+ 'role' => $role
+ ], wp_registration_url());
+
+ // Build term-specific content
+ $termContent = [];
+ foreach ($terms as $taxonomy => $termID) {
+ if (!$termID) continue;
+
+ $term = get_term($termID, BASE . $taxonomy);
+ if ($term && !is_wp_error($term)) {
+ $config = JVB_TAXONOMY[$taxonomy] ?? [];
+ $singular = $config['singular'] ?? $taxonomy;
+
+ $termContent[] = sprintf(
+ "<p>%s has also invited you to join %s. You'll be automatically added to this %s when you register.</p>",
+ $inviterName,
+ html_entity_decode($term->name),
+ $singular
+ );
+ }
+ }
+ $termText = implode('', $termContent);
+
+ $button = JVB()->email()->button($signupUrl, 'Join the Scene!');
+ $link = JVB()->email()->link($signupUrl);
+ $signature = JVB()->email()->signature();
+
+ $message = sprintf(
+ '<p>Hi %s!</p>
+ <p>%s has invited you to join them on %s.</p>
+ %s
+ <h2>Interested?</h2>
+ <p>Join in by clicking the button below:</p>
+ %s
+ <p>Or by copying and pasting the link below into your browser:</p>
+ %s
+ <div class="divider"></div>
+ <p>This invitation expires in %d days.</p>
+ <p>Ink on,</p>
+ %s',
+ $name,
+ $inviterName,
+ $siteName,
+ $termText,
+ $button,
+ $link,
+ $this->expiryDays,
+ $signature
+ );
+
+ $message = apply_filters('jvbInvitationMessage',
+ $message,
+ $name,
+ $inviterName,
+ $role,
+ $terms,
+ $this->expiryDays,
+ $button,
+ $link,
+ $signature
+ );
+
+ JVB()->email()->sendEmail($email, $subject, $message);
+ }
+
+ /**
+ * Check invitation on user registration
+ * @throws Exception
+ */
+ public function checkInvitation(int $userID): void
+ {
+ $token = sanitize_text_field($_GET['invite'] ?? '');
+ $email = sanitize_email($_GET['email'] ?? '');
+
+ if (!$token || !$email) {
+ return;
+ }
+
+ $user = get_userdata($userID);
+ if (!$user || $user->user_email !== $email) {
+ return;
+ }
+
+ $this->acceptInvitation($token, $email, $userID);
+ }
+
+ /**
+ * Accept invitation and grant appropriate capabilities
+ * @throws Exception
+ */
+ public function acceptInvitation(string $token, string $email, int $userID): bool
+ {
+ // Verify invitation using fluent CustomTable
+ $invitation = $this->table
+ ->where([
+ 'invitation_token' => $token,
+ 'email' => $email,
+ 'status' => 'pending'
+ ])
+ ->first();
+
+ if (!$invitation) {
+ return false;
+ }
+
+ // Check expiry
+ if (strtotime($invitation->expires_at) < time()) {
+ return false;
+ }
+
+ // Update in transaction
+ $success = $this->table->transaction(function($table) use ($invitation, $userID) {
+ // Update invitation status
+ $table->update(
+ [
+ 'status' => 'accepted',
+ 'new_user_id' => $userID,
+ 'accepted_at' => current_time('mysql')
+ ],
+ ['id' => $invitation->id]
+ );
+
+ // Grant capabilities
+ $user = get_userdata($userID);
+ $user->add_cap('skip_moderation', true);
+
+ return true;
+ });
+
+ if (!$success) {
+ return false;
+ }
+
+ // Handle term membership
+ $this->processTermMembership($userID, $invitation);
+
+ // Notify inviters
+ $this->notifyInvitersOfAcceptance($invitation, $userID);
+
+ // Invalidate cache
+ Cache::for('invitations')->forget('user_' . $userID);
+
+ return true;
+ }
+
+ /**
+ * Process term membership for accepted invitation
+ */
+ protected function processTermMembership(int $userID, object $invitation): void
+ {
+ foreach (JVB()->roles()->getInvitableTaxonomies() as $taxonomy) {
+ $column = 'to_' . $taxonomy;
+ if (isset($invitation->$column) && $invitation->$column) {
+ $termID = (int) $invitation->$column;
+
+ do_action(
+ BASE . 'add_user_to_term',
+ $userID,
+ $termID,
+ $taxonomy,
+ 'member'
+ );
+ }
+ }
+ }
+
+ /**
+ * Notify inviters that invitation was accepted
+ */
+ protected function notifyInvitersOfAcceptance(object $invitation, int $userID): void
+ {
+ $inviters = json_decode($invitation->inviters, true) ?: [];
+ $userData = get_userdata($userID);
+
+ foreach ($inviters as $inviter) {
+ JVB()->notification()->addNotification(
+ $inviter['user_id'],
+ 'invitation_accepted',
+ [
+ 'invited_email' => $invitation->email,
+ 'user_id' => $userID,
+ 'display_name' => $userData->display_name
+ ]
+ );
+ }
+ }
+
+ /**
+ * Clean up expired invitations (daily cron)
+ */
+ public function cleanupExpiredInvitations(): void
+ {
+ // Use raw query for date comparison
+ $this->table->query(
+ "UPDATE {$this->table->getFullTableName()}
+ SET status = 'expired'
+ WHERE status = 'pending'
+ AND expires_at < NOW()"
+ );
+
+ // Clear cache after cleanup
+ Cache::for('invitations')->flush();
+ }
+
+ /*****************************************************************
+ * Emails
+ *****************************************************************/
+ /**
+ * Send revocation email
+ */
+ public function sendRevocationEmail(string $email, string $name): void
+ {
+ $siteName = get_bloginfo('name');
+
+ $subject = apply_filters('jvbInvitationRevokedSubject',
+ sprintf('[%s] Your invitation has been revoked', $siteName)
+ );
+
+ $content = apply_filters('jvbInvitationRevokedMessage',
+ sprintf(
+ '<p>Hey %s,</p>
+ <p>This is to let you know that your invitation to join %s has been revoked.</p>
+ <p>If you believe this was done in error, please contact the person who invited you or the site admin.</p>',
+ $name,
+ $siteName
+ ),
+ $name
+ );
+
+ JVB()->email()->sendEmail($email, $subject, $content, 'INVITATION REVOKED');
+ }
+
+ /**
+ * TODO: Check with LoginManager.php
+ * Modify login labels for invitation flow
+ */
+ public function modifyLoginLabels(array $labels, array $getParams): array
+ {
+ if (!isset($getParams['invite']) || !isset($getParams['email'])) {
+ return $labels;
+ }
+
+ $email = sanitize_email($getParams['email']);
+ $token = sanitize_text_field($getParams['invite']);
+ $role = $getParams['role'] ?? '';
+
+ if (empty($role)) {
+ return $labels;
+ }
+
+ // Use fluent interface
+ $invitation = $this->table
+ ->where([
+ 'invitation_token' => $token,
+ 'email' => $email,
+ 'invited_role' => $role,
+ 'status' => 'pending'
+ ])
+ ->first();
+
+ if (!$invitation) {
+ return $labels;
+ }
+
+ // Build inviter names
+ $inviters = json_decode($invitation->inviters, true) ?: [];
+ $names = array_map(fn($inviter) => jvbGetUsername($inviter['user_id']), $inviters);
+
+ $message = count($names) > 1
+ ? 'are already here, and have invited you to join in!'
+ : ' is already here, and invited you to join in!';
+
+ $labels['title'] = 'Join the Scene, ' . $invitation->name;
+ $labels['description'] = [jvbCommaList($names) . ' ' . $message];
+
+ return $labels;
+ }
+}
diff --git a/inc/managers/LoginManager.php b/inc/managers/LoginManager.php
index 5a25159..26700e2 100644
--- a/inc/managers/LoginManager.php
+++ b/inc/managers/LoginManager.php
@@ -1,11 +1,9 @@
<?php
namespace JVBase\managers;
-use JVBase\blocks\CustomBlocks;
use JVBase\forms\TaxonomySelector;
-use JVBase\meta\MetaManager;
-use JVBase\meta\MetaForm;
-use JVBase\managers\AjaxRateLimiter;
+use JVBase\meta\Form;
+
use JVBase\utility\Features;
use WP_Error;
use WP_User;
@@ -17,7 +15,7 @@
class LoginManager
{
protected Features $siteFeatures;
- protected ?MetaForm $metaForm = null;
+ protected ?Form $form = null;
protected Cache $cache;
@@ -533,7 +531,6 @@
protected function renderForms():void
{
- $this->metaForm = new MetaForm();
$form = $this->action.'form';
?>
<section class="login-box col btw">
@@ -552,7 +549,7 @@
do_action('jvb_add_token_inputs', $this->action);
foreach ($this->fields as $name => $config) {
- $this->metaForm->render($name, '', $config);
+ echo Form::render($name, '', $config);
}
$this->maybeTurnstile();
diff --git a/inc/managers/ReferralManager.php b/inc/managers/ReferralManager.php
index b3c1644..e6a4517 100644
--- a/inc/managers/ReferralManager.php
+++ b/inc/managers/ReferralManager.php
@@ -3,7 +3,7 @@
use JVBase\managers\MagicLinkManager;
use JVBase\integrations\Cloudflare;
-use JVBase\meta\MetaForm;
+use JVBase\meta\Form;
use JVBase\ui\CRUDSkeleton;
use JVBase\ui\Tabs;
use JVBase\utility\Features;
@@ -1051,7 +1051,6 @@
JVB()->connect('cloudflare')->renderTurnstile();
$turnstile = ob_get_clean();
- $meta = new MetaForm();
$reward_text = $this->getRewardText(true);
// Pre-fill code if from referral link
@@ -1070,21 +1069,21 @@
<form id="referral-code-form">
'.jvbFormStatus(). '
<input type="hidden" name="user_select" value="' . esc_attr(get_option(BASE.'referral_role','client')) . '">
- ' .$meta->return('referral_name', null, [
+ ' .Form::render('referral_name', null, [
'required' => true,
'type' => 'text',
'label' => 'Your Name',
'placeholder'=> 'Mister Meeseeks',
'autocomplete'=>'name'
]).
- $meta->return('referral_email', null, [
+ Form::render('referral_email', null, [
'required' => true,
'type' => 'email',
'label' => 'Your Email',
'placeholder'=> 'look@me.com',
'autocomplete'=> 'email'
]).
- $meta->return('referral_code', $prefill_code, [
+ Form::render('referral_code', null, $prefill_code, [
'required' => true,
'type' => 'text',
'label' => 'Referral Code',
@@ -1113,7 +1112,7 @@
</div>';
$loginForm = '<form id="login-form">
- '.jvbFormStatus().$meta->return('login_email', null, [
+ '.jvbFormStatus().Form::render('login_email', null, [
'required' => true,
'type' => 'email',
'label' => 'Your Email',
@@ -2491,7 +2490,6 @@
<p>Or, if you prefer, enter your friends name(s) and email(s), and we'll send off some emails.</p>
<p><small>(No data is stored. Your friends will get an email from our email.)</small></p>
<?php
- $meta = new MetaForm();
$invite = [
'type' => 'tag_list',
'label' => 'Invite Your Friends',
@@ -2528,14 +2526,14 @@
'hint' => 'We\'ll add your code and a link automatically.'
]
];
- $meta->render('invite', [], $invite);
+ echo Form::render('invite', null, $invite);
?>
<details>
<summary class="icon icon-caret-down">Customize Message</summary>
<?php
foreach ($fields as $fieldName => $field) {
$value = (array_key_exists('value', $field)) ? $field['value'] : [];
- $meta->render($fieldName, $value, $field);
+ echo Form::render($fieldName, $value, $field);
}
?>
</details>
diff --git a/inc/managers/RoleManager.php b/inc/managers/RoleManager.php
index 09029f7..d4a3988 100644
--- a/inc/managers/RoleManager.php
+++ b/inc/managers/RoleManager.php
@@ -438,4 +438,280 @@
$this->registerRole($slug, $config);
}
}
+
+ /******************************************************************
+ * OWNABLE and MANAGABLE terms (ie: tattoo shops)
+ ******************************************************************/
+ /**
+ * Grant ownership of a content taxonomy term
+ * Owners have full control over the term and its members
+ *
+ * @param int $userID User ID
+ * @param int $termID Term ID
+ * @param string $taxonomy Taxonomy slug (without BASE)
+ * @return bool Success
+ */
+ public function grantOwnership(int $userID, int $termID, string $taxonomy): bool
+ {
+ if (!get_userdata($userID) || !term_exists($termID)){
+ return false;
+ }
+ $taxonomy = jvbNoBase($taxonomy);
+
+ // Verify this is an ownable content taxonomy
+ if (!Features::forTaxonomy($taxonomy)->has('is_content') ||
+ !Features::forTaxonomy($taxonomy)->has('is_ownable')) {
+ return false;
+ }
+
+ $user = get_userdata($userID);
+ if (!$user) {
+ return false;
+ }
+
+ // Grant both ownership and management
+ $user->add_cap(BASE . 'can_own_' . $termID);
+ $user->add_cap(BASE . 'can_manage_' . $termID);
+
+ do_action(BASE . 'granted_ownership', $userID, $termID, $taxonomy);
+
+ return true;
+ }
+
+ /**
+ * Revoke ownership of a content taxonomy term
+ *
+ * @param int $userID User ID
+ * @param int $termID Term ID
+ * @param string $taxonomy Taxonomy slug (without BASE)
+ * @return bool Success
+ */
+ public function revokeOwnership(int $userID, int $termID, string $taxonomy): bool
+ {
+ if (!get_userdata($userID) || !term_exists($termID)){
+ return false;
+ }
+ $taxonomy = jvbNoBase($taxonomy);
+
+ $user = get_userdata($userID);
+ if (!$user) {
+ return false;
+ }
+
+ $user->remove_cap(BASE . 'can_own_' . $termID);
+
+ do_action(BASE . 'revoked_ownership', $userID, $termID, $taxonomy);
+
+ return true;
+ }
+
+ /**
+ * Grant management capabilities for a content taxonomy term
+ * Managers can approve members and edit content but don't own the term
+ *
+ * @param int $userID User ID
+ * @param int $termID Term ID
+ * @param string $taxonomy Taxonomy slug (without BASE)
+ * @return bool Success
+ */
+ public function grantManagement(int $userID, int $termID, string $taxonomy): bool
+ {
+ if (!get_userdata($userID) || !term_exists($termID)){
+ return false;
+ }
+ $taxonomy = jvbNoBase($taxonomy);
+
+ // Verify this is an ownable content taxonomy
+ if (!Features::forTaxonomy($taxonomy)->has('is_content') ||
+ !Features::forTaxonomy($taxonomy)->has('is_ownable')) {
+ return false;
+ }
+
+ $user = get_userdata($userID);
+ if (!$user) {
+ return false;
+ }
+
+ $user->add_cap(BASE . 'can_manage_' . $termID);
+
+ do_action(BASE . 'granted_management', $userID, $termID, $taxonomy);
+
+ return true;
+ }
+
+ /**
+ * Revoke management capabilities for a content taxonomy term
+ *
+ * @param int $userID User ID
+ * @param int $termID Term ID
+ * @param string $taxonomy Taxonomy slug (without BASE)
+ * @return bool Success
+ */
+ public function revokeManagement(int $userID, int $termID, string $taxonomy): bool
+ {
+ if (!get_userdata($userID) || !term_exists($termID)){
+ return false;
+ }
+ $taxonomy = jvbNoBase($taxonomy);
+
+ $user = get_userdata($userID);
+ if (!$user) {
+ return false;
+ }
+
+ $user->remove_cap(BASE . 'can_manage_' . $termID);
+
+ do_action(BASE . 'revoked_management', $userID, $termID, $taxonomy);
+
+ return true;
+ }
+
+ /**
+ * Check if user owns a term
+ *
+ * @param int $userID User ID
+ * @param int $termID Term ID
+ * @return bool
+ */
+ public function isOwner(int $userID, int $termID): bool
+ {
+ return user_can($userID, BASE . 'can_own_' . $termID);
+ }
+
+ /**
+ * Check if user can manage a term (owner or manager)
+ *
+ * @param int $userID User ID
+ * @param int $termID Term ID
+ * @return bool
+ */
+ public function isManager(int $userID, int $termID): bool
+ {
+ return user_can($userID, BASE . 'can_manage_' . $termID) ||
+ user_can($userID, BASE . 'can_own_' . $termID);
+ }
+
+ /**
+ * Get all terms a user owns
+ *
+ * @param int $userID User ID
+ * @param string|null $taxonomy Optional: filter by taxonomy
+ * @return array Array of term IDs
+ */
+ public function getOwnedTerms(int $userID, ?string $taxonomy = null): array
+ {
+ $user = get_userdata($userID);
+ if (!$user) {
+ return [];
+ }
+
+ $owned = [];
+ foreach ($user->allcaps as $cap => $value) {
+ if ($value && strpos($cap, BASE . 'can_own_') === 0) {
+ $termID = (int) str_replace(BASE . 'can_own_', '', $cap);
+ if ($termID) {
+ $owned[] = $termID;
+ }
+ }
+ }
+
+ // Filter by taxonomy if specified
+ if ($taxonomy && !empty($owned)) {
+ $taxonomy = jvbCheckBase($taxonomy);
+ $filtered = [];
+ foreach ($owned as $termID) {
+ $term = get_term($termID);
+ if ($term && !is_wp_error($term) && $term->taxonomy === $taxonomy) {
+ $filtered[] = $termID;
+ }
+ }
+ return $filtered;
+ }
+
+ return $owned;
+ }
+
+ /**
+ * Get all terms a user can manage (owns or manages)
+ *
+ * @param int $userID User ID
+ * @param string|null $taxonomy Optional: filter by taxonomy
+ * @return array Array of term IDs
+ */
+ public function getManagedTerms(int $userID, ?string $taxonomy = null): array
+ {
+ $user = get_userdata($userID);
+ if (!$user) {
+ return [];
+ }
+
+ $managed = [];
+ foreach ($user->allcaps as $cap => $value) {
+ if ($value && (strpos($cap, BASE . 'can_manage_') === 0 ||
+ strpos($cap, BASE . 'can_own_') === 0)) {
+ $termID = (int) str_replace([BASE . 'can_manage_', BASE . 'can_own_'], '', $cap);
+ if ($termID && !in_array($termID, $managed)) {
+ $managed[] = $termID;
+ }
+ }
+ }
+
+ // Filter by taxonomy if specified
+ if ($taxonomy && !empty($managed)) {
+ $taxonomy = jvbCheckBase($taxonomy);
+ $filtered = [];
+ foreach ($managed as $termID) {
+ $term = get_term($termID);
+ if ($term && !is_wp_error($term) && $term->taxonomy === $taxonomy) {
+ $filtered[] = $termID;
+ }
+ }
+ return $filtered;
+ }
+
+ return $managed;
+ }
+
+ /**
+ * Get all ownable taxonomies
+ *
+ * @return array Array of taxonomy slugs
+ */
+ public function getOwnableTaxonomies(): array
+ {
+ static $ownable = null;
+
+ if ($ownable === null) {
+ $ownable = [];
+ foreach (JVB_TAXONOMY as $taxonomy => $config) {
+ if (Features::forTaxonomy($taxonomy)->has('is_content') &&
+ Features::forTaxonomy($taxonomy)->has('is_ownable')) {
+ $ownable[] = $taxonomy;
+ }
+ }
+ }
+
+ return $ownable;
+ }
+
+ /**
+ * Get all invitable taxonomies
+ *
+ * @return array Array of taxonomy slugs
+ */
+ public function getInvitableTaxonomies(): array
+ {
+ static $invitable = null;
+
+ if ($invitable === null) {
+ $invitable = [];
+ foreach (JVB_TAXONOMY as $taxonomy => $config) {
+ if (Features::forTaxonomy($taxonomy)->has('invitable')) {
+ $invitable[] = $taxonomy;
+ }
+ }
+ }
+
+ return $invitable;
+ }
}
diff --git a/inc/managers/SEO/SEOAdminPage.php b/inc/managers/SEO/SEOAdminPage.php
index 93dea24..633fbaf 100644
--- a/inc/managers/SEO/SEOAdminPage.php
+++ b/inc/managers/SEO/SEOAdminPage.php
@@ -2,7 +2,7 @@
namespace JVBase\managers\SEO;
use JVBase\managers\AdminPages;
-use JVBase\meta\MetaForm;
+use JVBase\meta\Form;
use JVBase\ui\Tabs;
if (!defined('ABSPATH')) {
@@ -20,12 +20,10 @@
{
private ConfigManager $config;
private SchemaBuilder $registry;
- private MetaForm $form;
public function __construct()
{
$this->registry = SchemaBuilder::getInstance();
- $this->form = new MetaForm();
// Add to JVB dashboard
@@ -149,7 +147,7 @@
}
$fieldConfig = $this->registry->getFieldDefinition($fieldName);
- $this->form->render($fieldName, $config[$fieldName]??'', $fieldConfig);
+ echo Form::render($fieldName, $config[$fieldName]??'', $fieldConfig);
if ($index === 0 && $fieldName === 'type') {
echo '<div class="seo-'.$type.'">';
}
@@ -269,7 +267,7 @@
$fields = $this->registry->getFieldsForType($type);
foreach ($fields as $fieldName) {
$config = $this->registry->getFieldDefinition($fieldName);
- $this->form->render($fieldName, '', $config);
+ echo Form::render($fieldName, '', $config);
}
?>
</div>
diff --git a/inc/managers/SEO/SchemaBuilder.php b/inc/managers/SEO/SchemaBuilder.php
index 12bf00f..7f8785c 100644
--- a/inc/managers/SEO/SchemaBuilder.php
+++ b/inc/managers/SEO/SchemaBuilder.php
@@ -192,7 +192,7 @@
}
/**
- * Get MetaManager configuration for a schema type
+ * Get Meta configuration for a schema type
* This creates the form fields for the selected @type
*/
public function getMetaConfigForType(string $type): array
diff --git a/inc/managers/SEO/SchemaFieldHelpers.php b/inc/managers/SEO/SchemaFieldHelpers.php
index 6b9ba0d..a4bf981 100644
--- a/inc/managers/SEO/SchemaFieldHelpers.php
+++ b/inc/managers/SEO/SchemaFieldHelpers.php
@@ -1,7 +1,7 @@
<?php
namespace JVBase\managers\SEO;
-use JVBase\meta\MetaManager;
+use JVBase\meta\Meta;
if (!defined('ABSPATH')) {
exit;
@@ -21,10 +21,10 @@
*
* @param string $fieldName Field name
* @param mixed $value Raw value
- * @param MetaManager|null $meta Optional MetaManager for accessing related fields
+ * @param Meta|null $meta Optional Meta for accessing related fields
* @return mixed Enhanced value
*/
- public static function autoResolve(string $fieldName, mixed $value, ?MetaManager $meta = null): mixed
+ public static function autoResolve(string $fieldName, mixed $value, ?Meta $meta = null): mixed
{
// Skip empty values
if ($value === null || $value === '') {
@@ -72,7 +72,7 @@
// Rating -> AggregateRating (needs rating_count from meta)
'rating'
- => $meta ? self::buildAggregateRating($value, $meta->getValue('rating_count')) : $value,
+ => $meta ? self::buildAggregateRating($value, $meta->get('rating_count')) : $value,
// Geo coordinates
'geo'
@@ -197,7 +197,7 @@
*
* Returns array with 'address' and 'geo' keys
*
- * @param array $location Location data from MetaManager
+ * @param array $location Location data from Meta
* @return array Schema with address and geo fields
*/
public static function buildLocation(array $location): array
@@ -263,7 +263,7 @@
/**
* Build opening hours from repeater field
*
- * @param array $hours Hours data from MetaManager
+ * @param array $hours Hours data from Meta
* @return array Schema with openingHours field
*/
public static function buildOpeningHours(array $hours): array
@@ -293,7 +293,7 @@
/**
* Build sameAs array from links repeater
*
- * @param array $links Links data from MetaManager
+ * @param array $links Links data from Meta
* @return array Schema with sameAs field
*/
public static function buildSameAs(array $links): array
diff --git a/inc/managers/SEO/SchemaOutputManager.php b/inc/managers/SEO/SchemaOutputManager.php
index cd8d0e5..ff062f4 100644
--- a/inc/managers/SEO/SchemaOutputManager.php
+++ b/inc/managers/SEO/SchemaOutputManager.php
@@ -2,7 +2,7 @@
namespace JVBase\managers\SEO;
use JVBase\managers\Cache;
-use JVBase\meta\MetaManager;
+use JVBase\meta\Meta;
use WP_Term;
use WP_User;
@@ -520,7 +520,7 @@
}
/**
- * Enhanced buildSchemaFromConfig with MetaManager integration
+ * Enhanced buildSchemaFromConfig with Meta integration
*/
private function buildSchemaFromConfig(array $config, string $schemaType, ?string $id = null): ?array
{
@@ -531,11 +531,11 @@
$schema['@id'] = $id;
}
- // Get MetaManager if we have a context
+ // Get Meta if we have a context
$meta = null;
$context = $this->getCurrentContext();
if ($context) {
- $meta = new MetaManager($context['objectId'], $context['objectType']);
+ $meta = new Meta($context['objectId'], $context['objectType']);
}
// Process each field
diff --git a/inc/managers/SEO/SchemaReferenceBuilder.php b/inc/managers/SEO/SchemaReferenceBuilder.php
index 507adea..6627899 100644
--- a/inc/managers/SEO/SchemaReferenceBuilder.php
+++ b/inc/managers/SEO/SchemaReferenceBuilder.php
@@ -1,10 +1,7 @@
<?php
namespace JVBase\managers\SEO;
-use JVBase\meta\MetaManager;
-use WP_Term;
-use WP_User;
-use WP_Post;
+use JVBase\meta\Meta;
if (!defined('ABSPATH')) {
exit;
@@ -480,8 +477,8 @@
case 'TattooParlor':
case 'Organization':
// Add minimal location (just street address)
- $meta = new MetaManager($objectId, $objectType);
- $location = $meta->getValue('location');
+ $meta = new Meta($objectId, $objectType);
+ $location = $meta->get('location');
if ($location && isset($location['address'])) {
$reference['address'] = [
diff --git a/inc/managers/SEO/SchemaRegistry.php b/inc/managers/SEO/SchemaRegistry.php
index df1b6d2..175209f 100644
--- a/inc/managers/SEO/SchemaRegistry.php
+++ b/inc/managers/SEO/SchemaRegistry.php
@@ -8,7 +8,7 @@
/**
* Schema.org Registry - Centralized field and type definitions
*
- * Field definitions use MetaManager field types and include transformer hints.
+ * Field definitions use Meta.php field types and include transformer hints.
* Types reference field names and support inheritance via 'extends'.
*/
class SchemaRegistry
@@ -132,7 +132,7 @@
}
/**
- * Get MetaManager configuration for a schema type
+ * Get Meta configuration for a schema type
* This creates the form fields for the selected @type
*/
public function getMetaConfigForType(string $type): array
diff --git a/inc/managers/SEO/TemplateResolver.php b/inc/managers/SEO/TemplateResolver.php
index 8b5c57d..1d8534a 100644
--- a/inc/managers/SEO/TemplateResolver.php
+++ b/inc/managers/SEO/TemplateResolver.php
@@ -1,7 +1,7 @@
<?php
namespace JVBase\managers\SEO;
-use JVBase\meta\MetaManager;
+use JVBase\meta\Meta;
use WP_Post;
use WP_Term;
use WP_User;
@@ -25,7 +25,7 @@
private ?int $objectId = null;
private ?string $objectType = null;
private ?string $contentType = null;
- private ?MetaManager $meta = null;
+ private ?Meta $meta = null;
private array $context = [];
private array $fieldDefinitions = [];
@@ -39,7 +39,7 @@
$this->contentType = $contentType;
if ($objectId && $objectType) {
- $this->meta = new MetaManager($objectId, $objectType, $contentType);
+ $this->meta = new Meta($objectId, $objectType, $contentType);
$this->loadFieldDefinitions();
}
@@ -133,9 +133,9 @@
return $special;
}
- // Try to get from MetaManager
+ // Try to get from Meta.php
if ($this->meta) {
- $value = $this->meta->getValue($variable);
+ $value = $this->meta->get($variable);
// Auto-resolve complex field types via SchemaFieldHelpers
$value = $this->autoResolveField($variable, $value);
diff --git a/inc/managers/SEO/_edmonotonink.php b/inc/managers/SEO/_edmonotonink.php
index 14120eb..ceb97d9 100644
--- a/inc/managers/SEO/_edmonotonink.php
+++ b/inc/managers/SEO/_edmonotonink.php
@@ -4,14 +4,16 @@
* Edmonton.ink Configuration
*
* Add this to your edmonton.ink child theme/plugin
- * This replaces all the hardcoded logic in SchemaManager and SEOMetaManager
+ * This replaces all the hardcoded logic in SchemaManager and SEO`MetaManager`
*/
// ==================================================
// SITE-WIDE SCHEMA CONFIGURATION
// ==================================================
-add_filter('jvb_schema', function ($schema) {
+ use JVBase\meta\Meta;
+
+ add_filter('jvb_schema', function ($schema) {
return array_merge($schema, [
'site_type' => 'directory',
'organization' => [
@@ -290,7 +292,7 @@
'schema' => [
'custom_builder' => function ($post_id) {
- $meta = new \JVBase\meta\MetaManager($post_id, 'post');
+ $meta = Meta::forPost($post_id);
$schema = [
'@type' => 'Event',
@@ -298,13 +300,13 @@
'url' => get_permalink($post_id),
];
- $date = $meta->getValue('event_date');
+ $date = $meta->get('event_date');
if ($date) {
$schema['startDate'] = date('c', strtotime($date));
}
- $venue = $meta->getValue('venue');
- $venue_address = $meta->getValue('venue_address');
+ $venue = $meta->get('venue');
+ $venue_address = $meta->get('venue_address');
if ($venue) {
$schema['location'] = [
'@type' => 'Place',
@@ -342,8 +344,8 @@
'variables' => [
'name' => 'term_name',
'city' => ['callback' => function ($term_id, $context) {
- $meta = new \JVBase\meta\MetaManager($term_id, 'term');
- $city_id = $meta->getValue('city');
+ $meta = Meta::forTerm($term_id);
+ $city_id = $meta->get('city');
if ($city_id && term_exists((int)$city_id, BASE . 'city')) {
$city_term = get_term($city_id, BASE . 'city');
if ($city_term && !is_wp_error($city_term)) {
@@ -353,13 +355,13 @@
return 'Edmonton';
}],
'tagline_text' => ['callback' => function ($term_id) {
- $meta = new \JVBase\meta\MetaManager($term_id, 'term');
- $tagline = $meta->getValue('tagline');
+ $meta = Meta::forTerm($term_id);
+ $tagline = $meta->get('tagline');
return $tagline ? " - {$tagline}" : '';
}],
'established_text' => ['callback' => function ($term_id) {
- $meta = new \JVBase\meta\MetaManager($term_id, 'term');
- $established = $meta->getValue('established');
+ $meta = Meta::forTerm($term_id);
+ $established = $meta->get('established');
return $established ? " Established in {$established}" : '';
}],
'artist_count' => ['callback' => function ($term_id) {
@@ -388,15 +390,15 @@
'priceRange' => 'price_range',
'image' => 'logo',
'url' => ['callback' => function ($term_id) {
- $meta = new \JVBase\meta\MetaManager($term_id, 'term');
- $website = $meta->getValue('website');
+ $meta = Meta::forTerm($term_id);
+ $website = $meta->get('website');
return $website ?: get_term_link($term_id);
}],
'sameAs' => ['callback' => function ($term_id) {
- $meta = new \JVBase\meta\MetaManager($term_id, 'term');
+ $meta = Meta::forTerm($term_id);
$links = [];
- if ($ig = $meta->getValue('instagram')) $links[] = $ig;
- if ($fb = $meta->getValue('facebook')) $links[] = $fb;
+ if ($ig = $meta->get('instagram')) $links[] = $ig;
+ if ($fb = $meta->get('facebook')) $links[] = $fb;
return !empty($links) ? $links : null;
}],
'memberOf' => [
@@ -414,8 +416,8 @@
'variables' => [
'name' => 'term_name',
'alt_names' => ['callback' => function ($term_id) {
- $meta = new \JVBase\meta\MetaManager($term_id, 'term');
- $alts = $meta->getValue('alternate_name');
+ $meta = Meta::forTerm($term_id);
+ $alts = $meta->get('alternate_name');
if (!empty($alts) && is_array($alts)) {
$names = array_filter(array_column($alts, 'name'));
if (!empty($names)) {
@@ -437,8 +439,8 @@
'description' => 'characteristics',
'about' => ['meta' => 'description'],
'alternateName' => ['callback' => function ($term_id) {
- $meta = new \JVBase\meta\MetaManager($term_id, 'term');
- $alts = $meta->getValue('alternate_name');
+ $meta = Meta::forTerm($term_id);
+ $alts = $meta->get('alternate_name');
if (!empty($alts) && is_array($alts)) {
return array_filter(array_column($alts, 'name'));
}
@@ -456,8 +458,8 @@
'variables' => [
'name' => 'term_name',
'similar' => ['callback' => function ($term_id) {
- $meta = new \JVBase\meta\MetaManager($term_id, 'term');
- $similar = $meta->getValue('similar');
+ $meta = Meta::forTerm($term_id);
+ $similar = $meta->get('similar');
if (!empty($similar)) {
$similar_names = [];
foreach ((array)$similar as $similar_id) {
diff --git a/inc/managers/SEOMetaManager.php b/inc/managers/SEOMetaManager.php
index 5ccbb0e..dc9c2de 100644
--- a/inc/managers/SEOMetaManager.php
+++ b/inc/managers/SEOMetaManager.php
@@ -1,7 +1,7 @@
<?php
namespace JVBase\managers;
-use JVBase\meta\MetaManager;
+use JVBase\meta\Meta;
use WP_Term;
use DateTime;
use DateMalformedStringException;
@@ -10,6 +10,7 @@
exit; // Exit if accessed directly
}
/**
+ * @deprecated use JVBase\managers\seo\SEO.php
* SEO Meta Manager for edmonton.ink
*
* Integrates with The SEO Framework to generate optimized titles and meta descriptions
@@ -113,7 +114,7 @@
protected function getPostTitle(int $post_id):string
{
$title = get_the_title($post_id);
- $meta = new MetaManager($post_id, 'post');
+ $meta = Meta::forPost($post_id);
$post_type = get_post_type($post_id);
return match ($post_type) {
@@ -132,7 +133,7 @@
*/
protected function getPostDescription(int $post_id):string
{
- $meta = new MetaManager($post_id, 'post');
+ $meta = Meta::forPost($post_id);
$post_type = get_post_type($post_id);
switch ($post_type) {
@@ -158,7 +159,7 @@
*/
protected function getTermTitle(WP_Term $term):string
{
- $meta = new MetaManager($term->term_id, 'term');
+ $meta = Meta::forTerm($term->term_id);
switch ($term->taxonomy) {
case BASE . 'shop':
@@ -186,7 +187,7 @@
*/
protected function getTermDescription(WP_Term $term):string
{
- $meta = new MetaManager($term->term_id, 'term');
+ $meta = Meta::forTerm($term->term_id);
switch ($term->taxonomy) {
case BASE . 'shop':
@@ -275,10 +276,9 @@
* Get artist title
*
* @param string $title The original title
- * @param MetaManager $meta The meta manager
* @return string The optimized title
*/
- protected function getArtistTitle(string $title, MetaManager $meta):string
+ protected function getArtistTitle(string $title):string
{
$city_terms = get_the_terms(get_the_ID(), BASE . 'city');
$city = ($city_terms && !is_wp_error($city_terms)) ? $city_terms[0]->name : 'Edmonton';
@@ -294,17 +294,17 @@
* Get artist description
*
* @param int $post_id The post ID
- * @param MetaManager $meta The meta manager
+ * @param Meta $meta The meta manager
* @return string The optimized description
*/
- protected function getArtistDescription(int $post_id, MetaManager $meta):string
+ protected function getArtistDescription(int $post_id, Meta $meta):string
{
- $bio = $meta->getValue('short_bio');
+ $bio = $meta->get('short_bio');
if ($bio !== '') {
return $bio;
}
- $first_name = $meta->getValue('first_name');
+ $first_name = $meta->get('first_name');
$city_terms = get_the_terms($post_id, BASE . 'city');
$city = ($city_terms && !is_wp_error($city_terms)) ? $city_terms[0]->name : 'Edmonton';
@@ -313,7 +313,7 @@
// Get top styles if available
$styles = [];
- $top_styles = $meta->getValue('top_style');
+ $top_styles = $meta->get('top_style');
if (!empty($top_styles)) {
foreach ((array)$top_styles as $style_id) {
$style = get_term($style_id, BASE . 'style');
@@ -325,7 +325,7 @@
// Get top themes if available
$themes = [];
- $top_themes = $meta->getValue('top_theme');
+ $top_themes = $meta->get('top_theme');
if (!empty($top_themes)) {
foreach ((array)$top_themes as $theme_id) {
$theme = get_term($theme_id, BASE . 'theme');
@@ -393,16 +393,16 @@
* Get partner description
*
* @param int $post_id The post ID
- * @param MetaManager $meta The meta manager
+ * @param Meta $meta The meta manager
* @return string The optimized description
*/
- protected function getPartnerDescription(int $post_id, MetaManager$meta):string
+ protected function getPartnerDescription(int $post_id, Meta$meta):string
{
- $short_bio = $meta->getValue('short_bio');
+ $short_bio = $meta->get('short_bio');
if ($short_bio !== '') {
return $short_bio;
}
- $established = $meta->getValue('established');
+ $established = $meta->get('established');
$description = get_the_title($post_id);
@@ -426,16 +426,16 @@
*/
protected function getEventTitle(int $post_id, string $title):string
{
- $meta = new MetaManager($post_id, 'post');
+ $meta = Meta::forPost($post_id);
// Get event type if available
- $event_type = $meta->getValue('event_type') ?: '';
+ $event_type = $meta->get('event_type') ?: '';
if ($event_type && term_exists((int)$event_type, BASE . 'type')) {
$event_type = get_term($event_type, BASE . 'type')->name;
}
// Get date information
- $date_start = $meta->getValue('date_start');
+ $date_start = $meta->get('date_start');
$month = '';
if ($date_start) {
$date = new DateTime($date_start);
@@ -473,17 +473,17 @@
*/
protected function getEventDescription(int $post_id):string
{
- $meta = new MetaManager($post_id, 'post');
+ $meta = Meta::forPost($post_id);
$title = get_the_title($post_id);
// Get event type if available
- $event_type = $meta->getValue('event_type') ?: '';
+ $event_type = $meta->get('event_type') ?: '';
if ($event_type && term_exists((int)$event_type, BASE . 'type')) {
$event_type = get_term($event_type, BASE . 'type')->name;
}
// Get date information
- $date_start = $meta->getValue('date_start');
+ $date_start = $meta->get('date_start');
$date_format = '';
if ($date_start) {
$date = new DateTime($date_start);
@@ -491,7 +491,7 @@
}
// Get location information
- $location = $meta->getValue('location');
+ $location = $meta->get('location');
$location_name = '';
if (!empty($location['shop'])) {
$shop_term = get_term($location['shop'], BASE . 'shop');
@@ -522,11 +522,11 @@
}
// Add event details if available
- $is_free = $meta->getValue('is_free');
+ $is_free = $meta->get('is_free');
if ($is_free) {
$description .= ". Free admission";
} else {
- $cost = $meta->getValue('cost');
+ $cost = $meta->get('cost');
if ($cost) {
$description .= ". Admission: {$cost}";
}
@@ -541,12 +541,12 @@
* Get shop title
*
* @param WP_Term $term The term object
- * @param MetaManager $meta The meta manager
+ * @param Meta $meta The meta manager
* @return string The optimized title
*/
- protected function getShopTitle(WP_Term $term, MetaManager $meta):string
+ protected function getShopTitle(WP_Term $term, Meta $meta):string
{
- $city_id = $meta->getValue('city');
+ $city_id = $meta->get('city');
$city = 'Edmonton';
if ($city_id && term_exists((int)$city_id, BASE . 'city')) {
@@ -573,19 +573,19 @@
* Get shop description
*
* @param WP_Term $term The term object
- * @param MetaManager $meta The meta manager
+ * @param Meta $meta The meta manager
* @return string The optimized description
*/
- protected function getShopDescription(WP_Term $term, MetaManager $meta):string
+ protected function getShopDescription(WP_Term $term, Meta $meta):string
{
- $short_bio = $meta->getValue('short_bio');
+ $short_bio = $meta->get('short_bio');
if ($short_bio !== '') {
return $short_bio;
}
- $established = $meta->getValue('established');
+ $established = $meta->get('established');
// Get city
- $city_id = $meta->getValue('city');
+ $city_id = $meta->get('city');
$city = 'Edmonton';
if ($city_id && term_exists((int)$city_id, BASE . 'city')) {
@@ -647,18 +647,18 @@
* Get style description
*
* @param WP_Term $term The term object
- * @param MetaManager $meta The meta manager
+ * @param Meta $meta The meta manager
* @return string The optimized description
*/
- protected function getStyleDescription(WP_Term $term, MetaManager $meta):string
+ protected function getStyleDescription(WP_Term $term, Meta $meta):string
{
- $tagline = $meta->getValue('tagline');
+ $tagline = $meta->get('tagline');
if (!$tagline !== '') {
return $tagline;
}
- $characteristics = $meta->getValue('characteristics');
- $alternate_names = $meta->getValue('alternate_name');
+ $characteristics = $meta->get('characteristics');
+ $alternate_names = $meta->get('alternate_name');
// Get alt names if available
$alt_name_text = '';
@@ -710,18 +710,18 @@
* Get theme description
*
* @param WP_Term $term The term object
- * @param MetaManager $meta The meta manager
+ * @param Meta $meta The meta manager
* @return string The optimized description
*/
- protected function getThemeDescription(WP_Term $term, MetaManager $meta):string
+ protected function getThemeDescription(WP_Term $term, Meta $meta):string
{
- $description_meta = $meta->getValue('description');
+ $description_meta = $meta->get('description');
if ($description_meta !== '') {
return $description_meta;
}
// Get similar themes if available
- $similar = $meta->getValue('similar');
+ $similar = $meta->get('similar');
$similar_text = '';
if (!empty($similar)) {
@@ -829,4 +829,4 @@
}
}
-new SEOMetaManager();
+//new SEOMetaManager();
diff --git a/inc/managers/SchemaManager.php b/inc/managers/SchemaManager.php
index 5242d30..d88016d 100644
--- a/inc/managers/SchemaManager.php
+++ b/inc/managers/SchemaManager.php
@@ -1,13 +1,14 @@
<?php
namespace JVBase\managers;
-use JVBase\meta\MetaManager;
+use JVBase\meta\Meta;
use WP_Query;
if (!defined('ABSPATH')) {
exit; // Exit if accessed directly
}
/**
+ * @deprecated use JVBase\managers\seo\SEO.php
* Schema.org Generator for edmonton.ink
*
* This class generates structured schema.org data for better SEO
@@ -240,16 +241,16 @@
*/
private function getArtistSchema(int $post_id):array
{
- $meta = new MetaManager($post_id, 'post');
+ $meta = Meta::forPost($post_id);
$metaValues = $meta->getAll();
$permalink = get_permalink($post_id);
// Get artist data
$name = get_the_title($post_id);
- $first_name = $meta->getValue('first_name');
- $bio = $meta->getValue('bio');
- $short_bio = $meta->getValue('short_bio');
+ $first_name = $meta->get('first_name');
+ $bio = $meta->get('bio');
+ $short_bio = $meta->get('short_bio');
$description = $short_bio ?: wp_strip_all_tags($bio) ?: get_the_excerpt($post_id);
// Build person schema
@@ -355,7 +356,7 @@
*/
private function getShopSchema(int $term_id): array
{
- $meta = new MetaManager($term_id, 'term');
+ $meta = Meta::forTerm($term_id);
$metaValues = $meta->getAll();
$term = get_term($term_id, BASE.'shop');
$permalink = get_term_link($term_id, BASE.'shop');
@@ -366,7 +367,7 @@
'@type' => 'LocalBusiness',
'@id' => $permalink . '#organization',
'name' => html_entity_decode($term->name),
- 'description' => $meta->getValue('short_bio') ?: $term->description,
+ 'description' => $meta->get('short_bio') ?: $term->description,
'url' => $permalink,
'priceRange' => '$$', // Default price range
'additionalType' => 'https://schema.org/TattooParlor', // Custom business type
@@ -968,7 +969,7 @@
*/
private function getStyleSchema(int $term_id):array
{
- $meta = new MetaManager($term_id, 'term');
+ $meta = Meta::forTerm($term_id);
$term = get_term($term_id, BASE.'style');
$permalink = get_term_link($term_id, BASE.'style');
@@ -977,7 +978,7 @@
'@type' => 'CreativeWork',
'@id' => $permalink . '#style',
'name' => html_entity_decode($term->name),
- 'description' => $meta->getValue('characteristics') ?: $term->description,
+ 'description' => $meta->get('characteristics') ?: $term->description,
'url' => $permalink,
'mainEntityOfPage' => [
'@type' => 'WebPage',
@@ -1002,7 +1003,7 @@
*/
private function getThemeSchema(int $term_id):array
{
- $meta = new MetaManager($term_id, 'term');
+ $meta = Meta::forTerm($term_id);
$term = get_term($term_id, BASE.'theme');
$permalink = get_term_link($term_id, BASE.'theme');
@@ -1010,7 +1011,7 @@
'@type' => 'CreativeWork',
'@id' => $permalink . '#theme',
'name' => html_entity_decode($term->name),
- 'description' => $meta->getValue('description') ?: $term->description,
+ 'description' => $meta->get('description') ?: $term->description,
'url' => $permalink,
'mainEntityOfPage' => [
'@type' => 'WebPage',
@@ -1034,7 +1035,7 @@
*/
private function getPartnerSchema(int $post_id):array
{
- $meta = new MetaManager($post_id, 'post');
+ $meta = Meta::forPost($post_id);
$metaValues = $meta->getAll();
$permalink = get_permalink($post_id);
diff --git a/inc/managers/ScriptLoader.php b/inc/managers/ScriptLoader.php
index 2645212..32874f8 100644
--- a/inc/managers/ScriptLoader.php
+++ b/inc/managers/ScriptLoader.php
@@ -2,7 +2,7 @@
add_action('init', 'jvbRegisterScripts', 5);
function jvbRegisterScripts() {
- $version = '1.1.29';
+ $version = '1.1.3';
$strategy = [
'strategy' => 'defer',
'in_footer' => true
diff --git a/inc/managers/_setup.php b/inc/managers/_setup.php
index 27b90b7..880d9b2 100644
--- a/inc/managers/_setup.php
+++ b/inc/managers/_setup.php
@@ -5,6 +5,7 @@
use JVBase\utility\Features;
require(JVB_DIR . '/inc/managers/ScriptLoader.php');
+require(JVB_DIR . '/inc/managers/CustomTable.php');
//require(JVB_DIR . '/inc/managers/CacheManager.php');
require(JVB_DIR . '/inc/managers/Cache.php');
class_alias('JVBase\managers\Cache', 'JVBase\managers\CacheManager');
@@ -67,6 +68,10 @@
require(JVB_DIR . '/inc/managers/NewsRelationships.php');
}
+if (Features::forMembership()->has('invitable')) {
+ require(JVB_DIR . '/inc/managers/Invitations.php');
+}
+
//
//require(JVB_DIR . '/inc/managers/SchemaManager.php');
//require(JVB_DIR . '/inc/managers/SEOMetaManager.php');
diff --git a/inc/managers/queue/Processor.php b/inc/managers/queue/Processor.php
index d743f77..a3eac9d 100644
--- a/inc/managers/queue/Processor.php
+++ b/inc/managers/queue/Processor.php
@@ -21,12 +21,13 @@
$ops = $this->storage->fetchRunnable(3);
- $lastOpId = null;
foreach ($ops as $op) {
+ if (!$this->dependenciesSatisfied($op)) {
+ continue;
+ }
if (!$this->storage->markProcessing($op->id)) {
continue;
}
- $lastOpId = $op->id;
$this->processOne($op);
usleep(10000);
}
@@ -329,4 +330,31 @@
return true;
}
+
+ private function dependenciesSatisfied(Operation $op): bool
+ {
+ if (empty($op->dependencies)) {
+ return true;
+ }
+
+ foreach ($op->dependencies as $depId) {
+ $dep = $this->storage->find($depId);
+
+ // Missing dependency = block (or decide to ignore; your call)
+ if (!$dep) {
+ return false;
+ }
+
+ if ($dep->state !== 'completed') {
+ return false;
+ }
+
+ if (!in_array($dep->outcome, ['success', 'partial'], true)) {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
}
diff --git a/inc/managers/queue/Queue.php b/inc/managers/queue/Queue.php
index 54509fa..31c3823 100644
--- a/inc/managers/queue/Queue.php
+++ b/inc/managers/queue/Queue.php
@@ -100,16 +100,11 @@
public function maintenance(): void
{
$this->locker->withLock(function () {
- $this->cleanupStuck();
+ $this->storage->resetStuckOperations(30);
});
}
- private function cleanupStuck(): void
- {
- $this->storage->resetStuckOperations(30);
- }
-
// === Public Getters ===
public function get(string $id): ?Operation
diff --git a/inc/managers/queue/Result.php b/inc/managers/queue/Result.php
index 8a78ab1..b07d7f5 100644
--- a/inc/managers/queue/Result.php
+++ b/inc/managers/queue/Result.php
@@ -10,4 +10,14 @@
public string $outcome, // success | partial | failed
public ?array $result = null
) {}
+
+ public static function fail(string $message): Result
+ {
+ return new self('failed', ['message' => $message]);
+ }
+
+ public static function success(array $data): Result
+ {
+ return new self('success', $data);
+ }
}
diff --git a/inc/managers/queue/Storage.php b/inc/managers/queue/Storage.php
index e565146..99cd8c3 100644
--- a/inc/managers/queue/Storage.php
+++ b/inc/managers/queue/Storage.php
@@ -34,24 +34,19 @@
{
$now = current_time('mysql');
- $rows = $this->wpdb->get_results($this->wpdb->prepare("
- SELECT oq.* FROM {$this->table} oq
- WHERE oq.state IN ('pending', 'scheduled')
- AND oq.scheduled_at <= %s
- AND NOT EXISTS (
- SELECT 1
- FROM JSON_TABLE(
- COALESCE(NULLIF(oq.dependencies, 'null'), '[]'),
- '\$[*]' COLUMNS (dep_id VARCHAR(64) PATH '\$')
- ) AS deps
- JOIN {$this->table} dep ON dep.id = deps.dep_id
- WHERE dep.state != 'completed'
- OR dep.outcome NOT IN ('success', 'partial')
- )
- ORDER BY FIELD(oq.priority, 'high', 'normal', 'low'), oq.scheduled_at
- LIMIT %d
- FOR UPDATE SKIP LOCKED
- ", $now, $limit));
+ $rows = $this->wpdb->get_results(
+ $this->wpdb->prepare("
+ SELECT *
+ FROM {$this->table}
+ WHERE state IN ('pending', 'scheduled')
+ AND scheduled_at <= %s
+ ORDER BY
+ FIELD(priority, 'high', 'normal', 'low'),
+ scheduled_at
+ LIMIT %d
+ FOR UPDATE SKIP LOCKED
+ ", $now, $limit)
+ );
return array_map([$this, 'rowToOperation'], $rows ?: []);
}
diff --git a/inc/managers/queue/_setup.php b/inc/managers/queue/_setup.php
index d85219f..e98194e 100644
--- a/inc/managers/queue/_setup.php
+++ b/inc/managers/queue/_setup.php
@@ -28,6 +28,7 @@
require_once JVB_DIR . '/inc/managers/queue/Processor.php';
require_once JVB_DIR . '/inc/managers/queue/executors/UploadExecutor.php';
require_once JVB_DIR . '/inc/managers/queue/executors/ContentExecutor.php';
+require_once JVB_DIR . '/inc/managers/queue/executors/InvitationExecutor.php';
// Facade
require_once JVB_DIR . '/inc/managers/queue/Queue.php';
diff --git a/inc/managers/queue/executors/ContentExecutor.php b/inc/managers/queue/executors/ContentExecutor.php
index 610037f..7d4db57 100644
--- a/inc/managers/queue/executors/ContentExecutor.php
+++ b/inc/managers/queue/executors/ContentExecutor.php
@@ -2,7 +2,7 @@
namespace JVBase\managers\queue\executors;
use JVBase\managers\queue\{Executor, Operation, Progress, Result, Storage};
-use JVBase\meta\MetaManager;
+use JVBase\meta\Meta;
use JVBase\utility\Features;
use Exception;
@@ -215,8 +215,9 @@
return true;
}
- $meta = new MetaManager($postId, 'post');
- return $meta->setAll($allowedFields);
+ $meta = Meta::forPost($postId);
+ $meta->setAll($allowedFields);
+ return true;
}
// ─────────────────────────────────────────────────────────────
@@ -315,9 +316,9 @@
$lastKey = array_key_last($posts);
foreach ($posts as $index => $post) {
- $meta = new MetaManager($post->ID, 'post');
+ $meta = Meta::forPost($post->ID);
if ($index === 0) {
- $meta->updateValue('timeline', '', false);
+ $meta->set('timeline', '', false);
$previousPost = $post;
continue; // Parent has no timeline
}
@@ -328,7 +329,7 @@
if ($timeline) {
$termId = $this->getOrCreateTerm($timeline, 'timeline');
if ($termId) {
- $success = $meta->updateValue('timeline', $termId, false);
+ $success = $meta->set('timeline', $termId, false);
}
}
}
@@ -443,7 +444,7 @@
protected function checkSharedFields(array $fields): void
{
foreach ($fields as $parentID => $shared) {
- $meta = new MetaManager($parentID, 'post');
+ $meta = Meta::forPost($parentID);
$values = $meta->getAll($shared);
$children = get_children([
@@ -457,7 +458,7 @@
}
foreach ($children as $child) {
- $childMeta = new MetaManager($child, 'post');
+ $childMeta = Meta::forPost($child);
$result = $childMeta->setAll($values, false);
}
}
diff --git a/inc/managers/queue/executors/InvitationExecutor.php b/inc/managers/queue/executors/InvitationExecutor.php
new file mode 100644
index 0000000..db23a21
--- /dev/null
+++ b/inc/managers/queue/executors/InvitationExecutor.php
@@ -0,0 +1,353 @@
+<?php
+namespace JVBase\managers\queue\executors;
+
+use JVBase\managers\CustomTable;
+use JVBase\managers\queue\Executor;
+use JVBase\managers\queue\Operation;
+use JVBase\managers\queue\Progress;
+use JVBase\managers\queue\Result;
+use JVBase\managers\RoleManager;
+use WP_Error;
+
+if (!defined('ABSPATH')) {
+ exit;
+}
+
+class InvitationExecutor implements Executor
+{
+ protected CustomTable $table;
+ protected RoleManager $roleManager;
+ protected int $expiryDays = 14;
+
+ public function __construct()
+ {
+ $this->table = CustomTable::for('invitations');
+ $this->roleManager = new RoleManager();
+ }
+
+ public function execute(Operation $operation, Progress $progress): Result
+ {
+ return match($operation->type) {
+ 'invitation_create' => $this->processCreation($operation, $progress),
+ 'invitation_resend' => $this->processResend($operation, $progress),
+ 'invitation_revoke' => $this->processRevoke($operation, $progress),
+ default => Result::fail("Unknown operation type: {$operation->type}")
+ };
+ }
+
+ protected function processCreation(Operation $operation, Progress $progress): Result
+ {
+ $invitations = $operation->requestData['invitations'] ?? [];
+ $userID = $operation->userId;
+
+ $results = [
+ 'success' => [],
+ 'failed' => []
+ ];
+
+ $this->table->startTransaction();
+
+ try {
+ foreach ($invitations as $index => $invite) {
+ $progress->track($index);
+
+ $result = $this->createSingleInvitation(
+ $invite['name'],
+ $invite['email'],
+ $userID,
+ $invite['invited_role'],
+ $invite['to_term'] ?? null,
+ $invite['taxonomy'] ?? null
+ );
+
+ if (is_wp_error($result)) {
+ $results['failed'][] = [
+ 'email' => $invite['email'],
+ 'name' => $invite['name'],
+ 'reason' => $result->get_error_message()
+ ];
+ } else {
+ $results['success'][] = $result;
+ }
+ }
+
+ if (!empty($results['success'])) {
+ $this->table->commit();
+
+ // Send emails after successful commit
+ foreach ($results['success'] as $invitation) {
+ $this->sendInvitationEmail($invitation, $userID);
+ }
+ } else {
+ $this->table->rollback();
+ }
+
+ return Result::success($results, [
+ 'sent' => count($results['success']),
+ 'failed' => count($results['failed'])
+ ]);
+
+ } catch (\Exception $e) {
+ $this->table->rollback();
+ return Result::fail($e->getMessage());
+ }
+ }
+
+ protected function createSingleInvitation(
+ string $name,
+ string $email,
+ int $inviterID,
+ string $invitedRole,
+ ?int $termID = null,
+ ?string $taxonomy = null
+ ): WP_Error|array {
+
+ // Check for existing invitation
+ $existing = $this->table->get([
+ 'email' => $email,
+ 'invited_role' => $invitedRole
+ ]);
+
+ $token = wp_generate_password(32, false);
+ $expiresAt = date('Y-m-d H:i:s', strtotime("+{$this->expiryDays} days"));
+
+ if ($existing) {
+ // Update existing
+ $inviters = json_decode($existing->inviters, true) ?: [];
+
+ $inviterExists = false;
+ foreach ($inviters as &$inviter) {
+ if ($inviter['user_id'] == $inviterID) {
+ $inviterExists = true;
+ $inviter['invited_at'] = current_time('mysql');
+ break;
+ }
+ }
+
+ if (!$inviterExists) {
+ $inviters[] = [
+ 'user_id' => $inviterID,
+ 'invited_at' => current_time('mysql')
+ ];
+ }
+
+ $updateData = [
+ 'inviters' => json_encode($inviters),
+ 'status' => 'pending',
+ 'expires_at' => $expiresAt
+ ];
+
+ if ($termID && $taxonomy) {
+ $updateData['to_' . $taxonomy] = $termID;
+ }
+
+ if ($existing->status === 'expired') {
+ $updateData['invitation_token'] = $token;
+ } else {
+ $token = $existing->invitation_token;
+ }
+
+ $this->table->update($updateData, ['id' => $existing->id]);
+ $invitationID = $existing->id;
+
+ } else {
+ // Create new
+ $insertData = [
+ 'name' => sanitize_text_field($name),
+ 'email' => $email,
+ 'invitation_token' => $token,
+ 'invited_role' => $invitedRole,
+ 'status' => 'pending',
+ 'inviters' => json_encode([[
+ 'user_id' => $inviterID,
+ 'invited_at' => current_time('mysql')
+ ]]),
+ 'expires_at' => $expiresAt
+ ];
+
+ if ($termID && $taxonomy) {
+ $insertData['to_' . $taxonomy] = $termID;
+ }
+
+ $invitationID = $this->table->insert($insertData);
+ }
+
+ return [
+ 'id' => $invitationID,
+ 'token' => $token,
+ 'expires_at' => $expiresAt,
+ 'to_term' => $termID,
+ 'taxonomy' => $taxonomy,
+ 'invited_role' => $invitedRole,
+ 'email' => $email,
+ 'name' => $name
+ ];
+ }
+
+ protected function sendInvitationEmail(array $invitation, int $inviterID): void
+ {
+ $terms = [];
+ if ($invitation['to_term'] && $invitation['taxonomy']) {
+ $terms[$invitation['taxonomy']] = $invitation['to_term'];
+ }
+
+ // This would call your email service
+ do_action(
+ BASE . 'send_invitation_email',
+ $invitation['name'],
+ $invitation['email'],
+ $invitation['token'],
+ $inviterID,
+ $terms,
+ $invitation['invited_role']
+ );
+ }
+
+ protected function processResend(Operation $operation, Progress $progress): Result
+ {
+ $invitationID = $operation->requestData['invitation_id'] ?? 0;
+ $userID = $operation->userId;
+
+ if (!$invitationID) {
+ return Result::fail('Invitation ID required');
+ }
+
+ // Get invitation
+ $invitation = $this->table->get(['id' => $invitationID]);
+
+ if (!$invitation) {
+ return Result::fail('Invitation not found');
+ }
+
+ // Verify status
+ if (!in_array($invitation->status, ['pending', 'expired'])) {
+ return Result::fail('Only pending or expired invitations can be resent');
+ }
+
+ // Check if user is an inviter
+ $inviters = json_decode($invitation->inviters, true) ?: [];
+ $isInviter = false;
+
+ foreach ($inviters as &$inviter) {
+ if ($inviter['user_id'] == $userID) {
+ $isInviter = true;
+ $inviter['invited_at'] = current_time('mysql');
+ break;
+ }
+ }
+
+ if (!$isInviter) {
+ return Result::fail('You are not authorized to resend this invitation');
+ }
+
+ // Generate new token and expiry
+ $token = wp_generate_password(32, false);
+ $expiresAt = date('Y-m-d H:i:s', strtotime("+{$this->expiryDays} days"));
+
+ // Update invitation
+ $this->table->update(
+ [
+ 'invitation_token' => $token,
+ 'status' => 'pending',
+ 'expires_at' => $expiresAt,
+ 'inviters' => json_encode($inviters)
+ ],
+ ['id' => $invitation->id]
+ );
+
+ // Build term data for email
+ $terms = [];
+ foreach ($this->roleManager->getInvitableTaxonomies() as $taxonomy) {
+ $column = 'to_' . $taxonomy;
+ if (isset($invitation->$column) && $invitation->$column) {
+ $terms[$taxonomy] = (int) $invitation->$column;
+ }
+ }
+
+ // Send email
+ do_action(
+ BASE . 'send_invitation_email',
+ $invitation->name,
+ $invitation->email,
+ $token,
+ $userID,
+ $terms,
+ $invitation->invited_role
+ );
+
+ return Result::success([
+ 'message' => 'Invitation resent successfully',
+ 'expires_at' => $expiresAt
+ ]);
+ }
+
+ protected function processRevoke(Operation $operation, Progress $progress): Result
+ {
+ $invitationID = $operation->requestData['invitation_id'] ?? 0;
+ $userID = $operation->userId;
+
+ if (!$invitationID) {
+ return Result::fail('Invitation ID required');
+ }
+
+ // Get invitation
+ $invitation = $this->table->get(['id' => $invitationID]);
+
+ if (!$invitation) {
+ return Result::fail('Invitation not found');
+ }
+
+ // Can only revoke pending/expired
+ if (!in_array($invitation->status, ['pending', 'expired'])) {
+ return Result::fail('Only pending or expired invitations can be revoked');
+ }
+
+ // Check if user is an inviter
+ $inviters = json_decode($invitation->inviters, true) ?: [];
+ $isInviter = false;
+ $updatedInviters = [];
+
+ foreach ($inviters as $inviter) {
+ if ($inviter['user_id'] == $userID) {
+ $isInviter = true;
+ } else {
+ $updatedInviters[] = $inviter;
+ }
+ }
+
+ if (!$isInviter) {
+ return Result::fail('You are not authorized to revoke this invitation');
+ }
+
+ // If other inviters remain, just update list
+ if (!empty($updatedInviters)) {
+ $this->table->update(
+ ['inviters' => json_encode($updatedInviters)],
+ ['id' => $invitation->id]
+ );
+
+ return Result::success([
+ 'message' => 'You have been removed from the inviters list',
+ 'fully_revoked' => false
+ ]);
+ }
+
+ // No inviters left, revoke completely
+ $this->table->update(
+ ['status' => 'revoked'],
+ ['id' => $invitation->id]
+ );
+
+ // Send revocation email
+ do_action(
+ BASE . 'send_revocation_email',
+ $invitation->email,
+ $invitation->name
+ );
+
+ return Result::success([
+ 'message' => 'Invitation revoked successfully',
+ 'fully_revoked' => true
+ ]);
+ }
+}
diff --git a/inc/managers/queue/executors/UploadExecutor.php b/inc/managers/queue/executors/UploadExecutor.php
index ab2f992..c92fee3 100644
--- a/inc/managers/queue/executors/UploadExecutor.php
+++ b/inc/managers/queue/executors/UploadExecutor.php
@@ -3,7 +3,7 @@
use JVBase\managers\queue\{Executor, Operation, Progress, Result};
use JVBase\managers\UploadManager;
-use JVBase\meta\MetaManager;
+use JVBase\meta\Meta;
use Exception;
use JVBase\utility\Features;
@@ -366,11 +366,11 @@
}
if (!empty($gallery_attachment_ids)) {
- $meta = new MetaManager($newPostID, 'post');
+ $meta = Meta::forPost($newPostID);
$fields = jvbGetFields($content, 'post');
foreach($fields as $name => $config) {
if ($config['type'] === 'gallery') {
- $meta->updateValue($name, implode(',', $gallery_attachment_ids));
+ $meta->set($name, implode(',', $gallery_attachment_ids));
break;
}
}
@@ -542,11 +542,11 @@
return;
}
- $existing = $meta->getValue($data['field_name']);
+ $existing = $meta->get($data['field_name']);
$existingIds = !empty($existing) ? explode(',', $existing) : [];
$allIds = array_unique(array_merge($existingIds, $attachmentIds));
- $meta->updateValue($data['field_name'], implode(',', $allIds));
+ $meta->set($data['field_name'], implode(',', $allIds));
}
private function updateFieldValue(array $data, array $results): void
@@ -561,25 +561,25 @@
return;
}
- $existing = $meta->getValue($data['field_name']);
+ $existing = $meta->get($data['field_name']);
$existingIds = !empty($existing) ? explode(',', $existing) : [];
$allIds = array_unique(array_merge($existingIds, $attachmentIds));
- $meta->updateValue($data['field_name'], implode(',', $allIds));
+ $meta->set($data['field_name'], implode(',', $allIds));
}
- private function getMetaManager(array $data): ?MetaManager
+ private function getMetaManager(array $data): ?Meta
{
if (!empty($data['post_id'])) {
- return new MetaManager($data['post_id'], 'post');
+ return Meta::forPost($data['post_id']);
}
if (!empty($data['term_id'])) {
- return new MetaManager($data['term_id'], 'term');
+ return Meta::forTerm($data['term_id']);
}
if (!empty($data['user'])) {
$link = (int)get_user_meta($data['user'], BASE . 'link', true);
if ($link) {
- return new MetaManager($link, 'post');
+ return Meta::forPost($link);
}
}
return null;
@@ -626,14 +626,14 @@
if (str_starts_with($mimeType, 'image/')) {
set_post_thumbnail($postId, $attachmentId);
} elseif (str_starts_with($mimeType, 'video/')) {
- $meta = new MetaManager($postId, 'post');
- $meta->updateValue('video', $attachmentId);
+ $meta = Meta::forPost($postId);
+ $meta->set('video', $attachmentId);
} else {
- $meta = new MetaManager($postId, 'post');
- $existing = $meta->getValue('documents');
+ $meta = Meta::forPost($postId);
+ $existing = $meta->get('documents');
$existingIds = !empty($existing) ? explode(',', $existing) : [];
$existingIds[] = $attachmentId;
- $meta->updateValue('documents', implode(',', $existingIds));
+ $meta->set('documents', implode(',', $existingIds));
}
}
diff --git a/inc/meta/Field.php b/inc/meta/Field.php
index 0efb8e3..c6cf372 100644
--- a/inc/meta/Field.php
+++ b/inc/meta/Field.php
@@ -27,6 +27,9 @@
$this->config = $config;
}
+ /**
+ * Set field value and track dirty state
+ */
public function set(mixed $value): self
{
$this->value = $value;
@@ -34,11 +37,17 @@
return $this;
}
+ /**
+ * Get current value
+ */
public function get(): mixed
{
return $this->value;
}
+ /**
+ * Mark field as clean (after save)
+ */
public function markClean(): self
{
$this->originalValue = $this->value;
@@ -46,6 +55,9 @@
return $this;
}
+ /**
+ * Reset to original value
+ */
public function reset(): self
{
$this->value = $this->originalValue;
@@ -53,6 +65,9 @@
return $this;
}
+ /**
+ * Add validation error
+ */
public function addError(string $message): self
{
$this->errors[] = $message;
@@ -60,6 +75,9 @@
return $this;
}
+ /**
+ * Clear all errors
+ */
public function clearErrors(): self
{
$this->errors = [];
@@ -67,18 +85,51 @@
return $this;
}
+ /**
+ * Get field type from config
+ */
public function type(): string
{
return $this->config['type'] ?? 'text';
}
+ /**
+ * Check if this is a WordPress default field
+ */
public function isWpDefault(): bool
{
return $this->config['_wp_default'] ?? false;
}
+ /**
+ * Check if this is a taxonomy relationship field (not taxonomy_type)
+ */
public function isTaxonomy(): bool
{
return $this->type() === 'taxonomy' && !isset($this->config['taxonomy_type']);
}
+
+ /**
+ * Check if field is required
+ */
+ public function isRequired(): bool
+ {
+ return !empty($this->config['required']);
+ }
+
+ /**
+ * Get field label
+ */
+ public function label(): string
+ {
+ return $this->config['label'] ?? $this->name;
+ }
+
+ /**
+ * Get field description
+ */
+ public function description(): string
+ {
+ return $this->config['description'] ?? '';
+ }
}
diff --git a/inc/meta/Form.php b/inc/meta/Form.php
new file mode 100644
index 0000000..048f62b
--- /dev/null
+++ b/inc/meta/Form.php
@@ -0,0 +1,1733 @@
+<?php
+namespace JVBase\meta;
+
+use DateTime;
+
+if (!defined('ABSPATH')) {
+ exit;
+}
+
+/**
+ * Static utility for rendering form fields
+ *
+ * Usage:
+ * echo Form::render('price', 150, ['type' => 'number', 'label' => 'Price']);
+ * echo Form::render('email', '', ['type' => 'email', 'required' => true]);
+ */
+class Form
+{
+ /**
+ * Render a form field based on type
+ */
+ public static function render(string $name, mixed $value, array $config = []): string
+ {
+ if ($value === null) {
+ $value = '';
+ }
+ if (!empty($config['hidden'])) {
+ return '';
+ }
+
+ $type = $config['type'] ?? 'text';
+ $method = 'render' . str_replace('_', '', ucwords($type, '_'));
+
+ $output = method_exists(static::class, $method)
+ ? static::$method($name, $value, $config)
+ : static::renderText($name, $value, $config);
+
+ return apply_filters('jvbRenderFormMeta', $output, $name, $config, $value, null);
+ }
+
+ /**
+ * Render with Meta instance (convenience method)
+ */
+ public static function renderFrom(Meta $meta, string $name): string
+ {
+ $value = $meta->get($name);
+ $config = $meta->config($name) ?? ['type' => 'text'];
+
+ return static::render($name, $value, $config);
+ }
+
+ /**
+ * Render complete form from Meta instance
+ */
+ public static function renderFormFrom(Meta $meta, string $endpoint, array $options = []): string
+ {
+ $id = $options['form-id'] ?? $endpoint;
+ $classes = isset($options['classes']) ? ' class="' . implode(' ', $options['classes']) . '"' : '';
+
+ $output = '<form id="' . esc_attr($endpoint) . '"' . $classes . ' data-save="' . esc_attr($endpoint) . '" data-form-id="' . esc_attr($id) . '">';
+
+ if (!empty($options['heading'])) {
+ $output .= '<h2>' . esc_html($options['heading']) . '</h2>';
+ }
+
+ if (!empty($options['description'])) {
+ $descriptions = is_array($options['description']) ? $options['description'] : [$options['description']];
+ foreach ($descriptions as $d) {
+ $output .= '<p>' . esc_html($d) . '</p>';
+ }
+ }
+
+ foreach ($meta->configs() as $name => $config) {
+ $output .= static::render($name, $meta->get($name), $config);
+ }
+
+ if (!empty($options['submit'])) {
+ $output .= '<button type="submit">' . jvbIcon('floppy-disk') . 'Save</button>';
+ }
+
+ $output .= '</form>';
+
+ return $output;
+ }
+
+ // ─────────────────────────────────────────────────────────────
+ // Helper Methods
+ // ─────────────────────────────────────────────────────────────
+
+ protected static function fieldWrap(string $name, string $content, array $config): string
+ {
+ $classes = static::buildClasses($config);
+ $datasets = static::buildDatasets($config);
+
+ if (!array_key_exists('type', $config)) {
+ error_log('Config without type: '.print_r($config, true));
+ }
+ $output = sprintf(
+ '<div class="%s" data-field="%s" data-field-type="%s"%s>',
+ $classes,
+ $name,
+ $config['type'],
+ $datasets
+ );
+
+ $output .= static::buildLabel($name, $config);
+ if (!array_key_exists('skipInput', $config)) {
+ $output .= static::buildInput($content);
+ }
+
+ $output .= static::buildHint($config);
+ $output .= static::buildDescription($name, $config);
+
+ $output .= '</div>';
+
+ return $output;
+ }
+ protected static function buildClasses(array $config): string
+ {
+ $classes = ['field field-' . ($config['type'] ?? 'text')];
+ if (!empty($config['required'])) {
+ $classes[] = 'required';
+ }
+ if (!empty($config['class'])) {
+ if (!is_array($config['class'])) {
+ $config['class'] = [$config['class']];
+ }
+ $classes = array_merge($classes, $config['class']);
+ }
+
+ return trim(implode(' ',$classes));
+ }
+
+ protected static function buildDatasets(array $config): string
+ {
+ $datasets = static::handleCustomDatasets($config);
+ $datasets .= static::handleValidationLogic($config);
+ $datasets .= static::handleConditionalLogic($config);
+ return $datasets;
+ }
+ protected static function handleCustomDatasets($config):string
+ {
+ if (array_key_exists('data', $config) && !empty($config['data'])) {
+ $datasets = array_map(function ($key) use ($config) {
+ $name = str_replace('_', '-', sanitize_title($key));
+ return ' data-'.$name.'="'.$config['data'][$key].'"';
+ }, $config['data']);
+
+ return implode($datasets);
+ }
+ return '';
+ }
+ protected static function handleValidationLogic($config):string
+ {
+ $datasets = '';
+ $dataAttrs = ['pattern', 'validate', 'min', 'max', 'minlength', 'maxlength', 'validation_message'];
+ $attrs = [];
+ foreach ($dataAttrs as $attr) {
+ if (array_key_exists($attr, $config) && !empty($config[$attr])) {
+ $attrs[$attr] = $config[$attr];
+ }
+ }
+ foreach($attrs as $attr => $value) {
+ $datasets .= sprintf(' data-%s="%s"', $attr, esc_attr($value));
+ }
+ return $datasets;
+ }
+ protected static function handleConditionalLogic($config):string
+ {
+ if (empty($config['condition'])) {
+ return '';
+ }
+
+ return sprintf(
+ 'data-depends-on="%s" data-depends-value="%s" data-depends-operator="%s"',
+ esc_attr($config['condition']['field']),
+ esc_attr($config['condition']['value']),
+ esc_attr($config['condition']['operator'] ?? '==')
+ );
+ }
+
+ protected static function buildLabel(string $name, array $config):string
+ {
+ if (!empty($config['label'])) {
+ return sprintf(
+ '<label for="%s">%s%s</label>',
+ esc_attr($name),
+ esc_html($config['label']),
+ !empty($config['required']) ? '<span class="required" aria-label="required">*</span>' : ''
+ );
+ }
+ return '';
+ }
+
+ protected static function buildInput(string $content):string
+ {
+ return sprintf(
+ '<div class="field-input-wrapper">
+ %s
+ <span class="validation-icon success" hidden aria-hidden="true">%s</span>
+ <span class="validation-icon error" hidden aria-hidden="true">%s</span>
+ </div><span class="validation-message" hidden role="alert"></span>',
+ $content,
+ jvbIcon('check-circle'),
+ jvbIcon('x-circle')
+ );
+ }
+
+ protected static function buildHint(array $config):string
+ {
+ if (!empty($config['hint'])) {
+ return sprintf(
+ '<span class="hint">%s</span>',
+ esc_html($config['hint'])
+ );
+ }
+ return '';
+ }
+
+ protected static function buildDescription(string $name, array $config):string
+ {
+ if (!empty($config['description'])) {
+ return sprintf(
+ '<p class="description" id="%s">%s</p>',
+ esc_attr($name),
+ esc_html($config['description'])
+ );
+ }
+ return '';
+ }
+
+ protected static function inputAttrs(string $name, array $config): string
+ {
+ $attrs = [
+ 'id' => $name,
+ 'name' => $name,
+ ];
+
+ if (!empty($config['placeholder'])) {
+ $attrs['placeholder'] = $config['placeholder'];
+ }
+ if (!empty($config['autocomplete'])) {
+ $attrs['autocomplete'] = $config['autocomplete'];
+ }
+ if (isset($config['min'])) {
+ $attrs['min'] = $config['min'];
+ }
+ if (isset($config['max'])) {
+ $attrs['max'] = $config['max'];
+ }
+ if (isset($config['step'])) {
+ $attrs['step'] = $config['step'];
+ }
+
+
+ $html = '';
+ //Add the attributes that stand on their own
+ $standalones = ['required', 'disabled', 'readonly','multiple'];
+ foreach($standalones as $s) {
+ if (array_key_exists($s, $config) && $config[$s] === true) {
+ $html .= ' '.$s;
+ }
+ }
+
+ foreach ($attrs as $key => $val) {
+
+ $html .= sprintf(' %s="%s"', $key, esc_attr($val));
+ }
+
+ return $html;
+ }
+
+ // ─────────────────────────────────────────────────────────────
+ // Type Renderers
+ // ─────────────────────────────────────────────────────────────
+
+ protected static function renderText(string $name, mixed $value, array $config): string
+ {
+ $value = ($value === '') ? ' value="'.esc_attr($value).'"' : '';
+ $input = sprintf(
+ '<input type="%s"%s%s />',
+ $config['subtype']??'text',
+ $value,
+ static::inputAttrs($name, $config)
+ );
+
+ return static::fieldWrap($name, $input, $config);
+ }
+
+ protected static function renderTextarea(string $name, mixed $value, array $config): string
+ {
+ $rows = $config['rows'] ?? 5;
+ $quill = (array_key_exists('quill', $config) && $config['quill'] === true) ? ' data-editor="true"' : '';
+
+ if ($quill !== '') {
+ $allowImages = array_key_exists('allowImage', $config);
+ $quill .= ($allowImages) ? ' data-allowimage="true"' : ' data-allowimage="false"';
+ }
+
+ $textarea = sprintf(
+ '<textarea rows="%d"%s%s>%s</textarea>',
+ $rows,
+ static::inputAttrs($name, $config),
+ $quill,
+ esc_textarea($value)
+ );
+
+ return static::fieldWrap($name, $textarea, $config);
+ }
+
+ protected static function renderNumber(string $name, mixed $value, array $config): string
+ {
+ $attrs = static::inputAttrs($name, $config);
+
+ $value = ($value === '') ? ' value="'.esc_attr($value).'"' : '';
+ $input = sprintf(
+ '<input type="number"%s%s />',
+ $value,
+ $attrs
+ );
+
+ return static::fieldWrap($name, $input, $config);
+ }
+
+ protected static function renderQuantity(string $name, mixed $value, array $config): string
+ {
+ if (!array_key_exists('class', $config)) {
+ $config['class']=[];
+ }
+ $config['class'][] ='quantity-input';
+
+ $attrs = static::inputAttrs($name, $config);
+
+ $value = ($value === '') ? ' value="'.esc_attr($value).'"' : '';
+ $input = sprintf(
+ '<div class="quantity">
+ <button type="button" class="decrease" title="%s" aria-label="Decrease %s">%s</button>
+ <input type="number"%s%s />
+ <button type="button" class="increase" title="%s" aria-label="Increase %s">%s</button>
+ </div>',
+ array_key_exists('remove', $config) ? $config['remove'] : 'Decrease amount',
+ array_key_exists('label', $config) ? $config['label'] : 'Amount',
+ jvbIcon('minus-square'),
+ $value,
+ $attrs,
+ array_key_exists('add', $config) ? $config['add'] : 'Increase amount',
+ array_key_exists('label', $config) ? $config['label'] : 'Amount',
+ jvbIcon('plus-square'),
+ );
+
+ return static::fieldWrap($name, $input, $config);
+ }
+
+ protected static function renderEmail(string $name, mixed $value, array $config): string
+ {
+ $config['validate'] = 'email';
+ $value = ($value === '') ? ' value="'.esc_attr($value).'"' : '';
+ $input = sprintf(
+ '<input type="email"%s%s />',
+ $value,
+ static::inputAttrs($name, $config)
+ );
+
+ return static::fieldWrap($name, $input, $config);
+ }
+
+ protected static function renderUrl(string $name, mixed $value, array $config): string
+ {
+ $config['validate'] = 'url';
+ $value = ($value === '') ? ' value="'.esc_attr($value).'"' : '';
+ $input = sprintf(
+ '<input type="url"%s%s />',
+ $value,
+ static::inputAttrs($name, $config)
+ );
+
+ return static::fieldWrap($name, $input, $config);
+ }
+
+ protected static function renderTel(string $name, mixed $value, array $config): string
+ {
+ $config['validate'] = 'phone';
+ $value = ($value === '') ? ' value="'.esc_attr($value).'"' : '';
+ $input = sprintf(
+ '<input type="tel"%s%s />',
+ $value,
+ static::inputAttrs($name, $config)
+ );
+ return static::fieldWrap($name, $input, $config);
+ }
+
+ protected static function renderDate(string $name, mixed $value, array $config): string
+ {
+ $format = !empty($config['format']) ? $config['format'] : 'Y-m-d';
+
+ // Format the date if we have a value
+ if (!empty($value)) {
+ $date = DateTime::createFromFormat($format, $value);
+ if ($date) {
+ $value = $date->format('Y-m-d'); // HTML date input requires Y-m-d format
+ }
+ }
+
+ $value = ($value === '') ? ' value="'.esc_attr($value).'"' : '';
+ $input = sprintf(
+ '<input type="date"%s%s />',
+ $value,
+ static::inputAttrs($name, $config)
+ );
+
+ return static::fieldWrap($name, $input, $config);
+ }
+
+ protected static function renderTime(string $name, mixed $value, array $config): string
+ {
+ $value = ($value === '') ? ' value="'.esc_attr($value).'"' : '';
+ $input = sprintf(
+ '<input type="time"%s%s />',
+ $value,
+ static::inputAttrs($name, $config)
+ );
+
+ return static::fieldWrap($name, $input, $config);
+ }
+
+ protected static function renderDatetime(string $name, mixed $value, array $config): string
+ {
+ $value = ($value === '') ? ' value="'.esc_attr($value).'"' : '';
+ $input = sprintf(
+ '<input type="datetime-local"%s%s />',
+ $value,
+ static::inputAttrs($name, $config)
+ );
+
+ return static::fieldWrap($name, $input, $config);
+ }
+
+ protected static function renderTrueFalse(string $name, mixed $value, array $config): string
+ {
+ if (!array_key_exists('class', $config)) {
+ $config['class'] = [];
+ }
+ $config['class'][] ='row btw';
+
+ $checked = filter_var($value, FILTER_VALIDATE_BOOLEAN);
+
+ $input = sprintf(
+ '<label class="toggle-switch row">
+ <input type="checkbox" value="1"%s%s />
+ <div class="slider"></div>
+ <span class="toggle-label">%s</span>
+ </label>',
+ static::inputAttrs($name, $config),
+ $checked ? ' checked' : '',
+ array_key_exists('required', $config) && $config['required']===true ? '<span class="required" aria-label="required">*</span>' : ''
+ );
+
+ unset($config['label']);
+ return static::fieldWrap($name, $input, $config);
+ }
+
+ protected static function renderToggleText($name, $value, $config):string
+ {
+ if (!isset($config['type'])) {
+ $config['type'] = 'toggle-text';
+ }
+ $input = sprintf(
+ '<input type="checkbox" value="all"%s%s>
+ <label for="%s" class="row">
+ %s
+ <span class="text row">
+ <span class="off">%s</span>
+ <span class="on">%s</span>
+ </span>
+ %s
+ </label>',
+ static::inputAttrs($name, $config),
+ filter_var($value, FILTER_VALIDATE_BOOLEAN) ? ' checked' : '',
+ $name,
+ array_key_exists('before', $config) ? esc_html($config['before']) : '',
+ array_key_exists('off', $config) ? esc_html($config['off']) : 'Off',
+ array_key_exists('on', $config) ? esc_html($config['on']) : 'On',
+ array_key_exists('after', $config) ? esc_html($config['after']) : '',
+ );
+ return static::fieldWrap($name, $input, $config);
+ }
+
+ protected static function renderSelect(string $name, mixed $value, array $config): string
+ {
+ $options = $config['options'] ?? [];
+
+ $optionsHtml = '';
+ if (empty($config['required'])) {
+ $optionsHtml .= '<option value="">— Select —</option>';
+ }
+
+ foreach ($options as $optValue => $optLabel) {
+ $optionsHtml .= sprintf(
+ '<option value="%s"%s>%s</option>',
+ esc_attr($optValue),
+ selected($value, $optValue),
+ esc_html($optLabel)
+ );
+ }
+
+ $select = sprintf(
+ '<select%s>%s</select>',
+ static::inputAttrs($name, $config),
+ $optionsHtml
+ );
+
+ return static::fieldWrap($name, $select, $config);
+ }
+
+ protected static function renderCheckbox(string $name, mixed $value, array $config): string
+ {
+ $options = $config['options'] ?? [];
+ $values = is_array($value) ? $value : explode(',', (string)$value);
+ $values = array_map('trim', $values);
+
+ $checkboxes = sprintf(
+ '<fieldset>
+ <legend>%s%s</legend>',
+ esc_html($config['label'] ?? 'Select Option(s)'),
+ array_key_exists('required', $config) && $config['required']===true ? '<span class="required" aria-label="required">*</span>' : ''
+ );
+
+ foreach ($options as $optValue => $optLabel) {
+ $checked = in_array($optValue, $values) ? ' checked' : '';
+ $checkboxes .= sprintf(
+ '
+ <input type="checkbox" name="%s[]" id="%s-%s" value="%s"%s />
+ <label class="checkbox-option" for="%s-%s">
+ <span>%s</span>
+ </label>',
+ esc_attr($name),
+ esc_attr($name),
+ $optValue,
+ esc_attr($optValue),
+ $checked,
+ $name,
+ $optValue,
+ esc_html($optLabel)
+ );
+ }
+
+ $checkboxes .= '</fieldset>';
+
+ unset($config['label']);
+ return static::fieldWrap($name, $checkboxes, $config);
+ }
+
+ protected static function renderRadio(string $name, mixed $value, array $config): string
+ {
+ $options = $config['options'] ?? [];
+
+ $radios = sprintf(
+ '<fieldset>
+ <legend>%s%s</legend>',
+ array_key_exists('label', $config) ? esc_html($config['label']) : 'Select an option',
+ array_key_exists('required', $config) && $config['required']===true ? '<span class="required" aria-label="required">*</span>' : ''
+ );
+
+ foreach ($options as $optValue => $optLabel) {
+ $radios .= sprintf(
+ '
+ <input type="radio" name="%s" value="%s"%s />
+ <label class="radio-option" for="%s-%s">
+ <span>%s</span>
+ </label>',
+ esc_attr($name),
+ esc_attr($optValue),
+ checked($value, $optValue),
+ $name,
+ $optValue,
+ esc_html($optLabel)
+ );
+ }
+
+ $radios .= '</fieldset>';
+
+ unset($config['label']);
+ return static::fieldWrap($name, $radios, $config);
+ }
+
+ protected static function renderUpload(string $name, mixed $value, array $config): string
+ {
+ $defaults = [
+ //File Type
+ 'subtype' => 'image', //'image', 'video', 'document', 'any'
+ 'accepted' => null, //null = use subtype defaults, or define an array of specific MIME types
+ //Upload Behavious
+ 'multiple' => false, //single or multiple uploads
+ 'limit' => 15, //Max number of uploads (0 = unlimited)
+ 'mode' => 'direct', // 'direct' or 'selection' TODO: unneeded?
+ 'destination'=> 'meta', //'meta', 'post', 'post_group'
+ //Processing Options
+ 'max_size' => null, //override default size limits
+ 'convert' => 'webp', //Image conversion format
+ 'quality' => 90, //Conversion quality
+ 'inputData' => []
+ ];
+ $config = array_merge($defaults, $config);
+
+ // Validate destination config
+ if (in_array($config['destination'], ['post', 'post_group']) && empty($config['content'])) {
+ error_log("Upload field '{$name}' has destination '{$config['destination']}' but no content defined");
+ return '';
+ }
+ $validate = [
+ 'subtype' => ['image', 'video', 'document', 'any'],
+ 'mode' => ['direct', 'selection'],
+ 'destination'=> ['meta', 'post', 'post_group']
+ ];
+ foreach ($validate as $key => $options) {
+ if (!in_array($config[$key], $options)) {
+ error_log('Invalid option set for '.$key.': '.print_r($config[$key], true));
+ return '';
+ }
+ }
+
+ $acceptAttr = implode(',',static::getAllowedTypes($config));
+
+
+ //Add upload config to the datasets (handled by fieldWrap())
+ $attrs = ['subtype', 'mode', 'destination', 'max_size'];
+ foreach ($attrs as $attr) {
+ $config['data'][$attr] = $config[$attr];
+ }
+ $config['data']['upload-field'] = '';
+ $config['data']['type'] = $config['multiple'] ? 'gallery' : 'single';
+ if (!empty($config['content'])) {
+ $config['data']['content'] = $config['content'];
+ }
+ if ($config['limit'] > 0) {
+ $config['data']['limit'] = $config['limit'];
+ }
+
+ $attachmentIds = static::parseIds($value);
+
+ $input = sprintf(
+ '<div class="file-upload-container">
+ <div class="file-upload-wrapper">
+ <input type="file"
+ %s
+ accept="%s"
+ data-max-size="%s">
+ <h2>%s</h2>
+ %s
+ <p class="file-upload-text">
+ <strong>Click to upload</strong> or drag and drop<br>
+ %s
+ (max. %s)
+ </p>',
+ static::inputAttrs($name, $config),
+ $acceptAttr,
+ esc_attr(static::getMaxFileSize($config)),
+ array_key_exists('label', $config) ? esc_html($config['label']) : 'Upload file(s)',
+ static::buildDescription($name, $config),
+ esc_html(static::getAcceptedTypesLabel($config)),
+ esc_html(static::formatFileSize(static::getMaxFileSize($config)))
+ );
+ $plural = (array_key_exists($config['content'], JVB_CONTENT)) ? JVB_CONTENT[$config['content']]['plural'] : (array_key_exists($config['content'], JVB_TAXONOMY) ? JVB_TAXONOMY[$config['content']]['plural'] : str_replace('_', ' ',$config['content']).'s');
+ $singular = (array_key_exists($config['content'], JVB_CONTENT)) ? JVB_CONTENT[$config['content']]['singular'] : (array_key_exists($config['content'], JVB_TAXONOMY) ? JVB_TAXONOMY[$config['content']]['singular'] : str_replace('_', ' ',$config['content']));
+ if ($config['destination'] === 'post_group') {
+ $input .= sprintf(
+ '<p class="hint">You can group images to create separate %s.</p>
+ <p class="hint">If a %s has multiple images, you can select the %s to set an image as the main one.</p>',
+ $plural,
+ $singular,
+ jvbIcon('star')
+ );
+ }
+
+ if (array_key_exists('upload_description', $config) && $config['upload_description']!==''){
+ $input .= sprintf('<p>%s</p>', esc_html($config['upload_description']));
+ }
+
+ $input .= '<div class="file-error"></div>';
+ $input .= jvbRenderProgressBar('', false, true, true);
+ $input .= '</div>';
+
+ if ($config['destination'] === 'post_group') {
+ $input .= static::renderUploadGroupAreaStart($config, $plural, $singular);
+ }
+
+ $input .= static::renderUploadItem($attachmentIds, $config['subtype']);
+ if ($config['destination'] === 'post_group') {
+ $input .= static::renderUploadGroupAreaEnd($config, $plural, $singular);
+ }
+
+ unset($config['description']);
+ unset($config['label']);
+ return static::fieldWrap($name, $input, $config);
+ }
+ protected static function renderUploadGroupAreaStart(array $config, string $plural='', string $singular = ''):string
+ {
+ return sprintf('<div class="group-display flex col" hidden>
+ <div class="preview-wrap flex col">
+ <div class="preview-actions">
+ <div class="selection-controls">
+ <div class="selected">
+ <div class="field">
+ <input type="checkbox" id="select-all-uploads" data-select-all data-selects="item-grid" name="select-all-uploads">
+ <label for="select-all-uploads">
+ Select All
+ </label>
+ </div>
+ <div class="info" hidden>
+
+ </div>
+ </div>
+
+ <div class="selection-actions row btw" hidden>
+ <button type="button" data-action="add-to-group">
+ %sGroup
+ </button>
+ <button type="button" data-action="delete-upload">
+ %sDelete
+ </button>
+ </div>
+ </div>
+
+ <button type="button" data-action="upload" class="submit-uploads">
+ %sUpload %s
+ </button>
+ </div>',
+ jvbIcon('plus-square'),
+ jvbIcon('trash'),
+ jvbIcon('cloud-arrow-up'),
+ $plural!==''? $plural : $config['content'],
+ );
+ }
+
+ protected static function renderUploadItem(array $attachmentIds, string $subtype):string
+ {
+ $out = jvbRenderProgressBar('<span class="text">Processing files...</span>
+ <span class="count">0/0</span>',false,true,true);
+ $out .= '<div class="item-grid preview">';
+ // Render existing attachments
+ foreach ($attachmentIds as $attachmentId) {
+ $out .= static::renderExistingAttachment($attachmentId, $subtype);
+ }
+ $out .= '</div>';
+ return $out;
+ }
+
+ public static function renderExistingAttachment(int $attachmentId, string $subtype):string
+ {
+ switch ($subtype){
+ case 'video':
+ return static::renderVideoPreview($attachmentId);
+ case 'document':
+ case 'file':
+ return static::renderFilePreview($attachmentId);
+ default:
+ return static::renderImagePreview($attachmentId);
+ }
+ }
+ protected static function renderUploadItemStart(?int $attachmentId = null):string
+ {
+ return sprintf(
+ '<div class="item upload" data-id="%d">
+ <div class="preview">
+ <input type="checkbox" class="select-item" name="select-item" id="select-item%d">
+ <label for="select-item%d" aria-label="Select image">',
+ $attachmentId,
+ ($attachmentId) ? '-'.$attachmentId : '',
+ ($attachmentId) ? '-'.$attachmentId : ''
+ );
+ }
+ protected static function renderUploadItemEnd():string {
+ return '</label>';
+ }
+ protected static function renderUploadItemActions(?int $attachmentId = null):string
+ {
+ return sprintf(
+ '<div class="item-actions row btw">
+ <div class="radio-button">
+ <input type="radio" class="featured btn" name="featured" id="featured%d" hidden>
+ <label for="featured">
+ %s%s<span class="screen-reader-text">Set as featured image</span>
+ </label>
+ </div>
+ <button type="button" data-action="delete-upload" title="Remove from Group">
+ %s
+ </button>
+ </div>',
+ ($attachmentId) ? '-'.$attachmentId : '',
+ jvbIcon('star'),
+ jvbIcon('star', ['style' => 'fill']),
+ jvbIcon('trash')
+ );
+ }
+ protected static function renderUploadItemMetaStart():string
+ {
+ return '</div>';
+ }
+ protected static function renderUploadItemMetaEnd():string
+ {
+ return '</div>';
+ }
+ protected static function renderVideoPreview(?int $ID = null, ?array $additionalFields = null):string
+ {
+ $out = static::renderUploadItemStart($ID);
+ //add video preview
+ $previewID = get_post_meta($ID, BASE.'poster', true);
+ if ($previewID !== '') {
+ $out .= jvbFormatImage($previewID, 'tiny', 'medium');
+ } else {
+ $out .= '<img><video></video><span></span>';
+ }
+ $out .= static::renderUploadItemEnd();
+ //add item actions
+ $out .= static::renderUploadItemActions($ID);
+ $out .= static::renderUploadItemMetaStart();
+
+ //Caption, description, title
+ $caption = ($ID) ? wp_get_attachment_caption($ID) : '';
+ $description = ($ID) ? get_the_content($ID) : '';
+ $title = ($ID) ? get_the_title($ID) : '';
+
+ $fields = [
+ 'type' => 'group',
+ 'wrap' => 'details',
+ 'label' => 'Edit Video Meta',
+ 'fields' => [
+ 'image-title' => [
+ 'type' => 'text',
+ 'label' => 'Video Title',
+ 'value' => $title,
+ 'data' => ['id' => $ID]
+ ],
+ 'poster' => [
+ 'type' => 'upload',
+ 'label' => 'Video Poster',
+ 'value' => $previewID,
+ 'multiple' => false,
+ ],
+ 'image-caption' => [
+ 'type' => 'textarea',
+ 'value' => $caption,
+ 'label' => 'Video Caption',
+ 'data' => ['id' => $ID]
+ ],
+ 'image-description' => [
+ 'type' => 'textarea',
+ 'value' => $description,
+ 'label' => 'Video Description',
+ 'data' => ['id' => $ID]
+ ]
+ ]
+ ];
+
+ $out .= static::render('image_data', $fields);
+ $out .= static::renderUploadItemMetaEnd();
+
+ if ($additionalFields) {
+ $out .= static::additionalFields($additionalFields);
+ }
+
+ return $out;
+ }
+ protected static function renderFilePreview(?int $ID, ?array $additionalFields = null):string
+ {
+ $out = static::renderUploadItemStart($ID);
+
+ $upload = wp_get_attachment_url($ID);
+ $fileType = wp_check_filetype($upload)['ext']??false;
+ $iconMap = [
+ 'pdf' => 'file-pdf',
+ 'csv' => 'file-csv',
+ 'doc' => 'file-doc',
+ 'docx' => 'file-doc',
+ 'txt' => 'file-txt',
+ 'xls' => 'file-xls',
+ 'xlsx' =>'file-xls'
+ ];
+ $icon = ($fileType) ? jvbIcon($iconMap[$fileType]??'file') : jvbIcon('file');
+ $out .= '<span>'.$icon.'</span>';
+
+ $out .= static::renderUploadItemEnd();
+ //add item actions
+ $out .= static::renderUploadItemActions($ID);
+ $out .= static::renderUploadItemMetaStart();
+
+ //Caption, description, title
+ $caption = ($ID) ? wp_get_attachment_caption($ID) : '';
+ $description = ($ID) ? get_the_content($ID) : '';
+ $title = ($ID) ? get_the_title($ID) : '';
+
+ $fields = [
+ 'type' => 'group',
+ 'wrap' => 'details',
+ 'label' => 'Edit File Meta',
+ 'fields' => [
+ 'image-title' => [
+ 'type' => 'text',
+ 'label' => 'File Title',
+ 'value' => $title,
+ 'data' => ['id' => $ID]
+ ],
+ 'poster' => [
+ 'type' => 'upload',
+ 'label' => 'File Poster',
+ 'multiple' => false,
+ ],
+ 'image-caption' => [
+ 'type' => 'textarea',
+ 'value' => $caption,
+ 'label' => 'File Caption',
+ 'data' => ['id' => $ID]
+ ],
+ 'image-description' => [
+ 'type' => 'textarea',
+ 'value' => $description,
+ 'label' => 'File Description',
+ 'data' => ['id' => $ID]
+ ]
+ ]
+ ];
+
+ $out .= static::render('image_data', $fields);
+ $out .= static::renderUploadItemMetaEnd();
+
+ if ($additionalFields) {
+ $out .= static::additionalFields($additionalFields);
+ }
+
+ return $out;
+ }
+ public static function renderImagePreview(?int $ID = null, ?array $additionalFields = null):string
+ {
+ $out = static::renderUploadItemStart($ID);
+ //add image preview
+ if ($ID) {
+ $out .= jvbFormatImage($ID, 'tiny', 'medium');
+ } else {
+ $out .= '<img><video></video><span></span>';
+ }
+ $out .= static::renderUploadItemEnd();
+ //add item actions
+ $out .= static::renderUploadItemActions($ID);
+ $out .= static::renderUploadItemMetaStart();
+
+ //Caption, description, title
+ $caption = ($ID) ? wp_get_attachment_caption($ID) : '';
+ $description = ($ID) ? get_the_content($ID) : '';
+ $alt = ($ID) ? get_post_meta($ID, '_wp_attachment_image_alt', true) : '';
+ $title = ($ID) ? get_the_title($ID) : '';
+
+ $fields = [
+ 'type' => 'group',
+ 'wrap' => 'details',
+ 'label' => 'Edit Image Meta',
+ 'fields' => [
+ 'image-title' => [
+ 'type' => 'text',
+ 'label' => 'Image Title',
+ 'value' => $title,
+ 'data' => ['id' => $ID]
+ ],
+ 'image-alt-text' => [
+ 'type' => 'text',
+ 'label' => 'Alt Text',
+ 'value' => $alt,
+ 'hint' => 'Alt text helps the visually impaired, as well as some benefits for SEO.',
+ 'data' => ['id' => $ID]
+ ],
+ 'image-caption' => [
+ 'type' => 'textarea',
+ 'value' => $caption,
+ 'label' => 'Image Caption',
+ 'data' => ['id' => $ID]
+ ],
+ 'image-description' => [
+ 'type' => 'textarea',
+ 'value' => $description,
+ 'label' => 'Image Description',
+ 'data' => ['id' => $ID]
+ ]
+ ]
+ ];
+
+ $out .= static::render('image_data', $fields);
+
+ $out .= static::renderUploadItemMetaEnd();
+ if ($additionalFields) {
+ $out .= static::additionalFields($additionalFields);
+ }
+
+ return $out;
+ }
+ protected static function additionalFields(array $fields):string
+ {
+ $out = '';
+ foreach ($fields as $name => $config) {
+ $out .= static::render($name, '', $config);
+ }
+ return $out;
+ }
+
+ protected static function renderUploadGroupAreaEnd(array $config, string $plural, string $singular):string
+ {
+ return sprintf(
+ '<p class="hint">%s These will become individual %s %s</p>
+ </div>
+ <div class="sidebar flex col">
+ <div class="header">
+ <h4>New %s</h4>
+ <p class="hint">Drag or select multiple images into groups to create separate %s.</p>
+ </div>
+ <div class="item-grid groups">
+ <div class="empty-group">
+ <p>Drag here to create a new %s!</p>
+ </div>
+ </div>
+ <p class="hint">%s Each group will become its own %s %s</p>
+ </div>
+ </div>',
+ jvbIcon('arrow-elbow-left-up'),
+ $plural,
+ jvbIcon('arrow-elbow-right-up'),
+ $plural,
+ $plural,
+ $singular,
+ jvbIcon('arrow-elbow-left-up'),
+ $singular,
+ jvbIcon('arrow-elbow-left-up')
+ );
+ }
+ protected static function getAllowedTypes(array $config): array
+ {
+ if (!empty($config['accepted'])) {
+ return $config['accepted'];
+ }
+ $defaults = [
+ 'image' => ['image/*'],
+ 'video' => ['video/*'],
+ 'document' => ['application/pdf', 'application/msword', 'application/vnd.ms-excel', 'text/plain', '.odt','application/vnd.openxmlformats-officedocument.wordprocessingml.document'],
+ ];
+ $defaults['any'] = array_merge(array_values($defaults));
+ return $defaults[$config['subtype']]??$defaults['image'];
+ }
+
+ protected static function getMaxFileSize(array $config):string
+ {
+
+ $defaults = [
+ 'image' => 5242880, // 5MB
+ 'video' => 104857600, // 100MB
+ 'document' => 10485760 // 10MB
+ ];
+
+ return absint($config['max_size']??$defaults[$config['subtype']] ?? $defaults['image']);
+ }
+
+ protected static function formatFileSize(int $bytes):string
+ {
+ if ($bytes >= 1073741824) {
+ return number_format($bytes / 1073741824, 1) . 'GB';
+ }
+ if ($bytes >= 1048576) {
+ return number_format($bytes / 1048576, 1) . 'MB';
+ }
+ if ($bytes >= 1024) {
+ return number_format($bytes / 1024, 1) . 'KB';
+ }
+ return $bytes . 'B';
+ }
+
+ protected static function getAcceptedTypesLabel(array $config):string
+ {
+ $labels = [
+ 'image' => 'JPG, JPEG, PNG, GIF, or WEBP',
+ 'video' => 'MP4, WEBM, or MOV',
+ 'document'=> 'PDF, DOC, XLS, or TXT',
+ 'any' => 'Images, Videos, or Documents'
+ ];
+
+ return $labels[$config['subtype']] ?? strtoupper(implode(', ', array_map(function($ext) {
+ return ltrim($ext, '.');
+ }, array_slice($config['accepted'], 0, 3))));
+ }
+
+ protected static function parseIds(mixed $value):array
+ {
+ if (empty($value)) {
+ return [];
+ }
+ if (is_string($value)) {
+ $value = explode(',',$value);
+ }
+ return array_filter(array_map('absint', $value), function($item) {
+ return $item > 0;
+ });
+ }
+
+ protected static function renderGallery(string $name, mixed $value, array $config): string
+ {
+ $config['multiple'] = true;
+ return static::renderUpload($name,$value,$config);
+ }
+
+ protected static function renderSelector(string $name, mixed $value, array $config, string $extra =''):string
+ {
+ $ids = static::parseIds($value);
+
+ if (!array_key_exists('subtype', $config) || !in_array($config['subtype'], ['taxonomy', 'content', 'user'])){
+ error_log('Invalid subtype for Selector: '.print_r($config['subtype']??false, true));
+ return '';
+ }
+
+ $config = array_merge([
+ 'max' => $config['max']??0,
+ 'search' => $config['search']??true,
+ 'createNew' => $config['createNew']??false,
+ 'autocomplete'=> $config['autocomplete']??true,
+ 'name' => $name,
+ 'update' => $config['update']??true,
+ 'required' => $config['required']??false,
+ 'type' => $config['subtype'],
+ ], $config);
+
+ $icon = match ($config['subtype']) {
+ 'taxonomy' => JVB_TAXONOMY[$config['taxonomy']]['icon'] ?? jvbDefaultIcon(),
+ 'content' => JVB_CONTENT[$config['content']]['icon'] ?? jvbDefaultIcon(),
+ 'user' => JVB_USER[$config['role']]['icon'] ?? 'user',
+ default => jvbDefaultIcon(),
+ };
+
+ $containerId = sprintf('%s-%s-selector', $name, $config['subtype']);
+
+ $input = sprintf(
+ '<div class="row btw">
+ <label for="%s-autocomplete">%s<span>%s</span></label>',
+ esc_attr($name),
+ jvbIcon($icon),
+ esc_html($config['label']),
+ );
+
+ $input .= static::buildSelectorButton($ids, $config);
+
+ if ($config['autocomplete']) {
+ $input .= static::buildSelectorAutocomplete($name, $config);
+ }
+ $plural = static::getPlural($config);
+ $input .= sprintf(
+ '<div class="selected-item row" role="region" aria-label="Selected %s"></div>',
+ $plural[1]??''
+ );
+
+ unset($config['label']);
+ unset($config['description']);
+ unset($config['hint']);
+ $config['skipInput'] = true;
+ return static::fieldWrap($containerId, $input, $config);
+ }
+
+ protected static function getPlural(array $config):array
+ {
+ switch ($config['subtype']) {
+ case 'taxonomy':
+ if (array_key_exists($config['taxonomy'], JVB_TAXONOMY)) {
+ $single = JVB_TAXONOMY[$config['taxonomy']]['singular'];
+ $plural = JVB_TAXONOMY[$config['taxonomy']]['plural'];
+ } else {
+ $taxonomy = get_taxonomy($config['taxonomy']);
+ if (!$taxonomy) {
+ return [];
+ }
+ $single = $taxonomy->labels->singular_name;
+ $plural = $taxonomy->labels->name;
+ }
+ break;
+ case 'content':
+ if (array_key_exists($config['content'], JVB_CONTENT)) {
+ $single = JVB_CONTENT[$config['content']]['singular'];
+ $plural = JVB_CONTENT[$config['content']]['plural'];
+ } else {
+ $postType = get_post_type_object($config['content']);
+ if (!$postType) {
+ return '';
+ }
+ $single = $postType->labels->singular_name;
+ $plural = $postType->labels->name;
+ }
+ break;
+
+ case 'user':
+ if (array_key_exists($config['user'], JVB_USER)) {
+ $single = JVB_USER[$config['user']]['singular'];
+ $plural = JVB_USER[$config['user']]['plural'];
+ } else {
+ $user = get_role($config['user']);
+ if (!$user) {
+ return '';
+ }
+ $single = 'User';
+ $plural = 'Users';
+ }
+ break;
+ }
+ return [$single, $plural];
+ }
+
+ protected static function buildSelectorButton(array $ids, array $config):string
+ {
+ switch ($config['subtype']) {
+ case 'taxonomy':
+ if (array_key_exists($config['taxonomy'], JVB_TAXONOMY)) {
+ $single = JVB_TAXONOMY[$config['taxonomy']]['singular'];
+ $plural = JVB_TAXONOMY[$config['taxonomy']]['plural'];
+ } else {
+ $taxonomy = get_taxonomy($config['taxonomy']);
+ if (!$taxonomy) {
+ return '';
+ }
+ $single = $taxonomy->labels->singular_name;
+ $plural = $taxonomy->labels->name;
+ }
+ $attr = sprintf(
+ ' data-taxonomy="%s" data-single="%s" data-plural="%s',
+ $config['taxonomy'],
+ $single,
+ $plural
+ );
+ break;
+ case 'content':
+ if (array_key_exists($config['content'], JVB_CONTENT)) {
+ $single = JVB_CONTENT[$config['content']]['singular'];
+ $plural = JVB_CONTENT[$config['content']]['plural'];
+ } else {
+ $postType = get_post_type_object($config['content']);
+ if (!$postType) {
+ return '';
+ }
+ $single = $postType->labels->singular_name;
+ $plural = $postType->labels->name;
+ }
+ $attr = sprintf(
+ ' data-content="%s" data-single="%s" data-plural="%s',
+ $config['content'],
+ $single,
+ $plural
+ );
+ break;
+
+ case 'user':
+ if (array_key_exists($config['user'], JVB_USER)) {
+ $single = JVB_USER[$config['user']]['singular'];
+ $plural = JVB_USER[$config['user']]['plural'];
+ } else {
+ $user = get_role($config['user']);
+ if (!$user) {
+ return '';
+ }
+ $single = 'User';
+ $plural = 'Users';
+ }
+ $attr = sprintf(
+ ' data-user="%s" data-single="%s" data-plural="%s',
+ $config['user'],
+ $single,
+ $plural
+ );
+ break;
+ }
+
+ $dataAttrs = [];
+ if ($config['update']) {
+ $dataAttrs[] = 'data-update="false"';
+ }
+ if ($config['max']>0) {
+ $dataAttrs[] = 'data-max="'.esc_attr($config['max']).'"';
+ }
+ if ($config['search']) {
+ $dataAttrs[] = 'data-search';
+ }
+ if ($config['createNew']) {
+ $dataAttrs[] = 'data-creatable';
+ }
+ if (array_key_exists('types', $config) && is_array($config['types'])) {
+ $dataAttrs[] = 'data-for="'.esc_attr(implode(',',$config['types'])).'"';
+ }
+ if (!empty($selected)) {
+ $dataAttrs[] = 'data-selected="'.esc_attr(implode(',',$selected)).'"';
+ }
+ if ($config['autocomplete']) {
+ $dataAttrs[] = 'autocomplete';
+ }
+ if (array_key_exists('hidden', $config) && $config['hidden']) {
+ $dataAttrs[] = 'hidden';
+ }
+
+ $dataAttrs = implode(' ',$dataAttrs);
+
+ return sprintf(
+ '<button type="button" class="filter-toggle selector-toggle"%s%s title="Open %s Selector" aria-label="Select %s">%s</button>',
+ $attr,
+ $dataAttrs,
+ $single,
+ $plural,
+ jvbIcon('plus-square')
+ );
+ }
+ protected static function buildSelectorAutocomplete(string $name, array $config):string
+ {
+ return sprintf(
+ '<input type="hidden" id="%s-autocomplete" autocomplete="off" data-ignore data-autocomplete>
+ <p class="message" hidden aria-live="polite">{ <span>Loading items</span> }</p>
+ <div class="auto-wrapper" hidden><ul class="search-results"></ul><button class="submit-term" hidden data-ignore><strong>Create: </strong> "<span></span>"</button></div>',
+ $name
+ );
+ }
+
+ protected static function renderTaxonomy(string $name, mixed $value, array $config): string
+ {
+ $config['subtype'] = 'taxonomy';
+ return static::renderSelector($name, $value, $config);
+ }
+
+ protected static function renderUser(string $name, mixed $value, array $config): string
+ {
+ $config['subtype'] = 'user';
+ return static::renderSelector($name, $value, $config);
+ }
+ protected static function renderContent(string $name, mixed $value, array $config): string
+ {
+ $config['subtype'] = 'content';
+ return static::renderSelector($name, $value, $config);
+ }
+
+ protected static function renderLocation(string $name, mixed $value, array $config): string
+ {
+ $googleMaps = JVB()->connect('maps');
+ if (!$googleMaps->isSetUp()) {
+ return '<div class="notice notice-warning"><p>Google Maps not configured. Please configure in Integrations settings.</p></div>';
+ }
+
+ $field_id = esc_attr($name);
+ $map_id = sprintf('%s_map', $field_id);
+ $components = ['address', 'lat', 'lng', 'street', 'city', 'province', 'postal_code', 'country'];
+ if (!empty($value)) {
+ $lat = (float)$value['lat']??'';
+ $lng = (float)$value['lng']??'';
+
+ $coords = [
+ 'lat' => $lat,
+ 'ng' => $lng
+ ];
+ } else {
+ $coords = null;
+ }
+ if (!array_key_exists('data', $config)) {
+ $config['data'] =[];
+ }
+ $js_config = [
+ 'fieldId' => $field_id,
+ 'initialCoords' => $coords
+ ];
+
+ $json_config = htmlspecialchars(json_encode($js_config), ENT_QUOTES, 'UTF-8');
+ $config['data']['location-field-init'] = $json_config;
+
+ $input = '';
+ if (!empty($value) && array_key_exists('address', $value)) {
+ $input = sprintf(
+ '<p><b>Current location:</b> %s</p><p class="hint">Search below to change:</p>',
+ esc_html($value['address'])
+ );
+ }
+ $links = (!empty($value)) ? jvbLocationLinks($value) : '';
+ $input .= sprintf(
+ '<div class="location-search-wrapper">
+ <div class="autocomplete-wrapper"></div>
+ <div class="location-preview">
+ <div id="%s" class="location-map"></div>
+ %s
+ </div>',
+ esc_attr($map_id),
+ $links
+ );
+
+ if (!empty($value)) {
+ foreach($components as $el) {
+ $input .= sprintf(
+ '<input type="hidden"
+ name="%s[%s]"
+ value="%s"
+ data-location-field="%s">',
+ esc_attr($name),
+ $el,
+ $value[$el]??'',
+ $el
+ );
+ }
+ }
+
+ $input .= '</div>';
+
+ return static::fieldWrap($name, $input, $config);
+ }
+
+ protected static function renderTagList(string $name, mixed $value, array $config):string
+ {
+ $tagFormat = $config['tag_format']??'first_field';
+ if (!array_key_exists('data', $config)) {
+ $config['data']= [];
+ }
+ $config['data']['tag-format'] = esc_attr($tagFormat);
+
+ $input = sprintf(
+ '<h3>%s</h3><div class="row start wrap">',
+ esc_html($config['label']??'')
+ );
+
+ foreach ($config['fields'] as $fieldName => $fieldConfig) {
+ $newName = sprintf('new_%s', $fieldName);
+ if (array_key_exists('required', $fieldConfig)) {
+ $fieldConfig['data']['required'] = true;
+ unset($fieldConfig['required']);
+ }
+ $fieldConfig['data']['ignore'] = true;
+
+ $input .= static::render($newName, '', $fieldConfig);
+ }
+ $input .= sprintf(
+ '<button type="button" class="button add-tag-item">%s<span>%s</span></button></div>',
+ jvbIcon('plus'),
+ $field['add_label']??'Add'
+ );
+
+ //Tag Display
+ $input .= '<div class="tag-items">'.static::renderTagItem($config['fields'], $value, $name, null, $tagFormat).'</div>';
+
+ //Template for tags
+ $input .= sprintf(
+ '<template class="%s">%s</template>',
+ uniqid('tagListItem'),
+ static::renderTagItem($config['fields'], [], null, $name, $tagFormat)
+ );
+
+ return static::fieldWrap($name, $input, $config);
+ }
+ protected static function renderTagItem(array $fields, mixed $values, string $name, ?int $index, string $tagFormat):string
+ {
+ $tagText = static::getTagDisplayText($fields, $values, $tagFormat);
+
+ $out = sprintf(
+ '<div class="tag-item"%s><span class="tag-label">%s</span>',
+ ($index) ? ' data-index="'.$index.'"' : '',
+ $tagText
+ );
+
+ foreach ($fields as $fieldName => $fieldConfig) {
+ $value = $values[$fieldName]??'';
+ $fullName = (!$index) ? $fieldName : sprintf('%s:%s:%s', $name, $index, $fieldName);
+
+ $out .= sprintf(
+ '<input type="hidden"
+ name="%s"
+ value="%s"
+ data-field="%s",
+ data-field-type="%s" />',
+ esc_attr($fullName),
+ esc_attr($value),
+ esc_attr($fieldName),
+ esc_attr($fieldConfig['type'])
+ );
+
+ $out .= sprintf(
+ '<button type="button" class="remove-tag" aria-label="Remove">%s</button></div>',
+ jvbIcon('x')
+ );
+ }
+ return $out;
+ }
+ protected static function getTagDisplayText(array $fields, mixed $values, string $tagFormat):string
+ {
+ if (empty($data)) {
+ return 'New Item';
+ }
+
+ switch ($tagFormat) {
+ case 'first_field':
+ $firstKey = array_key_first($fields);
+ return $values[$firstKey] ?? 'New Item';
+ case 'all_fields':
+ $values = array_filter(array_values($data));
+ return implode(', ', $values) ?: 'New Item';
+ default:
+ if (strpos($tagFormat, '{') !== false) {
+ $text = $tagFormat;
+ foreach ($values as $key => $value) {
+ $text = str_replace('{'.$key.'}', $value, $text);
+ }
+ return $text;
+ }
+ return $values[$tagFormat]??'New Item';
+ }
+ }
+
+ protected static function renderRepeater(string $name, mixed $value, array $config): string
+ {
+ $fields = $config['fields'] ?? [];
+ $rows = is_array($value) ? $value : [];
+ if(array_key_exists('row_label', $config)) {
+ $config['data']['label'] = esc_attr($config['row_label']);
+ }
+
+ $input = sprintf(
+ '<h3>%s</h3>',
+ esc_html($config['label'] ?? '')
+ );
+
+ $input .= '<div class="repeater-items">';
+ $rowTitle = array_key_exists('new_row', $config) ? $config['new_row'] : 'New Item';
+ if(!empty($rows)) {
+ foreach ($rows as $index=>$row) {
+ $input .= static::renderRepeaterRow($config['fields'], $row, $index, $name, $rowTitle);
+ }
+ }
+ $input .= '</div>';
+
+ $input .= '<template class="'.uniqid('repeaterRow').'">';
+ $input .= static::renderRepeaterRow($config['fields'], [], '','',$rowTitle);
+ $input .= '</template>';
+
+ $input .= sprintf(
+ '<button type="button" class="add-repeater-row">%s%s</button>',
+ jvbIcon('plus', ['title'=>'Add']),
+ array_key_exists('add_label', $config) ? $config['add_label'] : 'Add Item'
+ );
+
+ return static::fieldWrap($name, $input, $config);
+ }
+ protected static function renderRepeaterRow(array $fields, array $values, int|string $index, string $name, string $rowTitle='New Item'):string
+ {
+ $displayNumber = (is_string($index)) ? $index : ($index +1);
+
+ $output = sprintf(
+ '<div class="repeater-row" data-index="%s">
+ <button type="button" class="remove-row" title="Remove">
+ %s
+ </button>
+ <details%s>
+ <summary class="row btw repeater-row-header">
+ <span class="drag-handle">%s</span>
+ <span class="row-number">#%s</span>
+ <span class="row-title">%s</span>
+ </summary>
+ <div class="repeater-row-content">',
+ esc_attr($index),
+ jvbIcon('trash'),
+ is_string($index) ? ' open' : '',
+ jvbIcon('dots-six-vertical'),
+ $displayNumber,
+ $rowTitle
+ );
+ foreach ($fields as $fieldName => $fieldConfig) {
+ $fieldValue = $values[$fieldName] ?? '';
+ $fullName = ($name === '') ? $fieldName : sprintf('%s:%s:%s', $name, $index, $fieldName);
+ $output .= static::render($fullName, $fieldValue, $fieldConfig);
+ }
+
+ return $output.'</div></details></div>';
+ }
+
+ /**
+ * Group fields are mainly for ease of conditional logic and visual layout
+ * @param string $name
+ * @param mixed $value
+ * @param array $config
+ * @return string
+ */
+ protected static function renderGroup(string $name, mixed $value, array $config): string
+ {
+ $fields = $config['fields'] ?? [];
+ error_log('Group fields: '.print_r($fields, true));
+ $values = is_array($value) ? $value : [];
+
+ $wrapper = (array_key_exists('wrap', $config)) ? 'details' : 'fieldset';
+ $legend = (array_key_exists('wrap', $config)) ? 'summary' : 'legend';
+
+ $output = sprintf(
+ '<%s><%s>%s</%s>'
+ ,
+ esc_attr($wrapper),
+ esc_attr($legend),
+ array_key_exists('label', $config) ? $config['label'] : 'Group',
+ esc_attr($legend)
+ );
+
+ foreach ($fields as $fieldName => $fieldConfig) {
+ $fieldValue = $values[$fieldName] ?? '';
+ $fullName = "{$name}:{$fieldName}";
+ $output .= static::render($fullName, $fieldValue, $fieldConfig);
+ }
+
+ $output .= sprintf('</%s>', esc_attr($wrapper));
+
+ return static::fieldWrap($name, $output, $config);
+ }
+
+ public static function outputSelectorModal():string
+ {
+ return sprintf('<dialog id="jvb-selector" aria-labelledby="modal-title" aria-modal="true">
+ <div class="wrap col">
+ <header class="modal-header">
+ <h3 id="modal-title">Select Taxonomy</h3>
+ </header>
+
+
+ <div class="selected-items row" role="region" aria-label="Selected items"></div>
+
+ <div class="items-wrap">
+ <!-- Common/Favorite terms section -->
+ <details class="favourite-terms" hidden>
+ <summary class="title row btw">Your Go Tos:</summary>
+ <ul class="favourite-list row btw"></ul>
+ </details>
+
+ <!-- Pagination info -->
+ <p class="pagination-info" hidden></p>
+
+ <!-- Navigation breadcrumbs -->
+ <nav class="term-navigation row" aria-label="Term navigation">
+ <button type="button" class="back-to-parent" hidden>
+ <span aria-hidden="true">←</span> Back
+ </button>
+ </nav>
+
+
+ <p class="message" hidden aria-live="polite">
+ { <span>loading items</span> }
+ </p>
+ <!-- Terms list -->
+ <ul class="items-container col start" role="listbox" aria-label="Available terms">
+ <!-- Terms will be populated here -->
+ </ul>
+
+ <button class="submit-term" hidden data-ignore><strong>Create: </strong> "<span></span>"</button>
+
+ <!-- Infinite scroll sentinel -->
+ <div class="scroll-sentinel" aria-hidden="true"></div>
+ </div>
+
+ <!-- Search section -->
+ <div class="search-wrapper">
+ <div class="search-bar">
+ %s
+ </div>
+ </div>
+
+ <!-- Create new term section -->
+ <details class="create-term" hidden>
+ <summary class="row btw">Add New Term</summary>
+ <div class="create-new-term-section">
+ <form class="create-term" data-nocache data-form-id="create-term" data-save="terms">
+ <div class="form-group">
+ <label for="term_name">Term Name:</label>
+ <input type="text" name="term_name" id="term_name" required>
+ </div>
+
+ <div class="form-group">
+ <label for="select_parent">Nest it under:</label>
+ <select name="parent" id="select_parent">
+ <option value="0">None (Top Level)</option>
+ </select>
+ </div>
+ </form>
+
+ </div>
+ </details>
+ %s
+ </div>
+ </dialog>
+ <template class="loadingItems">
+ <p>{ <span>loading items</span> }</p>
+ </template>
+ <template class="autocompleteItem">
+ <li class="autocomplete item btn"></li>
+ </template>
+ <template class="noTermResults">
+ <p>{ <span>nothing found</span> }</p>
+ </template>
+ <template class="termListItem">
+ <li>
+ <input type="checkbox">
+ <label>
+ <span class="term-name"></span>
+ </label>
+ </li>
+ </template>
+ <template class="termChildrenToggle">
+ <button type="button" class="toggle-children" aria-expanded="false">
+ %s
+ </button>
+ </template>
+ <template class="selectedTerm">
+ <div class="selected-item row">
+ <span class="item-name"></span>
+ <button type="button" class="remove-term row">%s</button>
+ </div>
+ </template>
+ <template class="termBreadcrumb">
+ <button type="button" class="path-level"></button>
+ </template>',
+ static::search('Search terms', 'search-terms'),
+ jvbModalActions(),
+ jvbIcon('plus-square'),
+ jvbIcon('x')
+ );
+ }
+
+ public static function search(string $placeholder = 'Search...', string $id = 'search'):string
+ {
+ $id = sanitize_title($id);
+ return sprintf(
+ '<div class="search-container row start nowrap">
+ <input type="search" id="%s" placeholder="%s">
+ <button title="Clear Search" type="button" class="clear-search" aria-label="Clear search"
+ onclick="this.previousElementSibling.value = \'\'; this.previousElementSibling.focus();">%s</button>
+ <button type="button" title="Search" class="toggle search" aria-label="Toggles search input visually" onclick="this.parentNode.classList.toggle(\'open\');this.previousElementSibling.previousElementSibling.focus();">%s</button>
+ </div>',
+ $id,
+ $placeholder,
+ jvbIcon('x', ['title' => 'Clear Search']),
+ jvbIcon('magnifying-glass')
+ );
+ }
+}
diff --git a/inc/meta/Item.php b/inc/meta/Item.php
index 33ae50c..65e9ecf 100644
--- a/inc/meta/Item.php
+++ b/inc/meta/Item.php
@@ -16,14 +16,18 @@
public ?string $contentType; // tattoo, artist, style (without BASE prefix)
public ?object $wpObject; // WP_Post, WP_Term, WP_User
- /** @var array<string, Field> */
+ /** @var array<string, Field> Loaded fields */
public array $fields = [];
/** @var array<string, array> Raw field configs from registry */
public array $fieldConfigs = [];
- public ?string $baseKey = null; // For options
+ /** @var string|null Base key for options storage */
+ public ?string $baseKey = null;
+ /**
+ * WordPress default fields by object type
+ */
public const WP_DEFAULTS = [
'post' => [
'post_title',
@@ -58,43 +62,89 @@
$this->contentType = $contentType;
}
+ /**
+ * Check if field exists in configs
+ */
public function hasField(string $name): bool
{
return isset($this->fields[$name]) || isset($this->fieldConfigs[$name]);
}
+ /**
+ * Get loaded field instance
+ */
public function getField(string $name): ?Field
{
return $this->fields[$name] ?? null;
}
+ /**
+ * Set/add a field instance
+ */
public function setField(Field $field): self
{
$this->fields[$field->name] = $field;
return $this;
}
- public function getFieldConfig(string $name): ?array
+ /**
+ * Remove a field instance
+ */
+ public function removeField(string $name): self
{
- return $this->fieldConfigs[$name] ?? null;
+ unset($this->fields[$name]);
+ return $this;
}
+ /**
+ * Get field configuration
+ */
+ public function getFieldConfig(string $name): ?array
+ {
+ if (isset($this->fieldConfigs[$name])) {
+ return $this->fieldConfigs[$name];
+ }
+
+ // Search nested fields (repeaters, groups)
+ foreach ($this->fieldConfigs as $config) {
+ if (isset($config['fields'][$name])) {
+ return $config['fields'][$name];
+ }
+ }
+
+ return null;
+ }
+
+ /**
+ * Check if field is a WordPress default
+ */
public function isWpDefault(string $name): bool
{
$defaults = self::WP_DEFAULTS[$this->objectType] ?? [];
return in_array($name, $defaults, true);
}
+ /**
+ * Get all dirty (changed) fields
+ * @return array<string, Field>
+ */
public function getDirtyFields(): array
{
return array_filter($this->fields, fn(Field $f) => $f->isDirty);
}
+ /**
+ * Get all invalid fields
+ * @return array<string, Field>
+ */
public function getInvalidFields(): array
{
return array_filter($this->fields, fn(Field $f) => !$f->isValid);
}
+ /**
+ * Mark all loaded fields as clean
+ */
public function markAllClean(): self
{
foreach ($this->fields as $field) {
@@ -103,13 +153,50 @@
return $this;
}
+ /**
+ * Reset all fields to original values
+ */
+ public function resetAll(): self
+ {
+ foreach ($this->fields as $field) {
+ $field->reset();
+ }
+ return $this;
+ }
+
+ /**
+ * Check if any fields are dirty
+ */
public function hasDirtyFields(): bool
{
return !empty($this->getDirtyFields());
}
+ /**
+ * Check if all fields are valid
+ */
public function isValid(): bool
{
return empty($this->getInvalidFields());
}
+
+ /**
+ * Get all field names from configs
+ */
+ public function getFieldNames(): array
+ {
+ return array_keys($this->fieldConfigs);
+ }
+
+ /**
+ * Get loaded field values as array
+ */
+ public function toArray(): array
+ {
+ $data = [];
+ foreach ($this->fields as $name => $field) {
+ $data[$name] = $field->value;
+ }
+ return $data;
+ }
}
diff --git a/inc/meta/Meta.php b/inc/meta/Meta.php
index 05810e6..1316684 100644
--- a/inc/meta/Meta.php
+++ b/inc/meta/Meta.php
@@ -1,8 +1,6 @@
<?php
namespace JVBase\meta;
-use Exception;
-
if (!defined('ABSPATH')) {
exit;
}
@@ -10,37 +8,62 @@
/**
* Main facade for meta operations
* Fluent API for getting/setting meta values with validation & sanitization
+ *
+ * Usage:
+ * $meta = Meta::forPost($id);
+ * $meta->price = 150;
+ * $meta->save();
+ *
+ * Meta::forPost($id)->set('price', 150)->set('style', 'traditional')->save();
*/
class Meta
{
protected Item $item;
protected Storage $storage;
- protected MetaValidator $validator;
- protected MetaSanitizer $sanitizer;
+ protected Validator $validator;
+ protected Sanitizer $sanitizer;
protected MetaTypeManager $typeManager;
protected bool $autoValidate = true;
protected bool $autoSanitize = true;
+ /** @var array<string, callable[]> */
+ protected array $onChangeCallbacks = [];
+
+ /** @var array<string, callable> */
+ protected array $computed = [];
+
// ─────────────────────────────────────────────────────────────
// Factory Methods
// ─────────────────────────────────────────────────────────────
+ /**
+ * Create Meta instance for a post
+ */
public static function forPost(int $id): self
{
return new self($id, 'post');
}
+ /**
+ * Create Meta instance for a term
+ */
public static function forTerm(int $id): self
{
return new self($id, 'term');
}
+ /**
+ * Create Meta instance for a user
+ */
public static function forUser(int $id): self
{
return new self($id, 'user');
}
+ /**
+ * Create Meta instance for options
+ */
public static function forOptions(?string $baseKey = null): self
{
$instance = new self($baseKey, 'options');
@@ -48,6 +71,129 @@
return $instance;
}
+ /**
+ * Bulk load multiple posts with optional field preloading
+ * @return array<int, Meta>
+ */
+ public static function bulkForPosts(array $ids, array $preloadFields = []): array
+ {
+ return self::bulkFor($ids, 'post', $preloadFields);
+ }
+
+ /**
+ * Bulk load multiple terms with optional field preloading
+ * @return array<int, Meta>
+ */
+ public static function bulkForTerms(array $ids, array $preloadFields = []): array
+ {
+ return self::bulkFor($ids, 'term', $preloadFields);
+ }
+
+ /**
+ * Bulk load multiple users with optional field preloading
+ * @return array<int, Meta>
+ */
+ public static function bulkForUsers(array $ids, array $preloadFields = []): array
+ {
+ return self::bulkFor($ids, 'user', $preloadFields);
+ }
+
+ /**
+ * Generic bulk loader
+ * @return array<int, Meta>
+ */
+ protected static function bulkFor(array $ids, string $type, array $preloadFields = []): array
+ {
+ if (empty($ids)) {
+ return [];
+ }
+
+ $metas = [];
+
+ // Create instances
+ foreach ($ids as $id) {
+ $metas[$id] = new self($id, $type);
+ }
+
+ // Preload fields if specified
+ if (!empty($preloadFields)) {
+ self::bulkPreload($metas, $type, $preloadFields);
+ }
+
+ return $metas;
+ }
+
+ /**
+ * Bulk preload fields for multiple Meta instances
+ * @param Meta[] $metas
+ */
+ protected static function bulkPreload(array $metas, string $objectType, array $fields): void
+ {
+ if (empty($metas) || empty($fields)) {
+ return;
+ }
+
+ $ids = array_keys($metas);
+ $values = Storage::getBulkValues($ids, $objectType, $fields);
+
+ // Distribute results to Meta instances
+ foreach ($values as $id => $fieldValues) {
+ if (!isset($metas[$id])) {
+ continue;
+ }
+
+ $meta = $metas[$id];
+ foreach ($fieldValues as $name => $value) {
+ $config = $meta->config($name) ?? ['type' => 'text'];
+ $field = new Field($name, $value, $config);
+ $meta->item()->setField($field);
+ }
+ }
+ }
+
+ /**
+ * Save multiple Meta instances efficiently
+ * @param Meta[] $metas
+ * @return array<int, bool>
+ */
+ public static function saveBulk(array $metas, bool $updateTimestamp = true): array
+ {
+ // Validate all first
+ $invalid = [];
+ foreach ($metas as $id => $meta) {
+ if (!$meta->isValid()) {
+ $invalid[$id] = $meta->getErrors();
+ }
+ }
+
+ if (!empty($invalid)) {
+ JVB()->error()->log('meta', 'Bulk save has validation errors', [
+ 'invalid_items' => $invalid
+ ], 'warning');
+ }
+
+ // Filter to only valid metas
+ $validMetas = array_filter($metas, fn($m) => $m->isValid());
+
+ // Check overrides before bulk save
+ foreach ($validMetas as $meta) {
+ foreach ($meta->item()->getDirtyFields() as $field) {
+ if ($meta->checkOverrides($field)) {
+ $field->markClean();
+ }
+ }
+ }
+
+ $results = Storage::saveBulk($validMetas, $updateTimestamp);
+
+ // Mark invalid ones as failed
+ foreach ($invalid as $id => $errors) {
+ $results[$id] = false;
+ }
+
+ return $results;
+ }
+
// ─────────────────────────────────────────────────────────────
// Constructor
// ─────────────────────────────────────────────────────────────
@@ -55,8 +201,8 @@
public function __construct(int|string|null $id, string $type)
{
$this->storage = new Storage();
- $this->validator = new MetaValidator();
- $this->sanitizer = new MetaSanitizer();
+ $this->validator = new Validator();
+ $this->sanitizer = new Sanitizer();
$this->typeManager = new MetaTypeManager();
$this->item = $this->buildItem($id, $type);
@@ -118,7 +264,7 @@
public function __isset(string $name): bool
{
- return $this->item->hasField($name);
+ return $this->item->hasField($name) || isset($this->computed[$name]);
}
// ─────────────────────────────────────────────────────────────
@@ -130,6 +276,11 @@
*/
public function get(string $name): mixed
{
+ // Check computed fields first
+ if (isset($this->computed[$name])) {
+ return ($this->computed[$name])($this);
+ }
+
// Return from loaded field if exists
if ($field = $this->item->getField($name)) {
return $field->get();
@@ -146,7 +297,7 @@
}
/**
- * Set a field value (validates & sanitizes)
+ * Set a field value (validates & sanitizes by default)
*/
public function set(string $name, mixed $value): self
{
@@ -157,8 +308,6 @@
$config = ['type' => 'text', 'name' => $name];
}
- $config['name'] = $name;
-
// Validate
if ($this->autoValidate && !$this->validator->validate($value, $config)) {
$field = $this->item->getField($name) ?? new Field($name, $value, $config);
@@ -174,17 +323,27 @@
// Get or create field
$field = $this->item->getField($name);
+ $oldValue = null;
if ($field) {
+ $oldValue = $field->value;
$field->set($value);
} else {
- // Need to load original to track dirty state
+ // Load original to track dirty state
$original = $this->storage->get($this->item, $name);
+ $oldValue = $original;
$field = new Field($name, $original, $config);
$field->set($value);
$this->item->setField($field);
}
+ // Fire change callbacks
+ if (isset($this->onChangeCallbacks[$name]) && $oldValue !== $value) {
+ foreach ($this->onChangeCallbacks[$name] as $callback) {
+ $callback($value, $oldValue, $this);
+ }
+ }
+
return $this;
}
@@ -259,12 +418,36 @@
return $result;
}
+ /**
+ * Delete multiple field values
+ */
+ public function deleteAll(array $names): array
+ {
+ $results = [];
+ foreach ($names as $name) {
+ $results[$name] = $this->delete($name);
+ }
+ return $results;
+ }
+
+ // ─────────────────────────────────────────────────────────────
+ // Repeater Access
+ // ─────────────────────────────────────────────────────────────
+
+ /**
+ * Get repeater accessor for fluent repeater operations
+ */
+ public function repeater(string $name): Repeater
+ {
+ return new Repeater($this, $name);
+ }
+
// ─────────────────────────────────────────────────────────────
// Utility Methods
// ─────────────────────────────────────────────────────────────
/**
- * Get all dirty (changed) fields
+ * Get all dirty (changed) field values
*/
public function getDirty(): array
{
@@ -287,9 +470,7 @@
*/
public function reset(): self
{
- foreach ($this->item->fields as $field) {
- $field->reset();
- }
+ $this->item->resetAll();
return $this;
}
@@ -332,7 +513,17 @@
}
/**
- * Get the underlying item (for rendering, etc)
+ * Re-enable validation and sanitization
+ */
+ public function withDefaults(): self
+ {
+ $this->autoValidate = true;
+ $this->autoSanitize = true;
+ return $this;
+ }
+
+ /**
+ * Get the underlying Item
*/
public function item(): Item
{
@@ -355,11 +546,77 @@
return $this->item->fieldConfigs;
}
+ /**
+ * Get item ID
+ */
+ public function id(): int|string|null
+ {
+ return $this->item->id;
+ }
+
+ /**
+ * Get object type (post, term, user, options)
+ */
+ public function objectType(): string
+ {
+ return $this->item->objectType;
+ }
+
+ /**
+ * Get content type (tattoo, artist, etc)
+ */
+ public function contentType(): ?string
+ {
+ return $this->item->contentType;
+ }
+
+ /**
+ * Eager load all fields
+ */
+ public function eager(): self
+ {
+ $this->getAll();
+ return $this;
+ }
+
+ /**
+ * Convert loaded fields to array
+ */
+ public function toArray(): array
+ {
+ return $this->item->toArray();
+ }
+
+ // ─────────────────────────────────────────────────────────────
+ // Event Callbacks
+ // ─────────────────────────────────────────────────────────────
+
+ /**
+ * Register callback for field changes
+ */
+ public function onChange(string $field, callable $callback): self
+ {
+ $this->onChangeCallbacks[$field][] = $callback;
+ return $this;
+ }
+
+ /**
+ * Register computed/virtual field
+ */
+ public function computed(string $name, callable $getter): self
+ {
+ $this->computed[$name] = $getter;
+ return $this;
+ }
+
// ─────────────────────────────────────────────────────────────
// Protected Helpers
// ─────────────────────────────────────────────────────────────
- protected function checkOverrides(Field $field): bool
+ /**
+ * Check for field update overrides
+ */
+ public function checkOverrides(Field $field): bool
{
$name = $field->name;
$type = $field->type();
@@ -384,6 +641,9 @@
return false;
}
+ /**
+ * Get default value for a field type
+ */
protected function getDefaultValue(string $name): mixed
{
$config = $this->item->getFieldConfig($name);
@@ -396,4 +656,5 @@
default => '',
};
}
+
}
diff --git a/inc/meta/MetaForm.php b/inc/meta/MetaForm.php
deleted file mode 100644
index ea8f614..0000000
--- a/inc/meta/MetaForm.php
+++ /dev/null
@@ -1,1781 +0,0 @@
-<?php
-namespace JVBase\meta;
-
-use JVBase\forms\TaxonomySelector;
-use JVBase\forms\PostSelector;
-use DateTime;
-
-if (!defined('ABSPATH')) {
- exit;
-}
-
-/**
- * Renders the form fields for managing the meta
- */
-class MetaForm
-{
- protected int $max_file_size = 5242880;
- protected ?MetaTypeManager $type_manager = null;
-
- /* ========== MAIN RENDER METHOD ========== */
- public function return(string $name, mixed $value, array $config, bool $showHidden = false)
- {
- return $this->render($name, $value, $config, $showHidden, true);
- }
- public function render(string $name, mixed $value, array $config, bool $showHidden = false, bool $return = false): mixed
- {
- $out = '';
- if (jvbCheck('hidden', $config) && !$showHidden) {
- return $out;
- }
-
- if (!array_key_exists('type', $config)) {
- return $out;
- }
- if (!$value) {
- $value = $this->getDefaultValue($config['type']);
- }
-
- // Handle hidden display type
- if (array_key_exists('display', $config) && $config['display'] === 'hidden') {
- $out = '<input type="hidden" name="' . $name . '" value="' . $value . '" />';
- if (!$return) {
- echo $out;
- }
- return $out;
- }
-
- ob_start();
-
- // Try custom function overrides first
- $type = array_map('ucfirst', explode('_', $config['type']));
- $type = implode('', $type);
- $method = 'render' . $type . 'Field';
-
- $nameTemp = implode('', array_map('ucfirst', explode('_', $name)));
- $nameMethod = 'render' . $nameTemp . 'Field';
-
- if (function_exists($nameMethod)) {
- call_user_func($nameMethod, $value, $config);
- } elseif (function_exists($method)) {
- call_user_func($method, $value, $config);
- } elseif (method_exists($this, $method)) {
- $this->$method($name, $value, $config);
- }
-
- $out = ob_get_clean();
-
- do_action('jvbRenderFormField', $name, $config, $value);
- $out = apply_filters('jvbFilterRenderFormField', $out, $name, $config, $value);
-
- if (!$return) {
- echo $out;
- }
- return $out;
- }
-
- public function getDefaultValue(string $type):mixed {
- if (!$this->type_manager) {
- $this->type_manager = new MetaTypeManager();
- }
- return match ($this->type_manager->getMetaType($type)) {
- 'object', 'array' => [],
- 'boolean' => false,
- 'integer' => 0,
- default => '',
- };
- }
-
- /* ========== HELPER METHODS ========== */
-
- /**
- * Prepare common field data
- */
- protected function prepareFieldData(string $name, mixed $value, array $field): array
- {
- return [
- 'name' => array_key_exists('group', $field) ? $field['group'] . '::' . $name : $name,
- 'value' => isset($field['value']) ? $field['value'] : $value,
- 'id' => (array_key_exists('base', $field) ? esc_attr($field['base']) : '') . esc_attr($name),
- ];
- }
-
- /**
- * Build common HTML attributes for inputs
- */
- protected function buildInputAttributes(string $name, array $field): string
- {
- $attrs = [];
-
- // Conditional rendering
- if (array_key_exists('condition', $field)) {
- $attrs['conditional'] = $this->handleConditionalField($field);
- }
-
- // Accessibility
- if (!empty($field['description'])) {
- $attrs['aria-describedby'] = $name . '-help';
- }
-
- // Common attributes
- $common = ['placeholder', 'autocomplete', 'pattern', 'minlength', 'maxlength', 'min', 'max', 'step'];
- foreach ($common as $attr) {
- if (array_key_exists($attr, $field)) {
- $attrs[$attr] = $field[$attr];
- }
- }
-
- // Required
- if (!empty($field['required'])) {
- $attrs['required'] = true;
- }
-
- // Build attribute string
- $attrString = '';
- foreach ($attrs as $key => $val) {
- if ($key === 'conditional') {
- $attrString .= ' ' . $val; // Already formatted
- } elseif ($val === true) {
- $attrString .= ' ' . $key;
- } else {
- $attrString .= ' ' . $key . '="' . esc_attr($val) . '"';
- }
- }
-
- return $attrString;
- }
-
- /**
- * Build validation data attributes
- */
- protected function buildValidationAttributes(array $field): string
- {
- $attrs = [];
-
- if (!empty($field['pattern'])) {
- $attrs['data-pattern'] = $field['pattern'];
- }
-
- if (!empty($field['validate'])) {
- $attrs['data-validate'] = $field['validate'];
- }
-
- if (isset($field['min'])) {
- $attrs['data-min'] = $field['min'];
- }
-
- if (isset($field['max'])) {
- $attrs['data-max'] = $field['max'];
- }
-
- if (isset($field['minlength'])) {
- $attrs['data-minlength'] = $field['minlength'];
- }
-
- if (isset($field['maxlength'])) {
- $attrs['data-maxlength'] = $field['maxlength'];
- }
-
- if (!empty($field['validation_message'])) {
- $attrs['data-validation-message'] = $field['validation_message'];
- }
-
- $attrs['data-type'] = $field['type'];
-
- $attrString = '';
- foreach ($attrs as $key => $val) {
- $attrString .= ' ' . $key . '="' . esc_attr($val) . '"';
- }
-
- return $attrString;
- }
-
- /* ========== GENERIC FIELD WRAPPER ========== */
-
- /**
- * Render a standard input field with validation wrapper
- */
- protected function renderStandardInput(string $name, mixed $value, array $field, string $inputType = 'text'): void
- {
- $data = $this->prepareFieldData($name, $value, $field);
- $inputAttrs = $this->buildInputAttributes($name, $field);
- $validationAttrs = $this->buildValidationAttributes($field);
- $conditional = array_key_exists('condition', $field) ? $this->handleConditionalField($field) : '';
-
- $pattern = array_key_exists('pattern', $field) ? $field['pattern'] : '';
- $customData = '';
- if (array_key_exists('data', $field) && !empty($field['data'])) {
- foreach ($field['data'] as $key => $v) {
- $customData .= ($v === '') ? ' data-' . $key : ' data-' . $key . '="' . $v . '"';
- }
- }
- ?>
- <div class="field <?= esc_attr($field['type']) ?> <?= esc_attr($name) ?>"
- <?= $conditional ?>
- data-field="<?= esc_attr($name) ?>"
- data-field-type="<?=esc_attr($field['type'])?>"
- <?= $validationAttrs ?>>
-
- <?php $this->renderLabel($name, $field); ?>
-
- <div class="field-input-wrapper">
- <input
- type="<?= esc_attr($inputType) ?>"
- id="<?= esc_attr($data['id']) ?>"
- name="<?= esc_attr($data['name']) ?>"
- value="<?= esc_attr($data['value']) ?>"
- <?= $inputAttrs ?>
- <?= $customData?>
- <?= $pattern?>
- >
- <span class="validation-icon success" hidden aria-hidden="true">
- <?= jvbIcon('check-circle') ?>
- </span>
- <span class="validation-icon error" hidden aria-hidden="true">
- <?= jvbIcon('x-circle') ?>
- </span>
- </div>
-
- <span class="validation-message" hidden role="alert"></span>
-
- <?php $this->renderHintAndDescription($field, $name); ?>
- </div>
- <?php
- }
-
- /**
- * Render field label with optional character count
- */
- protected function renderLabel(string $name, array $field): void
- {
- ?>
- <label for="<?= esc_attr($name) ?>">
- <?= esc_html($field['label']) ?>
- <?php if (!empty($field['required'])) : ?>
- <span class="required" aria-label="required">*</span>
- <?php endif; ?>
- <?php if (!empty($field['limit'])) : ?>
- <span class="char-count" data-limit="<?= esc_attr($field['limit']) ?>">
- <span class="current">0</span>/<?= esc_attr($field['limit']) ?>
- </span>
- <?php endif; ?>
- </label>
- <?php
- }
-
- /**
- * Render hint and description
- */
- protected function renderHintAndDescription(array $field, string $name): void
- {
- if (!empty($field['hint'])) {
- $this->renderHint($field['hint']);
- }
-
- if (!empty($field['description'])) {
- $this->renderDescription($field['description'], $name);
- }
- }
-
- protected function renderHint(string $hint): void
- {
- ?>
- <span class="hint"><?= esc_html($hint) ?></span>
- <?php
- }
-
- protected function renderDescription(string $description, string $name): void
- {
- ?>
- <p class="description" id="<?= esc_attr($name) ?>-help">
- <?= wp_kses_post($description) ?>
- </p>
- <?php
- }
-
- /* ========== SIMPLE INPUT FIELD TYPES ========== */
-
- public function renderTextField(string $name, mixed $value, array $field): void
- {
- $this->renderStandardInput($name, $value, $field, $field['subtype'] ?? 'text');
- }
-
- public function renderEmailField(string $name, mixed $value, array $field): void
- {
- $field['validate'] = 'email'; // Auto-add email validation
- $this->renderStandardInput($name, $value, $field, 'email');
- }
-
- private function renderUrlField(string $name, mixed $value, array $field): void
- {
- $field['validate'] = 'url'; // Auto-add URL validation
- $this->renderStandardInput($name, $value, $field, 'url');
- }
-
- private function renderTelField(string $name, mixed $value, array $field): void
- {
- $field['validate'] = 'phone'; // Auto-add phone validation
- $this->renderStandardInput($name, $value, $field, 'tel');
- }
-
- private function renderDateField(string $name, mixed $value, array $field): void
- {
- $format = !empty($field['format']) ? $field['format'] : 'Y-m-d';
-
- // Format the date if we have a value
- if (!empty($value)) {
- $date = DateTime::createFromFormat($format, $value);
- if ($date) {
- $value = $date->format('Y-m-d'); // HTML date input requires Y-m-d format
- }
- }
-
- $this->renderStandardInput($name, $value, $field, 'date');
- }
-
- private function renderTimeField(string $name, mixed $value, array $field): void
- {
- $this->renderStandardInput($name, $value, $field, 'time');
- }
-
- private function renderDatetimeField(string $name, mixed $value, array $field): void
- {
- $this->renderStandardInput($name, $value, $field, 'datetime-local');
- }
-
- /* ========== TEXTAREA FIELD ========== */
-
- public function renderTextareaField(string $name, mixed $value, array $field): void
- {
- $data = $this->prepareFieldData($name, $value, $field);
- $inputAttrs = $this->buildInputAttributes($name, $field);
- $validationAttrs = $this->buildValidationAttributes($field);
- $conditional = array_key_exists('condition', $field) ? $this->handleConditionalField($field) : '';
-
- $rows = isset($field['rows']) ? (int)$field['rows'] : 4;
- $quill = (array_key_exists('quill', $field) && $field['quill'] == true) ? ' data-editor="true"' : '';
-
- if ($quill !== '') {
- $allowImages = array_key_exists('allowImage', $field);
- $quill .= ($allowImages) ? ' data-allowimage="true"' : ' data-allowimage="false"';
- }
-
- // Handle array values
- if (is_array($value)) {
- $value = implode(', ', $value);
- }
-
- ?>
- <div class="field <?= esc_attr($field['type']) ?> <?= esc_attr($name) ?>"
- <?= $conditional ?>
- data-field="<?= esc_attr($name) ?>"
- data-field-type="<?=esc_attr($field['type'])?>"
- <?= $validationAttrs ?>>
-
- <?php $this->renderLabel($name, $field); ?>
-
- <div class="field-input-wrapper">
- <textarea
- id="<?= esc_attr($data['id']) ?>"
- name="<?= esc_attr($data['name']) ?>"
- rows="<?= esc_attr($rows) ?>"
- <?= $quill ?>
- <?= $inputAttrs ?>
- ><?= esc_textarea($data['value']) ?></textarea>
- <span class="validation-icon success" hidden aria-hidden="true">
- <?= jvbIcon('check-circle') ?>
- </span>
- <span class="validation-icon error" hidden aria-hidden="true">
- <?= jvbIcon('x-circle') ?>
- </span>
- </div>
-
- <span class="validation-message" hidden role="alert"></span>
-
- <?php $this->renderHintAndDescription($field, $name); ?>
- </div>
- <?php
- }
-
- /* ========== NUMBER FIELD ========== */
-
- private function renderNumberField(string $name, mixed $value, array $field): void
- {
- $data = $this->prepareFieldData($name, $value, $field);
- $conditional = array_key_exists('condition', $field) ? $this->handleConditionalField($field) : '';
- $validationAttrs = $this->buildValidationAttributes($field);
- $describedBy = (!empty($field['description'])) ? ' aria-describedby="' . $name . '-help"' : '';
-
- $min = isset($field['min']) ? (float)$field['min'] : 0;
- $max = isset($field['max']) ? (float)$field['max'] : 100;
- $step = isset($field['step']) ? (float)$field['step'] : 1;
-
- // Handle custom data attributes
- $customData = '';
- if (array_key_exists('data', $field) && !empty($field['data'])) {
- foreach ($field['data'] as $key => $v) {
- $customData .= ($v === '') ? ' data-' . $key : ' data-' . $key . '="' . $v . '"';
- }
- }
-
- if (empty($value)) {
- $value = $field['default'] ?? 0;
- }
-
- $autocomplete = (array_key_exists('autocomplete', $field)) ? ' autocomplete="' . $field['autocomplete'] . '"' : '';
-
- ?>
- <div class="field <?= esc_attr($field['type']) ?> <?= esc_attr($name) ?> row"
- <?= $conditional ?>
- data-field="<?= esc_attr($name) ?>"
- data-field-type="<?=esc_attr($field['type'])?>"
- <?= $validationAttrs ?>>
-
- <?php $this->renderLabel($name, $field); ?>
-
- <div class="quantity" <?= $customData ?>>
- <button type="button"
- class="decrease"
- title="<?= array_key_exists('remove', $field) ? $field['remove'] : 'Decrease amount' ?>"
- aria-label="Decrease <?= esc_attr($field['label']) ?>">
- <?= jvbIcon('minus-square') ?>
- </button>
-
- <input type="number"
- id="<?= esc_attr($data['id']) ?>"
- name="<?= esc_attr($data['name']) ?>"
- value="<?= esc_attr($value) ?>"
- min="<?= esc_attr($min) ?>"
- max="<?= esc_attr($max) ?>"
- step="<?= esc_attr($step) ?>"
- class="quantity-input"
- <?= $describedBy ?>
- <?= $autocomplete ?>
- <?= !empty($field['required']) ? 'required' : '' ?>>
-
- <button type="button"
- class="increase"
- title="<?= array_key_exists('add', $field) ? $field['add'] : 'Increase amount' ?>"
- aria-label="Increase <?= esc_attr($field['label']) ?>">
- <?= jvbIcon('plus-square') ?>
- </button>
- </div>
-
- <?php $this->renderHintAndDescription($field, $name); ?>
- </div>
- <?php
- }
-
- /* ========== SELECT, RADIO, CHECKBOX FIELDS ========== */
-
- private function renderSelectField(string $name, mixed $value, array $field): void
- {
- $data = $this->prepareFieldData($name, $value, $field);
- $inputAttrs = $this->buildInputAttributes($name, $field);
- $validationAttrs = $this->buildValidationAttributes($field);
- $conditional = array_key_exists('condition', $field) ? $this->handleConditionalField($field) : '';
-
- ?>
- <div class="field <?= esc_attr($field['type']) ?> <?= esc_attr($name) ?>"
- <?= $conditional ?>
- data-field="<?= esc_attr($name) ?>"
- data-field-type="<?=esc_attr($field['type'])?>"
- <?= $validationAttrs ?>>
-
- <?php $this->renderLabel($name, $field); ?>
-
- <div class="field-input-wrapper">
- <select
- id="<?= esc_attr($data['id']) ?>"
- name="<?= esc_attr($data['name']) ?>"
- <?= $inputAttrs ?>>
- <?php foreach ($field['options'] as $key => $label) : ?>
- <option value="<?= esc_attr($key) ?>" <?php selected($value, $key); ?>>
- <?= esc_html($label) ?>
- </option>
- <?php endforeach; ?>
- </select>
- <span class="validation-icon success" hidden aria-hidden="true">
- <?= jvbIcon('check-circle') ?>
- </span>
- <span class="validation-icon error" hidden aria-hidden="true">
- <?= jvbIcon('x-circle') ?>
- </span>
- </div>
-
- <span class="validation-message" hidden role="alert"></span>
-
- <?php $this->renderHintAndDescription($field, $name); ?>
- </div>
- <?php
- }
-
- private function renderRadioField(string $name, mixed $value, array $field): void
- {
- $data = $this->prepareFieldData($name, $value, $field);
- $validationAttrs = $this->buildValidationAttributes($field);
- $conditional = array_key_exists('condition', $field) ? $this->handleConditionalField($field) : '';
-
- ?>
- <div class="field <?= esc_attr($field['type']) ?> <?= esc_attr($name) ?>"
- <?= $conditional ?>
- data-field="<?= esc_attr($name) ?>"
- data-field-type="<?=esc_attr($field['type'])?>"
- <?= $validationAttrs ?>>
-
- <fieldset>
- <legend><?= esc_html($field['label']) ?>
- <?php if (!empty($field['required'])) : ?>
- <span class="required" aria-label="required">*</span>
- <?php endif; ?>
- </legend>
-
- <?php foreach ($field['options'] as $key => $label) : ?>
- <input
- type="radio"
- id="<?= esc_attr($data['name']) ?>-<?= esc_attr($key)?>"
- name="<?= esc_attr($data['name']) ?>"
- value="<?= esc_attr($key) ?>"
- <?php checked($value, $key); ?>
- <?= !empty($field['required']) ? 'required' : '' ?>
- >
- <label class="radio-option" for="<?= esc_attr($data['name']) ?>-<?= esc_attr($key)?>">
- <span><?= $label ?></span>
- </label>
- <?php endforeach; ?>
- </fieldset>
-
- <span class="validation-message" hidden role="alert"></span>
-
- <?php $this->renderHintAndDescription($field, $name); ?>
- </div>
- <?php
- }
-
- private function renderCheckboxField(string $name, mixed $value, array $field): void
- {
- $data = $this->prepareFieldData($name, $value, $field);
- $validationAttrs = $this->buildValidationAttributes($field);
- $conditional = array_key_exists('condition', $field) ? $this->handleConditionalField($field) : '';
-
- if (!is_array($value)) {
- $value = !empty($value) ? [$value] : [];
- }
-
- ?>
- <div class="field <?= esc_attr($field['type']) ?> <?= esc_attr($name) ?>"
- <?= $conditional ?>
- data-field="<?= esc_attr($name) ?>"
- data-field-type="<?=esc_attr($field['type'])?>"
- <?= $validationAttrs ?>>
-
- <fieldset>
- <legend><?= esc_html($field['label']) ?>
- <?php if (!empty($field['required'])) : ?>
- <span class="required" aria-label="required">*</span>
- <?php endif; ?>
- </legend>
-
- <?php foreach ($field['options'] as $key => $label) : ?>
- <input
- type="checkbox"
- id="<?= esc_attr($data['name']) ?>-<?= esc_attr($key)?>"
- name="<?= esc_attr($data['name']) ?>[]"
- value="<?= esc_attr($key) ?>"
- <?php checked(in_array($key, $value)); ?>
- >
- <label class="checkbox-option" for="<?= esc_attr($data['name']) ?>-<?= esc_attr($key)?>">
- <span><?= esc_html($label) ?></span>
- </label>
- <?php endforeach; ?>
- </fieldset>
-
- <span class="validation-message" hidden role="alert"></span>
-
- <?php $this->renderHintAndDescription($field, $name); ?>
- </div>
- <?php
- }
-
- private function renderTrueFalseField(string $name, mixed $value, array $field): void
- {
- $data = $this->prepareFieldData($name, $value, $field);
- $validationAttrs = $this->buildValidationAttributes($field);
- $conditional = array_key_exists('condition', $field) ? $this->handleConditionalField($field) : '';
- $describedBy = (!empty($field['description'])) ? ' aria-describedby="' . $name . '-help"' : '';
-
- ?>
- <div class="field true-false <?= esc_attr($name) ?> row btw"
- <?= $conditional ?>
- data-field="<?= esc_attr($name) ?>"
- data-field-type="<?=esc_attr($field['type'])?>"
- <?= $validationAttrs ?>>
-
- <label class="toggle-switch row" <?= $describedBy ?>>
- <input
- type="checkbox"
- name="<?= esc_attr($data['name']) ?>"
- value="1"
- <?= ($value) ? ' checked' : '' ?>
- <?= !empty($field['required']) ? 'required' : '' ?>
- >
- <div class="slider"></div>
- <span class="toggle-label">
- <?php if (!empty($field['required'])) : ?>
- <span class="required" aria-label="required">*</span>
- <?php endif; ?>
-
- <?= esc_html($field['label']) ?></span>
- </label>
- <span class="validation-message" hidden role="alert"></span>
- <?php $this->renderHintAndDescription($field, $name); ?>
- </div>
- <?php
- }
-
-
-
- /* ========== REPEATER FIELD ========== */
-
- private function renderRepeaterField(string $name, mixed $value, array $field):void
- {
- $values = is_array($value) ? $value : array();
-
- $conditional = $this->handleConditionalField($field);
- $row_label = isset($field['row_label']) ? $field['row_label'] : '';
- $rowTitle = (array_key_exists('new_row', $field)) ? $field['new_row'] : 'New Item';
- if (array_key_exists('group', $field)) {
- $name = $field['group'].'::'.$name;
- }
- $describedBy = (!empty($field['description'])) ? ' aria-describedby="'.$name.'-help"' : '';
- ?>
- <div class="field repeater <?=$name?>"
- data-field="<?= esc_attr($name); ?>"
- data-field-type="<?=esc_attr($field['type'])?>"
- <?= $describedBy ?>
- <?= $row_label ? 'data-label="' . esc_attr($row_label) . '"' : ''; ?>
- <?=$conditional?>>
- <?php
- if (!array_key_exists('label', $field)) {
- error_log('No label for: '.print_r($name, true));
- }
- ?>
- <h3><?= esc_html($field['label']); ?></h3>
-
-
- <div class="repeater-items">
- <?php
- if (!empty($values)) {
- foreach ($values as $index => $row) {
- $this->renderRepeaterRow($field['fields'], $row, $index, $name, $rowTitle);
- }
- }
- ?>
- </div>
-
- <template class="<?=uniqid('repeaterRow')?>">
- <?php $this->renderRepeaterRow($field['fields'], array(), '', '', $rowTitle); ?>
- </template>
-
- <button type="button" class="add-repeater-row">
- <?= jvbIcon('plus', ['title'=> 'Add']); ?> <?= (array_key_exists('add_label', $field)) ? $field['add_label'] : 'Add Item'; ?>
- </button>
- <?php if (array_key_exists('hint', $field)) { $this->renderHint($field['hint']); } ?>
- <?php if (array_key_exists('description', $field)) { $this->renderDescription($field['description'], $name); } ?>
- </div>
- <?php
- }
-
- private function renderRepeaterRow(array $fields, array $values, int|string $index, string $base_name, string $rowTitle = 'New Item'):void
- {
- $display_number = (is_string($index)) ? $index : ($index + 1);
- ?>
- <div class="repeater-row" data-index="<?= esc_attr($index); ?>">
- <details <?= (is_string($index)) ? 'open' : ''; ?>>
- <summary class="repeater-row-header row btw">
- <span class="drag-handle"><?= jvbIcon('dots-six-vertical'); ?></span>
- <span class="row-number">#<?= esc_html($display_number); ?></span>
- <span class="row-title"><?= esc_html($this->getRowTitle($fields, $values, $rowTitle)); ?></span>
- <button type="button" class="remove-row" title="Remove">
- <?= jvbIcon('trash', ['title'=>'Remove']); ?>
- </button>
- </summary>
- <div class="repeater-row-content">
- <?php
- foreach ($fields as $slug => $field) :
- if ($base_name === '') {
- $field_name = $slug;
- } else {
- $field_name = sprintf('%s:%s:%s', $base_name, $index, $slug);
- }
- $field_value = isset($values[$slug]) ? $values[$slug] : '';
- $name = $field_name;
- $this->render($name, $field_value, $field);
- endforeach;
- ?>
- </div>
- </details>
- </div>
- <?php
- }
-
- private function getRowTitle(array $fields, array $values, string $rowTitle): string
- {
- // Try to find the first text field or textarea value to use as title
- foreach ($fields as $slug => $field) {
- if (in_array($field['type'], ['text', 'textarea']) &&
- isset($values[$slug]) &&
- !empty($values[$slug])) {
- return $values[$slug];
- }
- }
- return $rowTitle;
- }
-
- /* ========== GROUP FIELD ========== */
-
- protected function renderGroupField(string $name, mixed $value, array $field): void
- {
- if (!array_key_exists('fields', $field) || empty($field['fields'])) {
- error_log('No fields to render');
- return;
- }
-
-
- $values = is_array($value) ? $value : [];
- $original = $name;
-
- if (array_key_exists('group', $field)) {
- $name = $field['group'] . '::' . $name;
- }
-
- $hidden = (array_key_exists('mode', $field) && $field['mode'] === 'hidden');
-
- if ($hidden) {
- // Simplified render for hidden groups
- $this->renderGroupFields($name, $values, $field);
- return;
- }
-
- // Standard fieldset render
- $conditional = $this->handleConditionalField($field);
- $validationAttrs = $this->buildValidationAttributes($field);
- $describedBy = (!empty($field['description'])) ? ' aria-describedby="' . $name . '-help"' : '';
- $fieldset = (array_key_exists('wrap', $field) && $field['wrap'] === 'details') ? 'details' : 'fieldset';
- $legend = (array_key_exists('wrap', $field) && $field['wrap'] === 'details') ? 'summary' : 'legend';
- ?>
- <<?= $fieldset?> class="field group <?= esc_attr($name) ?>"
- <?= $conditional ?>
- data-field="<?= esc_attr($name) ?>"
- data-field-type="<?=esc_attr($field['type'])?>"
- <?= $validationAttrs ?>
- <?= $describedBy ?>>
- <<?=$legend?>><?= esc_html($field['label']) ?></<?=$legend?>>
-
- <?php $this->renderHintAndDescription($field, $name); ?>
-
- <div class="group-fields <?= esc_attr($original) ?>">
- <?php $this->renderGroupFields($name, $values, $field); ?>
- </div>
-
- <span class="validation-message" hidden role="alert"></span>
- </<?= $fieldset?>>
- <?php
- }
-
- /**
- * Render individual fields within a group
- * Reusable for both standard and hidden group modes
- */
- private function renderGroupFields(string $groupName, array $values, array $field): void
- {
- foreach ($field['fields'] as $field_name => $config) {
- // Set the group context for proper field naming
- if (!array_key_exists('wrap', $field) || $field['wrap'] !== 'details') {
- $config['group'] = $groupName;
- }
-
- // Get the value for this specific field
- $field_value = $values[$field_name] ?? '';
-
- // Handle conditional fields within the group
- if (isset($config['condition'])) {
- $condition_field = $config['condition']['field'];
- if (!str_contains($condition_field, '::')) {
- $config['condition']['field'] = $groupName . '::' . $condition_field;
- }
- }
-
- $this->render($field_name, $field_value, $config);
- }
- }
-
- /* ========== UPLOAD FIELD ========== */
- private function renderGalleryField(string $name, mixed $value, array $field):void
- {
- $field['multiple'] = true;
- $this->renderUploadField($name, $value, $field);
- }
- private function renderUploadField(string $name, mixed $value, array $field): void
- {
- $defaultConfig = [
- //File Type
- 'subtype' => 'image', // 'image', 'video', 'document', 'any'
- 'accepted_types' => null, // null = use subtype defaults, or array of specific MIME types
- //Upload Behaviour
- 'multiple' => false, // Single or multiple uploads
- 'limit' => 0, // Max number of uploads (0 = unlimited)
- 'mode' => 'direct', // 'direct' or 'selection'
- //Destination
- 'destination' => 'meta', // 'meta', 'post', 'post_group'
- //Processing Options
- 'max_size' => null, // Override default size limits
- 'convert' => 'webp', // Image conversion format
- 'quality' => 90, // Conversion quality
- 'create_thumbnails' => true,
- ];
- $config = array_merge($defaultConfig, $field);
-
- // Validate destination config
- if (in_array($config['destination'], ['post', 'post_group']) && empty($config['content'])) {
- error_log("Upload field '{$name}' has destination '{$config['destination']}' but no content defined");
- return;
- }
-
- // Get accepted types
- $acceptedTypes = $this->getAllowedTypes($config);
-
- // Build accept attribute for input
- $acceptExtensions = $this->getMimeExtensions($acceptedTypes);
- $acceptAttr = implode(',', $acceptedTypes);
-
- // Determine field attributes
- $subtype = $config['subtype'] ?? 'image';
- $multiple = $config['multiple'] ?? false;
- $limit = $config['limit'] ?? 0;
- $mode = $config['mode'] ?? 'direct';
- $destination = $config['destination'];
-
- // Get existing attachments
- $attachmentIds = $this->parseAttachmentIds($value);
-
- // Determine field type for UI
- $fieldType = $multiple ? 'gallery' : 'single';
-
- // Build data attributes
- $dataAttrs = [
- 'data-field' => $name,
- 'data-upload-field' => '',
- 'data-mode' => $mode,
- 'data-type' => $fieldType,
- 'data-subtype' => $subtype,
- 'data-destination' => $destination,
- ];
- if (!empty($field['content'])) {
- $dataAttrs['data-content'] = $field['content'];
- }
- if ($limit > 0) {
- $dataAttrs['data-limit'] = $limit;
- }
-
- // Build data attributes
- $conditional = $this->handleConditionalField($field);
- $describedBy = !empty($field['description']) ? ' aria-describedby="' . esc_attr($name) . '-help"' : '';
-
- if (!empty($field['group'])) {
- $name = $field['group'] . '::' . $name;
- }
-
- // Convert data attributes to string
- $dataAttrString = '';
- foreach ($dataAttrs as $attr => $val) {
- $dataAttrString .= ' ' . $attr . ($val !== '' ? '="' . esc_attr($val) . '"' : '');
- }
- ?>
- <div class="field upload <?= esc_attr($name) ?>"
- data-field="<?=esc_attr($name)?>"
- data-field-type="upload"
- <?= $dataAttrString ?>
- <?= $conditional ?>>
-
- <div class="file-upload-container">
- <div class="file-upload-wrapper">
- <input type="file"
- name="<?= !empty($field['base']) ? esc_attr($field['base']) : '' ?><?= esc_attr($name) ?>_temp"
- id="<?= !empty($field['base']) ? esc_attr($field['base']) : '' ?><?= esc_attr($name) ?>_temp"
- accept="<?= esc_attr($acceptAttr) ?>"
- data-max-size="<?= esc_attr($this->getMaxFileSize($subtype)) ?>"
- <?= $multiple ? 'multiple' : '' ?>
- <?= !empty($field['required']) ? 'required' : '' ?>>
-
- <h2><?= esc_html($field['label']) ?></h2>
-
- <?php if (!empty($field['description'])) : ?>
- <p><?= esc_html($field['description']) ?></p>
- <?php endif; ?>
-
- <p class="file-upload-text">
- <strong>Click to upload</strong> or drag and drop<br>
- <?= esc_html($this->getAcceptedTypesLabel($subtype, $acceptExtensions)) ?>
- (max. <?= esc_html($this->formatFileSize($this->getMaxFileSize($subtype))) ?>)
- </p>
-
- <?php if ($destination === 'post_group') {
- $plural = (array_key_exists($field['content'], JVB_CONTENT)) ? JVB_CONTENT[$field['content']]['plural'] : (array_key_exists($field['content'], JVB_TAXONOMY) ? JVB_TAXONOMY[$field['content']]['plural'] : str_replace('_', ' ',$field['content']).'s');
- $singular = (array_key_exists($field['content'], JVB_CONTENT)) ? JVB_CONTENT[$field['content']]['singular'] : (array_key_exists($field['content'], JVB_TAXONOMY) ? JVB_TAXONOMY[$field['content']]['singular'] : str_replace('_', ' ',$field['content']));
- ?>
- <p class="hint">You can group images to create separate <?= $plural ?>.</p>
- <p class="hint">If a <?=$singular?> has multiple images, you can select the <?= jvbIcon('star')?> to set an image as the main one.</p>
- <?php }
- if (!empty($field['upload_description'])) : ?>
- <p><?= esc_html($field['upload_description']); ?></p>
- <?php endif; ?>
- <div class="file-error"></div>
- </div>
- <?php jvbRenderProgressBar(); ?>
- </div>
-
-
- <?php if ($destination === 'post_group') : ?>
- <div class="group-display flex col" hidden>
- <div class="preview-wrap flex col">
- <div class="preview-actions">
- <div class="selection-controls">
- <div class="selected">
- <div class="field">
- <input type="checkbox" id="select-all-uploads" data-select-all data-selects="item-grid" name="select-all-uploads">
- <label for="select-all-uploads">
- Select All
- </label>
- </div>
- <div class="info" hidden>
-
- </div>
- </div>
-
- <div class="selection-actions row btw" hidden>
- <button type="button" data-action="add-to-group">
- <?= jvbIcon('plus-square') ?>
- Group
- </button>
- <button type="button" data-action="delete-upload">
- <?= jvbIcon('trash') ?>
- Delete
- </button>
- </div>
- </div>
-
- <button type="button" data-action="upload" class="submit-uploads">
- <?= jvbIcon('cloud-arrow-up') ?> Upload <?= esc_html($plural ?? 'Content'); ?>
- </button>
- </div>
- <?php endif; ?>
-
- <?php jvbRenderProgressBar('<span class="text">Processing files...</span>
- <span class="count">0/0</span>'); ?>
- <div class="item-grid preview">
- <?php
- // Render existing attachments
- foreach ($attachmentIds as $attachmentId) {
- echo $this->renderExistingAttachment($attachmentId, $subtype);
- }
- ?>
- </div>
-
- <?php if ($destination === 'post_group') : ?>
- <p class="hint"><?= jvbIcon('arrow-elbow-left-up') ?> These will become individual <?= $plural ?> <?= jvbIcon('arrow-elbow-right-up')?></p>
- </div>
- <div class="sidebar flex col">
- <div class="header">
- <h4>New <?= $plural?></h4>
- <p class="hint">Drag or select multiple images into groups to create separate <?= $plural ?>.</p>
- </div>
- <div class="item-grid groups">
- <div class="empty-group">
- <p>Drag here to create a new <?= $singular ?>!</p>
- </div>
- </div>
- <p class="hint"><?= jvbIcon('arrow-elbow-left-up') ?> Each group will become its own <?= $singular ?> <?= jvbIcon('arrow-elbow-right-up')?></p>
- </div>
- </div>
- <?php endif; ?>
-
- <?php if ($destination === 'meta') : ?>
- <input type="hidden"
- name="<?= array_key_exists('base', $field) ? esc_attr($field['base']) : ''?><?= esc_attr($name); ?>"
- value="<?= esc_attr($value); ?>"
- <?= !empty($field['required']) ? 'required' : ''; ?>>
- <?php endif; ?>
- </div>
- <?php
- }
-
- private function renderExistingAttachment(int $attachmentId, string $subtype): string
- {
- ob_start();
-
- switch ($subtype) {
- case 'image':
- $this->renderImagePreview($attachmentId);
- break;
- case 'video':
- $this->renderVideoPreview($attachmentId);
- break;
- case 'document':
- case 'file':
- $this->renderFilePreview($attachmentId);
- break;
- default:
- $this->renderImagePreview($attachmentId);
- break;
- }
-
- return ob_get_clean();
- }
-
- /**
- * Get max file size for subtype
- */
- private function getMaxFileSize(string $subtype): int
- {
- $sizes = [
- 'image' => 5242880, // 5MB
- 'video' => 104857600, // 100MB
- 'document' => 10485760 // 10MB
- ];
-
- return $sizes[$subtype] ?? $sizes['image'];
- }
-
- /**
- * Format file size for display
- */
- private function formatFileSize(int $bytes): string
- {
- if ($bytes >= 1073741824) {
- return number_format($bytes / 1073741824, 1) . 'GB';
- }
- if ($bytes >= 1048576) {
- return number_format($bytes / 1048576, 1) . 'MB';
- }
- if ($bytes >= 1024) {
- return number_format($bytes / 1024, 1) . 'KB';
- }
- return $bytes . 'B';
- }
-
- /**
- * Get accepted types label
- */
- private function getAcceptedTypesLabel(string $subtype, array $extensions): string
- {
- $labels = [
- 'image' => 'JPG, PNG, GIF, or WEBP',
- 'video' => 'MP4, WEBM, or MOV',
- 'document' => 'PDF, DOC, XLS, or TXT',
- 'any' => 'Images, Videos, or Documents'
- ];
-
- return $labels[$subtype] ?? strtoupper(implode(', ', array_map(function($ext) {
- return ltrim($ext, '.');
- }, array_slice($extensions, 0, 3))));
- }
-
- /**
- * Render upload preview items
- */
- private function renderUploadPreviews(array $attachmentIds, array $config): void
- {
- if (empty($attachmentIds)) {
- return;
- }
-
- foreach ($attachmentIds as $id) {
- switch ($config['subtype']) {
- case 'image':
- $this->renderImagePreview($id, $config);
- break;
- case 'video':
- $this->renderVideoPreview($id, $config);
- break;
- case 'file':
- $this->renderFilePreview($id, $config);
- break;
- }
- }
- }
-
- public function renderImagePreview(?int $id = null, array $config = []):void
- {
- $attachment = ($id) ? wp_get_attachment_image($id, 'thumbnail', false) : false;
- $caption = ($id) ? wp_get_attachment_caption($id) : '';
- $alt = ($id) ? get_post_meta($id, '_wp_attachment_image_alt',true) : '';
- $title = ($id) ? get_the_title($id) : '';
- $addID = ($id) ? '-'.$id : '';
- $dataID = ($id) ? ['id' => $id] : '';
- ?>
- <div class="item upload"<?= ($id) ? ' data-id="'.$id.'"' : '' ?>>
- <div class="preview">
- <?php jvbRenderProgressBar('',true) ?>
- <input type="checkbox" class="upload-select" name="select-item" id="select-item<?=$addID?>">
- <label for="select-item<?=$addID?>" aria-label="Select image">
- <?= ($attachment) ?: '<img>
- <video></video>
- <span></span>' ?>
- </label>
- <div class="item-actions row btw">
- <div class="radio-button">
- <input type="radio" class="featured btn" name="featured" id="featured" hidden>
- <label for="featured">
- <?=jvbIcon('star')?>
- <?=jvbIcon('star', ['style' => 'fill'])?>
- <span class="screen-reader-text">Set as featured image</span>
- </label>
- </div>
-
- <button type="button" data-action="delete-upload" title="Remove from Group">
- <?=jvbIcon('trash')?>
- </button>
- </div>
- </div>
- <details>
- <summary class="row btw"><?=jvbIcon('pencil-simple')?><span>Edit Info</span></summary>
-
- <?php
-
- $fields = [
- 'image_data' => [
- 'type' => 'group',
- 'wrap' => 'details',
- 'label' => 'Image Fields',
- 'fields' => [
- 'image-title'.$addID => [
- 'type' => 'text',
- 'label' => 'Image Title',
- 'value' => $title,
- 'data' => $dataID
- ],
- 'image-alt-text'.$addID => [
- 'type' => 'text',
- 'label' => 'Alt Text',
- 'value' => $alt,
- 'hint' => 'Alt text helps the visually impaired, as well as some benefits for SEO.',
- 'data' => $dataID
- ],
- 'image-caption'.$addID => [
- 'type' => 'textarea',
- 'value' => $caption,
- 'label' => 'Image Caption',
- 'data' => $dataID
- ]
- ]
- ]
- ];
- $fields = array_key_exists('fields', $config) ? array_merge($fields, $config['fields']) : $fields;
- $meta = new MetaManager($id);
- foreach ($fields as $field => $config) {
- $meta->render('form', $field, $config);
- }
- ?>
- </details>
- </div>
- <?php
- }
- public function renderVideoPreview(?int $id = null, array $config = []):void
- {
- $attachment = ($id) ? wp_get_attachment_image($id, 'thumbnail', true) : false;
- $caption = ($id) ? wp_get_attachment_caption($id) : '';
- $description = ($id) ? get_the_content($id) : '';
- $title = ($id) ? get_the_title($id) : '';
- $addID = ($id) ? '-'.$id : '';
- $dataID = ($id) ? ['id' => $id] : '';
- ?>
- <div class="item upload"<?= ($id) ? ' data-id="'.$id.'"' : '' ?>>
- <div class="preview">
- <?php jvbRenderProgressBar('',true) ?>
- <input type="checkbox" class="upload-select" name="select-item" id="select-item<?=$addID?>">
- <label for="select-item<?=$addID?>" aria-label="Select image">
- <?= ($attachment) ?: '<img>
- <video></video>
- <span></span>'; ?>
- </label>
- <div class="item-actions row btw">
- <div class="radio-button">
- <input type="radio" class="featured btn" name="featured" id="featured" hidden>
- <label for="featured">
- <?=jvbIcon('star')?>
- <?=jvbIcon('star', ['style' => 'fill'])?>
- <span class="screen-reader-text">Set as featured image</span>
- </label>
- </div>
-
- <button type="button" data-action="delete-upload" title="Remove from Group">
- <?=jvbIcon('trash')?>
- </button>
- </div>
- </div>
- <details>';
- <summary class="row btw"><?=jvbIcon('pencil-simple')?><span>Edit Info</span></summary>
-
- <?php
- $fields = array_key_exists('fields', $config) ? $config['fields'] : [];
- $fields = array_merge([
- 'upload_data' => [
- 'type' => 'group',
- 'wrap' => 'details',
- 'label' => 'Video Info',
- 'hint' => 'These will be automatically generated if left blank.',
- 'fields' => [
- 'title' => [
- 'type' => 'text',
- 'label' => 'Video Title',
- 'value' => $title,
- 'data' => $dataID
- ],
- 'caption' => [
- 'type' => 'textarea',
- 'value' => $caption,
- 'label' => 'Video Caption',
- 'data' => $dataID
- ],
- 'description' => [
- 'type' => 'textarea',
- 'value' => $description,
- 'label' => 'Video Description',
- 'data' => $dataID
- ]
- ]
- ]
- ], $fields);
- $this->render('upload_data', null, $fields);
- ?>
- </details>
- </div>
- <?php
- }
- public function renderFilePreview(?int $id = null, array $config = []):void
- {
-
- $attachment = ($id) ? wp_get_attachment_image($id, 'thumbnail', true) : false;
- $caption = ($id) ? wp_get_attachment_caption($id) : '';
- $description = ($id) ? get_the_content($id) : '';
- $title = ($id) ? get_the_title($id) : '';
- $addID = ($id) ? '-'.$id : '';
- $dataID = ($id) ? ['id' => $id] : '';
- ?>
- <div class="item upload"<?= ($id) ? ' data-id="'.$id.'"' : '' ?>>
- <div class="preview">
- <?php jvbRenderProgressBar('',true) ?>
- <input type="checkbox" class="upload-select" name="select-item" id="select-item<?=$addID?>">
- <label for="select-item<?=$addID?>" aria-label="Select image">
- <?= ($attachment) ?: '<img>
- <video></video>
- <span></span>'; ?>
- </label>
- <div class="item-actions row btw">
- <div class="radio-button">
- <input type="radio" class="featured btn" name="featured" id="featured" hidden>
- <label for="featured">
- <?=jvbIcon('star')?>
- <?=jvbIcon('star', ['style' => 'fill'])?>
- <span class="screen-reader-text">Set as featured image</span>
- </label>
- </div>
-
- <button type="button" data-action="delete-upload" title="Remove from Group">
- <?=jvbIcon('trash')?>
- </button>
- </div>
- </div>
- <details>';
- <summary class="row btw"><?=jvbIcon('pencil-simple')?><span>Edit Info</span></summary>
-
- <?php
- $fields = array_key_exists('fields', $config) ? $config['fields'] : [];
- $fields = array_merge([
- 'upload_data' => [
- 'type' => 'group',
- 'wrap' => 'details',
- 'label' => 'File Info',
- 'hint' => 'These will be automatically generated if left blank.',
- 'fields' => [
- 'title' => [
- 'type' => 'text',
- 'label' => 'File Title',
- 'value' => $title,
- 'data' => $dataID
- ],
- 'caption' => [
- 'type' => 'textarea',
- 'value' => $caption,
- 'label' => 'File Caption',
- 'data' => $dataID
- ],
- 'description' => [
- 'type' => 'textarea',
- 'value' => $description,
- 'label' => 'File Description',
- 'data' => $dataID
- ]
- ]
- ]
- ], $fields);
- $this->render('upload_data', null, $fields);
- ?>
- </details>
- </div>
- <?php
- }
-
- /**
- * Get upload instruction text based on config
- */
- private function getUploadInstructions(array $config): string
- {
- $extensions = $this->getMimeExtensions($this->getAllowedTypes($config));
- $extList = implode(', ', array_map('strtoupper', $extensions));
- $maxSize = $config['max_size'] ?? $this->max_file_size;
- $maxSizeMB = round($maxSize / 1048576, 1);
-
- return "{$extList} (max. {$maxSizeMB}MB)";
- }
-
- /* ========== TAXONOMY/USER SELECTOR FIELDS ========== */
-
- private function renderTaxonomyField(string $name, string $value, array $field): void
- {
- if (array_key_exists('group', $field)) {
- $name = $field['group'] . '::' . $name;
- }
-
- $this->renderSelectorField($name, $value, $field, 'taxonomy');
- }
-
- private function renderUserField(string $name, string $value, array $field): void
- {
- if (array_key_exists('group', $field)) {
- $name = $field['group'] . '::' . $name;
- }
-
- $this->renderSelectorField($name, $value, $field, 'post');
- }
-
- /**
- * Generic selector field renderer
- * Handles both taxonomy and post selectors with consistent structure
- */
- public function renderSelectorField(string $name, mixed $value, array $field, string $type): void
- {
- $conditional = $this->handleConditionalField($field);
- $validationAttrs = $this->buildValidationAttributes($field);
- $describedBy = (!empty($field['description'])) ? ' aria-describedby="' . $name . '-help"' : '';
-
- // Parse selected values
- $value = (is_array($value)) ? array_filter(array_map('absint', $value)): $value;
- $selected = ($value === '') ? [] : (is_array($value) ? $value : explode(',', $value));
-
- // Generate unique container ID
- $containerId = $name . '-' . $type . '-selector';
-
- // Create selector instance with proper parameters
- if ($type === 'taxonomy') {
- $taxonomy = $field['taxonomy'];
- $icon = JVB_TAXONOMY[$taxonomy]['icon']??'';
-
- // Map field config to selector config
- $selectorConfig = [
- 'max' => $field['max'] ?? 0, // 0 = unlimited
- 'search' => $field['search'] ?? true,
- 'label' => $field['label'] ?? '',
- 'createNew' => $field['createNew'] ?? false,
- 'required' => $field['required'] ?? false,
- 'base' => $field['base'] ?? '',
- 'update' => $field['update'] ?? true,
- 'name' => $name,
- 'autocomplete' => $field['autocomplete'] ?? false,
- ];
- if ($icon !== '') {
- $selectorConfig['icon'] = $icon;
- }
-
- $selector = new TaxonomySelector($containerId, $taxonomy, $selectorConfig);
- $icon = $taxonomy;
- } else {
- $postType = $field['post_type'];
-
- // Map field config to selector config
- $selectorConfig = [
- 'max' => $field['max'] ?? 0,
- 'search' => $field['search'] ?? true,
- 'label' => $field['label'] ?? '',
- 'required' => $field['required'] ?? false,
- 'base' => $field['base'] ?? '',
- 'update' => $field['update'] ?? true,
- 'shop_id' => $field['shop_id'] ?? null,
- 'autocomplete'=> $field['autocomplete'] ?? true,
- ];
-
- $selector = new PostSelector($containerId, $postType, $selectorConfig);
- $icon = $postType;
- }
-
- ?>
- <div class="field selector <?= esc_attr($type) ?> <?= esc_attr($name) ?>"
- <?= $conditional ?>
- data-field="<?= esc_attr($name) ?>"
- data-field-type="selector"
- data-type="<?=esc_attr($field['type'])?>"
- <?= $validationAttrs ?>
- <?= $describedBy ?>>
-
- <?= $selector->render($selected) ?>
-
- <!-- Hidden input for form submission -->
- <input type="hidden"
- class="<?= esc_attr($type) ?>-selector-input"
- name="<?= array_key_exists('base', $field) ? esc_attr($field['base']) : '' ?><?= esc_attr($name) ?>"
- data-<?= esc_attr($type) ?>="<?= esc_attr($field[$type === 'taxonomy' ? 'taxonomy' : 'post_type']) ?>"
- value="<?= esc_attr(is_array($selected) ? implode(',', $selected) : $value) ?>"
- <?= !empty($field['required']) ? 'required' : '' ?>>
-
- <?php $this->renderHintAndDescription($field, $name); ?>
-
- <span class="validation-message" hidden role="alert"></span>
- </div>
- <?php
- }
-
- /* ========== LOCATION FIELD ========== */
-
- protected function renderLocationField(string $name, mixed $value, array $field): void
- {
- $googleMaps = JVB()->connect('maps');
- if (!$googleMaps->isSetUp()) {
- echo '<div class="notice notice-warning"><p>Google Maps not configured. Please configure in Integrations settings.</p></div>';
- return;
- }
-
- // Extract stored values
- if (is_string($value)) {
- $value = maybe_unserialize($value);
- }
- $stored_data = is_array($value) ? $value : [];
-
- $address = $stored_data['address'] ?? '';
- $lat = $stored_data['lat'] ?? '';
- $lng = $stored_data['lng'] ?? '';
-
- // Generate unique field ID
- $field_id = esc_attr($name);
- $map_id = $field_id . '_map';
-
- // Handle grouped fields
- if (array_key_exists('group', $field)) {
- $name = $field['group'] . '::' . $name;
- }
- $describedBy = (!empty($field['description'])) ? ' aria-describedby="'.$name.'-help"' : '';
-
- // Prepare configuration for JavaScript initialization
- $js_config = [
- 'fieldId' => $field_id,
- 'initialCoords' => (!empty($lat) && !empty($lng)) ? [
- 'lat' => (float)$lat,
- 'lng' => (float)$lng
- ] : null
- ];
-
- // IMPORTANT: Properly escape the JSON for use in HTML attribute
- $json_config = htmlspecialchars(json_encode($js_config), ENT_QUOTES, 'UTF-8');
- ?>
-
- <div class="field location <?= esc_attr($field_id) ?>"
- data-field="<?= esc_attr($field_id) ?>"
- data-field-type="<?=esc_attr($field['type'])?>"
- data-location-field-init="<?= $json_config ?>"<?=$describedBy?>>
-
- <?php
- if (!empty($stored_data['street'])) {
- echo '<p><b>Current location:</b> '.esc_html($stored_data['street']).'</p>';
- echo '<p class="hint"><b>Search below to change:</b></p>';
- }
- ?>
- <?php if (array_key_exists('hint', $field)) { $this->renderHint($field['hint']); } ?>
- <?php if (array_key_exists('description', $field)) { $this->renderDescription($field['description'], $name); } ?>
-
- <div class="location-search-wrapper">
- <div class="autocomplete-wrapper"></div>
-
- <!-- Map container -->
- <div class="location-preview">
- <div id="<?= esc_attr($map_id); ?>"
- class="location-map">
- </div>
-
- <?php if (!empty($stored_data)):
- jvbLocationLinks($stored_data);
- endif; ?>
- </div>
-
- <!-- Hidden inputs for data storage -->
- <input type="hidden"
- name="<?= array_key_exists('base', $field) ? esc_attr($field['base']) : ''?><?= esc_attr($name); ?>[address]"
- value="<?= esc_attr($address); ?>"
- data-location-field="address">
-
- <input type="hidden"
- name="<?= array_key_exists('base', $field) ? esc_attr($field['base']) : ''?><?= esc_attr($name); ?>[lat]"
- value="<?= esc_attr($lat); ?>"
- data-location-field="lat">
-
- <input type="hidden"
- name="<?= array_key_exists('base', $field) ? esc_attr($field['base']) : ''?><?= esc_attr($name); ?>[lng]"
- value="<?= esc_attr($lng); ?>"
- data-location-field="lng">
-
- <?php
- // Component fields
- $components = ['street', 'city', 'province', 'postal_code', 'country'];
- foreach ($components as $component):
- ?>
- <input type="hidden"
- name="<?= array_key_exists('base', $field) ? esc_attr($field['base']) : ''?><?= esc_attr($name); ?>[<?= $component; ?>]"
- value="<?= esc_attr($stored_data[$component] ?? ''); ?>"
- data-location-field="<?= esc_attr($component); ?>">
- <?php endforeach; ?>
-
- </div>
- </div>
- <?php
- }
-
- /* ========== HTML FIELD ========== */
-
- protected function renderHtmlField(string $name, mixed $value, array $field): void
- {
- $method_name = $field['content'];
- $content = '';
-
- if (method_exists($this, $method_name)) {
- $content = $this->$method_name();
- }
-
- if ($content === '') {
- return;
- }
-
- echo sprintf(
- '<div class="html-field-container" data-field-type="html" data-field="%s">%s</div>',
- esc_attr($name),
- $content
- );
- }
-
- /* ========== UTILITY METHODS ========== */
-
- private function handleConditionalField(array $field):string
- {
- if (empty($field['condition'])) {
- return '';
- }
-
- $condition = $field['condition'];
- return sprintf(
- 'data-depends-on="%s" data-depends-value="%s" data-depends-operator="%s"',
- esc_attr($field['condition']['field']),
- esc_attr($field['condition']['value']),
- esc_attr($field['condition']['operator'] ?? '==')
- );
- }
-
- protected function getAllowedTypes(array $config): array
- {
- if (!empty($config['accepted_types'])) {
- return $config['accepted_types'];
- }
-
- // Default types based on subtype
- $defaults = [
- 'image' => ['image/*'],
- 'video' => ['video/*'],
- 'document' => ['application/pdf', 'application/msword', 'application/vnd.ms-excel', 'text/plain', '.odt','application/vnd.openxmlformats-officedocument.wordprocessingml.document'],
- ];
- $defaults['any'] = array_merge($defaults['image'], $defaults['video'], $defaults['document']);
-
- return $defaults[$config['subtype']] ?? $defaults['image'];
- }
-
- protected function getMimeExtensions(array $mimeTypes): array
- {
- $extensions = [];
- foreach ($mimeTypes as $mime) {
- if (str_contains($mime, '*')) {
- continue; // Skip wildcards
- }
- $ext = str_replace(['image/', 'video/', 'application/'], '', $mime);
- $extensions[] = '.' . $ext;
- }
- return $extensions;
- }
-
- protected function parseAttachmentIds(mixed $value): array
- {
- if (empty($value)) {
- return [];
- }
-
- if (is_array($value)) {
- return array_filter($value, 'is_numeric');
- }
-
- if (is_string($value)) {
- return array_filter(explode(',', $value), 'is_numeric');
- }
-
- return is_numeric($value) ? [$value] : [];
- }
- /**
- * Render tag list field - inline tag input interface
- */
- protected function renderTagListField(string $name, mixed $value, array $field): void
- {
- $values = is_array($value) ? $value : [];
- $conditional = $this->handleConditionalField($field);
- $validationAttrs = $this->buildValidationAttributes($field);
-
- if (array_key_exists('group', $field)) {
- $name = $field['group'] . '::' . $name;
- }
-
- $describedBy = (!empty($field['description'])) ? ' aria-describedby="' . $name . '-help"' : '';
-
- // Tag display format - defaults to first field value
- $tagFormat = $field['tag_format'] ?? 'first_field';
- ?>
- <div class="field tag-list <?= esc_attr($name) ?>"
- data-field="<?= esc_attr($name) ?>"
- data-field-type="<?=esc_attr($field['type'])?>"
- data-tag-format="<?= esc_attr($tagFormat) ?>"
- <?= $describedBy ?>
- <?= $conditional ?>
- <?= $validationAttrs ?>>
-
- <?php if (!empty($field['label'])): ?>
- <h3><?= esc_html($field['label']) ?></h3>
- <?php endif; ?>
-
- <!-- Inline input row -->
- <div class="tag-input-row">
- <?php foreach ($field['fields'] as $subfield_name => $subfield_config): ?>
- <?php
- $subfield_config['label'] = $subfield_config['label'] ?? ucfirst($subfield_name);
- $input_name = 'new_' . $subfield_name;
-
- // Store required state but don't render it on the input
- // This prevents form submission validation but allows JS validation
-
- if (array_key_exists('required', $subfield_config)) {
- $subfield_config['data']['required'] = true;
- unset($subfield_config['required']); // Remove required for HTML rendering
- }
- $subfield_config['data']['ignore'] = true;
-
- $this->render($input_name, '', $subfield_config, false, false);
- ?>
- <?php endforeach; ?>
-
- <button type="button" class="button add-tag-item">
- <?= jvbIcon('plus') ?> <?= $field['add_label'] ?? 'Add' ?>
- </button>
- </div>
-
- <!-- Tags display -->
- <div class="tag-items">
- <?php foreach ($values as $index => $item_data): ?>
- <?php $this->renderTagItem($field['fields'], $item_data, $index, $name, $tagFormat); ?>
- <?php endforeach; ?>
- </div>
-
- <!-- Template for new tags -->
- <template class="<?=uniqid('tagListItem')?>">
- <?php $this->renderTagItem($field['fields'], [], '', $name, $tagFormat); ?>
- </template>
-
- <?php if (!empty($field['hint'])): ?>
- <?php $this->renderHint($field['hint']); ?>
- <?php endif; ?>
-
- <?php if (!empty($field['description'])): ?>
- <?php $this->renderDescription($field['description'], $name); ?>
- <?php endif; ?>
- </div>
- <?php
- }
-
- /**
- * Render individual tag item
- */
- protected function renderTagItem(array $fields, array $data, int|string $index, string $base_name, string $format): void
- {
- $tag_text = $this->getTagDisplayText($fields, $data, $format);
- ?>
- <div class="tag-item" data-index="<?= esc_attr($index) ?>">
- <span class="tag-label"><?= esc_html($tag_text) ?></span>
-
- <!-- Hidden inputs for data -->
- <?php foreach ($fields as $field_name => $field_config): ?>
- <?php
- $value = $data[$field_name] ?? '';
- $full_name = is_string($index) ? $field_name : "{$base_name}:{$index}:{$field_name}";
- ?>
- <input type="hidden"
- name="<?= esc_attr($full_name) ?>"
- value="<?= esc_attr($value) ?>"
- data-field="<?= esc_attr($field_name) ?>"
- data-field-type="<?=esc_attr($field_config['type'])?>" />
- <?php endforeach; ?>
-
- <button type="button" class="remove-tag" aria-label="Remove">
- <?= jvbIcon('x') ?>
- </button>
- </div>
- <?php
- }
-
- /**
- * Get tag display text based on format
- */
- protected function getTagDisplayText(array $fields, array $data, string $format): string
- {
- if (empty($data)) {
- return 'New Item';
- }
-
- switch ($format) {
- case 'first_field':
- // Use the first field's value
- $first_key = array_key_first($fields);
- return $data[$first_key] ?? 'New Item';
-
- case 'all_fields':
- // Show all field values separated by commas
- $values = array_filter(array_values($data));
- return implode(', ', $values) ?: 'New Item';
-
- case 'custom':
- // Custom format - would need callback
- return 'New Item';
-
- default:
- // Format is a template string like "{name} ({email})"
- if (strpos($format, '{') !== false) {
- $text = $format;
- foreach ($data as $key => $value) {
- $text = str_replace('{' . $key . '}', $value, $text);
- }
- return $text;
- }
- // Use specific field name
- return $data[$format] ?? 'New Item';
- }
- }
-}
diff --git a/inc/meta/MetaFormOld.php b/inc/meta/MetaFormOld.php
index 22851a5..871e771 100644
--- a/inc/meta/MetaFormOld.php
+++ b/inc/meta/MetaFormOld.php
@@ -1,12 +1,12 @@
<?php
-namespace JVBase\inc\meta;
+namespace JVBase\meta;
use JVBase\forms\TaxonomySelector;
use JVBase\forms\PostSelector;
use DateTime;
if (!defined('ABSPATH')) {
- exit; // Exit if accessed directly
+ exit;
}
/**
@@ -14,25 +14,31 @@
*/
class MetaFormOld
{
- protected int $max_file_size = 5242880;
+ protected int $max_file_size = 5242880;
+ protected ?MetaTypeManager $type_manager = null;
- //Rendering fields
- public function render(string $name, mixed $value, array $config, bool $showHidden = false, bool $return = false):mixed
- {
+ /* ========== MAIN RENDER METHOD ========== */
+ public function return(string $name, mixed $value, array $config, bool $showHidden = false)
+ {
+ return $this->render($name, $value, $config, $showHidden, true);
+ }
+ public function render(string $name, mixed $value, array $config, bool $showHidden = false, bool $return = false): mixed
+ {
$out = '';
- if (jvbCheck('hidden', $config) && !$showHidden) {
- return $out;
- }
- // Get conditional attributes if they exist
- $conditional = array_key_exists('condition', $config) ?
- $this->handleConditionalField($config) : '';
+ if (jvbCheck('hidden', $config) && !$showHidden) {
+ return $out;
+ }
- if (!array_key_exists('type', $config)) {
- return $out;
- }
+ if (!array_key_exists('type', $config)) {
+ return $out;
+ }
+ if (!$value) {
+ $value = $this->getDefaultValue($config['type']);
+ }
- if (array_key_exists('display', $config) && $config['display'] === 'hidden'){
- $out = '<input type="hidden" name="'.$name.'" value="'.$value.'" />';
+ // Handle hidden display type
+ if (array_key_exists('display', $config) && $config['display'] === 'hidden') {
+ $out = '<input type="hidden" name="' . $name . '" value="' . $value . '" />';
if (!$return) {
echo $out;
}
@@ -40,1264 +46,776 @@
}
ob_start();
- $type = array_map( 'ucfirst', explode('_', $config['type']));
- $type = implode('', $type);
- $method = 'render' . $type . 'Field';
+
+ // Try custom function overrides first
+ $type = array_map('ucfirst', explode('_', $config['type']));
+ $type = implode('', $type);
+ $method = 'render' . $type . 'Field';
$nameTemp = implode('', array_map('ucfirst', explode('_', $name)));
- $nameMethod = 'render'.$nameTemp.'Field';
- if(function_exists($nameMethod)) {
+ $nameMethod = 'render' . $nameTemp . 'Field';
+
+ if (function_exists($nameMethod)) {
call_user_func($nameMethod, $value, $config);
} elseif (function_exists($method)) {
call_user_func($method, $value, $config);
} elseif (method_exists($this, $method)) {
- $this->$method($name, $value, $config);
- }
-
-
+ $this->$method($name, $value, $config);
+ }
$out = ob_get_clean();
do_action('jvbRenderFormField', $name, $config, $value);
$out = apply_filters('jvbFilterRenderFormField', $out, $name, $config, $value);
+
if (!$return) {
echo $out;
}
return $out;
- }
+ }
- public function renderTextField(string $name, mixed $value, array $field):void
- {
- // Use field-specific value if provided, otherwise use the meta value
- $display_value = isset($field['value']) ? $field['value'] : $value;
- $conditional = $this->handleConditionalField($field);
- $describedBy = (!empty($field['description'])) ? ' aria-describedby="'.$name.'-help"' : '';
- if (array_key_exists('group', $field)) {
- $name = $field['group'].'::'.$name;
- }
- $placeholder = (array_key_exists('placeholder', $field)) ? ' placeholder="'.$field['placeholder'].'"' : '';
- $autocomplete = (array_key_exists('autocomplete', $field)) ? ' autocomplete="'.$field['autocomplete'].'"' : '';
- ?>
- <div class="field <?=$field['type']?> <?=$name?>" <?=$conditional?> data-field="<?=$name?>">
- <label for="<?= esc_attr($name); ?>">
- <?= esc_html($field['label']); ?>
- <?php if (!empty($field['limit'])) : ?>
- <span class="char-count" data-limit="<?= esc_attr($field['limit']); ?>">
- <span class="current">0</span>/<?= esc_attr($field['limit']); ?>
- </span>
- <?php endif; ?>
- </label>
- <input
- type="<?= esc_attr($field['subtype']??'text'); ?>"
- id="<?= array_key_exists('base', $field) ? esc_attr($field['base']) : ''?><?= esc_attr($name); ?>"
- name="<?= array_key_exists('base', $field) ? esc_attr($field['base']) : ''?><?= esc_attr($name); ?>"
- value="<?= esc_attr($display_value); ?>"
- <?= $placeholder ?>
- <?= $autocomplete ?>
- <?= !empty($field['required']) ? 'required' : ''; ?>
- <?= $describedBy ?>
- >
- <?php if (array_key_exists('hint', $field)) { $this->renderHint($field['hint']); } ?>
- <?php if (array_key_exists('description', $field)) { $this->renderDescription($field['description'], $name); } ?>
- </div>
- <?php
- if (array_key_exists('limit', $field)) {
- $this->outputCharacterCountJS();
- }
- }
+ public function getDefaultValue(string $type):mixed {
+ if (!$this->type_manager) {
+ $this->type_manager = new MetaTypeManager();
+ }
+ return match ($this->type_manager->getMetaType($type)) {
+ 'object', 'array' => [],
+ 'boolean' => false,
+ 'integer' => 0,
+ default => '',
+ };
+ }
- private function renderTelField(string $name, mixed $value, array $field):void
- {$describedBy = (!empty($field['description'])) ? ' aria-describedby="'.$name.'-help"' : '';
- // Use field-specific value if provided, otherwise use the meta value
- $display_value = isset($field['value']) ? $field['value'] : $value;
- $conditional = $this->handleConditionalField($field);
- if (array_key_exists('group', $field)) {
- $name = $field['group'].'::'.$name;
- }
- $placeholder = (array_key_exists('placeholder', $field)) ? ' placeholder="'.$field['placeholder'].'"' : '';
- $autocomplete = (array_key_exists('autocomplete', $field)) ? ' autocomplete="'.$field['autocomplete'].'"' : '';
- ?>
- <div class="field <?=$field['type']?> <?=$name?>" <?=$conditional?> data-field="<?=$name?>">
- <label for="<?= esc_attr($name); ?>">
- <?= esc_html($field['label']); ?>
- <?php if (!empty($field['limit'])) : ?>
- <span class="char-count" data-limit="<?= esc_attr($field['limit']); ?>">
- <span class="current">0</span>/<?= esc_attr($field['limit']); ?>
- </span>
- <?php endif; ?>
- </label>
- <input
- type="tel"
- id="<?= array_key_exists('base', $field) ? esc_attr($field['base']) : ''?><?= esc_attr($name); ?>"
- name="<?= array_key_exists('base', $field) ? esc_attr($field['base']) : ''?><?= esc_attr($name); ?>"
- value="<?= esc_attr($display_value); ?>"
- <?= $placeholder ?>
- <?= $autocomplete?>
- <?= $describedBy ?>
- <?= !empty($field['required']) ? 'required' : ''; ?>
- >
- <?php if (array_key_exists('hint', $field)) { $this->renderHint($field['hint']); } ?>
- <?php if (array_key_exists('description', $field)) { $this->renderDescription($field['description'], $name); } ?>
- </div>
- <?php
- if (array_key_exists('limit', $field)) {
- $this->outputCharacterCountJS();
- }
- }
- private function renderEmailField(string $name, mixed $value, array $field):void
- {
- $describedBy = (!empty($field['description'])) ? ' aria-describedby="'.$name.'-help"' : '';
- // Use field-specific value if provided, otherwise use the meta value
- $display_value = isset($field['value']) ? $field['value'] : $value;
- $conditional = $this->handleConditionalField($field);
- if (array_key_exists('group', $field)) {
- $name = $field['group'].'::'.$name;
- }
- $placeholder = (array_key_exists('placeholder', $field)) ? ' placeholder="'.$field['placeholder'].'"' : '';
- $autocomplete = (array_key_exists('autocomplete', $field)) ? ' autocomplete="'.$field['autocomplete'].'"' : '';
- ?>
- <div class="field <?=$field['type']?> <?=$name?>" <?=$conditional?> data-field="<?=$name?>">
- <label for="<?= esc_attr($name); ?>">
- <?= esc_html($field['label']); ?>
- <?php if (!empty($field['limit'])) : ?>
- <span class="char-count" data-limit="<?= esc_attr($field['limit']); ?>">
- <span class="current">0</span>/<?= esc_attr($field['limit']); ?>
- </span>
- <?php endif; ?>
- </label>
- <input
- type="email"
- <?= array_key_exists('base', $field) ? esc_attr($field['base']) : ''?><?= esc_attr($name); ?>"
- name="<?= array_key_exists('base', $field) ? esc_attr($field['base']) : ''?><?= esc_attr($name); ?>"
- value="<?= esc_attr($display_value); ?>"
- <?= $placeholder ?>
- <?= $autocomplete ?>
- <?= $describedBy ?>
- <?= !empty($field['required']) ? 'required' : ''; ?>
- >
- <?php if (array_key_exists('hint', $field)) { $this->renderHint($field['hint']); } ?>
- <?php if (array_key_exists('description', $field)) { $this->renderDescription($field['description'], $name); } ?>
- </div>
- <?php
- if (array_key_exists('limit', $field)) {
- $this->outputCharacterCountJS();
- }
- }
+ /* ========== HELPER METHODS ========== */
- private function renderUrlField(string $name, mixed $value, array $field):void
- {
- $describedBy = (!empty($field['description'])) ? ' aria-describedby="'.$name.'-help"' : '';
- // Use field-specific value if provided, otherwise use the meta value
- $display_value = isset($field['value']) ? $field['value'] : $value;
- $conditional = $this->handleConditionalField($field);
- if (array_key_exists('group', $field)) {
- $name = $field['group'].'::'.$name;
- }
- $placeholder = (array_key_exists('placeholder', $field)) ? ' placeholder="'.$field['placeholder'].'"' : '';
- $autocomplete = (array_key_exists('autocomplete', $field)) ? ' autocomplete="'.$field['autocomplete'].'"' : '';
- ?>
- <div class="field <?=$field['type']?> <?=$name?>" <?=$conditional?> data-field="<?=$name?>">
- <label for="<?= esc_attr($name); ?>">
- <?= esc_html($field['label']); ?>
- <?php if (!empty($field['limit'])) : ?>
- <span class="char-count" data-limit="<?= esc_attr($field['limit']); ?>">
- <span class="current">0</span>/<?= esc_attr($field['limit']); ?>
- </span>
- <?php endif; ?>
- </label>
- <input
- type="url"
- id="<?= array_key_exists('base', $field) ? esc_attr($field['base']) : ''?><?= esc_attr($name); ?>"
- name="<?= array_key_exists('base', $field) ? esc_attr($field['base']) : ''?><?= esc_attr($name); ?>"
- value="<?= esc_attr($display_value); ?>"
- <?= $placeholder ?>
- <?= $describedBy ?>
- <?= $autocomplete ?>
- <?= !empty($field['required']) ? 'required' : ''; ?>
- >
- <?php if (array_key_exists('hint', $field)) { $this->renderHint($field['hint']); } ?>
- <?php if (array_key_exists('description', $field)) { $this->renderDescription($field['description'], $name); } ?>
- </div>
- <?php
- if (array_key_exists('limit', $field)) {
- $this->outputCharacterCountJS();
- }
- }
+ /**
+ * Prepare common field data
+ */
+ protected function prepareFieldData(string $name, mixed $value, array $field): array
+ {
+ return [
+ 'name' => array_key_exists('group', $field) ? $field['group'] . '::' . $name : $name,
+ 'value' => isset($field['value']) ? $field['value'] : $value,
+ 'id' => (array_key_exists('base', $field) ? esc_attr($field['base']) : '') . esc_attr($name),
+ ];
+ }
- private function renderNumberField(string $name, mixed $value, array $field):void
- {
- $describedBy = (!empty($field['description'])) ? ' aria-describedby="'.$name.'-help"' : '';
- $description = '<ul class="list-none"><li>Tip: hold Ctrl/Command to increase 5x</li><li>Shift to increase 10x,</li><li>Or Ctrl/Command + Shift to increase 50x</li></ul>';
- $description .= $field['description']??'';
- $conditional = $this->handleConditionalField($field);
+ /**
+ * Build common HTML attributes for inputs
+ */
+ protected function buildInputAttributes(string $name, array $field): string
+ {
+ $attrs = [];
+
+ // Conditional rendering
+ if (array_key_exists('condition', $field)) {
+ $attrs['conditional'] = $this->handleConditionalField($field);
+ }
+
+ // Accessibility
+ if (!empty($field['description'])) {
+ $attrs['aria-describedby'] = $name . '-help';
+ }
+
+ // Common attributes
+ $common = ['placeholder', 'autocomplete', 'pattern', 'minlength', 'maxlength', 'min', 'max', 'step'];
+ foreach ($common as $attr) {
+ if (array_key_exists($attr, $field)) {
+ $attrs[$attr] = $field[$attr];
+ }
+ }
+
+ // Required
+ if (!empty($field['required'])) {
+ $attrs['required'] = true;
+ }
+
+ // Build attribute string
+ $attrString = '';
+ foreach ($attrs as $key => $val) {
+ if ($key === 'conditional') {
+ $attrString .= ' ' . $val; // Already formatted
+ } elseif ($val === true) {
+ $attrString .= ' ' . $key;
+ } else {
+ $attrString .= ' ' . $key . '="' . esc_attr($val) . '"';
+ }
+ }
+
+ return $attrString;
+ }
+
+ /**
+ * Build validation data attributes
+ */
+ protected function buildValidationAttributes(array $field): string
+ {
+ $attrs = [];
+
+ if (!empty($field['pattern'])) {
+ $attrs['data-pattern'] = $field['pattern'];
+ }
+
+ if (!empty($field['validate'])) {
+ $attrs['data-validate'] = $field['validate'];
+ }
+
+ if (isset($field['min'])) {
+ $attrs['data-min'] = $field['min'];
+ }
+
+ if (isset($field['max'])) {
+ $attrs['data-max'] = $field['max'];
+ }
+
+ if (isset($field['minlength'])) {
+ $attrs['data-minlength'] = $field['minlength'];
+ }
+
+ if (isset($field['maxlength'])) {
+ $attrs['data-maxlength'] = $field['maxlength'];
+ }
+
+ if (!empty($field['validation_message'])) {
+ $attrs['data-validation-message'] = $field['validation_message'];
+ }
+
+ $attrs['data-type'] = $field['type'];
+
+ $attrString = '';
+ foreach ($attrs as $key => $val) {
+ $attrString .= ' ' . $key . '="' . esc_attr($val) . '"';
+ }
+
+ return $attrString;
+ }
+
+ /* ========== GENERIC FIELD WRAPPER ========== */
+
+ /**
+ * Render a standard input field with validation wrapper
+ */
+ protected function renderStandardInput(string $name, mixed $value, array $field, string $inputType = 'text'): void
+ {
+ $data = $this->prepareFieldData($name, $value, $field);
+ $inputAttrs = $this->buildInputAttributes($name, $field);
+ $validationAttrs = $this->buildValidationAttributes($field);
+ $conditional = array_key_exists('condition', $field) ? $this->handleConditionalField($field) : '';
+
+ $pattern = array_key_exists('pattern', $field) ? $field['pattern'] : '';
+ $customData = '';
+ if (array_key_exists('data', $field) && !empty($field['data'])) {
+ foreach ($field['data'] as $key => $v) {
+ $customData .= ($v === '') ? ' data-' . $key : ' data-' . $key . '="' . $v . '"';
+ }
+ }
+ ?>
+ <div class="field <?= esc_attr($field['type']) ?> <?= esc_attr($name) ?>"
+ <?= $conditional ?>
+ data-field="<?= esc_attr($name) ?>"
+ data-field-type="<?=esc_attr($field['type'])?>"
+ <?= $validationAttrs ?>>
+
+ <?php $this->renderLabel($name, $field); ?>
+
+ <div class="field-input-wrapper">
+ <input
+ type="<?= esc_attr($inputType) ?>"
+ id="<?= esc_attr($data['id']) ?>"
+ name="<?= esc_attr($data['name']) ?>"
+ value="<?= esc_attr($data['value']) ?>"
+ <?= $inputAttrs ?>
+ <?= $customData?>
+ <?= $pattern?>
+ >
+ <span class="validation-icon success" hidden aria-hidden="true">
+ <?= jvbIcon('check-circle') ?>
+ </span>
+ <span class="validation-icon error" hidden aria-hidden="true">
+ <?= jvbIcon('x-circle') ?>
+ </span>
+ </div>
+
+ <span class="validation-message" hidden role="alert"></span>
+
+ <?php $this->renderHintAndDescription($field, $name); ?>
+ </div>
+ <?php
+ }
+
+ /**
+ * Render field label with optional character count
+ */
+ protected function renderLabel(string $name, array $field): void
+ {
+ ?>
+ <label for="<?= esc_attr($name) ?>">
+ <?= esc_html($field['label']) ?>
+ <?php if (!empty($field['required'])) : ?>
+ <span class="required" aria-label="required">*</span>
+ <?php endif; ?>
+ <?php if (!empty($field['limit'])) : ?>
+ <span class="char-count" data-limit="<?= esc_attr($field['limit']) ?>">
+ <span class="current">0</span>/<?= esc_attr($field['limit']) ?>
+ </span>
+ <?php endif; ?>
+ </label>
+ <?php
+ }
+
+ /**
+ * Render hint and description
+ */
+ protected function renderHintAndDescription(array $field, string $name): void
+ {
+ if (!empty($field['hint'])) {
+ $this->renderHint($field['hint']);
+ }
+
+ if (!empty($field['description'])) {
+ $this->renderDescription($field['description'], $name);
+ }
+ }
+
+ protected function renderHint(string $hint): void
+ {
+ ?>
+ <span class="hint"><?= esc_html($hint) ?></span>
+ <?php
+ }
+
+ protected function renderDescription(string $description, string $name): void
+ {
+ ?>
+ <p class="description" id="<?= esc_attr($name) ?>-help">
+ <?= wp_kses_post($description) ?>
+ </p>
+ <?php
+ }
+
+ /* ========== SIMPLE INPUT FIELD TYPES ========== */
+
+ public function renderTextField(string $name, mixed $value, array $field): void
+ {
+ $this->renderStandardInput($name, $value, $field, $field['subtype'] ?? 'text');
+ }
+
+ public function renderEmailField(string $name, mixed $value, array $field): void
+ {
+ $field['validate'] = 'email'; // Auto-add email validation
+ $this->renderStandardInput($name, $value, $field, 'email');
+ }
+
+ private function renderUrlField(string $name, mixed $value, array $field): void
+ {
+ $field['validate'] = 'url'; // Auto-add URL validation
+ $this->renderStandardInput($name, $value, $field, 'url');
+ }
+
+ private function renderTelField(string $name, mixed $value, array $field): void
+ {
+ $field['validate'] = 'phone'; // Auto-add phone validation
+ $this->renderStandardInput($name, $value, $field, 'tel');
+ }
+
+ private function renderDateField(string $name, mixed $value, array $field): void
+ {
+ $format = !empty($field['format']) ? $field['format'] : 'Y-m-d';
+
+ // Format the date if we have a value
+ if (!empty($value)) {
+ $date = DateTime::createFromFormat($format, $value);
+ if ($date) {
+ $value = $date->format('Y-m-d'); // HTML date input requires Y-m-d format
+ }
+ }
+
+ $this->renderStandardInput($name, $value, $field, 'date');
+ }
+
+ private function renderTimeField(string $name, mixed $value, array $field): void
+ {
+ $this->renderStandardInput($name, $value, $field, 'time');
+ }
+
+ private function renderDatetimeField(string $name, mixed $value, array $field): void
+ {
+ $this->renderStandardInput($name, $value, $field, 'datetime-local');
+ }
+
+ /* ========== TEXTAREA FIELD ========== */
+
+ public function renderTextareaField(string $name, mixed $value, array $field): void
+ {
+ $data = $this->prepareFieldData($name, $value, $field);
+ $inputAttrs = $this->buildInputAttributes($name, $field);
+ $validationAttrs = $this->buildValidationAttributes($field);
+ $conditional = array_key_exists('condition', $field) ? $this->handleConditionalField($field) : '';
+
+ $rows = isset($field['rows']) ? (int)$field['rows'] : 4;
+ $quill = (array_key_exists('quill', $field) && $field['quill'] == true) ? ' data-editor="true"' : '';
+
+ if ($quill !== '') {
+ $allowImages = array_key_exists('allowImage', $field);
+ $quill .= ($allowImages) ? ' data-allowimage="true"' : ' data-allowimage="false"';
+ }
+
+ // Handle array values
+ if (is_array($value)) {
+ $value = implode(', ', $value);
+ }
+
+ ?>
+ <div class="field <?= esc_attr($field['type']) ?> <?= esc_attr($name) ?>"
+ <?= $conditional ?>
+ data-field="<?= esc_attr($name) ?>"
+ data-field-type="<?=esc_attr($field['type'])?>"
+ <?= $validationAttrs ?>>
+
+ <?php $this->renderLabel($name, $field); ?>
+
+ <div class="field-input-wrapper">
+ <textarea
+ id="<?= esc_attr($data['id']) ?>"
+ name="<?= esc_attr($data['name']) ?>"
+ rows="<?= esc_attr($rows) ?>"
+ <?= $quill ?>
+ <?= $inputAttrs ?>
+ ><?= esc_textarea($data['value']) ?></textarea>
+ <span class="validation-icon success" hidden aria-hidden="true">
+ <?= jvbIcon('check-circle') ?>
+ </span>
+ <span class="validation-icon error" hidden aria-hidden="true">
+ <?= jvbIcon('x-circle') ?>
+ </span>
+ </div>
+
+ <span class="validation-message" hidden role="alert"></span>
+
+ <?php $this->renderHintAndDescription($field, $name); ?>
+ </div>
+ <?php
+ }
+
+ /* ========== NUMBER FIELD ========== */
+
+ private function renderNumberField(string $name, mixed $value, array $field): void
+ {
+ $data = $this->prepareFieldData($name, $value, $field);
+ $conditional = array_key_exists('condition', $field) ? $this->handleConditionalField($field) : '';
+ $validationAttrs = $this->buildValidationAttributes($field);
+ $describedBy = (!empty($field['description'])) ? ' aria-describedby="' . $name . '-help"' : '';
$min = isset($field['min']) ? (float)$field['min'] : 0;
$max = isset($field['max']) ? (float)$field['max'] : 100;
$step = isset($field['step']) ? (float)$field['step'] : 1;
- $data = '';
+ // Handle custom data attributes
+ $customData = '';
if (array_key_exists('data', $field) && !empty($field['data'])) {
- foreach($field['data'] as $key => $v) {
- if ($v === '') {
- $data .= ' data-'.$key;
- } else {
- $data .= ' data-'.$key.'="'.$v.'"';
- }
+ foreach ($field['data'] as $key => $v) {
+ $customData .= ($v === '') ? ' data-' . $key : ' data-' . $key . '="' . $v . '"';
}
}
if (empty($value)) {
- $value = $field['default']??0;
+ $value = $field['default'] ?? 0;
}
- if (array_key_exists('group', $field)) {
- $name = $field['group'].'::'.$name;
- }
- $autocomplete = (array_key_exists('autocomplete', $field)) ? ' autocomplete="'.$field['autocomplete'].'"' : '';
- ?>
- <div class="field <?=$field['type']?> <?=$name?> row" <?=$conditional?> data-field="<?=$name?>">
- <label for="<?= esc_attr($name); ?>">
- <?= esc_html($field['label']); ?>
- </label>
+ $autocomplete = (array_key_exists('autocomplete', $field)) ? ' autocomplete="' . $field['autocomplete'] . '"' : '';
- <div class="quantity"
- <?=$data?>>
-
- <button type="button"
- class="decrease"
- title="<?= array_key_exists('remove', $field) ? $field['remove'] : 'Decrease amount' ?>"
- aria-label="Decrease <?= esc_attr($field['label']); ?>">
- <?= jvbIcon('minus-square')?>
- </button>
-
- <input type="number"
- id="<?= array_key_exists('base', $field) ? esc_attr($field['base']) : ''?><?= esc_attr($name); ?>"
- name="<?= array_key_exists('base', $field) ? esc_attr($field['base']) : ''?><?= esc_attr($name); ?>"
- value="<?= esc_attr($value); ?>"
- min="<?= esc_attr($min); ?>"
- max="<?= esc_attr($max); ?>"
- step="<?= esc_attr($step); ?>"
- class="quantity-input"
- <?= $describedBy ?>
- <?= $autocomplete ?>
- <?= !empty($field['required']) ? 'required' : ''; ?>>
-
- <button type="button"
- class="increase"
- title="<?= array_key_exists('add', $field) ? $field['add'] : 'Increase amount' ?>"
- aria-label="Increase <?= esc_attr($field['label']); ?>">
- <?= jvbIcon('plus-square')?>
- </button>
- </div>
- <?php $this->renderDescription($description, $name); ?>
- </div>
- <?php
- }
-
- public function renderTextareaField(string $name, mixed $value, array $field):void
- {
- $describedBy = (!empty($field['description'])) ? ' aria-describedby="'.$name.'-help"' : '';
- $rows = isset($field['rows']) ? (int)$field['rows'] : 4;
- $conditional = $this->handleConditionalField($field);
- $quill = (array_key_exists('quill', $field) && $field['quill'] == true) ? ' data-editor="true"' : '';
- if ($quill !== '') {
- $allowImages = array_key_exists('allowImage', $field);
- $quill .= ($allowImages) ? ' data-allowimage="true"' : ' data-allowimage="false"';
- }
- // Handle array values
- if (is_array($value)) {
- $value = implode(', ', $value);
- }
- if (array_key_exists('group', $field)) {
- $name = $field['group'].'::'.$name;
- }
- $placeholder = (array_key_exists('placeholder', $field)) ? ' placeholder="'.$field['placeholder'].'"' : '';
- ?>
- <div class="field <?=$field['type']?> <?=$name?>" <?=$conditional?> data-field="<?=$name?>">
- <label for="<?= esc_attr($name ?? ''); ?>">
- <?= esc_html($field['label']); ?>
- <?php if (!empty($field['limit'])) : ?>
- <span class="char-count" data-limit="<?= esc_attr($field['limit']); ?>">
- <span class="current">0</span>/<?= esc_attr($field['limit']); ?>
- </span>
- <?php endif; ?>
- </label>
- <textarea
- id="<?= array_key_exists('base', $field) ? esc_attr($field['base']) : ''?><?= esc_attr($name ?? ''); ?>"
- name="<?= array_key_exists('base', $field) ? esc_attr($field['base']) : ''?><?= esc_attr($name ?? ''); ?>"
- <?= $quill ?>
- rows="<?= esc_attr($rows); ?>"
- <?= $placeholder ?>
- <?= $describedBy ?>
- <?= !empty($field['required']) ? 'required' : ''; ?>
- <?= !empty($field['limit']) ? 'data-limit="' . esc_attr($field['limit']) . '"' : ''; ?>
- ><?= esc_textarea($value); ?></textarea>
- <?php if (array_key_exists('hint', $field)) { $this->renderHint($field['hint']); } ?>
- <?php if (array_key_exists('description', $field)) { $this->renderDescription($field['description'], $name); } ?>
- </div>
- <?php
- }
-
- private function renderSetField(string $name, mixed $value, array $field):void
- {
- $this->renderCheckboxField($name, $value, $field);
- }
-
- private function renderCheckboxField(string $name, mixed $value, array $field):void
- {
- $describedBy = (!empty($field['description'])) ? ' aria-describedby="'.$name.'-help"' : '';
- $value = !is_array($value) ? explode(',', $value) : $value;
- $limit = isset($field['limit']) ? (int)$field['limit'] : 0;
- $conditional = $this->handleConditionalField($field);
- if (array_key_exists('group', $field)) {
- $name = $field['group'].'::'.$name;
- }
- ?>
- <div class="field checkbox-group" <?= $limit ? 'data-limit="' . esc_attr($limit) . '"' : ''; ?> <?=$conditional?> data-field="<?=$name?>"<?=$describedBy?>>
- <span class="label"><?= esc_html($field['label']); ?></span>
- <div class="checkbox-options flex">
- <?php foreach ($field['options'] as $key => $label) : ?>
- <input
- type="checkbox"
- id="<?= array_key_exists('base', $field) ? esc_attr($field['base']) : ''?><?= esc_attr($name).'-'.esc_attr($key)?>"
- <?= array_key_exists('base', $field) ? esc_attr($field['base']) : ''?><?= esc_attr($name)?>[]"
- value="<?= esc_attr($key); ?>"
- <?= (in_array($key, $value)) ? 'checked' : ''; ?>
- <?= !empty($field['required']) ? 'required' : ''; ?>
- >
- <label class="checkbox-option" for="<?= esc_attr($name).'-'.esc_attr($key) ?>">
- <?= esc_html($label); ?>
- </label>
- <?php endforeach; ?>
- </div>
- <?php if (array_key_exists('hint', $field)) { $this->renderHint($field['hint']); } ?>
- <?php if (array_key_exists('description', $field)) { $this->renderDescription($field['description'], $name); } ?>
- </div>
- <?php
- }
- private function renderRadioField(string $name, mixed $value, array $field):void
- {
- $describedBy = (!empty($field['description'])) ? ' aria-describedby="'.$name.'-help"' : '';
- $value = (array)$value;
- $conditional = $this->handleConditionalField($field);
-
- if (!array_key_exists('label', $field)) {
- error_log('No label for: '.print_r($name, true));
- }
- if (array_key_exists('group', $field)) {
- $name = $field['group'].'::'.$name;
- }
- ?>
- <div class="field radio-group"<?=$conditional?> data-field="<?=$name?>"<?=$describedBy?>>
- <label><?= esc_html($field['label']); ?></label>
-
- <div class="radio-options row">
- <?php foreach ($field['options'] as $key => $label) : ?>
- <input
- type="radio"
- id="<?= array_key_exists('base', $field) ? esc_attr($field['base']) : ''?><?= esc_attr($name).'-'.esc_attr($key) ?>"
- name="<?= array_key_exists('base', $field) ? esc_attr($field['base']) : ''?><?= esc_attr($name); ?>"
- value="<?= esc_attr($key); ?>"
- <?= !empty($field['required']) ? 'required' : ''; ?>
- <?= (in_array($key, $value)) ? 'checked' : ''; ?>
- >
- <label class="radio-option" for="<?= esc_attr($name).'-'.esc_attr($key) ?>">
- <?= esc_html($label); ?>
- </label>
- <?php endforeach; ?>
- </div>
- <?php if (array_key_exists('hint', $field)) { $this->renderHint($field['hint']); } ?>
- <?php if (array_key_exists('description', $field)) { $this->renderDescription($field['description'], $name); } ?>
-
- </div>
- <?php
- }
-
- private function renderRepeaterField(string $name, mixed $value, array $field):void
- {
- error_log('Rendering Repeater Field!');
- $values = is_array($value) ? $value : array();
-
- $conditional = $this->handleConditionalField($field);
- $row_label = isset($field['row_label']) ? $field['row_label'] : '';
- $rowTitle = (array_key_exists('new_row', $field)) ? $field['new_row'] : 'New Item';
- if (array_key_exists('group', $field)) {
- $name = $field['group'].'::'.$name;
- }
- $describedBy = (!empty($field['description'])) ? ' aria-describedby="'.$name.'-help"' : '';
- ?>
- <div class="field repeater <?=$name?>"
- data-field="<?= esc_attr($name); ?>"
- <?= $describedBy ?>
- <?= $row_label ? 'data-label="' . esc_attr($row_label) . '"' : ''; ?>
- <?=$conditional?>>
- <?php
- if (!array_key_exists('label', $field)) {
- error_log('No label for: '.print_r($name, true));
- }
- ?>
- <h3><?= esc_html($field['label']); ?></h3>
-
-
- <div class="repeater-items">
- <?php
- if (!empty($values)) {
- foreach ($values as $index => $row) {
- $this->renderRepeaterRow($field['fields'], $row, $index, $name, $rowTitle);
- }
- }
- ?>
- </div>
-
- <template class="<?=uniqid('repeaterTemplate')?>">
- <?php $this->renderRepeaterRow($field['fields'], array(), '', '', $rowTitle); ?>
- </template>
-
- <button type="button" class="add-repeater-row">
- <?= jvbIcon('plus', ['title'=> 'Add']); ?> <?= (array_key_exists('add_label', $field)) ? $field['add_label'] : 'Add Item'; ?>
- </button>
- <?php if (array_key_exists('hint', $field)) { $this->renderHint($field['hint']); } ?>
- <?php if (array_key_exists('description', $field)) { $this->renderDescription($field['description'], $name); } ?>
- </div>
- <?php
- }
-
- private function renderRepeaterRow(array $fields, array $values, int|string $index, string $base_name, string $rowTitle = 'New Item'):void
- {
- $display_number = (is_string($index)) ? $index : ($index + 1);
- ?>
- <div class="repeater-row" data-index="<?= esc_attr($index); ?>">
- <details <?= (is_string($index)) ? 'open' : ''; ?>>
- <summary class="repeater-row-header row btw">
- <span class="drag-handle"><?= jvbIcon('dots-six-vertical'); ?></span>
- <span class="row-number">#<?= esc_html($display_number); ?></span>
- <span class="row-title"><?= esc_html($this->getRowTitle($fields, $values, $rowTitle)); ?></span>
- <button type="button" class="remove-row" title="Remove">
- <?= jvbIcon('trash', ['title'=>'Remove']); ?>
- </button>
- </summary>
- <div class="repeater-row-content">
- <?php
- foreach ($fields as $slug => $field) :
- if ($base_name === '') {
- $field_name = $slug;
- } else {
- $field_name = sprintf('%s:%s:%s', $base_name, $index, $slug);
- }
- $field_value = isset($values[$slug]) ? $values[$slug] : '';
- $name = $field_name;
- $this->render($name, $field_value, $field);
- endforeach;
- ?>
- </div>
- </details>
- </div>
- <?php
- }
-
- private function getRowTitle(array $fields, array $values, string $rowTitle):string
- {
- // Try to find the first text field or textarea value to use as title
- foreach ($fields as $slug => $field) {
- if (in_array($field['type'], ['text', 'textarea']) &&
- isset($values[$slug]) &&
- !empty($values[$slug])) {
- return $values[$slug];
- }
- }
- return $rowTitle;
- }
-
- private function renderTaxonomyField(string $name, mixed $value, array $field):void
- {
- $conditional = $this->handleConditionalField($field);
- $taxonomy = $field['taxonomy'];
-
- // Get currently selected terms
- $selected_terms = ($value === '') ? [] : explode(',', $value);
-
-
- // Convert selected term IDs to the format expected by single modal
- $processedSelected = [];
- if (!empty($selected_terms)) {
- foreach ($selected_terms as $termId) {
- if (is_numeric($termId)) {
- $term = get_term($termId, $taxonomy);
- if ($term && !is_wp_error($term)) {
- $processedSelected[$term->term_id] = [
- 'name' => html_entity_decode($term->name),
- 'path' => TaxonomySelector::getTermPath($term)
- ];
- }
- }
- }
- }
-
- // Create configuration for single modal system
- $config = [
- 'taxonomy' => $taxonomy,
- 'max' => $field['limit'] ?? 0,
- 'search' => $field['search'] ?? true,
- 'createNew' => $field['createNew'] ?? false,
- 'selected' => $processedSelected,
- 'base' => $field['base'] ?? '',
- ];
-
- $describedBy = (!empty($field['description'])) ? ' aria-describedby="'.$name.'-help"' : '';
?>
- <div class="field taxonomy <?=$name?>" <?= $conditional ?> data-field="<?=$name?>">
- <div class="field-group-header">
- <label class="toggle">
- <?= jvbIcon(str_replace(BASE, '', $taxonomy)) ?>
- <?= esc_html($field['label']) ?>
- </label>
- </div>
+ <div class="field <?= esc_attr($field['type']) ?> <?= esc_attr($name) ?> row"
+ <?= $conditional ?>
+ data-field="<?= esc_attr($name) ?>"
+ data-field-type="<?=esc_attr($field['type'])?>"
+ <?= $validationAttrs ?>>
- <?php
- $tax = new TaxonomySelector($name, $taxonomy, $config);
- $extra = '<input type="hidden"
- name="'.esc_attr($name).'"
- id="'.esc_attr($name).'"'.$describedBy.'
- data-taxonomy="'.esc_attr($taxonomy).'"
- value="'.esc_attr(is_array($selected_terms) ? implode(',', $selected_terms) : $selected_terms).'">';
- echo $tax->render([], $extra);
- ?>
+ <?php $this->renderLabel($name, $field); ?>
- <?php if (array_key_exists('hint', $field)) { $this->renderHint($field['hint']); } ?>
- <?php if (array_key_exists('description', $field)) { $this->renderDescription($field['description'], $name); } ?>
- </div>
- <?php
- }
+ <div class="quantity" <?= $customData ?>>
+ <button type="button"
+ class="decrease"
+ title="<?= array_key_exists('remove', $field) ? $field['remove'] : 'Decrease amount' ?>"
+ aria-label="Decrease <?= esc_attr($field['label']) ?>">
+ <?= jvbIcon('minus-square') ?>
+ </button>
- protected function renderPostSelectorField(string $name, mixed $value, array $field):void
- {
- $conditional = $this->handleConditionalField($field);
+ <input type="number"
+ id="<?= esc_attr($data['id']) ?>"
+ name="<?= esc_attr($data['name']) ?>"
+ value="<?= esc_attr($value) ?>"
+ min="<?= esc_attr($min) ?>"
+ max="<?= esc_attr($max) ?>"
+ step="<?= esc_attr($step) ?>"
+ class="quantity-input"
+ <?= $describedBy ?>
+ <?= $autocomplete ?>
+ <?= !empty($field['required']) ? 'required' : '' ?>>
- // Process selected posts
- $selected_posts = $value;
- if (is_string($selected_posts)) {
- $selected_posts = !empty($selected_posts) ? explode(',', $selected_posts) : [];
- } elseif (!is_array($selected_posts)) {
- $selected_posts = [];
- }
-
- // Configure the post selector
- $config = [
- 'multiple' => $field['multiple'] ?? true,
- 'maxSelections' => $field['limit'] ?? 0,
- 'search' => true,
- 'placeholder' => $field['placeholder'] ?? 'Search posts...',
- 'noResults' => 'No posts found',
- 'shop_id' => $field['shop_id'] ?? null,
- 'onClose' => 'updateMetaFormPost'
- ];
-
- $postSelector = new PostSelector($field['post_type'], $config);
- $containerId = $name . '-post-selector';
- $describedBy = (!empty($field['description'])) ? ' aria-describedby="'.$name.'-help"' : '';
- ?>
- <div class="field post-selector <?=$name?>" <?= $conditional ?> data-field="<?=$name?>">
- <div class="field-group-header">
- <label class="toggle">
- <?= jvbIcon($field['post_type'] . '-selector') ?>
- <?= esc_html($field['label'] ?? ucfirst($field['post_type'])) ?>
- </label>
- <button title="Add <?= esc_attr(ucfirst($field['post_type'])) ?>"
- class="add-item-btn"
- type="button">
- <?= jvbIcon('plus-square', ['title' => "Add " . ucfirst($field['post_type'])]) ?>
+ <button type="button"
+ class="increase"
+ title="<?= array_key_exists('add', $field) ? $field['add'] : 'Increase amount' ?>"
+ aria-label="Increase <?= esc_attr($field['label']) ?>">
+ <?= jvbIcon('plus-square') ?>
</button>
</div>
- <?= $postSelector->render($selected_posts, $containerId) ?>
-
- <!-- Hidden input for form submission -->
- <input type="hidden"
- name="<?= array_key_exists('base', $field) ? esc_attr($field['base']) : ''?><?= esc_attr($name) ?>"
- class="post-selector-input"
- <?= $describedBy ?>
- data-post-type="<?= esc_attr($field['post_type']) ?>"
- value="<?= esc_attr(is_array($selected_posts) ? implode(',', $selected_posts) : $selected_posts) ?>">
-
- <?php if (array_key_exists('hint', $field)) { $this->renderHint($field['hint']); } ?>
- <?php if (array_key_exists('description', $field)) { $this->renderDescription($field['description'], $name); } ?>
+ <?php $this->renderHintAndDescription($field, $name); ?>
</div>
<?php
- }
+ }
- protected function renderGroupField(string $name, mixed $value, array $field):void
+ /* ========== SELECT, RADIO, CHECKBOX FIELDS ========== */
+
+ private function renderSelectField(string $name, mixed $value, array $field): void
{
- if (!array_key_exists('fields', $field) || empty($field['fields'])) {
- return;
+ $data = $this->prepareFieldData($name, $value, $field);
+ $inputAttrs = $this->buildInputAttributes($name, $field);
+ $validationAttrs = $this->buildValidationAttributes($field);
+ $conditional = array_key_exists('condition', $field) ? $this->handleConditionalField($field) : '';
+
+ ?>
+ <div class="field <?= esc_attr($field['type']) ?> <?= esc_attr($name) ?>"
+ <?= $conditional ?>
+ data-field="<?= esc_attr($name) ?>"
+ data-field-type="<?=esc_attr($field['type'])?>"
+ <?= $validationAttrs ?>>
+
+ <?php $this->renderLabel($name, $field); ?>
+
+ <div class="field-input-wrapper">
+ <select
+ id="<?= esc_attr($data['id']) ?>"
+ name="<?= esc_attr($data['name']) ?>"
+ <?= $inputAttrs ?>>
+ <?php foreach ($field['options'] as $key => $label) : ?>
+ <option value="<?= esc_attr($key) ?>" <?php selected($value, $key); ?>>
+ <?= esc_html($label) ?>
+ </option>
+ <?php endforeach; ?>
+ </select>
+ <span class="validation-icon success" hidden aria-hidden="true">
+ <?= jvbIcon('check-circle') ?>
+ </span>
+ <span class="validation-icon error" hidden aria-hidden="true">
+ <?= jvbIcon('x-circle') ?>
+ </span>
+ </div>
+
+ <span class="validation-message" hidden role="alert"></span>
+
+ <?php $this->renderHintAndDescription($field, $name); ?>
+ </div>
+ <?php
+ }
+
+ private function renderRadioField(string $name, mixed $value, array $field): void
+ {
+ $data = $this->prepareFieldData($name, $value, $field);
+ $validationAttrs = $this->buildValidationAttributes($field);
+ $conditional = array_key_exists('condition', $field) ? $this->handleConditionalField($field) : '';
+
+ ?>
+ <div class="field <?= esc_attr($field['type']) ?> <?= esc_attr($name) ?>"
+ <?= $conditional ?>
+ data-field="<?= esc_attr($name) ?>"
+ data-field-type="<?=esc_attr($field['type'])?>"
+ <?= $validationAttrs ?>>
+
+ <fieldset>
+ <legend><?= esc_html($field['label']) ?>
+ <?php if (!empty($field['required'])) : ?>
+ <span class="required" aria-label="required">*</span>
+ <?php endif; ?>
+ </legend>
+
+ <?php foreach ($field['options'] as $key => $label) : ?>
+ <input
+ type="radio"
+ id="<?= esc_attr($data['name']) ?>-<?= esc_attr($key)?>"
+ name="<?= esc_attr($data['name']) ?>"
+ value="<?= esc_attr($key) ?>"
+ <?php checked($value, $key); ?>
+ <?= !empty($field['required']) ? 'required' : '' ?>
+ >
+ <label class="radio-option" for="<?= esc_attr($data['name']) ?>-<?= esc_attr($key)?>">
+ <span><?= $label ?></span>
+ </label>
+ <?php endforeach; ?>
+ </fieldset>
+
+ <span class="validation-message" hidden role="alert"></span>
+
+ <?php $this->renderHintAndDescription($field, $name); ?>
+ </div>
+ <?php
+ }
+
+ private function renderCheckboxField(string $name, mixed $value, array $field): void
+ {
+ $data = $this->prepareFieldData($name, $value, $field);
+ $validationAttrs = $this->buildValidationAttributes($field);
+ $conditional = array_key_exists('condition', $field) ? $this->handleConditionalField($field) : '';
+
+ if (!is_array($value)) {
+ $value = !empty($value) ? [$value] : [];
}
- // Handle conditional fields
- $conditional = $this->handleConditionalField($field);
+ ?>
+ <div class="field <?= esc_attr($field['type']) ?> <?= esc_attr($name) ?>"
+ <?= $conditional ?>
+ data-field="<?= esc_attr($name) ?>"
+ data-field-type="<?=esc_attr($field['type'])?>"
+ <?= $validationAttrs ?>>
- // Ensure value is an array
- $values = is_array($value) ? $value : [];
- $original = $name;
+ <fieldset>
+ <legend><?= esc_html($field['label']) ?>
+ <?php if (!empty($field['required'])) : ?>
+ <span class="required" aria-label="required">*</span>
+ <?php endif; ?>
+ </legend>
+
+ <?php foreach ($field['options'] as $key => $label) : ?>
+ <input
+ type="checkbox"
+ id="<?= esc_attr($data['name']) ?>-<?= esc_attr($key)?>"
+ name="<?= esc_attr($data['name']) ?>[]"
+ value="<?= esc_attr($key) ?>"
+ <?php checked(in_array($key, $value)); ?>
+ >
+ <label class="checkbox-option" for="<?= esc_attr($data['name']) ?>-<?= esc_attr($key)?>">
+ <span><?= esc_html($label) ?></span>
+ </label>
+ <?php endforeach; ?>
+ </fieldset>
+
+ <span class="validation-message" hidden role="alert"></span>
+
+ <?php $this->renderHintAndDescription($field, $name); ?>
+ </div>
+ <?php
+ }
+
+ private function renderTrueFalseField(string $name, mixed $value, array $field): void
+ {
+ $data = $this->prepareFieldData($name, $value, $field);
+ $validationAttrs = $this->buildValidationAttributes($field);
+ $conditional = array_key_exists('condition', $field) ? $this->handleConditionalField($field) : '';
+ $describedBy = (!empty($field['description'])) ? ' aria-describedby="' . $name . '-help"' : '';
+
+ ?>
+ <div class="field true-false <?= esc_attr($name) ?> row btw"
+ <?= $conditional ?>
+ data-field="<?= esc_attr($name) ?>"
+ data-field-type="<?=esc_attr($field['type'])?>"
+ <?= $validationAttrs ?>>
+
+ <label class="toggle-switch row" <?= $describedBy ?>>
+ <input
+ type="checkbox"
+ name="<?= esc_attr($data['name']) ?>"
+ value="1"
+ <?= ($value) ? ' checked' : '' ?>
+ <?= !empty($field['required']) ? 'required' : '' ?>
+ >
+ <div class="slider"></div>
+ <span class="toggle-label">
+ <?php if (!empty($field['required'])) : ?>
+ <span class="required" aria-label="required">*</span>
+ <?php endif; ?>
+
+ <?= esc_html($field['label']) ?></span>
+ </label>
+ <span class="validation-message" hidden role="alert"></span>
+ <?php $this->renderHintAndDescription($field, $name); ?>
+ </div>
+ <?php
+ }
+
+
+
+ /* ========== REPEATER FIELD ========== */
+
+ private function renderRepeaterField(string $name, mixed $value, array $field):void
+ {
+ $values = is_array($value) ? $value : array();
+
+ $conditional = $this->handleConditionalField($field);
+ $row_label = isset($field['row_label']) ? $field['row_label'] : '';
+ $rowTitle = (array_key_exists('new_row', $field)) ? $field['new_row'] : 'New Item';
if (array_key_exists('group', $field)) {
$name = $field['group'].'::'.$name;
}
$describedBy = (!empty($field['description'])) ? ' aria-describedby="'.$name.'-help"' : '';
- $hidden = (array_key_exists('mode', $field) && $field['mode'] === 'hidden');
- if (!$hidden) {
- ?>
- <fieldset class="field group <?= esc_attr($name) ?>" <?= $conditional ?> data-field="<?=$name?>"<?= $describedBy?>>
- <legend><?= esc_html($field['label']) ?></legend>
- <?php
- }
?>
+ <div class="field repeater <?=$name?>"
+ data-field="<?= esc_attr($name); ?>"
+ data-field-type="<?=esc_attr($field['type'])?>"
+ <?= $describedBy ?>
+ <?= $row_label ? 'data-label="' . esc_attr($row_label) . '"' : ''; ?>
+ <?=$conditional?>>
+ <?php
+ if (!array_key_exists('label', $field)) {
+ error_log('No label for: '.print_r($name, true));
+ }
+ ?>
+ <h3><?= esc_html($field['label']); ?></h3>
- <div class="group-fields <?=$original?>"<?= ($hidden) ? ' data-field="'.$name.'"' : ''?>"<?= $describedBy ?>>
- <?php if (array_key_exists('hint', $field)) { $this->renderHint($field['hint']); } ?>
- <?php if (array_key_exists('description', $field)) { $this->renderDescription($field['description'], $name); } ?>
+
+ <div class="repeater-items">
<?php
- foreach ($field['fields'] as $field_name => $config) {
- // Set the group context for proper field naming
- $config['group'] = $name;
-
- // Get the value for this specific field
- $field_value = $values[$field_name] ?? '';
-
- // Handle conditional fields within the group
- if (isset($config['condition'])) {
- // Convert condition field reference to group context
- $condition_field = $config['condition']['field'];
- if (!str_contains($condition_field, '::')) {
- $config['condition']['field'] = $name . '::' . $condition_field;
- }
+ if (!empty($values)) {
+ foreach ($values as $index => $row) {
+ $this->renderRepeaterRow($field['fields'], $row, $index, $name, $rowTitle);
}
-
- $this->render($field_name, $field_value, $config);
}
?>
</div>
- <?php
- if (!$hidden) {
- ?>
- </fieldset>
- <?php
- }
- }
- protected function renderLocationField(string $name, mixed $value, array $field): void
- {
- $googleMaps = JVB()->connect('maps');
- if (!$googleMaps->isSetUp()) {
- echo '<div class="notice notice-warning"><p>Google Maps not configured. Please configure in Integrations settings.</p></div>';
- return;
- }
- // Extract stored values
- if (is_string($value)) {
- $value = maybe_unserialize($value);
- }
- $stored_data = is_array($value) ? $value : [];
+ <template class="<?=uniqid('repeaterRow')?>">
+ <?php $this->renderRepeaterRow($field['fields'], array(), '', '', $rowTitle); ?>
+ </template>
- $address = $stored_data['address'] ?? '';
- $lat = $stored_data['lat'] ?? '';
- $lng = $stored_data['lng'] ?? '';
-
- // Generate unique field ID
- $field_id = esc_attr($name);
- $map_id = $field_id . '_map';
-
- // Handle grouped fields
- if (array_key_exists('group', $field)) {
- $name = $field['group'] . '::' . $name;
- }
- $describedBy = (!empty($field['description'])) ? ' aria-describedby="'.$name.'-help"' : '';
-
- // Prepare configuration for JavaScript initialization
- $js_config = [
- 'fieldId' => $field_id,
- 'initialCoords' => (!empty($lat) && !empty($lng)) ? [
- 'lat' => (float)$lat,
- 'lng' => (float)$lng
- ] : null
- ];
-
- // IMPORTANT: Properly escape the JSON for use in HTML attribute
- $json_config = htmlspecialchars(json_encode($js_config), ENT_QUOTES, 'UTF-8');
- ?>
-
- <div class="field location <?= esc_attr($field_id) ?>"
- data-field="<?= esc_attr($field_id) ?>"
- data-location-field-init="<?= $json_config ?>"<?=$describedBy?>>
-
- <?php
- if (!empty($stored_data['street'])) {
- echo '<p><b>Current location:</b> '.esc_html($stored_data['street']).'</p>';
- echo '<p class="hint"><b>Search below to change:</b></p>';
- }
- ?>
+ <button type="button" class="add-repeater-row">
+ <?= jvbIcon('plus', ['title'=> 'Add']); ?> <?= (array_key_exists('add_label', $field)) ? $field['add_label'] : 'Add Item'; ?>
+ </button>
<?php if (array_key_exists('hint', $field)) { $this->renderHint($field['hint']); } ?>
<?php if (array_key_exists('description', $field)) { $this->renderDescription($field['description'], $name); } ?>
-
- <div class="location-search-wrapper">
- <div class="autocomplete-wrapper"></div>
-
- <!-- Map container -->
- <div class="location-preview">
- <div id="<?= esc_attr($map_id); ?>"
- class="location-map">
- </div>
-
- <?php if (!empty($stored_data)):
- jvbLocationLinks($stored_data);
- endif; ?>
- </div>
-
- <!-- Hidden inputs for data storage -->
- <input type="hidden"
- name="<?= array_key_exists('base', $field) ? esc_attr($field['base']) : ''?><?= esc_attr($name); ?>[address]"
- value="<?= esc_attr($address); ?>"
- data-location-field="address">
-
- <input type="hidden"
- name="<?= array_key_exists('base', $field) ? esc_attr($field['base']) : ''?><?= esc_attr($name); ?>[lat]"
- value="<?= esc_attr($lat); ?>"
- data-location-field="lat">
-
- <input type="hidden"
- name="<?= array_key_exists('base', $field) ? esc_attr($field['base']) : ''?><?= esc_attr($name); ?>[lng]"
- value="<?= esc_attr($lng); ?>"
- data-location-field="lng">
-
- <?php
- // Component fields
- $components = ['street', 'city', 'province', 'postal_code', 'country'];
- foreach ($components as $component):
- ?>
- <input type="hidden"
- name="<?= array_key_exists('base', $field) ? esc_attr($field['base']) : ''?><?= esc_attr($name); ?>[<?= $component; ?>]"
- value="<?= esc_attr($stored_data[$component] ?? ''); ?>"
- data-location-field="<?= esc_attr($component); ?>">
- <?php endforeach; ?>
-
- </div>
</div>
<?php
}
- //TODO: This is more or less handled by PostSelector/TaxonomySelector, no?
- private function renderAssociationField(string $name, mixed $value, array $field):void
- {
- // Ensure value is an array
- if (!is_array($value)) {
- $value = empty($value) ? [] : [$value];
- }
- // Get field configuration
- $limit = isset($field['limit']) ? (int)$field['limit'] : 0;
- $object_types = isset($field['object_types']) ? $field['object_types'] : ['post'];
- $post_types = isset($field['post_types']) ? $field['post_types'] : ['post'];
- $taxonomies = isset($field['taxonomies']) ? $field['taxonomies'] : [];
- $describedBy = (!empty($field['description'])) ? ' aria-describedby="'.$name.'-help"' : '';
- // Create unique ID for this field
- $field_id = 'association-' . esc_attr($name);
- $conditional = $this->handleConditionalField($field);
- if (array_key_exists('group', $field)) {
- $name = $field['group'].'::'.$name;
- }
- ?>
- <div class="field association <?=$name?>" data-field="<?= esc_attr($name); ?>" <?= $conditional; ?>>
- <label><?= esc_html($field['label']); ?></label>
- <?php if (array_key_exists('hint', $field)) { $this->renderHint($field['hint']); } ?>
- <?php if (array_key_exists('description', $field)) { $this->renderDescription($field['description'], $name); } ?>
+ private function renderRepeaterRow(array $fields, array $values, int|string $index, string $base_name, string $rowTitle = 'New Item'):void
+ {
+ $display_number = (is_string($index)) ? $index : ($index + 1);
+ ?>
+ <div class="repeater-row" data-index="<?= esc_attr($index); ?>">
+ <details <?= (is_string($index)) ? 'open' : ''; ?>>
+ <summary class="repeater-row-header row btw">
+ <span class="drag-handle"><?= jvbIcon('dots-six-vertical'); ?></span>
+ <span class="row-number">#<?= esc_html($display_number); ?></span>
+ <span class="row-title"><?= esc_html($this->getRowTitle($fields, $values, $rowTitle)); ?></span>
+ <button type="button" class="remove-row" title="Remove">
+ <?= jvbIcon('trash', ['title'=>'Remove']); ?>
+ </button>
+ </summary>
+ <div class="repeater-row-content">
+ <?php
+ foreach ($fields as $slug => $field) :
+ if ($base_name === '') {
+ $field_name = $slug;
+ } else {
+ $field_name = sprintf('%s:%s:%s', $base_name, $index, $slug);
+ }
+ $field_value = isset($values[$slug]) ? $values[$slug] : '';
+ $name = $field_name;
+ $this->render($name, $field_value, $field);
+ endforeach;
+ ?>
+ </div>
+ </details>
+ </div>
+ <?php
+ }
- <div class="association-container"<?=$describedBy?>>
- <div class="association-search">
- <input type="text"
- id="<?= esc_attr($field_id); ?>-search"
- class="association-search-input"
- placeholder="Search items...">
+ private function getRowTitle(array $fields, array $values, string $rowTitle): string
+ {
+ // Try to find the first text field or textarea value to use as title
+ foreach ($fields as $slug => $field) {
+ if (in_array($field['type'], ['text', 'textarea']) &&
+ isset($values[$slug]) &&
+ !empty($values[$slug])) {
+ return $values[$slug];
+ }
+ }
+ return $rowTitle;
+ }
- <div class="association-filter">
- <?php if (count($object_types) > 1 || count($post_types) > 1 || count($taxonomies) > 0) : ?>
- <select class="association-filter-select">
- <?php if (in_array('post', $object_types)) : ?>
- <?php foreach ($post_types as $post_type) : ?>
- <?php
- $post_type_obj = get_post_type_object($post_type);
- $label = $post_type_obj ? $post_type_obj->labels->singular_name : ucfirst($post_type);
- ?>
- <option value="post:<?= esc_attr($post_type); ?>">
- <?= esc_html($label); ?>
- </option>
- <?php endforeach; ?>
- <?php endif; ?>
+ /* ========== GROUP FIELD ========== */
- <?php if (in_array('term', $object_types)) : ?>
- <?php foreach ($taxonomies as $taxonomy) : ?>
- <?php
- $tax_obj = get_taxonomy($taxonomy);
- $label = $tax_obj ? $tax_obj->labels->singular_name : ucfirst($taxonomy);
- ?>
- <option value="term:<?= esc_attr($taxonomy); ?>">
- <?= esc_html($label); ?>
- </option>
- <?php endforeach; ?>
- <?php endif; ?>
- </select>
- <?php endif; ?>
+ protected function renderGroupField(string $name, mixed $value, array $field): void
+ {
+ if (!array_key_exists('fields', $field) || empty($field['fields'])) {
+ error_log('No fields to render');
+ return;
+ }
- <button type="button" class="search-button">
- <?= jvbIcon('magnifying-glass', ['title' => 'Search']); ?>
- </button>
- </div>
- </div>
- <div class="association-results">
- <div class="association-available">
- <h4>Available Items</h4>
- <ul class="available-items"></ul>
- <div class="association-loading" hidden>
- Loading...
- </div>
- <div class="association-no-results" hidden>
- No items found
- </div>
- <div class="association-pagination">
- <button type="button" class="prev-page" disabled>
- <?= jvbIcon('arrow-left', ['title' => 'Previous']); ?>
- </button>
- <span class="page-info">Page <span class="current-page">1</span></span>
- <button type="button" class="next-page" disabled>
- <?= jvbIcon('arrow-right', ['title' => 'Next']); ?>
- </button>
- </div>
- </div>
+ $values = is_array($value) ? $value : [];
+ $original = $name;
- <div class="association-actions">
- <button type="button" class="add-selected" disabled>
- <?= jvbIcon('arrow-right', ['title' => 'Add selected']); ?>
- </button>
- <button type="button" class="remove-selected" disabled>
- <?= jvbIcon('arrow-left', ['title' => 'Remove selected']); ?>
- </button>
- </div>
+ if (array_key_exists('group', $field)) {
+ $name = $field['group'] . '::' . $name;
+ }
- <div class="association-selected">
- <h4>Selected Items
- <?php if ($limit) : ?>
- <span class="limit-info">(<?= esc_html($limit); ?> max)</span>
- <?php endif; ?>
- </h4>
- <ul class="selected-items row">
- <?php
- // Display currently selected items
- foreach ($value as $item_id) {
- // Try to determine the type and get details
- $item_type = '';
- $item_title = '';
- $item_object = '';
+ $hidden = (array_key_exists('mode', $field) && $field['mode'] === 'hidden');
- // Check if it's a post
- if (in_array('post', $object_types)) {
- $post = get_post($item_id);
- if ($post && in_array($post->post_type, $post_types)) {
- $item_type = 'post';
- $item_title = $post->post_title;
- $item_object = $post->post_type;
- }
- }
+ if ($hidden) {
+ // Simplified render for hidden groups
+ $this->renderGroupFields($name, $values, $field);
+ return;
+ }
- // Check if it's a term
- if (empty($item_type) && in_array('term', $object_types)) {
- foreach ($taxonomies as $taxonomy) {
- $term = get_term($item_id, $taxonomy);
- if (!is_wp_error($term) && $term) {
- $item_type = 'term';
- $item_title = html_entity_decode($term->name);
- $item_object = $term->taxonomy;
- break;
- }
- }
- }
+ // Standard fieldset render
+ $conditional = $this->handleConditionalField($field);
+ $validationAttrs = $this->buildValidationAttributes($field);
+ $describedBy = (!empty($field['description'])) ? ' aria-describedby="' . $name . '-help"' : '';
+ $fieldset = (array_key_exists('wrap', $field) && $field['wrap'] === 'details') ? 'details' : 'fieldset';
+ $legend = (array_key_exists('wrap', $field) && $field['wrap'] === 'details') ? 'summary' : 'legend';
+ ?>
+ <<?= $fieldset?> class="field group <?= esc_attr($name) ?>"
+ <?= $conditional ?>
+ data-field="<?= esc_attr($name) ?>"
+ data-field-type="<?=esc_attr($field['type'])?>"
+ <?= $validationAttrs ?>
+ <?= $describedBy ?>>
+ <<?=$legend?>><?= esc_html($field['label']) ?></<?=$legend?>>
- // Only output if we found the item
- if (!empty($item_type) && !empty($item_title)) {
- ?>
- <li data-id="<?= esc_attr($item_id); ?>"
- data-type="<?= esc_attr($item_type); ?>"
- data-object="<?= esc_attr($item_object); ?>">
- <span class="item-title"><?= esc_html($item_title); ?></span>
- <span class="item-type"><?= esc_html(ucfirst($item_object)); ?></span>
- <button type="button" class="remove-item row">
- <?= jvbIcon('x', ['title' => 'Remove']); ?>
- </button>
- </li>
- <?php
- }
- }
- ?>
- </ul>
- </div>
- </div>
- </div>
+ <?php $this->renderHintAndDescription($field, $name); ?>
- <!-- Hidden input to store selected values -->
- <input type="hidden" name="<?= array_key_exists('base', $field) ? esc_attr($field['base']) : ''?><?= esc_attr($name); ?>" value="<?= esc_attr(implode(',', $value)); ?>">
- </div>
+ <div class="group-fields <?= esc_attr($original) ?>">
+ <?php $this->renderGroupFields($name, $values, $field); ?>
+ </div>
- <script>
- (function() {
- // Initialize association field
- const container = document.querySelector('[data-field="<?= esc_attr($name); ?>"]');
- if (!container) return;
+ <span class="validation-message" hidden role="alert"></span>
+ </<?= $fieldset?>>
+ <?php
+ }
- const searchInput = container.querySelector('.association-search-input');
- const filterSelect = container.querySelector('.association-filter-select');
- const searchButton = container.querySelector('.search-button');
- const availableList = container.querySelector('.available-items');
- const selectedList = container.querySelector('.selected-items');
- const hiddenInput = container.querySelector('input[name="<?= array_key_exists('base', $field) ? esc_attr($field['base']) : ''?><?= esc_attr($name); ?>"]');
- const addButton = container.querySelector('.add-selected');
- const removeButton = container.querySelector('.remove-selected');
- const loadingIndicator = container.querySelector('.association-loading');
- const noResultsMessage = container.querySelector('.association-no-results');
- const prevPageButton = container.querySelector('.prev-page');
- const nextPageButton = container.querySelector('.next-page');
- const currentPageSpan = container.querySelector('.current-page');
+ /**
+ * Render individual fields within a group
+ * Reusable for both standard and hidden group modes
+ */
+ private function renderGroupFields(string $groupName, array $values, array $field): void
+ {
+ foreach ($field['fields'] as $field_name => $config) {
+ // Set the group context for proper field naming
+ if (!array_key_exists('wrap', $field) || $field['wrap'] !== 'details') {
+ $config['group'] = $groupName;
+ }
- // Configuration
- const config = {
- limit: <?= $limit ?: 0; ?>,
- objectTypes: <?= json_encode($object_types); ?>,
- postTypes: <?= json_encode($post_types); ?>,
- taxonomies: <?= json_encode($taxonomies); ?>,
- perPage: 10,
- currentPage: 1
- };
+ // Get the value for this specific field
+ $field_value = $values[$field_name] ?? '';
- // Current state
- let currentSearch = '';
- let currentFilter = filterSelect ? filterSelect.value : (config.objectTypes.includes('post') ? 'post:' + config.postTypes[0] : 'term:' + config.taxonomies[0]);
- let availableItems = [];
- let selectedItems = [];
+ // Handle conditional fields within the group
+ if (isset($config['condition'])) {
+ $condition_field = $config['condition']['field'];
+ if (!str_contains($condition_field, '::')) {
+ $config['condition']['field'] = $groupName . '::' . $condition_field;
+ }
+ }
- // Parse initial selected items
- const initialValue = hiddenInput.value;
- if (initialValue) {
- selectedItems = initialValue.split(',').map(id => parseInt(id, 10));
- }
+ $this->render($field_name, $field_value, $config);
+ }
+ }
- // Event Listeners
- if (searchButton) {
- searchButton.addEventListener('click', performSearch);
- }
-
- if (searchInput) {
- searchInput.addEventListener('keypress', function(e) {
- if (e.key === 'Enter') {
- e.preventDefault();
- performSearch();
- }
- });
- }
-
- if (filterSelect) {
- filterSelect.addEventListener('change', function() {
- currentFilter = this.value;
- config.currentPage = 1;
- performSearch();
- });
- }
-
- if (prevPageButton) {
- prevPageButton.addEventListener('click', function() {
- if (config.currentPage > 1) {
- config.currentPage--;
- performSearch();
- }
- });
- }
-
- if (nextPageButton) {
- nextPageButton.addEventListener('click', function() {
- config.currentPage++;
- performSearch();
- });
- }
-
- // Add items
- addButton.addEventListener('click', function() {
- const selectedAvailableItems = availableList.querySelectorAll('li.selected');
- selectedAvailableItems.forEach(item => {
- const id = parseInt(item.dataset.id, 10);
- // Check limit
- if (config.limit && selectedItems.length >= config.limit) {
- return;
- }
-
- // Skip if already selected
- if (selectedItems.includes(id)) {
- return;
- }
-
- // Add to selection
- selectedItems.push(id);
-
- // Clone and modify for selected list
- const clone = item.cloneNode(true);
- clone.classList.remove('selected');
-
- // Replace checkbox with remove button
- const checkbox = clone.querySelector('input[type="checkbox"]');
- if (checkbox) {
- const removeBtn = document.createElement('button');
- removeBtn.type = 'button';
- removeBtn.className = 'remove-item';
- removeBtn.innerHTML = '<?= jvbIcon('x', ['title' => 'Remove']); ?>';
- removeBtn.addEventListener('click', function() {
- removeItem(id, clone);
- });
-
- checkbox.parentNode.replaceChild(removeBtn, checkbox);
- }
-
- selectedList.appendChild(clone);
- });
-
- // Update hidden input
- updateHiddenInput();
-
- // Update UI state
- updateButtonStates();
- });
-
- // Remove items
- removeButton.addEventListener('click', function() {
- const selectedSelectedItems = selectedList.querySelectorAll('li.selected');
- selectedSelectedItems.forEach(item => {
- const id = parseInt(item.dataset.id, 10);
- removeItem(id, item);
- });
- });
-
- // Listen for clicks on items in both lists
- availableList.addEventListener('click', function(e) {
- const item = e.target.closest('li');
- if (!item) return;
-
- // If clicking checkbox, handle separately
- if (e.target.type === 'checkbox') {
- updateButtonStates();
- return;
- }
-
- // Toggle selection
- if (item.classList.contains('selected')) {
- item.classList.remove('selected');
- item.querySelector('input[type="checkbox"]').checked = false;
- } else {
- item.classList.add('selected');
- item.querySelector('input[type="checkbox"]').checked = true;
- }
-
- updateButtonStates();
- });
-
- selectedList.addEventListener('click', function(e) {
- const item = e.target.closest('li');
- if (!item) return;
-
- // If clicking remove button, handle it
- if (e.target.closest('.remove-item')) {
- const id = parseInt(item.dataset.id, 10);
- removeItem(id, item);
- return;
- }
-
- // Toggle selection
- item.classList.toggle('selected');
- updateButtonStates();
- });
-
- // Helper Functions
- function performSearch() {
- currentSearch = searchInput.value.trim();
-
- // Show loading
- loadingIndicator.hidden = false;
- noResultsMessage.hidden = true;
- availableList.innerHTML = '';
-
- // Get filter parts
- const [type, object] = currentFilter.split(':');
-
- // Prepare data for AJAX
- const data = {
- action: 'jvb_association_search',
- nonce: jvbSettings.nonce,
- type: type,
- object: object,
- search: currentSearch,
- page: config.currentPage,
- per_page: config.perPage,
- selected: selectedItems
- };
-
- // Make AJAX request to WordPress REST API
- fetch(jvbSettings.api + 'terms', {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/json',
- 'X-WP-Nonce': jvbSettings.nonce
- },
- body: JSON.stringify(data)
- })
- .then(response => response.json())
- .then(response => {
- // Hide loading
- loadingIndicator.hidden = true;
-
- if (response.success && response.items && response.items.length > 0) {
- // Update available items
- availableItems = response.items;
-
- // Render items
- renderAvailableItems();
-
- // Update pagination
- updatePagination(response.total, response.pages);
- } else {
- // Show no results
- noResultsMessage.hidden = false;
- prevPageButton.disabled = true;
- nextPageButton.disabled = true;
- currentPageSpan.textContent = '1';
- }
- })
- .catch(error => {
- console.error('Error searching items:', error);
- loadingIndicator.hidden = true;
- noResultsMessage.hidden = false;
- });
- }
-
- function renderAvailableItems() {
- availableList.innerHTML = '';
-
- availableItems.forEach(item => {
- const isSelected = selectedItems.includes(item.id);
-
- const li = document.createElement('li');
- li.dataset.id = item.id;
- li.dataset.type = item.type;
- li.dataset.object = item.object;
-
- // Create checkbox
- const checkbox = document.createElement('input');
- checkbox.type = 'checkbox';
- checkbox.id = `${name}-item-${item.id}`;
-
- // Create label for title
- const titleSpan = document.createElement('span');
- titleSpan.className = 'item-title';
- titleSpan.textContent = item.title;
-
- // Create label for type
- const typeSpan = document.createElement('span');
- typeSpan.className = 'item-type';
- typeSpan.textContent = item.object_label;
-
- // Append elements
- li.appendChild(checkbox);
- li.appendChild(titleSpan);
- li.appendChild(typeSpan);
-
- // Disable if already selected
- if (isSelected) {
- li.classList.add('disabled');
- checkbox.disabled = true;
-
- // Add note that item is already selected
- const note = document.createElement('span');
- note.className = 'item-note';
- note.textContent = 'Already selected';
- li.appendChild(note);
- }
-
- availableList.appendChild(li);
- });
- }
-
- function updatePagination(total, pages) {
- // Update current page display
- currentPageSpan.textContent = config.currentPage;
-
- // Update prev/next buttons
- prevPageButton.disabled = config.currentPage <= 1;
- nextPageButton.disabled = config.currentPage >= pages;
- }
-
- function removeItem(id, element) {
- // Remove from array
- selectedItems = selectedItems.filter(itemId => itemId !== id);
-
- // Remove from DOM
- if (element) {
- element.remove();
- }
-
- // Update hidden input
- updateHiddenInput();
-
- // Update buttons
- updateButtonStates();
- }
-
- function updateHiddenInput() {
- hiddenInput.value = selectedItems.join(',');
- }
-
- function updateButtonStates() {
- // Add button is enabled if at least one available item is selected
- // and we haven't reached the limit
- const hasSelectedAvailable = availableList.querySelector('li.selected:not(.disabled)') !== null;
- addButton.disabled = !hasSelectedAvailable ||
- (config.limit > 0 && selectedItems.length >= config.limit);
-
- // Remove button is enabled if at least one selected item is selected
- const hasSelectedItems = selectedList.querySelector('li.selected') !== null;
- removeButton.disabled = !hasSelectedItems;
- }
-
- // Initial search
- performSearch();
- })();
- </script>
- <?php
- }
-
- private function renderTrueFalseField(string $name, mixed $value, array $field):void
- {
- $conditional = $this->handleConditionalField($field);
- if (array_key_exists('group', $field)) {
- $name = $field['group'].'::'.$name;
- }
- $describedBy = (!empty($field['description'])) ? ' aria-describedby="'.$name.'-help"' : '';
- ?>
- <div class="field true-false <?=$name?> row btw" <?=$conditional?> data-field="<?=$name?>">
- <label class="toggle-switch row"<?=$describedBy?>>
- <input
- type="checkbox"
- name="<?= array_key_exists('base', $field) ? esc_attr($field['base']) : ''?><?= esc_attr($name); ?>"
- value="1"
- <?= ($value) ? ' checked':'' ?>
- <?= !empty($field['required']) ? 'required' : ''; ?>
- >
- <div class="slider"></div>
- <span class="toggle-label"><?= esc_html($field['label']); ?></span>
- </label>
- <?php if (array_key_exists('hint', $field)) { $this->renderHint($field['hint']); } ?>
- <?php if (array_key_exists('description', $field)) { $this->renderDescription($field['description'], $name); } ?>
- </div>
- <?php
- }
-
+ /* ========== UPLOAD FIELD ========== */
+ private function renderGalleryField(string $name, mixed $value, array $field):void
+ {
+ $field['multiple'] = true;
+ $this->renderUploadField($name, $value, $field);
+ }
private function renderUploadField(string $name, mixed $value, array $field): void
{
$defaultConfig = [
@@ -1313,7 +831,7 @@
//Processing Options
'max_size' => null, // Override default size limits
'convert' => 'webp', // Image conversion format
- 'quality' => 80, // Conversion quality
+ 'quality' => 90, // Conversion quality
'create_thumbnails' => true,
];
$config = array_merge($defaultConfig, $field);
@@ -1329,7 +847,7 @@
// Build accept attribute for input
$acceptExtensions = $this->getMimeExtensions($acceptedTypes);
- $acceptAttr = implode(',', $acceptExtensions);
+ $acceptAttr = implode(',', $acceptedTypes);
// Determine field attributes
$subtype = $config['subtype'] ?? 'image';
@@ -1375,6 +893,8 @@
}
?>
<div class="field upload <?= esc_attr($name) ?>"
+ data-field="<?=esc_attr($name)?>"
+ data-field-type="upload"
<?= $dataAttrString ?>
<?= $conditional ?>>
@@ -1412,6 +932,7 @@
<?php endif; ?>
<div class="file-error"></div>
</div>
+ <?php jvbRenderProgressBar(); ?>
</div>
@@ -1422,7 +943,7 @@
<div class="selection-controls">
<div class="selected">
<div class="field">
- <input type="checkbox" id="select-all-uploads" name="select-all-uploads">
+ <input type="checkbox" id="select-all-uploads" data-select-all data-selects="item-grid" name="select-all-uploads">
<label for="select-all-uploads">
Select All
</label>
@@ -1489,101 +1010,27 @@
<?php
}
-
- protected function getAllowedTypes(array $config):array
+ private function renderExistingAttachment(int $attachmentId, string $subtype): string
{
- $typeMap = [
- 'image' => [
- 'image/jpeg',
- 'image/png',
- 'image/gif',
- 'image/webp'
- ],
- 'video' => [
- 'video/mp4',
- 'video/webm',
- 'video/ogg',
- 'video/ogv',
- 'video/quicktime'
- ],
- 'document' => [
- 'application/pdf',
- 'application/msword',
- 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
- 'application/vnd.ms-excel',
- 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
- 'text/plain',
- 'text/csv'
- ],
- 'any' => [] // Will be merged from all types
- ];
- // If specific types are defined, use those
- if (!empty($config['accepted_types'])) {
- return is_array($config['accepted_types'])
- ? $config['accepted_types']
- : [$config['accepted_types']];
+ ob_start();
+
+ switch ($subtype) {
+ case 'image':
+ $this->renderImagePreview($attachmentId);
+ break;
+ case 'video':
+ $this->renderVideoPreview($attachmentId);
+ break;
+ case 'document':
+ case 'file':
+ $this->renderFilePreview($attachmentId);
+ break;
+ default:
+ $this->renderImagePreview($attachmentId);
+ break;
}
- // Otherwise use subtype defaults
- $subtype = $config['subtype'] ?? 'image';
-
- if ($subtype === 'any') {
- return array_merge(
- $typeMap['image'],
- $typeMap['video'],
- $typeMap['document']
- );
- }
-
- return $typeMap[$subtype] ?? $typeMap['image'];
-
- }
- /**
- * Parse attachment IDs from value
- */
- private function parseAttachmentIds(mixed $value): array
- {
- if (empty($value)) return [];
-
- if (is_array($value)) {
- return array_filter(array_map('absint', $value));
- }
-
- return array_filter(array_map('absint', explode(',', $value)));
- }
-
- /**
- * Get file extensions for MIME types
- */
- private function getMimeExtensions(array $mimeTypes): array
- {
- $extensionMap = [
- 'image/jpeg' => ['.jpg', '.jpeg'],
- 'image/png' => ['.png'],
- 'image/gif' => ['.gif'],
- 'image/webp' => ['.webp'],
- 'video/mp4' => ['.mp4'],
- 'video/webm' => ['.webm'],
- 'video/ogg' => ['.ogg'],
- 'video/ogv' => ['.ogv'],
- 'video/quicktime' => ['.mov'],
- 'application/pdf' => ['.pdf'],
- 'application/msword' => ['.doc'],
- 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' => ['.docx'],
- 'application/vnd.ms-excel' => ['.xls'],
- 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' => ['.xlsx'],
- 'text/plain' => ['.txt'],
- 'text/csv' => ['.csv'],
- ];
-
- $extensions = [];
- foreach ($mimeTypes as $mime) {
- if (isset($extensionMap[$mime])) {
- $extensions = array_merge($extensions, $extensionMap[$mime]);
- }
- }
-
- return array_unique($extensions);
+ return ob_get_clean();
}
/**
@@ -1599,15 +1046,7 @@
return $sizes[$subtype] ?? $sizes['image'];
}
- /**
- * Get human-readable file size label
- */
- private function getMaxFileSizeLabel(string $subtype): string
- {
- $bytes = $this->getMaxFileSize($subtype);
- $mb = round($bytes / 1048576);
- return "{$mb}MB";
- }
+
/**
* Format file size for display
*/
@@ -1643,486 +1082,700 @@
}
/**
- * Render existing attachment
+ * Render upload preview items
*/
- private function renderExistingAttachment(int $attachmentId, string $subtype): string
+ private function renderUploadPreviews(array $attachmentIds, array $config): void
{
- $attachment = get_post($attachmentId);
- if (!$attachment) return '';
-
- $url = wp_get_attachment_url($attachmentId);
- $thumbUrl = $subtype === 'image'
- ? wp_get_attachment_image_url($attachmentId, 'medium')
- : $url;
-
- ob_start();
- ?>
- <div class="upload-item existing" data-attachment-id="<?= esc_attr($attachmentId) ?>" data-subtype="<?= esc_attr($subtype) ?>">
- <div class="preview">
- <?php if ($subtype === 'image') : ?>
- <img src="<?= esc_url($thumbUrl) ?>" alt="<?= esc_attr(get_post_meta($attachmentId, '_wp_attachment_image_alt', true)) ?>">
- <?php elseif ($subtype === 'video') : ?>
- <video src="<?= esc_url($url) ?>" controls></video>
- <?php else : ?>
- <div class="document-preview">
- <?= jvbIcon('document') ?>
- <span><?= esc_html(basename($url)) ?></span>
- </div>
- <?php endif; ?>
-
- <div class="overlay">
- <div class="actions">
- <button type="button" class="remove" title="Remove">
- <span class="screen-reader-text">Remove <?= esc_attr($subtype) ?></span>
- ×
- </button>
- </div>
- </div>
- </div>
-
- <?php if ($subtype === 'image') {
- echo jvbImageMeta();
- } ?>
- </div>
- <?php
- return ob_get_clean();
- }
-
- private function renderImageField(string $name, mixed $value, array $field):void
- {
- $image_url = $title = $alt = $caption = false;
- if ($value !== 0 || $value !== '') {
- $image_url = wp_get_attachment_image_url((int)$value, 'medium') ?: false;
- $caption = wp_get_attachment_caption((int)$value);
- $alt = get_post_meta((int)$value, '_wp_attachment_image_alt', true);
- $title = get_the_title((int)$value);
+ if (empty($attachmentIds)) {
+ return;
}
- $mode = array_key_exists('mode', $field) ? $field['mode'] : 'direct';
- $multiple = ($mode === 'selection' || isset($field['multiple']));
- if (array_key_exists('group', $field)) {
- $name = $field['group'].'::'.$name;
- }
- $groupable = (array_key_exists('imageType', $field) && $field['imageType'] === 'groupable');
- $singular = (array_key_exists('singular', $field)) ? $field['singular'] : 'post';
- $plural = (array_key_exists('plural', $field)) ? $field['plural'] : 'posts';
- $dataContent = (array_key_exists('content', $field)) ? ' data-content="'.$field['content'].'"' : '';
- $dataType = ($groupable) ? 'groupable' : (($multiple) ? 'gallery' : 'single');
- $describedBy = (!empty($field['description'])) ? ' aria-describedby="'.$name.'-help"' : '';
- ?>
- <div class="field image <?=$name?>"
- data-field="<?= esc_attr($name); ?>"
- data-upload-field
- data-mode="<?= esc_attr($mode); ?>"
- <?=$dataContent?>
- <?= ' data-type="'.$dataType.'"'?>>
+ foreach ($attachmentIds as $id) {
+ switch ($config['subtype']) {
+ case 'image':
+ $this->renderImagePreview($id, $config);
+ break;
+ case 'video':
+ $this->renderVideoPreview($id, $config);
+ break;
+ case 'file':
+ $this->renderFilePreview($id, $config);
+ break;
+ }
+ }
+ }
- <div class="file-upload-container">
- <div class="file-upload-wrapper">
- <input type="file"
- name="<?= array_key_exists('base', $field) ? esc_attr($field['base']) : ''?><?= esc_attr($name); ?>_temp"
- id="<?= array_key_exists('base', $field) ? esc_attr($field['base']) : ''?><?= esc_attr($name); ?>_temp"
- accept=".jpg,.jpeg,.png,.gif,.webp"
- data-max-size="<?= $this->max_file_size; ?>"
- <?= $multiple ? 'multiple' : ''; ?>>
- <h2><?= esc_html($field['label']); ?></h2>
- <?php if (!empty($field['description'])) : ?>
- <p><?= esc_html($field['description']); ?></p>
- <?php endif; ?>
- <p class="file-upload-text">
- <strong>Click to upload</strong> or drag and drop<br>
- JPG, PNG, GIF, or WEBP (max. 5MB)
- </p>
- <?php if ($groupable) { ?>
- <p class="hint">You can group images to create separate <?= $plural ?>.</p>
- <p class="hint">If a <?=$singular?> has multiple images, you can select the <?= jvbIcon('star')?> to set an image as the main one.</p>
- <?php } ?>
- <?php if (!empty($field['upload_description'])) : ?>
- <p><?= esc_html($field['upload_description']); ?></p>
- <?php endif; ?>
- </div>
- <div class="file-error"></div>
- </div>
- <?php if ($groupable) : ?>
- <div class="group-display" hidden>
- <div class="preview-wrap">
- <div class="preview-actions">
- <div class="selection-controls">
- <div class="selected">
- <div class="field">
- <input type="checkbox" id="select-all-uploads" name="select-all-uploads">
- <label for="select-all-uploads">
- Select All
- </label>
- </div>
- <div class="info" hidden>
- With <span class="selection-count">0</span> selected
- </div>
- </div>
-
-
- <!-- Selection actions (hidden by default) -->
- <div class="selection-actions" hidden>
- <button type="button" class="create-from-selection">
- <?= jvbIcon('plus-square') ?>
- Create New <?= $singular ?>
- </button>
- <button type="button" class="remove-selection">
- <?= jvbIcon('trash') ?>
- Remove
- </button>
- </div>
- </div>
-
- <button type="button" class="submit-uploads">
- <?= jvbIcon('cloud-arrow-up') ?> Upload <?= esc_html($plural ?? 'Content'); ?>
- </button>
- </div>
- <?php endif; ?>
-
- <?php jvbRenderProgressBar('<span class="text">Processing files...</span>
- <span class="count">0/0</span>'); ?>
- <div class="item-grid preview">
- <?php if ($image_url) {
- echo jvbRenderImageForm((int)$value);
- } ?>
+ public function renderImagePreview(?int $id = null, array $config = []):void
+ {
+ $attachment = ($id) ? wp_get_attachment_image($id, 'thumbnail', false) : false;
+ $caption = ($id) ? wp_get_attachment_caption($id) : '';
+ $alt = ($id) ? get_post_meta($id, '_wp_attachment_image_alt',true) : '';
+ $title = ($id) ? get_the_title($id) : '';
+ $addID = ($id) ? '-'.$id : '';
+ $dataID = ($id) ? ['id' => $id] : '';
+ ?>
+ <div class="item upload"<?= ($id) ? ' data-id="'.$id.'"' : '' ?>>
+ <div class="preview">
+ <?php jvbRenderProgressBar('',true) ?>
+ <input type="checkbox" class="upload-select" name="select-item" id="select-item<?=$addID?>">
+ <label for="select-item<?=$addID?>" aria-label="Select image">
+ <?= ($attachment) ?: '<img>
+ <video></video>
+ <span></span>' ?>
+ </label>
+ <div class="item-actions row btw">
+ <div class="radio-button">
+ <input type="radio" class="featured btn" name="featured" id="featured" hidden>
+ <label for="featured">
+ <?=jvbIcon('star')?>
+ <?=jvbIcon('star', ['style' => 'fill'])?>
+ <span class="screen-reader-text">Set as featured image</span>
+ </label>
</div>
- <?php if ($groupable) : ?>
- <p class="hint"><?= jvbIcon('arrow-elbow-left-up') ?> These will become individual <?= $plural ?> <?= jvbIcon('arrow-elbow-right-up')?></p>
- </div>
- <div class="sidebar">
- <div class="header">
- <h4>New <?= $plural?></h4>
- <p class="hint">Drag images into groups to create separate <?= $plural ?>.</p>
- <p class="hint">Select multiple images and click "Add to <?= $singular?>" or create new <?= $plural ?>.</p>
- </div>
- <button type="button" class="create-group-from-selection">
- <?= jvbIcon('plus-square') ?>
- Create New <?= $singular ?>
+ <button type="button" data-action="delete-upload" title="Remove from Group">
+ <?=jvbIcon('trash')?>
</button>
- <div class="item-grid groups">
- <div class="empty-group">
- <p>Drag here to create a new <?= $singular ?>!</p>
- </div>
- </div>
- <p class="hint"><?= jvbIcon('arrow-elbow-left-up') ?> Each group will become its own <?= $singular ?> <?= jvbIcon('arrow-elbow-right-up')?></p>
</div>
</div>
- <?php endif; ?>
+ <details>
+ <summary class="row btw"><?=jvbIcon('pencil-simple')?><span>Edit Info</span></summary>
- <?php if ($mode === 'direct') : ?>
+ <?php
+
+ $fields = [
+ 'image_data' => [
+ 'type' => 'group',
+ 'wrap' => 'details',
+ 'label' => 'Image Fields',
+ 'fields' => [
+ 'image-title'.$addID => [
+ 'type' => 'text',
+ 'label' => 'Image Title',
+ 'value' => $title,
+ 'data' => $dataID
+ ],
+ 'image-alt-text'.$addID => [
+ 'type' => 'text',
+ 'label' => 'Alt Text',
+ 'value' => $alt,
+ 'hint' => 'Alt text helps the visually impaired, as well as some benefits for SEO.',
+ 'data' => $dataID
+ ],
+ 'image-caption'.$addID => [
+ 'type' => 'textarea',
+ 'value' => $caption,
+ 'label' => 'Image Caption',
+ 'data' => $dataID
+ ]
+ ]
+ ]
+ ];
+ $fields = array_key_exists('fields', $config) ? array_merge($fields, $config['fields']) : $fields;
+ $meta = new MetaManager($id);
+ foreach ($fields as $field => $config) {
+ $meta->render('form', $field, $config);
+ }
+ ?>
+ </details>
+ </div>
+ <?php
+ }
+ public function renderVideoPreview(?int $id = null, array $config = []):void
+ {
+ $attachment = ($id) ? wp_get_attachment_image($id, 'thumbnail', true) : false;
+ $caption = ($id) ? wp_get_attachment_caption($id) : '';
+ $description = ($id) ? get_the_content($id) : '';
+ $title = ($id) ? get_the_title($id) : '';
+ $addID = ($id) ? '-'.$id : '';
+ $dataID = ($id) ? ['id' => $id] : '';
+ ?>
+ <div class="item upload"<?= ($id) ? ' data-id="'.$id.'"' : '' ?>>
+ <div class="preview">
+ <?php jvbRenderProgressBar('',true) ?>
+ <input type="checkbox" class="upload-select" name="select-item" id="select-item<?=$addID?>">
+ <label for="select-item<?=$addID?>" aria-label="Select image">
+ <?= ($attachment) ?: '<img>
+ <video></video>
+ <span></span>'; ?>
+ </label>
+ <div class="item-actions row btw">
+ <div class="radio-button">
+ <input type="radio" class="featured btn" name="featured" id="featured" hidden>
+ <label for="featured">
+ <?=jvbIcon('star')?>
+ <?=jvbIcon('star', ['style' => 'fill'])?>
+ <span class="screen-reader-text">Set as featured image</span>
+ </label>
+ </div>
+
+ <button type="button" data-action="delete-upload" title="Remove from Group">
+ <?=jvbIcon('trash')?>
+ </button>
+ </div>
+ </div>
+ <details>';
+ <summary class="row btw"><?=jvbIcon('pencil-simple')?><span>Edit Info</span></summary>
+
+ <?php
+ $fields = array_key_exists('fields', $config) ? $config['fields'] : [];
+ $fields = array_merge([
+ 'upload_data' => [
+ 'type' => 'group',
+ 'wrap' => 'details',
+ 'label' => 'Video Info',
+ 'hint' => 'These will be automatically generated if left blank.',
+ 'fields' => [
+ 'title' => [
+ 'type' => 'text',
+ 'label' => 'Video Title',
+ 'value' => $title,
+ 'data' => $dataID
+ ],
+ 'caption' => [
+ 'type' => 'textarea',
+ 'value' => $caption,
+ 'label' => 'Video Caption',
+ 'data' => $dataID
+ ],
+ 'description' => [
+ 'type' => 'textarea',
+ 'value' => $description,
+ 'label' => 'Video Description',
+ 'data' => $dataID
+ ]
+ ]
+ ]
+ ], $fields);
+ $this->render('upload_data', null, $fields);
+ ?>
+ </details>
+ </div>
+ <?php
+ }
+ public function renderFilePreview(?int $id = null, array $config = []):void
+ {
+
+ $attachment = ($id) ? wp_get_attachment_image($id, 'thumbnail', true) : false;
+ $caption = ($id) ? wp_get_attachment_caption($id) : '';
+ $description = ($id) ? get_the_content($id) : '';
+ $title = ($id) ? get_the_title($id) : '';
+ $addID = ($id) ? '-'.$id : '';
+ $dataID = ($id) ? ['id' => $id] : '';
+ ?>
+ <div class="item upload"<?= ($id) ? ' data-id="'.$id.'"' : '' ?>>
+ <div class="preview">
+ <?php jvbRenderProgressBar('',true) ?>
+ <input type="checkbox" class="upload-select" name="select-item" id="select-item<?=$addID?>">
+ <label for="select-item<?=$addID?>" aria-label="Select image">
+ <?= ($attachment) ?: '<img>
+ <video></video>
+ <span></span>'; ?>
+ </label>
+ <div class="item-actions row btw">
+ <div class="radio-button">
+ <input type="radio" class="featured btn" name="featured" id="featured" hidden>
+ <label for="featured">
+ <?=jvbIcon('star')?>
+ <?=jvbIcon('star', ['style' => 'fill'])?>
+ <span class="screen-reader-text">Set as featured image</span>
+ </label>
+ </div>
+
+ <button type="button" data-action="delete-upload" title="Remove from Group">
+ <?=jvbIcon('trash')?>
+ </button>
+ </div>
+ </div>
+ <details>';
+ <summary class="row btw"><?=jvbIcon('pencil-simple')?><span>Edit Info</span></summary>
+
+ <?php
+ $fields = array_key_exists('fields', $config) ? $config['fields'] : [];
+ $fields = array_merge([
+ 'upload_data' => [
+ 'type' => 'group',
+ 'wrap' => 'details',
+ 'label' => 'File Info',
+ 'hint' => 'These will be automatically generated if left blank.',
+ 'fields' => [
+ 'title' => [
+ 'type' => 'text',
+ 'label' => 'File Title',
+ 'value' => $title,
+ 'data' => $dataID
+ ],
+ 'caption' => [
+ 'type' => 'textarea',
+ 'value' => $caption,
+ 'label' => 'File Caption',
+ 'data' => $dataID
+ ],
+ 'description' => [
+ 'type' => 'textarea',
+ 'value' => $description,
+ 'label' => 'File Description',
+ 'data' => $dataID
+ ]
+ ]
+ ]
+ ], $fields);
+ $this->render('upload_data', null, $fields);
+ ?>
+ </details>
+ </div>
+ <?php
+ }
+
+ /**
+ * Get upload instruction text based on config
+ */
+ private function getUploadInstructions(array $config): string
+ {
+ $extensions = $this->getMimeExtensions($this->getAllowedTypes($config));
+ $extList = implode(', ', array_map('strtoupper', $extensions));
+ $maxSize = $config['max_size'] ?? $this->max_file_size;
+ $maxSizeMB = round($maxSize / 1048576, 1);
+
+ return "{$extList} (max. {$maxSizeMB}MB)";
+ }
+
+ /* ========== TAXONOMY/USER SELECTOR FIELDS ========== */
+
+ private function renderTaxonomyField(string $name, string $value, array $field): void
+ {
+ if (array_key_exists('group', $field)) {
+ $name = $field['group'] . '::' . $name;
+ }
+
+ $this->renderSelectorField($name, $value, $field, 'taxonomy');
+ }
+
+ private function renderUserField(string $name, string $value, array $field): void
+ {
+ if (array_key_exists('group', $field)) {
+ $name = $field['group'] . '::' . $name;
+ }
+
+ $this->renderSelectorField($name, $value, $field, 'post');
+ }
+
+ /**
+ * Generic selector field renderer
+ * Handles both taxonomy and post selectors with consistent structure
+ */
+ public function renderSelectorField(string $name, mixed $value, array $field, string $type): void
+ {
+ $conditional = $this->handleConditionalField($field);
+ $validationAttrs = $this->buildValidationAttributes($field);
+ $describedBy = (!empty($field['description'])) ? ' aria-describedby="' . $name . '-help"' : '';
+
+ // Parse selected values
+ $value = (is_array($value)) ? array_filter(array_map('absint', $value)): $value;
+ $selected = ($value === '') ? [] : (is_array($value) ? $value : explode(',', $value));
+
+ // Generate unique container ID
+ $containerId = $name . '-' . $type . '-selector';
+
+ // Create selector instance with proper parameters
+ if ($type === 'taxonomy') {
+ $taxonomy = $field['taxonomy'];
+ $icon = JVB_TAXONOMY[$taxonomy]['icon']??'';
+
+ // Map field config to selector config
+ $selectorConfig = [
+ 'max' => $field['max'] ?? 0, // 0 = unlimited
+ 'search' => $field['search'] ?? true,
+ 'label' => $field['label'] ?? '',
+ 'createNew' => $field['createNew'] ?? false,
+ 'required' => $field['required'] ?? false,
+ 'base' => $field['base'] ?? '',
+ 'update' => $field['update'] ?? true,
+ 'name' => $name,
+ 'autocomplete' => $field['autocomplete'] ?? false,
+ ];
+ if ($icon !== '') {
+ $selectorConfig['icon'] = $icon;
+ }
+
+ $selector = new TaxonomySelector($containerId, $taxonomy, $selectorConfig);
+ $icon = $taxonomy;
+ } else {
+ $postType = $field['post_type'];
+
+ // Map field config to selector config
+ $selectorConfig = [
+ 'max' => $field['max'] ?? 0,
+ 'search' => $field['search'] ?? true,
+ 'label' => $field['label'] ?? '',
+ 'required' => $field['required'] ?? false,
+ 'base' => $field['base'] ?? '',
+ 'update' => $field['update'] ?? true,
+ 'shop_id' => $field['shop_id'] ?? null,
+ 'autocomplete'=> $field['autocomplete'] ?? true,
+ ];
+
+ $selector = new PostSelector($containerId, $postType, $selectorConfig);
+ $icon = $postType;
+ }
+
+ ?>
+ <div class="field selector <?= esc_attr($type) ?> <?= esc_attr($name) ?>"
+ <?= $conditional ?>
+ data-field="<?= esc_attr($name) ?>"
+ data-field-type="selector"
+ data-type="<?=esc_attr($field['type'])?>"
+ <?= $validationAttrs ?>
+ <?= $describedBy ?>>
+
+ <?= $selector->render($selected) ?>
+
+ <!-- Hidden input for form submission -->
+ <input type="hidden"
+ class="<?= esc_attr($type) ?>-selector-input"
+ name="<?= array_key_exists('base', $field) ? esc_attr($field['base']) : '' ?><?= esc_attr($name) ?>"
+ data-<?= esc_attr($type) ?>="<?= esc_attr($field[$type === 'taxonomy' ? 'taxonomy' : 'post_type']) ?>"
+ value="<?= esc_attr(is_array($selected) ? implode(',', $selected) : $value) ?>"
+ <?= !empty($field['required']) ? 'required' : '' ?>>
+
+ <?php $this->renderHintAndDescription($field, $name); ?>
+
+ <span class="validation-message" hidden role="alert"></span>
+ </div>
+ <?php
+ }
+
+ /* ========== LOCATION FIELD ========== */
+
+ protected function renderLocationField(string $name, mixed $value, array $field): void
+ {
+ $googleMaps = JVB()->connect('maps');
+ if (!$googleMaps->isSetUp()) {
+ echo '<div class="notice notice-warning"><p>Google Maps not configured. Please configure in Integrations settings.</p></div>';
+ return;
+ }
+
+ // Extract stored values
+ if (is_string($value)) {
+ $value = maybe_unserialize($value);
+ }
+ $stored_data = is_array($value) ? $value : [];
+
+ $address = $stored_data['address'] ?? '';
+ $lat = $stored_data['lat'] ?? '';
+ $lng = $stored_data['lng'] ?? '';
+
+ // Generate unique field ID
+ $field_id = esc_attr($name);
+ $map_id = $field_id . '_map';
+
+ // Handle grouped fields
+ if (array_key_exists('group', $field)) {
+ $name = $field['group'] . '::' . $name;
+ }
+ $describedBy = (!empty($field['description'])) ? ' aria-describedby="'.$name.'-help"' : '';
+
+ // Prepare configuration for JavaScript initialization
+ $js_config = [
+ 'fieldId' => $field_id,
+ 'initialCoords' => (!empty($lat) && !empty($lng)) ? [
+ 'lat' => (float)$lat,
+ 'lng' => (float)$lng
+ ] : null
+ ];
+
+ // IMPORTANT: Properly escape the JSON for use in HTML attribute
+ $json_config = htmlspecialchars(json_encode($js_config), ENT_QUOTES, 'UTF-8');
+ ?>
+
+ <div class="field location <?= esc_attr($field_id) ?>"
+ data-field="<?= esc_attr($field_id) ?>"
+ data-field-type="<?=esc_attr($field['type'])?>"
+ data-location-field-init="<?= $json_config ?>"<?=$describedBy?>>
+
+ <?php
+ if (!empty($stored_data['street'])) {
+ echo '<p><b>Current location:</b> '.esc_html($stored_data['street']).'</p>';
+ echo '<p class="hint"><b>Search below to change:</b></p>';
+ }
+ ?>
+ <?php if (array_key_exists('hint', $field)) { $this->renderHint($field['hint']); } ?>
+ <?php if (array_key_exists('description', $field)) { $this->renderDescription($field['description'], $name); } ?>
+
+ <div class="location-search-wrapper">
+ <div class="autocomplete-wrapper"></div>
+
+ <!-- Map container -->
+ <div class="location-preview">
+ <div id="<?= esc_attr($map_id); ?>"
+ class="location-map">
+ </div>
+
+ <?php if (!empty($stored_data)):
+ jvbLocationLinks($stored_data);
+ endif; ?>
+ </div>
+
+ <!-- Hidden inputs for data storage -->
<input type="hidden"
- name="<?= array_key_exists('base', $field) ? esc_attr($field['base']) : ''?><?= esc_attr($name); ?>"
- value="<?= esc_attr($value); ?>"
- <?= !empty($field['required']) ? 'required' : ''; ?>>
+ name="<?= array_key_exists('base', $field) ? esc_attr($field['base']) : ''?><?= esc_attr($name); ?>[address]"
+ value="<?= esc_attr($address); ?>"
+ data-location-field="address">
+
+ <input type="hidden"
+ name="<?= array_key_exists('base', $field) ? esc_attr($field['base']) : ''?><?= esc_attr($name); ?>[lat]"
+ value="<?= esc_attr($lat); ?>"
+ data-location-field="lat">
+
+ <input type="hidden"
+ name="<?= array_key_exists('base', $field) ? esc_attr($field['base']) : ''?><?= esc_attr($name); ?>[lng]"
+ value="<?= esc_attr($lng); ?>"
+ data-location-field="lng">
+
+ <?php
+ // Component fields
+ $components = ['street', 'city', 'province', 'postal_code', 'country'];
+ foreach ($components as $component):
+ ?>
+ <input type="hidden"
+ name="<?= array_key_exists('base', $field) ? esc_attr($field['base']) : ''?><?= esc_attr($name); ?>[<?= $component; ?>]"
+ value="<?= esc_attr($stored_data[$component] ?? ''); ?>"
+ data-location-field="<?= esc_attr($component); ?>">
+ <?php endforeach; ?>
+
+ </div>
+ </div>
+ <?php
+ }
+
+ /* ========== HTML FIELD ========== */
+
+ protected function renderHtmlField(string $name, mixed $value, array $field): void
+ {
+ $method_name = $field['content'];
+ $content = '';
+
+ if (method_exists($this, $method_name)) {
+ $content = $this->$method_name();
+ }
+
+ if ($content === '') {
+ return;
+ }
+
+ echo sprintf(
+ '<div class="html-field-container" data-field-type="html" data-field="%s">%s</div>',
+ esc_attr($name),
+ $content
+ );
+ }
+
+ /* ========== UTILITY METHODS ========== */
+
+ private function handleConditionalField(array $field):string
+ {
+ if (empty($field['condition'])) {
+ return '';
+ }
+
+ $condition = $field['condition'];
+ return sprintf(
+ 'data-depends-on="%s" data-depends-value="%s" data-depends-operator="%s"',
+ esc_attr($field['condition']['field']),
+ esc_attr($field['condition']['value']),
+ esc_attr($field['condition']['operator'] ?? '==')
+ );
+ }
+
+ protected function getAllowedTypes(array $config): array
+ {
+ if (!empty($config['accepted_types'])) {
+ return $config['accepted_types'];
+ }
+
+ // Default types based on subtype
+ $defaults = [
+ 'image' => ['image/*'],
+ 'video' => ['video/*'],
+ 'document' => ['application/pdf', 'application/msword', 'application/vnd.ms-excel', 'text/plain', '.odt','application/vnd.openxmlformats-officedocument.wordprocessingml.document'],
+ ];
+ $defaults['any'] = array_merge($defaults['image'], $defaults['video'], $defaults['document']);
+
+ return $defaults[$config['subtype']] ?? $defaults['image'];
+ }
+
+ protected function getMimeExtensions(array $mimeTypes): array
+ {
+ $extensions = [];
+ foreach ($mimeTypes as $mime) {
+ if (str_contains($mime, '*')) {
+ continue; // Skip wildcards
+ }
+ $ext = str_replace(['image/', 'video/', 'application/'], '', $mime);
+ $extensions[] = '.' . $ext;
+ }
+ return $extensions;
+ }
+
+ protected function parseAttachmentIds(mixed $value): array
+ {
+ if (empty($value)) {
+ return [];
+ }
+
+ if (is_array($value)) {
+ return array_filter($value, 'is_numeric');
+ }
+
+ if (is_string($value)) {
+ return array_filter(explode(',', $value), 'is_numeric');
+ }
+
+ return is_numeric($value) ? [$value] : [];
+ }
+ /**
+ * Render tag list field - inline tag input interface
+ */
+ protected function renderTagListField(string $name, mixed $value, array $field): void
+ {
+ $values = is_array($value) ? $value : [];
+ $conditional = $this->handleConditionalField($field);
+ $validationAttrs = $this->buildValidationAttributes($field);
+
+ if (array_key_exists('group', $field)) {
+ $name = $field['group'] . '::' . $name;
+ }
+
+ $describedBy = (!empty($field['description'])) ? ' aria-describedby="' . $name . '-help"' : '';
+
+ // Tag display format - defaults to first field value
+ $tagFormat = $field['tag_format'] ?? 'first_field';
+ ?>
+ <div class="field tag-list <?= esc_attr($name) ?>"
+ data-field="<?= esc_attr($name) ?>"
+ data-field-type="<?=esc_attr($field['type'])?>"
+ data-tag-format="<?= esc_attr($tagFormat) ?>"
+ <?= $describedBy ?>
+ <?= $conditional ?>
+ <?= $validationAttrs ?>>
+
+ <?php if (!empty($field['label'])): ?>
+ <h3><?= esc_html($field['label']) ?></h3>
+ <?php endif; ?>
+
+ <!-- Inline input row -->
+ <div class="tag-input-row">
+ <?php foreach ($field['fields'] as $subfield_name => $subfield_config): ?>
+ <?php
+ $subfield_config['label'] = $subfield_config['label'] ?? ucfirst($subfield_name);
+ $input_name = 'new_' . $subfield_name;
+
+ // Store required state but don't render it on the input
+ // This prevents form submission validation but allows JS validation
+
+ if (array_key_exists('required', $subfield_config)) {
+ $subfield_config['data']['required'] = true;
+ unset($subfield_config['required']); // Remove required for HTML rendering
+ }
+ $subfield_config['data']['ignore'] = true;
+
+ $this->render($input_name, '', $subfield_config, false, false);
+ ?>
+ <?php endforeach; ?>
+
+ <button type="button" class="button add-tag-item">
+ <?= jvbIcon('plus') ?> <?= $field['add_label'] ?? 'Add' ?>
+ </button>
+ </div>
+
+ <!-- Tags display -->
+ <div class="tag-items">
+ <?php foreach ($values as $index => $item_data): ?>
+ <?php $this->renderTagItem($field['fields'], $item_data, $index, $name, $tagFormat); ?>
+ <?php endforeach; ?>
+ </div>
+
+ <!-- Template for new tags -->
+ <template class="<?=uniqid('tagListItem')?>">
+ <?php $this->renderTagItem($field['fields'], [], '', $name, $tagFormat); ?>
+ </template>
+
+ <?php if (!empty($field['hint'])): ?>
+ <?php $this->renderHint($field['hint']); ?>
+ <?php endif; ?>
+
+ <?php if (!empty($field['description'])): ?>
+ <?php $this->renderDescription($field['description'], $name); ?>
<?php endif; ?>
</div>
<?php
- }
+ }
- protected function renderGalleryField(string $name, string|null|false $value, array $field):void
- {
- $ids = ($value === '' || is_null($value) || !$value) ? [] : explode(',',$value);
-
- if (!empty($ids)) {
- $ids = array_map('absint', $ids);
- }
-
- $conditional = $this->handleConditionalField($field);
- if (array_key_exists('group', $field)) {
- $name = $field['group'].'::'.$name;
- }
- //TODO: This can probably just be a wrapper for renderImageField...
- ?>
- <div class="field gallery <?=$name?>"
- data-field="<?= esc_attr($name); ?>"
- <?= $conditional ?>>
-
- <label><?= esc_html($field['label']); ?></label>
-
- <!-- Container for existing images -->
- <div class="gallery-preview">
- <?php
- if (!empty($ids)) {
- foreach ($ids as $id) {
- $url = wp_get_attachment_image_url($id, 'medium');
- if ($url) {
- echo '<div class="preview-item" data-id="' . esc_attr($id) . '">';
- echo '<img src="' . esc_url($url) . '" alt="">';
- echo '<button type="button" class="remove-preview">' . jvbIcon('trash', ['title'=>'Remove']) . '</button>';
- echo '</div>';
- }
- }
- }
- ?>
- </div>
-
- <!-- Hidden file uploader that will be managed by BatchFileUploader -->
- <div class="file-upload-container">
- <div class="file-upload-wrapper">
- <input type="file"
- name="<?= array_key_exists('base', $field) ? esc_attr($field['base']) : ''?><?= esc_attr($name); ?>_temp"
- accept=".jpg,.jpeg,.png,.gif,.webp"
- multiple>
- <p class="file-upload-text">
- <strong>Click to upload</strong> or drag and drop<br>
- JPG, PNG, GIF, or WEBP (max. 5MB)
- </p>
- </div>
- <div class="file-error"></div>
- </div>
-
- <!-- Hidden input for storing the IDs -->
- <input type="hidden"
- name="<?= array_key_exists('base', $field) ? esc_attr($field['base']) : ''?><?= esc_attr($name); ?>"
- value="<?= esc_attr($value); ?>"
- <?= !empty($field['required']) ? 'required' : ''; ?>>
-
- <?php if (array_key_exists('hint', $field)) { $this->renderHint($field['hint']); } ?>
- <?php if (array_key_exists('description', $field)) { $this->renderDescription($field['description'], $name); } ?>
- </div>
-
- <?php
- }
- private function renderSelectField(string $name, mixed $value, array $field):void
- {
- $describedBy = (!empty($field['description'])) ? ' aria-describedby="'.$name.'-help"' : '';
- $conditional = $this->handleConditionalField($field);
- $default = isset($field['default']) ? $field['default'] : '';
- $value = !empty($value) ? $value : $default;
- if (array_key_exists('group', $field)) {
- $name = $field['group'].'::'.$name;
- }
- ?>
- <div class="field <?=$field['type']?> <?=$name?>" data-field="<?=$name?>" <?=$conditional?>>
- <label for="<?= esc_attr($name); ?>">
- <?= esc_html($field['label']); ?>
- </label>
- <?php if (array_key_exists('hint', $field)) { $this->renderHint($field['hint']); } ?>
- <?php if (array_key_exists('description', $field)) { $this->renderDescription($field['description'], $name); } ?>
- <select
- id="<?= array_key_exists('base', $field) ? esc_attr($field['base']) : ''?><?= esc_attr($name); ?>"
- name="<?= array_key_exists('base', $field) ? esc_attr($field['base']) : ''?><?= esc_attr($name); ?>"
- <?=$describedBy?>
- <?= !empty($field['required']) ? 'required' : ''; ?>
- >
- <?php foreach ($field['options'] as $key => $label) : ?>
- <option value="<?= esc_attr($key); ?>"
- <?php selected($value, $key); ?>>
- <?= esc_html($label); ?>
- </option>
- <?php endforeach; ?>
- </select>
- </div>
- <?php
- }
-
- protected function renderHtmlField(string $name, mixed $value, array $field):void
- {
- $method_name = $field['content'];
- $content = '';
- if (method_exists($this, $method_name)) {
- $content = $this->$method_name();
- }
-
- echo ($content == '') ? '' : sprintf(
- '<div class="html-field-container" data-field-type="html" data-field="%s">%s</div>',
- esc_attr($name),
- $content
- );
- }
-
- private function renderDateField(string $name, mixed $value, array $field):void
- {
- $describedBy = (!empty($field['description'])) ? ' aria-describedby="'.$name.'-help"' : '';
- $conditional = $this->handleConditionalField($field);
- $format = !empty($field['format']) ? $field['format'] : 'Y-m-d';
-
- // Format the date if we have a value
- if (!empty($value)) {
- $date = DateTime::createFromFormat($format, $value);
- if ($date) {
- $value = $date->format('Y-m-d'); // HTML date input requires Y-m-d format
- }
- }
- if (array_key_exists('group', $field)) {
- $name = $field['group'].'::'.$name;
- }
- ?>
- <div class="field <?=$field['type']?> <?=$name?>" <?=$conditional?> data-field="<?=$name?>">
- <label for="<?= esc_attr($name); ?>">
- <?= esc_html($field['label']); ?>
- </label>
- <div class="date-wrapper"<?=$describedBy?>>
- <input
- type="date"
- id="<?= array_key_exists('base', $field) ? esc_attr($field['base']) : ''?><?= esc_attr($name); ?>"
- name="<?= array_key_exists('base', $field) ? esc_attr($field['base']) : ''?><?= esc_attr($name); ?>"
- value="<?= esc_attr($value); ?>"
- <?= !empty($field['required']) ? 'required' : ''; ?>
- data-format="<?= esc_attr($format); ?>"
- >
- <?= jvbIcon('calendar') ?>
- </div>
- <?php if (array_key_exists('hint', $field)) { $this->renderHint($field['hint']); } ?>
- <?php if (array_key_exists('description', $field)) { $this->renderDescription($field['description'], $name); } ?>
-
- </div>
- <?php
- }
-
- public function renderTimeField(string $name, mixed $value, array $field):void
+ /**
+ * Render individual tag item
+ */
+ protected function renderTagItem(array $fields, array $data, int|string $index, string $base_name, string $format): void
{
- $conditional = $this->handleConditionalField($field);
- $describedBy = (!empty($field['description'])) ? ' aria-describedby="'.$name.'-help"' : '';
- // Convert various time formats to HTML time input format (HH:MM)
- if (!empty($value)) {
- // If it's already in HH:MM format, use as-is
- if (preg_match('/^\d{2}:\d{2}$/', $value)) {
- // Value is already in correct format
- } else {
- // Try to parse and convert
- $timestamp = strtotime($value);
- if ($timestamp !== false) {
- $value = date('H:i', $timestamp);
- } else {
- $value = '';
- }
- }
- }
-
- if (array_key_exists('group', $field)) {
- $name = $field['group'].'::'.$name;
- }
+ $tag_text = $this->getTagDisplayText($fields, $data, $format);
?>
- <div class="field <?=$field['type']?> <?=$name?>" <?=$conditional?> data-field="<?=$name?>">
- <label for="<?= esc_attr($name); ?>">
- <?= esc_html($field['label']); ?>
- </label>
- <div class="time-wrapper"<?=$describedBy?>>
- <input
- type="time"
- id="<?= array_key_exists('base', $field) ? esc_attr($field['base']) : ''?><?= esc_attr($name); ?>"
- name="<?= array_key_exists('base', $field) ? esc_attr($field['base']) : ''?><?= esc_attr($name); ?>"
- value="<?= esc_attr($value); ?>"
- <?= !empty($field['required']) ? 'required' : ''; ?>
- <?= !empty($field['min']) ? 'min="' . esc_attr($field['min']) . '"' : ''; ?>
- <?= !empty($field['max']) ? 'max="' . esc_attr($field['max']) . '"' : ''; ?>
- <?= !empty($field['step']) ? 'step="' . esc_attr($field['step']) . '"' : ''; ?>
- >
+ <div class="tag-item" data-index="<?= esc_attr($index) ?>">
+ <span class="tag-label"><?= esc_html($tag_text) ?></span>
- </div>
- <?php if (array_key_exists('hint', $field)) { $this->renderHint($field['hint']); } ?>
- <?php if (array_key_exists('description', $field)) { $this->renderDescription($field['description'], $name); } ?>
+ <!-- Hidden inputs for data -->
+ <?php foreach ($fields as $field_name => $field_config): ?>
+ <?php
+ $value = $data[$field_name] ?? '';
+ $full_name = is_string($index) ? $field_name : "{$base_name}:{$index}:{$field_name}";
+ ?>
+ <input type="hidden"
+ name="<?= esc_attr($full_name) ?>"
+ value="<?= esc_attr($value) ?>"
+ data-field="<?= esc_attr($field_name) ?>"
+ data-field-type="<?=esc_attr($field_config['type'])?>" />
+ <?php endforeach; ?>
+
+ <button type="button" class="remove-tag" aria-label="Remove">
+ <?= jvbIcon('x') ?>
+ </button>
</div>
<?php
}
- private function renderDatetimeField(string $name, mixed $value, array $field):void
+ /**
+ * Get tag display text based on format
+ */
+ protected function getTagDisplayText(array $fields, array $data, string $format): string
{
- $conditional = $this->handleConditionalField($field);
- $describedBy = (!empty($field['description'])) ? ' aria-describedby="'.$name.'-help"' : '';
- // Convert datetime to HTML datetime-local format (YYYY-MM-DDTHH:MM)
- if (!empty($value)) {
- $date = DateTime::createFromFormat('Y-m-d H:i:s', $value);
- if (!$date) {
- // Try alternative formats
- $formats = ['Y-m-d\TH:i:s', 'Y-m-d\TH:i', 'Y-m-d H:i'];
- foreach ($formats as $format) {
- $date = DateTime::createFromFormat($format, $value);
- if ($date) break;
+ if (empty($data)) {
+ return 'New Item';
+ }
+
+ switch ($format) {
+ case 'first_field':
+ // Use the first field's value
+ $first_key = array_key_first($fields);
+ return $data[$first_key] ?? 'New Item';
+
+ case 'all_fields':
+ // Show all field values separated by commas
+ $values = array_filter(array_values($data));
+ return implode(', ', $values) ?: 'New Item';
+
+ case 'custom':
+ // Custom format - would need callback
+ return 'New Item';
+
+ default:
+ // Format is a template string like "{name} ({email})"
+ if (strpos($format, '{') !== false) {
+ $text = $format;
+ foreach ($data as $key => $value) {
+ $text = str_replace('{' . $key . '}', $value, $text);
+ }
+ return $text;
}
- }
-
- if ($date) {
- $value = $date->format('Y-m-d\TH:i'); // HTML datetime-local format
- } else {
- $value = '';
- }
+ // Use specific field name
+ return $data[$format] ?? 'New Item';
}
-
- if (array_key_exists('group', $field)) {
- $name = $field['group'].'::'.$name;
- }
- ?>
- <div class="field <?=$field['type']?> <?=$name?>" <?=$conditional?> data-field="<?=$name?>">
- <label for="<?= esc_attr($name); ?>">
- <?= esc_html($field['label']); ?>
- </label>
- <div class="datetime-wrapper"<?=$describedBy?>>
- <input
- type="datetime-local"
- id="<?= array_key_exists('base', $field) ? esc_attr($field['base']) : ''?><?= esc_attr($name); ?>"
- name="<?= array_key_exists('base', $field) ? esc_attr($field['base']) : ''?><?= esc_attr($name); ?>"
- value="<?= esc_attr($value); ?>"
- <?= !empty($field['required']) ? 'required' : ''; ?>
- <?= !empty($field['min']) ? 'min="' . esc_attr($field['min']) . '"' : ''; ?>
- <?= !empty($field['max']) ? 'max="' . esc_attr($field['max']) . '"' : ''; ?>
- <?= !empty($field['step']) ? 'step="' . esc_attr($field['step']) . '"' : ''; ?>
- >
- <?= jvbIcon('calendar') ?>
- </div>
- <?php if (array_key_exists('hint', $field)) { $this->renderHint($field['hint']); } ?>
- <?php if (array_key_exists('description', $field)) { $this->renderDescription($field['description'], $name); } ?>
- </div>
- <?php
- }
-
-
- public function outputCharacterCountJS():void
- {
- ?>
- <script>
- document.querySelectorAll('[maxlength]').forEach(field => {
- const counter = field.closest('.field')?.querySelector('.char-count .current');
- if (counter) {
- const updateCount = () => counter.textContent = field.value.length;
- field.addEventListener('input', updateCount);
- updateCount();
- }
- });
- </script>
- <?php
- }
-
-
- //Conditional Fields
- private function handleConditionalField(array $field):string
- {
- if (empty($field['condition'])) {
- return '';
- }
-
- $condition = $field['condition'];
- return sprintf(
- 'data-depends-on="%s" data-depends-value="%s" data-depends-operator="%s"',
- esc_attr($field['condition']['field']),
- esc_attr($field['condition']['value']),
- esc_attr($field['condition']['operator'] ?? '==')
- );
- }
-
- protected function renderDescription(string $description, string $name):void
- {
- $id = $name.'-help';
- $out = '<div class="has-tooltip">
- <span class="tt-toggle">'.jvbIcon('question').'</span>
- <div role="tooltip" id="'.$id.'"><p>'.$description.'</p></div>
- </div>';
- echo $out;
- }
-
- protected function renderHint(array|string $hint):void
- {
- if (is_array($hint)) {
- $out = '';
- foreach($hint as $h) {
- $out .= '<p class="hint">'.$h.'</p>';
- }
- } else {
- $out = '<p class="hint">'.$hint.'</p>';
- }
- echo $out;
}
}
diff --git a/inc/meta/MetaManager.php b/inc/meta/MetaManager.php
index 9da898e..4fe121f 100644
--- a/inc/meta/MetaManager.php
+++ b/inc/meta/MetaManager.php
@@ -13,13 +13,16 @@
/**
* Core meta management class
*/
+
+/**
+ * @deprecated Use Meta() now
+ */
class MetaManager
{
public MetaTypeManager $type_manager;
- public MetaValidator $validator;
- public MetaSanitizer $sanitizer;
- public MetaRenderer $renderer;
- public MetaForm $form;
+ public Validator $validator;
+ public Sanitizer $sanitizer;
+ public Render $renderer;
protected int|null $object_id;
public object|null $data;
protected array $fields =[];
@@ -90,10 +93,9 @@
}
$this->type_manager = new MetaTypeManager();
- $this->validator = new MetaValidator();
- $this->sanitizer = new MetaSanitizer();
- $this->renderer = new MetaRenderer();
- $this->form = new MetaForm();
+ $this->validator = new Validator();
+ $this->sanitizer = new Sanitizer();
+ $this->renderer = new Render();
}
/**
@@ -690,11 +692,11 @@
$out = '';
switch ($type) {
case 'form':
- $out = $this->form->render($name, $value, $config, $showHidden, true);
+ $out = Form::render($name, $value, $config);
$out = apply_filters('jvbRenderFormMeta', $out, $name, $config, $value, $this->getObjectType());
break;
case 'render':
- $out = $this->renderer->render($name, $value, $config, true);
+ $out = $this->renderer->render($name, $value, $config);
if (empty($out) && !$hideEmpty) {
$out = $this->getEmptyTemplate($config['type'], $name);
}
diff --git a/inc/meta/MetaRegistry.php b/inc/meta/MetaRegistry.php
deleted file mode 100644
index 9162fbb..0000000
--- a/inc/meta/MetaRegistry.php
+++ /dev/null
@@ -1,191 +0,0 @@
-<?php
-namespace JVBase\meta;
-
-use JVBase\registry\ContentRegistry;
-use InvalidArgumentException;
-
-if (!defined('ABSPATH')) {
- exit; // Exit if accessed directly
-}
-
-class MetaRegistry
-{
- protected string $object; //post type or taxonomy slug
- protected string $object_type; //post, term, user, [comment]
- protected MetaManager $meta_manager;
- protected array $fields;
- protected string $prefix = BASE;
-
- public function __construct(array $fields, string $object, string $object_type)
- {
- if (!in_array($object_type, ['post', 'term', 'user'])) {
- return;
- }
-
- $this->fields = $fields;
-
- $this->object = jvbCheckBase($object);
- $this->object_type = $object_type;
- $this->meta_manager = new MetaManager();
- }
-
- /**
- * Register meta fields
- */
- public function registerMetaFields():void
- {
- $fields = $this->fields;
-
- foreach ($fields as $name => $options) {
- if (in_array($name, [
- 'post_title',
- 'post_content',
- 'post_excerpt',
- 'featured_image',
- 'display_name',
- 'user_email',
- ])) {
- unset($fields[$name]);
- }
- }
- foreach ($fields as $field_name => $field) {
- $this->validateFieldType($field_name, $field);
- $field = array_merge($this->meta_manager->type_manager->getType($field['type']), $field);
- $args = $this->getFieldArgs($field_name, $field);
-
- // Allow modification of field registration args
- $args = apply_filters(
- BASE . 'meta_field_args',
- $args,
- $field_name,
- $field,
- $this->object_type
- );
-
- $temp = register_meta($this->object_type, $this->prefix . $field_name, $args);
-
-
- if (!$temp) {
- $args['auth_callback'] = gettype($args['auth_callback']);
- error_log('Error with registering meta:'.print_r([
- 'object_type' => $this->object_type,
- 'prefix' => $this->prefix,
- 'field_name' => $field_name,
- 'args' => $args,
- ], true));
- }
-
- do_action(BASE . 'meta_field_registered', $field_name, $field, $this);
- }
- }
-
- protected function validateFieldType(string $field_name, array $field):void
- {
- $required = ['name', 'type', 'label'];
- $field['name'] = $field_name;
- foreach ($required as $type) {
- if (!isset($field[$type])) {
- throw new InvalidArgumentException(sprintf('Field %s is required', $type));
- }
- }
- }
-
- protected function getFieldArgs(string $field_name, array $field):array
- {
- $args = [
- 'object_subtype' => $this->object,
- 'type' => $this->meta_manager->getMetaType($field['type']),
- 'label' => __($field['label'], 'jvb') ?? '',
- 'description' => __($field['description']?? '', 'jvb') ,
- 'single' => true,
- 'show_in_rest' => $field['show_in_rest'] ?? true,
- 'sanitize_callback' => $this->meta_manager->getSanitizeCallback($field),
- 'auth_callback' => [$this, 'validate_permissions'],
- 'default' => $field['default'] ?? '',
-
- ];
-
- if ($this->object_type === 'post') {
- $args['revisions_enabled'] = true;
- }
-
-
- // Add schema for complex fields
- if (in_array($field['type'], ['repeater', 'group', 'location']) || $args['type'] === 'array') {
- $args['show_in_rest'] = [
- 'schema' => $this->getFieldSchema($field)
- ];
- }
-
- return $args;
- }
-
- /**
- * Get schema for complex field types
- */
- protected function getFieldSchema(array $field):array
- {
- if ($field['type'] === 'repeater') {
- $properties = [];
- foreach ($field['fields'] as $key => $subfield) {
- $properties[$key] = [
- 'type' => $this->meta_manager->getMetaType($subfield['type'])
- ];
- }
-
- return [
- 'type' => 'object',
- 'items' => [
- 'type' => 'object',
- 'properties' => $properties
- ]
- ];
- } elseif ($field['type'] === 'group') {
- $properties = [];
- foreach ($field['fields'] as $key => $subfield) {
- $properties[$key] = [
- 'type' => $this->meta_manager->getMetaType($subfield['type'])
- ];
-
- // Add description if available
- if (isset($subfield['description'])) {
- $properties[$key]['description'] = $subfield['description'];
- }
-
- // Add enum for select/radio fields
- if (in_array($subfield['type'], ['select', 'radio']) && isset($subfield['options'])) {
- $properties[$key]['enum'] = array_keys($subfield['options']);
- }
- }
-
- return [
- 'type' => 'object',
- 'properties' => $properties,
- 'additionalProperties' => false
- ];
- } elseif ($field['type'] === 'location') {
- return [
- 'type' => 'object',
- 'properties' => [
- 'address' => ['type' => 'string'],
- 'lat' => ['type' => 'number'],
- 'lng' => ['type' => 'number']
- ]
- ];
- }
-
- return [
- 'items' => ['type' => 'string']
- ];
- }
-
-
- protected function logError(string $message, array $context = [])
- {
- error_log(sprintf(
- '[MetaRegistry] %s | Context: %s',
- $message,
- json_encode($context)
- ));
- }
-}
diff --git a/inc/meta/MetaRenderer.php b/inc/meta/MetaRenderer.php
deleted file mode 100644
index cbf8d4b..0000000
--- a/inc/meta/MetaRenderer.php
+++ /dev/null
@@ -1,561 +0,0 @@
-<?php
-namespace JVBase\meta;
-
-if (!defined('ABSPATH')) {
- exit; // Exit if accessed directly
-}
-class MetaRenderer
-{
-
- /**
- * Rendering can be overridden in individual plugin/theme setups by creating a function
- * A) for the named field via camelCased, no underscore function: BASE.Render{$name}Field($value, $config);
- * B) for the entire field type via camelCased, no underscore function: BASE.Render{$type}Field($value, $config);
- * @param string $name
- * @param mixed $value
- * @param array $config
- *
- * @return mixed
- */
- public function render(string $name, mixed $value, array $config, bool $return = false):mixed
- {
- $type = array_map('ucfirst', explode('_', $config['type']));
- $type = implode('', $type);
- $method = 'render' . $type . 'Field';
- $methodFunction = str_replace('_', '', BASE).ucfirst($method);
- $nameFunction = str_replace('_', '', BASE).'Render'.ucfirst($name).'Field';
- $output = '';
- if (function_exists($nameFunction)) {
- $output = call_user_func($nameFunction, $value, $config);
- } elseif (function_exists($methodFunction)) {
- $output = call_user_func($methodFunction, $value, $config);
- } elseif (method_exists($this, $method)) {
- $output = $this->$method($name, $value, $config);
- }
-
- if ($return) {
- return $output;
- }
-
- echo $output;
- return true;
- }
-
- protected function renderTextField(string $name, string $value, array $field):string
- {
- return apply_filters('the_content', $value);
- }
-
- protected function renderTextareaField(string $name, string $value, array $field):string
- {
- return $this->renderTextField($name, $value, $field);
- }
- protected function renderNumberField(string $name, string $value, array $field):string
- {
- return $this->renderTextField($name, $value, $field);
- }
- protected function renderEmailField(string $name, string $value, array $field):string
- {
- $link = 'mailto:'.$value.'?subject='.rawurlencode('Contact from edmonton.ink').'&body='.rawurlencode('Hey,
- I found you on edmonton.ink, and I wanted to reach out!');
-
- return '<a href="'.$link.'" title="Send an Email">'.jvbIcon('envelope').'<span>Email</span></a>';
-
- }
- protected function renderUrlField(string $name, string $value, array $field):string
- {
-// jvbDump($value, 'URL Field:');
-// jvbDump($field);
- return '';
- }
- protected function renderImageField(string $name, string $value, array $field):string
- {
- if (!is_numeric($value)) {
- return '';
- }
- return jvbFormatImage($value, 'tiny', 'medium');
- }
- protected function renderGalleryField(string $name, array|null|false $value, array $field):string
- {
-// jvbDump($value, 'Gallery Field:');
-// jvbDump($field);
- return '';
- }
- protected function renderTaxonomyField($name, $value, $field):string
- {
- if ($value === '') {
- return '';
- }
- $value = explode(',', $value);
- $terms = get_terms([
- 'taxonomy' => jvbCheckBase($field['taxonomy']),
- 'terms_in' => $value
- ]);
- return jvbRenderTermList($terms, $field['label']);
- }
-
- protected function renderTagListField(string $name, array|bool $value, array $field): string
- {
- if (empty($value) || !is_array($value)) {
- return '';
- }
-
- if (!isset($field['fields']) || !is_array($field['fields'])) {
- return '';
- }
-
- $tag_format = $field['tag_format'] ?? 'first_field';
- $output = '<div class="tag-list-display">';
-
- if (!empty($field['label']) && ($field['show_label'] ?? false)) {
- $output .= '<h4 class="tag-list-label">' . esc_html($field['label']) . '</h4>';
- }
-
- $output .= '<div class="tag-list-items">';
-
- foreach ($value as $item) {
- if (!is_array($item) || empty($item)) {
- continue;
- }
-
- $tag_text = $this->getTagDisplayText($item, $tag_format);
- $output .= '<span class="tag-list-item">' . esc_html($tag_text) . '</span>';
- }
-
- $output .= '</div></div>';
-
- return $output;
- }
-
- /**
- * Get display text for a tag based on format
- */
- protected function getTagDisplayText(array $data, string $format): string
- {
- $values = array_filter(array_values($data));
-
- if (empty($values)) {
- return '';
- }
-
- switch ($format) {
- case 'first_field':
- return $values[0];
-
- case 'all_fields':
- return implode(', ', $values);
-
- default:
- // Template format like "{name} ({email})"
- if (strpos($format, '{') !== false) {
- $text = $format;
- foreach ($data as $key => $value) {
- $text = str_replace('{' . $key . '}', $value, $text);
- }
- return $text;
- }
-
- // Use specific field
- return $data[$format] ?? $values[0];
- }
- }
-
- protected function renderRepeaterField($name, $value, $field):string
- {
-// jvbDump($value, 'Repeater Field:');
-// jvbDump($field);
- return '';
- }
-
- protected function renderGroupField(string $name, array|bool $value, array $field):string
- {
- if (empty($value) || !$value || !isset($field['fields'])) {
- return '';
- }
-
- $output = '<div class="group-field ' . esc_attr($name) . '">';
-
- // Add field label if configured to show
- if (isset($field['show_label']) && $field['show_label'] && !empty($field['label'])) {
- $output .= '<h3 class="group-label">' . esc_html($field['label']) . '</h3>';
- }
-
- $output .= '<div class="group-content col">';
-
- foreach ($field['fields'] as $subfield_name => $subfield_config) {
- if (!isset($value[$subfield_name]) || empty($value[$subfield_name])) {
- continue;
- }
-
- $subfield_value = $value[$subfield_name];
-
- // Check if subfield has conditions that prevent display
- if (isset($subfield_config['condition'])) {
- $condition = $subfield_config['condition'];
- $condition_field = $condition['field'];
- $condition_value = $value[$condition_field] ?? '';
- $operator = $condition['operator'] ?? '==';
- $expected_value = $condition['value'];
-
- $condition_met = false;
- switch ($operator) {
- case '==':
- $condition_met = $condition_value == $expected_value;
- break;
- case '!=':
- $condition_met = $condition_value != $expected_value;
- break;
- case 'in':
- $condition_met = is_array($expected_value) && in_array($condition_value, $expected_value);
- break;
- case 'not_in':
- $condition_met = is_array($expected_value) && !in_array($condition_value, $expected_value);
- break;
- }
-
- if (!$condition_met) {
- continue;
- }
- }
-
- // Render the subfield
- $subfield_output = $this->render($subfield_name, $subfield_value, $subfield_config, true);
-
- if (!empty($subfield_output)) {
- $output .= '<div class="group-item ' . esc_attr($subfield_name) . '">';
-
- // Add label if configured
- if (isset($subfield_config['show_label']) && $subfield_config['show_label'] && !empty($subfield_config['label'])) {
- $output .= '<span class="subfield-label">' . esc_html($subfield_config['label']) . ':</span> ';
- }
-
- $output .= $subfield_output;
- $output .= '</div>';
- }
- }
-
- $output .= '</div></div>';
-
- return $output;
- }
- protected function renderLocationField(string $name, array|bool $value, array $field): string
- {
- if (!$value) {
- return '';
- }
- // Early return if no location data
- if (empty($value) || empty($value['lat']) || empty($value['lng'])) {
- return '';
- }
-
- $googleMaps = JVB()->connect('maps');
- if (!$googleMaps->isSetUp()) {
- error_log('Google Maps is not set up');
- // Fallback to text-only display
- return $this->renderLocationFallback($value, $field);
- }
-
- error_log('Google Maps: '.print_r($googleMaps, true));
- $lat = (float)$value['lat'];
- $lng = (float)$value['lng'];
- $address = $googleMaps->formatAddress($value, 'full');
-
- // Map display options (can be configured via field config)
- $map_options = array_merge([
- 'height' => '200px',
- 'zoom' => 14,
- 'show_marker' => true,
- 'show_info_window' => true,
- 'interactive' => true
- ], $field['map_options'] ?? []);
-
- // Link options
- $link_options = array_merge([
- 'show_icons' => true,
- 'style' => 'buttons',
- 'include' => ['google', 'apple']
- ], $field['link_options'] ?? []);
-
- ob_start();
- ?>
- <div class="jvb-location-display">
- <?php
- // Render the interactive map
- echo $googleMaps->renderDisplayMap($value, $map_options);
- ?>
-
- <div class="jvb-location-details">
- <?php if (!empty($address)): ?>
- <div class="jvb-location-address">
- <?= jvbIcon('map-pin'); ?>
- <span><?= esc_html($address); ?></span>
- </div>
- <?php endif; ?>
-
- <?php
- // Render map application links
- echo $googleMaps->renderMapLinks($lat, $lng, $address, $link_options);
- ?>
- </div>
- </div>
-
- <style>
- .jvb-location-display {
- margin: 1rem 0;
- }
-
- .jvb-location-map-display {
- margin-bottom: 1rem;
- border: 1px solid #e0e0e0;
- }
-
- .jvb-location-details {
- display: flex;
- flex-wrap: wrap;
- gap: 1rem;
- align-items: center;
- font-size: 0.95rem;
- }
-
- .jvb-location-address {
- display: flex;
- align-items: center;
- color: #1B1B1B;
- flex: 1;
- min-width: 200px;
- }
-
- .jvb-location-address svg {
- margin-right: 0.5rem;
- color: #FF0080;
- width: 18px;
- height: 18px;
- }
-
- .jvb-map-links {
- display: flex;
- gap: 0.75rem;
- }
-
- .jvb-map-links.button-style .map-link {
- display: inline-flex;
- align-items: center;
- padding: 0.4rem 0.75rem;
- border-radius: 6px;
- background-color: #1B1B1B;
- color: #EFEFEF;
- text-decoration: none;
- transition: all 0.2s ease;
- font-size: 0.9rem;
- }
-
- .jvb-map-links.button-style .map-link svg {
- margin-right: 0.35rem;
- width: 16px;
- height: 16px;
- }
-
- .jvb-map-links.button-style .map-link:hover {
- background-color: #FF0080;
- transform: translateY(-1px);
- }
-
- .jvb-map-links.text-style .map-link {
- color: #FF0080;
- text-decoration: none;
- font-weight: 500;
- }
-
- .jvb-map-links.text-style .map-link:hover {
- text-decoration: underline;
- }
-
- .map-info-window {
- padding: 8px 12px;
- font-weight: 500;
- color: #1B1B1B;
- }
-
- @media (max-width: 768px) {
- .jvb-location-details {
- flex-direction: column;
- align-items: flex-start;
- gap: 0.75rem;
- }
-
- .jvb-location-address {
- min-width: auto;
- }
- }
- </style>
- <?php
-
- return ob_get_clean();
- }
-
- protected function renderSetField(string $name, string $value, array $field):string
- {
- if ($value === '') {
- return '';
- }
- $value = explode(',', $value);
-
- $output = '<div class="set-field-display row">';
-
- if (!empty($field['label']) && ($field['show_label'] ?? false)) {
- $output .= '<span class="set-label">' . esc_html($field['label']) . ':</span> ';
- }
-
- $items = [];
- foreach ($value as $key) {
- if (isset($field['options'][$key])) {
- $items[] = '<span class="set-item">' . esc_html($field['options'][$key]) . '</span>';
- }
- }
-
- $separator = $field['separator'] ?? ', ';
- $output .= implode($separator, $items);
- $output .= '</div>';
-
- return $output;
- }
-
- protected function renderCheckboxField(string $name, string $value, array $field):string
- {
- // Checkbox fields are essentially the same as set fields for display
- return $this->renderSetField($name, $value, $field);
- }
-
- protected function renderRadioField(string $name, string $value, array $field):string
- {
- if (empty($value) || !isset($field['options'][$value])) {
- return '';
- }
-
- $display_value = $field['options'][$value];
-
- $output = '<div class="radio-field-display">';
-
- if (!empty($field['label']) && ($field['show_label'] ?? false)) {
- $output .= '<span class="radio-label">' . esc_html($field['label']) . ':</span> ';
- }
-
- $output .= '<span class="radio-value">' . esc_html($display_value) . '</span>';
- $output .= '</div>';
-
- return $output;
- }
-
- protected function renderSelectField(string $name, string $value, array $field):string
- {
- // Select fields display the same as radio fields
- return $this->renderRadioField($name, $value, $field);
- }
-
- protected function renderTrueFalseField(string $name, bool $value, array $field):string
- {
- $display_text = $value ? ($field['true_text'] ?? 'Yes') : ($field['false_text'] ?? 'No');
- $css_class = $value ? 'true-value' : 'false-value';
-
- $output = '<div class="true-false-field-display">';
-
- if (!empty($field['label']) && ($field['show_label'] ?? false)) {
- $output .= '<span class="true-false-label">' . esc_html($field['label']) . ':</span> ';
- }
-
- $output .= '<span class="' . $css_class . '">' . esc_html($display_text) . '</span>';
- $output .= '</div>';
-
- return $output;
- }
-
- protected function renderTimeField(string $name, string $value, array $field):string
- {
- if (empty($value)) {
- return '';
- }
-
- // Format time for display
- $display_format = $field['display_format'] ?? 'g:i A'; // Default: 12-hour format with AM/PM
-
- // Parse the time value
- $timestamp = strtotime($value);
- if ($timestamp === false) {
- return '';
- }
-
- $formatted_time = date($display_format, $timestamp);
-
- // Add icon if configured
- $show_icon = $field['show_icon'] ?? true;
- $icon_html = $show_icon ? jvbIcon('clock') : '';
-
- return sprintf(
- '<div class="time-field-display">%s<span class="time-value">%s</span></div>',
- $icon_html,
- esc_html($formatted_time)
- );
- }
-
- protected function renderDatetimeField(string $name, string $value, array $field):string
- {
- if (empty($value)) {
- return '';
- }
-
- // Parse datetime
- $date = DateTime::createFromFormat('Y-m-d H:i:s', $value);
- if (!$date) {
- // Try alternative formats
- $formats = ['Y-m-d\TH:i:s', 'Y-m-d\TH:i', 'Y-m-d H:i'];
- foreach ($formats as $format) {
- $date = DateTime::createFromFormat($format, $value);
- if ($date) break;
- }
- }
-
- if (!$date) {
- return '';
- }
-
- // Format for display
- $display_format = $field['display_format'] ?? 'F j, Y \a\t g:i A'; // Default: March 15, 2024 at 2:30 PM
- $formatted_datetime = $date->format($display_format);
-
- // Add icon if configured
- $show_icon = $field['show_icon'] ?? true;
- $icon_html = $show_icon ? jvbIcon('calendar') : '';
-
- return sprintf(
- '<div class="datetime-field-display">%s<span class="datetime-value">%s</span></div>',
- $icon_html,
- esc_html($formatted_datetime)
- );
- }
-
- protected function renderDateField(string $name, string $value, array $field):string
- {
- if (empty($value)) {
- return '';
- }
-
- $timestamp = strtotime($value);
- if ($timestamp === false) {
- return '';
- }
-
- // Format for display
- $display_format = $field['display_format'] ?? 'F j, Y'; // Default: March 15, 2024
- $formatted_date = date($display_format, $timestamp);
-
- // Add icon if configured
- $show_icon = $field['show_icon'] ?? true;
- $icon_html = $show_icon ? jvbIcon('calendar') : '';
-
- return sprintf(
- '<div class="date-field-display">%s<span class="date-value">%s</span></div>',
- $icon_html,
- esc_html($formatted_date)
- );
- }
-}
diff --git a/inc/meta/MetaTypeManager.php b/inc/meta/MetaTypeManager.php
index 6fcc1ba..4ea0aeb 100644
--- a/inc/meta/MetaTypeManager.php
+++ b/inc/meta/MetaTypeManager.php
@@ -9,7 +9,7 @@
*/
class MetaTypeManager
{
- protected array $type_map = [
+ protected static array $type_map = [
'text' => [
'type' => 'string',
'sanitize' => 'sanitize_text_field',
@@ -130,23 +130,23 @@
'default' => '',
]
];
- public function getType(string $field_name):array
+ public static function getType(string $field_name):array
{
- return $this->type_map[$field_name]??[];
+ return static::$type_map[$field_name]??[];
}
- public function getMetaType(string $field_type):string
+ public static function getMetaType(string $field_type):string
{
- return $this->type_map[$field_type]['type'] ?? 'string';
+ return static::$type_map[$field_type]['type'] ?? 'string';
}
- public function getSanitizeCallback(string $field_type):string
+ public static function getSanitizeCallback(string $field_type):string
{
- return $this->type_map[$field_type]['sanitize'] ?? 'sanitize_text_field';
+ return static::$type_map[$field_type]['sanitize'] ?? 'sanitize_text_field';
}
- public function registerType(string $type, array $config):void
+ public static function registerType(string $type, array $config):void
{
- $this->type_map[$type] = $config;
+ static::$type_map[$type] = $config;
}
}
diff --git a/inc/meta/MetaValidator.php b/inc/meta/MetaValidator.php
deleted file mode 100644
index 1d0236a..0000000
--- a/inc/meta/MetaValidator.php
+++ /dev/null
@@ -1,599 +0,0 @@
-<?php
-namespace JVBase\meta;
-
-use DateTime;
-use JVBase\meta\MetaTypeManager;
-use WP_Error;
-
-if (!defined('ABSPATH')) {
- exit; // Exit if accessed directly
-}
-/**
- * Handles meta value validation
- */
-class MetaValidator
-{
- protected MetaTypeManager $type_manager;
- protected array $errors = [];
-
- public function __construct()
- {
- $this->type_manager = new MetaTypeManager();
- }
-
- public function validate(mixed $value, array $field_config):bool
- {
- $this->errors = []; // Reset errors
- $type = $field_config['type'];
-
- // Required field check
- if (!empty($field_config['required']) && empty($value)) {
- $this->addError($field_config['name'], __('This field is required', 'jvb'));
- return false;
- }
-
- // Type-specific validation
- $method = "validate_{$type}";
- if (method_exists($this, $method)) {
- return $this->$method($value, $field_config);
- }
-
- return true;
- }
-
-
- protected function validateNumber(float $value, array $field_config):bool|WP_Error
- {
- if (empty($value)) {
- if (!empty($field_config['required'])) { // ✅ Correct variable
- return new \WP_Error('required_field', 'This field is required');
- }
- return true;
- }
-
- if (!is_numeric($value)) {
- $this->addError($field_config['name'], __('Must be a number', 'jvb'));
- return false;
- }
-
- if (array_key_exists('min', $field_config) && $value < $field_config['min']) {
- $this->addError(
- $field_config['name'],
- sprintf(__('Must be at least %s', 'jvb'), $field_config['min'])
- );
- return false;
- }
-
- if (array_key_exists('max', $field_config) && $value > $field_config['max']) {
- $this->addError(
- $field_config['name'],
- sprintf(__('Must not exceed %s', 'jvb'), $field_config['max'])
- );
- return false;
- }
-
- return true;
- }
-
- protected function validateEmail(string $value, array $config):bool|WP_Error
- {
- if (empty($value)) {
- if (!empty($config['required'])) { // ✅ Correct variable
- return new \WP_Error('required_field', 'This field is required');
- }
- return true;
- }
-
- // Validate email format
- if (!is_email($value)) {
- $this->addError($config['name'], __('Invalid email address', 'jvb'));
- return false;
- }
-
- return true;
- }
-
-
- protected function validateGroup(array $value, array $config):bool
- {
- if (empty($value) || !is_array($value)) {
- if (!empty($config['required'])) {
- $this->addError($config['name'], __('This field is required', 'jvb'));
- return false;
- }
- return true;
- }
-
- // Validate each sub-field
- if (!empty($config['fields']) && is_array($config['fields'])) {
- foreach ($config['fields'] as $subFieldName => $subFieldConfig) {
- if (isset($value[$subFieldName])) {
- $subFieldConfig['name'] = $subFieldName;
- $isValid = $this->validate($value[$subFieldName], $subFieldConfig);
- if (!$isValid) {
- return false;
- }
- }
- }
- }
-
- return true;
- }
-
- protected function validateGallery(array|string $value, array $field):bool|WP_Error
- {
- if (empty($value)) {
- if (!empty($field['required'])) {
- return new WP_Error('required_field', 'This field is required');
- }
- return true;
- }
-
- $ids = is_array($value) ? $value : explode(',', $value);
-
- // Check maximum images if specified
- if (!empty($field['max_images']) && count($ids) > $field['max_images']) {
- return new WP_Error(
- 'max_images_exceeded',
- sprintf('Maximum of %d images allowed', $field['max_images'])
- );
- }
-
- // Validate each ID is an actual image attachment
- foreach ($ids as $id) {
- $id = absint($id);
- if ($id <= 0 || !wp_attachment_is_image($id)) {
- return new WP_Error('invalid_image', 'One or more invalid images');
- }
- }
-
- return true;
- }
-
- protected function validateUrl(string $value, array $config):bool|WP_Error
- {
- if (empty($value)) {
- if (!empty($field['required'])) {
- return new WP_Error('required_field', 'This field is required');
- }
- return true;
- }
- $check = filter_var($value, FILTER_VALIDATE_URL) !== false;
- if (!$check) {
- $this->addError(
- $config['name'],
- __('Must be a valid URL', 'jvb')
- );
- }
- return $check;
- }
-
- protected function validateDate(string $value, array $config):bool|WP_Error
- {
- if (empty($value)) {
- if (!empty($field['required'])) {
- return new WP_Error('required_field', 'This field is required');
- }
- return true;
- }
- $timestamp = strtotime($value);
- if ($timestamp === false) {
- $this->addError(
- $config['name'],
- __('It\'s gotta be a time, bro', 'jvb')
- );
- return false;
- }
-
- if (isset($config['min_date']) && $timestamp < strtotime($config['min_date'])) {
- return false;
- }
-
- if (isset($config['max_date']) && $timestamp > strtotime($config['max_date'])) {
- return false;
- }
-
- return true;
- }
-
- protected function validateDatetime(string $value, array $config):bool
- {
- if (empty($value)) {
- if (!empty($config['required'])) {
- $this->addError($config['name'], __('This field is required', 'jvb'));
- return false;
- }
- return true;
- }
-
- // Try to parse the datetime
- $date = DateTime::createFromFormat('Y-m-d H:i:s', $value);
- if (!$date) {
- // Try alternative formats
- $formats = ['Y-m-d\TH:i:s', 'Y-m-d\TH:i', 'Y-m-d H:i'];
- foreach ($formats as $format) {
- $date = DateTime::createFromFormat($format, $value);
- if ($date) break;
- }
- }
-
- if (!$date) {
- $this->addError($config['name'], __('Invalid datetime format', 'jvb'));
- return false;
- }
-
- $timestamp = $date->getTimestamp();
-
- // Validate datetime range if specified
- if (isset($config['min_datetime'])) {
- $min_timestamp = strtotime($config['min_datetime']);
- if ($timestamp < $min_timestamp) {
- $min_date = new DateTime($config['min_datetime']);
- $this->addError(
- $config['name'],
- sprintf(__('DateTime must be after %s', 'jvb'), $min_date->format('F j, Y g:i A'))
- );
- return false;
- }
- }
-
- if (isset($config['max_datetime'])) {
- $max_timestamp = strtotime($config['max_datetime']);
- if ($timestamp > $max_timestamp) {
- $max_date = new DateTime($config['max_datetime']);
- $this->addError(
- $config['name'],
- sprintf(__('DateTime must be before %s', 'jvb'), $max_date->format('F j, Y g:i A'))
- );
- return false;
- }
- }
-
- return true;
- }
-
- protected function validateTime(string $value, array $config):bool
- {
- if (empty($value)) {
- if (!empty($config['required'])) {
- $this->addError($config['name'], __('This field is required', 'jvb'));
- return false;
- }
- return true;
- }
-
- // Check if time is in valid format (HH:MM)
- if (!preg_match('/^([01]?[0-9]|2[0-3]):[0-5][0-9]$/', $value)) {
- $this->addError($config['name'], __('Time must be in HH:MM format', 'jvb'));
- return false;
- }
-
- // Validate time range if specified
- if (isset($config['min_time'])) {
- if (strtotime($value) < strtotime($config['min_time'])) {
- $this->addError(
- $config['name'],
- sprintf(__('Time must be after %s', 'jvb'), $config['min_time'])
- );
- return false;
- }
- }
-
- if (isset($config['max_time'])) {
- if (strtotime($value) > strtotime($config['max_time'])) {
- $this->addError(
- $config['name'],
- sprintf(__('Time must be before %s', 'jvb'), $config['max_time'])
- );
- return false;
- }
- }
-
- return true;
- }
-
- protected function validateTagList(array $value, array $config): bool
- {
- if (empty($value)) {
- if (!empty($config['required'])) {
- $this->addError($config['name'], __('This field is required', 'jvb'));
- return false;
- }
- return true;
- }
-
- if (!is_array($value)) {
- $this->addError($config['name'], __('Invalid data format', 'jvb'));
- return false;
- }
-
- // Check min/max items
- if (isset($config['min_items']) && count($value) < $config['min_items']) {
- $this->addError(
- $config['name'],
- sprintf(__('Minimum of %d items required', 'jvb'), $config['min_items'])
- );
- return false;
- }
-
- if (isset($config['max_items']) && count($value) > $config['max_items']) {
- $this->addError(
- $config['name'],
- sprintf(__('Maximum of %d items allowed', 'jvb'), $config['max_items'])
- );
- return false;
- }
-
- // Validate each item's fields
- if (!isset($config['fields']) || !is_array($config['fields'])) {
- return true;
- }
-
- foreach ($value as $index => $row) {
- if (!is_array($row)) {
- continue;
- }
-
- foreach ($config['fields'] as $field_name => $field_config) {
- if (!isset($row[$field_name])) {
- continue;
- }
-
- $field_config['name'] = "{$config['name']}[{$index}][{$field_name}]";
-
- if (!$this->validate($row[$field_name], $field_config)) {
- return false;
- }
- }
- }
-
- return true;
- }
-
- protected function validateRepeater(array $value, array $config):bool|WP_Error
- {
- if (empty($value)) {
- if (!empty($field['required'])) {
- return new WP_Error('required_field', 'This field is required');
- }
- return true;
- }
- if (!is_array($value)) {
- return false;
- }
-
- if (isset($config['min_rows']) && count($value) < $config['min_rows']) {
- return false;
- }
-
- if (isset($config['max_rows']) && count($value) > $config['max_rows']) {
- return false;
- }
-
- foreach ($value as $row) {
- foreach ($config['fields'] as $field_name => $field_config) {
- if (!isset($row[$field_name])) {
- continue;
- }
- if (!$this->validate($row[$field_name], $field_config)) {
- return false;
- }
- }
- }
-
- return true;
- }
-
- public function getErrors():array
- {
- return $this->errors;
- }
-
- public function hasErrors():bool
- {
- return !empty($this->errors);
- }
-
- protected function addError(string $field, string $message):void
- {
- if (!isset($this->errors[$field])) {
- $this->errors[$field] = [];
- }
- $this->errors[$field][] = $message;
- }
-
- protected function validateText(string $value, array $config): bool
- {
- if (empty($value)) {
- if (!empty($config['required'])) {
- $this->addError($config['name'], __('This field is required', 'jvb'));
- return false;
- }
- return true;
- }
-
- // Check character limit
- if (isset($config['limit']) && strlen($value) > $config['limit']) {
- $this->addError(
- $config['name'],
- sprintf(__('Must not exceed %d characters', 'jvb'), $config['limit'])
- );
- return false;
- }
-
- // Pattern validation if specified
- if (isset($config['pattern']) && !preg_match($config['pattern'], $value)) {
- $this->addError($config['name'], __('Invalid format', 'jvb'));
- return false;
- }
-
- return true;
- }
-
- protected function validateTextarea(string $value, array $config): bool
- {
- return $this->validateText($value, $config); // Reuse text validation
- }
-
- protected function validateSelect(string $value, array $config): bool
- {
- if (empty($value)) {
- if (!empty($config['required'])) {
- $this->addError($config['name'], __('This field is required', 'jvb'));
- return false;
- }
- return true;
- }
-
- if (!isset($config['options']) || !array_key_exists($value, $config['options'])) {
- $this->addError($config['name'], __('Invalid selection', 'jvb'));
- return false;
- }
-
- return true;
- }
-
- protected function validateRadio(string $value, array $config): bool
- {
- return $this->validateSelect($value, $config); // Same logic
- }
-
- protected function validateSet(array|string $value, array $config): bool
- {
- if (empty($value)) {
- if (!empty($config['required'])) {
- $this->addError($config['name'], __('This field is required', 'jvb'));
- return false;
- }
- return true;
- }
-
- if (!is_array($value)) {
- $value = explode(',', $value);
- }
-
- if (!isset($config['options'])) {
- return true;
- }
-
- $invalid_values = array_diff($value, array_keys($config['options']));
- if (!empty($invalid_values)) {
- $this->addError($config['name'], __('Invalid selections', 'jvb'));
- return false;
- }
-
- return true;
- }
-
- protected function validateCheckbox(array|string $value, array $config): bool
- {
- return $this->validateSet($value, $config); // Same logic
- }
-
- protected function validateImage(int $value, array $config): bool
- {
- if (empty($value)) {
- if (!empty($config['required'])) {
- $this->addError($config['name'], __('This field is required', 'jvb'));
- return false;
- }
- return true;
- }
-
- if (!wp_attachment_is_image($value)) {
- $this->addError($config['name'], __('Invalid image', 'jvb'));
- return false;
- }
-
- return true;
- }
-
- protected function validateTaxonomy(array|string $value, array $config): bool
- {
- if (empty($value)) {
- if (!empty($config['required'])) {
- $this->addError($config['name'], __('This field is required', 'jvb'));
- return false;
- }
- return true;
- }
-
- if (!is_array($value)) {
- $value = explode(',', $value);
- }
-
- $taxonomy = (str_starts_with($config['taxonomy'], BASE))
- ? $config['taxonomy']
- : BASE . $config['taxonomy'];
-
- foreach ($value as $term_id) {
- if (!term_exists((int)$term_id, $taxonomy)) {
- $this->addError($config['name'], __('Invalid term selected', 'jvb'));
- return false;
- }
- }
-
- return true;
- }
-
- protected function validateUser(array|string $value, array $config): bool
- {
- if (empty($value)) {
- if (!empty($config['required'])) {
- $this->addError($config['name'], __('This field is required', 'jvb'));
- return false;
- }
- return true;
- }
-
- if (!is_array($value)) {
- $value = explode(',', $value);
- }
-
- foreach ($value as $user_id) {
- if (!get_userdata((int)$user_id)) {
- $this->addError($config['name'], __('Invalid user selected', 'jvb'));
- return false;
- }
- }
-
- return true;
- }
-
- protected function validateLocation(array $value, array $config): bool
- {
- if (empty($value)) {
- if (!empty($config['required'])) {
- $this->addError($config['name'], __('This field is required', 'jvb'));
- return false;
- }
- return true;
- }
-
- if (!is_array($value)) {
- $this->addError($config['name'], __('Location must be an array', 'jvb'));
- return false;
- }
-
- // Validate required location fields
- $required_fields = ['lat', 'lng'];
- foreach ($required_fields as $field) {
- if (!isset($value[$field]) || !is_numeric($value[$field])) {
- $this->addError($config['name'], __('Invalid location coordinates', 'jvb'));
- return false;
- }
- }
-
- return true;
- }
-
- protected function validateTrueFalse(mixed $value, array $config): bool
- {
- if (!empty($config['required']) && empty($value)) {
- $this->addError($config['name'], __('This field is required', 'jvb'));
- return false;
- }
-
- return true; // Boolean values are always valid after sanitization
- }
-}
diff --git a/inc/meta/Registry.php b/inc/meta/Registry.php
new file mode 100644
index 0000000..8a4c3a7
--- /dev/null
+++ b/inc/meta/Registry.php
@@ -0,0 +1,182 @@
+<?php
+namespace JVBase\meta;
+
+use InvalidArgumentException;
+
+if (!defined('ABSPATH')) {
+ exit;
+}
+
+class Registry
+{
+ protected string $object; // post type or taxonomy slug
+ protected string $object_type; // post, term, user
+ protected array $fields;
+ protected string $prefix = BASE;
+
+ public function __construct(array $fields, string $object, string $object_type)
+ {
+ if (!in_array($object_type, ['post', 'term', 'user'])) {
+ return;
+ }
+
+ $this->fields = $fields;
+ $this->object = jvbCheckBase($object);
+ $this->object_type = $object_type;
+ }
+
+ public function registerMetaFields(): void
+ {
+ $fields = $this->fields;
+
+ foreach ($fields as $name => $options) {
+ if (in_array($name, [
+ 'post_title',
+ 'post_content',
+ 'post_excerpt',
+ 'featured_image',
+ 'display_name',
+ 'user_email',
+ ])) {
+ unset($fields[$name]);
+ }
+ }
+
+ foreach ($fields as $field_name => $field) {
+ $this->validateFieldType($field_name, $field);
+ $field = array_merge(MetaTypeManager::getType($field['type']), $field);
+ $args = $this->getFieldArgs($field_name, $field);
+
+ $args = apply_filters(
+ BASE . 'meta_field_args',
+ $args,
+ $field_name,
+ $field,
+ $this->object_type
+ );
+
+ $temp = register_meta($this->object_type, $this->prefix . $field_name, $args);
+
+ if (!$temp) {
+ $args['auth_callback'] = gettype($args['auth_callback']);
+ error_log('Error with registering meta:' . print_r([
+ 'object_type' => $this->object_type,
+ 'prefix' => $this->prefix,
+ 'field_name' => $field_name,
+ 'args' => $args,
+ ], true));
+ }
+
+ do_action(BASE . 'meta_field_registered', $field_name, $field, $this);
+ }
+ }
+
+ protected function validateFieldType(string $field_name, array $field): void
+ {
+ $required = ['name', 'type', 'label'];
+ $field['name'] = $field_name;
+ foreach ($required as $type) {
+ if (!isset($field[$type])) {
+ throw new InvalidArgumentException(sprintf('Field %s is required', $type));
+ }
+ }
+ }
+
+ protected function getFieldArgs(string $field_name, array $field): array
+ {
+ $args = [
+ 'object_subtype' => $this->object,
+ 'type' => MetaTypeManager::getMetaType($field['type']),
+ 'label' => __($field['label'], 'jvb') ?? '',
+ 'description' => __($field['description'] ?? '', 'jvb'),
+ 'single' => true,
+ 'show_in_rest' => $field['show_in_rest'] ?? true,
+ 'sanitize_callback' => $this->getSanitizeCallback($field),
+ 'auth_callback' => [$this, 'validate_permissions'],
+ 'default' => $field['default'] ?? '',
+ ];
+
+ if ($this->object_type === 'post') {
+ $args['revisions_enabled'] = true;
+ }
+
+ if (in_array($field['type'], ['repeater', 'group', 'location']) || $args['type'] === 'array') {
+ $args['show_in_rest'] = [
+ 'schema' => $this->getFieldSchema($field)
+ ];
+ }
+
+ return $args;
+ }
+
+ /**
+ * Build sanitize callback for register_meta
+ */
+ protected function getSanitizeCallback(array $field): callable
+ {
+ return fn($value) => MetaSanitizer::sanitize($value, $field);
+ }
+
+ protected function getFieldSchema(array $field): array
+ {
+ if ($field['type'] === 'repeater') {
+ $properties = [];
+ foreach ($field['fields'] as $key => $subfield) {
+ $properties[$key] = [
+ 'type' => MetaTypeManager::getMetaType($subfield['type'])
+ ];
+ }
+
+ return [
+ 'type' => 'object',
+ 'items' => [
+ 'type' => 'object',
+ 'properties' => $properties
+ ]
+ ];
+ } elseif ($field['type'] === 'group') {
+ $properties = [];
+ foreach ($field['fields'] as $key => $subfield) {
+ $properties[$key] = [
+ 'type' => MetaTypeManager::getMetaType($subfield['type'])
+ ];
+
+ if (isset($subfield['description'])) {
+ $properties[$key]['description'] = $subfield['description'];
+ }
+
+ if (in_array($subfield['type'], ['select', 'radio']) && isset($subfield['options'])) {
+ $properties[$key]['enum'] = array_keys($subfield['options']);
+ }
+ }
+
+ return [
+ 'type' => 'object',
+ 'properties' => $properties,
+ 'additionalProperties' => false
+ ];
+ } elseif ($field['type'] === 'location') {
+ return [
+ 'type' => 'object',
+ 'properties' => [
+ 'address' => ['type' => 'string'],
+ 'lat' => ['type' => 'number'],
+ 'lng' => ['type' => 'number']
+ ]
+ ];
+ }
+
+ return [
+ 'items' => ['type' => 'string']
+ ];
+ }
+
+ protected function logError(string $message, array $context = []): void
+ {
+ error_log(sprintf(
+ '[Meta.Registry] %s | Context: %s',
+ $message,
+ json_encode($context)
+ ));
+ }
+}
diff --git a/inc/meta/Render.php b/inc/meta/Render.php
new file mode 100644
index 0000000..854ae62
--- /dev/null
+++ b/inc/meta/Render.php
@@ -0,0 +1,427 @@
+<?php
+namespace JVBase\meta;
+
+if (!defined('ABSPATH')) {
+ exit;
+}
+
+/**
+ * Static utility for rendering meta values on frontend
+ *
+ * Usage:
+ * echo Render::render('price', 150, ['type' => 'number', 'prefix' => '$']);
+ * echo Render::render('gallery', $images, ['type' => 'gallery']);
+ */
+class Render
+{
+ /**
+ * Render a field value based on type
+ */
+ public static function render(string $name, mixed $value, array $config = []): string
+ {
+ if (!apply_filters('jvbShouldRenderMeta', true, $name, $config['type'] ?? 'text', null)) {
+ return '';
+ }
+
+ $type = $config['type'] ?? 'text';
+ $method = 'render' . str_replace('_', '', ucwords($type, '_'));
+
+ $output = method_exists(static::class, $method)
+ ? static::$method($name, $value, $config)
+ : static::renderDefault($name, $value, $config);
+
+ return apply_filters('jvbRenderFrontendMeta', $output, $name, $config, $value, null);
+ }
+
+ /**
+ * Render with Meta instance (convenience method)
+ */
+ public static function renderFrom(Meta $meta, string $name, bool $hideEmpty = true): string
+ {
+ $value = $meta->get($name);
+ $config = $meta->config($name) ?? ['type' => 'text'];
+
+ $output = static::render($name, $value, $config);
+
+ if (empty($output) && !$hideEmpty) {
+ return static::getEmptyTemplate($config['type'] ?? 'text', $name);
+ }
+
+ return $output;
+ }
+
+ // ─────────────────────────────────────────────────────────────
+ // Type Renderers
+ // ─────────────────────────────────────────────────────────────
+
+ protected static function renderDefault(string $name, mixed $value, array $config): string
+ {
+ if (empty($value)) {
+ return '';
+ }
+ return sprintf('<span class="%s">%s</span>', esc_attr($name), esc_html($value));
+ }
+
+ protected static function renderText(string $name, mixed $value, array $config): string
+ {
+ if (empty($value)) {
+ return '';
+ }
+ return sprintf('<span class="%s">%s</span>', esc_attr($name), esc_html($value));
+ }
+
+ protected static function renderTextarea(string $name, mixed $value, array $config): string
+ {
+ if (empty($value)) {
+ return '';
+ }
+ return sprintf('<div class="%s">%s</div>', esc_attr($name), wp_kses_post(wpautop($value)));
+ }
+
+ protected static function renderNumber(string $name, mixed $value, array $config): string
+ {
+ if ($value === '' || $value === null) {
+ return '';
+ }
+
+ $prefix = $config['prefix'] ?? '';
+ $suffix = $config['suffix'] ?? '';
+ $decimals = $config['decimals'] ?? 0;
+
+ return sprintf(
+ '<span class="%s">%s%s%s</span>',
+ esc_attr($name),
+ esc_html($prefix),
+ esc_html(number_format((float)$value, $decimals)),
+ esc_html($suffix)
+ );
+ }
+
+ protected static function renderEmail(string $name, mixed $value, array $config): string
+ {
+ if (empty($value)) {
+ return '';
+ }
+ return sprintf(
+ '<a class="%s" href="mailto:%s">%s%s</a>',
+ esc_attr($name),
+ esc_attr($value),
+ jvbIcon('envelope'),
+ esc_html($value)
+ );
+ }
+
+ protected static function renderUrl(string $name, mixed $value, array $config): string
+ {
+ if (empty($value)) {
+ return '';
+ }
+
+ $label = $config['label'] ?? parse_url($value, PHP_URL_HOST) ?? $value;
+
+ return sprintf(
+ '<a class="%s" href="%s" target="_blank" rel="noopener">%s%s</a>',
+ esc_attr($name),
+ esc_url($value),
+ jvbIcon('link'),
+ esc_html($label)
+ );
+ }
+
+ protected static function renderDate(string $name, mixed $value, array $config): string
+ {
+ if (empty($value)) {
+ return '';
+ }
+
+ $format = $config['format'] ?? get_option('date_format');
+ $timestamp = strtotime($value);
+
+ if (!$timestamp) {
+ return '';
+ }
+
+ return sprintf(
+ '<time class="%s" datetime="%s">%s%s</time>',
+ esc_attr($name),
+ esc_attr(date('Y-m-d', $timestamp)),
+ jvbIcon('calendar'),
+ esc_html(date_i18n($format, $timestamp))
+ );
+ }
+
+ protected static function renderTime(string $name, mixed $value, array $config): string
+ {
+ if (empty($value)) {
+ return '';
+ }
+
+ $format = $config['format'] ?? get_option('time_format');
+ $timestamp = strtotime($value);
+
+ if (!$timestamp) {
+ return '';
+ }
+
+ return sprintf(
+ '<time class="%s" datetime="%s">%s%s</time>',
+ esc_attr($name),
+ esc_attr(date('H:i', $timestamp)),
+ jvbIcon('clock'),
+ esc_html(date_i18n($format, $timestamp))
+ );
+ }
+
+ protected static function renderDatetime(string $name, mixed $value, array $config): string
+ {
+ if (empty($value)) {
+ return '';
+ }
+
+ $format = $config['format'] ?? get_option('date_format') . ' ' . get_option('time_format');
+ $timestamp = strtotime($value);
+
+ if (!$timestamp) {
+ return '';
+ }
+
+ return sprintf(
+ '<time class="%s" datetime="%s">%s%s</time>',
+ esc_attr($name),
+ esc_attr(date('Y-m-d\TH:i:s', $timestamp)),
+ jvbIcon('calendar'),
+ esc_html(date_i18n($format, $timestamp))
+ );
+ }
+
+ protected static function renderTrueFalse(string $name, mixed $value, array $config): string
+ {
+ $isTrue = filter_var($value, FILTER_VALIDATE_BOOLEAN);
+ $labels = $config['labels'] ?? ['Yes', 'No'];
+
+ return sprintf(
+ '<span class="%s bool-%s">%s</span>',
+ esc_attr($name),
+ $isTrue ? 'true' : 'false',
+ esc_html($isTrue ? $labels[0] : $labels[1])
+ );
+ }
+
+ protected static function renderImage(string $name, mixed $value, array $config): string
+ {
+ if (empty($value)) {
+ return '';
+ }
+
+ $size = $config['size'] ?? 'medium';
+ $image = wp_get_attachment_image($value, $size, false, [
+ 'class' => "{$name} attachment-{$size}"
+ ]);
+
+ return $image ?: '';
+ }
+
+ protected static function renderGallery(string $name, mixed $value, array $config): string
+ {
+ if (empty($value)) {
+ return '';
+ }
+
+ $ids = is_array($value) ? $value : explode(',', $value);
+ $ids = array_filter(array_map('intval', $ids));
+
+ if (empty($ids)) {
+ return '';
+ }
+
+ $size = $config['size'] ?? 'thumbnail';
+ $output = sprintf('<div class="%s gallery">', esc_attr($name));
+
+ foreach ($ids as $id) {
+ $image = wp_get_attachment_image($id, $size);
+ if ($image) {
+ $output .= $image;
+ }
+ }
+
+ $output .= '</div>';
+ return $output;
+ }
+
+ protected static function renderTaxonomy(string $name, mixed $value, array $config): string
+ {
+ if (empty($value)) {
+ return '';
+ }
+
+ $ids = is_array($value) ? $value : explode(',', $value);
+ $ids = array_filter(array_map('intval', $ids));
+
+ if (empty($ids)) {
+ return '';
+ }
+
+ $terms = [];
+ foreach ($ids as $id) {
+ $term = get_term($id);
+ if ($term && !is_wp_error($term)) {
+ $link = get_term_link($term);
+ $terms[] = sprintf(
+ '<a href="%s">%s</a>',
+ esc_url($link),
+ esc_html($term->name)
+ );
+ }
+ }
+
+ return sprintf(
+ '<span class="%s taxonomy">%s</span>',
+ esc_attr($name),
+ implode(', ', $terms)
+ );
+ }
+
+ protected static function renderUser(string $name, mixed $value, array $config): string
+ {
+ if (empty($value)) {
+ return '';
+ }
+
+ $ids = is_array($value) ? $value : explode(',', $value);
+ $ids = array_filter(array_map('intval', $ids));
+
+ if (empty($ids)) {
+ return '';
+ }
+
+ $users = [];
+ foreach ($ids as $id) {
+ $user = get_userdata($id);
+ if ($user) {
+ $users[] = esc_html($user->display_name);
+ }
+ }
+
+ return sprintf(
+ '<span class="%s user">%s</span>',
+ esc_attr($name),
+ implode(', ', $users)
+ );
+ }
+
+ protected static function renderSelect(string $name, mixed $value, array $config): string
+ {
+ if (empty($value) || empty($config['options'])) {
+ return '';
+ }
+
+ $label = $config['options'][$value] ?? $value;
+
+ return sprintf(
+ '<span class="%s">%s</span>',
+ esc_attr($name),
+ esc_html($label)
+ );
+ }
+
+ protected static function renderSet(string $name, mixed $value, array $config): string
+ {
+ if (empty($value) || empty($config['options'])) {
+ return '';
+ }
+
+ $values = is_array($value) ? $value : explode(',', $value);
+ $labels = [];
+
+ foreach ($values as $v) {
+ $v = trim($v);
+ if (isset($config['options'][$v])) {
+ $labels[] = $config['options'][$v];
+ }
+ }
+
+ if (empty($labels)) {
+ return '';
+ }
+
+ return sprintf(
+ '<span class="%s">%s</span>',
+ esc_attr($name),
+ esc_html(implode(', ', $labels))
+ );
+ }
+
+ protected static function renderLocation(string $name, mixed $value, array $config): string
+ {
+ if (empty($value) || empty($value['address'])) {
+ return '';
+ }
+
+ return sprintf(
+ '<address class="%s">%s%s</address>',
+ esc_attr($name),
+ jvbIcon('map-pin'),
+ esc_html($value['address'])
+ );
+ }
+
+ protected static function renderRepeater(string $name, mixed $value, array $config): string
+ {
+ if (empty($value) || !is_array($value)) {
+ return '';
+ }
+
+ $fields = $config['fields'] ?? [];
+ $output = sprintf('<div class="%s repeater">', esc_attr($name));
+
+ foreach ($value as $index => $row) {
+ $output .= '<div class="repeater-row">';
+ foreach ($row as $fieldName => $fieldValue) {
+ $fieldConfig = $fields[$fieldName] ?? ['type' => 'text'];
+ $output .= static::render($fieldName, $fieldValue, $fieldConfig);
+ }
+ $output .= '</div>';
+ }
+
+ $output .= '</div>';
+ return $output;
+ }
+
+ protected static function renderGroup(string $name, mixed $value, array $config): string
+ {
+ if (empty($value) || !is_array($value)) {
+ return '';
+ }
+
+ $fields = $config['fields'] ?? [];
+ $output = sprintf('<div class="%s group">', esc_attr($name));
+
+ foreach ($value as $fieldName => $fieldValue) {
+ $fieldConfig = $fields[$fieldName] ?? ['type' => 'text'];
+ $output .= static::render($fieldName, $fieldValue, $fieldConfig);
+ }
+
+ $output .= '</div>';
+ return $output;
+ }
+
+ // ─────────────────────────────────────────────────────────────
+ // Empty Templates
+ // ─────────────────────────────────────────────────────────────
+
+ public static function getEmptyTemplate(string $type, string $name): string
+ {
+ $template = match ($type) {
+ 'text', 'textarea', 'number' => '<p class="' . esc_attr($name) . '"></p>',
+ 'url', 'email' => '<a class="' . esc_attr($name) . '">' . jvbIcon('link') . '</a>',
+ 'set', 'checkbox', 'radio', 'taxonomy', 'user' => '<span class="' . esc_attr($name) . '"></span>',
+ 'image', 'gallery' => '<div class="' . esc_attr($name) . ' images"><img/></div>',
+ 'date' => '<p class="' . esc_attr($name) . '">' . jvbIcon('calendar') . '<span></span></p>',
+ 'time' => '<p class="' . esc_attr($name) . '">' . jvbIcon('clock') . '<time></time></p>',
+ 'true_false' => '<p class="' . esc_attr($name) . '"></p>',
+ default => ''
+ };
+
+ return apply_filters('jvbMetaTypeTemplate', $template, $type);
+ }
+}
diff --git a/inc/meta/Repeater.php b/inc/meta/Repeater.php
new file mode 100644
index 0000000..583ae44
--- /dev/null
+++ b/inc/meta/Repeater.php
@@ -0,0 +1,397 @@
+<?php
+namespace JVBase\meta;
+
+if (!defined('ABSPATH')) {
+ exit;
+}
+
+/**
+ * Fluent accessor for repeater field operations
+ *
+ * Usage:
+ * $meta->repeater('gallery')->field(0, 'image');
+ * $meta->repeater('links')->addRow(['url' => '...', 'title' => '...']);
+ * $meta->repeater('gallery')->setField(0, 'caption', 'New caption');
+ */
+class Repeater
+{
+ protected Meta $meta;
+ protected string $name;
+ protected array $data;
+ protected array $config;
+
+ public function __construct(Meta $meta, string $name)
+ {
+ $this->meta = $meta;
+ $this->name = $name;
+ $this->data = $meta->get($name) ?: [];
+ $this->config = $meta->config($name) ?? [];
+
+ // Ensure data is array
+ if (!is_array($this->data)) {
+ $this->data = [];
+ }
+ }
+
+ // ─────────────────────────────────────────────────────────────
+ // Read Operations
+ // ─────────────────────────────────────────────────────────────
+
+ /**
+ * Get all rows
+ */
+ public function all(): array
+ {
+ return $this->data;
+ }
+
+ /**
+ * Get a specific row
+ */
+ public function row(int $index): ?array
+ {
+ return $this->data[$index] ?? null;
+ }
+
+ /**
+ * Get first row
+ */
+ public function first(): ?array
+ {
+ return $this->data[0] ?? null;
+ }
+
+ /**
+ * Get last row
+ */
+ public function last(): ?array
+ {
+ if (empty($this->data)) {
+ return null;
+ }
+ return $this->data[array_key_last($this->data)];
+ }
+
+ /**
+ * Get field value from specific row
+ */
+ public function field(int $index, string $field): mixed
+ {
+ return $this->data[$index][$field] ?? null;
+ }
+
+ /**
+ * Get row count
+ */
+ public function count(): int
+ {
+ return count($this->data);
+ }
+
+ /**
+ * Check if empty
+ */
+ public function isEmpty(): bool
+ {
+ return empty($this->data);
+ }
+
+ /**
+ * Check if row exists
+ */
+ public function hasRow(int $index): bool
+ {
+ return isset($this->data[$index]);
+ }
+
+ // ─────────────────────────────────────────────────────────────
+ // Write Operations
+ // ─────────────────────────────────────────────────────────────
+
+ /**
+ * Set field value in specific row
+ */
+ public function setField(int $index, string $field, mixed $value): self
+ {
+ if (!isset($this->data[$index])) {
+ $this->data[$index] = [];
+ }
+
+ $this->data[$index][$field] = $value;
+ $this->sync();
+
+ return $this;
+ }
+
+ /**
+ * Add a new row
+ */
+ public function addRow(array $data = []): self
+ {
+ $this->data[] = $data;
+ $this->sync();
+ return $this;
+ }
+
+ /**
+ * Insert row at specific index
+ */
+ public function insertRow(int $index, array $data = []): self
+ {
+ array_splice($this->data, $index, 0, [$data]);
+ $this->sync();
+ return $this;
+ }
+
+ /**
+ * Update entire row (merge with existing)
+ */
+ public function updateRow(int $index, array $data): self
+ {
+ $this->data[$index] = array_merge($this->data[$index] ?? [], $data);
+ $this->sync();
+ return $this;
+ }
+
+ /**
+ * Replace entire row
+ */
+ public function replaceRow(int $index, array $data): self
+ {
+ $this->data[$index] = $data;
+ $this->sync();
+ return $this;
+ }
+
+ /**
+ * Remove a row
+ */
+ public function removeRow(int $index): self
+ {
+ unset($this->data[$index]);
+ $this->data = array_values($this->data); // Re-index
+ $this->sync();
+ return $this;
+ }
+
+ /**
+ * Remove last row
+ */
+ public function pop(): self
+ {
+ array_pop($this->data);
+ $this->sync();
+ return $this;
+ }
+
+ /**
+ * Remove first row
+ */
+ public function shift(): self
+ {
+ array_shift($this->data);
+ $this->sync();
+ return $this;
+ }
+
+ /**
+ * Clear all rows
+ */
+ public function clear(): self
+ {
+ $this->data = [];
+ $this->sync();
+ return $this;
+ }
+
+ /**
+ * Reorder rows
+ */
+ public function reorder(array $newOrder): self
+ {
+ $reordered = [];
+ foreach ($newOrder as $index) {
+ if (isset($this->data[$index])) {
+ $reordered[] = $this->data[$index];
+ }
+ }
+ $this->data = $reordered;
+ $this->sync();
+ return $this;
+ }
+
+ /**
+ * Move row to new position
+ */
+ public function moveRow(int $from, int $to): self
+ {
+ if (!isset($this->data[$from])) {
+ return $this;
+ }
+
+ $row = $this->data[$from];
+ unset($this->data[$from]);
+ $this->data = array_values($this->data);
+ array_splice($this->data, $to, 0, [$row]);
+ $this->sync();
+
+ return $this;
+ }
+
+ // ─────────────────────────────────────────────────────────────
+ // Query Operations
+ // ─────────────────────────────────────────────────────────────
+
+ /**
+ * Find rows where field equals value
+ */
+ public function where(string $field, mixed $value): array
+ {
+ return array_filter($this->data, fn($row) => ($row[$field] ?? null) === $value);
+ }
+
+ /**
+ * Find first row where field equals value
+ */
+ public function firstWhere(string $field, mixed $value): ?array
+ {
+ foreach ($this->data as $row) {
+ if (($row[$field] ?? null) === $value) {
+ return $row;
+ }
+ }
+ return null;
+ }
+
+ /**
+ * Find row index where field equals value
+ */
+ public function findIndex(string $field, mixed $value): ?int
+ {
+ foreach ($this->data as $index => $row) {
+ if (($row[$field] ?? null) === $value) {
+ return $index;
+ }
+ }
+ return null;
+ }
+
+ /**
+ * Filter rows by callback
+ */
+ public function filter(callable $callback): array
+ {
+ return array_filter($this->data, $callback);
+ }
+
+ /**
+ * Map rows through callback
+ */
+ public function map(callable $callback): array
+ {
+ return array_map($callback, $this->data);
+ }
+
+ /**
+ * Pluck single field from all rows
+ */
+ public function pluck(string $field): array
+ {
+ return array_column($this->data, $field);
+ }
+
+ /**
+ * Sort rows by field
+ */
+ public function sortBy(string $field, string $direction = 'asc'): self
+ {
+ usort($this->data, function($a, $b) use ($field, $direction) {
+ $aVal = $a[$field] ?? null;
+ $bVal = $b[$field] ?? null;
+
+ $result = $aVal <=> $bVal;
+ return $direction === 'desc' ? -$result : $result;
+ });
+
+ $this->sync();
+ return $this;
+ }
+
+ // ─────────────────────────────────────────────────────────────
+ // Bulk Operations
+ // ─────────────────────────────────────────────────────────────
+
+ /**
+ * Replace all rows
+ */
+ public function setAll(array $rows): self
+ {
+ $this->data = $rows;
+ $this->sync();
+ return $this;
+ }
+
+ /**
+ * Append multiple rows
+ */
+ public function addRows(array $rows): self
+ {
+ foreach ($rows as $row) {
+ $this->data[] = $row;
+ }
+ $this->sync();
+ return $this;
+ }
+
+ /**
+ * Update field in all rows
+ */
+ public function setFieldAll(string $field, mixed $value): self
+ {
+ foreach ($this->data as $index => $row) {
+ $this->data[$index][$field] = $value;
+ }
+ $this->sync();
+ return $this;
+ }
+
+ /**
+ * Remove field from all rows
+ */
+ public function removeFieldAll(string $field): self
+ {
+ foreach ($this->data as $index => $row) {
+ unset($this->data[$index][$field]);
+ }
+ $this->sync();
+ return $this;
+ }
+
+ // ─────────────────────────────────────────────────────────────
+ // Internal
+ // ─────────────────────────────────────────────────────────────
+
+ /**
+ * Sync data back to Meta instance
+ */
+ protected function sync(): void
+ {
+ $this->meta->set($this->name, $this->data);
+ }
+
+ /**
+ * Get field config for subfield
+ */
+ public function fieldConfig(string $field): ?array
+ {
+ return $this->config['fields'][$field] ?? null;
+ }
+
+ /**
+ * Get row label field name
+ */
+ public function rowLabelField(): ?string
+ {
+ return $this->config['row_label'] ?? null;
+ }
+}
diff --git a/inc/meta/MetaSanitizer.php b/inc/meta/Sanitizer.php
similarity index 77%
rename from inc/meta/MetaSanitizer.php
rename to inc/meta/Sanitizer.php
index 6f722f7..14789c2 100644
--- a/inc/meta/MetaSanitizer.php
+++ b/inc/meta/Sanitizer.php
@@ -1,8 +1,6 @@
<?php
namespace JVBase\meta;
-use JVBase\meta\MetaTypeManager;
-
if (!defined('ABSPATH')) {
exit; // Exit if accessed directly
}
@@ -10,35 +8,30 @@
/**
* Handles meta value sanitization
*/
-class MetaSanitizer
+class Sanitizer
{
- protected MetaTypeManager $type_manager;
- public function __construct()
- {
- $this->type_manager = new MetaTypeManager();
- }
+ public static function sanitize(mixed $value, array $field_config): mixed
+ {
+ $callback = static::getCallback($field_config);
- public function sanitize(mixed $value, array $field_config):mixed
- {
- $callback = $this->getCallback($field_config);
- if (is_array($callback)) {
- return call_user_func([$this, $callback[1]], $value, $field_config);
- }
- if (method_exists($this, $callback)) {
- return $this->$callback($value, $field_config);
- } else {
- return call_user_func($callback, $value);
- }
- }
+ if (is_array($callback)) {
+ return call_user_func([static::class, $callback[1]], $value, $field_config);
+ }
+ if (method_exists(static::class, $callback)) {
+ return static::$callback($value, $field_config);
+ }
- public function getCallback(array $field_config):mixed
+ return call_user_func($callback, $value);
+ }
+
+ public static function getCallback(array $field_config):mixed
{
return $field_config['sanitize'] ??
- $this->type_manager->getSanitizeCallback($field_config['type']);
+ MetaTypeManager::getSanitizeCallback($field_config['type']);
}
- protected function sanitizeTaxonomy(array|string $values, array $field_config):string
+ protected static function sanitizeTaxonomy(array|string $values, array $field_config):string
{
if (!is_array($values)) {
$values = explode(',', $values);
@@ -54,7 +47,7 @@
return implode(',', $values);
}
- protected function sanitizeUser(array|string $values, array $field_config):string
+ protected static function sanitizeUser(array|string $values, array $field_config):string
{
if (!is_array($values)) {
$values = explode(',', $values);
@@ -65,12 +58,8 @@
return implode(',', $values);
}
- protected function sanitizeTagList(array $values, array $field_config): array
+ protected static function sanitizeTagList(array $values, array $field_config): array
{
- if (!is_array($values)) {
- return [];
- }
-
if (empty(array_filter($values, fn($value) => !empty($value)))) {
return [];
}
@@ -103,7 +92,7 @@
}
$subfield_config['name'] = $key; // For backwards compatibility
- $clean_row[$key] = $this->sanitize($row[$key], $subfield_config);
+ $clean_row[$key] = static::sanitize($row[$key], $subfield_config);
}
// Only add row if it has at least one non-empty value
@@ -115,11 +104,8 @@
return $sanitized;
}
- protected function sanitizeRepeater(array $values, array $field_config):array
+ protected static function sanitizeRepeater(array $values, array $field_config):array
{
- if (!is_array($values)) {
- return [];
- }
if (empty(array_filter($values, fn($value) => !empty($value)))) {
return [];
}
@@ -143,7 +129,7 @@
continue;
}
$subfield_config['name'] = $key;//For backwards compatability
- $clean_row[$key] = $this->sanitize($row[$key], $subfield_config);
+ $clean_row[$key] = static::sanitize($row[$key], $subfield_config);
}
$sanitized[] = $clean_row;
}
@@ -151,7 +137,7 @@
return $sanitized;
}
- protected function sanitizeGroup(array|string $values, array $field_config):array
+ protected static function sanitizeGroup(array|string $values, array $field_config):array
{
if (!is_array($values)) {
return [];
@@ -173,19 +159,19 @@
foreach ($field_config['fields'] as $key => $subfield_config) {
if (!array_key_exists($key, $clean_values)) {
// Use default value if not provided
- $default = $this->type_manager->getType($subfield_config['type'])['default'] ?? '';
+ $default = MetaTypeManager::getType($subfield_config['type'])['default'] ?? '';
$sanitized[$key] = $default;
continue;
}
$subfield_config['name'] = $key; // For backwards compatibility
- $sanitized[$key] = $this->sanitize($clean_values[$key], $subfield_config);
+ $sanitized[$key] = static::sanitize($clean_values[$key], $subfield_config);
}
return $sanitized;
}
- protected function sanitizeUpload(array|string $value):string
+ protected static function sanitizeUpload(array|string $value):string
{
if (empty($value)) {
return '';
@@ -203,7 +189,7 @@
return implode(',', $valid_ids);
}
- protected function sanitizeLocation(array $value, array $field_config):array
+ protected static function sanitizeLocation(array $value, array $field_config):array
{
error_log('Location field to sanitize: '.print_r($value, true));
return [
@@ -219,7 +205,7 @@
];
}
- protected function sanitizeOptions(array|string $value, array $field_config):string
+ protected static function sanitizeOptions(array|string $value, array $field_config):string
{
error_log('Sanitizing options: '.print_r($value, true));
if (!isset($field_config['options'])) {
@@ -231,13 +217,13 @@
return implode(',', array_intersect($value, array_keys($field_config['options'])));
}
- protected function sanitizeDate(string $value, array $field_config):string
+ protected static function sanitizeDate(string $value, array $field_config):string
{
$timestamp = strtotime($value);
return $timestamp ? date('Y-m-d', $timestamp) : '';
}
- protected function sanitizeDateTime(string $value, array $field_config): string
+ protected static function sanitizeDateTime(string $value, array $field_config): string
{
if (empty($value)) {
return '';
@@ -252,7 +238,7 @@
return date('Y-m-d H:i:s', $timestamp);
}
- protected function sanitizeTime(string $value, array $field_config):string
+ protected static function sanitizeTime(string $value, array $field_config):string
{
// Remove any whitespace
$value = trim($value);
@@ -318,7 +304,7 @@
return '';
}
- public function sanitizeFloat(string $value, array $config):float
+ public static function sanitizeFloat(string $value, array $config):float
{
if (is_numeric($value)) {
return (float) $value;
diff --git a/inc/meta/Storage.php b/inc/meta/Storage.php
index b2ea8ac..30160a9 100644
--- a/inc/meta/Storage.php
+++ b/inc/meta/Storage.php
@@ -2,7 +2,6 @@
namespace JVBase\meta;
use Exception;
-use wpdb;
if (!defined('ABSPATH')) {
exit;
@@ -14,7 +13,7 @@
*/
class Storage
{
- protected wpdb $wpdb;
+ protected \wpdb $wpdb;
public function __construct()
{
@@ -22,6 +21,10 @@
$this->wpdb = $wpdb;
}
+ // ─────────────────────────────────────────────────────────────
+ // Single Item Operations
+ // ─────────────────────────────────────────────────────────────
+
/**
* Load a single field value from database
*/
@@ -43,11 +46,11 @@
}
/**
- * Load multiple field values in a single query
+ * Load multiple field values for single item
*/
public function getAll(Item $item, array $fieldNames): array
{
- if (empty($fieldNames) || !$item->id) {
+ if (empty($fieldNames) || (!$item->id && $item->objectType !== 'options')) {
return [];
}
@@ -57,7 +60,7 @@
$values = [];
- // Get meta fields in bulk
+ // Get meta fields in bulk query
if (!empty($metaFields)) {
$values = $this->bulkGetMeta($item, $metaFields);
}
@@ -95,7 +98,7 @@
}
/**
- * Save all dirty fields on an item
+ * Save all dirty fields on a single item
*/
public function save(Item $item, bool $updateTimestamp = true): bool
{
@@ -142,6 +145,14 @@
*/
public function delete(Item $item, string $name): bool
{
+ // Handle taxonomy fields
+ $config = $item->getFieldConfig($name);
+ if ($config && ($config['type'] ?? '') === 'taxonomy' && !isset($config['taxonomy_type'])) {
+ $taxonomy = jvbCheckBase($config['taxonomy']);
+ wp_set_object_terms($item->id, [], $taxonomy, false);
+ return true;
+ }
+
$metaKey = BASE . $name;
return match ($item->objectType) {
@@ -154,7 +165,215 @@
}
// ─────────────────────────────────────────────────────────────
- // Protected helpers
+ // Bulk Operations
+ // ─────────────────────────────────────────────────────────────
+
+ /**
+ * Save multiple Meta instances in optimized transaction
+ * @param Meta[] $metas Array of Meta instances
+ * @return array<int, bool> Results keyed by item ID
+ */
+ public static function saveBulk(array $metas, bool $updateTimestamp = true): array
+ {
+ global $wpdb;
+
+ $results = [];
+ $postIdsToUpdate = [];
+
+ $wpdb->query('START TRANSACTION');
+
+ try {
+ // Group by object type for efficient processing
+ $grouped = [];
+ foreach ($metas as $meta) {
+ $item = $meta->item();
+ $type = $item->objectType;
+ $grouped[$type][] = ['meta' => $meta, 'item' => $item];
+ }
+
+ foreach ($grouped as $objectType => $group) {
+ $storage = new self();
+ [$table, $idColumn] = $storage->getTableInfo($objectType);
+
+ if (!$table && $objectType !== 'options') {
+ continue;
+ }
+
+ // Collect all operations
+ $metaInserts = [];
+ $wpDefaultUpdates = [];
+ $taxonomyUpdates = [];
+ $optionUpdates = [];
+
+ foreach ($group as $entry) {
+ $item = $entry['item'];
+ $dirty = $item->getDirtyFields();
+
+ if (empty($dirty)) {
+ $results[$item->id] = true;
+ continue;
+ }
+
+ foreach ($dirty as $field) {
+ if ($objectType === 'options') {
+ $optionUpdates[] = [
+ 'key' => $storage->optionKey($item, $field->name),
+ 'value' => $field->value
+ ];
+ } elseif ($field->isWpDefault()) {
+ $wpDefaultUpdates[$item->id][$field->name] = $field->value;
+ } elseif ($field->isTaxonomy()) {
+ $taxonomyUpdates[] = [
+ 'object_id' => $item->id,
+ 'taxonomy' => jvbCheckBase($field->config['taxonomy']),
+ 'value' => $field->value
+ ];
+ } else {
+ $metaInserts[] = [
+ 'id' => $item->id,
+ 'key' => BASE . $field->name,
+ 'value' => maybe_serialize($field->value)
+ ];
+ }
+ }
+
+ if ($updateTimestamp && $objectType === 'post') {
+ $postIdsToUpdate[] = $item->id;
+ }
+ }
+
+ // Execute bulk operations
+ if (!empty($metaInserts)) {
+ self::bulkUpsertMeta($table, $idColumn, $metaInserts);
+ }
+
+ if (!empty($wpDefaultUpdates)) {
+ self::batchUpdateWpDefaults($objectType, $wpDefaultUpdates);
+ }
+
+ if (!empty($taxonomyUpdates)) {
+ self::batchUpdateTaxonomies($taxonomyUpdates);
+ }
+
+ if (!empty($optionUpdates)) {
+ self::batchUpdateOptions($optionUpdates);
+ }
+
+ // Mark all fields clean
+ foreach ($group as $entry) {
+ $entry['item']->markAllClean();
+ $results[$entry['item']->id ?? 'options'] = true;
+ }
+ }
+
+ $wpdb->query('COMMIT');
+
+ // Update post timestamps in single query
+ if (!empty($postIdsToUpdate)) {
+ self::batchTouchPosts(array_unique($postIdsToUpdate));
+ }
+
+ // Clear caches
+ foreach ($metas as $meta) {
+ (new self())->clearCache($meta->item());
+ }
+
+ return $results;
+
+ } catch (Exception $e) {
+ $wpdb->query('ROLLBACK');
+
+ foreach ($metas as $meta) {
+ $results[$meta->item()->id ?? 'options'] = false;
+ }
+
+ JVB()->error()->log('meta_storage', 'Bulk save failed: ' . $e->getMessage(), [], 'error');
+
+ return $results;
+ }
+ }
+
+ /**
+ * Bulk load meta for multiple items
+ * @param array $ids Object IDs
+ * @param string $objectType post, term, user
+ * @param array $fields Field names to load
+ * @return array<int, array<string, mixed>> Values keyed by ID then field name
+ */
+ public static function getBulkValues(array $ids, string $objectType, array $fields): array
+ {
+ if (empty($ids) || empty($fields)) {
+ return [];
+ }
+
+ global $wpdb;
+ $storage = new self();
+
+ [$table, $idColumn] = $storage->getTableInfo($objectType);
+
+ if (!$table) {
+ return [];
+ }
+
+ // Separate WP defaults from custom meta
+ $defaults = Item::WP_DEFAULTS[$objectType] ?? [];
+ $wpFields = array_intersect($defaults, $fields);
+ $metaFields = array_diff($fields, $wpFields);
+
+ // Initialize results
+ $values = [];
+ foreach ($ids as $id) {
+ $values[$id] = array_fill_keys($fields, '');
+ }
+
+ // Bulk get custom meta
+ if (!empty($metaFields)) {
+ $metaKeys = array_map(fn($f) => BASE . $f, $metaFields);
+
+ $idPlaceholders = implode(',', array_fill(0, count($ids), '%d'));
+ $keyPlaceholders = implode(',', array_fill(0, count($metaKeys), '%s'));
+
+ $query = $wpdb->prepare(
+ "SELECT {$idColumn} as object_id, meta_key, meta_value
+ FROM {$table}
+ WHERE {$idColumn} IN ({$idPlaceholders})
+ AND meta_key IN ({$keyPlaceholders})",
+ array_merge($ids, $metaKeys)
+ );
+
+ $results = $wpdb->get_results($query, ARRAY_A);
+
+ foreach ($results as $row) {
+ $objectId = (int)$row['object_id'];
+ $fieldName = str_replace(BASE, '', $row['meta_key']);
+ $values[$objectId][$fieldName] = maybe_unserialize($row['meta_value']);
+ }
+ }
+
+ // Get WP default fields (requires individual lookups unfortunately)
+ if (!empty($wpFields)) {
+ foreach ($ids as $id) {
+ $tempItem = new Item($id, $objectType);
+
+ // Load WP object for defaults
+ $tempItem->wpObject = match ($objectType) {
+ 'post' => get_post($id),
+ 'term' => get_term($id),
+ 'user' => get_user_by('id', $id),
+ default => null
+ };
+
+ foreach ($wpFields as $field) {
+ $values[$id][$field] = $storage->getWpDefault($tempItem, $field);
+ }
+ }
+ }
+
+ return $values;
+ }
+
+ // ─────────────────────────────────────────────────────────────
+ // Protected Helpers - Single Item
// ─────────────────────────────────────────────────────────────
protected function getWpDefault(Item $item, string $name): mixed
@@ -226,7 +445,7 @@
$taxonomy = jvbCheckBase($field->config['taxonomy']);
$value = $field->value;
- if (empty(trim($value))) {
+ if (empty(trim((string)$value))) {
wp_set_object_terms($item->id, [], $taxonomy, false);
return true;
}
@@ -266,7 +485,7 @@
return $values;
}
- protected function getTableInfo(string $objectType): array
+ public function getTableInfo(string $objectType): array
{
return match ($objectType) {
'post' => [$this->wpdb->postmeta, 'post_id'],
@@ -286,14 +505,14 @@
return update_option($this->optionKey($item, $field->name), $field->value);
}
- protected function optionKey(Item $item, string $name): string
+ public function optionKey(Item $item, string $name): string
{
return $item->baseKey
? BASE . $item->baseKey . '_' . $name
: BASE . $name;
}
- protected function clearCache(Item $item): void
+ public function clearCache(Item $item): void
{
match ($item->objectType) {
'post' => clean_post_cache($item->id),
@@ -302,4 +521,164 @@
default => null
};
}
+
+ // ─────────────────────────────────────────────────────────────
+ // Protected Helpers - Bulk Operations
+ // ─────────────────────────────────────────────────────────────
+
+ /**
+ * Bulk upsert meta using INSERT ... ON DUPLICATE KEY UPDATE
+ */
+ protected static function bulkUpsertMeta(string $table, string $idColumn, array $inserts): void
+ {
+ global $wpdb;
+
+ if (empty($inserts)) {
+ return;
+ }
+
+ // MySQL's ON DUPLICATE KEY requires a unique index
+ // For meta tables, we need to check existing and do update/insert
+ $existing = [];
+ $toInsert = [];
+ $toUpdate = [];
+
+ // Check which meta keys exist
+ $checks = [];
+ foreach ($inserts as $row) {
+ $checks[] = $wpdb->prepare("(%d, %s)", $row['id'], $row['key']);
+ }
+
+ $existingQuery = "SELECT {$idColumn}, meta_key FROM {$table}
+ WHERE ({$idColumn}, meta_key) IN (" . implode(',', $checks) . ")";
+ $existingRows = $wpdb->get_results($existingQuery, ARRAY_A);
+
+ foreach ($existingRows as $row) {
+ $existing[$row[$idColumn] . '_' . $row['meta_key']] = true;
+ }
+
+ // Separate inserts and updates
+ foreach ($inserts as $row) {
+ $key = $row['id'] . '_' . $row['key'];
+ if (isset($existing[$key])) {
+ $toUpdate[] = $row;
+ } else {
+ $toInsert[] = $row;
+ }
+ }
+
+ // Batch insert new records
+ if (!empty($toInsert)) {
+ $values = [];
+ $placeholders = [];
+
+ foreach ($toInsert as $row) {
+ $placeholders[] = "(%d, %s, %s)";
+ $values[] = $row['id'];
+ $values[] = $row['key'];
+ $values[] = $row['value'];
+ }
+
+ $sql = "INSERT INTO {$table} ({$idColumn}, meta_key, meta_value) VALUES " . implode(', ', $placeholders);
+ $wpdb->query($wpdb->prepare($sql, $values));
+ }
+
+ // Batch update existing records
+ if (!empty($toUpdate)) {
+ foreach ($toUpdate as $row) {
+ $wpdb->update(
+ $table,
+ ['meta_value' => $row['value']],
+ [$idColumn => $row['id'], 'meta_key' => $row['key']],
+ ['%s'],
+ ['%d', '%s']
+ );
+ }
+ }
+ }
+
+ /**
+ * Batch update post timestamps
+ */
+ protected static function batchTouchPosts(array $postIds): void
+ {
+ global $wpdb;
+
+ if (empty($postIds)) {
+ return;
+ }
+
+ $now = current_time('mysql');
+ $nowGmt = current_time('mysql', true);
+ $ids = implode(',', array_map('intval', $postIds));
+
+ $wpdb->query(
+ "UPDATE {$wpdb->posts}
+ SET post_modified = '{$now}', post_modified_gmt = '{$nowGmt}'
+ WHERE ID IN ({$ids})"
+ );
+ }
+
+ /**
+ * Batch update taxonomy relationships
+ */
+ protected static function batchUpdateTaxonomies(array $updates): void
+ {
+ foreach ($updates as $update) {
+ $termIds = empty(trim((string)$update['value']))
+ ? []
+ : array_map('intval', array_filter(explode(',', $update['value'])));
+
+ wp_set_object_terms($update['object_id'], $termIds, $update['taxonomy'], false);
+ }
+ }
+
+ /**
+ * Batch update WordPress default fields
+ */
+ protected static function batchUpdateWpDefaults(string $objectType, array $updates): void
+ {
+ foreach ($updates as $id => $fields) {
+ // Handle post_thumbnail separately
+ if (isset($fields['post_thumbnail'])) {
+ set_post_thumbnail($id, $fields['post_thumbnail']);
+ unset($fields['post_thumbnail']);
+ }
+ if (isset($fields['featured_image'])) {
+ set_post_thumbnail($id, $fields['featured_image']);
+ unset($fields['featured_image']);
+ }
+
+ if (empty($fields)) {
+ continue;
+ }
+
+ // Handle post_date conversion
+ if (isset($fields['post_date'])) {
+ $datetime = strtotime($fields['post_date']);
+ if ($datetime !== false) {
+ $fields['post_date'] = date('Y-m-d H:i:s', $datetime);
+ $fields['post_date_gmt'] = get_gmt_from_date($fields['post_date']);
+ $fields['edit_date'] = true;
+ }
+ }
+
+ match ($objectType) {
+ 'post' => wp_update_post(array_merge(['ID' => $id], $fields)),
+ 'user' => wp_update_user(array_merge(['ID' => $id], $fields)),
+ 'term' => null, // Terms need taxonomy, handled separately
+ default => null
+ };
+ }
+ }
+
+ /**
+ * Batch update options
+ */
+ protected static function batchUpdateOptions(array $updates): void
+ {
+ foreach ($updates as $update) {
+ update_option($update['key'], $update['value']);
+ }
+ }
}
diff --git a/inc/meta/Validator.php b/inc/meta/Validator.php
new file mode 100644
index 0000000..2b8087f
--- /dev/null
+++ b/inc/meta/Validator.php
@@ -0,0 +1,375 @@
+<?php
+namespace JVBase\meta;
+
+use DateTime;
+
+if (!defined('ABSPATH')) {
+ exit; // Exit if accessed directly
+}
+/**
+ * Handles meta value validation
+ */
+class Validator
+{
+ protected array $errors = [];
+
+ public static function validate(mixed $value, array $config): bool|array
+ {
+ $errors = [];
+ $type = $config['type'];
+ $name = $config['name'] ?? 'field';
+
+ // Required field check
+ if (!empty($config['required']) && static::isEmpty($value)) {
+ return [$name => __('This field is required', 'jvb')];
+ }
+
+ // Skip validation for empty optional fields
+ if (static::isEmpty($value)) {
+ return true;
+ }
+
+ // Type-specific validation
+ $method = 'validate' . str_replace('_', '', ucwords($type, '_'));
+ if (method_exists(static::class, $method)) {
+ $result = static::$method($value, $config);
+ if ($result !== true) {
+ return is_array($result) ? $result : [$name => $result];
+ }
+ }
+
+ return true;
+ }
+
+ protected static function isEmpty(mixed $value): bool
+ {
+ if ($value === null || $value === '' || $value === []) {
+ return true;
+ }
+ if (is_array($value) && empty(array_filter($value))) {
+ return true;
+ }
+ return false;
+ }
+
+ protected static function validateText(string $value, array $config): bool|string
+ {
+ if (isset($config['limit']) && strlen($value) > $config['limit']) {
+ return sprintf(__('Must not exceed %d characters', 'jvb'), $config['limit']);
+ }
+
+ if (isset($config['pattern']) && !preg_match($config['pattern'], $value)) {
+ return __('Invalid format', 'jvb');
+ }
+
+ return true;
+ }
+
+ protected static function validateTextarea(string $value, array $config): bool|string
+ {
+ return static::validateText($value, $config);
+ }
+
+ protected static function validateNumber(mixed $value, array $config): bool|string
+ {
+ if (!is_numeric($value)) {
+ return __('Must be a number', 'jvb');
+ }
+
+ if (isset($config['min']) && $value < $config['min']) {
+ return sprintf(__('Must be at least %s', 'jvb'), $config['min']);
+ }
+
+ if (isset($config['max']) && $value > $config['max']) {
+ return sprintf(__('Must not exceed %s', 'jvb'), $config['max']);
+ }
+
+ return true;
+ }
+
+ protected static function validateEmail(string $value, array $config): bool|string
+ {
+ if (!is_email($value)) {
+ return __('Invalid email address', 'jvb');
+ }
+ return true;
+ }
+
+ protected static function validateUrl(string $value, array $config): bool|string
+ {
+ if (filter_var($value, FILTER_VALIDATE_URL) === false) {
+ return __('Must be a valid URL', 'jvb');
+ }
+ return true;
+ }
+
+ protected static function validateDate(string $value, array $config): bool|string
+ {
+ $timestamp = strtotime($value);
+ if ($timestamp === false) {
+ return __('Invalid date format', 'jvb');
+ }
+
+ if (isset($config['min_date']) && $timestamp < strtotime($config['min_date'])) {
+ return sprintf(__('Date must be after %s', 'jvb'), $config['min_date']);
+ }
+
+ if (isset($config['max_date']) && $timestamp > strtotime($config['max_date'])) {
+ return sprintf(__('Date must be before %s', 'jvb'), $config['max_date']);
+ }
+
+ return true;
+ }
+
+
+ protected static function validateDatetime(string $value, array $config): bool|string
+ {
+ $date = DateTime::createFromFormat('Y-m-d H:i:s', $value);
+ if (!$date) {
+ $formats = ['Y-m-d\TH:i:s', 'Y-m-d\TH:i', 'Y-m-d H:i'];
+ foreach ($formats as $format) {
+ $date = DateTime::createFromFormat($format, $value);
+ if ($date) break;
+ }
+ }
+
+ if (!$date) {
+ return __('Invalid datetime format', 'jvb');
+ }
+
+ $timestamp = $date->getTimestamp();
+
+ if (isset($config['min_datetime']) && $timestamp < strtotime($config['min_datetime'])) {
+ return sprintf(__('DateTime must be after %s', 'jvb'), $config['min_datetime']);
+ }
+
+ if (isset($config['max_datetime']) && $timestamp > strtotime($config['max_datetime'])) {
+ return sprintf(__('DateTime must be before %s', 'jvb'), $config['max_datetime']);
+ }
+
+ return true;
+ }
+
+ protected static function validateTime(string $value, array $config): bool|string
+ {
+ if (!preg_match('/^([01]?[0-9]|2[0-3]):[0-5][0-9]$/', $value)) {
+ return __('Time must be in HH:MM format', 'jvb');
+ }
+
+ if (isset($config['min_time']) && strtotime($value) < strtotime($config['min_time'])) {
+ return sprintf(__('Time must be after %s', 'jvb'), $config['min_time']);
+ }
+
+ if (isset($config['max_time']) && strtotime($value) > strtotime($config['max_time'])) {
+ return sprintf(__('Time must be before %s', 'jvb'), $config['max_time']);
+ }
+
+ return true;
+ }
+
+ protected static function validateSelect(string $value, array $config): bool|string
+ {
+ if (!isset($config['options']) || !array_key_exists($value, $config['options'])) {
+ return __('Invalid selection', 'jvb');
+ }
+ return true;
+ }
+
+ protected static function validateRadio(string $value, array $config): bool|string
+ {
+ return static::validateSelect($value, $config);
+ }
+
+ protected static function validateSet(array|string $value, array $config): bool|string
+ {
+ if (!is_array($value)) {
+ $value = explode(',', $value);
+ }
+
+ if (!isset($config['options'])) {
+ return true;
+ }
+
+ $invalid = array_diff($value, array_keys($config['options']));
+ if (!empty($invalid)) {
+ return __('Invalid selections', 'jvb');
+ }
+
+ return true;
+ }
+
+ protected static function validateCheckbox(array|string $value, array $config): bool|string
+ {
+ return static::validateSet($value, $config);
+ }
+
+ protected static function validateImage(int $value, array $config): bool|string
+ {
+ if (!wp_attachment_is_image($value)) {
+ return __('Invalid image', 'jvb');
+ }
+ return true;
+ }
+
+ protected static function validateGallery(array|string $value, array $config): bool|string
+ {
+ $ids = is_array($value) ? $value : explode(',', $value);
+
+ if (!empty($config['max_images']) && count($ids) > $config['max_images']) {
+ return sprintf(__('Maximum of %d images allowed', 'jvb'), $config['max_images']);
+ }
+
+ foreach ($ids as $id) {
+ if (absint($id) <= 0 || !wp_attachment_is_image(absint($id))) {
+ return __('One or more invalid images', 'jvb');
+ }
+ }
+
+ return true;
+ }
+
+ protected static function validateTaxonomy(array|string $value, array $config): bool|string
+ {
+ if (!is_array($value)) {
+ $value = explode(',', $value);
+ }
+
+ $taxonomy = (str_starts_with($config['taxonomy'], BASE))
+ ? $config['taxonomy']
+ : BASE . $config['taxonomy'];
+
+ foreach ($value as $term_id) {
+ if (!term_exists((int)$term_id, $taxonomy)) {
+ return __('Invalid term selected', 'jvb');
+ }
+ }
+
+ return true;
+ }
+
+ protected static function validateUser(array|string $value, array $config): bool|string
+ {
+ if (!is_array($value)) {
+ $value = explode(',', $value);
+ }
+
+ foreach ($value as $user_id) {
+ if (!get_userdata((int)$user_id)) {
+ return __('Invalid user selected', 'jvb');
+ }
+ }
+
+ return true;
+ }
+
+ protected static function validateLocation(array $value, array $config): bool|string
+ {
+ if (!is_array($value)) {
+ return __('Location must be an array', 'jvb');
+ }
+
+ $required_fields = ['lat', 'lng'];
+ foreach ($required_fields as $field) {
+ if (!isset($value[$field]) || !is_numeric($value[$field])) {
+ return __('Invalid location coordinates', 'jvb');
+ }
+ }
+
+ return true;
+ }
+
+ protected static function validateRepeater(array $value, array $config): bool|string
+ {
+ if (!is_array($value)) {
+ return __('Invalid repeater data', 'jvb');
+ }
+
+ if (isset($config['min_rows']) && count($value) < $config['min_rows']) {
+ return sprintf(__('Minimum of %d rows required', 'jvb'), $config['min_rows']);
+ }
+
+ if (isset($config['max_rows']) && count($value) > $config['max_rows']) {
+ return sprintf(__('Maximum of %d rows allowed', 'jvb'), $config['max_rows']);
+ }
+
+ foreach ($value as $row) {
+ foreach ($config['fields'] as $field_name => $field_config) {
+ if (!isset($row[$field_name])) {
+ continue;
+ }
+ $field_config['name'] = $field_name;
+ $result = static::validate($row[$field_name], $field_config);
+ if ($result !== true) {
+ return is_array($result) ? array_values($result)[0] : $result;
+ }
+ }
+ }
+
+ return true;
+ }
+
+ protected static function validateGroup(array $value, array $config): bool|string
+ {
+ if (!is_array($value)) {
+ return __('Invalid group data', 'jvb');
+ }
+
+ if (!empty($config['fields']) && is_array($config['fields'])) {
+ foreach ($config['fields'] as $field_name => $field_config) {
+ if (isset($value[$field_name])) {
+ $field_config['name'] = $field_name;
+ $result = static::validate($value[$field_name], $field_config);
+ if ($result !== true) {
+ return is_array($result) ? array_values($result)[0] : $result;
+ }
+ }
+ }
+ }
+
+ return true;
+ }
+
+ protected static function validateTagList(array $value, array $config): bool|string
+ {
+ if (!is_array($value)) {
+ return __('Invalid data format', 'jvb');
+ }
+
+ if (isset($config['min_items']) && count($value) < $config['min_items']) {
+ return sprintf(__('Minimum of %d items required', 'jvb'), $config['min_items']);
+ }
+
+ if (isset($config['max_items']) && count($value) > $config['max_items']) {
+ return sprintf(__('Maximum of %d items allowed', 'jvb'), $config['max_items']);
+ }
+
+ if (!isset($config['fields']) || !is_array($config['fields'])) {
+ return true;
+ }
+
+ foreach ($value as $row) {
+ if (!is_array($row)) {
+ continue;
+ }
+
+ foreach ($config['fields'] as $field_name => $field_config) {
+ if (!isset($row[$field_name])) {
+ continue;
+ }
+
+ $field_config['name'] = $field_name;
+ $result = static::validate($row[$field_name], $field_config);
+ if ($result !== true) {
+ return is_array($result) ? array_values($result)[0] : $result;
+ }
+ }
+ }
+
+ return true;
+ }
+
+ protected static function validateTrueFalse(mixed $value, array $config): bool
+ {
+ return true; // Boolean values are always valid after sanitization
+ }
+}
diff --git a/inc/meta/_setup.php b/inc/meta/_setup.php
index 8e84bde..bc0f43f 100644
--- a/inc/meta/_setup.php
+++ b/inc/meta/_setup.php
@@ -5,13 +5,13 @@
require(JVB_DIR . '/inc/meta/Field.php'); //Single field with value, dirty state
require(JVB_DIR . '/inc/meta/Storage.php'); //Persistence layer
require(JVB_DIR . '/inc/meta/MetaTypeManager.php'); //Keep as is
-require(JVB_DIR . '/inc/meta/MetaValidator.php'); //Keep as is
+require(JVB_DIR . '/inc/meta/Validator.php'); //Keep as is
-require(JVB_DIR . '/inc/meta/MetaRenderer.php'); //decouple from manager
-require(JVB_DIR . '/inc/meta/MetaForm.php'); //decouple from manager
+require(JVB_DIR . '/inc/meta/Render.php');
+require(JVB_DIR . '/inc/meta/Form.php');
+require(JVB_DIR . '/inc/meta/Registry.php');
+require(JVB_DIR . '/inc/meta/Sanitizer.php');
//OLD SYSTEM
- require(JVB_DIR . '/inc/meta/MetaManager.php');
- require(JVB_DIR . '/inc/meta/MetaRegistry.php');
- require(JVB_DIR . '/inc/meta/MetaSanitizer.php');
+// require(JVB_DIR . '/inc/meta/MetaManager.php');
diff --git a/inc/registry/CheckCustomTables.php b/inc/registry/CheckCustomTables.php
index 16962fc..9ec862a 100644
--- a/inc/registry/CheckCustomTables.php
+++ b/inc/registry/CheckCustomTables.php
@@ -103,7 +103,12 @@
error_log('JVB: Starting table creation process');
error_log('JVB: Memory usage at start: ' . memory_get_usage(true) / 1024 / 1024 . ' MB');
- $tables = $calendar = $integrations = $karma = $stats = $invitable = $verifyEntry = $approval = $trackChanges = [];
+ $tables = $calendar = $integrations = $karma = $stats = $verifyEntry = $approval = $trackChanges = [];
+ $invitable = [
+ 'roles' => [],
+ 'terms' => []
+ ];
+
// Basic tables (these worked fine)
try {
@@ -113,10 +118,9 @@
// $tables = array_merge($tables, $this->umamiTracking());
// }
}
- if (array_key_exists('can_invite', $this->JVB_MEMBERSHIP) && is_array($this->JVB_MEMBERSHIP['can_invite'])) {
- foreach ($this->JVB_MEMBERSHIP['can_invite'] as $role => $canInvite) {
- $invitable[$role]['can_invite'] = $canInvite;
- }
+ if (array_key_exists('can_invite', $this->JVB_MEMBERSHIP) &&
+ is_array($this->JVB_MEMBERSHIP['can_invite'])) {
+ $invitable['roles'] = $this->JVB_MEMBERSHIP['can_invite'];
}
// if (jvbCheck('social', $this->JVB_SITE) || jvbCheck('gmb', $this->JVB_SITE) || jvbCheck('square', $this->JVB_SITE) || jvbCheck('helcim', $this->JVB_SITE)) {
@@ -208,9 +212,7 @@
$trackChanges[$type] = $config;
}
if (array_key_exists('invitable', $config) && $config['invitable']) {
- foreach ($config['for_content'] as $content) {
- $invitable[$content]['to_terms'][] = $type;
- }
+ $invitable['terms'][] = $type;
}
if (array_key_exists('verify_entry', $config) && $config['verify_entry']) {
$verifyEntry[$type] = $config;
@@ -295,17 +297,19 @@
// RE-ENABLE other table types
try {
- if (!empty($invitable)) {
- error_log('JVB: Creating invitation tables...');
+ if (!empty($invitable['roles']) || !empty($invitable['terms'])) {
+ error_log('JVB: Creating invitation table...');
$invitationTables = $this->invitationTables($invitable);
- error_log('JVB: Invitation tables created: ' . count($invitationTables));
+ error_log('JVB: Invitation table created: ' . count($invitationTables));
$tables = array_merge($tables, $invitationTables);
- error_log('JVB: Memory after invitations: ' . memory_get_usage(true) / 1024 / 1024 . ' MB');
}
} catch (Exception $e) {
- error_log("JVB: Error creating invitation tables: " . $e->getMessage());
+ error_log("JVB: Error creating invitation table: " . $e->getMessage());
}
+ // Store config for later use
+ update_option(BASE.'invitation_config', $invitable);
+
try {
if (!empty($approval)) {
error_log('JVB: Creating approval tables...');
@@ -1153,42 +1157,60 @@
];
}
- protected function invitationTables($types)
- {
-
- $tables = [];
- foreach ($types as $role => $config) {
- $definitions = "(
- `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
- `name` varchar(255) NOT NULL,
- `email` varchar(255) NOT NULL,
- `invitation_token` varchar(64) NOT NULL,
- `status` enum('pending', 'accepted', 'rejected', 'expired','revoked') DEFAULT 'pending',
- `inviters` JSON NOT NULL,";
- foreach($config['to_terms']??[] as $term) {
- $definitions .= "`to_{$term}` {$this->termIDType} DEFAULT NULL,";
- }
- $definitions .= "`new_user_id` bigint(20) NOT NULL,
- `expires_at` datetime NOT NULL,
- `accepted_at` datetime DEFAULT NULL,
- `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
- `updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
- PRIMARY KEY (`id`),
- UNIQUE KEY `unique_email` (`email`),
- KEY `token_lookup` (`invitation_token`),
- KEY `status_expiry` (`status`, `expires_at`),
- KEY `name_status` (`name`, `status`)
- )";
- foreach($config['to_terms']??[] as $term) {
- $definitions .= "CONSTRAINT `{$this->base}_{$term}_link` FOREIGN KEY (`to_{$term}`)
- REFERENCES `{$this->wpdb->terms}` (`term_id`) ON DELETE CASCADE";
- }
-
- $tables['invitations_'.$role] = $definitions;
+ protected function invitationTables(array $config): array
+ {
+ if (empty($config['roles']) && empty($config['terms'])) {
+ return [];
}
- return $tables;
- }
+ $definitions = "(
+ `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
+ `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}',
+ `new_user_id` {$this->userIDType} DEFAULT NULL,
+ `expires_at` datetime NOT NULL,
+ `accepted_at` datetime DEFAULT NULL,
+ `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ `updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+ ";
+
+ // Add term columns for all invitable taxonomies
+ foreach ($config['terms'] ?? [] as $taxonomy) {
+ $definitions .= "`to_{$taxonomy}` {$this->termIDType} DEFAULT NULL,";
+ }
+
+ $definitions .= "PRIMARY KEY (`id`),
+ UNIQUE KEY `unique_email_role` (`email`, `invited_role`),
+ KEY `token_lookup` (`invitation_token`),
+ KEY `status_expiry` (`status`, `expires_at`),
+ KEY `role_status` (`invited_role`, `status`),
+ KEY `email_status` (`email`, `status`),
+ ";
+
+ // Add foreign key constraints for terms
+ $constraints = [];
+ foreach ($config['terms'] ?? [] as $taxonomy) {
+ $constraints[] = "CONSTRAINT `{$this->base}invitations_{$taxonomy}_fk`
+ FOREIGN KEY (`to_{$taxonomy}`)
+ REFERENCES `{$this->wpdb->terms}` (`term_id`)
+ ON DELETE SET NULL";
+ }
+
+ // Add user foreign key
+ $constraints[] = "CONSTRAINT `{$this->base}invitations_user_fk`
+ FOREIGN KEY (`new_user_id`)
+ REFERENCES `{$this->userTable}` (`ID`)
+ ON DELETE SET NULL";
+
+ $definitions .= implode(',', $constraints);
+ $definitions .= ")";
+
+ return ['invitations' => $definitions];
+ }
protected function trackChangesTables($types)
{
diff --git a/inc/registry/ContentRegistry.php b/inc/registry/ContentRegistry.php
index 00719e2..3d02334 100644
--- a/inc/registry/ContentRegistry.php
+++ b/inc/registry/ContentRegistry.php
@@ -6,7 +6,7 @@
}
use JVBase\managers\RoleManager;
-use JVBase\meta\MetaRegistry;
+use JVBase\meta\Registry;
use JVBase\rest\RegisterRoutes;
class ContentRegistry
@@ -102,7 +102,7 @@
return;
}
- $meta_registry = new MetaRegistry($fields, $type, $object_type);
+ $meta_registry = new Registry($fields, $type, $object_type);
$meta_registry->registerMetaFields();
}
@@ -158,37 +158,6 @@
new OptionsRegistry($fields);
}
}
-
- /**
- * Register REST routes
- */
- public function registerRestRoutes(): void
- {
- // Register routes for post types
- foreach (JVB_CONTENT as $slug => $config) {
- $this->registerRoute($slug, $config);
- }
-
- // Register routes for content taxonomies
- foreach (JVB_TAXONOMY as $slug => $config) {
- if (jvbCheck('is_content', $config)) {
- $this->registerRoute($slug, $config, 'content_tax');
- }
- }
-
- // Register routes for options
- if (!empty(JVB_OPTIONS)) {
- $this->registerRoute('options', JVB_OPTIONS['fields'], 'options');
- }
- }
-
- /**
- * Register a single route
- */
- protected function registerRoute(string $slug, array $config, string $type = ''): void
- {
- JVB()->addRoute($slug, new RegisterRoutes($slug, $config));
- }
}
new ContentRegistry();
diff --git a/inc/registry/OptionsRegistry.php b/inc/registry/OptionsRegistry.php
index a21f2d8..391b842 100644
--- a/inc/registry/OptionsRegistry.php
+++ b/inc/registry/OptionsRegistry.php
@@ -4,7 +4,9 @@
if (!defined('ABSPATH')) {
exit;
}
-use JVBase\meta\MetaManager;
+
+use JVBase\meta\Form;
+use JVBase\meta\Meta;
class OptionsRegistry
{
@@ -22,7 +24,7 @@
*/
public function registerOptions(): void
{
- $meta = new MetaManager(null, 'options');
+ $meta = Meta::forOptions('options');
foreach ($this->fields as $field_name => $field) {
if (in_array($field_name, ['common', 'fields'])) {
continue;
@@ -34,7 +36,7 @@
// Add default value if not exists
if (get_option($option_name) === false) {
- add_option($option_name, $field['default'] ?? $meta->getDefaultValue($field['type']));
+ add_option($option_name, $field['default'] ??'');
}
//
@@ -87,11 +89,7 @@
$name = $args['name'];
$value = get_option(BASE . $name);
- // Use MetaForm to render if available
- if (class_exists('\JVBase\meta\MetaForm')) {
- $form = new \JVBase\meta\MetaForm();
- echo $form->renderField($name, $field, $value);
- }
+ echo Form::render($name, $value, $field);
}
private function getFieldType(string $type): string
@@ -104,12 +102,4 @@
default => 'string'
};
}
-
- private function getSanitizeCallback(array $field): callable
- {
- return function($value) use ($field) {
- $manager = new \JVBase\meta\MetaManager();
- return $manager->sanitizeField($value, $field);
- };
- }
}
diff --git a/inc/registry/PostTypeRegistrar.php b/inc/registry/PostTypeRegistrar.php
index f30eb55..0c60e5e 100644
--- a/inc/registry/PostTypeRegistrar.php
+++ b/inc/registry/PostTypeRegistrar.php
@@ -5,7 +5,7 @@
use JVBase\managers\CRUD;
use JVBase\utility\Features;
use WP_Post;
-use JVBase\meta\MetaRegistry;
+use JVBase\meta\Registry;
if (!defined('ABSPATH')) {
exit;
}
@@ -82,7 +82,7 @@
register_post_type($this->post_type, $args);
if (!empty($this->fields)) {
- $meta_registry = new MetaRegistry($this->fields, $this->slug, 'post');
+ $meta_registry = new Registry($this->fields, $this->slug, 'post');
$meta_registry->registerMetaFields();
}
}
diff --git a/inc/registry/TaxonomyRegistrar.php b/inc/registry/TaxonomyRegistrar.php
index 67fed28..448d917 100644
--- a/inc/registry/TaxonomyRegistrar.php
+++ b/inc/registry/TaxonomyRegistrar.php
@@ -1,8 +1,8 @@
<?php
namespace JVBase\registry;
-use JVBase\meta\MetaManager;
-use JVBase\meta\MetaRegistry;
+use JVBase\meta\Meta;
+use JVBase\meta\Registry;
if (!defined('ABSPATH')) {
exit;
}
@@ -52,7 +52,7 @@
$this->maybeAddRewriteRule($args['rewrite']);
if (!empty($this->fields)) {
- $meta_registry = new MetaRegistry($this->fields, $this->slug, 'term');
+ $meta_registry = new Registry($this->fields, $this->slug, 'term');
$meta_registry->registerMetaFields();
}
}
@@ -218,7 +218,7 @@
];
// Get meta manager for this term
- $meta = new MetaManager($term_id, 'term');
+ $meta = Meta::forTerm($term_id);
$values = $meta->getAll(array_keys($custom_fields));
// Process each custom field
diff --git a/inc/registry/UserRoleRegistrar.php b/inc/registry/UserRoleRegistrar.php
index 5f0b0d5..3e180d8 100644
--- a/inc/registry/UserRoleRegistrar.php
+++ b/inc/registry/UserRoleRegistrar.php
@@ -1,6 +1,6 @@
<?php
namespace JVBase\registry;
-use JVBase\meta\MetaRegistry;
+use JVBase\meta\Registry;
if (!defined('ABSPATH')) {
exit;
@@ -27,7 +27,7 @@
public function registerFields():void
{
if (!empty($this->fields)) {
- $meta_registry = new MetaRegistry($this->fields, $this->slug, 'term');
+ $meta_registry = new Registry($this->fields, $this->slug, 'term');
$meta_registry->registerMetaFields();
}
}
diff --git a/inc/rest/PermissionHandler.php b/inc/rest/PermissionHandler.php
index 409d479..8d4b447 100644
--- a/inc/rest/PermissionHandler.php
+++ b/inc/rest/PermissionHandler.php
@@ -343,7 +343,7 @@
/**
* Verify action-specific nonce (e.g., 'dash-{user_id}')
*/
- public static function verifyActionNonce(WP_REST_Request $request, string $actionPrefix, string $header = 'X-Action-Nonce'): bool|WP_Error
+ public static function verifyActionNonce(WP_REST_Request $request, string $actionPrefix, string $header = 'action_nonce'): bool|WP_Error
{
$userId = $request->get_param('user') ?: get_current_user_id();
$action = $actionPrefix . $userId;
@@ -352,29 +352,6 @@
}
/**
- * Combined permission check: user match + rate limit
- */
- public static function userMatchWithRateLimit(WP_REST_Request $request): bool|WP_Error
- {
- static $rateLimiter = null;
-
- if ($rateLimiter === null) {
- $rateLimiter = new RateLimiter();
- }
-
- // Check rate limit first
- if (!$rateLimiter->checkLimit($request)) {
- return new WP_Error(
- 'rate_limit',
- 'Too many requests. Please wait before trying again.',
- ['status' => 429]
- );
- }
-
- return self::userMatch($request);
- }
-
- /**
* Create a custom permission callback combining multiple checks
*
* Usage:
@@ -390,9 +367,11 @@
$check === 'admin' => self::isAdmin($request),
$check === 'verified' => self::isVerified($request),
$check === 'user' => self::userMatch($request),
+ $check === 'nonce' => self::verifyNonce($request),
is_array($check) && isset($check['role']) => self::hasRole($request, $check['role']),
is_array($check) && isset($check['roles']) => self::hasAnyRole($request, $check['roles']),
is_array($check) && isset($check['capability']) => self::hasCapability($request, $check['capability']),
+ is_array($check) && isset($check['actionNonce']) => self::verifyActionNonce($request, $check['actionNonce']),
is_callable($check) => $check($request),
default => true,
};
@@ -424,18 +403,19 @@
$check === 'admin' => self::isAdmin($request),
$check === 'verified' => self::isVerified($request),
$check === 'user' => self::userMatch($request),
+ $check === 'nonce' => self::verifyNonce($request),
is_array($check) && isset($check['role']) => self::hasRole($request, $check['role']),
+ is_array($check) && isset($check['roles']) => self::hasAnyRole($request, $check['roles']),
is_array($check) && isset($check['capability']) => self::hasCapability($request, $check['capability']),
+ is_array($check) && isset($check['actionNonce']) => self::verifyActionNonce($request, $check['actionNonce']),
is_callable($check) => $check($request),
default => false,
};
- // If it's a successful check (true), pass
if ($result === true) {
return true;
}
- // Track last error for reporting
if (is_wp_error($result)) {
$lastError = $result;
}
diff --git a/inc/rest/RateLimiter.php b/inc/rest/RateLimiter.php
index 7329f82..e1ff237 100644
--- a/inc/rest/RateLimiter.php
+++ b/inc/rest/RateLimiter.php
@@ -7,6 +7,7 @@
exit; // Exit if accessed directly
}
/**
+ * @deprecated Using RateLimits.php
* Handles rate limiting for REST requests
*/
class RateLimiter
diff --git a/inc/rest/RateLimits.php b/inc/rest/RateLimits.php
index e69de29..41d75b8 100644
--- a/inc/rest/RateLimits.php
+++ b/inc/rest/RateLimits.php
@@ -0,0 +1,98 @@
+<?php
+namespace JVBase\rest;
+
+use WP_REST_Request;
+
+if (!defined('ABSPATH')) {
+ exit;
+}
+
+/**
+ * Handles rate limiting for REST requests
+ */
+class RateLimits
+{
+ protected string $cacheGroup = 'jvb_rate_limits';
+
+ protected array $defaults = [
+ 'GET' => ['count' => 1000, 'window' => 3600],
+ 'POST' => ['count' => 100, 'window' => 3600],
+ 'PUT' => ['count' => 100, 'window' => 3600],
+ 'PATCH' => ['count' => 100, 'window' => 3600],
+ 'DELETE' => ['count' => 50, 'window' => 3600],
+ ];
+
+ /**
+ * Check if request is within rate limits
+ *
+ * @param WP_REST_Request $request
+ * @param int|null $limit Optional custom limit (overrides defaults)
+ * @param int|null $window Optional custom window in seconds (overrides defaults)
+ * @return bool True if within limits, false if exceeded
+ */
+ public function checkLimit(WP_REST_Request $request, ?int $limit = null, ?int $window = null): bool
+ {
+ $method = $request->get_method();
+ $default = $this->defaults[$method] ?? $this->defaults['GET'];
+
+ $limit = $limit ?? $default['count'];
+ $window = $window ?? $default['window'];
+
+ $key = $this->getCacheKey($request, $window);
+ $current = (int) wp_cache_get($key, $this->cacheGroup);
+
+ if ($current >= $limit) {
+ return false;
+ }
+
+ // Increment or initialize
+ if ($current === 0) {
+ wp_cache_set($key, 1, $this->cacheGroup, $window);
+ } else {
+ wp_cache_incr($key, 1, $this->cacheGroup);
+ }
+
+ return true;
+ }
+
+ /**
+ * Get remaining requests for current window
+ */
+ public function getRemaining(WP_REST_Request $request, ?int $limit = null, ?int $window = null): int
+ {
+ $method = $request->get_method();
+ $default = $this->defaults[$method] ?? $this->defaults['GET'];
+
+ $limit = $limit ?? $default['count'];
+ $window = $window ?? $default['window'];
+
+ $key = $this->getCacheKey($request, $window);
+ $current = (int) wp_cache_get($key, $this->cacheGroup);
+
+ return max(0, $limit - $current);
+ }
+
+ /**
+ * Reset rate limit for a request pattern
+ */
+ public function reset(WP_REST_Request $request, ?int $window = null): void
+ {
+ $method = $request->get_method();
+ $default = $this->defaults[$method] ?? $this->defaults['GET'];
+ $window = $window ?? $default['window'];
+
+ $key = $this->getCacheKey($request, $window);
+ wp_cache_delete($key, $this->cacheGroup);
+ }
+
+ protected function getCacheKey(WP_REST_Request $request, int $window): string
+ {
+ $ip = $request->get_header('X-Forwarded-For') ?: ($_SERVER['REMOTE_ADDR'] ?? 'unknown');
+ $userId = get_current_user_id();
+ $method = $request->get_method();
+ $route = $request->get_route();
+
+ // Include window in key so different windows don't collide
+ return "rate:{$ip}:{$userId}:{$method}:{$route}:{$window}";
+ }
+}
diff --git a/inc/rest/RegisterRoutes.php b/inc/rest/RegisterRoutes.php
index 04c00c9..c0104f4 100644
--- a/inc/rest/RegisterRoutes.php
+++ b/inc/rest/RegisterRoutes.php
@@ -4,10 +4,14 @@
if (!defined('ABSPATH')) {
exit;
}
-use JVBase\meta\MetaManager;
+use JVBase\meta\Meta;
use WP_Error;
use WP_REST_Request;
use WP_REST_Response;
+
+/**
+ * @deprecated
+ */
class RegisterRoutes extends RestRouteManager {
protected array $config;
@@ -160,7 +164,7 @@
'error' => 'User cannot change options'
];
}
- $meta = new MetaManager(null, $this->route);
+ $meta = Meta::forOptions($this->route);
} else {
$termID = (int) $data['term_id'];
if (!user_can($userID, 'manage_'.$this->route.'_'.$termID)) {
@@ -169,7 +173,7 @@
'error' => 'User cannot manage this '.$this->route
];
}
- $meta = new MetaManager($termID, 'term');
+ $meta = Meta::forTerm($termID);
}
$results = [];
@@ -181,9 +185,9 @@
foreach ($allowed as $name => $value) {
if (empty($value)) {
- $results[] = $meta->deleteValue($name);
+ $results[] = $meta->delete($name);
} else {
- $results[] = $meta->updateValue($name, $value);
+ $results[] = $meta->set($name, $value);
}
}
//Allow plugins & themes to process extra data here
@@ -327,9 +331,9 @@
return;
}
- $termMeta = new MetaManager($termID, 'term');
- $managers = explode(',', $termMeta->getValue('managers'));
- $owner = explode(',', $termMeta->getValue('owner'));
+ $termMeta = Meta::forTerm($termID);
+ $managers = explode(',', $termMeta->get('managers'));
+ $owner = explode(',', $termMeta->get('owner'));
$owners = array_unique(array_merge($managers, $owner));
diff --git a/inc/rest/Response.php b/inc/rest/Response.php
index d77980d..8a04cf1 100644
--- a/inc/rest/Response.php
+++ b/inc/rest/Response.php
@@ -430,8 +430,3 @@
return self::success($data);
}
}
-
-/**
- * Alias for backward compatibility
- */
-class ResponseBuilder extends Response {}
diff --git a/inc/rest/Rest.php b/inc/rest/Rest.php
index e69de29..6435ec8 100644
--- a/inc/rest/Rest.php
+++ b/inc/rest/Rest.php
@@ -0,0 +1,727 @@
+<?php
+namespace JVBase\rest;
+
+use JVBase\managers\Cache;
+use JVBase\utility\Features;
+use WP_REST_Request;
+use WP_REST_Response;
+use Exception;
+
+if (!defined('ABSPATH')) {
+ exit;
+}
+
+/**
+ * Base REST Route Manager
+ *
+ * Provides shared utilities for route handlers. Route registration
+ * should use the Route builder class.
+ *
+ * Responsibilities:
+ * - Cache management (headers, invalidation)
+ * - Query building helpers
+ * - Validation utilities
+ * - Audit logging
+ */
+abstract class Rest
+{
+ protected string $namespace = 'jvb/v1';
+ protected ?Cache $cache = null;
+ protected string $cacheName = '';
+ protected int $cacheTtl = 3600;
+ protected static ?string $action;
+
+ public function __construct()
+ {
+ if ($this->cacheName !== '') {
+ $this->cache = Cache::for($this->cacheName, $this->cacheTtl);
+ }
+
+ add_action('rest_api_init', [$this, 'registerRoutes']);
+ }
+
+ /**
+ * Register routes - implement using Route builder
+ */
+ abstract public function registerRoutes(): void;
+
+ // =========================================================================
+ // RESPONSE HELPERS
+ // =========================================================================
+
+ protected function success(array $data = [], int $status = 200): WP_REST_Response
+ {
+ return Response::success($data, $status);
+ }
+
+ protected function error(string $message, string $code = 'error', int $status = 400, ?string $field = null): WP_REST_Response
+ {
+ return Response::error($message, $code, $status, $field);
+ }
+
+ protected function validationError(array $errors): WP_REST_Response
+ {
+ return Response::validationError($errors);
+ }
+
+ protected function notFound(string $message = 'Not found'): WP_REST_Response
+ {
+ return Response::notFound($message);
+ }
+
+ protected function forbidden(string $message = 'Forbidden'): WP_REST_Response
+ {
+ return Response::forbidden($message);
+ }
+
+ protected function unauthorized(string $message = 'Unauthorized'): WP_REST_Response
+ {
+ return Response::unauthorized($message);
+ }
+
+ protected function queued(string $operationId, string $message = 'Queued for processing'): WP_REST_Response
+ {
+ return Response::queued($operationId, $message);
+ }
+
+ // =========================================================================
+ // CACHE MANAGEMENT
+ // =========================================================================
+
+ /**
+ * Check request headers for conditional caching (ETag, If-Modified-Since)
+ */
+ protected function checkHeaders(WP_REST_Request $request, string $cacheKey): ?WP_REST_Response
+ {
+ if (!$this->cache) {
+ return null;
+ }
+
+ $cached = $this->cache->get($cacheKey);
+ if (!$cached) {
+ return null;
+ }
+
+ $etag = $request->get_header('If-None-Match');
+ $cachedEtag = $cached['etag'] ?? null;
+
+ if ($etag && $cachedEtag && $etag === $cachedEtag) {
+ return new WP_REST_Response(null, 304);
+ }
+
+ $ifModifiedSince = $request->get_header('If-Modified-Since');
+ $lastModified = $cached['last_modified'] ?? null;
+
+ if ($ifModifiedSince && $lastModified) {
+ if (strtotime($ifModifiedSince) >= strtotime($lastModified)) {
+ return new WP_REST_Response(null, 304);
+ }
+ }
+
+ return null;
+ }
+
+ /**
+ * Add cache headers to response
+ */
+ protected function addCacheHeaders(WP_REST_Response $response, int $maxAge = 300): WP_REST_Response
+ {
+ $response->header('Cache-Control', "private, max-age={$maxAge}");
+ $response->header('Vary', 'Cookie');
+ $response->header('ETag', '"' . md5(serialize($response->get_data())) . '"');
+ $response->header('Last-Modified', gmdate('D, d M Y H:i:s') . ' GMT');
+
+ return $response;
+ }
+
+ /**
+ * Store response in cache with metadata
+ */
+ protected function cacheResponse(string $key, array $data): void
+ {
+ if (!$this->cache) {
+ return;
+ }
+
+ $this->cache->set($key, [
+ 'data' => $data,
+ 'etag' => '"' . md5(serialize($data)) . '"',
+ 'last_modified' => gmdate('D, d M Y H:i:s') . ' GMT',
+ ]);
+ }
+
+ // =========================================================================
+ // TIMESTAMP FORMATTING
+ // =========================================================================
+
+ /**
+ * Convert MySQL datetime to ISO 8601 timestamp
+ */
+ protected function formatTimestamp(?string $mysqlDatetime): ?string
+ {
+ return Response::formatTimestamp($mysqlDatetime);
+ }
+
+ // =========================================================================
+ // QUERY BUILDING
+ // =========================================================================
+
+ /**
+ * Apply taxonomy filters to WP_Query args
+ */
+ protected function applyTaxonomyFilters(array $args, array $data):array
+ {
+ // Handle JSON-encoded taxonomy data
+ if (array_key_exists('taxonomy', $data) && is_string($data['taxonomy'])) {
+ $data['taxonomy'] = json_decode($data['taxonomy'], true);
+ }
+
+ $taxonomies = $data['taxonomies'] ?? $data['taxonomy'] ?? [];
+ $taxQuery = [];
+
+ foreach($taxonomies as $taxonomy => $terms) {
+ // Better validation: check if taxonomy actually exists
+ if (!taxonomy_exists(jvbCheckBase($taxonomy))) {
+ continue;
+ }
+
+ $taxQuery[] = [
+ 'taxonomy' => jvbCheckBase($taxonomy),
+ 'field' => 'term_id',
+ 'terms' => array_map(
+ 'absint',
+ is_array($terms) ? $terms : explode(',', $terms)
+ ),
+ 'operator' => 'IN'
+ ];
+ }
+
+ if (!empty($taxQuery)) {
+ // Match 'all' = AND, anything else = OR
+ $relation = ($data['match'] ?? 'all') === 'all' ? 'AND' : 'OR';
+
+ $args['tax_query'] = array_merge([
+ 'relation' => $relation,
+ ], $taxQuery);
+ }
+
+ // Keep existing author filtering logic
+ $authorQuery = [];
+ foreach (jvbAuthorUsers() as $type) {
+ if (array_key_exists($type, $data)) {
+ $artist_ids = array_map(
+ 'absint',
+ is_array($data[$type]) ?
+ $data[$type] :
+ explode(',', $data[$type])
+ );
+ $authorQuery = array_merge($authorQuery, $artist_ids);
+ }
+ }
+ if (!empty($authorQuery)) {
+ $args['author__in'] = array_unique($authorQuery);
+ }
+
+ return $args;
+ }
+
+ /**
+ * Apply order/sort filters to WP_Query args
+ */
+ protected function applyOrderFilters(array $args, array $data):array
+ {
+ // Check for custom order first
+ $customArgs = $this->applyCustomOrder($args, $data);
+ if ($customArgs !== null) {
+ $order = (array_key_exists('order', $data)) ? strtoupper($data['order']) : 'DESC';
+ $customArgs['order'] = (in_array($order, ['ASC', 'DESC'])) ? $order : 'DESC';
+ return $customArgs;
+ }
+
+ //Handle random
+ if (array_key_exists('orderby', $data) && $data['orderby'] === 'random') {
+ $current_seed = jvbGetRandomSeed();
+ $args['orderby'] = 'RAND(' . $current_seed . ')';
+ unset($args['order']);
+ return $args;
+ }
+
+ if (in_array($data['orderby'], ['date', 'modified', 'title', 'alphabetical'])) {
+ if ($data['orderby'] === 'date' && $this->isTimeline($args, $data)) {
+ $args['meta_key'] = BASE . 'latest_date';
+ $args['orderby'] = 'meta_value_num';
+ } else {
+ $args['orderby'] = ($data['orderby'] === 'alphabetical') ? 'title' : $data['orderby'];
+ }
+
+ } else {
+ switch ($data['orderby']) {
+ case 'popularity':
+ $args['meta_key'] = BASE.'upvotes';
+ $args['orderby'] = 'meta_value_num';
+ break;
+ case 'karma':
+ $args['meta_key'] = BASE.'karma';
+ $args['orderby'] = 'meta_value_num';
+ break;
+ case 'unpopularity':
+ $args['meta_key'] = BASE.'downvotes';
+ $args['orderby'] = 'meta_value_num';
+ break;
+ case 'favourites':
+ $args['meta_key'] = BASE.'total_favourites';
+ $args['orderby'] = 'meta_value_num';
+ break;
+ case 'date':
+ default:
+ $args['orderby'] = 'date';
+ break;
+ }
+ }
+ $order = (array_key_exists('order', $data)) ? strtoupper($data['order']) : 'DESC';
+ $args['order'] = (in_array($order, ['ASC', 'DESC'])) ? $order : 'DESC';
+
+ return $args;
+ }
+
+ /**
+ * Apply custom order if defined in content/taxonomy/user config
+ *
+ * @param array $args WP_Query args
+ * @param array $data Request data
+ * @return array|null Modified args if custom order found, null otherwise
+ */
+ protected function applyCustomOrder(array $args, array $data): ?array
+ {
+ $orderby = $data['orderby'] ?? '';
+
+ // Skip if no orderby or it's a standard order
+ if (empty($orderby) || in_array($orderby, ['date', 'modified', 'title', 'alphabetical', 'random', 'popularity', 'karma', 'unpopularity', 'favourites'])) {
+ return null;
+ }
+
+ // Determine content type
+ $post_type = is_array($args['post_type']) ? $args['post_type'][0] : $args['post_type'];
+ $content = jvbNoBase($post_type);
+
+ // Get config for this content type
+ $config = Features::getConfig($content);
+ if (!$config) {
+ return null;
+ }
+
+ // Check if this orderby is a custom order
+ $customOrders = $config['custom_order'] ?? [];
+ if (empty($customOrders) || !isset($customOrders[$orderby])) {
+ return null;
+ }
+
+ // Get field definition
+ $fields = $config['fields'] ?? [];
+ if (!isset($fields[$orderby])) {
+ return null;
+ }
+
+ $field = $fields[$orderby];
+
+ // Set meta_key
+ $args['meta_key'] = BASE . $orderby;
+
+ // Determine orderby and meta_type based on field type
+ $fieldType = $field['type'] ?? 'text';
+ $subtype = $field['subtype'] ?? '';
+
+ switch ($fieldType) {
+ case 'number':
+ $args['orderby'] = 'meta_value_num';
+ break;
+
+ case 'text':
+ $args['orderby'] = ($subtype === 'number') ? 'meta_value_num' : 'meta_value';
+ break;
+
+ case 'date':
+ $args['orderby'] = 'meta_value';
+ $args['meta_type'] = 'DATE';
+ break;
+
+ case 'datetime':
+ $args['orderby'] = 'meta_value';
+ $args['meta_type'] = 'DATETIME';
+ break;
+
+ case 'true_false':
+ case 'checkbox':
+ $args['orderby'] = 'meta_value';
+ $args['meta_type'] = 'BINARY';
+ break;
+
+ default:
+ $args['orderby'] = 'meta_value';
+ }
+
+ return $args;
+ }
+
+ protected function applyDateFilters(array $args, array $data):array
+ {
+ if (!array_key_exists('date-filter', $data) && !array_key_exists('dateFrom', $data)) {
+ return $args;
+ }
+ if (array_key_exists('dateFrom', $data)) {
+ $dateFrom = strtotime(sanitize_text_field($data['dateFrom']));
+ $dateTo = strtotime(sanitize_text_field($data['dateTo']));
+ if ($dateFrom && $dateTo) {
+ $args['date_query'] = [
+ [
+ 'after' => date('c', $dateFrom),
+ 'before' => date('c', $dateTo),
+ 'inclusive' => true,
+ ]
+ ];
+ }
+ } else {
+ switch ($data['date-filter']) {
+ case 'today':
+ $args['date_query'] = [['after' => '1 day ago']];
+ break;
+ case 'week':
+ $args['date_query'] = [['after' => '1 week ago']];
+ break;
+ case 'month':
+ $args['date_query'] = [['after' => '1 month ago']];
+ break;
+ case 'year':
+ $args['date_query'] = [['after' => '1 year ago']];
+ break;
+ }
+ }
+ return $args;
+ }
+
+ protected function applyCalendarFilters(array $args, array $data):array
+ {
+ $meta_query = [];
+ $today = date('Y-m-d');
+ if (in_array('future', $args['post_status'])) {
+ $meta_query[] = [
+ 'key' => 'jvb_start_date',
+ 'value' => $today,
+ 'compare' => '>=',
+ 'type' => 'DATE'
+ ];
+ }
+ if (in_array('past', $args['post_status'])) {
+ $meta_query[] = [
+ 'key' => 'jvb_end_date',
+ 'value' => $today,
+ 'compare' => '<',
+ 'type' => 'DATE'
+ ];
+ }
+ if (in_array('recurring', $args['post_status'])) {
+ $meta_query[] = [
+ 'key' => 'jvb_is_recurring',
+ 'value' => true,
+ 'compare' => '='
+ ];
+ }
+ if (!empty($meta_query)) {
+ $args['meta_query'] = (array_key_exists('meta_query', $args)) ? array_merge($args['meta_query'], $meta_query) : $meta_query;
+ }
+ return $args;
+
+ }
+
+ /**
+ * Apply pagination to WP_Query args
+ */
+ protected function applyPagination(array $args, array $data): array
+ {
+ $args['posts_per_page'] = min(absint($data['per_page'] ?? 20), 100);
+ $args['paged'] = max(absint($data['page'] ?? 1), 1);
+ return $args;
+ }
+
+ // =========================================================================
+ // VALIDATION
+ // =========================================================================
+
+ /**
+ * Check if user ID matches current logged-in user
+ */
+ protected function userCheck(int $userId): bool
+ {
+ return $userId === get_current_user_id();
+ }
+
+ /**
+ * Check if content type exists
+ */
+ protected function checkContent(string $content, bool $returnBool = false): string|bool
+ {
+ $result = JVB_CONTENT[$content] ?? JVB_TAXONOMY[$content] ?? JVB_USER[$content] ?? '';
+ return $returnBool ? ($result !== '') : $result;
+ }
+
+ /**
+ * Check if user exists (cached)
+ */
+ protected function checkUser(int $userId): bool
+ {
+ $cache = Cache::for('checkUser', DAY_IN_SECONDS)->connect('user');
+ return $cache->remember($userId, fn() => (bool) get_userdata($userId));
+ }
+
+ /**
+ * Check if term exists (cached)
+ */
+ protected function checkTerm(array $args): bool
+ {
+ $termId = $args['term_id'] ?? $args['to_term'] ?? false;
+ $taxonomy = $args['taxonomy'] ?? false;
+
+ if (!$termId || !$taxonomy) {
+ return false;
+ }
+
+ $cache = Cache::for('checkTerm', DAY_IN_SECONDS)->connect('taxonomy');
+ return $cache->remember($termId, fn() => (bool) term_exists($termId, jvbCheckBase($taxonomy)));
+ }
+
+ /**
+ * Check if user is verified
+ */
+ protected function isVerifiedUser(int $userId): bool
+ {
+ $cache = Cache::for('verifiedUsers', DAY_IN_SECONDS)->connect('user');
+ return $cache->remember($userId, fn() => user_can($userId, 'skip_moderation'));
+ }
+
+ /**
+ * Sanitize array of IDs
+ */
+ protected function sanitizeIds(array $ids): array
+ {
+ return array_values(array_filter(array_map('absint', $ids), fn($id) => $id > 0));
+ }
+
+ /**
+ * Get and validate meta values
+ */
+ protected function getMetaValues(mixed $value): mixed
+ {
+ $decoded = is_string($value) ? json_decode($value, true) : $value;
+
+ if (!is_array($decoded)) {
+ return $value;
+ }
+
+ return array_map(fn($item) => is_object($item) ? (array) $item : $item, $decoded);
+ }
+
+ /***************************************************************************
+ * UTILITY
+ ***************************************************************************/
+ protected function isTimeline($args, $data):bool
+ {
+ $post_types = is_array($args['post_type']) ? $args['post_type'] : [$args['post_type']];
+ foreach ($post_types as $type) {
+ if (Features::forContent($type)->has('is_timeline')) {
+ return true;
+ }
+ }
+ return false;
+ }
+ // =========================================================================
+ // SECURITY
+ // =========================================================================
+
+ /**
+ * Verify Cloudflare Turnstile token
+ */
+ protected function verifyTurnstile(string $token): bool
+ {
+ if (!Features::hasIntegration('cloudflare') || !JVB()->connect('cloudflare')->isSetUp()) {
+ return true;
+ }
+
+ return !empty($token) && JVB()->connect('cloudflare')->verifyTurnstile($token);
+ }
+
+ /**
+ * Generate CSRF token for user
+ */
+ protected function generateCsrfToken(int $userId): string
+ {
+ $token = wp_generate_password(32, false);
+ set_transient(BASE . 'csrf_' . $userId, $token, HOUR_IN_SECONDS);
+ return $token;
+ }
+
+ /**
+ * Validate CSRF token from request header
+ */
+ protected function validateCsrfToken(WP_REST_Request $request): bool
+ {
+ if (!is_user_logged_in() || in_array($request->get_method(), ['GET', 'HEAD', 'OPTIONS'])) {
+ return true;
+ }
+
+ $userId = get_current_user_id();
+ $token = $request->get_header('X-CSRF-Token');
+ $stored = get_transient(BASE . 'csrf_' . $userId);
+
+ return !empty($stored) && !empty($token) && hash_equals($stored, $token);
+ }
+
+ // =========================================================================
+ // OPERATION LOCKING
+ // =========================================================================
+
+ /**
+ * Prevent concurrent requests for the same operation
+ */
+ protected function acquireOperationLock(string $key, int $duration = 5): bool
+ {
+ $lockKey = 'op_lock_' . md5($key);
+
+ if (get_transient($lockKey)) {
+ return false;
+ }
+
+ set_transient($lockKey, true, $duration);
+ return true;
+ }
+
+ /**
+ * Release operation lock
+ */
+ protected function releaseOperationLock(string $key): void
+ {
+ delete_transient('op_lock_' . md5($key));
+ }
+
+ // =========================================================================
+ // LOGGING
+ // =========================================================================
+
+ /**
+ * Log security-relevant events
+ */
+ protected function auditLog(string $event, array $data = []): void
+ {
+ $context = array_merge($data, [
+ 'timestamp' => current_time('mysql'),
+ 'user_id' => get_current_user_id() ?: 0,
+ 'ip' => $_SERVER['REMOTE_ADDR'] ?? '',
+ 'user_agent' => $_SERVER['HTTP_USER_AGENT'] ?? '',
+ ]);
+
+ try {
+ JVB()->error()->log('security_audit', $event, $context, 'info');
+ } catch (Exception $e) {
+ error_log("Security Audit: {$event} - " . json_encode($context));
+ }
+ }
+
+ /**
+ * Log errors with proper context
+ */
+ protected function logError(string $message, array $context = [], string $severity = 'error'): void
+ {
+ try {
+ JVB()->error()->log(static::class, $message, $context, $severity);
+ } catch (Exception $e) {
+ error_log(static::class . " Error: {$message} - " . json_encode($context));
+ }
+ }
+
+ /************************************************************
+ SESSION FINGERPRINT
+ ************************************************************/
+ /**
+ * Store session fingerprint for hijacking detection
+ */
+ protected function storeSessionFingerprint(int $user_id, WP_REST_Request $request): void
+ {
+ if (!defined('JVB_SESSION_FINGERPRINT') || !JVB_SESSION_FINGERPRINT) {
+ return;
+ }
+
+ $fingerprint = $this->generateSessionFingerprint($request);
+ update_user_meta($user_id, BASE . 'session_fingerprint', $fingerprint);
+ update_user_meta($user_id, BASE . 'session_timestamp', time());
+ }
+ /**
+ * Generate session fingerprint for hijacking detection
+ *
+ * @param WP_REST_Request $request The REST request
+ * @return string Hashed fingerprint
+ */
+ protected function generateSessionFingerprint(WP_REST_Request $request): string
+ {
+ return hash('sha256', implode('|', [
+ $request->get_header('User-Agent') ?? '',
+ // Use IP class instead of full IP to allow for mobile network changes
+ $this->getIPClass(
+ $request->get_header('X-Forwarded-For')
+ ?: $request->get_header('X-Real-IP')
+ ?: $_SERVER['REMOTE_ADDR'] ?? ''
+ )
+ ]));
+ }
+
+ /**
+ * Get IP class (first 3 octets) for session validation
+ * This allows for minor IP changes (common with mobile networks)
+ *
+ * @param string $ip IP address
+ * @return string First 3 octets
+ */
+ protected function getIPClass(string $ip): string
+ {
+ $parts = explode('.', $ip);
+ return implode('.', array_slice($parts, 0, 3));
+ }
+
+ /**
+ * Validate session fingerprint against stored value
+ *
+ * @param int $user_id User ID to validate
+ * @param WP_REST_Request $request Current request
+ * @return bool True if valid, false if potential hijacking
+ */
+ protected function validateSessionFingerprint(int $user_id, WP_REST_Request $request): bool
+ {
+ // Only enforce if enabled in config
+ if (!defined('JVB_SESSION_FINGERPRINT') || !JVB_SESSION_FINGERPRINT) {
+ return true;
+ }
+
+ $stored = get_user_meta($user_id, BASE . 'session_fingerprint', true);
+ $current = $this->generateSessionFingerprint($request);
+
+ if (empty($stored)) {
+ // First request - store fingerprint
+ update_user_meta($user_id, BASE . 'session_fingerprint', $current);
+ update_user_meta($user_id, BASE . 'session_timestamp', time());
+ return true;
+ }
+
+ // Compare using timing-safe comparison
+ return hash_equals($stored, $current);
+ }
+
+ /**
+ * Clear session fingerprint (call on logout)
+ *
+ * @param int $user_id User ID
+ * @return void
+ */
+ protected function clearSessionFingerprint(int $user_id): void
+ {
+ delete_user_meta($user_id, BASE . 'session_fingerprint');
+ delete_user_meta($user_id, BASE . 'session_timestamp');
+ }
+}
diff --git a/inc/rest/RestRouteManager.php b/inc/rest/RestRouteManager.php
index 0bf3041..e3122e6 100644
--- a/inc/rest/RestRouteManager.php
+++ b/inc/rest/RestRouteManager.php
@@ -19,6 +19,7 @@
}
/**
+ * @deprecated use Rest.php
* Handles route registration and high-level coordination
*/
abstract class RestRouteManager
@@ -30,7 +31,7 @@
protected string $route;
protected string $base;
protected string $content_type; //the registered post type
- protected string $type; //post, user, term, for MetaManager
+ protected string $type; //post, user, term, for Meta
protected string $action = ''; //optional additional nonce to check
protected array $callback; //route->callback array
protected string $operation_type; // from QueueManager.js and OperationQueue.php
diff --git a/inc/rest/Route.php b/inc/rest/Route.php
index 2350eea..4deb44a 100644
--- a/inc/rest/Route.php
+++ b/inc/rest/Route.php
@@ -12,8 +12,15 @@
* Fluent REST Route Builder
*
* Usage:
- * Route::get('queue', [$this, 'getQueue'])->auth('user')->args(['status' => 'string']);
- * Route::resource('content')->get(...)->post(...)->delete(false);
+ * // Single-method routes
+ * Route::for('queue')->get([$this, 'getQueue'])->auth('user');
+ * Route::for('content')->post([$this, 'create'])->auth('verified');
+ *
+ * // Multi-method resources
+ * Route::for('uploads')
+ * ->get([$this, 'list'])->auth('user')
+ * ->post([$this, 'upload'])->auth('user')
+ * ->delete(false);
*/
class Route
{
@@ -23,57 +30,21 @@
private bool $registered = false;
private static string $namespace = 'jvb/v1';
- private static ?RateLimiter $rateLimiter = null;
+ private static ?RateLimits $rateLimiter = null;
+
+ // =========================================================================
+ // ENTRY POINTS
+ // =========================================================================
/**
- * Create a resource route (supports multiple methods)
+ * Create a new route builder for the given path
*/
- public static function resource(string $path): self
+ public static function for(string $path): self
{
return new self($path);
}
/**
- * Create a GET route
- */
- public static function get(string $path, callable|array $callback): self
- {
- return (new self($path))->addMethod('GET', $callback);
- }
-
- /**
- * Create a POST route
- */
- public static function post(string $path, callable|array $callback): self
- {
- return (new self($path))->addMethod('POST', $callback);
- }
-
- /**
- * Create a PUT route
- */
- public static function put(string $path, callable|array $callback): self
- {
- return (new self($path))->addMethod('PUT', $callback);
- }
-
- /**
- * Create a PATCH route
- */
- public static function patch(string $path, callable|array $callback): self
- {
- return (new self($path))->addMethod('PATCH', $callback);
- }
-
- /**
- * Create a DELETE route
- */
- public static function delete(string $path, callable|array $callback): self
- {
- return (new self($path))->addMethod('DELETE', $callback);
- }
-
- /**
* Set custom namespace (defaults to 'jvb/v1')
*/
public static function setNamespace(string $namespace): void
@@ -89,13 +60,40 @@
return self::$namespace;
}
+ /**
+ * Convert readable pattern to WordPress regex
+ * Example: 'queue/{id}' becomes 'queue/(?P<id>[a-zA-Z0-9_-]+)'
+ */
+ public static function pattern(string $path, array $patterns = []): string
+ {
+ $defaults = [
+ 'id' => '[a-zA-Z0-9_-]+',
+ 'slug' => '[a-zA-Z0-9_-]+',
+ 'type' => '[a-zA-Z_]+',
+ 'int' => '[0-9]+',
+ ];
+
+ $patterns = array_merge($defaults, $patterns);
+
+ return preg_replace_callback('/\{(\w+)(?::(\w+))?\}/', function($matches) use ($patterns) {
+ $name = $matches[1];
+ $type = $matches[2] ?? $name;
+ $pattern = $patterns[$type] ?? $patterns['id'];
+ return "(?P<{$name}>{$pattern})";
+ }, $path);
+ }
+
private function __construct(string $path)
{
$this->path = '/' . ltrim($path, '/');
}
+ // =========================================================================
+ // HTTP METHODS
+ // =========================================================================
+
/**
- * Add GET method to resource
+ * Add GET handler
*/
public function get(callable|array $callback): self
{
@@ -103,7 +101,7 @@
}
/**
- * Add POST method to resource
+ * Add POST handler
*/
public function post(callable|array $callback): self
{
@@ -111,7 +109,7 @@
}
/**
- * Add PUT method to resource
+ * Add PUT handler
*/
public function put(callable|array $callback): self
{
@@ -119,7 +117,7 @@
}
/**
- * Add PATCH method to resource
+ * Add PATCH handler
*/
public function patch(callable|array $callback): self
{
@@ -127,19 +125,16 @@
}
/**
- * Add DELETE method to resource (pass false to explicitly disable)
+ * Add DELETE handler (pass false to explicitly disable)
*/
public function delete(callable|array|false $callback): self
{
if ($callback === false) {
- return $this; // Explicitly disabled
+ return $this;
}
return $this->addMethod('DELETE', $callback);
}
- /**
- * Internal method to add HTTP method
- */
private function addMethod(string $method, callable|array $callback): self
{
// Finalize previous method if exists
@@ -157,17 +152,21 @@
return $this;
}
+ // =========================================================================
+ // AUTHENTICATION
+ // =========================================================================
+
/**
- * Set authentication/permission requirement
+ * Set authentication requirement
*
- * @param string|array|false $auth
+ * @param string|array|callable|false $auth
* - 'public' or false: Anyone can access
* - 'user': Logged-in user must match 'user' param in request
* - 'logged_in': Any logged-in user
* - 'admin': Users with manage_options capability
* - 'verified': Users with skip_moderation capability
- * - ['capability' => 'edit_posts']: Specific capability check
- * - ['role' => 'artist']: Specific role check
+ * - ['capability' => 'edit_posts']: Specific capability
+ * - ['role' => 'artist']: Specific role
* - ['roles' => ['artist', 'admin']]: Multiple roles (OR)
* - callable: Custom permission callback
*/
@@ -180,31 +179,23 @@
$this->currentMethod['permission_callback'] = match (true) {
$auth === false || $auth === 'public'
=> '__return_true',
-
$auth === 'logged_in'
=> 'is_user_logged_in',
-
$auth === 'user'
=> [PermissionHandler::class, 'userMatch'],
-
$auth === 'admin'
=> [PermissionHandler::class, 'isAdmin'],
-
$auth === 'verified'
=> [PermissionHandler::class, 'isVerified'],
-
+ $auth === 'nonce' => [PermissionHandler::class, 'nonce'],
is_callable($auth)
=> $auth,
-
is_array($auth) && isset($auth['capability'])
=> fn(WP_REST_Request $req) => current_user_can($auth['capability']),
-
is_array($auth) && isset($auth['role'])
=> fn(WP_REST_Request $req) => PermissionHandler::hasRole($req, $auth['role']),
-
is_array($auth) && isset($auth['roles'])
=> fn(WP_REST_Request $req) => PermissionHandler::hasAnyRole($req, $auth['roles']),
-
default
=> '__return_true',
};
@@ -213,10 +204,7 @@
}
/**
- * Add rate limiting to the route
- *
- * @param int $limit Maximum requests
- * @param int $window Time window in seconds
+ * Add rate limiting
*/
public function rateLimit(int $limit = 60, int $window = 60): self
{
@@ -227,12 +215,10 @@
$originalCallback = $this->currentMethod['permission_callback'];
$this->currentMethod['permission_callback'] = function(WP_REST_Request $request) use ($originalCallback, $limit, $window) {
- // Initialize rate limiter if needed
if (self::$rateLimiter === null) {
- self::$rateLimiter = new RateLimiter();
+ self::$rateLimiter = new RateLimits();
}
- // Check rate limit first
if (!self::$rateLimiter->checkLimit($request, $limit, $window)) {
return new WP_Error(
'rate_limit',
@@ -241,16 +227,13 @@
);
}
- // Then check original permission
if ($originalCallback === '__return_true') {
return true;
}
- if (is_callable($originalCallback)) {
- return call_user_func($originalCallback, $request);
- }
-
- return true;
+ return is_callable($originalCallback)
+ ? call_user_func($originalCallback, $request)
+ : true;
};
return $this;
@@ -258,9 +241,6 @@
/**
* Require nonce verification
- *
- * @param string $action Nonce action name (default: 'wp_rest')
- * @param string $header Header name containing nonce (default: 'X-WP-Nonce')
*/
public function nonce(string $action = 'wp_rest', string $header = 'X-WP-Nonce'): self
{
@@ -281,35 +261,31 @@
);
}
- // Then check original permission
if ($originalCallback === '__return_true') {
return true;
}
- if (is_callable($originalCallback)) {
- return call_user_func($originalCallback, $request);
- }
-
- return true;
+ return is_callable($originalCallback)
+ ? call_user_func($originalCallback, $request)
+ : true;
};
return $this;
}
+ // =========================================================================
+ // ARGUMENTS
+ // =========================================================================
+
/**
* Define route arguments with shorthand syntax
*
- * @param array $args Argument definitions
- * Shorthand: ['name' => 'type|required|default:value|enum:a,b,c']
- * Full: ['name' => ['type' => 'string', 'required' => true, ...]]
- *
* Examples:
* 'status' => 'string'
* 'status' => 'string|required'
* 'status' => 'string|default:all'
* 'status' => 'string|enum:pending,completed,failed'
* 'limit' => 'integer|default:50|min:1|max:100'
- * 'ids' => 'array|required'
*/
public function args(array $args): self
{
@@ -337,12 +313,8 @@
return $this;
}
- /**
- * Parse shorthand argument definition into WP REST format
- */
private function parseArgDefinition(string|array $definition): array
{
- // Already full format
if (is_array($definition)) {
return $definition;
}
@@ -351,11 +323,11 @@
$type = trim($parts[0]);
$arg = [
- 'type' => $type,
+ 'type' => $type === 'int' ? 'integer' : ($type === 'bool' ? 'boolean' : $type),
'required' => false,
];
- // Add sanitize callback based on type
+ // Sanitize callback based on type
$arg['sanitize_callback'] = match ($type) {
'integer', 'int' => 'absint',
'string' => 'sanitize_text_field',
@@ -365,36 +337,22 @@
default => null,
};
- // Normalize type for WP
- if ($type === 'int') {
- $arg['type'] = 'integer';
- } elseif ($type === 'bool') {
- $arg['type'] = 'boolean';
- }
-
// Parse modifiers
foreach (array_slice($parts, 1) as $part) {
$part = trim($part);
- if ($part === 'required') {
- $arg['required'] = true;
- } elseif (str_starts_with($part, 'default:')) {
- $value = substr($part, 8);
- $arg['default'] = $this->castValue($value, $type);
- } elseif (str_starts_with($part, 'enum:')) {
- $arg['enum'] = array_map('trim', explode(',', substr($part, 5)));
- } elseif (str_starts_with($part, 'min:')) {
- $arg['minimum'] = (int) substr($part, 4);
- } elseif (str_starts_with($part, 'max:')) {
- $arg['maximum'] = (int) substr($part, 4);
- } elseif (str_starts_with($part, 'desc:')) {
- $arg['description'] = substr($part, 5);
- } elseif (str_starts_with($part, 'pattern:')) {
- $arg['pattern'] = substr($part, 8);
- }
+ match (true) {
+ $part === 'required' => $arg['required'] = true,
+ str_starts_with($part, 'default:') => $arg['default'] = $this->castValue(substr($part, 8), $type),
+ str_starts_with($part, 'enum:') => $arg['enum'] = array_map('trim', explode(',', substr($part, 5))),
+ str_starts_with($part, 'min:') => $arg['minimum'] = (int) substr($part, 4),
+ str_starts_with($part, 'max:') => $arg['maximum'] = (int) substr($part, 4),
+ str_starts_with($part, 'desc:') => $arg['description'] = substr($part, 5),
+ str_starts_with($part, 'pattern:') => $arg['pattern'] = substr($part, 8),
+ default => null,
+ };
}
- // Remove null sanitize callback
if ($arg['sanitize_callback'] === null) {
unset($arg['sanitize_callback']);
}
@@ -402,9 +360,6 @@
return $arg;
}
- /**
- * Cast value to appropriate type
- */
private function castValue(string $value, string $type): mixed
{
return match ($type) {
@@ -416,6 +371,10 @@
};
}
+ // =========================================================================
+ // REGISTRATION
+ // =========================================================================
+
/**
* Register the route with WordPress
*/
@@ -425,7 +384,6 @@
return $this;
}
- // Add current method if not empty
if (!empty($this->currentMethod)) {
$this->methods[] = $this->currentMethod;
$this->currentMethod = [];
@@ -435,13 +393,10 @@
return $this;
}
- // Register single method or array of methods
$config = count($this->methods) === 1 ? $this->methods[0] : $this->methods;
-
register_rest_route(self::$namespace, $this->path, $config);
$this->registered = true;
-
return $this;
}
@@ -450,31 +405,8 @@
*/
public function __destruct()
{
- if (!$this->registered && !empty($this->methods) || !empty($this->currentMethod)) {
+ if (!$this->registered && (!empty($this->methods) || !empty($this->currentMethod))) {
$this->register();
}
}
-
- /**
- * Convert WordPress route pattern to more readable format
- * Converts: queue/{id} to queue/(?P<id>[a-zA-Z0-9_-]+)
- */
- public static function pattern(string $path, array $patterns = []): string
- {
- $defaults = [
- 'id' => '[a-zA-Z0-9_-]+',
- 'slug' => '[a-zA-Z0-9_-]+',
- 'type' => '[a-zA-Z_]+',
- 'int' => '[0-9]+',
- ];
-
- $patterns = array_merge($defaults, $patterns);
-
- return preg_replace_callback('/\{(\w+)(?::(\w+))?\}/', function($matches) use ($patterns) {
- $name = $matches[1];
- $type = $matches[2] ?? $name;
- $pattern = $patterns[$type] ?? $patterns['id'];
- return "(?P<{$name}>{$pattern})";
- }, $path);
- }
}
diff --git a/inc/rest/_setup.php b/inc/rest/_setup.php
index b94d38f..2b6682b 100644
--- a/inc/rest/_setup.php
+++ b/inc/rest/_setup.php
@@ -2,16 +2,16 @@
use JVBase\utility\Features;
//NEW METHOD
-//require(JVB_DIR . '/inc/rest/Route.php');
-//require(JVB_DIR . '/inc/rest/PermissionHandler.php');
-//require(JVB_DIR . '/inc/rest/Response.php');
-//require(JVB_DIR . '/inc/rest/Rest.php'); //Refactored RestRouteManager.php
-//require(JVB_DIR . '/inc/rest/RateLimits.php');
+require(JVB_DIR . '/inc/rest/Route.php');
+require(JVB_DIR . '/inc/rest/PermissionHandler.php');
+require(JVB_DIR . '/inc/rest/Response.php');
+require(JVB_DIR . '/inc/rest/Rest.php');
+require(JVB_DIR . '/inc/rest/RateLimits.php');
//OLD METHOD
-require(JVB_DIR . '/inc/rest/RateLimiter.php');
-require(JVB_DIR . '/inc/rest/RestRouteManager.php');
-require(JVB_DIR . '/inc/rest/RegisterRoutes.php');
+//require(JVB_DIR . '/inc/rest/RateLimiter.php');
+//require(JVB_DIR . '/inc/rest/RestRouteManager.php');
+//require(JVB_DIR . '/inc/rest/RegisterRoutes.php');
if (Features::forSite()->has('feed_block')) {
require(JVB_DIR . '/inc/rest/routes/FeedRoutes.php');
@@ -37,10 +37,10 @@
require(JVB_DIR . '/inc/rest/routes/UploadRoutes.php');
require(JVB_DIR . '/inc/rest/routes/SettingsRoutes.php');
if (Features::forSite()->has('dashboard')) {
- require(JVB_DIR . '/inc/rest/routes/AdminRoutes.php');
+// require(JVB_DIR . '/inc/rest/routes/AdminRoutes.php');
require(JVB_DIR . '/inc/rest/routes/ContentRoutes.php');
- require(JVB_DIR . '/inc/rest/routes/BioRoutes.php');
- require(JVB_DIR . '/inc/rest/routes/ShopRoutes.php');
+// require(JVB_DIR . '/inc/rest/routes/BioRoutes.php');
+// require(JVB_DIR . '/inc/rest/routes/ShopRoutes.php');
}
if (Features::forMembership()->has('forum')) {
require(JVB_DIR . '/inc/rest/routes/NewsRoutes.php');
diff --git a/inc/rest/routes/AdminRoutes.php b/inc/rest/routes/AdminRoutes.php
deleted file mode 100644
index a32211c..0000000
--- a/inc/rest/routes/AdminRoutes.php
+++ /dev/null
@@ -1,680 +0,0 @@
-<?php
-namespace JVBase\rest\routes;
-
-use JVBase\JVB;
-use JVBase\rest\RestRouteManager;
-use JVBase\meta\MetaManager;
-use WP_Query;
-use WP_Error;
-use WP_REST_Request;
-use WP_REST_Response;
-
-if (!defined('ABSPATH')) {
- exit; // Exit if accessed directly
-}
-
-class AdminRoutes extends RestRouteManager
-{
- protected array $fields;
- protected $meta;
- protected string $metaType;
- protected string $content;
-
- public function __construct()
- {
- $this->cache_name = 'itsme';
- parent::__construct();
- $this->action = 'itsme';
- }
- public function registerRoutes():void
- {
- if (!current_user_can('manage_options')) {
- return;
- }
- register_rest_route($this->namespace, '/myster', [
- [
- 'methods' => 'GET',
- 'callback' => [$this, 'getItems'],
- 'permission_callback' => [$this, 'checkPermission']
- ],
- [
- 'methods' => 'POST',
- 'callback' => [$this, 'updateItems'],
- 'permission_callback' => [$this, 'checkPermission']
- ]
- ]);
-
- register_rest_route($this->namespace, '/admin-action', [
- [
- 'methods' => 'POST',
- 'callback' => [$this, 'adminAction'],
- 'permission_callback' => [$this, 'checkPermission']
- ]
- ]);
- }
-
- /**
- * @param WP_REST_Request $request The Request object
- *
- * @return bool whether or not we can proceed
- */
- public function checkPermission(WP_REST_Request $request):bool
- {
- if (!current_user_can('manage_options')) {
- return false;
- }
- $this->verifyNonce($request, 'wp_rest');
- if ($this->action!=='') {
- $this->verifyNonce($request, $this->action . get_current_user_id(), $request->get_header('action_nonce'));
- }
- return true;
- }
-
- /**
- * Handles admin actions from the custom WP Admin pages.
- * Extended by other managers that register admin subpages
- * @param WP_REST_Request $request
- *
- * @return WP_REST_Response
- */
- public function adminAction(WP_REST_Request $request):WP_REST_Response
- {
- error_log('Request Params: '.print_r($request->get_param('action'), true));
- return apply_filters(
- BASE.'admin_action_filter',
- new WP_REST_Response([
- 'success' => false,
- 'message' => 'No filters found'
- ]),
- $request,
- sanitize_text_field($request->get_param('action'))
- );
- }
-
- /**
- * @param WP_REST_Request $request Request object
- *
- * @return WP_REST_Response
- */
- public function updateItems(WP_REST_Request $request):WP_REST_Response
- {
- error_log('Received Request: '.print_r($request->get_params(), true));
- $content = $request->get_param('content');
- if (!$this->checkContent($content, true)) {
- return new WP_REST_Response([
- 'sucess' => false,
- 'message' => 'Invalid attempt'
- ]);
- }
- $data = $request->get_param('data');
- if (empty($data)) {
- return new WP_REST_Response([
- 'success' => true,
- 'message' => 'Nothing to Update'
- ]);
- }
-
- $type = match ($content) {
- 'shop',
- 'artform',
- 'type',
- 'media',
- 'artstyle',
- 'arttheme',
- 'city',
- 'colour',
- 'offerfor',
- 'pstyle',
- 'style',
- 'theme' => 'term',
- default => 'post',
- };
- $errors = [];
- $success = [];
- error_log('Data: '.print_r($data, true));
-
- foreach ($data as $ID => $fields) {
- $ID = (int)$ID;
- $meta = new MetaManager($ID, $type);
- $allFields = JVB()->getFields($content);
- foreach ($fields as $name => $value) {
- $process = true;
- if (!array_key_exists($name, $allFields)) {
- $errors[$ID][$name] = __('Field not found', 'jvb');
- $process = false;
- }
- error_log('Proceeding as Normal...');
- //Switch between config types to extract values for special cases
- $config = $allFields[$name];
-
- switch ($config['type']) {
- case 'radio':
- //Ensure we only chose one option
- $temp = explode(',', $value);
- $value = trim($temp[0]);
- if (!in_array($value, $config['options'])) {
- $errors[$ID][$name] = __('Invalid Option', 'jvb');
- $process = false;
- }
- break;
- case 'set':
- $temp = explode(',', $value);
- $value = array_map('trim', $temp);
- break;
- case 'repeater':
- error_log('Repeater: '.print_r($name, true));
- $single = false;
- switch ($name) {
- case 'keywords':
- $single = 'keyword';
- break;
- case 'languages':
- $single = 'language';
- break;
- case 'links':
- $single = 'url';
- break;
- case 'followers':
- $single = 'count';
- break;
- }
- $items = array_keys($config['fields']);
-
- //First, separate out any ]
- if (strpos($value, ']')) {
- $rows = array_map(function ($item) {
- return str_replace('[', '', $item);
- }, explode(']', $value));
- array_pop($rows);
- error_log('Rows: '.print_r($rows, true));
- $value = [];
- foreach ($rows as $index => $row) {
- $r = array_map('trim', explode(',', $row));
- if (count($r) !== count($items)) {
- $errors[$ID][$name] = __('Not enough fields set. May not save correctly', 'jvb');
- }
- //attempt to save fields
- $new = [];
- foreach ($items as $key => $i) {
- $new[$i] = (array_key_exists($key, $r)) ? $r[$key] : (($i==='checked') ? date('Y-m-d'): '');
- }
- $value[] = $new;
- }
- error_log('Processed Repeater Value for sanitizing: '.print_r($value, true));
-
- } elseif ($value === '') {
- $value = [];
- } else {
- if (!$single) {
- $errors[$ID][$name] = __('Must set single key', 'jvb');
- $process = false;
- }
- $rows = array_map('trim', explode(',', $value));
- $value = [];
- foreach ($rows as $row) {
- $new = [];
- foreach ($items as $i) {
- $new[$i] = ($i === $single) ? $row : (($i==='checked') ? date('Y-m-d') : '');
- }
- error_log('New Repeater Row Output: '.print_r($new, true));
- $value[] = $new;
- }
- }
-
- break;
- }
-
- switch ($name) {
- case 'shop':
- case 'type':
- case 'city':
- $terms = array_map('trim', explode(',', $value));
- $set = [];
- foreach ($terms as $term) {
- $t = get_term_by('name', $value, BASE.$name);
- if (!$t) {
- $errors[$ID][$name][] = __($value.' does not exist yet', 'jvb');
- } else {
- $set[] = $t->term_id;
- }
- }
- if (!empty($set)) {
- $result = wp_set_post_terms($ID, $set, BASE.$name);
- $result = is_array($result);
- }
- break;
- case 'owner':
- error_log('Processing Owner Request from Admin Routes...');
- $users = array_map('trim', explode(',', $value));
- foreach ($users as $user) {
- $t = jvbGetUserByDisplayName($user)??jvbGetUserByFirstName($user)??false;
- error_log('Got user: '.print_r($t, true));
- if (!$t) {
- $errors[$ID][$name][] = __($value.' does not exist yet...', 'jvb');
- $result = false;
- } else {
- $result = JVB()->routes('shop')->setShopOwner($t->ID, $ID, true);
- }
- }
- break;
- case 'managers':
- error_log('Processing Manager Request from Admin Routes...');
- $users = array_map('trim', explode(',', $value));
- foreach ($users as $user) {
- $t = jvbGetUserByDisplayName($user)??jvbGetUserByFirstName($user)??false;
- error_log('Got user: '.print_r($t, true));
- if (!$t) {
- $result = $errors[$ID][$name][] = __($value.' does not exist yet...', 'jvb');
- } else {
- $result = JVB()->routes('shop')->setShopManager($t->ID, $ID, true);
- }
- }
- break;
- case 'location':
- $value = [
- 'address' => $value,
- 'lat' => '',
- 'lng' => '',
- ];
- $result = $meta->updateValue($name, $value);
- break;
- case 'hours':
- $temp = [];
- foreach ($value as $key => $v) {
- if (strpos($v['days'], '-')) {
- $temp[$key]['days'] = jvbExpandDayRange($v['days']);
- $temp[$key]['time_open'] = $v['time_open'];
- $temp[$key]['time_closes'] = $v['time_closes'];
- }
- }
- $value = $temp;
- error_log('Final Hours for processing: '.print_r($value, true));
- $result = $meta->updateValue($name, $value);
- break;
- default:
- $result = $meta->updateValue($name, $value);
- break;
- }
-
- //Save the value
- if ($result) {
- $success[$ID][] = $name;
- } else {
- $errors[$ID][] = [
- $name => 'Could not update value'
- ];
- }
- }
- }
- return new WP_REST_Response([
- 'success' => true,
- 'successful' => $success,
- 'errors' => $errors
- ]);
- }
-
- /**
- * @param array $filters
- *
- * @return array
- */
- protected function checkFilters(array $filters)
- {
- global $karma;
- $out = [];
- foreach ($filters as $type => $value) {
- if (!array_key_exists($type, $karma)) {
- continue;
- }
- $out[$type] = jvbSanitizeIDList($value);
- }
- return $out;
- }
-
- /**
- * @param WP_REST_Request $request The REST Request
- *
- * @return array
- */
- protected function buildParams(WP_REST_Request $request):array
- {
- $data = $request->get_params();
- $this->setMetaType($data['content']);
- switch ($this->metaType) {
- case 'post':
- $key = 'post_type';
- break;
- case 'term':
- $key = 'taxonomy';
- break;
- case 'user':
- $key = 'role';
- break;
- default:
- return [];
- }
- global $jvb_everything;
- return [
- $key => (array_key_exists('content', $data) &&
- array_key_exists($data['content'], $jvb_everything)) ?
- BASE.$data['content'] : BASE.'artist',
- 'order' => (array_key_exists('order', $data) &&
- in_array(strtolower($data['order']), ['asc', 'desc'])) ?
- strtolower($data['order']) : 'desc',
- 'orderby' => (array_key_exists('orderby', $data) &&
- in_array($data['orderby'], ['name', 'date', 'followers', 'karma'])) ?
- $data['orderby'] : 'name',
- 'paged' => (array_key_exists('page', $data) && is_numeric($data['page'])) ?
- (int)$data['page'] : 1,
- 'filters' => (array_key_exists('filters', $data)) ?
- $this->checkFilters($data['filters']) : null,
- ];
- }
-
- protected function setMetaType(string $type):void
- {
- $this->metaType = match (true) {
- array_key_exists($type, JVB_CONTENT) => 'post',
- array_key_exists($type, JVB_TAXONOMY) => 'term',
- array_key_exists($type, JVB_USER) => 'user',
- default => false,
- };
- }
-
- /**
- * @param WP_REST_Request $request The REST Request
- *
- * @return WP_REST_Response
- */
- public function getItems(WP_REST_Request $request):WP_REST_Response
- {
-
- $args = $this->buildParams($request);
- $key = $this->cache->generateKey($args);
-
- $cache = $this->cache->get($key);
- if ($cache) {
- return new WP_REST_Response($cache);
- }
-
- $this->content = $request->get_param('content');
- $args['posts_per_page'] = 50;
-
- $this->fields = jvbGetFields($this->content);
-// $this->fields = array_filter(JVB()->getFields($data['content']), function($arr){
-// return array_key_exists('quickEdit', $arr);
-// });
-
- $response = match ($this->metaType) {
- 'post' => $this->getPostItems($args),
- 'term' => $this->getTermItems($args),
- 'user' => $this->getUserItems($args),
- default => []
- };
-
- $this->cache->set($key, $response);
- return new WP_REST_Response($response);
- }
-
- /**
- * @param array $args the data from buildParams method
- *
- * @return array
- */
- protected function getTermItems(array $args):array
- {
- if (isJVBContentTax($args['taxonomy'])) {
- return $this->getContentTypeTaxItems($args);
- }
- // Build query arguments
- $args = array_merge($args, [
- 'hide_empty' => false,
- 'number' => $args['posts_per_page'],
- 'offset' => ($args['paged'] - 1) * $args['posts_per_page'],
- 'fields' => 'ids'
- ]);
-
- // Add ordering
- switch ($args['orderby']) {
- case 'date':
- $args['orderby'] = 'id'; // Terms don't have date, so use ID as a proxy
- break;
- case 'karma':
- // Terms should be ordered by meta value
- $args['orderby'] = 'meta_value_num';
- $args['meta_key'] = BASE . 'karma';
- break;
- default:
- $args['orderby'] = 'name';
- break;
- }
- $args['order'] = strtoupper($args['order']);
-
- // Add any filters
- if (!empty($args['filters'])) {
- // Term meta filtering would go here
- $meta_query = [];
-
- foreach ($args['filters'] as $filter_type => $filter_values) {
- if (!empty($filter_values)) {
- // Example: filter by parent terms
- if ($filter_type === 'parent') {
- $args['parent'] = $filter_values[0]; // Assume single parent filter
- } else {
- // For meta-based filters
- $meta_query[] = [
- 'key' => BASE . $filter_type,
- 'value' => $filter_values,
- 'compare' => 'IN'
- ];
- }
- }
- }
-
- if (!empty($meta_query)) {
- $args['meta_query'] = $meta_query;
- }
- }
- unset($args['filters']);
-
- // Get count first for pagination info
- $count_args = $args;
- unset($count_args['number']);
- unset($count_args['offset']);
- $count_args['fields'] = 'count';
- $total_items = get_terms($count_args);
-
- // Get the actual terms
- $term_ids = get_terms($args);
-
- // Error handling
- if (is_wp_error($term_ids)) {
- return [
- 'items' => [],
- 'has_more' => false,
- 'total_items' => 0,
- 'total_pages' => 0,
- 'error' => $term_ids->get_error_message()
- ];
- }
-
- // Format each term
- $items = array_map([$this, 'formatItem'], $term_ids);
-
- return [
- 'items' => $items,
- 'has_more' => ($total_items > ($args['offset'] + $args['number'])),
- 'total_items' => (int)$total_items,
- 'total_pages' => ceil($total_items / $args['posts_per_page'])
- ];
- }
-
- protected function getUserItems(array $args):array
- {
- return [];
- }
-
- /**
- * @param array $data the $data built by buildParams
- *
- * @return array
- */
- protected function getContentTypeTaxItems(array $data):array
- {
- global $wpdb;
- $table_name = $wpdb->prefix . BASE . jvbNoBase($data['taxonomy']);
-
- // Start building the query to get just the term_ids with proper ordering
- $sql_select = "SELECT s.term_id";
- $sql_from = " FROM {$table_name} AS s";
- $sql_where = " WHERE 1=1";
- $sql_orderby = "";
- $sql_limit = "";
-
- $params = [];
-
- // Add filters if any
- if (!empty($data['filters'])) {
- foreach ($data['filters'] as $filter_type => $filter_values) {
- if ($filter_type === 'city' && !empty($filter_values)) {
- $placeholders = implode(',', array_fill(0, count($filter_values), '%d'));
- $sql_where .= $wpdb->prepare(" AND s.city IN ($placeholders)", ...$filter_values);
- }
- // Add more filter conditions for other fields as needed
- }
- }
-
- // Determine ORDER BY clause - here we can use any column from the custom table
- $valid_order_columns = ['name', 'updated_at', 'established_year', 'karma'];
- $orderby_column = in_array($data['orderby'], $valid_order_columns) ? $data['orderby'] : 'name';
- $sql_orderby = " ORDER BY s." . $orderby_column;
-
- // Validate order direction
- $order_direction = strtoupper($data['order']) === 'DESC' ? 'DESC' : 'ASC';
- $sql_orderby .= " " . $order_direction;
-
- // Calculate offset for pagination
- $per_page = absint($data['posts_per_page']);
- $page = max(1, absint($data['paged']));
- $offset = ($page - 1) * $per_page;
-
- // Add pagination with prepared statement
- $sql_limit = $wpdb->prepare(" LIMIT %d OFFSET %d", $per_page, $offset);
-
- // Count query (without limit clause)
- $count_sql = "SELECT COUNT(s.term_id)" . $sql_from . $sql_where;
- $total_items = $wpdb->get_var($count_sql);
-
- // Final query with all components
- $sql = $sql_select . $sql_from . $sql_where . $sql_orderby . $sql_limit;
-
- // Execute the query
- $shop_term_ids = $wpdb->get_col($sql);
-
- // Now get the full term objects for these IDs
- $items = [];
- foreach ($shop_term_ids as $shop_term_id) {
- $items[] = $this->formatItem($shop_term_id);
- }
-
- return [
- 'items' => $items,
- 'has_more' => ($total_items > ($offset + $per_page)),
- 'total_items' => (int)$total_items,
- 'total_pages' => ceil($total_items / $per_page)
- ];
- }
-
- /**
- * @param array $args array returned by buildParams
- *
- * @return array
- */
- protected function getPostItems(array $args):array
- {
- $args['fields'] = 'ids';
- $args['post_status'] = ['draft', 'trash', 'publish'];
-
- $query = new WP_Query($args);
- $items = array_map([$this, 'formatItem'], $query->posts);
-
- return [
- 'items' => $items,
- 'has_more' => $query->max_num_pages > $args['paged'],
- 'total_items' => $query->found_posts,
- 'total_pages' => $query->max_num_pages
- ];
- }
-
- /**
- * @param int $ID the ID of the item to format
- *
- * @return array
- */
- protected function formatItem(int $ID):array
- {
- $meta = new MetaManager($ID, $this->metaType);
- $item = [
- 'id' => $ID,
- 'public' => !($this->metaType === 'post') || get_post_status($ID) === 'publish',
- ];
-
- $hierarchical = false;
- if ($this->metaType === 'term') {
- $term = get_term($ID);
- if ($term) {
- $hierarchical = is_taxonomy_hierarchical($term->taxonomy);
- }
- }
-
- foreach ($this->fields as $key => $config) {
- switch ($key) {
- case 'type':
- case 'city':
- case 'shop':
- $terms = get_the_terms($ID, BASE.$key);
- $value = [];
- if ($terms && !is_wp_error($terms)) {
- foreach ($terms as $term) {
- $value[] = htmlspecialchars_decode($term->name);
- }
- }
- $item[$key] = implode(', ', $value);
- break;
- default:
- $item[$key] = $meta->getValue($key);
- break;
- }
-
- switch ($config['type']) {
- case 'repeater':
- if (empty($item[$key]) || !is_array($item[$key])) {
- $item[$key] = '';
- } else {
- $temp = '';
- foreach ($item[$key] as $row) {
- if (is_array($row)) {
- // Format each row as [value1,value2,value3]
- $rowValues = array_values($row);
- $temp .= '[' . implode(',', $rowValues) . ']';
- } else {
- // Handle simpler cases where rows might not be arrays
- $temp .= '[' . $row . ']';
- }
- }
- $item[$key] = $temp;
- }
- break;
- case 'location':
- $item[$key] = $item[$key]['address'];
- break;
- }
- if ($hierarchical && $key === 'term_name') {
- $item[$key.'_path'] = JVB()->routes('term')->getTermPath($ID, html_entity_decode($term->name), $term->taxonomy);
- }
- }
-
- error_log('Item: '.print_r($item, true));
- return $item;
- }
-}
diff --git a/inc/rest/routes/ApprovalRoutes.php b/inc/rest/routes/ApprovalRoutes.php
index 62fc5e4..e01ffd5 100644
--- a/inc/rest/routes/ApprovalRoutes.php
+++ b/inc/rest/routes/ApprovalRoutes.php
@@ -2,11 +2,12 @@
namespace JVBase\rest\routes;
-use JVBase\JVB;
-use JVBase\rest\RestRouteManager;
-use JVBase\managers\Cache;
+use JVBase\managers\CustomTable;
+use JVBase\rest\PermissionHandler;
+use JVBase\rest\Rest;
+use JVBase\rest\Route;
+use JVBase\rest\Response;
use JVBase\utility\Features;
-use WP_User;
use WP_REST_Request;
use WP_REST_Response;
use Exception;
@@ -15,20 +16,18 @@
exit; // Exit if accessed directly
}
-class ApprovalRoutes extends RestRouteManager
+class ApprovalRoutes extends Rest
{
protected array $userTypes;
protected array $termTypes;
protected array $allTypes;
- protected array $requestTables;
- protected array $voteTables;
protected int $expiryDays = 7;
protected bool $hasMemberApproval = false;
public function __construct()
{
- $this->cache_name = 'approvals';
+ $this->cacheName = 'approvals';
$this->hasMemberApproval = Features::forMembership()->has('member_verified');
parent::__construct();
@@ -63,37 +62,28 @@
public function registerRoutes():void
{
- register_rest_route($this->namespace, '/approvals', [
- [
- 'methods' => 'GET',
- 'callback' => [ $this, 'getApprovals' ],
- 'permission_callback' => [ $this, 'checkPermission' ]
- ],
- [
- 'methods' => 'POST',
- 'callback' => [ $this, 'handleApprovalAction' ],
- 'permission_callback' => [ $this, 'checkPermission' ]
- ]
- ]);
+ Route::for('approvals')
+ ->get([$this, 'getApprovals'])
+ ->args([
+ 'user' => 'integer|required',
+ 'type' => 'string',
+ 'status' => 'string|enum:pending,approved,rejected,expired',
+ ])
+ ->auth(PermissionHandler::combine(['user', 'verified']))
+ ->rateLimit(30)
+ ->post([$this, 'handleAction'])
+ ->args([
+ 'user' => 'integer|required',
+ 'request_id' => 'integer|required',
+ 'action' => 'string|required|enum:approve,reject',
+ 'type' => 'string|required',
+ 'notes' => 'string',
+ ])
+ ->auth(PermissionHandler::combine(['user', 'verified']))
+ ->rateLimit(3);
}
/**
- * @param WP_REST_Request $request The REST request
- *
- * @return bool
- */
- public function checkPermission(WP_REST_Request $request):bool
- {
- $userID = get_current_user_id();
- if (!user_can($userID, 'skip_moderation')) {
- return false;
- }
-
- return parent::checkPermission($request);
- }
-
-
- /**
* Handler for user registration
*
* @param int $user_id New user ID
@@ -101,24 +91,18 @@
*
* @return void
*/
- public function handleNewUserRegistration(int $user_id, object $user):void
- {
+ public function handleNewUserRegistration(int $user_id, object $user): void
+ {
$intersect = array_intersect(
- array_map(
- function ($role) {
- return BASE.$role;
- },
- $this->userTypes
- ),
+ array_map(fn($role) => BASE.$role, $this->userTypes),
(array) $user->roles
);
- if (!empty($intersect)) {
- // Mark as unverified initially
- $user->add_cap('skip_moderation', false);
- // Create approval request
- $this->createArtistApprovalRequest($user_id);
- }
- }
+
+ if (!empty($intersect)) {
+ $user->add_cap('skip_moderation', false);
+ $this->createArtistApprovalRequest($user_id);
+ }
+ }
/**
@@ -126,216 +110,170 @@
*
* @return WP_REST_Response
*/
- public function handleApprovalAction(WP_REST_Request $request):WP_REST_Response
+ public function handleAction(WP_REST_Request $request):WP_REST_Response
{
- $data = $request->get_params();
- $request_id = $data['request_id'] ?? 0;
- $user_id = (array_key_exists('user', $data) &&
- is_numeric($data['user'])) ?
- (int) $data['user'] : get_current_user_id();
- $action = (array_key_exists('action', $data) && in_array($data['action'], [
- 'approve',
- 'reject'
- ])) ? $data['action'] : false;
+ $data = $request->get_params();
+ $request_id = absint($data['request_id']);
+ $user_id = absint($data['user']);
+ $action = sanitize_text_field($data['action']);
+ $type = sanitize_text_field($data['type']);
+ $notes = sanitize_text_field($data['notes'] ?? '');
- $type = (array_key_exists('type', $data) &&
- in_array($data['type'], $this->allTypes)) ?
- $data['type'] :
- false;
- $notes = (array_key_exists('notes', $data)) ? sanitize_text_field($data['notes']) : '';
+ if (!in_array($type, $this->allTypes)) {
+ return Response::validationError(['message' => 'Invalid type']);
+ }
- if ($action && $request_id !== 0 && $type) {
- $result = $this->handleVote($type, $action, $request_id, $user_id, $notes);
- return new WP_REST_Response([
- 'success' => $result,
- 'message' => $result ? 'Vote recorded successfully' : 'Failed to record vote'
- ], $result ? 200 : 500);
- }
- return new WP_REST_Response([
- 'success' => false,
- 'message' => 'Invalid action or request ID'
- ], 400);
+ $result = $this->handleVote($type, $action, $request_id, $user_id, $notes);
+
+ return $result
+ ? Response::success(['message' => 'Vote recorded successfully'])
+ : Response::error('Failed to record vote');
}
- protected function getRequestTable(string $type, string $prefix):string
- {
- return match ($type) {
- 'term' => $prefix . BASE . 'approval_term_requests',
- default => $prefix . BASE . 'approval_' . $type . '_requests',
- };
- }
- protected function getVoteTable(string $type, string $prefix):string
- {
- return match ($type) {
- 'term' => $prefix . BASE . 'approval_term_votes',
- default => $prefix . BASE . 'approval_' . $type . '_votes',
- };
- }
/**
* Artist and Term Approvals
*/
- protected function handleVote(string $type, string $vote, int $request_id, int $user_id, string $notes = ''):bool
- {
- if (!in_array($vote, ['approve', 'reject'])) {
- return false;
- }
- global $wpdb;
- $table = $this->getRequestTable($type, $wpdb->prefix);
- $votes = $this->getVoteTable($type, $wpdb->prefix);
+ protected function handleVote(string $type, string $vote, int $request_id, int $user_id, string $notes = ''): bool
+ {
+ if (!in_array($vote, ['approve', 'reject'])) {
+ return false;
+ }
+ $requestTable = $this->getTableName($type, 'requests');
+ $voteTable = $this->getTableName($type, 'votes');
- try {
- $request = $wpdb->get_row($wpdb->prepare(
- "SELECT * FROM $table WHERE id = %d",
- $request_id
- ));
- if (!$request || $request->status !== 'pending') {
- throw new Exception("Invalid approval request");
- }
+ $requests = CustomTable::for($requestTable);
+ $votes = CustomTable::for($voteTable);
- $already_voted = $wpdb->get_row($wpdb->prepare(
- "SELECT * FROM $votes WHERE request_id = %d AND user_id = %d",
- $request_id,
- $user_id
- ));
+ 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 ($already_voted && $already_voted->vote !== $vote) {
- $wpdb->update(
- $votes,
- [
- 'vote' => $vote,
- ],
- [
- 'id' => $already_voted->id
- ]
- );
- return true;
- } elseif ($already_voted) {
- throw new Exception("User has already voted on this request");
- }
+ if (!$request || $request->status !== 'pending') {
+ throw new Exception("Invalid approval request");
+ }
- $result = $wpdb->insert(
- $votes,
- [
- 'request_id' => $request_id,
- 'user_id' => $user_id,
- 'vote' => $vote,
- 'notes' => $notes,
- 'created_at' => current_time('mysql')
- ]
- );
- if (!$result) {
- throw new Exception("Failed to record vote");
- }
+ // Check if user already voted
+ $existingVote = $votes->where([
+ 'request_id' => $request_id,
+ 'user_id' => $user_id
+ ])->first();
- $user = get_userdata($user_id);
- if ($vote === 'approve') {
- $approvers = json_decode($request->approved_by, true)?:[];
+ 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");
+ }
- $approvers[$user_id] = [
- 'name' => $user->display_name,
- 'voted' => current_time('mysql')
- ];
- $wpdb->update(
- $table,
- [
- 'current_approvals' => $request->current_approvals + 1,
- 'updated_at' => current_time('mysql'),
- 'approved_by' => $approvers,
- 'expires_at' => $this->rebuildExpiryDate()
- ],
- [
- 'id' => $request_id
- ]
- );
- if ($request->current_approvals + 1 >= $request->required_approvals) {
- switch ($type) {
- case 'user':
- case 'artist':
- $this->completeVerification($request_id);
- break;
- case 'term':
- $this->makeTermLive($request);
- break;
- }
- }
- } elseif ($vote === 'reject') {
- $rejecters = json_decode($request->rejected_by, true)?:[];
+ // Insert new vote
+ $votes->create([
+ 'request_id' => $request_id,
+ 'user_id' => $user_id,
+ 'vote' => $vote,
+ 'notes' => $notes,
+ ]);
- $rejecters[$user_id] = [
- 'name' => $user->display_name,
- 'voted' => current_time('mysql')
- ];
- $wpdb->update(
- $table,
- [
- 'current_rejections' => $request->current_rejections + 1,
- 'rejected_by' => $rejecters,
- 'updated_at' => current_time('mysql'),
- 'expires_at' => $this->rebuildExpiryDate()
- ],
- [
- 'id' => $request_id
- ]
- );
- if ($request->current_rejections + 1 >= $request->required_approvals) {
- switch ($type) {
- case 'user':
- case 'artist':
- $this->denyVerification($request_id);
- break;
- case 'term':
- $this->makeTermUnalive($request);
- break;
- }
- }
- }
+ // Update request based on vote type
+ $user = get_userdata($user_id);
- $wpdb->query('COMMIT');
+ 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) {
- $wpdb->query('ROLLBACK');
+ return true;
+ });
+ } catch (Exception $e) {
+ $this->logError('handleVote', [
+ 'error' => $e->getMessage(),
+ 'user_id' => $user_id,
+ 'request_id' => $request_id,
+ 'vote' => $vote
+ ]);
+ return false;
+ }
+ }
- JVB()->error()
- ->log(
- '[ApprovalRoutes]:handleVote',
- "Error creating '.$type.' approval request: " . $e->getMessage(),
- [
- 'user_id' => $user_id,
- 'request_id' => $request_id,
- 'vote' => $vote
- ]
- );
- return false;
- }
- }
+ /**
+ * 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):bool|int
- {
- global $wpdb;
+ protected function createApprovalRequest(string $type, array $request): int
+ {
+ $tableName = $this->getTableName($type, 'requests');
- $table = $this->getRequestTable($type, $wpdb->prefix);
+ $id = CustomTable::for($tableName)->create($request);
- $result = $wpdb->insert(
- $table,
- $request
- );
+ if (!$id) {
+ throw new Exception('Failed to create approval request');
+ }
- if (!$result) {
- throw new Exception($wpdb->last_error);
- }
- return $wpdb->insert_id;
- }
+ return $id;
+ }
/*************
* Artist Approvals
@@ -347,60 +285,45 @@
*
* @return int|false Request ID or false on failure
*/
- public function createArtistApprovalRequest(int $user_id):int|false
- {
- global $wpdb;
- $wpdb->query('START TRANSACTION');
+ /**
+ * 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 {
- //Check for existing first
- $table = $this->getRequestTable(jvbUserRole($user_id), $wpdb->prefix);
+ try {
+ return $table->transaction(function($table) use ($user_id) {
+ // Check for existing request
+ $existing = $table->where(['user_id' => $user_id])->first();
- // Verify this is not a duplicate request
- $existing = $wpdb->get_var($wpdb->prepare(
- "SELECT id FROM $table
- WHERE user_id = %d",
- $user_id
- ));
+ if ($existing) {
+ return $existing->id;
+ }
- if ($existing) {
- return $existing;
- }
+ $user_data = get_userdata($user_id);
- $user_data = get_userdata($user_id);
- $request = [
- 'user_id' => $user_id,
- 'status' => 'pending',
- 'expires_at' => date('Y-m-d H:i:s', strtotime('+30 days')),
- 'created_at' => current_time('mysql'),
- 'updated_at' => current_time('mysql'),
- 'name' => $user_data->display_name,
- 'email' => $user_data->user_email,
- ];
-
- $result = $this->createApprovalRequest('user', $request);
-
- if (!$result) {
- throw new Exception($wpdb->last_error);
- }
-
- $wpdb->query('COMMIT');
-
- return $result;
- } catch (Exception $e) {
- $wpdb->query('ROLLBACK');
- JVB()->error()
- ->log(
- '[ApprovalRoutes]:createArtistApprovalRequest',
- "Error creating artist approval request: " . $e->getMessage(),
- [
- 'user_id' => $user_id,
- ]
- );
-
- return false;
- }
- }
+ 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
@@ -475,154 +398,57 @@
return $this->handleVote(jvbUserRole($user_id), $vote, $request_id, $user_id, $notes);
}
- /**
- * Mark an artist as verified after receiving required approvals
- *
- * @param int $request_id The approval request ID
- *
- * @return bool Success status
- */
- public function completeVerification(int $request_id):bool
- {
- global $wpdb;
- $approval_table = $wpdb->prefix . $this->userRequests;
+ /**
+ * Complete verification - REFACTORED
+ */
+ protected function completeVerification(int $request_id, string $type = 'artist'): void
+ {
+ $tableName = $this->getTableName($type, 'requests');
+ $table = CustomTable::for($tableName);
- // Get the request details
- $request = $wpdb->get_row($wpdb->prepare(
- "SELECT * FROM $approval_table WHERE id = %d",
- $request_id
- ));
+ $table->where(['id' => $request_id])->updateResults([
+ 'status' => 'approved',
+ 'approved_at' => current_time('mysql')
+ ]);
- if (!$request || $request->status !== 'pending') {
- return false;
- }
+ $request = $table->where(['id' => $request_id])->first();
- // Check if enough approvals have been collected
- if ($request->current_approvals < $request->required_approvals) {
- return false;
- }
+ if ($request && $request->user_id) {
+ $user = new \WP_User($request->user_id);
+ $user->add_cap('skip_moderation', true);
- // Start a transaction
- $wpdb->query('START TRANSACTION');
+ JVB()->notification()->addNotification(
+ $request->user_id,
+ 'approval_granted',
+ ['message' => 'Your account has been verified!']
+ );
+ }
- try {
- // Get the user ID from the request
- $user_id = $request->user_id;
+ $this->cache->flush();
+ }
- $this->verifyArtist($user_id, $request->current_approvals);
+ protected function denyVerification(int $request_id, string $type = 'artist'): void
+ {
+ $tableName = $this->getTableName($type, 'requests');
+ $table = CustomTable::for($tableName);
- // Update the request status
- $updated = $wpdb->update(
- $approval_table,
- [
- 'status' => 'approved',
- 'updated_at' => current_time('mysql')
- ],
- [ 'id' => $request_id ]
- );
+ $table->where(['id' => $request_id])->updateResults([
+ 'status' => 'rejected',
+ 'rejected_at' => current_time('mysql')
+ ]);
- if ($updated === false) {
- throw new Exception("Failed to update approval request status");
- }
+ $request = $table->where(['id' => $request_id])->first();
- // Notify the user they've been verified
- JVB()->notification()->addNotification(
- $user_id,
- 'artist_approved',
- [
- 'request_id' => $request_id,
- 'approval_date' => current_time('mysql')
- ]
- );
+ if ($request && $request->user_id) {
+ JVB()->notification()->addNotification(
+ $request->user_id,
+ 'approval_denied',
+ ['message' => 'Your verification request was not approved.']
+ );
+ }
- $wpdb->query('COMMIT');
-
- return true;
- } catch (Exception $e) {
- $wpdb->query('ROLLBACK');
- JVB()->error()
- ->log(
- '[ApprovalRoutes]:completeVerification',
- "Error verifying user: " . $e->getMessage(),
- [
- 'user_id' => $user_id,
- ]
- );
-
- return false;
- }
- }
-
- public function denyVerification(int $request_id):bool
- {
- global $wpdb;
- $approval_table = $wpdb->prefix . $this->userRequests;
-
- // Get the request details
- $request = $wpdb->get_row($wpdb->prepare(
- "SELECT * FROM $approval_table WHERE id = %d",
- $request_id
- ));
-
- if (!$request || $request->status !== 'pending') {
- return false;
- }
-
- // Check if enough approvals have been collected
- if ($request->current_rejections < $request->required_approvals) {
- return false;
- }
-
- // Start a transaction
- $wpdb->query('START TRANSACTION');
-
- try {
- // Get the user ID from the request
- $user_id = $request->user_id;
-
- $this->unverifyArtist($user_id, $request->rejected_by);
-
- // Update the request status
- $updated = $wpdb->update(
- $approval_table,
- [
- 'status' => 'rejected',
- 'updated_at' => current_time('mysql')
- ],
- [ 'id' => $request_id ]
- );
-
- if ($updated === false) {
- throw new Exception("Failed to update approval request status");
- }
-
- // Notify the user they've been verified
- JVB()->notification()->addNotification(
- $user_id,
- 'artist_rejected',
- [
- 'request_id' => $request_id,
- 'approval_date' => current_time('mysql')
- ]
- );
-
- $wpdb->query('COMMIT');
-
- return true;
- } catch (Exception $e) {
- $wpdb->query('ROLLBACK');
- JVB()->error()
- ->log(
- '[ApprovalRoutes]:denyVerification',
- "Error removing artist verification status: " . $e->getMessage(),
- [
- 'user_id' => $user_id
- ]
- );
-
- return false;
- }
- }
+ $this->cache->flush();
+ }
/**
* Get verification details for a request
@@ -632,40 +458,35 @@
*
* @return array|false Verification details or false if not verified
*/
- public function getVerificationDetails(int $requestID, string $type):array|false
- {
- global $wpdb;
+ public function getVerificationDetails(int $requestID, string $type): array|false
+ {
+ $requestTable = CustomTable::for($this->getTableName($type, 'requests'));
+ $voteTable = CustomTable::for($this->getTableName($type, 'votes'));
- $approval_table = $this->getRequestTable($type, $wpdb->prefix);
- $votes_table = $this->getVoteTable($type, $wpdb->prefix);
+ $request = $requestTable->where(['id' => $requestID])->first(ARRAY_A);
- // Get the approval request
- $request = $wpdb->get_row($wpdb->prepare(
- "SELECT * FROM $approval_table
- WHERE id = %d
- ORDER BY updated_at DESC",
- $requestID
- ), ARRAY_A);
+ if (!$request) {
+ return false;
+ }
- if (!$request) {
- return false;
- }
+ // Get the votes for this request
+ $votes = $voteTable
+ ->where(['request_id' => $request['id']])
+ ->orderBy('created_at', 'ASC')
+ ->getResults(ARRAY_A);
- // Get the votes for this request
- $votes = $wpdb->get_results($wpdb->prepare(
- "SELECT v.*, u.display_name as approver_name
- FROM $votes_table v
- WHERE v.request_id = %d
- ORDER BY v.created_at",
- $request['id']
- ), ARRAY_A);
+ // 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';
+ }
- return [
- 'request' => $request,
- 'votes' => $votes,
- 'verification_date' => $request['updated_at'],
- ];
- }
+ return [
+ 'request' => $request,
+ 'votes' => $votes,
+ 'verification_date' => $request['updated_at'],
+ ];
+ }
/*************
* Term Approvals
@@ -682,80 +503,69 @@
*
* @return boolean Success or failure
*/
- protected function makeTermLive(object $request):bool
- {
- global $wpdb;
+ protected function makeTermLive(object $request): bool
+ {
+ try {
+ $taxonomy = $request->taxonomy;
+ $term_name = $request->name;
+ $parent = $request->parent;
- try {
- // Get term data from request
- $taxonomy = $request->taxonomy;
- $term_name = $request->name;
- $parent = $request->parent;
+ $result = wp_insert_term($term_name, $taxonomy, [
+ 'parent' => $parent
+ ]);
- $result = wp_insert_term($term_name, $taxonomy, [
- 'parent' => $parent
- ]);
+ if (is_wp_error($result)) {
+ throw new Exception($result->get_error_message());
+ }
- if (is_wp_error($result)) {
- throw new Exception($result->get_error_message());
- }
- $term_id = $result['term_id'];
+ $term_id = $result['term_id'];
- $table = $this->getRequestTable('term', $wpdb);
- // Update request status
- $wpdb->update(
- $table,
- [
- 'status' => 'approved',
- 'updated_at' => current_time('mysql'),
- 'created_term' => $term_id
- ],
- [ 'id' => $request->id ]
- );
+ // Update request status
+ CustomTable::for($this->getTableName('term', 'requests'))
+ ->where(['id' => $request->id])
+ ->updateResults([
+ 'status' => 'approved',
+ 'created_term' => $term_id
+ ]);
- $userIDs = [];
- $approvedBy = [];
- $approvors = json_decode($request->approved_by, true) ?: [];
- $requesters = json_decode($request->requested_by, true) ?: [];
- $rejectors = json_decode($request->rejected_by, true) ?: [];
- foreach (array_merge($requesters, $approvors, $rejectors) as $user_id => $info) {
- $userIDs[] = $user_id;
- }
- foreach ($approvors as $user_id => $info) {
- $approvedBy[] = $info['name'];
- }
+ $userIDs = [];
+ $approvedBy = [];
+ $approvers = json_decode($request->approved_by, true) ?: [];
+ $requesters = json_decode($request->requested_by, true) ?: [];
+ $rejectors = json_decode($request->rejected_by, true) ?: [];
- $approvedBy = jvbCommaList($approvedBy);
+ foreach (array_merge($requesters, $approvers, $rejectors) as $user_id => $info) {
+ $userIDs[] = $user_id;
+ }
+ foreach ($approvers as $user_id => $info) {
+ $approvedBy[] = $info['name'];
+ }
- // Notify the requester
- JVB()->notification()->addNotification(
- $userIDs,
- 'term_approved',
- [
- 'term_id' => $term_id,
- 'term_name' => $term_name,
- 'taxonomy' => $taxonomy,
- 'approved_by' => $approvedBy
- ]
- );
+ $approvedBy = jvbCommaList($approvedBy);
- return true;
- } catch (Exception $e) {
- JVB()->error()
- ->log(
- '[ApprovalRoutes]:makeTermLive',
- "Error making term live: " . $e->getMessage(),
- [
- 'request_id' => $request->id,
- 'requester' => $request->requested_by,
- 'term_name' => $term_name,
- 'taxonomy' => $taxonomy
- ]
- );
+ JVB()->notification()->addNotification(
+ $userIDs,
+ 'term_approved',
+ [
+ 'term_id' => $term_id,
+ 'term_name' => $term_name,
+ 'taxonomy' => $taxonomy,
+ 'approved_by' => $approvedBy
+ ]
+ );
- return false;
- }
- }
+ 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
*
@@ -763,64 +573,54 @@
*
* @return boolean Success or failure
*/
- protected function makeTermUnalive(object $request):bool
- {
- global $wpdb;
+ protected function makeTermUnalive(object $request): bool
+ {
+ try {
+ // Update request status
+ CustomTable::for($this->getTableName('term', 'requests'))
+ ->where(['id' => $request->id])
+ ->updateResults([
+ 'status' => 'rejected'
+ ]);
- try {
- // Update request status
- $wpdb->update(
- $this->getRequestTable('term', $wpdb),
- [
- 'status' => 'rejected',
- 'updated_at' => current_time('mysql'),
- ],
- [ 'id' => $request->id ]
- );
+ $userIDs = [];
+ $rejectedBy = [];
- $userIDs = [];
- $rejectedBy = [];
+ $approvers = json_decode($request->approved_by, true) ?: [];
+ $requesters = json_decode($request->requested_by, true) ?: [];
+ $rejectors = json_decode($request->rejected_by, true) ?: [];
- $approvors = json_decode($request->approved_by, true) ?: [];
- $requesters = json_decode($request->requested_by, true) ?: [];
- $rejectors = json_decode($request->rejected_by, true) ?: [];
- foreach (array_merge($requesters, $approvors, $rejectors) as $user_id => $info) {
- $userIDs[] = $user_id;
- }
- foreach ($rejectors as $user_id => $info) {
- $rejectedBy[] = $info['name'];
- }
+ 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);
+ $rejectedBy = jvbCommaList($rejectedBy);
- // Notify the requester
- JVB()->notification()->addNotification(
- $userIDs,
- 'term_rejected',
- [
- 'term_name' => $request->name,
- 'taxonomy' => $request->taxonomy,
- 'rejected_by' => $rejectedBy
- ]
- );
+ JVB()->notification()->addNotification(
+ $userIDs,
+ 'term_rejected',
+ [
+ 'term_name' => $request->name,
+ 'taxonomy' => $request->taxonomy,
+ 'rejected_by' => $rejectedBy
+ ]
+ );
- return true;
- } catch (Exception $e) {
- JVB()->error()
- ->log(
- '[ApprovalRoutes]:makeTermUnalive',
- "Error rejecting term: " . $e->getMessage(),
- [
- 'request_id' => $request->id,
- 'requester' => $request->requested_by,
- 'term_name' => $request->name,
- 'taxonomy' => $request->taxonomy
- ]
- );
+ return true;
+ } catch (Exception $e) {
+ $this->logError('makeTermUnalive', [
+ 'error' => $e->getMessage(),
+ 'request_id' => $request->id,
+ 'term_name' => $request->name ?? '',
+ 'taxonomy' => $request->taxonomy ?? ''
+ ]);
- return false;
- }
- }
+ return false;
+ }
+ }
/**
* Create a new term approval request
@@ -833,179 +633,150 @@
*
* @return int|false Request ID or false on failure
*/
- public function createTermApprovalRequest(
- int $user_id,
- string $taxonomy,
- string $name,
- int $parent = 0,
- int $required_approvals = 3
- ):int|false {
- global $wpdb;
- $table = $this->getRequestTable('term', $wpdb);
+ public function createTermApprovalRequest(
+ int $user_id,
+ string $taxonomy,
+ string $name,
+ int $parent = 0,
+ int $required_approvals = 3
+ ): int|false {
+ $table = CustomTable::for($this->getTableName('term', 'requests'));
- try {
- $wpdb->query('START TRANSACTION');
- // Step 1: Check if user already has a pending request for this term
- $existing = $wpdb->get_row($wpdb->prepare(
- "SELECT id, requested_by FROM $table
- WHERE name = %s
- AND taxonomy = %s
- AND parent = %d
- AND status = 'pending'",
- $name,
- $taxonomy,
- $parent
- ));
+ 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) {
- // Decode the requested_by JSON field
- $requestedBy = json_decode($existing->requested_by, true) ?: [];
+ if ($existing) {
+ $requestedBy = json_decode($existing->requested_by, true) ?: [];
- // Check if this user has already requested this term
- if (isset($requestedBy[$user_id])) {
- $wpdb->query('COMMIT');
- return (int)$existing->id;
- }
+ if (isset($requestedBy[$user_id])) {
+ return (int)$existing->id;
+ }
- // Add this user to the requesters
- $requestedBy[$user_id] = get_userdata($user_id)->display_name;
+ $requestedBy[$user_id] = get_userdata($user_id)->display_name;
- // Update the request with the new requester
- $updated = $wpdb->update(
- $table,
- ['requested_by' => json_encode($requestedBy)],
- ['id' => $existing->id]
- );
+ $table->where(['id' => $existing->id])->updateResults([
+ 'requested_by' => json_encode($requestedBy)
+ ]);
- if (!$updated) {
- throw new Exception($wpdb->last_error);
- }
+ return (int)$existing->id;
+ }
- $wpdb->query('COMMIT');
- 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
+ ]);
- $request = [
- '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')),
- 'created_at' => current_time('mysql'),
- 'updated_at' => current_time('mysql')
- ];
- $result = $this->createApprovalRequest('term', $request);
-
- if (!$result) {
- throw new Exception($wpdb->last_error);
- }
-
- $request_id = $wpdb->insert_id;
- $wpdb->query('COMMIT');
- return $request_id;
- } catch (Exception $e) {
- $wpdb->query('ROLLBACK');
- JVB()->error()
- ->log(
- '[ApprovalRoutes]:createTermApprovalRequest',
- "Error creating term approval request: " . $e->getMessage(),
- [
- 'user_id' => $user_id,
- 'taxonomy' => $taxonomy,
- 'name' => $name
- ]
- );
-
- return false;
- }
- }
+ return false;
+ }
+ }
/**
* Clean up expired approval requests and notify admin
*
* @return void
*/
- public function cleanupExpiredApprovals(): void
- {
- global $wpdb;
- $tables = array_map(function ($table) use ($wpdb){
- return $wpdb->prefix . BASE . 'approval_'.$table.'_requests';
- }, $this->allTypes);
+ public function cleanupExpiredApprovals(): void
+ {
+ $now = current_time('mysql');
+ foreach ($this->allTypes as $type) {
+ $tableName = $this->getTableName($type, 'requests');
- foreach ($tables as $table) {
- $wpdb->query($wpdb->prepare(
- "UPDATE $table SET status = 'expired', updated_at = %s
- WHERE status = 'pending' AND expires_at < %s",
- current_time('mysql'),
- current_time('mysql')
- ));
- }
+ CustomTable::for($tableName)->query(
+ "UPDATE {table}
+ SET status = 'expired'
+ WHERE status = 'pending'
+ AND expires_at < %s",
+ [$now]
+ );
+ }
- // Clear caches
$this->cache->flush();
- }
+ }
- public function getApprovals(WP_REST_Request $request)
- {
- $user_id = get_current_user_id();
- $params = $request->get_params();
- $type = $params['type'] ?? 'all';
- $status = $params['status'] ?? 'pending';
+ protected function getTableName(string $type, string $suffix): string
+ {
+ return match ($type) {
+ 'term' => "approval_term_{$suffix}",
+ default => "approval_{$type}_{$suffix}",
+ };
+ }
- // Get appropriate approvals based on type
- if ($type === 'user' || $type === 'all') {
- $user_approvals = $this->getUserApprovals($status);
- } else {
- $user_approvals = [];
- }
+ public function getApprovals(WP_REST_Request $request): WP_REST_Response
+ {
+ $user_id = absint($request->get_param('user'));
+ $type = sanitize_text_field($request->get_param('type') ?? 'all');
+ $status = sanitize_text_field($request->get_param('status') ?? 'pending');
- if ($type === 'term' || $type === 'all') {
- $term_approvals = $this->getTermApprovals($status);
- } else {
- $term_approvals = [];
- }
+ if (!$this->checkUser($user_id)) {
+ return $this->unauthorized();
+ }
- return new WP_REST_Response([
- 'user_approvals' => $user_approvals,
- 'term_approvals' => $term_approvals
- ]);
- }
+ $cacheKey = compact('user_id', 'type', 'status');
- private function getUserApprovals(string $status = 'pending'): array
- {
- global $wpdb;
- $table = $wpdb->prefix . $this->userRequests;
+ $result = $this->cache->remember($cacheKey, function() use ($type, $status) {
+ $data = [];
- // Build the status condition
- $status_condition = ($status === 'all') ?
- "status IN ('pending', 'approved', 'rejected', 'expired')" :
- $wpdb->prepare("status = %s", $status);
+ if ($type === 'user' || $type === 'all') {
+ $data['user_approvals'] = $this->getUserApprovals($status);
+ }
- return $wpdb->get_results(
- "SELECT * FROM $table
- WHERE $status_condition
- ORDER BY created_at DESC"
- );
- }
+ if ($type === 'term' || $type === 'all') {
+ $data['term_approvals'] = $this->getTermApprovals($status);
+ }
- private function getTermApprovals(string $status = 'pending'): array
- {
- global $wpdb;
- $table = $wpdb->prefix . $this->termRequests;
+ return $data;
+ });
- // Build the status condition
- $status_condition = ($status === 'all') ?
- "status IN ('pending', 'approved', 'rejected', 'expired')" :
- $wpdb->prepare("status = %s", $status);
+ return $this->success($result);
+ }
- return $wpdb->get_results(
- "SELECT * FROM $table
- WHERE $status_condition
- ORDER BY created_at DESC"
- );
- }
+ private function getUserApprovals(string $status = 'pending'): array
+ {
+ $table = CustomTable::for($this->getTableName('artist', 'requests'));
+
+ $query = $table;
+
+ if ($status !== 'all') {
+ $query = $query->where(['status' => $status]);
+ }
+
+ return $query->orderBy('created_at', 'DESC')->getResults(ARRAY_A);
+ }
+
+ private function getTermApprovals(string $status = 'pending'): array
+ {
+ $table = CustomTable::for($this->getTableName('term', 'requests'));
+
+ if ($status === 'all') {
+ return $table->orderBy('created_at', 'DESC')->getResults(ARRAY_A);
+ }
+
+ return $table
+ ->where(['status' => $status])
+ ->orderBy('created_at', 'DESC')
+ ->getResults(ARRAY_A);
+ }
}
diff --git a/inc/rest/routes/BioRoutes.php b/inc/rest/routes/BioRoutes.php
deleted file mode 100644
index 685c0b2..0000000
--- a/inc/rest/routes/BioRoutes.php
+++ /dev/null
@@ -1,266 +0,0 @@
-<?php
-namespace JVBase\rest\routes;
-
-use JVBase\JVB;
-use JVBase\rest\RestRouteManager;
-use JVBase\meta\MetaManager;
-use JVBase\managers\UploadManager;
-use JVBase\managers\ImageGenerator;
-use WP_REST_Request;
-use WP_REST_Response;
-use WP_Error;
-
-if (!defined('ABSPATH')) {
- exit; // Exit if accessed directly
-}
-//TODO: Invalidate feed caches on updates
-class BioRoutes extends RestRouteManager
-{
- protected int $count;
- public function __construct()
- {
- $this->cache_name = 'user_bio';
- parent::__construct();
- add_filter(BASE.'handle_bulk_operation', [$this, 'generateThumbnail'], 10, 3);
- add_filter(BASE.'handle_bulk_operation', [$this, 'processOperation'], 10, 3);
- $this->content_type = 'artist';
- $this->type = 'post';
- $this->action = 'dash-';
- $this->count = 1;
- $this->operation_type = 'bio_update';
- }
-
- /**
- * Register bio routes
- * @return void
- */
- public function registerRoutes():void
- {
- register_rest_route($this->namespace, '/bio', [
- [
- 'methods' => 'POST',
- 'callback' => [ $this, 'handleBioRequest' ],
- 'permission_callback' => [ $this, 'checkPermission' ]
- ]
- ]);
- }
-
- /**
- * @param WP_REST_Request $request
- *
- * @return WP_REST_Response
- */
- public function handleBioRequest(WP_REST_Request $request)
- {
- error_log('Bio Request: '.print_r($request->get_params(), true));
-
- $data = $request->get_params();
- error_log('Data: '.print_r($data, true));
- $user = $data['user'];
- $operationID = $data['id'];
- unset($data['user']);
- unset($data['id']);
- if (!$this->userCheck($user)) {
- return new WP_REST_Response([
- 'success' => false,
- 'message' => 'Looks like you may not be who you say you are...'
- ]);
- }
-// $data = json_encode($data);
- if (array_key_exists('term_name', $data)) {
- unset($data['term_name']);
- unset($data['select_parent']);
- }
-
- $queue = JVB()->queue();
- $queue->queueOperation(
- 'bio_update',
- $user,
- $data,
- [
- 'operation_id' => $operationID,
- 'priority' => 'high',
- 'notification' => true,
- ]
- );
-
- return new WP_REST_Response([
- 'success' => true,
- 'message' => 'Updates queued for processing'
- ]);
- }
-
-
- /**
- * @param WP_Error|array $result
- * @param object $operation
- * @param array $data
- *
- * @return WP_Error|array
- */
- public function processOperation(WP_Error|array $result, object $operation, array $data):WP_Error|array
- {
- if ($operation->type !== 'bio_update') {
- return $result;
- }
- $user_id = $operation->user_id;
- $userLink = get_user_meta($user_id, BASE.'link', true);
- $type = get_post_type((int)$userLink);
-
- if (!in_array($type, [BASE.'artist', BASE.'partner'])) {
- return [
- 'success' => false,
- 'error' => 'Invalid User Type'
- ];
- }
-
- $meta = new MetaManager($userLink, 'post');
- $results = [];
-
- error_log('processing data: '.print_r($data, true));
- $progress = false;
- if (array_key_exists('progress', $data)) {
- $progress = $data['progress'];
- unset($data['progress']);
- }
- $fields = jvbGetFields($type, 'post');
- foreach ($data as $name=>$value) {
- if ($name === 'shop') {
- JVB()->routes('shop')->requestShopAdmission($operation->user_id, $value);
- $data['requested_shop'] = $value;
- unset($data['shop']);
- }
- }
- $allowedFields = array_filter(
- $data,
- function ($key) use ($fields) {
- return array_key_exists($key, $fields);
- },
- ARRAY_FILTER_USE_KEY
- );
- $meta->setAll($allowedFields);
-
- if ($progress) {
- $data['progress'] = $progress;
- }
- if (array_intersect(
- array_keys($data),
- [
- 'image_portrait',
- 'shop',
- 'city',
- 'type',
- 'top_style',
- 'display_name'
- ]
- )) {
- if ((array_key_exists('image_portrait', $data) && $data['image_portrait']!=='') || $meta->getValue('image_portrait') !== '') {
- $this->checkGenerateThumbnail($user_id, $this->buildThumbnailData($user_id));
- }
- }
-
- return [
- 'success' => true,
- 'result' => $results,
- ];
- }
-
-
- /**
- * Queues a featured image generator
- * @param int $user_id
- * @param array $data
- *
- * @return void
- */
- protected function checkGenerateThumbnail(int $user_id, array $data):void
- {
- if (!$this->checkUser($user_id)) {
- return;
- }
- if (empty($data)) {
- return;
- }
- if (array_key_exists('image_portrait', $data) && !is_int($data['image_portrait'])) {
- return;
- }
- $queue = JVB()->queue();
- $queue->queueOperation(
- 'featured_image',
- $user_id,
- $data
- );
- }
-
- /**
- * Processes the featured image generation operation
- * @param WP_Error|array $result
- * @param object $operation
- * @param array $data
- *
- * @return WP_Error|array
- */
- public function generateThumbnail(WP_Error|array $result, object $operation, array $data):WP_Error|array
- {
- if ($operation->type !== 'featured_image') {
- return $result;
- }
- $data['imageType'] = 'artist';
- $fileGenerator = new UploadManager('artist', $operation->user_id);
- $generator = new ImageGenerator($data, $fileGenerator);
- $result = $generator->generate();
- if ($result['success']) {
- set_post_thumbnail(get_user_meta($operation->user_id, BASE.'link', true), $result['attachment_id']);
- }
- return $result;
- }
-
- /**
- * @param int $user_id
- *
- * @return array
- */
- protected function buildThumbnailData(int $user_id):array
- {
- if (!$this->checkUser($user_id)) {
- return [];
- }
- $cache = $this->cache->get($user_id);
- if ($cache) {
- return $cache;
- }
-
- $link = get_user_meta($user_id, BASE.'link', true);
- $userMeta = new MetaManager($user_id, 'user');
- $postMeta = new MetaManager($link, 'post');
- $styles = explode(',', $postMeta->getValue('top_style'));
- $temp = [];
- if (!empty($styles)) {
- foreach ($styles as $style) {
- if (term_exists($style, BASE.'style')) {
- $temp[] = get_term((int)$style, BASE.'style')->name;
- }
- }
- $styles = (empty($temp)) ? '' : implode(',', $temp);
- } else {
- $styles = '';
- }
-
-
- $shop = get_term((int)$postMeta->getValue('shop'), BASE.'shop')->name??'';
-
- $data = [
- 'user_id' => $user_id,
- 'display_name' => $userMeta->getValue('display_name'),
- 'image' => $postMeta->getValue('image_portrait'),
- 'shop' => $shop,
- 'styles' => $styles,
- 'city' => jvbArtistCity($link),
- 'type' => jvbArtistType($link),
- ];
-
- $this->cache->set($user_id, $data);
-
- return $data;
- }
-}
diff --git a/inc/rest/routes/ContentRoutes.php b/inc/rest/routes/ContentRoutes.php
index fb53392..119b3a1 100644
--- a/inc/rest/routes/ContentRoutes.php
+++ b/inc/rest/routes/ContentRoutes.php
@@ -2,13 +2,14 @@
namespace JVBase\rest\routes;
-use JVBase\JVB;
use JVBase\managers\queue\executors\ContentExecutor;
-use JVBase\managers\queue\Storage;
use JVBase\managers\queue\TypeConfig;
-use JVBase\rest\RestRouteManager;
+use JVBase\meta\Meta;
+use JVBase\rest\PermissionHandler;
+use JVBase\rest\Response;
+use JVBase\rest\Rest;
use JVBase\managers\Cache;
-use JVBase\meta\MetaManager;
+use JVBase\rest\Route;
use JVBase\utility\Features;
use WP_Post;
use WP_Query;
@@ -21,29 +22,27 @@
exit; // Exit if accessed directly
}
-class ContentRoutes extends RestRouteManager
+class ContentRoutes extends Rest
{
protected array $fields = [];
protected array $taxonomies = [];
- protected MetaManager $meta;
protected string $post_type = '';
protected string $user_id = '';
//For Timeline-specific posts
protected array $timelineSharedFields = [];
protected array $timelineUniqueFields = [];
+ protected static ?string $action = 'dash-';
+ protected Meta $meta;
public function __construct()
{
- $this->cache_name = 'user_content_' . get_current_user_id();
+ $this->cacheName = 'user_content_' . get_current_user_id();
parent::__construct();
if (JVB_TESTING) {
$this->cache->flush();
}
$this->cache->connect('post', true);
-
- $this->action = 'dash-';
- $this->operation_type = 'content_update';
add_action('init', [$this, 'registerContentExecutors'], 5);
}
@@ -74,35 +73,13 @@
*/
public function registerRoutes(): void
{
- // Base content endpoint
- register_rest_route($this->namespace, "/content", [
- [
- 'methods' => 'GET',
- 'callback' => [$this, 'handleContentRequest'],
- 'permission_callback' => [$this, 'checkPermission'],
- ],
- [
- 'methods' => 'POST',
- 'callback' => [$this, 'handleContentUpdate'],
- 'permission_callback' => [$this, 'checkPermission']
- ]
- ]);
-
- //TODO: consolidate create/batch in with create? I don't think we are ever creating a single item
- register_rest_route($this->namespace, "/create", [
- [
- 'methods' => 'POST',
- 'callback' => [$this, 'handleContentCreate'],
- 'permission_callback' => [$this, 'checkPermission']
- ]
- ]);
- register_rest_route($this->namespace, "/create/batch", [
- [
- 'methods' => 'POST',
- 'callback' => [$this, 'handleBatchCreation'],
- 'permission_callback' => [$this, 'checkPermission']
- ]
- ]);
+ Route::for('content')
+ ->get([$this, 'getContent'])
+ ->auth(PermissionHandler::combine(['user', 'nonce', ['actionNonce'=>'dash-']]))
+ ->rateLimit(20)
+ ->post([$this, 'postContent'])
+ ->auth(PermissionHandler::combine(['user', 'nonce', ['actionNonce'=>'dash-']]))
+ ->rateLimit(30);
}
protected function initTimelineFields(string $content): void
@@ -165,23 +142,13 @@
*
* @return WP_REST_Response
*/
- public function handleContentUpdate(WP_REST_Request $request): WP_REST_Response
+ public function postContent(WP_REST_Request $request): WP_REST_Response
{
$data = $request->get_params();
$user_id = $data['user'];
- if (!$this->userCheck($user_id)) {
- return new WP_REST_Response([
- 'success' => true,
- 'message' => 'You for real?'
- ]);
- }
-
if (!array_key_exists('posts', $data) || !is_array($data['posts'])) {
- return new WP_REST_Response([
- 'success' => true,
- 'message' => 'No posts found'
- ]);
+ return Response::success(['message'=>'No posts found in request']);
}
$count = count($data['posts']);
@@ -203,50 +170,8 @@
'operation_id' => $operationId
]
);
- return new WP_REST_Response([
- 'success' => true,
- 'message' => 'Queued for processing',
- 'operation' => $operationId
- ]);
- }
- /**
- * Handle content creation
- * @param WP_REST_Request $request
- *
- * @return WP_REST_Response
- */
- public function handleContentCreate(WP_REST_Request $request): WP_REST_Response
- {
- $data = $request->get_json_params();
- $user_id = $data['user'];
-
- if (!isset($data['posts']) || !is_array($data['posts'])) {
- return new WP_REST_Response([
- 'success' => false,
- 'message' => 'Invalid request format'
- ]);
- }
-
- $count = count($data['posts']);
- $operationId = $data['id'];
- unset($data['user']);
- unset($data['id']);
- JVB()->queue()->queueOperation(
- 'batch_creation',
- $user_id,
- $data,
- [
- 'count' => $count,
- 'operation_id' => $operationId,
- ]
- );
-
- return new WP_REST_Response([
- 'success' => true,
- 'message' => 'Queued for processing',
- 'operation' => $operationId
- ]);
+ return Response::queued($operationId);
}
@@ -256,18 +181,11 @@
*
* @return WP_REST_Response
*/
- public function handleContentRequest(WP_REST_Request $request): WP_REST_Response
+ public function getContent(WP_REST_Request $request): WP_REST_Response
{
$params = $request->get_params();
$user_id = $params['user'];
- if (!$this->userCheck($user_id)) {
- return new WP_REST_Response([
- 'success' => false,
- 'message' => 'User does not match up. Are you a bot?',
- ]);
- }
-
$post_status = $params['status'];
if ($post_status === 'all') {
$post_status = ['publish', 'draft'];
@@ -328,10 +246,9 @@
return $cache_check;
}
-
$cache = $this->cache->get($key);
if ($cache) {
- $response = new WP_REST_Response($cache);
+ $response = Response::success($cache);
return $this->addCacheHeaders($response);
}
@@ -352,13 +269,14 @@
$data = [
'items' => $posts,
'total' => $query->found_posts,
- 'total_pages' => $query->max_num_pages
+ 'total_pages' => $query->max_num_pages,
+ 'has_more' => $args['paged']??1 < $query->max_num_pages,
];
$this->cache->set($key, $data);
- $response = new WP_REST_Response($data);
+ $response = Response::success($data);
return $this->addCacheHeaders($response);
}
@@ -464,452 +382,7 @@
return $out;
}
- /**
- * Processes operation from queue
- * @param object $operation
- * @param array $data
- *
- * @return array
- */
- protected function processBatches(object $operation, array $data): array
- {
- $this->user_id = $operation->user_id;
- $posts = $data['posts'];
- if (empty($posts)) {
- return [
- 'success' => false,
- 'message' => 'No posts to update'
- ];
- }
-
- $results = [];
-
- foreach ($posts as $ID => $post_data) {
- if (Features::forContent($post_data['content'])->has('is_timeline') && array_key_exists('timeline', $post_data)) {
- // Handle timeline posts - ensure we have a valid integer ID
- $parent_id = (int)$ID;
-
- // Skip if ID is invalid (0, 'null', etc would become 0)
- if ($parent_id === 0) {
- error_log('Invalid timeline parent ID: ' . $ID);
- $results[$ID] = [
- 'success' => false,
- 'message' => 'Invalid parent post ID for timeline'
- ];
- continue;
- }
-
- $results[$ID] = $this->processTimelinePost($parent_id, $post_data);
- continue;
- }
- if (str_starts_with($ID, 'new')) {
-
- error_log('New post detected. Creating... with: ' . print_r([
- 'post_author' => $this->user_id,
- 'post_type' => jvbCheckBase($post_data['content']),
- 'post_title' => $post_data['post_title'] ?? '',
- 'post_status' => $post_data['status'] ?? 'draft',
- ], true));
- error_log('Recieved Data: ' . print_r($post_data, true));
- $ID = wp_insert_post([
- 'post_author' => $this->user_id,
- 'post_type' => jvbCheckBase($post_data['content']),
- 'post_title' => $post_data['post_title'] ?? '',
- 'post_status' => $post_data['status'] ?? 'draft',
- ]);
- if (!$ID || is_wp_error($ID)) {
- $results[$ID] = [
- 'success' => false,
- 'message' => 'Couldn\'t Create Post'
- ];
- continue;
- }
- $fields = jvbGetFields($post_data['content']);
- $allowedFields = array_filter($post_data, function ($key) use ($fields) {
- return array_key_exists($key, $fields);
- }, ARRAY_FILTER_USE_KEY);
-
- $meta = new MetaManager($ID, 'post');
- $success = $meta->setAll($allowedFields);
- $results[$ID] = [
- 'success' => $success
- ];
- } else {
- if (!$this->verifyOwnership($ID)) {
- $results[$ID] = [
- 'success' => false,
- 'message' => 'No permission to modify this post'
- ];
- continue;
- }
- error_log('Saving post data: ' . print_r($post_data, true));
-
- if (array_key_exists('post_status', $post_data)) {
- switch ($post_data['post_status']) {
- case 'publish':
- unset($post_data['post_status']);
- if (user_can($this->user_id, 'manage_options') || user_can($this->user_id, 'skip_moderation')) {
- $result = wp_update_post(['ID' => $ID, 'post_status' => 'publish']);
- }
- break;
- case 'draft':
- $result = wp_update_post([
- 'ID' => $ID,
- 'post_status' => 'draft'
- ]);
- break;
- case 'trash':
- $result = wp_trash_post($ID);
- break;
- case 'delete':
- $result = wp_delete_post($ID, true);
- return ['success' => (bool)$result];
- }
- }
- error_log('Updating data: ' . print_r($post_data, true));
- $fields = jvbGetFields($post_data['content']);
- $allowedFields = array_filter($post_data, function ($key) use ($fields) {
- return array_key_exists($key, $fields);
- }, ARRAY_FILTER_USE_KEY);
-
- error_log('Allowed Fields: ' . print_r($allowedFields, true));
- $meta = new MetaManager($ID, 'post');
- $success = $meta->setAll($allowedFields);
- $results[$ID] = [
- 'success' => $success
- ];
-
- }
- }
-
- if (jvbSiteHasNotifications()) {
- $this->notifications = JVB()->notification();
- $this->notifications->addNotification(
- $this->user_id,
- 'content_update_complete',
- null,
- 'Content updates completed!'
- );
- }
-
-
- return [
- 'success' => true,
- 'result' => $results
- ];
- }
-
- /**
- * Extracts the postdata for timeline post child posts from the pseudo-repeater element
- * @param int $parent_id
- * @param array $post_data
- * @return array|true[]
- */
- protected function processTimelinePost(int $parent_id, array $post_data): array
- {
- if (!$this->verifyOwnership($parent_id)) {
- return ['success' => false, 'message' => 'No permission'];
- }
-
- error_log('[Processing Timeline Post...');
-
- $ignore = ['content', 'user'];
- $this->fields = jvbGetFields($post_data['content']);
- $this->initTimelineFields($post_data['content']);
-
- // Get parent post details
- $parent_post = get_post($parent_id);
- $parent_title = $parent_post->post_title;
- $parent_is_published = ($parent_post->post_status === 'publish');
-
- // Extract shared data from top level (excluding post_thumbnail which is unique per post)
- $sharedData = array_filter($post_data, function ($key) use ($ignore) {
- return in_array($key, $this->timelineSharedFields)
- && !in_array($key, $ignore)
- && $key !== 'post_thumbnail';
- }, ARRAY_FILTER_USE_KEY);
-
- // If no shared post_title at top level, extract from first timeline entry
- if (!isset($sharedData['post_title']) && isset($post_data['timeline'][0]['post_title'])) {
- $sharedData['post_title'] = $post_data['timeline'][0]['post_title'];
- }
- $clearParent = false;
- if (array_key_exists('timeline', $post_data) && is_array($post_data['timeline'])) {
- // Remove post_title and post_thumbnail from shared taxonomies
- $sharedTaxonomies = array_filter($sharedData, function ($key) {
- return $key !== 'post_title' && $key !== 'post_thumbnail';
- }, ARRAY_FILTER_USE_KEY);
-
- // Ensure the parent post exists and is still first in the array
- $index = array_search((string)$parent_id, array_column($post_data['timeline'], 'id'));
-
- if ($index === false) {
- return [
- 'success' => false,
- 'message' => 'Missing parent id. This should not have happened'
- ];
- }
-
- if ($index !== 0) {
- $new_parent_id = $post_data['timeline'][0]['id'];
-
- if (is_numeric($new_parent_id) && (int)$new_parent_id > 0) {
- $new_parent_id = (int)$new_parent_id;
- wp_update_post([
- 'ID' => $new_parent_id,
- 'post_parent' => 0
- ]);
-
- wp_update_post([
- 'ID' => $parent_id,
- 'post_parent' => $new_parent_id
- ]);
-
- $existing_children = get_children([
- 'post_parent' => $parent_id,
- 'fields' => 'ids'
- ]);
-
- foreach ($existing_children as $child_id) {
- if ($child_id !== $new_parent_id) {
- wp_update_post([
- 'ID' => $child_id,
- 'post_parent' => $new_parent_id
- ]);
- }
- }
-
- // Update parent references
- $parent_id = $new_parent_id;
- $parent_post = get_post($parent_id);
- $parent_title = $parent_post->post_title;
- $parent_is_published = ($parent_post->post_status === 'publish');
- } else {
- $item = $post_data['timeline'][$index];
- unset($post_data['timeline'][$index]);
- array_unshift($post_data['timeline'], $item);
- }
- }
-
- $errors = [];
- $success = [];
- $existing_children = get_children([
- 'post_parent' => $parent_id,
- 'orderby' => 'menu_order',
- 'post_status' => ['publish', 'draft'],
- 'fields' => 'ids'
- ]);
-
- $prevDate = null;
- $latest_date = null;
- $earliest_date = null;
- foreach ($post_data['timeline'] as $order => $timeline) {
- // Get unique fields for this specific timeline entry
- $allowedFields = array_filter($timeline, function ($key) use ($ignore) {
- return in_array($key, $this->timelineUniqueFields) && !in_array($key, $ignore);
- }, ARRAY_FILTER_USE_KEY);
-
- // Determine the post title
- $is_parent = ((int)$timeline['id'] === $parent_id);
- $provided_title = $timeline['post_title'] ?? '';
- $auto_generated_pattern = '/^.+Treatment #?\d+$/'; // Matches "Title - Treatment #1" or "Title - Treatment 1"
-
- if ($is_parent) {
- // Parent keeps its own title or uses shared title
- $allowedFields['post_title'] = $provided_title ?: ($sharedData['post_title'] ?? $parent_title);
- } else {
- // For child posts, auto-generate if:
- // 1. No title provided, OR
- // 2. Title matches auto-generated pattern (meaning it wasn't customized)
- if (empty($provided_title) || preg_match($auto_generated_pattern, $provided_title)) {
- $allowedFields['post_title'] = 'Treatment ' . $order;
- } else {
- // Keep custom title
- $allowedFields['post_title'] = $provided_title;
- }
- }
-
- // Merge with shared taxonomies AFTER setting unique fields
- $allowedFields = array_merge($sharedTaxonomies, $allowedFields);
-
- // Handle post creation if needed
- if (!array_key_exists('id', $timeline) || !is_numeric($timeline['id'])) {
- $newChild = wp_insert_post([
- 'post_author' => $this->user_id,
- 'post_type' => jvbCheckBase($post_data['content']),
- 'post_title' => $allowedFields['post_title'],
- 'post_parent' => $parent_id,
- 'menu_order' => $order,
- 'post_status' => $parent_is_published ? 'publish' : 'draft'
- ]);
- if (!$newChild || is_wp_error($newChild)) {
- $errors[] = [
- 'message' => 'Could not create child post',
- 'data' => $timeline
- ];
- continue;
- }
- $timeline['id'] = $newChild;
- }
-
- if (in_array((int)$timeline['id'], $existing_children)) {
- unset($existing_children[array_search((int)$timeline['id'], $existing_children)]);
- }
-
- // Update post status and menu order
- $post_updates = ['ID' => $timeline['id']];
-
- if (!$is_parent) {
- $post_updates['menu_order'] = $order;
-
- // Auto-publish child if parent is published
- if ($parent_is_published) {
- $current_post = get_post($timeline['id']);
- if ($current_post && $current_post->post_status !== 'publish') {
- $post_updates['post_status'] = 'publish';
- }
- }
- }
-
- if (count($post_updates) > 1) {
- $result = wp_update_post($post_updates);
- error_log('Updated post ' . $timeline['id'] . ' with: ' . print_r($post_updates, true) . ' Result: ' . $result);
- $clearParent = true;
- }
-
- // Update metadata
- $meta = new MetaManager($timeline['id'], 'post');
- $oldValues = $meta->getAll(array_keys($allowedFields));
-
-// // Set number taxonomy to menu_order (always update for reordering)
-// if (!$is_parent) {
-// $number_value = $order;
-// $term = get_term_by('name', (string)$number_value, BASE . 'number');
-// if (!$term) {
-// $result = wp_insert_term((string)$number_value, BASE . 'number');
-// if ($result && !is_wp_error($result)) {
-// $term = $result['term_id'];
-// }
-// } else {
-// $term = $term->term_id;
-// }
-// $allowedFields['number'] = $term;
-// }
-
- // Auto-timeline logic
- if ($prevDate) {
- $newDate = array_key_exists('date', $oldValues) ? $oldValues['date'] : ((array_key_exists('date', $allowedFields)) ? $allowedFields['date'] : null);
- if ($newDate) {
- $date1 = new \DateTime($prevDate);
- $date2 = new \DateTime($newDate);
- $weeks = floor($date1->diff($date2)->days / 7);
- if ($weeks > 0) {
- $termToCheck = $weeks . ' Weeks';
- $term = get_term_by('name', $termToCheck, BASE . 'timeline');
- if (!$term) {
- $result = wp_insert_term($termToCheck, BASE . 'timeline');
- if ($result && !is_wp_error($result)) {
- $term = $result['term_id'];
- }
- } else {
- $term = $term->term_id;
- }
- $allowedFields['timeline'] = $term;
- }
- }
- }
- $prevDate = array_key_exists('date', $oldValues) ? $oldValues['date'] : ((array_key_exists('date', $allowedFields)) ? $allowedFields['date'] : $prevDate);
-
- $updateValues = array_filter($allowedFields, function ($value, $key) use ($oldValues) {
- return (!array_key_exists($key, $oldValues) || $value !== $oldValues[$key]);
- }, ARRAY_FILTER_USE_BOTH);
-
-
- $meta->setAll($updateValues);
- $timeline['id'] = (int)$timeline['id'];
-
- $success[] = $timeline['id'];
- }
- }
-
- // Delete any remaining children that no longer exist
- if (!empty($existing_children)) {
- foreach ($existing_children as $ID) {
- wp_delete_post($ID);
- }
- }
-
- if ($clearParent) {
- $this->cache->flush();
- Cache::onPostChange($parent_id, $parent_post);
- }
-
-
- return ['success' => true, 'data' => [
- 'success' => $success,
- 'errors' => $errors
- ]];
- }
-
- /**
- * Handle batch content creation from uploads
- * @param WP_REST_Request $request
- *
- * @return WP_REST_Response
- */
- public function handleBatchCreation(WP_REST_Request $request): WP_REST_Response
- {
- //Operation has two parts
- //First, queue image processing
- //Then queue post creation from the stored IDs, depending on mode
- //if direct, each image becomes a new post
- //if selection, each group becomes its own post,
- // and ungrouped items each become their own post
- if (!isset($_FILES['files'])) {
- return new WP_REST_Response([
- 'success' => false,
- 'message' => 'No files uploaded...',
- ]);
- }
-
- $data = $request->get_params();
-
-
- $user_id = $data['user'];
- if (!$this->userCheck($user_id)) {
- return new WP_REST_Response([
- 'success' => 'false',
- 'message' => 'Invalid user match... are you a bot?'
- ]);
- }
- $operation_id = $data['id'];
- $response = new WP_REST_Response([
- 'success' => true,
- 'message' => 'Successfully sent to server. Added to queue.',
- 'operation_id' => $operation_id,
- 'status' => 'pending'
- ]);
- $this->queue = JVB()->queue();
- JVB()->routes('uploads')->handleUploadRequest($request, false);
- $this->queue->queueOperation(
- 'batch_creation',
- $user_id,
- [
- 'content' => $request->get_param('content'),
- 'mode' => $request->get_param('mode') ?: 'direct',
- 'files_data' => $request->get_param('files_data')
- ],
- [
- 'operation_id' => $operation_id,
- 'priority' => 'high',
- 'notification' => true,
- 'depends_on' => $operation_id . '_upload'
- ]
- );
-
- return $response;
- }
/**
* Generates a post title, based on content type
@@ -936,7 +409,7 @@
$this->initTimelineFields($post->post_type);
return $this->formatTimeline($post);
}
- $this->meta = new MetaManager($post->ID, 'post');
+ $this->meta = Meta::forPost($post->ID);
$data = [
'id' => $post->ID,
'title' => $post->post_title,
@@ -1006,7 +479,7 @@
{
$item = $this->prepareItem($post, true, false);
//Step 1: Get the fields that apply to all posts
- $mainMeta = new MetaManager($post->ID, 'post');
+ $mainMeta = Meta::forPost($post->ID);
$item['fields'] = $mainMeta->getAll($this->timelineSharedFields);
//Step 2: Get the fields for each individual posts
@@ -1016,7 +489,7 @@
$subFields = [];
$images = [];
foreach ($children as $child) {
- $meta = new MetaManager($child, 'post');
+ $meta = Meta::forPost($child);
$f = $meta->getAll($this->timelineUniqueFields);
$f = ['id' => $child] + $f;
$subFields[] = $f;
@@ -1025,292 +498,8 @@
}
$item['fields']['timeline'] = $subFields;
$item['images'] = $item['images'] + $images;
- $item['number'] = $mainMeta->getValue('number');
+ $item['number'] = $mainMeta->get('number');
return $item;
}
-
- /**
- * Builds the taxonomy query
- * @param array $taxonomies
- *
- * @return array|string[]
- */
- protected function buildTaxQuery(array $taxonomies): array
- {
- $tax_query = [];
- error_log('Taxonomies in query: ' . print_r($taxonomies, true));
-
- foreach ($taxonomies as $taxonomy => $terms) {
- if (!empty($terms)) {
- $tax_query[] = [
- 'taxonomy' => jvbCheckBase($taxonomy),
- 'field' => 'term_id',
- 'terms' => array_map('absint', (array)$terms)
- ];
- }
- }
-
-
- return count($tax_query) > 1
- ? array_merge(['relation' => 'AND'], $tax_query)
- : $tax_query;
- }
-
- /**
- * Builds the date query
- * @param array $date_params
- *
- * @return array
- */
- protected function buildDateQuery(array $date_params): array
- {
- $query = [];
-
- if (!empty($date_params['after'])) {
- $query['after'] = sanitize_text_field($date_params['after']);
- }
-
- if (!empty($date_params['before'])) {
- $query['before'] = sanitize_text_field($date_params['before']);
- }
-
- if (isset($date_params['inclusive'])) {
- $query['inclusive'] = (bool)$date_params['inclusive'];
- }
-
- return empty($query) ? [] : [$query];
- }
-
- /**
- * @param int $post_id
- *
- * @return bool
- */
- protected function verifyOwnership(int $post_id): bool
- {
- $post = get_post($post_id);
- return $post && $post->post_author == $this->user_id;
- }
-
- /**
- * Processes operation from Operation Queue
- * @deprecated We process Queue through ContentExecutor.php, setup in registerContentExecutors())
- * @param WP_Error|array $result
- * @param object $operation
- * @param array $data
- *
- * @return array|WP_Error
- */
- public function processOperation(WP_Error|array $result, object $operation, array $data): array|WP_Error
- {
- if ($operation->type === 'batch_creation') {
- $JVB = JVB();
- $queue = $JVB->queue();
-
- $images = $queue->getOperationValue($operation->id . '_upload', 'result') ?? false;
-
- $this->user_id = $operation->user_id;
- $this->post_type = BASE . $data['content'];
- try {
- $results = [];
- if ($images) {
- if ($data['mode'] == 'selection') {
- $total = count($images);
- foreach ($images as $group => $files) {
- $settings = json_decode($data['files_data'][$group]);
-
- switch ($settings->type) {
- case 'group':
- $featuredIndex = $settings->metadata->featuredFile ?? 0;
- $title = $settings->metadata->title ?? $this->generatePostTitle($data['content']);
- $new = wp_insert_post([
- 'post_type' => BASE . $data['content'],
- 'post_title' => $title,
- 'post_status' => 'draft',
- 'post_author' => $operation->user_id
- ]);
- if ($new && !is_wp_error($new)) {
- set_post_thumbnail($new, $files[$featuredIndex]['attachment_id']);
- unset($files[$featuredIndex]);
- if (!empty($files)) {
- $meta = new MetaManager($new, 'post');
- $IDs = array_column($files, 'attachment_id');
- $meta->updateValue('gallery', implode(',', $IDs));
- }
- $results[] = $new;
-// $queue->updateOperationProgress($operation->id, $group + 1, $total);
- }
- break;
- default:
- foreach ($files as $img) {
- $new = wp_insert_post([
- 'post_type' => BASE . $data['content'],
- 'post_title' => $this->generatePostTitle($data['content']),
- 'post_status' => 'draft',
- 'post_author' => $operation->user_id
- ]);
-
- if ($new && !is_wp_error($new)) {
- set_post_thumbnail($new, $img['attachment_id']);
- $results[] = $new;
-// $queue->updateOperationProgress($operation->id, $group + 1, $total);
- }
- }
- break;
- }
- }
- } else {
- $total = count($images);
- foreach ($images as $key => $img) {
- $new = wp_insert_post([
- 'post_type' => BASE . $data['content'],
- 'post_title' => $this->generatePostTitle($data['content']),
- 'post_status' => 'draft',
- 'post_author' => $operation->user_id
- ]);
- if ($new && !is_wp_error($new)) {
- set_post_thumbnail($new, $img['attachment_id']);
- }
- $results[] = $new;
-// $queue->updateOperationProgress($operation->id, $key + 1, $total);
- }
- }
- }
-
- return [
- 'success' => true,
- 'result' => $results
- ];
- } catch (Exception $e) {
- $JVB->error()->log(
- '[ContentRoutes]:processOperation',
- $e->getMessage()
- );
- }
-
- return $results;
- } elseif ($operation->type == 'content_update') {
- $result = $this->processBatches($operation, $data);
- }
-
- return $result;
- }
- // Add to ContentRoutes.php
-
- /**
- * One-time migration: Set latest_date meta for all timeline posts
- * Call this once via WP-CLI or a temporary admin page
- *
- * Usage: add_action('admin_init', function() {
- * if (current_user_can('manage_options')) {
- * JVB()->routes('content')->migrateTimelineLatestDates();
- * }
- * });
- */
- public function migrateTimelineLatestDates(): array
- {
- global $wpdb;
-
- $results = [
- 'processed' => 0,
- 'updated' => 0,
- 'skipped' => 0,
- 'errors' => []
- ];
-
- // Get all timeline post types
- $timeline_types = [];
- foreach (JVB_CONTENT as $type => $config) {
- if (Features::forContent($type)->has('is_timeline')) {
- $timeline_types[] = BASE . $type;
- }
- }
-
- if (empty($timeline_types)) {
- return $results;
- }
-
- // Get all parent timeline posts
- $args = [
- 'post_type' => $timeline_types,
- 'post_status' => ['publish', 'draft'],
- 'post_parent' => 0,
- 'posts_per_page' => -1,
- 'fields' => 'ids'
- ];
-
- $parent_ids = get_posts($args);
-
- foreach ($parent_ids as $parent_id) {
- $results['processed']++;
-
- try {
- // Get all children including the parent
- $children = get_children([
- 'post_parent' => $parent_id,
- 'post_status' => ['publish', 'draft'],
- 'orderby' => 'menu_order',
- 'order' => 'ASC',
- 'fields' => 'ids'
- ]);
-
- // Add parent to the list
- array_unshift($children, $parent_id);
-
- // Find latest date among all posts
- $latest_timestamp = 0;
-
- foreach ($children as $post_id) {
- $date = get_post_meta($post_id, BASE . 'date', true);
-
- if ($date) {
- $timestamp = strtotime($date);
- if ($timestamp > $latest_timestamp) {
- $latest_timestamp = $timestamp;
- }
- }
- }
-
- // Update parent with latest date
- if ($latest_timestamp > 0) {
- update_post_meta($parent_id, BASE . 'latest_date', $latest_timestamp);
- $results['updated']++;
- error_log("Updated post {$parent_id} with latest_date: {$latest_timestamp}");
- } else {
- // Fallback to parent post's post_date
- $parent_post = get_post($parent_id);
- $fallback_timestamp = strtotime($parent_post->post_date);
-
- if ($fallback_timestamp > 0) {
- update_post_meta($parent_id, BASE . 'latest_date', $fallback_timestamp);
- $results['updated']++;
- error_log("Updated post {$parent_id} with fallback latest_date: {$fallback_timestamp} (from post_date)");
- } else {
- $results['skipped']++;
- error_log("No dates found for post {$parent_id}");
- }
- }
-
- } catch (Exception $e) {
- $results['errors'][] = [
- 'post_id' => $parent_id,
- 'error' => $e->getMessage()
- ];
- }
- }
-
- error_log('Timeline migration complete: ' . print_r($results, true));
- return $results;
- }
}
-
-
-//add_action('init', function() {
-//// delete_option('jvb_timeline_migrated');
-// if (get_option('jvb_timeline_migrated')) {
-// return;
-// }
-// JVB()->routes('content')->migrateTimelineLatestDates();
-// update_option('jvb_timeline_migrated', true);
-//});
diff --git a/inc/rest/routes/ContentTermsRoutes.php b/inc/rest/routes/ContentTermsRoutes.php
new file mode 100644
index 0000000..695f192
--- /dev/null
+++ b/inc/rest/routes/ContentTermsRoutes.php
@@ -0,0 +1,17 @@
+<?php
+namespace JVBase\rest\routes;
+
+use JVBase\rest\Rest;
+
+if (!defined('ABSPATH')) {
+ exit;
+}
+
+class ContentTermsRoutes extends Rest
+{
+
+ public function registerRoutes(): void
+ {
+ // TODO: Implement registerRoutes() method.
+ }
+}
diff --git a/inc/rest/routes/ErrorRoutes.php b/inc/rest/routes/ErrorRoutes.php
index d76c79c..0a4737e 100644
--- a/inc/rest/routes/ErrorRoutes.php
+++ b/inc/rest/routes/ErrorRoutes.php
@@ -1,15 +1,16 @@
<?php
namespace JVBase\rest\routes;
-use JVBase\JVB;
-use JVBase\rest\RestRouteManager;
+use JVBase\rest\Response;
+use JVBase\rest\Rest;
+use JVBase\rest\Route;
use WP_REST_Request;
use WP_REST_Response;
if (!defined('ABSPATH')) {
exit; // Exit if accessed directly
}
-class ErrorRoutes extends RestRouteManager
+class ErrorRoutes extends Rest
{
/**
* Registers error routes
@@ -17,13 +18,15 @@
*/
public function registerRoutes():void
{
- register_rest_route($this->namespace, '/errors/log', [
- [
- 'methods' => 'POST',
- 'callback' => [$this, 'handleErrorLog'],
- 'permission_callback' => '__return_true', // Allow anyone to log errors
- ]
- ]);
+ Route::for('errors/log')
+ ->post([$this, 'handleErrorLog'])
+ ->args([
+ 'error_type' => 'string|required|enum:network,timeout,offline,auth,rate_limit,server,client,unknown',
+ 'message' => 'string|required',
+ 'context' => 'string',
+ ])
+ ->auth('public')
+ ->rateLimit(10);
}
/**
@@ -31,27 +34,31 @@
*
* @return WP_REST_Response
*/
- public function handleErrorLog(WP_REST_Request$request):WP_REST_Response
- {
- $error_type = $request->get_param('error_type');
- $message = $request->get_param('message');
- $context = json_decode($request->get_param('context'), true);
+ public function handleErrorLog(WP_REST_Request $request): WP_REST_Response
+ {
+ $error_type = sanitize_text_field($request->get_param('error_type'));
+ $message = sanitize_text_field($request->get_param('message'));
+ $context = $request->get_param('context');
- // Determine severity based on error type
- $severity = $this->getSeverityFromType($error_type);
+ // Parse context JSON if provided
+ $contextData = [];
+ if (!empty($context)) {
+ $decoded = json_decode($context, true);
+ $contextData = is_array($decoded) ? $decoded : [];
+ }
- JVB()->error()->log(
- $context['component'] ?? 'client-js',
- $message,
- $context,
- $severity
- );
+ // Determine severity based on error type
+ $severity = $this->getSeverityFromType($error_type);
- return new WP_REST_Response([
- 'success' => true,
- 'message' => 'Error logged'
- ]);
- }
+ JVB()->error()->log(
+ $contextData['component'] ?? 'client-js',
+ $message,
+ $contextData,
+ $severity
+ );
+
+ return Response::success(['message'=>'Error logged']);
+ }
/**
* @param string $type
diff --git a/inc/rest/routes/FavouritesRoutes.php b/inc/rest/routes/FavouritesRoutes.php
index 19121b2..f127aae 100644
--- a/inc/rest/routes/FavouritesRoutes.php
+++ b/inc/rest/routes/FavouritesRoutes.php
@@ -1,3121 +1,1022 @@
<?php
namespace JVBase\rest\routes;
-use JVBase\JVB;
use JVBase\managers\Cache;
-use JVBase\rest\RestRouteManager;
+use JVBase\managers\CustomTable;
+use JVBase\rest\PermissionHandler;
+use JVBase\rest\Response;
+use JVBase\rest\Rest;
+use JVBase\rest\Route;
use WP_REST_Request;
use WP_REST_Response;
-use WP_Error;
use Exception;
if (!defined('ABSPATH')) {
- exit; // Exit if accessed directly
+ exit;
}
-class FavouritesRoutes extends RestRouteManager
+/**
+ * TODO: Extract business logic into a Favourites.php manager class
+ */
+class FavouritesRoutes extends Rest
{
- protected array $valid_types;
+ protected array $valid_types;
protected Cache $listsCache;
protected Cache $sharedListsCache;
protected Cache $favouritesCache;
+ protected CustomTable $favourites;
+ protected CustomTable $lists;
+ protected CustomTable $listItems;
+ protected CustomTable $listShares;
- public function __construct()
- {
- $this->cache_name = 'favourites';
- parent::__construct();
+ public function __construct()
+ {
+ $this->cacheName = 'favourites';
+ $this->cacheTtl = HOUR_IN_SECONDS;
+ parent::__construct();
+
+ // Set up cache connections
$this->cache->connect('post')->connect('user')->connect('taxonomy');
$this->listsCache = Cache::for('lists')->connect('favourites', true);
$this->sharedListsCache = Cache::for('sharedLists')->connect('favourites', true);
$this->favouritesCache = Cache::for('allFavourites')->connect('favourites', true);
- $this->valid_types = array_keys(array_merge(JVB_CONTENT, JVB_TAXONOMY));
+ $this->valid_types = array_keys(array_merge(JVB_CONTENT, JVB_TAXONOMY));
- $this->action = 'favourites-';
+ // Initialize CustomTable instances
+ $this->favourites = CustomTable::for('favourites');
+ $this->lists = CustomTable::for('favourites_lists');
+ $this->listItems = CustomTable::for('favourites_list_items');
+ $this->listShares = CustomTable::for('favourites_list_shares');
-
- add_filter(BASE.'handle_bulk_operation', [$this, 'processOperation'], 10, 3);
- add_action('before_delete_post', [$this, 'cleanupPostFavourites']);
- add_action('delete_term', [$this, 'cleanupTermFavourites'], 10, 3);
-
+ // Register hooks
+ add_filter(BASE.'handle_bulk_operation', [$this, 'processOperation'], 10, 3);
+ add_action('before_delete_post', [$this, 'cleanupPostFavourites']);
+ add_action('delete_term', [$this, 'cleanupTermFavourites'], 10, 3);
add_action('jvbUserRegistered', [$this, 'maybeAcceptListInvite'], 10, 3);
-
- // Register cleanup scheduler
- add_action('jvb_cleanupOrphanedFavourites', [$this, 'cleanupOrphanedFavourites']);
- }
-
- /**
- * Registers favourites routes
- * @return void
- */
- public function registerRoutes():void
- {
- // Main favourites endpoint - GET for retrieval, POST for toggling/notes
- register_rest_route($this->namespace, '/favourites', [
- [
- 'methods' => 'GET',
- 'callback' => [$this, 'getFavourites'],
- 'permission_callback' => [$this, 'checkPermission']
- ],
- [
- 'methods' => 'POST',
- 'callback' => [$this, 'handleFavouriteOperation'],
- 'permission_callback' => [$this, 'checkPermission']
- ]
- ]);
-
- // Lists endpoint - GET for retrieval, POST for creation/modifications
- register_rest_route($this->namespace, '/favourites/lists', [
- [
- 'methods' => 'GET',
- 'callback' => [$this, 'getLists'],
- 'permission_callback' => [$this, 'checkPermission']
- ],
- //Adding and removing list items is handled by the body dta
- [
- 'methods' => 'POST',
- 'callback' => [$this, 'handleListOperation'],
- 'permission_callback' => [$this, 'checkPermission']
- ]
- ]);
-
- // List shares operations
- register_rest_route($this->namespace, '/favourites/lists/shares', [
- [
- 'methods' => 'GET',
- 'callback' => [$this, 'getShares'],
- 'permission_callback' => [$this, 'checkPermission']
- ],
- //Adds and removes are handled in the body data
- [
- 'methods' => 'POST',
- 'callback' => [$this, 'handleShare'],
- 'permission_callback' => [$this, 'checkPermission']
- ]
- ]);
- }
- protected function buildParams(WP_REST_Request $request):array
- {
- $data = $request->get_params();
- error_log('Favourites Request Data: '.print_r($data, true));
- $args = [];
- if (!array_key_exists('user', $data)) {
- return $args;
- }
- $args['user'] = absint($data['user']);
- if (!array_key_exists('page', $data)) {
- //No filters set, just get a list of favourites
- return $args;
- }
- $args = array_merge($args, [
- 'page' => max(1, absint($data['page'] ?? 1)),
- 'content' => $this->checkContent($data['content'])
- ]);
- return $this->applyOrderFilters($args, $data);
+ add_action('jvb_cleanupOrphanedFavourites', [$this, 'cleanupOrphanedFavourites']);
}
- /**
- * Get user's favourites with optional filtering
- *
- * @param WP_REST_Request $request Request object
- * @return WP_REST_Response Response with favourites data
- */
- public function getFavourites(WP_REST_Request $request):WP_REST_Response
- {
- $args = $this->buildParams($request);
- if (!$args['user'] || $args['user'] === ''){
- return $this->addCacheHeaders(new WP_REST_Response([
- 'success' => false,
- 'message' => 'No user set'
- ]));
+
+ public function registerRoutes(): void
+ {
+ // Favourites endpoints
+ Route::for('favourites')
+ ->get([$this, 'getFavourites'])
+ ->args([
+ 'user' => 'integer|required',
+ 'type' => 'string',
+ 'include_all' => 'boolean',
+ ])
+ ->auth(PermissionHandler::combine(['user', ['actionNonce' => 'favourites-']]))
+ ->rateLimit(30)
+ ->post([$this, 'handleFavourite'])
+ ->args([
+ 'user' => 'integer|required',
+ 'id' => 'string|required',
+ 'action' => 'string|required|enum:add,remove,toggle,batch,note',
+ 'type' => 'string',
+ 'target_id' => 'integer',
+ 'items' => 'array',
+ 'notes' => 'string',
+ ])
+ ->auth(PermissionHandler::combine(['user', ['actionNonce' => 'favourites-']]))
+ ->rateLimit(30);
+
+ // Lists endpoints
+ Route::for('favourites/lists')
+ ->get([$this, 'getLists'])
+ ->args(['user' => 'integer|required'])
+ ->auth(PermissionHandler::combine(['user', ['actionNonce' => 'favourites-']]))
+ ->rateLimit(30)
+ ->post([$this, 'handleList'])
+ ->args([
+ 'user' => 'integer|required',
+ 'id' => 'string|required',
+ 'action' => 'string|required|enum:create,update,delete,share,unshare,add_items,remove_items',
+ 'list_id' => 'integer',
+ 'name' => 'string',
+ 'items' => 'array',
+ ])
+ ->auth(PermissionHandler::combine(['user', ['actionNonce' => 'favourites-']]))
+ ->rateLimit(20);
+
+ // Favourite counts
+ Route::for('favourites/counts')
+ ->get([$this, 'getFavouriteCounts'])
+ ->args(['user' => 'integer|required'])
+ ->auth(PermissionHandler::combine(['user', ['actionNonce' => 'favourites-']]));
+ }
+
+ /**
+ * Get user's favourites with optional filtering
+ */
+ public function getFavourites(WP_REST_Request $request): WP_REST_Response
+ {
+ $user_id = absint($request->get_param('user'));
+
+ if (!$this->userCheck($user_id)) {
+ return $this->unauthorized();
}
+
+ $args = $this->buildParams($request);
$key = $this->cache->generateKey($args);
- // Check HTTP cache headers for user-specific data
+
+ // Check cache headers
$cache_check = $this->checkHeaders($request, $key);
if ($cache_check) {
return $cache_check;
}
- if (count($args) === 1 || (array_key_exists('all', $args) && $args['all'] === true)) {
- $result = $this->getAllFavourites($args['user']);
+ if (count($args) === 1 || ($request->get_param('include_all') === true)) {
+ $result = $this->getAllFavourites($user_id);
} else {
- $result = $this->cache->remember(
- $this->cache->generateKey($args),
- function() use ($args) {
- return $this->getFilteredFavourites($args);
- }
- );
+ $result = $this->cache->remember($key, function() use ($args) {
+ return $this->getFilteredFavourites($args);
+ });
}
- $response = new WP_REST_Response($result);
- return $this->addCacheHeaders($response);
- }
- protected function getFilteredFavourites(array $args):array
+ return $this->addCacheHeaders(Response::success($result));
+ }
+
+ /**
+ * Get filtered favourites using CustomTable fluent interface
+ */
+ protected function getFilteredFavourites(array $args): array
{
try {
- global $wpdb;
- $table = $wpdb->prefix . BASE . 'favourites';
-
- // Build query with proper escaping
- $query_parts = ["SELECT f.* FROM {$table} f WHERE user_id = %d"];
- $params = [$args['user']];
+ // Build base query
+ $query = $this->favourites->where(['user_id' => $args['user']]);
// Add type filter if specified
- if ($args['content'] && $args['content']!=='all') {
- $query_parts[] = "AND type = %s";
- $params[] = BASE . $args['content'];
+ if (!empty($args['content']) && $args['content'] !== 'all') {
+ $query = $this->favourites->where([
+ 'user_id' => $args['user'],
+ 'type' => BASE . $args['content']
+ ]);
}
- // Add ordering - make sure to use whitelist for column names
- if ($args['orderby'] === 'date') {
- $args['orderby'] = 'date_added';
+ // Apply ordering and pagination
+ $orderby = in_array($args['orderby'] ?? 'date_added', ['date_added', 'type'])
+ ? $args['orderby']
+ : 'date_added';
+ $order = in_array(strtoupper($args['order'] ?? 'DESC'), ['ASC', 'DESC'])
+ ? strtoupper($args['order'])
+ : 'DESC';
+
+ $favourites = $query
+ ->orderBy($orderby, $order)
+ ->limit(100, ($args['page'] - 1) * 100)
+ ->getResults();
+
+ // Get total count
+ $count_query = $this->favourites->where(['user_id' => $args['user']]);
+ if (!empty($args['content']) && $args['content'] !== 'all') {
+ $count_query->where(['type' => BASE . $args['content']]);
}
- $valid_orderby_columns = ['date_added', 'type'];
- $valid_orders = ['ASC', 'DESC'];
- $orderby = in_array($args['orderby'], $valid_orderby_columns) ? $args['orderby'] : 'date_added';
- $order = in_array(strtoupper($args['order']), $valid_orders) ? strtoupper($args['order']) : 'DESC';
- $query_parts[] = "ORDER BY {$orderby} {$order}";
+ $total_items = $count_query->countResults();
- // Add pagination
- $query_parts[] = "LIMIT %d OFFSET %d";
- $params[] = 100;
- $params[] = ($args['page'] - 1) * 100;
-
- // Execute query
- $query = implode(' ', $query_parts);
- $favourites = $wpdb->get_results($wpdb->prepare($query, $params));
-
- // Get total count for pagination
- $count_query = "SELECT COUNT(*) FROM {$table} WHERE user_id = %d";
- $count_params = [$args['user']];
-
- if ($args['content'] && $args['content'] !== 'all') {
- $count_query .= " AND type = %s";
- $count_params[] = BASE . $args['content'];
- }
-
- $total_items = (int)$wpdb->get_var($wpdb->prepare($count_query, $count_params));
-
- // Format the favourites using batch processing to reduce queries
- $formatted = $this->formatItems($favourites);
-
- // Get counts by type for filters
- $counts = $this->getFavouriteCounts($args['user']);
-
- // Prepare response data
return [
- 'items' => $formatted,
- 'has_more' => ($args['page'] * 100) < $total_items,
- 'total' => $total_items,
- 'success' => true,
+ 'items' => $this->formatItems($favourites),
+ 'has_more' => ($args['page'] * 100) < $total_items,
+ 'total' => $total_items,
+ 'success' => true,
];
} catch (Exception $e) {
- $this->logError(
- $e->getMessage(),
- [
- 'method' => 'getFilteredFavourites',
- 'args' => $args
- ]
- );
+ $this->logError('getFilteredFavourites', [
+ 'error' => $e->getMessage(),
+ 'args' => $args
+ ]);
+
return [
- 'success' => false,
- 'favourites' => [],
- 'counts' => 0,
- 'pagination' => []
+ 'success' => false,
+ 'items' => [],
+ 'total' => 0,
+ 'has_more' => false
];
}
}
- /**
- * Get all user's favourites organized by content type
- *
- * @param int $user_id User ID
- * @return WP_REST_Response Response with favourites by content type
- */
- protected function getAllFavourites(int $user_id):WP_REST_Response
- {
- if (!$this->checkUser($user_id)) {
- return new WP_REST_Response([
- 'success' => false,
- 'message' => 'User ID doesn\'t match... are you a bot?'
- ]);
- }
-
- $result = $this->cache->remember(
- $user_id,
- function() use ($user_id) {
- return $this->fetchAllFavourites($user_id);
- }
- );
-
- return new WP_REST_Response($result);
- }
-
- protected function fetchAllFavourites(int $user_id):array
+ /**
+ * Get all user's favourites organized by content type
+ */
+ protected function getAllFavourites(int $user_id): array
{
- try {
- global $wpdb;
- $table = $wpdb->prefix . BASE . 'favourites';
+ return $this->cache->remember($user_id, function() use ($user_id) {
+ try {
+ $favourites = $this->favourites
+ ->where(['user_id' => $user_id])
+ ->getResults();
- // Get all favourites for this user
- $query = $wpdb->prepare(
- "SELECT type, target_id FROM {$table} WHERE user_id = %d",
- $user_id
- );
-
- $favourites = $wpdb->get_results($query);
-
- // Organize by content type
- $by_type = [];
-
- foreach ($favourites as $fav) {
- $type = str_replace(BASE, '', $fav->type);
-
- if (!isset($by_type[$type])) {
- $by_type[$type] = [];
+ $by_type = [];
+ foreach ($favourites as $fav) {
+ $type = str_replace(BASE, '', $fav->type);
+ if (!isset($by_type[$type])) {
+ $by_type[$type] = [];
+ }
+ $by_type[$type][] = (int)$fav->target_id;
}
- $by_type[$type][] = (int)$fav->target_id;
+ return [
+ 'success' => true,
+ 'items' => $by_type,
+ 'has_more' => false,
+ ];
+
+ } catch (Exception $e) {
+ $this->logError('getAllFavourites', [
+ 'error' => $e->getMessage(),
+ 'user_id' => $user_id
+ ]);
+
+ return [
+ 'success' => false,
+ 'items' => [],
+ ];
+ }
+ });
+ }
+
+ /**
+ * Handle favourite operations
+ */
+ public function handleFavourite(WP_REST_Request $request): WP_REST_Response
+ {
+ $user_id = absint($request->get_param('user'));
+ $operation_id = sanitize_text_field($request->get_param('id'));
+ $action = sanitize_text_field($request->get_param('action'));
+
+ if (!$this->userCheck($user_id)) {
+ return $this->unauthorized();
+ }
+
+ $data = [
+ 'action' => $action,
+ 'type' => sanitize_text_field($request->get_param('type') ?? ''),
+ 'target_id' => absint($request->get_param('target_id') ?? 0),
+ 'items' => $request->get_param('items') ?? [],
+ 'notes' => sanitize_textarea_field($request->get_param('notes') ?? ''),
+ ];
+
+ JVB()->queue()->queueOperation(
+ 'favourite_' . $action,
+ $user_id,
+ $data,
+ [
+ 'operation_id' => $operation_id,
+ 'priority' => 'high',
+ ]
+ );
+
+ return $this->queued($operation_id);
+ }
+
+ /**
+ * Get user's favourite lists
+ */
+ public function getLists(WP_REST_Request $request): WP_REST_Response
+ {
+ $user_id = absint($request->get_param('user'));
+
+ if (!$this->userCheck($user_id)) {
+ return $this->unauthorized();
+ }
+
+ $params = ['user' => $user_id];
+ if ($request->get_param('id')) {
+ $params['list'] = sanitize_text_field($request->get_param('id'));
+ }
+
+ $key = $this->listsCache->generateKey($params);
+
+ // Check cache headers
+ $cache_check = $this->checkHeaders($request, $key);
+ if ($cache_check) {
+ return $cache_check;
+ }
+
+ $list_id = $request->get_param('id');
+ $response = $list_id
+ ? $this->getListDetails($list_id, $user_id)
+ : $this->getAvailableLists($user_id);
+
+ return $this->addCacheHeaders(Response::success($response));
+ }
+
+ /**
+ * Get lists available to a user using CustomTable
+ */
+ protected function getAvailableLists(int $user_id, bool $include_shared = true): array
+ {
+ if (!$this->checkUser($user_id)) {
+ return [];
+ }
+
+ $cache = $include_shared ? $this->sharedListsCache : $this->listsCache;
+
+ return $cache->remember($user_id, function() use ($user_id, $include_shared) {
+ try {
+ // Get owned lists
+ $owned = $this->lists
+ ->where(['user_id' => $user_id])
+ ->orderBy('created_at', 'DESC')
+ ->getResults(ARRAY_A);
+
+ // Add item counts
+ foreach ($owned as &$list) {
+ $list['item_count'] = $this->listItems
+ ->where(['list_id' => $list['id']])
+ ->countResults();
+ $list['is_owner'] = true;
+ $list['is_shared'] = false;
+ }
+
+ if (!$include_shared) {
+ return [
+ 'success' => true,
+ 'lists' => $owned
+ ];
+ }
+
+ // Get shared lists
+ $shares = $this->listShares
+ ->where(['user_id' => $user_id, 'status' => 'accepted'])
+ ->getResults();
+
+ $shared_lists = [];
+ foreach ($shares as $share) {
+ $list = $this->lists
+ ->where(['id' => $share->list_id])
+ ->first(ARRAY_A);
+
+ if ($list) {
+ $owner = get_userdata($list['user_id']);
+ $list['owner_name'] = $owner ? $owner->display_name : 'Unknown';
+ $list['item_count'] = $this->listItems
+ ->where(['list_id' => $list['id']])
+ ->countResults();
+ $list['permission_type'] = $share->permission_type;
+ $list['is_owner'] = false;
+ $list['is_shared'] = true;
+
+ $shared_lists[] = $list;
+ }
+ }
+
+ return [
+ 'success' => true,
+ 'lists' => [
+ 'owned' => $owned,
+ 'shared' => $shared_lists
+ ]
+ ];
+
+ } catch (Exception $e) {
+ $this->logError('getAvailableLists', [
+ 'error' => $e->getMessage(),
+ 'user_id' => $user_id
+ ]);
+
+ return [];
+ }
+ });
+ }
+
+ /**
+ * Get favourite counts by type
+ */
+ public function getFavouriteCounts(WP_REST_Request $request): WP_REST_Response
+ {
+ $user_id = absint($request->get_param('user'));
+
+ if (!$this->userCheck($user_id)) {
+ return $this->unauthorized();
+ }
+
+ $key = "counts_{$user_id}";
+
+ $counts = $this->cache->remember($key, function() use ($user_id) {
+ try {
+ // Get counts grouped by type using raw query
+ $results = $this->favourites->queryResults(
+ "SELECT type, COUNT(*) as count FROM {table} WHERE user_id = %d GROUP BY type",
+ [$user_id],
+ OBJECT_K
+ );
+
+ $all_counts = array_fill_keys(
+ array_map(fn($type) => str_replace(BASE, '', $type), array_keys($this->valid_types)),
+ 0
+ );
+
+ foreach ($results as $type => $data) {
+ $type_key = str_replace(BASE, '', $type);
+ $all_counts[$type_key] = (int)$data->count;
+ }
+
+ return $all_counts;
+
+ } catch (Exception $e) {
+ $this->logError('getFavouriteCounts', [
+ 'error' => $e->getMessage(),
+ 'user_id' => $user_id
+ ]);
+
+ return array_fill_keys(array_keys($this->valid_types), 0);
+ }
+ });
+
+ return Response::success(['counts' => $counts]);
+ }
+
+ /**
+ * Process favourite operations using transactions
+ */
+ public function processOperation($result, object $operation, array $data)
+ {
+ $action_map = [
+ 'favourite_add' => 'addFavourite',
+ 'favourite_remove' => 'removeFavourite',
+ 'favourite_batch' => 'batchFavourites',
+ 'favourite_note' => 'processNote',
+ 'favourite_list_create' => 'createList',
+ 'favourite_list_update' => 'updateList',
+ 'favourite_list_delete' => 'deleteList',
+ 'favourite_list_add_items' => 'addToList',
+ 'favourite_list_remove_items' => 'removeFromList',
+ 'favourite_list_share' => 'shareList',
+ 'favourite_list_unshare' => 'unshareList',
+ ];
+
+ if (!isset($action_map[$operation->type])) {
+ return $result;
+ }
+
+ try {
+ $method = $action_map[$operation->type];
+ $response = $this->$method($operation->user_id, $data);
+
+ // Clear cache on success
+ if ($response['success'] ?? false) {
+ Cache::invalidateItem('favourites', $operation->user_id);
+ $this->listsCache->flush();
+ $this->sharedListsCache->flush();
+ }
+
+ return $response;
+ } catch (Exception $e) {
+ $this->logError('processOperation', [
+ 'error' => $e->getMessage(),
+ 'operation_id' => $operation->id,
+ 'type' => $operation->type
+ ]);
+
+ return [
+ 'success' => false,
+ 'result' => $e->getMessage()
+ ];
+ }
+ }
+
+ /**
+ * Add a favourite using findOrCreate pattern
+ */
+ protected function addFavourite(int $user_id, array $data): array
+ {
+ $type = $data['type'];
+ $target_id = $data['target_id'];
+
+ if (!str_starts_with($type, BASE)) {
+ $type = BASE . $type;
+ }
+
+ if (!isset($this->valid_types[$type])) {
+ return ['success' => false, 'result' => 'Invalid type'];
+ }
+
+ // Use findOrCreate pattern
+ $result = $this->favourites->findOrCreate([
+ 'user_id' => $user_id,
+ 'type' => $type,
+ 'target_id' => $target_id
+ ]);
+
+ if ($result['created']) {
+ $this->updateFavouriteCount($type, $target_id);
+ $this->maybeNotifyOwner($type, $target_id, $user_id);
+ }
+
+ return [
+ 'success' => true,
+ 'result' => [
+ 'action' => $result['created'] ? 'added' : 'already_exists',
+ 'favourite_id' => $result['id'],
+ 'count' => $this->getFavouriteCount($type, $target_id)
+ ]
+ ];
+ }
+
+ /**
+ * Remove a favourite
+ */
+ protected function removeFavourite(int $user_id, array $data): array
+ {
+ $type = $data['type'];
+ $target_id = $data['target_id'];
+
+ if (!str_starts_with($type, BASE)) {
+ $type = BASE . $type;
+ }
+
+ $deleted = $this->favourites->where([
+ 'user_id' => $user_id,
+ 'type' => $type,
+ 'target_id' => $target_id
+ ])->deleteResults();
+
+ if ($deleted) {
+ $this->updateFavouriteCount($type, $target_id);
+ $this->removeRelatedNotifications($type, $target_id, $user_id);
+ }
+
+ return [
+ 'success' => true,
+ 'result' => [
+ 'action' => $deleted ? 'removed' : 'not_found',
+ 'count' => $this->getFavouriteCount($type, $target_id)
+ ]
+ ];
+ }
+
+ /**
+ * Batch favourites using transaction
+ */
+ protected function batchFavourites(int $user_id, array $data): array
+ {
+ $items = $data['items'] ?? [];
+
+ if (empty($items)) {
+ return ['success' => false, 'result' => 'No items provided'];
+ }
+
+ return $this->favourites->transaction(function($table) use ($user_id, $items) {
+ $results = [
+ 'added' => 0,
+ 'removed' => 0,
+ 'errors' => []
+ ];
+
+ foreach ($items as $item) {
+ try {
+ $type = BASE . ($item['type'] ?? '');
+ $target_id = absint($item['target_id'] ?? 0);
+ $action = $item['action'] ?? 'add';
+
+ if ($action === 'add') {
+ $result = $table->findOrCreate([
+ 'user_id' => $user_id,
+ 'type' => $type,
+ 'target_id' => $target_id
+ ]);
+ if ($result['created']) $results['added']++;
+ } else {
+ $deleted = $table->where([
+ 'user_id' => $user_id,
+ 'type' => $type,
+ 'target_id' => $target_id
+ ])->deleteResults();
+ if ($deleted) $results['removed']++;
+ }
+
+ $this->updateFavouriteCount($type, $target_id);
+ } catch (Exception $e) {
+ $results['errors'][] = $item['target_id'] ?? 'unknown';
+ }
}
return [
'success' => true,
- 'items' => $by_type,
- 'has_more' => false,
+ 'result' => $results
];
+ });
+ }
+
+ /**
+ * Create a list using transaction
+ */
+ protected function createList(int $user_id, array $data): array
+ {
+ $name = sanitize_text_field($data['name'] ?? 'Untitled List');
+ $description = sanitize_textarea_field($data['description'] ?? '');
+ $items = $data['items'] ?? [];
+
+ return $this->lists->transaction(function($table) use ($user_id, $name, $description, $items) {
+ $list_id = $table->create([
+ 'user_id' => $user_id,
+ 'name' => $name,
+ 'description' => $description,
+ ]);
+
+ if (!$list_id) {
+ throw new Exception('Failed to create list');
+ }
+
+ $added_count = 0;
+ if (!empty($items)) {
+ $result = $this->addItemsToList($list_id, $items, $user_id);
+ $added_count = $result['added'];
+ }
+
+ return [
+ 'success' => true,
+ 'result' => [
+ 'list_id' => $list_id,
+ 'name' => $name,
+ 'added_items' => $added_count,
+ ]
+ ];
+ });
+ }
+
+ /**
+ * Add items to list with bulk insert
+ */
+ protected function addItemsToList(int $list_id, array $items, int $user_id): array
+ {
+ if (empty($items)) {
+ return ['added' => 0, 'errors' => []];
+ }
+
+ $added = 0;
+ $errors = [];
+
+ try {
+ // Group items by type
+ $items_by_type = [];
+ foreach ($items as $item) {
+ if (empty($item['type']) || !isset($item['target_id'])) {
+ $errors[] = ['message' => 'Item missing type or target_id', 'item' => $item];
+ continue;
+ }
+
+ $type = str_starts_with($item['type'], BASE)
+ ? $item['type']
+ : BASE . $item['type'];
+
+ if (!isset($items_by_type[$type])) {
+ $items_by_type[$type] = [];
+ }
+
+ $items_by_type[$type][] = absint($item['target_id']);
+ }
+
+ foreach ($items_by_type as $type => $target_ids) {
+ // Get existing items to avoid duplicates
+ $existing = $this->listItems
+ ->where(['list_id' => $list_id, 'item_type' => $type])
+ ->getResults();
+
+ $existing_map = [];
+ foreach ($existing as $item) {
+ $existing_map[$item->item_id] = true;
+ }
+
+ // Get favourite IDs in bulk
+ $favs = $this->favourites
+ ->where(['user_id' => $user_id, 'type' => $type])
+ ->getResults();
+
+ $fav_map = [];
+ foreach ($favs as $fav) {
+ $fav_map[$fav->target_id] = $fav->id;
+ }
+
+ // Prepare items for bulk insert
+ $to_insert = [];
+ foreach ($target_ids as $target_id) {
+ if (isset($existing_map[$target_id])) {
+ continue;
+ }
+
+ $to_insert[] = [
+ 'list_id' => $list_id,
+ 'item_type' => $type,
+ 'item_id' => $target_id,
+ 'favourite_id' => $fav_map[$target_id] ?? null
+ ];
+ }
+
+ // Bulk insert
+ if (!empty($to_insert)) {
+ $columns = ['list_id', 'item_type', 'item_id', 'favourite_id'];
+ $result = $this->listItems->bulkInsert($to_insert, $columns);
+ $added += $result;
+ }
+ }
+
+ // Update list timestamp
+ $this->lists->where(['id' => $list_id])->updateResults([]);
+
+ return ['added' => $added, 'errors' => $errors];
} catch (Exception $e) {
- $this->logError(
- $e->getMessage(),
- [
- 'context' => 'fetchAllFavourites',
- 'user' => $user_id
- ]
- );
- return [
- 'success' => false,
- 'favourites' => [],
- ];
+ $this->logError('addItemsToList', [
+ 'error' => $e->getMessage(),
+ 'user_id' => $user_id,
+ 'list_id' => $list_id,
+ ]);
+
+ $errors[] = ['message' => $e->getMessage()];
+ return ['added' => 0, 'errors' => $errors];
}
}
- /**
- * Handle favourite operations (toggle, notes update)
- *
- * @param WP_REST_Request $request Request object
- * @return WP_REST_Response Response with operation result
- */
- public function handleFavouriteOperation(WP_REST_Request $request): WP_REST_Response
- {
- $data = $request->get_json_params();
- $operation = $data['operation'] ?? 'toggle';
- $user_id = get_current_user_id();
+ /**
+ * Clean up favourites when a post is deleted
+ */
+ public function cleanupPostFavourites(int $post_id): void
+ {
+ try {
+ $type = get_post_type($post_id);
+ if (!$type) return;
- $queue = JVB()->queue();
- $operation_id = $data['id'] ?? uniqid('fav_');
+ $type = BASE . $type;
- error_log('Favourite Request: '.print_r($data, true));
+ // Delete using fluent interface
+ $this->favourites->where([
+ 'type' => $type,
+ 'target_id' => $post_id
+ ])->deleteResults();
- switch ($operation) {
- case 'toggle':
- $adds = $request->get_param('adds') ?? [];
- $removes = $request->get_param('removes') ?? [];
+ $this->listItems->where([
+ 'item_type' => $type,
+ 'item_id' => $post_id
+ ])->deleteResults();
- $queue->queueOperation(
- 'favourites_batch',
- $request->get_param('user'),
- [
- 'adds' => $adds,
- 'removes' => $removes
- ],
- [
- 'count' => count($adds) + count($removes),
- 'chunk_key' => ['adds', 'removes'],
- 'chunk_size' => 20,
- 'priority' => 'normal',
- 'operation_id' => $request->get_param('id')
- ]
- );
-
- break;
-
- case 'update_notes':
- // Handle notes update
- if (!array_key_exists('target_id', $data)) {
- return $this->createErrorResponse(
- self::ERROR_MISSING_PARAMS,
- 'Type and target ID are required',
- 400
- );
- }
-
- $ids = explode(',', $data['target_id']);
- foreach ($ids as $key => $id) {
- $ids[$key] = (int)$id;
- }
- $ids = implode(',', $ids);
-
- $queue->queueOperation(
- 'favourite_notes',
- $user_id,
- [
- 'target_id' => $ids,
- 'notes' => sanitize_textarea_field($data['notes'] ?? '')
- ],
- [
- 'count' => 1,
- 'operation_id' => $operation_id
- ]
- );
-
- break;
-
- default:
- return $this->createErrorResponse(
- self::ERROR_INVALID_OPERATION,
- 'Invalid operation',
- 400
- );
- }
-
- return new WP_REST_Response([
- 'success' => true,
- 'message' => __('Operation queued', 'jvb'),
- 'operation_id' => $operation_id,
- 'queue_status' => $queue->getQueueStatus()
- ]);
- }
-
- /**
- * Get user's favourite lists
- *
- * @param WP_REST_Request $request Request object
- * @return WP_REST_Response Response with lists data
- */
- public function getLists(WP_REST_Request $request):WP_REST_Response
- {
- $user_id = get_current_user_id();
-
- if (!$user_id || !$this->userCheck($user_id)) {
- return new WP_REST_Response([
- 'success' => false,
- 'message' => 'Invalid user'
+ } catch (Exception $e) {
+ $this->logError('cleanupPostFavourites', [
+ 'error' => $e->getMessage(),
+ 'post_id' => $post_id
]);
}
+ }
- $params = [
- 'user' => $user_id,
- ];
- if ($request->get_param('id')) {
- $params['list'] = sanitize_text_field($request->get_param('id'));
- }
- $key = $this->listsCache->generateKey($params);
- // Check HTTP cache headers
- $cache_check = $this->checkHeaders($request, $key);
- if ($cache_check) {
- return $cache_check;
- }
-
- $list_id = $request->get_param('id');
-
- if ($list_id) {
- $response = $this->getListDetails($list_id, $user_id);
- } else {
- $response = $this->getAvailableLists($user_id);
- }
-
- $response = new WP_REST_Response($response);
- return $this->addCacheHeaders($response);
- }
- /**
- * Get lists available to a user (owned and shared)
- *
- * @param int $user_id User ID
- * @param bool $include_shared Include lists shared with user
- * @return array Lists data
- */
- public function getAvailableLists(int $user_id, bool $include_shared = true):array
- {
- if (!$this->checkUser($user_id)) {
- return [];
- }
-
- $cache = ($include_shared) ? $this->sharedListsCache : $this->listsCache;
- return $cache->remember(
- $user_id,
- function() use ($user_id, $include_shared) {
- global $wpdb;
- error_log('Attempting to get available lists..');
- $lists_table = $wpdb->prefix . BASE . 'favourites_lists';
- $items_table = $wpdb->prefix . BASE . 'favourites_list_items';
- $shares_table = $wpdb->prefix . BASE . 'favourites_list_shares';
-
- try {
- // Get owned lists
- $owned_query = $wpdb->prepare(
- "SELECT l.*,
- COUNT(DISTINCT i.id) as item_count,
- TRUE as is_owner,
- FALSE as is_shared
- FROM {$lists_table} l
- LEFT JOIN {$items_table} i ON l.id = i.list_id
- WHERE l.user_id = %d
- GROUP BY l.id
- ORDER BY l.created_at DESC",
- $user_id
- );
-
- $lists = $wpdb->get_results($owned_query);
- error_log('Lists result: '.print_r($lists, true));
-
- // Add shared lists if requested
- if ($include_shared) {
- $shared_query = $wpdb->prepare(
- "SELECT l.*,
- u.display_name as owner_name,
- COUNT(DISTINCT i.id) as item_count,
- s.permission_type,
- FALSE as is_owner,
- TRUE as is_shared
- FROM {$lists_table} l
- JOIN {$shares_table} s ON l.id = s.list_id
- JOIN {$wpdb->users} u ON l.user_id = u.ID
- LEFT JOIN {$items_table} i ON l.id = i.list_id
- WHERE s.user_id = %d
- GROUP BY l.id
- ORDER BY l.created_at DESC",
- $user_id
- );
-
- $shared_lists = $wpdb->get_results($shared_query);
- error_log('Shared lists: '.print_r($shared_lists, true));
- $lists = [
- 'owned' => $lists,
- 'shared'=> $shared_lists,
- ];
- }
- error_log('Lists: '.print_r($lists, true));
- return [
- 'success' => true,
- 'lists' => $lists
- ];
- } catch (Exception $e) {
- JVB()->error()->log(
- 'favourites',
- 'Error getting available lists: ' . $e->getMessage(),
- ['user_id' => $user_id],
- 'error'
- );
-
- return [];
- }
+ /**
+ * Clean up favourites when a term is deleted
+ */
+ public function cleanupTermFavourites(int $term_id, int $tt_id, string $taxonomy): void
+ {
+ try {
+ if (!isset($this->valid_types[$taxonomy])) {
+ return;
}
- );
- }
- /**
- * Get detailed information for a single list
- *
- * @param int $list_id List ID
- * @param int $user_id User ID
- * @return WP_REST_Response|WP_Error Response with list details or error
- */
- protected function getListDetails(int $list_id, int $user_id):WP_REST_Response
- {
- $key = sprintf(
- 'user_%d_list_%d',
- $user_id,
- $list_id
- );
- $cache = $this->listsCache->get($key);
- if ($cache) {
- return new WP_REST_Response($cache);
- }
- //No cache, build it again
- global $wpdb;
- $lists_table = $wpdb->prefix . BASE . 'favourites_lists';
- $items_table = $wpdb->prefix . BASE . 'favourites_list_items';
- $shares_table = $wpdb->prefix . BASE . 'favourites_list_shares';
- $pending_shares_table = $wpdb->prefix . BASE . 'favourites_pending_shares';
+ // Delete using fluent interface
+ $this->favourites->where([
+ 'type' => $taxonomy,
+ 'target_id' => $term_id
+ ])->deleteResults();
- // Check if user has access to this list
- $list_access = $wpdb->get_row($wpdb->prepare("
- SELECT l.*,
- CASE WHEN l.user_id = %d THEN 1 ELSE 0 END as is_owner,
- CASE WHEN s.id IS NOT NULL THEN 1 ELSE 0 END as is_shared
- FROM {$lists_table} l
- LEFT JOIN {$shares_table} s ON l.id = s.list_id AND s.user_id = %d
- WHERE l.id = %d
- ", $user_id, $user_id, $list_id));
+ $this->listItems->where([
+ 'item_type' => $taxonomy,
+ 'item_id' => $term_id
+ ])->deleteResults();
- if (!$list_access || (!$list_access->is_owner && !$list_access->is_shared)) {
- return $this->createErrorResponse(
- self::ERROR_ACCESS_DENIED,
- 'You do not have access to this list',
- 403
- );
- }
-
- // Get list items
- $items = $wpdb->get_results($wpdb->prepare("
- SELECT i.*, f.user_id, f.type, f.target_id, f.notes
- FROM {$items_table} i
- LEFT JOIN {$wpdb->prefix}" . BASE . "favourites f
- ON i.favourite_id = f.id
- WHERE i.list_id = %d
- ORDER BY i.added_at DESC
- ", $list_id));
-
- // Format items using batch processing to reduce queries
- $formatted_items = [];
- $dummy_items = [];
- $favourite_items = [];
-
- foreach ($items as $item) {
- if ($item->favourite_id === null) {
- // Create a dummy favourite object
- $dummy_items[] = (object)[
- 'type' => $item->item_type,
- 'target_id' => $item->item_id,
- 'date_added' => $item->added_at
- ];
- } else {
- // Use the joined favourite data
- $favourite_items[] = (object)[
- 'id' => $item->favourite_id,
- 'user_id' => $item->user_id,
- 'type' => $item->type,
- 'target_id' => $item->target_id,
- 'notes' => $item->notes,
- 'date_added' => $item->added_at
- ];
- }
- }
-
- // Process items in batches to reduce DB queries
- $formatted_dummy_items = $this->formatItems($dummy_items);
- $formatted_favourite_items = $this->formatItems($favourite_items);
-
- // Combine formatted items
- $formatted_items = array_merge($formatted_dummy_items, $formatted_favourite_items);
-
- // Get shared users if owner
- $shared_users = [];
- if ($list_access->is_owner) {
- // Get active shares
- $active_shares = $wpdb->get_results($wpdb->prepare("
- SELECT s.*, u.user_email as email, u.display_name
- FROM {$shares_table} s
- JOIN {$wpdb->users} u ON s.user_id = u.ID
- WHERE s.list_id = %d
- ", $list_id));
-
- // Get pending shares
- $pending_shares = $wpdb->get_results($wpdb->prepare("
- SELECT * FROM {$pending_shares_table}
- WHERE list_id = %d
- ", $list_id));
-
- // Format shared users
- foreach ($active_shares as $share) {
- $shared_users[] = [
- 'email' => $share->email,
- 'name' => $share->display_name,
- 'permission_type' => $share->permission_type,
- 'date_added' => $share->created_at,
- 'status' => 'active'
- ];
- }
-
- foreach ($pending_shares as $share) {
- $shared_users[] = [
- 'email' => $share->email,
- 'invitation_token' => $share->invitation_token,
- 'date_added' => $share->created_at,
- 'status' => 'pending'
- ];
- }
- }
-
- // Prepare response data
- $response_data = [
- 'success' => true,
- 'list' => [
- 'id' => (int)$list_access->id,
- 'name' => $list_access->name,
- 'description' => $list_access->description,
- 'created_at' => $list_access->created_at,
- 'is_owner' => (bool)$list_access->is_owner,
- 'is_shared' => (bool)$list_access->is_shared,
- 'items' => $formatted_items,
- 'shared_users' => $shared_users
- ]
- ];
-
- $this->listsCache->set($key, $response_data);
- return new WP_REST_Response($response_data);
- }
-
- /**
- * Handle list operations (create, update, delete, add/remove items)
- *
- * @param WP_REST_Request $request Request object
- * @return WP_REST_Response Response with operation result
- */
- public function handleListOperation(WP_REST_Request $request):WP_REST_Response
- {
- $data = $request->get_json_params();
- $operation = $data['operation'] ?? '';
- $user_id = get_current_user_id();
-
- // Get queue system
- $queue = JVB()->queue();
- $operation_id = (array_key_exists('id', $data)) ? $data['id'] : uniqid('list_');
-
- // Process based on operation type
- switch ($operation) {
- case 'create':
- // Create new list
- if (!array_key_exists('name', $data)) {
- return $this->createErrorResponse(
- self::ERROR_MISSING_PARAMS,
- 'List name is required',
- 400
- );
- }
-
- $queue->queueOperation(
- 'favourite_list_create',
- $user_id,
- [
- 'name' => sanitize_text_field($data['name']),
- 'description' => sanitize_textarea_field($data['description'] ?? ''),
- 'items' => $data['items'] ?? []
- ],
- [
- 'count' => 1,
- 'operation_id' => $operation_id
- ]
- );
- break;
-
- case 'update':
- // Update list
- if (!array_key_exists('list_id', $data)) {
- return $this->createErrorResponse(
- self::ERROR_MISSING_PARAMS,
- 'List ID is required',
- 400
- );
- }
-
- $queue->queueOperation(
- 'favourite_list_update',
- $user_id,
- [
- 'list_id' => (int)$data['list_id'],
- 'name' => $data['name'] ?? null,
- 'description' => $data['description'] ?? null
- ],
- [
- 'count' => 1,
- 'operation_id' => $operation_id
- ]
- );
- break;
-
- case 'delete':
- // Delete list
- if (!array_key_exists('list_id', $data)) {
- return $this->createErrorResponse(
- self::ERROR_MISSING_PARAMS,
- 'List ID is required',
- 400
- );
- }
-
- $queue->queueOperation(
- 'favourite_list_delete',
- $user_id,
- [
- 'list_id' => (int)$data['list_id']
- ],
- [
- 'count' => 1,
- 'operation_id' => $operation_id
- ]
- );
- break;
-
- case 'add_items':
- // Add items to list
- if (!array_key_exists('list_id', $data) || !array_key_exists('items', $data)) {
- return $this->createErrorResponse(
- self::ERROR_MISSING_PARAMS,
- 'List ID and items are required',
- 400
- );
- }
-
- $queue->queueOperation(
- 'favourite_list_add',
- $user_id,
- [
- 'list_id' => (int)$data['list_id'],
- 'items' => $data['items']
- ],
- [
- 'count' => 1,
- 'chunk_key' => 'items',
- 'chunk_size' => 20,
- 'operation_id' => $operation_id
- ]
- );
- break;
-
- case 'remove_items':
- // Remove items from list
- if (!array_key_exists('list_id', $data) || !array_key_exists('items', $data)) {
- return $this->createErrorResponse(
- self::ERROR_MISSING_PARAMS,
- 'List ID and items are required',
- 400
- );
- }
-
- $queue->queueOperation(
- 'favourite_list_remove',
- $user_id,
- [
- 'list_id' => (int)$data['list_id'],
- 'items' => $data['items']
- ],
- [
- 'count' => 1,
- 'chunk_key' => 'items',
- 'chunk_size' => 20,
- 'operation_id' => $operation_id
- ]
- );
- break;
-
- default:
- return $this->createErrorResponse(
- self::ERROR_INVALID_OPERATION,
- 'Invalid list operation',
- 400
- );
- }
-
-
- return new WP_REST_Response([
- 'success' => true,
- 'message' => __('List operation queued', 'jvb'),
- 'operation_id' => $operation_id,
- 'queue_status' => $queue->getQueueStatus()
- ]);
- }
-
- /**
- * Get shares for a list
- *
- * @param WP_REST_Request $request Request object
- * @return WP_REST_Response Response with shares data
- */
- public function getShares(WP_REST_Request $request):WP_REST_Response
- {
- $user_id = $request->get_param('user');
-
- if (!$user_id || !$this->userCheck($user_id)) {
- return new WP_REST_Response([
- 'success' => false,
- 'message' => 'Invalid user'
+ } catch (Exception $e) {
+ $this->logError('cleanupTermFavourites', [
+ 'error' => $e->getMessage(),
+ 'term_id' => $term_id,
+ 'taxonomy' => $taxonomy
]);
}
+ }
- $list_id = $request->get_param('list_id');
-
- if (!$list_id) {
- return $this->createErrorResponse(
- self::ERROR_MISSING_PARAMS,
- 'List ID is required',
- 400
+ /**
+ * Cleanup orphaned favourites using CustomTable query method
+ */
+ public function cleanupOrphanedFavourites(): bool
+ {
+ try {
+ // Delete favourites for non-existent users
+ $this->favourites->query(
+ "DELETE f FROM {table} f
+ LEFT JOIN {$GLOBALS['wpdb']->users} u ON f.user_id = u.ID
+ WHERE u.ID IS NULL"
);
+
+ // Delete favourites for non-existent posts
+ $post_types = array_filter(
+ array_keys($this->valid_types),
+ fn($type) => $this->valid_types[$type]['table'] === 'post'
+ );
+
+ foreach ($post_types as $type) {
+ $this->favourites->query(
+ "DELETE f FROM {table} f
+ LEFT JOIN {$GLOBALS['wpdb']->posts} p ON f.target_id = p.ID
+ WHERE f.type = %s AND p.ID IS NULL",
+ [$type]
+ );
+ }
+
+ return true;
+
+ } catch (Exception $e) {
+ $this->logError('cleanupOrphanedFavourites', [
+ 'error' => $e->getMessage()
+ ]);
+
+ return false;
+ }
+ }
+
+ /**
+ * Helper methods
+ */
+ protected function buildParams(WP_REST_Request $request): array
+ {
+ $data = $request->get_params();
+ $args = ['user' => absint($data['user'])];
+
+ if (!array_key_exists('page', $data)) {
+ return $args;
}
- $args = [
- 'user' => $user_id,
- 'list' => sanitize_text_field($list_id),
- ];
- $key = $this->sharedListsCache->generateKey($args);
- // Check HTTP cache headers
- $cache_check = $this->checkHeaders($request, $key);
- if ($cache_check) {
- return $cache_check;
+ $args = array_merge($args, [
+ 'page' => max(1, absint($data['page'] ?? 1)),
+ 'content' => $this->checkContent($data['content'] ?? 'all')
+ ]);
+
+ return $this->applyOrderFilters($args, $data);
+ }
+
+
+ /**
+ * Batch format a collection of favourite items
+ *
+ * @param array $items Collection of favourite items
+ * @return array Formatted items
+ */
+ protected function formatItems(array $items):array
+ {
+ if (empty($items)) {
+ return [];
}
+ // Group items by type to reduce queries
+ $items_by_type = [];
+ foreach ($items as $item) {
+ if (!isset($item->type) || !isset($item->target_id)) {
+ continue;
+ }
+ // Verify item type is valid
+ if (!isset($this->valid_types[$item->type])) {
+ continue;
+ }
- $cache = $this->sharedListsCache->get($key);
- if ($cache) {
- return new WP_REST_Response($cache);
- }
+ $type = $item->type;
+ if (!isset($items_by_type[$type])) {
+ $items_by_type[$type] = [];
+ }
+ $items_by_type[$type][] = $item;
+ }
- try {
- global $wpdb;
- $lists_table = $wpdb->prefix . BASE . 'favourites_lists';
- $shares_table = $wpdb->prefix . BASE . 'favourites_list_shares';
+ $formatted = [];
- // Verify ownership
- $is_owner = $wpdb->get_var($wpdb->prepare(
- "SELECT 1 FROM {$lists_table} WHERE id = %d AND user_id = %d",
- $list_id,
- $user_id
- ));
+ // Process post-type items in batches
+ foreach ($items_by_type as $type => $type_items) {
+ $config = $this->valid_types[$type];
- if (!$is_owner) {
- return $this->createErrorResponse(
- self::ERROR_ACCESS_DENIED,
- 'You do not own this list',
- 403
- );
- }
+ if ($config['table'] === 'post') {
+ $formatted = array_merge($formatted, $this->formatPostFavourites($type_items));
+ } else {
+ $formatted = array_merge($formatted, $this->formatTermFavourites($type_items));
+ }
+ }
- // Get all shares (both active and pending) in one query
- $query = $wpdb->prepare(
- "SELECT s.*,
- CASE
- WHEN s.user_id IS NOT NULL THEN u.display_name
- ELSE NULL
- END as display_name,
- CASE
- WHEN s.user_id IS NOT NULL THEN u.user_email
- ELSE s.email
- END as email
- FROM {$shares_table} s
- LEFT JOIN {$wpdb->users} u ON s.user_id = u.ID
- WHERE s.list_id = %d
- ORDER BY s.status, s.created_at DESC",
- $list_id
- );
+ return $formatted;
+ }
+ protected function getFavouriteCount(string $type, int $target_id): int
+ {
+ return $this->favourites->where([
+ 'type' => $type,
+ 'target_id' => $target_id
+ ])->countResults();
+ }
- $all_shares = $wpdb->get_results($query);
+ protected function updateFavouriteCount(string $type, int $target_id): void
+ {
+ $count = $this->getFavouriteCount($type, $target_id);
- // Format shares for response
- $shares = [];
- foreach ($all_shares as $share) {
- $formatted_share = [
- 'id' => $share->id,
- 'email' => $share->email,
- 'status' => $share->status,
- 'date_added' => $share->created_at,
- ];
+ if (str_contains($type, 'post') || in_array($type, array_keys($this->valid_types))) {
+ update_post_meta($target_id, BASE.'favourite_count', $count);
+ } else {
+ update_term_meta($target_id, BASE.'favourite_count', $count);
+ }
+ }
- // Add attributes specific to the status
- if ($share->status === 'accepted') {
- $formatted_share['name'] = $share->display_name;
- $formatted_share['user_id'] = $share->user_id;
- $formatted_share['permission_type'] = $share->permission_type;
- } else if ($share->status === 'pending') {
- // Include invitation token if needed for managing invitations
- $formatted_share['invitation_token'] = $share->invitation_token;
- }
+ /**
+ * Notify content owner of new favourite if configured
+ *
+ * @param string $type Content type
+ * @param int $target_id Content ID
+ * @param int $user_id User who favourited
+ * @return void
+ */
+ protected function maybeNotifyOwner(string $type, int $target_id, int $user_id): void
+ {
+ try {
+ $owner_id = $this->getContentOwner($type, $target_id);
- $shares[] = $formatted_share;
- }
+ if ($owner_id && $owner_id !== $user_id) {
+ JVB()->notification()->addNotification(
+ $owner_id,
+ 'new_favourite',
+ [
+ 'user_id' => $user_id,
+ 'type' => $type,
+ 'target_id' => $target_id
+ ]
+ );
+ }
+ } catch (Exception $e) {
+ // Silent fail - notifications are non-critical
+ }
+ }
- $response_data = [
- 'success' => true,
- 'list_id' => $list_id,
- 'shares' => $shares
- ];
+ /**
+ * Remove any existing notifications about a favorite action
+ *
+ * @param int $user_id User who removed the favorite
+ * @param string $type Content type
+ * @param int $target_id Content ID
+ * @return void
+ */
+ protected function removeRelatedNotifications(int $user_id, string $type, int $target_id):void
+ {
+ try {
+ // Get the content owner(s)
+ $owner_ids = $this->getContentOwner($type, $target_id);
+ if (!$owner_ids) {
+ return;
+ }
- // Cache the results
- $this->sharedListsCache->set($key, $response_data);
+ $owner_ids = (is_array($owner_ids)) ? $owner_ids : [$owner_ids];
- $response = new WP_REST_Response($response_data);
- return $this->addCacheHeaders($response);
+ foreach ($owner_ids as $owner_id) {
+ // Skip if owner is the same as the user who unfavorited
+ if ($owner_id === $user_id) {
+ continue;
+ }
- } catch (Exception $e) {
- return $this->createErrorResponse(
- self::ERROR_PROCESSING,
- 'Error retrieving shares: ' . $e->getMessage(),
- 500,
- ['user_id' => $user_id, 'list_id' => $list_id]
- );
- }
- }
+ global $wpdb;
+ $notifications_table = $wpdb->prefix . BASE . 'notifications';
- /**
- * Handle share operations (add/remove)
- *
- * @param WP_REST_Request $request Request object
- * @return WP_REST_Response Response with operation result
- */
- public function handleShare(WP_REST_Request $request):WP_REST_Response
- {
- $data = $request->get_json_params();
- $operation = $data['operation'] ?? '';
- $user_id = get_current_user_id();
-
- $queue = JVB()->queue();
- $operation_id = $data['id'] ?? uniqid('share_');
-
- if (empty($data['list_id']) || empty($data['email'])) {
- return $this->createErrorResponse(
- self::ERROR_MISSING_PARAMS,
- 'List ID and email are required',
- 400
- );
- }
-
- switch ($operation) {
- case 'add':
- // Share list with email
- if (!is_email($data['email'])) {
- return $this->createErrorResponse(
- self::ERROR_MISSING_PARAMS,
- 'Invalid email address',
- 400
- );
- }
-
- $queue->queueOperation(
- 'favourite_list_share',
- $user_id,
- [
- 'list_id' => (int)$data['list_id'],
- 'email' => sanitize_email($data['email']),
- 'permission_type' => in_array($data['permission_type'] ?? '', ['view', 'edit'])
- ? $data['permission_type']
- : 'view'
- ],
- [
- 'count' => 1,
- 'operation_id' => $operation_id
- ]
- );
- break;
-
- case 'remove':
- // Remove share
- $queue->queueOperation(
- 'favourite_list_unshare',
- $user_id,
- [
- 'list_id' => (int)$data['list_id'],
- 'email' => sanitize_email($data['email'])
- ],
- [
- 'count' => 1,
- 'operation_id' => $operation_id
- ]
- );
- break;
-
- case 'accept':
- $data = $request->get_json_params();
- $token = $data['token'] ?? '';
- $email = sanitize_email($data['email'] ?? '');
- $user_id = get_current_user_id(); // Get current user if logged in
-
- $result = $this->acceptListInvitation($token, $email, $user_id > 0 ? $user_id : null);
- break;
- default:
- return $this->createErrorResponse(
- self::ERROR_INVALID_OPERATION,
- 'Invalid share operation',
- 400
- );
- }
-
-
- return new WP_REST_Response([
- 'success' => true,
- 'message' => __('Share operation queued', 'jvb'),
- 'operation_id' => $operation_id,
- 'queue_status' => $queue->getQueueStatus()
- ]);
- }
-
- /**
- * Batch format a collection of favourite items
- *
- * @param array $items Collection of favourite items
- * @return array Formatted items
- */
- protected function formatItems(array $items):array
- {
- if (empty($items)) {
- return [];
- }
-
- // Group items by type to reduce queries
- $items_by_type = [];
- foreach ($items as $item) {
- if (!isset($item->type) || !isset($item->target_id)) {
- continue;
- }
-
- // Verify item type is valid
- if (!isset($this->valid_types[$item->type])) {
- continue;
- }
-
- $type = $item->type;
- if (!isset($items_by_type[$type])) {
- $items_by_type[$type] = [];
- }
- $items_by_type[$type][] = $item;
- }
-
- $formatted = [];
-
- // Process post-type items in batches
- foreach ($items_by_type as $type => $type_items) {
- $config = $this->valid_types[$type];
-
- if ($config['table'] === 'post') {
- $formatted = array_merge($formatted, $this->formatPostFavourites($type_items));
- } else {
- $formatted = array_merge($formatted, $this->formatTermFavourites($type_items));
- }
- }
-
- return $formatted;
- }
-
- /**
- * Batch format post-type favourites to reduce queries
- *
- * @param array $items Collection of favourite items of post type
- * @return array Formatted items
- */
- protected function formatPostFavourites(array $items):array
- {
- if (empty($items)) {
- return [];
- }
-
- $formatted = [];
- $post_ids = array_map(function ($item) {
- return (int)$item->target_id;
- }, $items);
-
- // Get all posts in one query
- $posts = get_posts([
- 'post__in' => $post_ids,
- 'post_type' => 'any',
- 'posts_per_page' => -1,
- 'post_status' => 'any',
- ]);
-
- // Create a lookup map
- $posts_by_id = [];
- foreach ($posts as $post) {
- $posts_by_id[$post->ID] = $post;
- }
-
- // Get all thumbnails for artists in one query if needed
- $artist_ids = [];
- foreach ($items as $item) {
- if ($item->type === BASE.'artist') {
- $artist_ids[] = (int)$item->target_id;
- }
- }
-
- $artist_images = [];
- if (!empty($artist_ids)) {
- global $wpdb;
- $meta_query = $wpdb->prepare(
- "SELECT post_id, meta_value FROM {$wpdb->postmeta}
- WHERE meta_key = %s AND post_id IN (" . implode(',', array_fill(0, count($artist_ids), '%d')) . ")",
- array_merge([BASE.'image'], $artist_ids)
- );
- $results = $wpdb->get_results($meta_query);
-
- foreach ($results as $result) {
- $artist_images[$result->post_id] = $result->meta_value;
- }
- }
-
- // Get all thumbnails in one query
- $thumbnail_ids = [];
- foreach ($items as $item) {
- if ($item->type !== BASE.'artist' && isset($posts_by_id[$item->target_id])) {
- $thumb_id = get_post_thumbnail_id($item->target_id);
- if ($thumb_id) {
- $thumbnail_ids[$item->target_id] = $thumb_id;
- }
- }
- }
-
- // Format each item
- foreach ($items as $item) {
- $post_id = (int)$item->target_id;
-
- // Skip if post doesn't exist
- if (!isset($posts_by_id[$post_id])) {
- continue;
- }
-
- $post = $posts_by_id[$post_id];
-
- $formatted_item = [
- 'id' => $item->id ?? null,
- 'type' => str_replace(BASE, '', $item->type),
- 'target_id' => $post_id,
- 'date_added' => $item->date_added ?? current_time('mysql'),
- 'notes' => $item->notes ?? '',
- 'url' => get_permalink($post),
- 'title' => $post->post_title,
- 'author' => [
- 'id' => $post->post_author,
- 'name' => get_the_author_meta('display_name', $post->post_author)
- ]
- ];
-
- // Add thumbnail
- if ($item->type === BASE.'artist') {
- $meta_value = $artist_images[$post_id] ?? null;
- $formatted_item['thumbnail'] = $meta_value ? jvbFormatImage($meta_value, 'medium', 'medium') : null;
- } else {
- $thumb_id = $thumbnail_ids[$post_id] ?? null;
- $formatted_item['thumbnail'] = $thumb_id ? jvbFormatImage($thumb_id, 'medium', 'medium') : null;
- }
-
- $formatted[] = $formatted_item;
- }
-
- return $formatted;
- }
-
- /**
- * Batch format term-type favourites to reduce queries
- *
- * @param array $items Collection of favourite items of term type
- * @return array Formatted items
- */
- protected function formatTermFavourites(array $items):array
- {
- if (empty($items)) {
- return [];
- }
-
- $formatted = [];
-
- // Group by taxonomy
- $terms_by_taxonomy = [];
- foreach ($items as $item) {
- $tax = $item->type;
- if (!isset($terms_by_taxonomy[$tax])) {
- $terms_by_taxonomy[$tax] = [];
- }
- $terms_by_taxonomy[$tax][] = (int)$item->target_id;
- }
-
- // Get all terms by taxonomy
- $terms_by_id = [];
- foreach ($terms_by_taxonomy as $taxonomy => $term_ids) {
- $terms = get_terms([
- 'taxonomy' => $taxonomy,
- 'include' => $term_ids,
- 'hide_empty' => false,
- ]);
-
- if (!is_wp_error($terms)) {
- foreach ($terms as $term) {
- $terms_by_id[$taxonomy . '_' . $term->term_id] = $term;
- }
- }
- }
-
- // Get all shop images in one query if needed
- $shop_ids = [];
- foreach ($items as $item) {
- if ($item->type === BASE.'shop') {
- $shop_ids[] = (int)$item->target_id;
- }
- }
-
- $shop_images = [];
- if (!empty($shop_ids)) {
- global $wpdb;
- $meta_query = $wpdb->prepare(
- "SELECT term_id, meta_value FROM {$wpdb->termmeta}
- WHERE meta_key = %s AND term_id IN (" . implode(',', array_fill(0, count($shop_ids), '%d')) . ")",
- array_merge([BASE.'image'], $shop_ids)
- );
- $results = $wpdb->get_results($meta_query);
-
- foreach ($results as $result) {
- $shop_images[$result->term_id] = $result->meta_value;
- }
- }
-
- // Format each item
- foreach ($items as $item) {
- $term_id = (int)$item->target_id;
- $key = $item->type . '_' . $term_id;
-
- // Skip if term doesn't exist
- if (!isset($terms_by_id[$key])) {
- continue;
- }
-
- $term = $terms_by_id[$key];
-
- $formatted_item = [
- 'id' => $item->id ?? null,
- 'type' => str_replace(BASE, '', $item->type),
- 'target_id' => $term_id,
- 'date_added' => $item->date_added ?? current_time('mysql'),
- 'notes' => $item->notes ?? '',
- 'title' => html_entity_decode($term->name),
- 'url' => get_term_link($term)
- ];
-
- // Add thumbnail for shops
- if ($item->type === BASE.'shop') {
- $meta_value = $shop_images[$term_id] ?? null;
- $formatted_item['thumbnail'] = $meta_value ? jvbFormatImage($meta_value, 'medium', 'medium') : null;
- }
-
- $formatted[] = $formatted_item;
- }
-
- return $formatted;
- }
-
- /**
- * Get counts of favourites by type for a user
- *
- * @param int $user_id User ID
- * @return array Counts by type
- */
- public function getFavouriteCounts(int $user_id, bool $show_all = true):array
- {
- $key = 'favourite_counts_by_type_' . $user_id;
- $key .= ($show_all) ? '_all' : '_not_all';
- $cache = $this->cache->get($key);
- if ($cache) {
- return $cache;
- }
- try {
- global $wpdb;
- $table = $wpdb->prefix . BASE.'favourites';
-
- $counts = $wpdb->get_results($wpdb->prepare("
- SELECT type, COUNT(*) as count
- FROM {$table}
- WHERE user_id = %d
- GROUP BY type
- ", $user_id), OBJECT_K);
-
- $all_counts = [];
- if ($show_all) {
- // Fill in zeros for types with no favourites
- $all_counts = array_fill_keys(array_keys($this->valid_types), 0);
- $temp = [];
- foreach ($all_counts as $type => $count) {
- $type_key = str_replace(BASE, '', $type);
- $temp[$type_key] = $count;
- }
- $all_counts = $temp;
- }
-
-
- foreach ($counts as $type => $data) {
- $type_key = str_replace(BASE, '', $type);
- $all_counts[$type_key] = (int)$data->count;
- }
-
- $this->cache->set($key, $all_counts);
- return $all_counts;
- } catch (Exception $e) {
- JVB()->error()->log(
- 'favourites',
- 'Error getting counts by type: ' . $e->getMessage(),
- ['user_id' => $user_id],
- 'warning'
- );
- return array_fill_keys(array_keys($this->valid_types), 0);
- }
- }
-
- /**
- * Process batch favourites operation
- *
- * @param int $user_id User ID
- * @param array $data Operation data
- * @return array Operation result
- */
- protected function processBatches(int $user_id, array $data):array
- {
- error_log('Processing Batch Operation');
- if (empty($data['adds']) && empty($data['removes'])) {
- return [
- 'success' => true,
- 'result' => array()
- ];
- }
- error_log('Proceeding to TRANSACTION');
- global $wpdb;
-
- // Start transaction
- $wpdb->query('START TRANSACTION');
- try {
- // Collect notifications to send after transaction
- $notifications = [];
-
- // Process adds
- foreach ($data['adds'] as $item) {
- $result = $this->addFavourite($user_id, $item['type'], $item['target_id']);
- if (is_wp_error($result)) {
- $results[] = [
- 'success' => false,
- 'error' => $result->get_error_message(),
- 'type' => $item['type'],
- 'target_id' => $item['target_id']
- ];
- } else {
- $results[] = array_merge($item, $result);
-
- // If notification needed, add to collection instead of sending immediately
- if (isset($this->valid_types[$item['type']]) && $this->valid_types[$item['type']]['notify_owner']) {
- $owner_ids = $this->getContentOwner($item['type'], $item['target_id']);
- if ($owner_ids) {
- $owner_ids = (is_array($owner_ids)) ? $owner_ids : [$owner_ids];
- $type_label = str_replace(BASE, '', $item['type']);
-
- foreach ($owner_ids as $owner_id) {
- if ($owner_id !== $user_id) {
- $notifications[] = [
- 'owner_id' => $owner_id,
- 'type' => 'new_favourite',
- 'action_user_id' => $user_id,
- 'message' => sprintf(
- '%s favorited your %s',
- jvbShareName($user_id),
- $type_label
- ),
- 'target_id' => $item['target_id'],
- 'target_type' => $item['type'],
- 'context' => [
- 'favourite_user_id' => $user_id,
- 'content_type' => $item['type'],
- 'content_id' => $item['target_id'],
- ]
- ];
- }
- }
- }
- }
- }
- }
-
- // Process removes
- foreach ($data['removes'] as $item) {
- $result = $this->removeFavourite($user_id, $item['type'], $item['target_id']);
- if (is_wp_error($result)) {
- $results[] = [
- 'success' => false,
- 'error' => $result->get_error_message(),
- 'type' => $item['type'],
- 'target_id' => $item['target_id']
- ];
- } else {
- $results[] = array_merge($item, $result);
- }
- }
-
- $wpdb->query('COMMIT');
-
- // Send all notifications at once after successful commit
- $manager = JVB()->notification();
- if (!empty($notifications)) {
- foreach ($notifications as $notification) {
- $manager->addNotification(
- $notification['owner_id'],
- $notification['type'],
- $notification['action_user_id'],
- $notification['message'],
- $notification['target_id'],
- $notification['target_type'],
- $notification['context']
- );
- }
- }
- error_log('Results: '.print_r($results, true));
-
- $this->cache->forget('favourite_counts_by_type_' . $user_id.'_all');
- $this->cache->forget('favourite_counts_by_type_' . $user_id.'_not_all');
- return [
- 'success' => true,
- 'result' => $results
- ];
-
- } catch (Exception $e) {
- // Something went wrong, roll back changes
- $wpdb->query('ROLLBACK');
-
- JVB()->error()->log(
- 'favourites',
- 'Batch operation failed: ' . $e->getMessage(),
- ['user_id' => $user_id, 'item_count' => count($items ?? [])],
- 'error'
- );
- error_log('Error: '.print_r($e->getMessage(), true));
-
- return [
- 'success' => false,
- 'result' => $e->getMessage()
- ];
- }
- }
-
- /**
- * Add a favourite
- *
- * @param int $user_id User ID
- * @param string $type Content type
- * @param int $target_id Target content ID
- * @return array Operation result
- */
- protected function addFavourite(int $user_id, string $type, int $target_id):array
- {
- if (!str_starts_with($type, BASE)) {
- $type = BASE.$type;
- }
-
- // Validate type
- if (!isset($this->valid_types[$type])) {
- return [
- 'success' => false,
- 'message' => 'Invalid type',
- 'type' => $type,
- 'target_id' => $target_id
- ];
- }
-
- try {
- global $wpdb;
- $table = $wpdb->prefix . BASE . 'favourites';
-
- // Check if already favourited
- $exists = $wpdb->get_var($wpdb->prepare(
- "SELECT 1 FROM {$table}
- WHERE user_id = %d AND type = %s AND target_id = %d
- LIMIT 1",
- $user_id,
- $type,
- $target_id
- ));
-
- if ($exists) {
- $result = [
- 'success' => true,
- 'action' => 'already_exists',
- 'type' => str_replace(BASE, '', $type),
- 'target_id' => $target_id,
- 'count' => $this->getFavouriteCount($type, $target_id)
- ];
-
- // Fire after action even for already existing
- do_action('jvb_after_favourite_add', $user_id, $type, $target_id, $result);
-
- return $result;
- }
-
- // Insert new favourite
- $inserted = $wpdb->insert(
- $table,
- [
- 'user_id' => $user_id,
- 'type' => $type,
- 'target_id' => $target_id,
- 'date_added' => current_time('mysql')
- ],
- ['%d', '%s', '%d', '%s']
- );
-
- if ($inserted === false) {
- throw new Exception($wpdb->last_error);
- }
-
- // Get new favourite ID
- $favourite_id = $wpdb->insert_id;
-
- // Update favourite count
- $this->updateFavouriteCount($type, $target_id);
-
- // Notify owner if needed
- $this->maybeNotifyOwner($type, $target_id, $user_id);
-
- return [
- 'success' => true,
- 'action' => 'added',
- 'favourite_id' => $favourite_id,
- 'type' => str_replace(BASE, '', $type),
- 'target_id' => $target_id,
- 'count' => $this->getFavouriteCount($type, $target_id)
- ];
- } catch (Exception $e) {
- JVB()->error()->log(
- 'favourites',
- 'Error adding favourite: ' . $e->getMessage(),
- ['user_id' => $user_id, 'type' => $type, 'target_id' => $target_id],
- 'error'
- );
-
- $result = [
- 'success' => false,
- 'message' => $e->getMessage(),
- 'type' => str_replace(BASE, '', $type),
- 'target_id' => $target_id
- ];
-
- // Fire error action
- do_action('jvb_favourite_add_error', $user_id, $type, $target_id, $e->getMessage());
-
- return $result;
- }
- }
-
- protected function removeFavourite(int $user_id, string $type, int $target_id)
- {
- try {
- if (!str_starts_with($type, BASE)) {
- $type = BASE.$type;
- }
-
- // Validate type
- if (!isset($this->valid_types[$type])) {
- return [
- 'success' => false,
- 'message' => 'Invalid type',
- 'type' => $type,
- 'target_id' => $target_id
- ];
- }
-
- global $wpdb;
- $table = $wpdb->prefix . BASE . 'favourites';
-
- // Check if favorite exists before deleting
- $exists = $wpdb->get_var($wpdb->prepare(
- "SELECT 1 FROM {$table}
- WHERE user_id = %d AND type = %s AND target_id = %d
- LIMIT 1",
- $user_id,
- $type,
- $target_id
- ));
-
- if (!$exists) {
- return [
- 'success' => true,
- 'action' => 'already_removed',
- 'type' => str_replace(BASE, '', $type),
- 'target_id' => $target_id,
- 'count' => $this->getFavouriteCount($type, $target_id)
- ];
- }
-
- $deleted = $wpdb->delete(
- $table,
- [
- 'user_id' => $user_id,
- 'type' => $type,
- 'target_id' => $target_id
- ],
- ['%d', '%s', '%d']
- );
-
- if ($deleted === false) {
- throw new Exception($wpdb->last_error);
- }
-
- // Update favourite count
- $this->updateFavouriteCount($type, $target_id);
-
- // Remove any related notifications
- $this->removeRelatedNotifications($user_id, $type, $target_id);
-
- // Invalidate cache
- $this->cache->flush();
- $this->listsCache->flush();
- $this->sharedListsCache->flush();
- $this->favouritesCache->flush();
-
- return [
- 'success' => true,
- 'action' => 'removed',
- 'type' => str_replace(BASE, '', $type),
- 'target_id' => $target_id,
- 'count' => $this->getFavouriteCount($type, $target_id)
- ];
- } catch (Exception $e) {
- JVB()->error()->log(
- 'favourites',
- 'Error removing favourite: ' . $e->getMessage(),
- ['user_id' => $user_id, 'type' => $type, 'target_id' => $target_id],
- 'error'
- );
-
- return [
- 'success' => false,
- 'message' => $e->getMessage(),
- 'type' => str_replace(BASE, '', $type),
- 'target_id' => $target_id
- ];
- }
- }
-
- /**
- * Remove any existing notifications about a favorite action
- *
- * @param int $user_id User who removed the favorite
- * @param string $type Content type
- * @param int $target_id Content ID
- * @return void
- */
- protected function removeRelatedNotifications(int $user_id, string $type, int $target_id):void
- {
- try {
- // Get the content owner(s)
- $owner_ids = $this->getContentOwner($type, $target_id);
- if (!$owner_ids) {
- return;
- }
-
- $owner_ids = (is_array($owner_ids)) ? $owner_ids : [$owner_ids];
-
- foreach ($owner_ids as $owner_id) {
- // Skip if owner is the same as the user who unfavorited
- if ($owner_id === $user_id) {
- continue;
- }
-
- global $wpdb;
- $notifications_table = $wpdb->prefix . BASE . 'notifications';
-
- // Find recent (within last 30 days) new_favourite notifications from this user for this content
- $notifications = $wpdb->get_results($wpdb->prepare(
- "SELECT id FROM {$notifications_table}
+ // Find recent (within last 30 days) new_favourite notifications from this user for this content
+ $notifications = $wpdb->get_results($wpdb->prepare(
+ "SELECT id FROM {$notifications_table}
WHERE owner_id = %d
AND action_user_id = %d
AND type = 'new_favourite'
AND target_id = %d
AND target_type = %s
AND created_at > DATE_SUB(%s, INTERVAL 30 DAY)",
- $owner_id,
- $user_id,
- $target_id,
- $type,
- current_time('mysql')
- ));
+ $owner_id,
+ $user_id,
+ $target_id,
+ $type,
+ current_time('mysql')
+ ));
- if (empty($notifications)) {
- continue;
- }
+ if (empty($notifications)) {
+ continue;
+ }
- // Delete the notifications
- foreach ($notifications as $notification) {
- $wpdb->delete(
- $notifications_table,
- ['id' => $notification->id],
- ['%d']
- );
- }
+ // Delete the notifications
+ foreach ($notifications as $notification) {
+ $wpdb->delete(
+ $notifications_table,
+ ['id' => $notification->id],
+ ['%d']
+ );
+ }
- // Invalidate notification cache for this user
+ // Invalidate notification cache for this user
// if (method_exists(JVB()->notification(), 'clearNotificationCache')) {
// JVB()->notification()->clearNotificationCache($owner_id);
// }
- }
- } catch (Exception $e) {
- // Log but continue
- JVB()->error()->log(
- 'favourites',
- 'Error removing related notifications: ' . $e->getMessage(),
- ['type' => $type, 'target_id' => $target_id, 'user_id' => $user_id],
- 'warning'
- );
- }
- }
-
- /**
- * Process favourite notes update with improved transaction handling
- *
- * @param int $user_id User ID
- * @param array $data Operation data
- * @return array Operation result
- */
- protected function processNote(int $user_id, array $data):array
- {
- global $wpdb;
- $result = [];
-
- // Start transaction
- $wpdb->query('START TRANSACTION');
-
- try {
- $IDs = explode(',', $data['target_id']);
- $results = [];
- foreach ($IDs as $ID) {
- $target_id = absint($ID);
- if ($target_id <= 0) {
- throw new Exception('Invalid target ID');
- }
- $notes = sanitize_textarea_field($data['notes'] ?? '');
-
- $table = $wpdb->prefix . BASE . 'favourites';
-
- // Check if favourite exists
- $favourite_id = $wpdb->get_var($wpdb->prepare(
- "SELECT id FROM {$table}
- WHERE user_id = %d AND target_id = %d",
- $user_id, $target_id
- ));
-
- if (!$favourite_id) {
- $result[] = [
- 'success' => false,
- 'target_id' => $target_id,
- 'message' => __('No favourite id found...', 'jvb')
- ];
- } else {
- // Update existing favourite
- $updated = $wpdb->update(
- $table,
- ['notes' => $notes],
- ['id' => $favourite_id],
- ['%s'],
- ['%d']
- );
-
- if ($updated === false) {
- throw new Exception($wpdb->last_error);
- }
-
- $result[] = [
- 'success' => true,
- 'action' => 'updated_notes',
- 'favourite_id' => $favourite_id,
- 'target_id' => $target_id
- ];
- }
- }
-
- // If we got here, everything worked - commit the transaction
- $wpdb->query('COMMIT');
-
- return [
- 'success' => true,
- 'results' => $result
- ];
- } catch (Exception $e) {
- // Something went wrong, roll back changes
- $wpdb->query('ROLLBACK');
-
- JVB()->error()->log(
- 'favourites',
- 'Notes update failed: ' . $e->getMessage(),
- [
- 'user_id' => $user_id,
- 'target_id' => $data['target_id'] ?? null
- ],
- 'error'
- );
-
- return [
- 'success' => false,
- 'result' => $e->getMessage(),
- ];
- }
- }
-
- /**
- * Process list creation
- *
- * @param int $user_id User ID
- * @param array $data Operation data
- * @return array Operation result
- */
- protected function processListCreate(int $user_id, array $data):array
- {
- global $wpdb;
-
- // Start transaction
- $wpdb->query('START TRANSACTION');
-
- try {
- $name = sanitize_text_field($data['name']);
- $description = sanitize_textarea_field($data['description'] ?? '');
- $items = $data['items'] ?? [];
-
- // Fire pre-create action
- do_action('jvb_before_list_create', $user_id, $name, $description);
-
- $lists_table = $wpdb->prefix . BASE . 'favourites_lists';
-
- // Create the list
- $inserted = $wpdb->insert(
- $lists_table,
- [
- 'user_id' => $user_id,
- 'name' => $name,
- 'description' => $description,
- 'created_at' => current_time('mysql'),
- 'updated_at' => current_time('mysql')
- ],
- ['%d', '%s', '%s', '%s', '%s']
- );
-
- if (!$inserted) {
- throw new Exception($wpdb->last_error);
- }
-
- $list_id = $wpdb->insert_id;
-
- // Add items if any, but limit batch size
- $added_count = 0;
- $batch_size = 50; // Process in batches of 50
-
- if (!empty($items)) {
- $item_batches = array_chunk($items, $batch_size);
-
- foreach ($item_batches as $batch) {
- $result = $this->addItemsToList($list_id, $batch, $user_id);
- $batch_added = $result['added'];
- $added_count += $batch_added;
-
- // Give the server a small break between large batches
- if (count($items) > $batch_size) {
- usleep(50000); // 50ms pause
- }
- }
- }
-
- // Commit transaction
- $wpdb->query('COMMIT');
-
-
- // Fire post-create action
- do_action('jvb_after_list_create', $user_id, $list_id, $name, $added_count);
-
- return [
- 'success' => true,
- 'result' => [
- 'list_id' => $list_id,
- 'name' => $name,
- 'added_items' => $added_count,
- 'message' => sprintf('Created list "%s" with %d items', $name, $added_count)
- ]
- ];
- } catch (Exception $e) {
- // Rollback on error
- $wpdb->query('ROLLBACK');
-
- JVB()->error()->log(
- 'favourites',
- 'Error creating list: ' . $e->getMessage(),
- ['user_id' => $user_id, 'name' => $data['name'] ?? ''],
- 'error'
- );
-
- return [
- 'success' => false,
- 'result' => $e->getMessage()
- ];
- }
- }
-
-
-
- /**
- * Process adding items to a list
- *
- * @param int $user_id User ID
- * @param array $data Operation data
- * @return array Operation result
- */
- protected function processAddToList(int $user_id, array $data): array
- {
- global $wpdb;
-
- // Start transaction
- $wpdb->query('START TRANSACTION');
-
- try {
- $list_ids = explode(',', $data['list_id']);
- $items = $data['items'] ?? [];
-
- if (empty($items)) {
- throw new Exception('Items array is required');
- }
-
- $lists_table = $wpdb->prefix . BASE . 'favourites_lists';
- $shares_table = $wpdb->prefix . BASE . 'favourites_list_shares';
-
- $results = [];
- $total_added = 0;
- $all_errors = [];
-
- // Process each list
- foreach ($list_ids as $list_id) {
- $list_id = (int) $list_id;
-
- if (!$list_id) {
- $all_errors[] = ['message' => 'Invalid list ID', 'list_id' => $list_id];
- continue;
- }
-
- // Check permission - either owner or has edit permission
- $has_permission = $wpdb->get_var($wpdb->prepare(
- "SELECT 1 FROM {$lists_table} WHERE id = %d AND user_id = %d
- UNION
- SELECT 1 FROM {$shares_table}
- WHERE list_id = %d AND user_id = %d AND permission_type = 'edit'
- LIMIT 1",
- $list_id,
- $user_id,
- $list_id,
- $user_id
- ));
-
- if (!$has_permission) {
- $all_errors[] = [
- 'message' => 'You do not have permission to add items to this list',
- 'list_id' => $list_id
- ];
- continue;
- }
-
- // Call the optimized helper method to add items
- $add_result = $this->addItemsToList($list_id, $items, $user_id);
-
- // Track results
- $total_added += $add_result['added'];
-
- if (!empty($add_result['errors'])) {
- $all_errors = array_merge($all_errors, $add_result['errors']);
- }
-
- $results[] = [
- 'list_id' => $list_id,
- 'added_count' => $add_result['added']
- ];
-
- }
-
- // If we've had no successful additions and only errors, throw an exception
- if ($total_added == 0 && !empty($all_errors)) {
- throw new Exception('Failed to add any items: ' . json_encode($all_errors));
- }
-
- // Commit the transaction
- $wpdb->query('COMMIT');
-
- // Invalidate relevant caches
- $this->listsCache->flush();
-
- return [
- 'success' => true,
- 'results' => [
- 'success' => $results,
- 'added_count' => $total_added,
- 'errors' => $all_errors
- ]
- ];
-
- } catch (Exception $e) {
- // Something went wrong, roll back changes
- $wpdb->query('ROLLBACK');
-
- JVB()->error()->log(
- 'favourites',
- 'Error adding items to list: ' . $e->getMessage(),
- ['user_id' => $user_id, 'list_ids' => $data['list_id'] ?? 0],
- 'error'
- );
-
- return [
- 'success' => false,
- 'result' => $e->getMessage()
- ];
- }
- }
-
- /**
- * Helper method to add items to a list with improved performance and error handling
- *
- * @param int $list_id List ID
- * @param array $items Items to add
- * @param int $user_id User ID
- * @return array Success details with counts and errors
- */
- protected function addItemsToList(int $list_id, array $items, int $user_id)
- {
- if (empty($items)) {
- return ['added' => 0, 'errors' => []];
- }
-
- global $wpdb;
- $items_table = $wpdb->prefix . BASE . 'favourites_list_items';
- $added = 0;
- $errors = [];
-
- try {
- // Group items by type for more efficient processing
- $items_by_type = [];
- foreach ($items as $item) {
- if (empty($item['type']) || !isset($item['target_id'])) {
- $errors[] = ['message' => 'Item missing type or target_id', 'item' => $item];
- continue;
- }
-
- $type = isset($item['type']) && strpos($item['type'], BASE) !== 0
- ? BASE . $item['type']
- : $item['type'];
-
- if (!isset($items_by_type[$type])) {
- $items_by_type[$type] = [];
- }
-
- $items_by_type[$type][] = (int)$item['target_id'];
- }
-
- // Process each type in bulk
- foreach ($items_by_type as $type => $target_ids) {
- // Find existing items to avoid duplicates
- $placeholders = implode(',', array_fill(0, count($target_ids), '%d'));
- $query_params = array_merge([$list_id], $target_ids);
- array_unshift($query_params, $type);
-
- $existing_query = $wpdb->prepare(
- "SELECT item_id FROM {$items_table}
- WHERE list_id = %d AND item_type = %s AND item_id IN ({$placeholders})",
- $query_params
- );
-
- $existing_ids = $wpdb->get_col($existing_query);
- $existing_ids_map = array_flip($existing_ids);
-
- // Get favourite IDs in bulk
- $favourites_table = $wpdb->prefix . BASE . "favourites";
- $fav_query = $wpdb->prepare(
- "SELECT id, target_id FROM {$favourites_table}
- WHERE user_id = %d AND type = %s AND target_id IN ({$placeholders})",
- array_merge([$user_id, $type], $target_ids)
- );
-
- $fav_results = $wpdb->get_results($fav_query);
- $fav_id_map = [];
-
- foreach ($fav_results as $fav) {
- $fav_id_map[$fav->target_id] = $fav->id;
- }
-
- // Prepare bulk insert
- $now = current_time('mysql');
- $insert_values = [];
- $insert_placeholders = [];
-
- foreach ($target_ids as $target_id) {
- // Skip if already in list
- if (isset($existing_ids_map[$target_id])) {
- continue;
- }
-
- $fav_id = $fav_id_map[$target_id] ?? null;
- $insert_values[] = $list_id;
- $insert_values[] = $type;
- $insert_values[] = $target_id;
- $insert_values[] = $fav_id;
- $insert_values[] = $now;
-
- $insert_placeholders[] = "(%d, %s, %d, %d, %s)";
- }
-
- // Perform bulk insert if there are items to add
- if (!empty($insert_placeholders)) {
- $insert_query = "INSERT INTO {$items_table}
- (list_id, item_type, item_id, favourite_id, added_at)
- VALUES " . implode(',', $insert_placeholders);
-
- $result = $wpdb->query($wpdb->prepare($insert_query, $insert_values));
-
- if ($result === false) {
- throw new Exception($wpdb->last_error);
- }
-
- $added += $result;
- }
- }
-
- // Update list modified timestamp
- $lists_table = $wpdb->prefix . BASE . 'favourites_lists';
- $wpdb->update(
- $lists_table,
- ['updated_at' => current_time('mysql')],
- ['id' => $list_id],
- ['%s'],
- ['%d']
- );
-
-
- return ['added' => $added, 'errors' => $errors];
-
- } catch (Exception $e) {
- // Rollback transaction on error
- $wpdb->query('ROLLBACK');
-
- JVB()->error()->log(
- 'favourites',
- 'Error adding items to list: ' . $e->getMessage(),
- ['user_id' => $user_id, 'list_id' => $list_id, 'items_count' => count($items)],
- 'error'
- );
-
- $errors[] = ['message' => $e->getMessage()];
- return ['added' => 0, 'errors' => $errors];
- }
- }
-
- /**
- * Process list update
- *
- * @param int $user_id User ID
- * @param array $data Operation data
- * @return array Operation result
- */
- protected function processUpdateList(int $user_id, array $data):array
- {
- $list_id = intval($data['list_id'] ?? 0);
-
- if (!$list_id) {
- return [
- 'success' => false,
- 'result' => 'List ID is required'
- ];
- }
-
- try {
- global $wpdb;
- $lists_table = $wpdb->prefix . BASE . 'favourites_lists';
-
- // Verify ownership
- $is_owner = $wpdb->get_var($wpdb->prepare(
- "SELECT 1 FROM {$lists_table}
- WHERE id = %d AND user_id = %d",
- $list_id,
- $user_id
- ));
-
- if (!$is_owner) {
- return [
- 'success' => false,
- 'result' => 'You do not have permission to update this list'
- ];
- }
-
- // Build update data
- $update_data = [];
- $update_format = [];
-
- if (isset($data['name'])) {
- $update_data['name'] = sanitize_text_field($data['name']);
- $update_format[] = '%s';
- }
-
- if (isset($data['description'])) {
- $update_data['description'] = sanitize_textarea_field($data['description']);
- $update_format[] = '%s';
- }
-
- if (empty($update_data)) {
- return [
- 'success' => true,
- 'result' => 'No changes to update'
- ];
- }
-
- // Add updated timestamp
- $update_data['updated_at'] = current_time('mysql');
- $update_format[] = '%s';
-
- $updated = $wpdb->update(
- $lists_table,
- $update_data,
- ['id' => $list_id, 'user_id' => $user_id],
- $update_format,
- ['%d', '%d']
- );
-
- if ($updated === false) {
- throw new Exception($wpdb->last_error);
- }
-
- return [
- 'success' => true,
- 'result' => [
- 'message' => 'List updated successfully',
- 'list_id' => $list_id,
- 'updates' => array_keys($update_data)
- ]
- ];
- } catch (Exception $e) {
- JVB()->error()->log(
- 'favourites',
- 'Error updating list: ' . $e->getMessage(),
- ['user_id' => $user_id, 'list_id' => $list_id],
- 'error'
- );
-
- return [
- 'success' => false,
- 'result' => $e->getMessage()
- ];
- }
- }
-
- /**
- * Process list deletion
- *
- * @param int $user_id User ID
- * @param array $data Operation data
- * @return array Operation result
- */
- protected function processListDeletion(int $user_id, array $data):array
- {
- $list_id = intval($data['list_id'] ?? 0);
-
- if (!$list_id) {
- return [
- 'success' => false,
- 'result' => 'List ID is required'
- ];
- }
-
- try {
- global $wpdb;
- $lists_table = $wpdb->prefix . BASE . 'favourites_lists';
-
- // Verify ownership
- $is_owner = $wpdb->get_var($wpdb->prepare(
- "SELECT 1 FROM {$lists_table}
- WHERE id = %d AND user_id = %d",
- $list_id,
- $user_id
- ));
-
- if (!$is_owner) {
- return [
- 'success' => false,
- 'result' => 'You do not have permission to delete this list'
- ];
- }
-
- // Start transaction for cascading deletes
- $wpdb->query('START TRANSACTION');
-
- // Delete from all related tables
- $items_table = $wpdb->prefix . BASE . 'favourites_list_items';
- $shares_table = $wpdb->prefix . BASE . 'favourites_list_shares';
- $pending_shares_table = $wpdb->prefix . BASE . 'favourites_pending_shares';
-
- $wpdb->delete($items_table, ['list_id' => $list_id], ['%d']);
- $wpdb->delete($shares_table, ['list_id' => $list_id], ['%d']);
- $wpdb->delete($pending_shares_table, ['list_id' => $list_id], ['%d']);
-
- // Delete the list itself
- $deleted = $wpdb->delete(
- $lists_table,
- ['id' => $list_id, 'user_id' => $user_id],
- ['%d', '%d']
- );
-
- if ($deleted === false) {
- throw new Exception($wpdb->last_error);
- }
-
- $wpdb->query('COMMIT');
-
- return [
- 'success' => true,
- 'result' => [
- 'message' => 'List deleted successfully',
- 'list_id' => $list_id
- ]
- ];
- } catch (Exception $e) {
- $wpdb->query('ROLLBACK');
-
- JVB()->error()->log(
- 'favourites',
- 'Error deleting list: ' . $e->getMessage(),
- ['user_id' => $user_id, 'list_id' => $list_id],
- 'error'
- );
-
- return [
- 'success' => false,
- 'result' => $e->getMessage()
- ];
- }
- }
-
- /**
- * Process removing items from a list
- *
- * @param int $user_id User ID
- * @param array $data Operation data
- * @return array Operation result
- */
- protected function removeFromList(int $user_id, array $data):array
- {
- $list_id = intval($data['list_id'] ?? 0);
- $items = $data['items'] ?? [];
-
- if (!$list_id || empty($items)) {
- return [
- 'success' => false,
- 'result' => 'List ID and items are required'
- ];
- }
-
- try {
- global $wpdb;
- $lists_table = $wpdb->prefix . BASE . 'favourites_lists';
- $items_table = $wpdb->prefix . BASE . 'favourites_list_items';
- $shares_table = $wpdb->prefix . BASE . 'favourites_list_shares';
-
- // Check permission - either owner or has edit permission
- $has_permission = $wpdb->get_var($wpdb->prepare(
- "SELECT 1 FROM {$lists_table} WHERE id = %d AND user_id = %d
- UNION
- SELECT 1 FROM {$shares_table}
- WHERE list_id = %d AND user_id = %d AND permission_type = 'edit'
- LIMIT 1",
- $list_id,
- $user_id,
- $list_id,
- $user_id
- ));
-
- if (!$has_permission) {
- return [
- 'success' => false,
- 'result' => 'You do not have permission to remove items from this list'
- ];
- }
-
- // Remove items
- $removed = 0;
-
- foreach ($items as $item) {
- if (empty($item['type']) || !isset($item['target_id'])) {
- continue;
- }
-
- $type = isset($item['type']) && strpos($item['type'], BASE) !== 0
- ? BASE . $item['type']
- : $item['type'];
-
- $deleted = $wpdb->delete(
- $items_table,
- [
- 'list_id' => $list_id,
- 'item_type' => $type,
- 'item_id' => intval($item['target_id'])
- ],
- ['%d', '%s', '%d']
- );
-
- if ($deleted) {
- $removed += $deleted;
- }
- }
-
- return [
- 'success' => true,
- 'result' => [
- 'message' => "{$removed} items removed from list",
- 'list_id' => $list_id,
- 'removed_count' => $removed
- ]
- ];
- } catch (Exception $e) {
- JVB()->error()->log(
- 'favourites',
- 'Error removing items from list: ' . $e->getMessage(),
- ['user_id' => $user_id, 'list_id' => $list_id],
- 'error'
- );
-
- return [
- 'success' => false,
- 'result' => $e->getMessage()
- ];
- }
- }
-
- /**
- * Process sharing a list
- *
- * @param int $user_id User ID
- * @param array $data Operation data
- * @return array Operation result
- */
- protected function shareList(int $user_id, array $data):array
- {
- global $wpdb;
- $result = null;
-
- // Start transaction
- $wpdb->query('START TRANSACTION');
-
- try {
- $list_id = intval($data['list_id'] ?? 0);
- $email = sanitize_email($data['email'] ?? '');
- $permission_type = in_array($data['permission_type'] ?? '', ['view', 'edit'])
- ? $data['permission_type']
- : 'view';
-
- if (!$list_id || !$email) {
- throw new Exception('List ID and email are required');
- }
-
- $lists_table = $wpdb->prefix . BASE . 'favourites_lists';
- $shares_table = $wpdb->prefix . BASE . 'favourites_list_shares';
-
- // Verify ownership
- $list = $wpdb->get_row($wpdb->prepare(
- "SELECT l.*, u.display_name as owner_name
- FROM {$lists_table} l
- JOIN {$wpdb->users} u ON l.user_id = u.ID
- WHERE l.id = %d AND l.user_id = %d",
- $list_id,
- $user_id
- ));
-
- if (!$list) {
- throw new Exception('You do not have permission to share this list');
- }
-
- // Look up user by email
- $share_user = get_user_by('email', $email);
-
- if ($share_user) {
- // User exists - check if they already have access
- $existing_share = $wpdb->get_row($wpdb->prepare(
- "SELECT * FROM {$shares_table}
- WHERE list_id = %d AND (user_id = %d OR email = %s)",
- $list_id,
- $share_user->ID,
- $email
- ));
-
- if ($existing_share) {
- // Update permission if it's different
- if ($existing_share->permission_type !== $permission_type ||
- $existing_share->status !== 'accepted') {
- $wpdb->update(
- $shares_table,
- [
- 'permission_type' => $permission_type,
- 'status' => 'accepted',
- 'user_id' => $share_user->ID,
- 'updated_at' => current_time('mysql')
- ],
- ['id' => $existing_share->id]
- );
-
- if ($wpdb->last_error) {
- throw new Exception($wpdb->last_error);
- }
-
- $result = [
- 'success' => true,
- 'action' => 'updated',
- 'message' => "Updated sharing permissions for {$email}",
- 'user_id' => $share_user->ID,
- 'email' => $email,
- 'permission_type' => $permission_type
- ];
- } else {
- $result = [
- 'success' => true,
- 'action' => 'already_shared',
- 'message' => "List is already shared with {$email}",
- 'user_id' => $share_user->ID,
- 'email' => $email,
- 'permission_type' => $permission_type
- ];
- }
- } else {
- // Add new share for existing user
- $wpdb->insert(
- $shares_table,
- [
- 'list_id' => $list_id,
- 'user_id' => $share_user->ID,
- 'email' => $email,
- 'permission_type' => $permission_type,
- 'status' => 'accepted',
- 'created_at' => current_time('mysql'),
- 'updated_at' => current_time('mysql')
- ],
- ['%d', '%d', '%s', '%s', '%s', '%s', '%s']
- );
-
- if ($wpdb->last_error) {
- throw new Exception($wpdb->last_error);
- }
-
- // Send notification to user
- JVB()->notification()->addNotification(
- $share_user->ID, // Recipient
- 'list_shared',
- $user_id, // Action user ID
- sprintf('%s shared a favorites list with you: "%s"', jvbShareName($user_id), $list->name),
- $list_id,
- 'favourites_list',
- [
- 'list_id' => $list_id,
- 'list_name' => $list->name,
- 'permission_type' => $permission_type
- ]
- );
-
- $result = [
- 'success' => true,
- 'result' => [
- 'message' => "List shared with {$email}",
- 'user_id' => $share_user->ID,
- 'email' => $email,
- 'permission_type' => $permission_type
- ]
- ];
- }
- } else {
- // User doesn't exist - check for existing pending invitation
- $existing_pending = $wpdb->get_var($wpdb->prepare(
- "SELECT id FROM {$shares_table}
- WHERE list_id = %d AND email = %s AND status = 'pending'",
- $list_id,
- $email
- ));
-
- if ($existing_pending) {
- $result = [
- 'success' => true,
- 'result' => [
- 'action' => 'already_pending',
- 'message' => "Invitation already sent to {$email}",
- 'email' => $email
- ]
- ];
- } else {
- // Generate invitation token
- $token = wp_generate_password(32, false);
-
- // Create pending share
- $wpdb->insert(
- $shares_table,
- [
- 'list_id' => $list_id,
- 'email' => $email,
- 'permission_type' => $permission_type,
- 'status' => 'pending',
- 'invitation_token' => $token,
- 'created_at' => current_time('mysql'),
- 'updated_at' => current_time('mysql')
- ],
- ['%d', '%s', '%s', '%s', '%s', '%s', '%s']
- );
-
- if ($wpdb->last_error) {
- throw new Exception($wpdb->last_error);
- }
-
- // Send invitation email
- $this->sendListInviteEmail($email, [
- 'list_id' => $list_id,
- 'list_name' => $list->name,
- 'token' => $token,
- 'owner_name' => $list->owner_name,
- 'user_id' => $user_id
- ]);
-
- $result = [
- 'success' => true,
- 'result' => [
- 'action' => 'invitation_sent',
- 'message' => "Invitation sent to {$email}",
- 'email' => $email
- ]
- ];
- }
- }
-
- // All operations successful, commit the transaction
- $wpdb->query('COMMIT');
-
- // Return the result that was set earlier
- return $result;
-
- } catch (Exception $e) {
- // Something went wrong, roll back changes
- $wpdb->query('ROLLBACK');
-
- JVB()->error()->log(
- 'favourites',
- 'Error sharing list: ' . $e->getMessage(),
- ['user_id' => $user_id, 'list_id' => $data['list_id'] ?? 0, 'email' => $data['email'] ?? ''],
- 'error'
- );
-
- return [
- 'success' => false,
- 'result' => $e->getMessage()
- ];
- }
- }
-
- /**
- * Process unsharing a list
- *
- * @param int $user_id User ID
- * @param array $data Operation data
- * @return array Operation result
- */
- protected function unshareList(int $user_id, array $data):array
- {
- $list_id = intval($data['list_id'] ?? 0);
- $email = sanitize_email($data['email'] ?? '');
-
- if (!$list_id || !$email) {
- return [
- 'success' => false,
- 'result' => 'List ID and email are required'
- ];
- }
-
- try {
- global $wpdb;
- $lists_table = $wpdb->prefix . BASE . 'favourites_lists';
- $shares_table = $wpdb->prefix . BASE . 'favourites_list_shares';
-
- // Verify ownership
- $is_owner = $wpdb->get_var($wpdb->prepare(
- "SELECT 1 FROM {$lists_table}
- WHERE id = %d AND user_id = %d",
- $list_id,
- $user_id
- ));
-
- if (!$is_owner) {
- return [
- 'success' => false,
- 'result' => 'You do not have permission to manage shares for this list'
- ];
- }
-
- // Look for any share with this email, regardless of status
- $existing_share = $wpdb->get_row($wpdb->prepare(
- "SELECT * FROM {$shares_table}
- WHERE list_id = %d AND email = %s",
- $list_id,
- $email
- ));
-
- if (!$existing_share) {
- // Also check by user_id if it's a registered user's email
- $share_user = get_user_by('email', $email);
- if ($share_user) {
- $existing_share = $wpdb->get_row($wpdb->prepare(
- "SELECT * FROM {$shares_table}
- WHERE list_id = %d AND user_id = %d",
- $list_id,
- $share_user->ID
- ));
- }
- }
-
- if (!$existing_share) {
- return [
- 'success' => false,
- 'result' => "No active share or invitation found for {$email}"
- ];
- }
-
- // For active shares, update status to 'revoked'
- // For pending invitations, also update status to 'revoked'
- $updated = $wpdb->update(
- $shares_table,
- [
- 'status' => 'revoked',
- 'updated_at' => current_time('mysql')
- ],
- ['id' => $existing_share->id],
- ['%s', '%s'],
- ['%d']
- );
-
- if ($updated === false) {
- throw new Exception($wpdb->last_error);
- }
-
- // Determine the appropriate message based on previous status
- if ($existing_share->status === 'accepted') {
- $action = 'unshared';
- $message = "Removed {$email}'s access to list";
-
- // Send notification to user if they're registered
- if ($existing_share->user_id) {
- // Get list details
- $list = $wpdb->get_row($wpdb->prepare(
- "SELECT * FROM {$lists_table} WHERE id = %d",
- $list_id
- ));
-
- if ($list) {
- JVB()->notification()->addNotification(
- $existing_share->user_id,
- 'list_share_revoked',
- $user_id, // Action user ID
- sprintf('Your access to the list "%s" has been revoked', $list->name),
- $list_id,
- 'favourites_list',
- [
- 'list_id' => $list_id,
- 'list_name' => $list->name
- ]
- );
- }
- }
- } else {
- $action = 'invitation_cancelled';
- $message = "Cancelled invitation to {$email}";
- }
-
- return [
- 'success' => true,
- 'result' => [
- 'action' => $action,
- 'message' => $message,
- 'email' => $email
- ]
- ];
-
- } catch (Exception $e) {
- JVB()->error()->log(
- 'favourites',
- 'Error unsharing list: ' . $e->getMessage(),
- ['user_id' => $user_id, 'list_id' => $list_id, 'email' => $email],
- 'error'
- );
-
- return [
- 'success' => false,
- 'result' => $e->getMessage()
- ];
- }
- }
-
- /**
- * Send list invitation email
- *
- * @param string $email Recipient email
- * @param array $data Invitation data
- * @return bool Success status
- */
- protected function sendListInviteEmail(string $email, array $data):bool
- {
- $list_name = $data['list_name'];
- $token = $data['token'];
- $owner_name = $data['owner_name'];
-
- // Generate invitation URL
- $invite_url = add_query_arg([
- 'action' => 'accept_list_invite',
- 'token' => $token,
- 'list' => $data['list_id']
- ], home_url('/'));
-
- $inviteButton = sprintf(
- '<p style="text-align: center;"><a href="%s" class="button">Set Your Password</a></p>',
- $invite_url
- );
- $inviteUrl = sprintf(
- '<p style="user-select:all;">%s</p>',
- $invite_url
- );
-
- $subject = sprintf('%s shared a favourites list with you on edmonton.ink', $owner_name);
-
- $message = sprintf(
- '<p>Hi there,</p>
- <p><strong>%s</strong> has shared their list \"<strong>%s</strong>\" with you on edmonton.ink.</p>
- <p>To view this list, click the button below:</p>
- %s
- <p>Or copy and paste this link into your browser:</p>
- %s
- <p>If you don\'t already have an account, you\'ll be guided through creating one.</p>
- %s',
- $owner_name,
- $list_name,
- $inviteButton,
- $inviteUrl,
- JVB()->email()->signature()
- );
-
- return JVB()->email()->sendEmail($email, $subject, $message);
- }
-
- /**
- * Accept a list share invitation, handling both registered and non-registered users
- *
- * @param string $token Invitation token
- * @param string $email Email address of the invited user
- * @param int|null $user_id Optional user ID if already registered
- * @return array Result with success status and messages
- */
- public function acceptListInvitation(string $token, string $email, ?int $user_id = null):array
- {
- if (!$token || !$email) {
- return [
- 'success' => false,
- 'message' => 'Invalid invitation parameters'
- ];
- }
-
- try {
- global $wpdb;
- $shares_table = $wpdb->prefix . BASE . 'favourites_list_shares';
-
- // Find the pending invitation
- $invitation = $wpdb->get_row($wpdb->prepare(
- "SELECT * FROM {$shares_table}
- WHERE invitation_token = %s
- AND email = %s
- AND status = 'pending'",
- $token,
- $email
- ));
-
- if (!$invitation) {
- return [
- 'success' => false,
- 'message' => 'Invalid or expired invitation'
- ];
- }
-
- // If no user_id provided, check if a user with this email exists
- if (!$user_id) {
- $existing_user = get_user_by('email', $email);
-
- if ($existing_user) {
- $user_id = $existing_user->ID;
- } else {
- // No user account exists - create a registration URL with the token embedded
- $registration_url = add_query_arg([
- 'action' => 'register',
- 'type' => 'favourites',
- 'list_token' => $token,
- 'email' => urlencode($email)
- ], wp_login_url());
-
- return [
- 'success' => false,
- 'message' => 'You need to create an account to access this shared list',
- 'needs_registration' => true,
- 'registration_url' => $registration_url
- ];
- }
- }
-
- // Get the list details for the response
- $lists_table = $wpdb->prefix . BASE . 'favourites_lists';
- $list = $wpdb->get_row($wpdb->prepare(
- "SELECT l.*, u.display_name as owner_name
- FROM {$lists_table} l
- JOIN {$wpdb->users} u ON l.user_id = u.ID
- WHERE l.id = %d",
- $invitation->list_id
- ));
-
- if (!$list) {
- return [
- 'success' => false,
- 'message' => 'The shared list no longer exists'
- ];
- }
-
- // Update the invitation to accepted status
- $updated = $wpdb->update(
- $shares_table,
- [
- 'status' => 'accepted',
- 'user_id' => $user_id,
- 'updated_at' => current_time('mysql')
- ],
- ['id' => $invitation->id],
- ['%s', '%d', '%s'],
- ['%d']
- );
-
- if ($updated === false) {
- throw new Exception($wpdb->last_error);
- }
-
- // Notify the list owner
- $user = get_userdata($user_id);
- $display_name = $user ? $user->display_name : $email;
- JVB()->notification()->addNotification(
- $list->user_id, // Owner ID
- 'list_share_accepted',
- $user_id, // Action user ID
- sprintf('%s accepted your invitation to the list "%s"', $display_name, $list->name),
- $invitation->list_id,
- 'favourites_list',
- [
- 'list_id' => $invitation->list_id,
- 'list_name' => $list->name,
- 'user_id' => $user_id,
- 'email' => $email
- ]
- );
-
- return [
- 'success' => true,
- 'message' => 'List successfully shared with you',
- 'list_id' => $invitation->list_id,
- 'list_name' => $list->name,
- 'permission_type' => $invitation->permission_type
- ];
-
- } catch (Exception $e) {
- JVB()->error()->log(
- 'favourites',
- 'Error accepting list invitation: ' . $e->getMessage(),
- ['token' => $token, 'email' => $email],
- 'error'
- );
-
- return [
- 'success' => false,
- 'message' => $e->getMessage()
- ];
- }
- }
-
- /**
- * Get count of favourites for an item
- *
- * @param string $type Item type
- * @param int $target_id Item ID
- * @return int Favourite count
- */
- protected function getFavouriteCount(string $type, int $target_id):int
- {
- try {
- global $wpdb;
- $table = $wpdb->prefix . BASE . 'favourites';
-
- return (int)$wpdb->get_var($wpdb->prepare(
- "SELECT COUNT(*) FROM {$table}
- WHERE type = %s AND target_id = %d",
- $type,
- $target_id
- ));
- } catch (Exception $e) {
- return 0;
- }
- }
-
- /**
- * Update favourite count meta for an item
- *
- * @param string $type Item type
- * @param int $target_id Item ID
- */
- protected function updateFavouriteCount(string $type, int $target_id):void
- {
- if (!isset($this->valid_types[$type])) {
- return;
- }
- try {
- $config = $this->valid_types[$type];
- $count = $this->getFavouriteCount($type, $target_id);
-
- if ($config['table'] === 'post') {
- update_post_meta($target_id, $config['count_meta_key'], $count);
- } else {
- update_term_meta($target_id, $config['count_meta_key'], $count);
- }
- } catch (Exception $e) {
- // Log but continue
- JVB()->error()->log(
- 'favourites',
- 'Error updating favourite count: ' . $e->getMessage(),
- ['type' => $type, 'target_id' => $target_id],
- 'warning'
- );
- }
- }
-
- /**
- * Notify content owner of new favourite if configured
- *
- * @param string $type Content type
- * @param int $target_id Content ID
- * @param int $user_id User who favourited
- * @return void
- */
- protected function maybeNotifyOwner(string $type, int $target_id, int $user_id):void
- {
- // Skip if this type doesn't need owner notification
- if (!array_key_exists($type, $this->valid_types) || !$this->valid_types[$type]['notify_owner']) {
- return;
- }
-
- try {
- $owner_ids = $this->getContentOwner($type, $target_id);
- if ($owner_ids) {
- $owner_ids = (is_array($owner_ids)) ? $owner_ids : [$owner_ids];
- foreach ($owner_ids as $owner_id) {
- if ($owner_id !== $user_id) {
- $type_label = str_replace(BASE, '', $type);
- JVB()->notification()->addNotification(
- $owner_id,
- 'new_favourite',
- $user_id, // Action user ID
- sprintf('%s favourited your %s', jvbShareName($user_id), $type_label),
- $target_id,
- $type,
- [
- 'favourite_user_id' => $user_id,
- 'content_type' => $type,
- 'content_id' => $target_id
- ]
- );
- }
- }
- }
- } catch (Exception $e) {
- // Log but continue
- JVB()->error()->log(
- 'favourites',
- 'Error notifying owner: ' . $e->getMessage(),
- ['type' => $type, 'target_id' => $target_id, 'user_id' => $user_id],
- 'warning'
- );
- }
- }
+ }
+ } catch (Exception $e) {
+ // Log but continue
+ JVB()->error()->log(
+ 'favourites',
+ 'Error removing related notifications: ' . $e->getMessage(),
+ ['type' => $type, 'target_id' => $target_id, 'user_id' => $user_id],
+ 'warning'
+ );
+ }
+ }
public function maybeAcceptListInvite(int $user_id, string $email, array $data):void
{
@@ -3124,363 +1025,1088 @@
}
}
- /**
- * Get the owner ID for a content item
- *
- * @param string $type Content type
- * @param int $target_id Content ID
- * @return int|null Owner ID
- */
- protected function getContentOwner(string $type, int $target_id):int|null
- {
- if (!array_key_exists($type, $this->valid_types)) {
- return null;
- }
+ /**
+ * Get detailed information for a single list
+ */
+ protected function getListDetails(int $list_id, int $user_id): array
+ {
+ $key = "list_{$list_id}_user_{$user_id}";
- try {
- if ($this->valid_types[$type]['table'] === 'post') {
- $post = get_post($target_id);
- return $post ? $post->post_author : false;
- } elseif ($type === BASE.'shop') {
- return $this->getShopOwner($target_id);
- }
- } catch (Exception $e) {
- return null;
- }
+ return $this->listsCache->remember($key, function () use ($list_id, $user_id) {
+ try {
+ // Check access - either owner or has share
+ $is_owner = $this->lists->where([
+ 'id' => $list_id,
+ 'user_id' => $user_id
+ ])->existsInQuery();
- return null;
- }
+ $share = null;
+ if (!$is_owner) {
+ $share = $this->listShares->where([
+ 'list_id' => $list_id,
+ 'user_id' => $user_id,
+ 'status' => 'accepted'
+ ])->first();
+ }
- /**
- * Get the owner ID for a shop
- *
- * @param int $shop_id Shop term ID
- * @return int|null Owner ID
- */
- protected function getShopOwner(int $shop_id):array
- {
- // Get shop manager users
- $owners = get_term_meta($shop_id, BASE.'owner', true);
- $owners = explode(',', $owners);
- $managers = get_term_meta($shop_id, BASE.'managers', true);
- $managers = explode(',', $managers);
+ if (!$is_owner && !$share) {
+ return [
+ 'success' => false,
+ 'message' => 'You do not have access to this list'
+ ];
+ }
- return array_merge($owners, $managers);
- }
+ // Get list details
+ $list = $this->lists->where(['id' => $list_id])->first(ARRAY_A);
+ if (!$list) {
+ return [
+ 'success' => false,
+ 'message' => 'List not found'
+ ];
+ }
- /**
- * Maintenance method to clean up orphaned favourites
- * Called by scheduled task
- */
- /**
- * Maintenance method to clean up orphaned favourites
- * Scheduled action
- * @return bool
- */
- public function cleanupOrphanedFavourites():bool
- {
- try {
- global $wpdb;
- $table = $wpdb->prefix . BASE.'favourites';
+ // Get list items
+ $items = $this->listItems
+ ->where(['list_id' => $list_id])
+ ->orderBy('added_at', 'DESC')
+ ->getResults();
- // Delete favourites for non-existent users
- $wpdb->query("
- DELETE f FROM $table f
- LEFT JOIN {$wpdb->users} u ON f.user_id = u.ID
- WHERE u.ID IS NULL
- ");
+ // Format items - convert to favourite-like objects
+ $formatted_items = [];
+ foreach ($items as $item) {
+ // Try to get the actual favourite record if it exists
+ if ($item->favourite_id) {
+ $fav = $this->favourites->where(['id' => $item->favourite_id])->first();
+ if ($fav) {
+ $formatted_items[] = $fav;
+ continue;
+ }
+ }
- // Delete favourites for non-existent posts
- $post_types = array_map(function ($type) use ($wpdb) {
- return $wpdb->prepare('%s', $type);
- }, array_filter(array_keys($this->valid_types), function ($type) {
- return $type === 'content';
- }));
+ // Create dummy favourite object for formatting
+ $formatted_items[] = (object)[
+ 'type' => $item->item_type,
+ 'target_id' => $item->item_id,
+ 'date_added' => $item->added_at
+ ];
+ }
- if (!empty($post_types)) {
- $post_types_list = implode(',', $post_types);
+ // Get shared users if owner
+ $shared_users = [];
+ if ($is_owner) {
+ $shares = $this->listShares
+ ->where(['list_id' => $list_id])
+ ->orderBy('created_at', 'DESC')
+ ->getResults();
- $wpdb->query("
- DELETE f FROM $table f
- LEFT JOIN {$wpdb->posts} p ON f.target_id = p.ID
- WHERE f.type IN ($post_types_list)
- AND p.ID IS NULL
- ");
- }
+ foreach ($shares as $share_item) {
+ $shared_user = [
+ 'email' => $share_item->email,
+ 'status' => $share_item->status,
+ 'date_added' => $share_item->created_at,
+ ];
- // Delete favourites for non-existent terms
- $term_types = array_map(function ($type) use ($wpdb) {
- return $wpdb->prepare('%s', $type);
- }, array_filter(array_keys($this->valid_types), function ($type) {
- return $type === 'tax';
- }));
+ if ($share_item->status === 'accepted' && $share_item->user_id) {
+ $user = get_userdata($share_item->user_id);
+ $shared_user['name'] = $user ? $user->display_name : 'Unknown';
+ $shared_user['permission_type'] = $share_item->permission_type;
+ }
- if (!empty($term_types)) {
- $term_types_list = implode(',', $term_types);
+ $shared_users[] = $shared_user;
+ }
+ }
- $wpdb->query("
- DELETE f FROM $table f
- LEFT JOIN {$wpdb->terms} t ON f.target_id = t.term_id
- WHERE f.type IN ($term_types_list)
- AND t.term_id IS NULL
- ");
- }
+ return [
+ 'success' => true,
+ 'list' => [
+ 'id' => (int)$list['id'],
+ 'name' => $list['name'],
+ 'description' => $list['description'] ?? '',
+ 'created_at' => $list['created_at'],
+ 'is_owner' => $is_owner,
+ 'is_shared' => !$is_owner,
+ 'items' => $this->formatItems($formatted_items),
+ 'shared_users' => $shared_users
+ ]
+ ];
- return true;
- } catch (Exception $e) {
- JVB()->error()->log(
- 'favourites',
- 'Error during cleanup: ' . $e->getMessage(),
- [],
- 'error'
- );
+ } catch (Exception $e) {
+ $this->logError('getListDetails', [
+ 'error' => $e->getMessage(),
+ 'user_id' => $user_id,
+ 'list_id' => $list_id
+ ]);
- return false;
- }
- }
+ return [
+ 'success' => false,
+ 'message' => 'Error retrieving list details'
+ ];
+ }
+ });
+ }
+ /**
+ * Process favourite notes update
+ */
+ protected function processNote(int $user_id, array $data): array
+ {
+ $target_ids = isset($data['target_id'])
+ ? array_map('absint', explode(',', $data['target_id']))
+ : [];
+ $notes = sanitize_textarea_field($data['notes'] ?? '');
- /**
- * Create a standardized error response
- *
- * @param string $code Error code
- * @param string $message Error message
- * @param int $status HTTP status code
- * @param array $additional_data Additional context data
- * @return WP_REST_Response Formatted error
- */
- protected function createErrorResponse(string $code, string $message, int $status = 400, array $additional_data = []):WP_REST_Response
- {
- $error = new WP_Error(
- $code,
- __($message, 'jvb'),
- ['status' => $status]
- );
+ if (empty($target_ids)) {
+ return ['success' => false, 'result' => 'No target IDs provided'];
+ }
- if (!empty($additional_data)) {
- $error->add_data($additional_data, 'additional_data');
- }
+ return $this->favourites->transaction(function ($table) use ($user_id, $target_ids, $notes) {
+ $results = [];
- // Log error using central error handling system
- JVB()->error()->log(
- 'favourites',
- $message,
- $additional_data,
- 'error'
- );
- return new WP_REST_Response([
- 'success' => false,
- 'code' => $code,
- 'message' => __($message, 'jvb'),
- 'status' => $status
- ]);
- }
+ foreach ($target_ids as $target_id) {
+ if ($target_id <= 0) {
+ $results[] = [
+ 'success' => false,
+ 'target_id' => $target_id,
+ 'message' => 'Invalid target ID'
+ ];
+ continue;
+ }
+ $favourite = $table->where([
+ 'user_id' => $user_id,
+ 'target_id' => $target_id
+ ])->first();
- /**
- * Handle queued operations for favourites
- *
- * @param WP_Error|array $result Current result
- * @param object $operation Operation from queue
- * @param array $data Current Data
- * @return array|WP_Error Processing result
- */
- public function processOperation(WP_Error|array $result, object $operation, array $data):array|WP_Error
- {
- // Check if this is a favourites-related operation type
- $favourites_operations = [
- 'favourites_batch',
- 'favourite_notes',
- 'favourite_list_create',
- 'favourite_list_update',
- 'favourite_list_delete',
- 'favourite_list_add',
- 'favourite_list_remove',
- 'favourite_list_share',
- 'favourite_list_unshare'
- ];
+ if (!$favourite) {
+ $results[] = [
+ 'success' => false,
+ 'target_id' => $target_id,
+ 'message' => 'Favourite not found'
+ ];
+ continue;
+ }
- if (!in_array($operation->type, $favourites_operations)) {
- return $result; // Not our operation, pass through
- }
+ $table->where(['id' => $favourite->id])->updateResults([
+ 'notes' => $notes
+ ]);
- try {
- $user_id = $operation->user_id;
+ $results[] = [
+ 'success' => true,
+ 'action' => 'updated_notes',
+ 'favourite_id' => $favourite->id,
+ 'target_id' => $target_id
+ ];
+ }
- switch ($operation->type) {
- case 'favourites_batch':
- $response = $this->processBatches($user_id, $data);
- $this->cache->flush();
- return $response;
+ return [
+ 'success' => true,
+ 'result' => $results
+ ];
+ });
+ }
- case 'favourite_notes':
- $response = $this->processNote($user_id, $data);
- $this->cache->flush();
- return $response;
+ /**
+ * Update list
+ */
+ protected function updateList(int $user_id, array $data): array
+ {
+ $list_id = absint($data['list_id'] ?? 0);
- case 'favourite_list_create':
- $response = $this->processListCreate($user_id, $data);
- $this->listsCache->flush();
- return $response;
+ if (!$list_id) {
+ return ['success' => false, 'result' => 'List ID is required'];
+ }
- case 'favourite_list_update':
- $response = $this->processUpdateList($user_id, $data);
- $this->listsCache->flush();
- return $response;
+ try {
+ // Verify ownership
+ $is_owner = $this->lists->where([
+ 'id' => $list_id,
+ 'user_id' => $user_id
+ ])->existsInQuery();
- case 'favourite_list_delete':
- $response = $this->processListDeletion($user_id, $data);
- $this->listsCache->flush();
- return $response;
+ if (!$is_owner) {
+ return [
+ 'success' => false,
+ 'result' => 'You do not have permission to update this list'
+ ];
+ }
- case 'favourite_list_add':
- $response = $this->processAddToList($user_id, $data);
- $this->listsCache->flush();
- return $response;
+ // Build update data
+ $update_data = [];
- case 'favourite_list_remove':
- $response = $this->removeFromList($user_id, $data);
- $this->listsCache->flush();
- return $response;
+ if (isset($data['name'])) {
+ $update_data['name'] = sanitize_text_field($data['name']);
+ }
- case 'favourite_list_share':
- $response = $this->shareList($user_id, $data);
- $this->sharedListsCache->flush();
- return $response;
+ if (isset($data['description'])) {
+ $update_data['description'] = sanitize_textarea_field($data['description']);
+ }
- case 'favourite_list_unshare':
- $response = $this->unshareList($user_id, $data);
- $this->sharedListsCache->flush();
- return $response;
+ if (empty($update_data)) {
+ return [
+ 'success' => true,
+ 'result' => 'No changes to update'
+ ];
+ }
- default:
- return $result;
- }
- } catch (Exception $e) {
- JVB()->error()->log(
- '[FavouritesRoutes]:processOperation',
- 'Failed to process queued operation: ' . $e->getMessage(),
- [
- 'operation_id' => $operation->id,
- 'type' => $operation->type,
- 'user_id' => $operation->user_id
- ],
- 'error'
- );
+ // Update the list
+ $this->lists->where(['id' => $list_id])->updateResults($update_data);
- return $result;
- }
- }
+ return [
+ 'success' => true,
+ 'result' => [
+ 'message' => 'List updated successfully',
+ 'list_id' => $list_id,
+ 'updates' => array_keys($update_data)
+ ]
+ ];
- /**
- * Clean up favourites when a post is deleted
- *
- * @param int $post_id The ID of the post being deleted
- */
- public function cleanupPostFavourites(int $post_id)
- {
- try {
- global $wpdb;
- $table = $wpdb->prefix . BASE . 'favourites';
- $type = get_post_type($post_id);
+ } catch (Exception $e) {
+ $this->logError('updateList', [
+ 'error' => $e->getMessage(),
+ 'user_id' => $user_id,
+ 'list_id' => $list_id
+ ]);
- if (!$type) {
- return;
- }
+ return [
+ 'success' => false,
+ 'result' => $e->getMessage()
+ ];
+ }
+ }
- $type = BASE . $type;
+ /**
+ * Delete list
+ */
+ protected function deleteList(int $user_id, array $data): array
+ {
+ $list_id = absint($data['list_id'] ?? 0);
- if (!isset($this->valid_types[$type])) {
- return;
- }
+ if (!$list_id) {
+ return ['success' => false, 'result' => 'List ID is required'];
+ }
- // Delete favourites for this post
- $wpdb->delete(
- $table,
- [
- 'type' => $type,
- 'target_id' => $post_id
- ],
- ['%s', '%d']
- );
+ return $this->lists->transaction(function ($table) use ($user_id, $list_id) {
+ // Verify ownership
+ $is_owner = $table->where([
+ 'id' => $list_id,
+ 'user_id' => $user_id
+ ])->existsInQuery();
- // Also remove from list items
- $items_table = $wpdb->prefix . BASE . 'favourites_list_items';
- $wpdb->delete(
- $items_table,
- [
- 'item_type' => $type,
- 'item_id' => $post_id
- ],
- ['%s', '%d']
- );
+ if (!$is_owner) {
+ throw new Exception('You do not have permission to delete this list');
+ }
- } catch (Exception $e) {
- JVB()->error()->log(
- 'favourites',
- 'Error cleaning up favourites for deleted post: ' . $e->getMessage(),
- ['post_id' => $post_id],
- 'error'
- );
- }
- }
+ // Delete related data (foreign keys should handle this, but being explicit)
+ $this->listItems->where(['list_id' => $list_id])->deleteResults();
+ $this->listShares->where(['list_id' => $list_id])->deleteResults();
- /**
- * Clean up favourites when a term is deleted
- *
- * @param int $term_id The ID of the deleted term
- * @param int $tt_id Term taxonomy ID
- * @param string $taxonomy The taxonomy slug
- */
- public function cleanupTermFavourites(int $term_id, int $tt_id, string $taxonomy)
- {
- try {
- if (!isset($this->valid_types[$taxonomy])) {
- return;
- }
+ // Delete the list
+ $table->where(['id' => $list_id])->deleteResults();
- global $wpdb;
- $table = $wpdb->prefix . BASE . 'favourites';
+ return [
+ 'success' => true,
+ 'result' => [
+ 'message' => 'List deleted successfully',
+ 'list_id' => $list_id
+ ]
+ ];
+ });
+ }
- // Delete favourites for this term
- $wpdb->delete(
- $table,
- [
- 'type' => $taxonomy,
- 'target_id' => $term_id
- ],
- ['%s', '%d']
- );
+ /**
+ * Add items to list
+ */
+ protected function addToList(int $user_id, array $data): array
+ {
+ $list_ids = isset($data['list_id'])
+ ? array_map('absint', explode(',', $data['list_id']))
+ : [];
+ $items = $data['items'] ?? [];
- // Also remove from list items
- $items_table = $wpdb->prefix . BASE . 'favourites_list_items';
- $wpdb->delete(
- $items_table,
- [
- 'item_type' => $taxonomy,
- 'item_id' => $term_id
- ],
- ['%s', '%d']
- );
+ if (empty($list_ids) || empty($items)) {
+ return ['success' => false, 'result' => 'List ID and items are required'];
+ }
- // Clean up list stats
- $stats_table = $wpdb->prefix . BASE . 'favourites_list_stats';
- $wpdb->delete(
- $stats_table,
- [
- 'item_type' => $taxonomy,
- 'item_id' => $term_id
- ],
- ['%s', '%d']
- );
+ return $this->listItems->transaction(function ($table) use ($user_id, $list_ids, $items) {
+ $results = [];
+ $total_added = 0;
+ $all_errors = [];
- } catch (Exception $e) {
- JVB()->error()->log(
- 'favourites',
- 'Error cleaning up favourites for deleted term: ' . $e->getMessage(),
- ['term_id' => $term_id, 'taxonomy' => $taxonomy],
- 'error'
- );
- }
- }
+ foreach ($list_ids as $list_id) {
+ if (!$list_id) {
+ $all_errors[] = ['message' => 'Invalid list ID', 'list_id' => $list_id];
+ continue;
+ }
+
+ // Check permission - either owner or has edit permission
+ $is_owner = $this->lists->where([
+ 'id' => $list_id,
+ 'user_id' => $user_id
+ ])->existsInQuery();
+
+ $has_edit = $this->listShares->where([
+ 'list_id' => $list_id,
+ 'user_id' => $user_id,
+ 'permission_type' => 'edit',
+ 'status' => 'accepted'
+ ])->existsInQuery();
+
+ if (!$is_owner && !$has_edit) {
+ $all_errors[] = [
+ 'message' => 'You do not have permission to add items to this list',
+ 'list_id' => $list_id
+ ];
+ continue;
+ }
+
+ // Add items to this list
+ $add_result = $this->addItemsToList($list_id, $items, $user_id);
+ $total_added += $add_result['added'];
+
+ if (!empty($add_result['errors'])) {
+ $all_errors = array_merge($all_errors, $add_result['errors']);
+ }
+
+ $results[] = [
+ 'list_id' => $list_id,
+ 'added_count' => $add_result['added']
+ ];
+ }
+
+ if ($total_added == 0 && !empty($all_errors)) {
+ throw new Exception('Failed to add any items');
+ }
+
+ return [
+ 'success' => true,
+ 'result' => [
+ 'lists' => $results,
+ 'total_added' => $total_added,
+ 'errors' => $all_errors
+ ]
+ ];
+ });
+ }
+
+ /**
+ * Remove items from list
+ */
+ protected function removeFromList(int $user_id, array $data): array
+ {
+ $list_id = absint($data['list_id'] ?? 0);
+ $items = $data['items'] ?? [];
+
+ if (!$list_id || empty($items)) {
+ return ['success' => false, 'result' => 'List ID and items are required'];
+ }
+
+ try {
+ // Check permission
+ $is_owner = $this->lists->where([
+ 'id' => $list_id,
+ 'user_id' => $user_id
+ ])->existsInQuery();
+
+ $has_edit = $this->listShares->where([
+ 'list_id' => $list_id,
+ 'user_id' => $user_id,
+ 'permission_type' => 'edit',
+ 'status' => 'accepted'
+ ])->existsInQuery();
+
+ if (!$is_owner && !$has_edit) {
+ return [
+ 'success' => false,
+ 'result' => 'You do not have permission to remove items from this list'
+ ];
+ }
+
+ // Remove items
+ $removed = 0;
+ foreach ($items as $item) {
+ if (empty($item['type']) || !isset($item['target_id'])) {
+ continue;
+ }
+
+ $type = str_starts_with($item['type'], BASE)
+ ? $item['type']
+ : BASE . $item['type'];
+
+ $deleted = $this->listItems->where([
+ 'list_id' => $list_id,
+ 'item_type' => $type,
+ 'item_id' => absint($item['target_id'])
+ ])->deleteResults();
+
+ if ($deleted) {
+ $removed += $deleted;
+ }
+ }
+
+ return [
+ 'success' => true,
+ 'result' => [
+ 'message' => "{$removed} items removed from list",
+ 'list_id' => $list_id,
+ 'removed_count' => $removed
+ ]
+ ];
+
+ } catch (Exception $e) {
+ $this->logError('removeFromList', [
+ 'error' => $e->getMessage(),
+ 'user_id' => $user_id,
+ 'list_id' => $list_id
+ ]);
+
+ return [
+ 'success' => false,
+ 'result' => $e->getMessage()
+ ];
+ }
+ }
+
+ /**
+ * Share list with another user
+ */
+ protected function shareList(int $user_id, array $data): array
+ {
+ $list_id = absint($data['list_id'] ?? 0);
+ $email = sanitize_email($data['email'] ?? '');
+ $permission_type = in_array($data['permission_type'] ?? '', ['view', 'edit'])
+ ? $data['permission_type']
+ : 'view';
+
+ if (!$list_id || !$email) {
+ return ['success' => false, 'result' => 'List ID and email are required'];
+ }
+
+ return $this->listShares->transaction(function ($table) use ($user_id, $list_id, $email, $permission_type) {
+ // Verify ownership
+ $list = $this->lists->where([
+ 'id' => $list_id,
+ 'user_id' => $user_id
+ ])->first();
+
+ if (!$list) {
+ throw new Exception('You do not have permission to share this list');
+ }
+
+ // Get owner details
+ $owner = get_userdata($user_id);
+ $owner_name = $owner ? $owner->display_name : 'Someone';
+
+ // Look up user by email
+ $share_user = get_user_by('email', $email);
+
+ if ($share_user) {
+ // User exists - check for existing share
+ $existing = $table->where([
+ 'list_id' => $list_id,
+ 'user_id' => $share_user->ID
+ ])->first();
+
+ if ($existing) {
+ // Update if different
+ if ($existing->permission_type !== $permission_type || $existing->status !== 'accepted') {
+ $table->where(['id' => $existing->id])->updateResults([
+ 'permission_type' => $permission_type,
+ 'status' => 'accepted',
+ ]);
+
+ return [
+ 'success' => true,
+ 'result' => [
+ 'action' => 'updated',
+ 'message' => "Updated sharing permissions for {$email}",
+ ]
+ ];
+ }
+
+ return [
+ 'success' => true,
+ 'result' => [
+ 'action' => 'already_shared',
+ 'message' => "List is already shared with {$email}",
+ ]
+ ];
+ }
+
+ // Create new share
+ $table->create([
+ 'list_id' => $list_id,
+ 'user_id' => $share_user->ID,
+ 'email' => $email,
+ 'permission_type' => $permission_type,
+ 'status' => 'accepted'
+ ]);
+
+ // Send notification
+ JVB()->notification()->addNotification(
+ $share_user->ID,
+ 'list_shared',
+ $user_id,
+ sprintf('%s shared a favorites list with you: "%s"', $owner_name, $list->name),
+ $list_id,
+ 'favourites_list',
+ [
+ 'list_id' => $list_id,
+ 'list_name' => $list->name,
+ 'permission_type' => $permission_type
+ ]
+ );
+
+ return [
+ 'success' => true,
+ 'result' => [
+ 'action' => 'shared',
+ 'message' => "List shared with {$email}",
+ ]
+ ];
+ }
+
+ // User doesn't exist - create pending invitation
+ $existing_pending = $table->where([
+ 'list_id' => $list_id,
+ 'email' => $email,
+ 'status' => 'pending'
+ ])->first();
+
+ if ($existing_pending) {
+ return [
+ 'success' => true,
+ 'result' => [
+ 'action' => 'already_pending',
+ 'message' => "Invitation already sent to {$email}",
+ ]
+ ];
+ }
+
+ // Create pending share with invitation token
+ $token = wp_generate_password(32, false);
+
+ $table->create([
+ 'list_id' => $list_id,
+ 'email' => $email,
+ 'permission_type' => $permission_type,
+ 'status' => 'pending',
+ 'invitation_token' => $token,
+ 'user_id' => 0 // Will be set when they accept
+ ]);
+
+ // Send invitation email
+ $this->sendListInviteEmail($email, [
+ 'list_id' => $list_id,
+ 'list_name' => $list->name,
+ 'token' => $token,
+ 'owner_name' => $owner_name,
+ 'user_id' => $user_id
+ ]);
+
+ return [
+ 'success' => true,
+ 'result' => [
+ 'action' => 'invitation_sent',
+ 'message' => "Invitation sent to {$email}",
+ ]
+ ];
+ });
+ }
+
+ /**
+ * Unshare list
+ */
+ protected function unshareList(int $user_id, array $data): array
+ {
+ $list_id = absint($data['list_id'] ?? 0);
+ $email = sanitize_email($data['email'] ?? '');
+
+ if (!$list_id || !$email) {
+ return ['success' => false, 'result' => 'List ID and email are required'];
+ }
+
+ try {
+ // Verify ownership
+ $is_owner = $this->lists->where([
+ 'id' => $list_id,
+ 'user_id' => $user_id
+ ])->existsInQuery();
+
+ if (!$is_owner) {
+ return [
+ 'success' => false,
+ 'result' => 'You do not have permission to manage shares for this list'
+ ];
+ }
+
+ // Find share by email
+ $existing_share = $this->listShares->where([
+ 'list_id' => $list_id,
+ 'email' => $email
+ ])->first();
+
+ if (!$existing_share) {
+ return [
+ 'success' => false,
+ 'result' => "No active share or invitation found for {$email}"
+ ];
+ }
+
+ // Update status to revoked (or just delete)
+ $this->listShares->where(['id' => $existing_share->id])->updateResults([
+ 'status' => 'revoked'
+ ]);
+
+ // Notify user if registered and was accepted
+ if ($existing_share->status === 'accepted' && $existing_share->user_id) {
+ $list = $this->lists->where(['id' => $list_id])->first();
+
+ if ($list) {
+ JVB()->notification()->addNotification(
+ $existing_share->user_id,
+ 'list_share_revoked',
+ $user_id,
+ sprintf('Your access to the list "%s" has been revoked', $list->name),
+ $list_id,
+ 'favourites_list',
+ [
+ 'list_id' => $list_id,
+ 'list_name' => $list->name
+ ]
+ );
+ }
+ }
+
+ $action = $existing_share->status === 'accepted' ? 'unshared' : 'invitation_cancelled';
+ $message = $existing_share->status === 'accepted'
+ ? "Removed {$email}'s access to list"
+ : "Cancelled invitation to {$email}";
+
+ return [
+ 'success' => true,
+ 'result' => [
+ 'action' => $action,
+ 'message' => $message,
+ ]
+ ];
+
+ } catch (Exception $e) {
+ $this->logError('unshareList', [
+ 'error' => $e->getMessage(),
+ 'user_id' => $user_id,
+ 'list_id' => $list_id,
+ 'email' => $email
+ ]);
+
+ return [
+ 'success' => false,
+ 'result' => $e->getMessage()
+ ];
+ }
+ }
+
+ /**
+ * Send list invitation email
+ */
+ protected function sendListInviteEmail(string $email, array $data): bool
+ {
+ $list_name = $data['list_name'];
+ $token = $data['token'];
+ $owner_name = $data['owner_name'];
+
+ // Generate invitation URL
+ $invite_url = add_query_arg([
+ 'action' => 'accept_list_invite',
+ 'token' => $token,
+ 'list' => $data['list_id']
+ ], home_url('/'));
+
+ $subject = sprintf('%s shared a favourites list with you', $owner_name);
+
+ $message = sprintf(
+ '<p>Hi there,</p>
+ <p><strong>%s</strong> has shared their list "<strong>%s</strong>" with you.</p>
+ <p>To view this list, click the link below:</p>
+ <p><a href="%s">Accept List Invitation</a></p>
+ <p>Or copy and paste this link: %s</p>
+ <p>If you don\'t have an account, you\'ll be guided through creating one.</p>',
+ $owner_name,
+ $list_name,
+ $invite_url,
+ $invite_url
+ );
+
+ return JVB()->email()->sendEmail($email, $subject, $message);
+ }
+
+ /**
+ * Accept a list share invitation
+ */
+ protected function acceptListInvitation(string $token, string $email, ?int $user_id = null): array
+ {
+ if (!$token || !$email) {
+ return [
+ 'success' => false,
+ 'message' => 'Invalid invitation parameters'
+ ];
+ }
+
+ try {
+ // Find the pending invitation
+ $invitation = $this->listShares->where([
+ 'invitation_token' => $token,
+ 'email' => $email,
+ 'status' => 'pending'
+ ])->first();
+
+ if (!$invitation) {
+ return [
+ 'success' => false,
+ 'message' => 'Invalid or expired invitation'
+ ];
+ }
+
+ // If no user_id provided, check if user exists
+ if (!$user_id) {
+ $existing_user = get_user_by('email', $email);
+
+ if ($existing_user) {
+ $user_id = $existing_user->ID;
+ } else {
+ // No account - need to register
+ $registration_url = add_query_arg([
+ 'action' => 'register',
+ 'type' => 'favourites',
+ 'list_token' => $token,
+ 'email' => urlencode($email)
+ ], wp_login_url());
+
+ return [
+ 'success' => false,
+ 'message' => 'You need to create an account to access this shared list',
+ 'needs_registration' => true,
+ 'registration_url' => $registration_url
+ ];
+ }
+ }
+
+ // Get list details
+ $list = $this->lists->where(['id' => $invitation->list_id])->first();
+
+ if (!$list) {
+ return [
+ 'success' => false,
+ 'message' => 'The shared list no longer exists'
+ ];
+ }
+
+ // Update invitation to accepted
+ $this->listShares->where(['id' => $invitation->id])->updateResults([
+ 'status' => 'accepted',
+ 'user_id' => $user_id,
+ ]);
+
+ // Notify list owner
+ $user = get_userdata($user_id);
+ $display_name = $user ? $user->display_name : $email;
+
+ JVB()->notification()->addNotification(
+ $list->user_id,
+ 'list_share_accepted',
+ $user_id,
+ sprintf('%s accepted your invitation to the list "%s"', $display_name, $list->name),
+ $invitation->list_id,
+ 'favourites_list',
+ [
+ 'list_id' => $invitation->list_id,
+ 'list_name' => $list->name,
+ ]
+ );
+
+ return [
+ 'success' => true,
+ 'message' => 'List successfully shared with you',
+ 'list_id' => $invitation->list_id,
+ 'list_name' => $list->name,
+ 'permission_type' => $invitation->permission_type
+ ];
+
+ } catch (Exception $e) {
+ $this->logError('acceptListInvitation', [
+ 'error' => $e->getMessage(),
+ 'token' => $token,
+ 'email' => $email
+ ]);
+
+ return [
+ 'success' => false,
+ 'message' => $e->getMessage()
+ ];
+ }
+ }
+
+ /**
+ * Get the owner ID for a content item
+ */
+ protected function getContentOwner(string $type, int $target_id): int|array|null
+ {
+ // For posts
+ if (str_contains($type, 'post') || in_array($type, array_keys($this->valid_types))) {
+ $post = get_post($target_id);
+ return $post ? $post->post_author : null;
+ }
+
+ // For terms (shops, etc.)
+ $owners = get_term_meta($target_id, BASE . 'owner', true);
+ if ($owners) {
+ $owner_array = array_map('absint', explode(',', $owners));
+ return count($owner_array) === 1 ? $owner_array[0] : $owner_array;
+ }
+
+ return null;
+ }
+
+ /**
+ * Batch format post-type favourites to reduce queries
+ */
+ protected function formatPostFavourites(array $items): array
+ {
+ if (empty($items)) {
+ return [];
+ }
+
+ $formatted = [];
+ $post_ids = array_map(fn($item) => (int)$item->target_id, $items);
+
+ // Get all posts in one query
+ $posts = get_posts([
+ 'post__in' => $post_ids,
+ 'post_type' => 'any',
+ 'posts_per_page' => -1,
+ 'post_status' => 'any',
+ ]);
+
+ // Create lookup map
+ $posts_by_id = [];
+ foreach ($posts as $post) {
+ $posts_by_id[$post->ID] = $post;
+ }
+
+ // Get thumbnails for artists
+ $artist_ids = [];
+ foreach ($items as $item) {
+ if ($item->type === BASE . 'artist') {
+ $artist_ids[] = (int)$item->target_id;
+ }
+ }
+
+ $artist_images = [];
+ if (!empty($artist_ids)) {
+ global $wpdb;
+ $placeholders = implode(',', array_fill(0, count($artist_ids), '%d'));
+ $results = $wpdb->get_results($wpdb->prepare(
+ "SELECT post_id, meta_value FROM {$wpdb->postmeta}
+ WHERE meta_key = %s AND post_id IN ($placeholders)",
+ array_merge([BASE . 'image'], $artist_ids)
+ ));
+
+ foreach ($results as $result) {
+ $artist_images[$result->post_id] = $result->meta_value;
+ }
+ }
+
+ // Get regular thumbnails
+ $thumbnail_ids = [];
+ foreach ($items as $item) {
+ if ($item->type !== BASE . 'artist' && isset($posts_by_id[$item->target_id])) {
+ $thumb_id = get_post_thumbnail_id($item->target_id);
+ if ($thumb_id) {
+ $thumbnail_ids[$item->target_id] = $thumb_id;
+ }
+ }
+ }
+
+ // Format each item
+ foreach ($items as $item) {
+ $post_id = (int)$item->target_id;
+
+ if (!isset($posts_by_id[$post_id])) {
+ continue;
+ }
+
+ $post = $posts_by_id[$post_id];
+
+ $formatted_item = [
+ 'id' => $item->id ?? null,
+ 'type' => str_replace(BASE, '', $item->type),
+ 'target_id' => $post_id,
+ 'date_added' => $item->date_added ?? current_time('mysql'),
+ 'notes' => $item->notes ?? '',
+ 'url' => get_permalink($post),
+ 'title' => $post->post_title,
+ 'author' => [
+ 'id' => $post->post_author,
+ 'name' => get_the_author_meta('display_name', $post->post_author)
+ ]
+ ];
+
+ // Add thumbnail
+ if ($item->type === BASE . 'artist') {
+ $meta_value = $artist_images[$post_id] ?? null;
+ $formatted_item['thumbnail'] = $meta_value ? jvbFormatImage($meta_value, 'medium', 'medium') : null;
+ } else {
+ $thumb_id = $thumbnail_ids[$post_id] ?? null;
+ $formatted_item['thumbnail'] = $thumb_id ? jvbFormatImage($thumb_id, 'medium', 'medium') : null;
+ }
+
+ $formatted[] = $formatted_item;
+ }
+
+ return $formatted;
+ }
+
+ /**
+ * Batch format term-type favourites to reduce queries
+ */
+ protected function formatTermFavourites(array $items): array
+ {
+ if (empty($items)) {
+ return [];
+ }
+
+ $formatted = [];
+
+ // Group by taxonomy
+ $terms_by_taxonomy = [];
+ foreach ($items as $item) {
+ $tax = $item->type;
+ if (!isset($terms_by_taxonomy[$tax])) {
+ $terms_by_taxonomy[$tax] = [];
+ }
+ $terms_by_taxonomy[$tax][] = (int)$item->target_id;
+ }
+
+ // Get all terms by taxonomy
+ $terms_by_id = [];
+ foreach ($terms_by_taxonomy as $taxonomy => $term_ids) {
+ $terms = get_terms([
+ 'taxonomy' => $taxonomy,
+ 'include' => $term_ids,
+ 'hide_empty' => false,
+ ]);
+
+ if (!is_wp_error($terms)) {
+ foreach ($terms as $term) {
+ $terms_by_id[$taxonomy . '_' . $term->term_id] = $term;
+ }
+ }
+ }
+
+ // Get shop images
+ $shop_ids = [];
+ foreach ($items as $item) {
+ if ($item->type === BASE . 'shop') {
+ $shop_ids[] = (int)$item->target_id;
+ }
+ }
+
+ $shop_images = [];
+ if (!empty($shop_ids)) {
+ global $wpdb;
+ $placeholders = implode(',', array_fill(0, count($shop_ids), '%d'));
+ $results = $wpdb->get_results($wpdb->prepare(
+ "SELECT term_id, meta_value FROM {$wpdb->termmeta}
+ WHERE meta_key = %s AND term_id IN ($placeholders)",
+ array_merge([BASE . 'image'], $shop_ids)
+ ));
+
+ foreach ($results as $result) {
+ $shop_images[$result->term_id] = $result->meta_value;
+ }
+ }
+
+ // Format each item
+ foreach ($items as $item) {
+ $term_id = (int)$item->target_id;
+ $key = $item->type . '_' . $term_id;
+
+ if (!isset($terms_by_id[$key])) {
+ continue;
+ }
+
+ $term = $terms_by_id[$key];
+
+ $formatted_item = [
+ 'id' => $item->id ?? null,
+ 'type' => str_replace(BASE, '', $item->type),
+ 'target_id' => $term_id,
+ 'date_added' => $item->date_added ?? current_time('mysql'),
+ 'notes' => $item->notes ?? '',
+ 'title' => html_entity_decode($term->name),
+ 'url' => get_term_link($term)
+ ];
+
+ // Add thumbnail for shops
+ if ($item->type === BASE . 'shop') {
+ $meta_value = $shop_images[$term_id] ?? null;
+ $formatted_item['thumbnail'] = $meta_value ? jvbFormatImage($meta_value, 'medium', 'medium') : null;
+ }
+
+ $formatted[] = $formatted_item;
+ }
+
+ return $formatted;
+ }
+
+ /**
+ * Handle list operations - routes to appropriate method
+ */
+ public function handleList(WP_REST_Request $request): WP_REST_Response
+ {
+ $user_id = absint($request->get_param('user'));
+ $operation_id = sanitize_text_field($request->get_param('id'));
+ $action = sanitize_text_field($request->get_param('action'));
+
+ if (!$this->userCheck($user_id)) {
+ return $this->unauthorized();
+ }
+
+ $data = [
+ 'action' => $action,
+ 'list_id' => absint($request->get_param('list_id') ?? 0),
+ 'name' => sanitize_text_field($request->get_param('name') ?? ''),
+ 'description' => sanitize_textarea_field($request->get_param('description') ?? ''),
+ 'items' => $request->get_param('items') ?? [],
+ 'email' => sanitize_email($request->get_param('email') ?? ''),
+ 'permission_type' => sanitize_text_field($request->get_param('permission_type') ?? 'view'),
+ ];
+
+ // Map action to operation type
+ $operation_type = match ($action) {
+ 'create' => 'favourite_list_create',
+ 'update' => 'favourite_list_update',
+ 'delete' => 'favourite_list_delete',
+ 'add_items' => 'favourite_list_add_items',
+ 'remove_items' => 'favourite_list_remove_items',
+ 'share' => 'favourite_list_share',
+ 'unshare' => 'favourite_list_unshare',
+ default => null
+ };
+
+ if (!$operation_type) {
+ return $this->error('Invalid action', 'invalid_action', 400);
+ }
+
+ JVB()->queue()->queueOperation(
+ $operation_type,
+ $user_id,
+ $data,
+ [
+ 'operation_id' => $operation_id,
+ 'priority' => 'normal',
+ ]
+ );
+
+ return $this->queued($operation_id);
+ }
}
diff --git a/inc/rest/routes/FeedRoutes.php b/inc/rest/routes/FeedRoutes.php
index 8c50485..af6c2c1 100644
--- a/inc/rest/routes/FeedRoutes.php
+++ b/inc/rest/routes/FeedRoutes.php
@@ -1,11 +1,10 @@
<?php
namespace JVBase\rest\routes;
-use JVBase\managers\Cache;
-use JVBase\rest\RestRouteManager;
+use JVBase\meta\Meta;
+use JVBase\rest\Rest;
use JVBase\integrations\Umami;
-use JVBase\meta\MetaManager;
-use JVBase\managers\TaxonomyRelationships;
+use JVBase\rest\Route;
use JVBase\utility\Checker;
use JVBase\utility\Features;
use WP_Query;
@@ -18,7 +17,7 @@
exit; // Exit if accessed directly
}
-class FeedRoutes extends RestRouteManager
+class FeedRoutes extends Rest
{
protected int $per_page = 36;
protected ?Umami $tracker = null;
@@ -29,8 +28,8 @@
public function __construct()
{
- $this->cache_name = 'feed';
- $this->cache_ttl = 86400;
+ $this->cacheName = 'feed';
+ $this->cacheTtl = 86400;
parent::__construct();
$this->cache
->connect('post', true)
@@ -58,17 +57,51 @@
*/
public function registerRoutes(): void
{
- register_rest_route($this->namespace, '/feed', [
- 'methods' => ['GET', 'POST'],
- 'callback' => [$this, 'handleFeedRequest'],
- 'permission_callback' => [$this, 'checkPermission'],
- ]);
+ Route::for('feed')
+ ->get([$this, 'handleFeedRequest'])
+ ->args([
+ 'content' => 'string',
+ 'page' => 'integer|default:1|min:1',
+ 'taxonomy' => 'string',
+ 'match' => 'string|enum:all,any|default:all',
+ 'orderby' => 'string',
+ 'order' => 'string|enum:ASC,DESC',
+ 'date-filter' => 'string',
+ 'dateFrom' => 'string',
+ 'dateTo' => 'string',
+ 'context' => 'string',
+ 'source' => 'string',
+ 'favourites' => 'boolean',
+ 'user' => 'integer',
+ 'highlight' => 'string',
+ ])
+ ->auth('public')
+ ->rateLimit(30, 60)
+ ->post([$this, 'handleFeedRequest'])
+ ->args([
+ 'content' => 'string',
+ 'page' => 'integer|default:1|min:1',
+ 'taxonomy' => 'string',
+ 'match' => 'string|enum:all,any|default:all',
+ 'orderby' => 'string',
+ 'order' => 'string|enum:ASC,DESC',
+ 'date-filter' => 'string',
+ 'dateFrom' => 'string',
+ 'dateTo' => 'string',
+ 'context' => 'string',
+ 'source' => 'string',
+ 'favourites' => 'boolean',
+ 'user' => 'integer',
+ 'highlight' => 'string',
+ ])
+ ->auth('public')
+ ->rateLimit(30, 60);
- register_rest_route($this->namespace, 'feed/types', [
- 'permission_callback' => [$this, 'checkPermission'],
- 'methods' => 'GET',
- 'callback' => [$this, 'getFeedTypes']
- ]);
+ // Feed types endpoint
+ Route::for('feed/types')
+ ->get([$this, 'getFeedTypes'])
+ ->auth('public')
+ ->rateLimit(60, 60);
}
/**
@@ -102,11 +135,15 @@
switch ($metaType) {
case 'post':
$config = JVB_CONTENT[$type];
+
+ $meta = Meta::forPost($postID);
if (!$skip && array_key_exists('is_timeline', $config) && $config['is_timeline']) {
return $this->formatTimeline($postID, $post);
}
break;
case 'term':
+
+ $meta = Meta::forTerm($postID);
$config = JVB_TAXONOMY[$type];
break;
}
@@ -122,7 +159,6 @@
}, ARRAY_FILTER_USE_KEY);
}
- $meta = new MetaManager($postID, $metaType);
$values = $meta->getAll(array_keys($fields));
$out = [
@@ -216,7 +252,7 @@
}
$item = $this->formatItem($postID, 'post', true);
//Step 1: Get the fields that apply to all posts
- $mainMeta = new MetaManager($post->ID, 'post');
+ $mainMeta = Meta::forPost($post->ID);
$item['fields'] = $mainMeta->getAll($this->timelineSharedFields);
//Step 2: Get the fields for each individual posts
@@ -228,7 +264,7 @@
$subFields = [];
$images = [];
foreach ($children as $child) {
- $meta = new MetaManager($child, 'post');
+ $meta = Meta::forPost($child);
$f = $meta->getAll($this->timelineUniqueFields);
$f = ['id' => $child] + $f;
$subFields[] = $f;
@@ -385,44 +421,6 @@
return $this->applyFavouritesFilter($args, $data);
}
-// protected function applyTaxonomyFilters(array $args, array $data): array
-// {
-// if (!array_key_exists('taxonomy', $data) || empty($data['taxonomy'])) {
-// return $args;
-// }
-//
-// $taxonomyFilters = $data['taxonomy'];
-//
-// // Validate taxonomies exist and sanitize
-// $validFilters = [];
-// foreach ($taxonomyFilters as $taxonomy => $terms) {
-// if (!taxonomy_exists(jvbCheckBase($taxonomy))) {
-// continue;
-// }
-//
-// $validFilters[] = [
-// 'taxonomy' => jvbCheckBase($taxonomy),
-// 'field' => 'term_id',
-// 'terms' => array_map('absint', (array)$terms),
-// 'operator' => 'IN'
-// ];
-// }
-//
-// if (empty($validFilters)) {
-// return $args;
-// }
-//
-// // Determine relation based on match filter
-// $relation = ($data['match'] ?? 'all') === 'all' ? 'AND' : 'OR';
-//
-// $args['tax_query'] = array_merge(
-// ['relation' => $relation],
-// $validFilters
-// );
-//
-// return $args;
-// }
-
/**
* @param WP_REST_Request $request
*
@@ -449,13 +447,13 @@
$args['highlight'] = $highlight;
}
$cached['items'] = $this->processHighlightedItem($cached['items'], $args);
- $response = new WP_REST_Response($cached);
+ $response = $this->success($cached);
return $this->addCacheHeaders($response);
}
// Fetch and format items
$items = $this->fetchFeedItems($args);
- $ttl = (str_contains($args['orderby'], 'RAND')) ? 300 : $this->cache_ttl;
+ $ttl = (str_contains($args['orderby'], 'RAND')) ? 300 : $this->cacheTtl;
$this->cache->set($key, $items, $ttl);
if ($request->get_param('highlight')) {
@@ -464,86 +462,12 @@
}
$items['items'] = $this->processHighlightedItem($items['items'], $args);
- $response = new WP_REST_Response($items);
+ $response = $this->success($items);
return $this->addCacheHeaders($response);
}
/**
- * Build cache context from query args
- * Extracts content types and parameters needed for proper cache checking
- *
- * @param array $args Built WP_Query arguments
- * @param WP_REST_Request $request Original request
- * @return array Cache context with content_types and additional_params
- */
- protected function buildCacheContext(array $args, WP_REST_Request $request): array
- {
- // Extract content types from post_type in args
- $post_types = is_array($args['post_type'])
- ? $args['post_type']
- : [$args['post_type']];
-
- $content_types = array_map('jvbNoBase', $post_types);
- $content_types[] = 'feed'; // Always include base feed type
-
- // Build additional params for ETag uniqueness
- $additional_params = [
- 'order' => $args['orderby'] ?? 'date',
- 'direction' => $args['order'] ?? 'DESC',
- 'page' => $args['paged'] ?? 1,
- ];
-
- if ($request->get_param('favourites')) {
- $additional_params['user'] = (int)$request->get_param('user');
- }
-
- // Include author filter if present (from context or favourites)
- if (!empty($args['author'])) {
- $additional_params['author'] = $args['author'];
- }
-
- if (!empty($args['author__in'])) {
- $additional_params['author__in'] = $args['author__in'];
- }
-
- // Include taxonomy filters if present
- if (!empty($args['tax_query'])) {
- $tax_filters = [];
- foreach ($args['tax_query'] as $key => $query) {
- if ($key === 'relation' || !is_array($query)) {
- continue;
- }
-
- $taxonomy = jvbNoBase($query['taxonomy'] ?? '');
- if ($taxonomy) {
- $tax_filters[$taxonomy] = $query['terms'] ?? [];
- // Also add taxonomy to content_types for timestamp checking
- $content_types[] = $taxonomy;
- }
- }
- if (!empty($tax_filters)) {
- $additional_params['taxonomies'] = $tax_filters;
- }
- }
-
- // Include date filters if present
- if (!empty($args['date_query'])) {
- $additional_params['date_filter'] = md5(serialize($args['date_query']));
- }
-
- // Include meta queries if present
- if (!empty($args['meta_query'])) {
- $additional_params['meta_filter'] = md5(serialize($args['meta_query']));
- }
-
- return [
- 'content_types' => array_unique($content_types),
- 'additional_params' => $additional_params
- ];
- }
-
- /**
- * @param array $args Formatted Args for WP_Query
+ * @param array $items Formatted Args for WP_Query
* @param array $data parsed Request Data
*
* @return array|null
@@ -597,35 +521,39 @@
: explode(',', $args['post_type']);
// Check if filtering global feed content
- $globalFeedTypes = array_map('jvbCheckBase',
- array_keys(Features::getTypesWithFeature('show_feed', 'content'))
- );
-
- if (array_intersect($args['post_type'], $globalFeedTypes)) {
- $artists = jvbGetContentUsers($context['id']);
- if (!empty($artists)) {
- $args['author__in'] = $artists;
+ if (in_array($context['type'], jvbGlobalFeedContentTaxonomies())) {
+ // Global: show posts from any content type with this taxonomy
+ $for_content = JVB_TAXONOMY[$context['type']]['for_content'] ?? [];
+ if (empty($for_content)) {
+ // Fall back to any content that has this taxonomy registered
+ $for_content = array_keys(
+ array_filter(
+ JVB_CONTENT,
+ fn($c) => in_array($context['type'], $c['taxonomies'] ?? [])
+ )
+ );
}
- } else {
- $args['tax_query'] = [
- 'relation' => 'AND',
- [
- 'taxonomy' => BASE . $context['type'],
- 'terms' => $context['id'],
- ]
- ];
+
+ // Convert to full post types with BASE prefix
+ $post_types = array_map(fn($type) => BASE . $type, $for_content);
+
+ // Filter to only show_feed content types
+ $show_feed_types = Features::getTypesWithFeature('show_feed', 'content');
+ $args['post_type'] = array_intersect(
+ $post_types,
+ array_map(fn($type) => BASE . $type, $show_feed_types)
+ );
}
- break;
- case taxonomy_exists(jvbCheckBase($context['type'])):
- $args['tax_query'] = [
- 'relation' => 'AND',
- [
- 'taxonomy' => BASE . $context['type'],
- 'terms' => $context['id'],
- ]
+
+ // Add term to tax query
+ $args['tax_query'][] = [
+ 'taxonomy' => jvbCheckBase($context['type']),
+ 'field' => 'term_id',
+ 'terms' => [(int)$context['id']],
];
break;
}
+
return $args;
}
@@ -635,38 +563,34 @@
*
* @return array
*/
- protected function applyFavouritesFilter(array $args, array $filters): array
+ protected function applyFavouritesFilter(array $args, array $data): array
{
- if (!array_key_exists('favourites', $filters)) {
+ if (empty($data['favourites']) || empty($data['user'])) {
return $args;
}
- global $wpdb;
- // Get post types for the current filter
- $post_types = is_array($args['post_type'])
- ? $args['post_type']
- : [$args['post_type']];
+ $user_id = (int)$data['user'];
+ $content = jvbNoBase($args['post_type']);
- $favourites_table = $wpdb->prefix . BASE . 'favourites';
- $placeholders = implode(',', array_fill(0, count($post_types), '%s'));
- $favourited_ids = $wpdb->get_col($wpdb->prepare(
- "SELECT target_id FROM {$favourites_table}
- WHERE user_id = %d AND type IN ($placeholders)",
- array_merge(
- [get_current_user_id()],
- $post_types
- )
- ));
+ // Get user's favourites for this content type
+ $fav_key = BASE . 'favourites_' . $content;
+ $favourites = get_user_meta($user_id, $fav_key, true);
- if (empty($favourited_ids)) {
- // Force empty results
+ if (empty($favourites)) {
+ // No favourites - return empty result
+ $args['post__in'] = [0]; // Will return no results
+ return $args;
+ }
+
+ $fav_ids = array_filter(array_map('intval', explode(',', $favourites)));
+
+ if (empty($fav_ids)) {
$args['post__in'] = [0];
return $args;
}
- $args['post__in'] = isset($args['post__in'])
- ? array_intersect($args['post__in'], $favourited_ids)
- : $favourited_ids;
+ $args['post__in'] = $fav_ids;
+ $args['orderby'] = 'post__in'; // Preserve favourite order
return $args;
}
@@ -1235,7 +1159,7 @@
$feedTypes = $this->buildFeedTypesConfig();
- $response = new WP_REST_Response($feedTypes);
+ $response = $this->success($feedTypes);
return $this->addCacheHeaders($response);
}
diff --git a/inc/rest/routes/FormRoutes.php b/inc/rest/routes/FormRoutes.php
index 91a49c8..9b136e2 100644
--- a/inc/rest/routes/FormRoutes.php
+++ b/inc/rest/routes/FormRoutes.php
@@ -1,10 +1,13 @@
<?php
namespace JVBase\rest\routes;
-use JVBase\rest\RestRouteManager;
+use JVBase\meta\Sanitizer;
+use JVBase\meta\Validator;
+use JVBase\rest\PermissionHandler;
+use JVBase\rest\Rest;
use JVBase\managers\Cache;
-use JVBase\meta\MetaManager;
use JVBase\blocks\FormBlock;
+use JVBase\rest\Route;
use JVBase\utility\Features;
use WP_REST_Request;
use WP_REST_Response;
@@ -20,17 +23,15 @@
*
* Handles REST API endpoints for form submissions
*/
-class FormRoutes extends RestRouteManager
+class FormRoutes extends Rest
{
- protected Cache $cache;
protected FormBlock $form_block;
public function __construct()
{
+ $this->cacheName = 'forms';
+ $this->cacheTtl = HOUR_IN_SECONDS;
parent::__construct();
- $this->action = 'form-';
- $this->cache = Cache::for('forms', HOUR_IN_SECONDS);
-
// Add query vars
add_filter('query_vars', [$this, 'addQueryVars']);
@@ -43,35 +44,27 @@
*/
public function registerRoutes(): void
{
- // Form submission endpoint
- register_rest_route($this->namespace, '/forms', [
- [
- 'methods' => 'POST',
- 'callback' => [$this, 'submitForm'],
- 'permission_callback' => [$this, 'checkRateLimit'], // Public endpoint, rate limited
- ],
- [
- 'methods' => 'GET',
- 'callback' => [$this, 'getForms'],
- 'permission_callback' => [$this, 'checkPermission']
- ]
- ]);
+ // ['actionNonce'=>'dash-']
+ Route::for('forms')
+ ->post([$this, 'submitForm'])
+ ->args([
+ 'form_type' => 'string|required',
+ 'form_id' => 'string|required',
+ 'timestamp' => 'string',
+ 'cf-turnstile-response' => 'string',
+ ])
+ ->auth('public')
+ ->rateLimit(5) // 5 submissions per minute
+ ->get([$this, 'getForms'])
+ ->auth(PermissionHandler::combine(['logged_in', ['actionNonce'=>'dash-']]))
+ ->rateLimit(30);
// Get specific form configuration
- register_rest_route($this->namespace, '/forms/(?P<form_type>[a-zA-Z0-9_-]+)', [
- [
- 'methods' => 'GET',
- 'callback' => [$this, 'getForm'],
- 'permission_callback' => [$this, 'checkPermission'],
- 'args' => [
- 'form_type' => [
- 'required' => true,
- 'type' => 'string',
- 'sanitize_callback' => 'sanitize_text_field'
- ]
- ]
- ]
- ]);
+ Route::for(Route::pattern('forms/{form_type}'))
+ ->get([$this, 'getForm'])
+ ->arg('form_type', 'string|required')
+ ->auth('logged_in')
+ ->rateLimit(30);
}
/**
@@ -98,25 +91,29 @@
$result = $this->handleFormSubmission($form_type, $form_id, $form_data, $files);
if (is_wp_error($result)) {
- return new WP_REST_Response([
- 'success' => false,
- 'message' => $result->get_error_message()
- ]);
+ return $this->error(
+ $result->get_error_message(),
+ $result->get_error_code(),
+ 400
+ );
}
if (array_key_exists('success', $result)){
- return new WP_REST_Response($result);
+ return $this->validationError($result);
}
- return new WP_REST_Response([
- 'success' => true,
- 'data' => $result
- ], 200);
+ return $this->success($result);
} catch (Exception $e) {
- return new WP_REST_Response([
- 'success' => false,
- 'message' => 'An error occurred while processing your submission.'
- ], 500);
+ $this->logError('Form submission error', [
+ 'message' => $e->getMessage(),
+ 'trace' => $e->getTraceAsString()
+ ]);
+
+ return $this->error(
+ 'An error occurred while processing your submission.',
+ 'submission_error',
+ 500
+ );
}
}
@@ -199,7 +196,9 @@
*/
protected function validateAndSanitizeData(array $form_config, array $form_data): array|WP_REST_Response
{
- $meta = new MetaManager(null, 'form');
+ $validator = new Validator();
+ $sanitizer = new Sanitizer();
+
$processed_data = [];
$errors = [];
@@ -240,7 +239,7 @@
$field_config['name'] = $field_name;
// Validate field
- if (!$meta->validator->validate($value, $field_config)) {
+ if (!$validator->validate($value, $field_config)) {
$label = $field_config['label'] ?? ucfirst(str_replace('_', ' ', $field_name));
$errors['errors'][$field_name] = [
'message' => sprintf('Field "%s" contains invalid data.', $label)
@@ -249,7 +248,7 @@
}
// Sanitize field
- $processed_data[$field_name] = $meta->sanitizer->sanitize($value, $field_config);
+ $processed_data[$field_name] = $sanitizer->sanitize($value, $field_config);
}
if (!empty($errors)) {
@@ -279,7 +278,7 @@
$submitter_name = $form_data['name'];
}
- if (!array_key_exists('preheader', $form_config)) {
+ if (array_key_exists('preheader', $form_config)) {
$preheader = $form_config['preheader'];
} else {
$submitter_name = $submitter_name?:'website visitor';
@@ -713,7 +712,7 @@
];
}
- return new WP_REST_Response($public_forms, 200);
+ return $this->success($public_forms);
}
/**
@@ -721,18 +720,16 @@
*/
public function getForm(WP_REST_Request $request): WP_REST_Response
{
- $form_type = $request->get_param('form_type');
+ $form_type = sanitize_text_field($request->get_param('form_type'));
$form_config = FormBlock::getForm($form_type);
if (!$form_config) {
- return new WP_REST_Response([
- 'error' => 'Form not found'
- ], 404);
+ return $this->notFound('Form not found');
}
// Remove sensitive data
unset($form_config['email_to']);
- return new WP_REST_Response($form_config, 200);
+ return $this->success($form_config);
}
}
diff --git a/inc/rest/routes/ImporterRoutes.php b/inc/rest/routes/ImporterRoutes.php
index 1dcc50b..2e4bdaf 100644
--- a/inc/rest/routes/ImporterRoutes.php
+++ b/inc/rest/routes/ImporterRoutes.php
@@ -1,9 +1,11 @@
<?php
-namespace JVBase\routes;
+namespace JVBase\rest\routes;
-use JVBase\managers\JaneClientImporter;
-use JVBase\managers\JaneSalesImporter;
+use JVBase\importers\JaneAppClientImporter;
+use JVBase\importers\JaneAppSalesImporter;
+use JVBase\rest\Rest;
+use JVBase\rest\Route;
use WP_REST_Request;
use WP_REST_Response;
use WP_Error;
@@ -17,62 +19,37 @@
*
* REST API endpoints for importing JaneApp data
*/
-class JaneImportRoutes
+class ImporterRoutes extends Rest
{
- protected string $namespace;
-
- public function __construct()
- {
- $this->namespace = BASE . 'v1';
- }
-
/**
* Register REST routes
*/
public function registerRoutes(): void
{
// Client import endpoint
- register_rest_route($this->namespace, '/jane/import-clients', [
- 'methods' => 'POST',
- 'callback' => [$this, 'importClients'],
- 'permission_callback' => [$this, 'checkAdminPermission'],
- 'args' => [
- 'file' => [
- 'required' => true,
- 'description' => 'CSV file containing client data'
- ],
- 'options' => [
- 'required' => false,
- 'default' => [],
- 'description' => 'Import options'
- ]
- ]
- ]);
+ Route::for('jane/import-clients')
+ ->post([$this, 'importClients'])
+ ->args([
+ 'options' => 'string', // JSON string of options
+ ])
+ ->auth('admin')
+ ->rateLimit(3, 300); // 3 imports per 5 minutes
// Sales import endpoint
- register_rest_route($this->namespace, '/jane/import-sales', [
- 'methods' => 'POST',
- 'callback' => [$this, 'importSales'],
- 'permission_callback' => [$this, 'checkAdminPermission'],
- 'args' => [
- 'file' => [
- 'required' => true,
- 'description' => 'CSV file containing sales data'
- ],
- 'options' => [
- 'required' => false,
- 'default' => [],
- 'description' => 'Import options'
- ]
- ]
- ]);
+ Route::for('jane/import-sales')
+ ->post([$this, 'importSales'])
+ ->args([
+ 'options' => 'string', // JSON string of options
+ ])
+ ->auth('admin')
+ ->rateLimit(3, 300); // 3 imports per 5 minutes
// Get import status
- register_rest_route($this->namespace, '/jane/import-status/(?P<id>[\w-]+)', [
- 'methods' => 'GET',
- 'callback' => [$this, 'getImportStatus'],
- 'permission_callback' => [$this, 'checkAdminPermission']
- ]);
+ Route::for(Route::pattern('jane/import-status/{id}'))
+ ->get([$this, 'getImportStatus'])
+ ->arg('id', 'string|required')
+ ->auth('admin')
+ ->rateLimit(30, 60);
}
/**
@@ -87,25 +64,27 @@
* Import clients from CSV
*
* @param WP_REST_Request $request
- * @return WP_REST_Response|WP_Error
+ * @return WP_REST_Response
*/
- public function importClients(WP_REST_Request $request)
+ public function importClients(WP_REST_Request $request): WP_REST_Response
{
// Get uploaded file
$files = $request->get_file_params();
if (empty($files['file'])) {
- return new WP_Error('no_file', 'No file uploaded', ['status' => 400]);
+ return $this->error('No file uploaded', 'no_file', 400);
}
$file = $files['file'];
// Validate file type
if (!$this->isValidCSV($file)) {
- return new WP_Error('invalid_file', 'Invalid file type. Please upload a CSV file.', ['status' => 400]);
+ return $this->error('Invalid file type. Please upload a CSV file.', 'invalid_file', 400);
}
- // Get options
- $options = $request->get_param('options') ?: [];
+ // Get and parse options
+ $options_param = $request->get_param('options');
+ $options = !empty($options_param) ? json_decode($options_param, true) : [];
+
$default_options = [
'update_existing' => true,
'create_users' => true,
@@ -114,14 +93,19 @@
$options = wp_parse_args($options, $default_options);
// Process import
- $importer = new JaneClientImporter();
+ $importer = new JaneAppClientImporter();
$results = $importer->importFromCSV($file['tmp_name'], $options);
if (is_wp_error($results)) {
- return new WP_Error(
- 'import_failed',
+ $this->logError('Client import failed', [
+ 'error' => $results->get_error_message(),
+ 'file' => $file['name']
+ ]);
+
+ return $this->error(
$results->get_error_message(),
- ['status' => 500]
+ 'import_failed',
+ 500
);
}
@@ -133,51 +117,57 @@
'completed_at' => current_time('mysql')
], HOUR_IN_SECONDS);
- return new WP_REST_Response([
- 'success' => true,
+ return $this->success([
'import_id' => $import_id,
'results' => $results,
'summary' => $this->generateClientImportSummary($results)
- ], 200);
+ ]);
}
/**
* Import sales from CSV
*
* @param WP_REST_Request $request
- * @return WP_REST_Response|WP_Error
+ * @return WP_REST_Response
*/
- public function importSales(WP_REST_Request $request)
+ public function importSales(WP_REST_Request $request): WP_REST_Response
{
// Get uploaded file
$files = $request->get_file_params();
if (empty($files['file'])) {
- return new WP_Error('no_file', 'No file uploaded', ['status' => 400]);
+ return $this->error('No file uploaded', 'no_file', 400);
}
$file = $files['file'];
// Validate file type
if (!$this->isValidCSV($file)) {
- return new WP_Error('invalid_file', 'Invalid file type. Please upload a CSV file.', ['status' => 400]);
+ return $this->error('Invalid file type. Please upload a CSV file.', 'invalid_file', 400);
}
- // Get options
- $options = $request->get_param('options') ?: [];
+ // Get and parse options
+ $options_param = $request->get_param('options');
+ $options = !empty($options_param) ? json_decode($options_param, true) : [];
+
$default_options = [
'skip_existing' => true
];
$options = wp_parse_args($options, $default_options);
// Process import
- $importer = new JaneSalesImporter();
+ $importer = new JaneAppSalesImporter();
$results = $importer->importFromCSV($file['tmp_name'], $options);
if (is_wp_error($results)) {
- return new WP_Error(
- 'import_failed',
+ $this->logError('Sales import failed', [
+ 'error' => $results->get_error_message(),
+ 'file' => $file['name']
+ ]);
+
+ return $this->error(
$results->get_error_message(),
- ['status' => 500]
+ 'import_failed',
+ 500
);
}
@@ -189,37 +179,29 @@
'completed_at' => current_time('mysql')
], HOUR_IN_SECONDS);
- return new WP_REST_Response([
- 'success' => true,
+ return $this->success([
'import_id' => $import_id,
'results' => $results,
'summary' => $this->generateSalesImportSummary($results)
- ], 200);
+ ]);
}
/**
* Get import status by ID
*
* @param WP_REST_Request $request
- * @return WP_REST_Response|WP_Error
+ * @return WP_REST_Response
*/
- public function getImportStatus(WP_REST_Request $request)
+ public function getImportStatus(WP_REST_Request $request): WP_REST_Response
{
- $import_id = $request->get_param('id');
+ $import_id = sanitize_text_field($request->get_param('id'));
$import_data = get_transient('jane_import_' . $import_id);
if (!$import_data) {
- return new WP_Error(
- 'import_not_found',
- 'Import not found or expired',
- ['status' => 404]
- );
+ return $this->notFound('Import not found or expired');
}
- return new WP_REST_Response([
- 'success' => true,
- 'data' => $import_data
- ], 200);
+ return $this->success($import_data);
}
/**
diff --git a/inc/rest/routes/IntegrationsRoutes.php b/inc/rest/routes/IntegrationsRoutes.php
index 31544e0..73112de 100644
--- a/inc/rest/routes/IntegrationsRoutes.php
+++ b/inc/rest/routes/IntegrationsRoutes.php
@@ -1,7 +1,8 @@
<?php
namespace JVBase\rest\routes;
-use JVBase\rest\RestRouteManager;
+use JVBase\rest\Rest;
+use JVBase\rest\Route;
use WP_REST_Request;
use WP_REST_Response;
use Exception;
@@ -10,7 +11,7 @@
exit; // Exit if accessed directly
}
-class IntegrationsRoutes extends RestRouteManager
+class IntegrationsRoutes extends Rest
{
/**
@@ -18,113 +19,26 @@
*/
public function registerRoutes(): void
{
- register_rest_route($this->namespace, '/integrations', [
- 'methods' => 'POST',
- 'callback' => [$this, 'handleAction'],
- 'permission_callback' => [$this, 'checkPermission'],
- 'args' => [
- 'service' => [
- 'required' => true,
- 'type' => 'string',
- 'enum' => JVB()->getAvailableServices()
- ],
- 'action' => [
- 'required' => true,
- 'sanitize_callback' => 'sanitize_text_field',
- ],
- 'user_id' => [
- 'required' => false,
- 'sanitize_callback' => 'absint',
- ],
- 'context' => [
- 'required' => false,
- 'default' => 'user',
- 'sanitize_callback' => 'sanitize_text_field',
- 'validate_callback' => function($param) {
- return in_array($param, ['admin', 'user']);
- }
- ],
- 'data' => [
- 'required' => false,
- 'default' => [],
- ]
- ]
- ]);
-
-// register_rest_route($this->namespace, '/oauth/callback', [
-// 'methods' => 'GET',
-// 'callback' => [$this, 'handleOAuthCallback'],
-// 'permission_callback' => '__return_true', // External service callback
-// 'args' => [
-// 'service' => [
-// 'required' => true,
-// 'sanitize_callback' => 'sanitize_text_field',
-// ],
-// 'code' => [
-// 'required' => false,
-// 'sanitize_callback' => 'sanitize_text_field',
-// ],
-// 'state' => [
-// 'required' => false,
-// 'sanitize_callback' => 'sanitize_text_field',
-// ],
-// 'error' => [
-// 'required' => false,
-// 'sanitize_callback' => 'sanitize_text_field',
-// ]
-// ]
-// ]);
-
- // Add OAuth initiation route (for AJAX calls)
- register_rest_route($this->namespace, '/oauth/connect', [
- 'methods' => 'POST',
- 'callback' => [$this, 'initiateOAuth'],
- 'permission_callback' => [$this, 'checkPermissions'],
- 'args' => [
- 'service' => [
- 'required' => true,
- 'sanitize_callback' => 'sanitize_text_field',
- ],
- 'user_id' => [
- 'required' => false,
- 'sanitize_callback' => 'absint',
- ],
- 'return_url' => [
- 'required' => false,
- 'sanitize_callback' => 'esc_url_raw',
- ]
- ]
- ]);
- }
-
- /**
- * Check permissions based on context
- */
- public function checkPermission(\WP_REST_Request $request): bool
- {
- parent::checkPermission($request);
- $context = $request->get_param('context') ?? 'user';
- $user_id = $request->get_param('user_id');
-
- // Admin context requires manage_options
- if ($context === 'admin') {
- return current_user_can('manage_options');
- }
-
- // User context
- if (!is_user_logged_in()) {
- return false;
- }
-
- $current_user_id = get_current_user_id();
-
- // If user_id provided, verify it matches current user
- // OR current user is admin
- if ($user_id && $user_id != $current_user_id) {
- return current_user_can('manage_options');
- }
-
- return true;
+ Route::for('integrations')
+ ->post([$this, 'handleAction'])
+ ->args([
+ 'service' => 'string|required|enum:'.implode(',',JVB()->getAvailableServices()),
+ 'action' => 'string|required',
+ 'user_id' => 'int',
+ 'context' => 'string|enum:admin,user',
+ 'data' => 'array'
+ ])
+ ->auth('user')
+ ->rateLimit(20);
+ Route::for('oath/connect')
+ ->post([$this, 'initiateOAuth'])
+ ->auth('user')
+ ->rateLimit(20)
+ ->args([
+ 'service' => 'string|required',
+ 'user_id' => 'int',
+ 'return_url'=> 'url'
+ ]);
}
/**
@@ -136,154 +50,51 @@
$service = $request->get_param('service');
$action = $request->get_param('action');
- // Get the integration instance
- $userID = absint($request->get_param('user_id'));
- if (!$this->userCheck($userID)) {
- return new WP_REST_Response([
- 'success' => false,
- 'message' => 'Invalid User'
- ]);
- }
- $theUserID = (user_can($userID, 'manage_options')) ? null : $userID;
+ $theUserID = (user_can($request->get_param('user'), 'manage_options')) ? null : $request->get_param('user');
$integration = JVB()->connect($service, $theUserID);
if (!$integration) {
- return new WP_REST_Response([
- 'success' => false,
- 'message' => 'Service not found'
- ], 404);
+ return $this->validationError(['message'=>'Invalid service']);
}
-
$integration->getCredentials();
// Handle the action
try {
- // Get data parameter - DON'T convert empty array to null
$data = $request->get_param('data');
-
- // Only set to null if it's truly empty or not provided
if (!is_array($data) && empty($data)) {
$data = null;
}
-
error_log('[IntegrationsRoutes] Calling processAction with data: ' . print_r($data, true));
$result = $integration->processAction($action, $data);
- return new WP_REST_Response($result, 200);
+ return $this->success($result);
} catch (Exception $e) {
- return new WP_REST_Response([
- 'success' => false,
- 'message' => $e->getMessage()
- ], 400);
+ return $this->error($e->getMessage());
}
}
public function initiateOAuth(WP_REST_Request $request): WP_REST_Response
{
$service = $request->get_param('service');
- $user_id = $request->get_param('user_id') ?: get_current_user_id();
+ $user_id = $request->get_param('user_id');
$return_url = $request->get_param('return_url');
$integration = JVB()->connect($service, $user_id);
if (!$integration || !$integration->isOAuthService) {
- return new WP_REST_Response([
- 'success' => false,
- 'message' => 'Invalid OAuth service'
- ], 400);
+ return $this->validationError(['message'=>'Invalid service']);
}
$auth_url = $integration->getOAuthUrl($return_url);
if ($auth_url) {
- return new WP_REST_Response([
- 'success' => true,
- 'auth_url' => $auth_url,
- 'popup' => true
- ], 200);
+ return $this->success($auth_url);
}
- return new WP_REST_Response([
- 'success' => false,
- 'message' => 'Failed to generate authorization URL'
- ], 400);
- }
-
- /**
- * Handle OAuth callback from external service
- */
- public function handleOAuthCallback(WP_REST_Request $request): WP_REST_Response
- {
- $service = $request->get_param('service');
- $code = $request->get_param('code');
- $state = $request->get_param('state');
- $error = $request->get_param('error');
-
- error_log('OAuth Callback - Service: ' . $request->get_param('service'));
- error_log('OAuth Callback - Code: ' . $request->get_param('code'));
- error_log('OAuth Callback - State: ' . $request->get_param('state'));
- error_log('OAuth Callback - Error: ' . $request->get_param('error'));
-
-
- $state_parts = explode('|', $state);
- $state_key = $state_parts[0] ?? '';
- $user_id = intval($state_parts[1] ?? 0);
- $user_id = ($user_id === 0) ? null : $user_id;
- $return_url = isset($state_parts[2]) ? base64_decode($state_parts[2]) : admin_url('admin.php?page=jvb-integrations');
-
-
- $state_data = get_transient('oauth_state_' . $state_key);
- error_log('State Data: '.print_r($state_data, true));
- if (!$state_data || $state_data['service'] !== $service) {
- wp_die('Invalid state parameter', 'OAuth Error');
- }
-
- // Delete the transient to prevent reuse
- delete_transient('oauth_state_' . $state_key);
- error_log('Return URL: '.print_r($return_url, true));
- // Handle error from OAuth provider
- if ($error) {
- $error_description = $request->get_param('error_description') ?? 'Authorization denied';
-
- wp_redirect(add_query_arg([
- 'page' => 'jvb-integrations',
- 'error' => 'OAuth authorization denied: ' . $error_description
- ], $return_url));
- exit;
- }
-
- // Get integration instance
- error_log('User ID: '.print_r($user_id, true));
- error_log('Service: '.print_r($service, true));
- $integration = JVB()->connect($service, $user_id);
-
- if (!$integration) {
- wp_die('Invalid service: ' . esc_html($service), 'OAuth Error');
- }
-
-
- // Exchange code for tokens
- $result = $integration->handleOAuthCode($code, $state);
-
- // Redirect back with result
- if ($result['success']) {
- wp_redirect(add_query_arg([
- 'page' => 'jvb-integrations',
- 'success' => 'Successfully connected to ' . $integration->title
- ], $return_url));
- } else {
- // Handle failure
- $error_message = $result['message'] ?? 'Failed to complete OAuth authorization';
-
- wp_redirect(add_query_arg([
- 'page' => 'jvb-integrations',
- 'error' => $error_message
- ], $return_url));
- }
- exit;
+ return $this->error('Failed to generate authorization URL');
}
}
diff --git a/inc/rest/routes/IntegrationsSquareRoutes.php b/inc/rest/routes/IntegrationsSquareRoutes.php
index af16f06..596be1e 100644
--- a/inc/rest/routes/IntegrationsSquareRoutes.php
+++ b/inc/rest/routes/IntegrationsSquareRoutes.php
@@ -2,47 +2,44 @@
namespace JVBase\rest\routes;
-use JVBase\rest\RestRouteManager;
+use JVBase\meta\Meta;
+use JVBase\rest\Rest;
+use Exception;
+use JVBase\rest\Route;
use WP_REST_Request;
use WP_REST_Response;
-use Exception;
if (!defined('ABSPATH')) {
exit; // Exit if accessed directly
}
-class IntegrationsSquareRoutes extends RestRouteManager
+class IntegrationsSquareRoutes extends Rest
{
public function registerRoutes():void
{
- register_rest_route('jvb/v1/square', '/process-payment', [
- 'methods' => 'POST',
- 'callback' => [$this, 'handlePaymentProcessing'],
- 'permission_callback' => '__return_true' // Adjust based on your auth
- ]);
+ Route::for('square/process-payment')
+ ->post([$this, 'handlePaymentProcessing'])
+ ->auth('public')
+ ->rateLimit(2);
- register_rest_route('jvb/v1/square', '/saved-cards', [
- 'methods' => 'GET',
- 'callback' => [$this, 'getSavedCards'],
- 'permission_callback' => 'is_user_logged_in'
- ]);
+ Route::for('square/saved-cards')
+ ->post([$this, 'getSavedCards'])
+ ->auth('user')
+ ->rateLimit(5);
+ Route::for('square/order-history')
+ ->get([$this, 'getOrderHistory'])
+ ->auth('user')
+ ->rateLimit(5);
- register_rest_route('jvb/v1/square', '/order-history', [
- 'methods' => 'GET',
- 'callback' => [$this, 'getOrderHistory'],
- 'permission_callback' => 'is_user_logged_in'
- ]);
-
-
- register_rest_route('jvb/v1/square', '/order-status/(?P<order_id>[a-zA-Z0-9_-]+)', [
- 'methods' => 'GET',
- 'callback' => [$this, 'getOrderStatus'],
- 'permission_callback' => '__return_true' // Allow guests with order ID
- ]);
+ Route::for(Route::pattern('square/order-status/{order_id}'))
+ ->get([$this, 'getOrderStatus'])
+ ->auth('public')
+ ->rateLimit(20);
}
- public function handlePaymentProcessing($request): array
+ //TODO: Are we processing this through our server at all? Or is it in the javascript going straight to square?
+ public function handlePaymentProcessing($request):WP_REST_Response
{
$data = $request->get_json_params();
@@ -50,30 +47,28 @@
// This ensures retries use SAME key
$cart_id = $data['cart_id'] ?? '';
if (!$cart_id) {
- return ['success' => false, 'message' => 'Missing cart ID'];
+ return $this->validationError(['message'=>'Missing cart ID']);
}
// Check if we already processed this cart
$existing_order = get_transient(BASE . 'cart_order_' . $cart_id);
if ($existing_order) {
// Return cached result - prevents double charge
- return $existing_order;
+ return $this->success($existing_order);
}
// Generate idempotency key tied to this specific cart
$idempotency_key = 'cart_' . $cart_id . '_' . time();
// Store key to prevent reprocessing
+ //TODO: Should we just use our Cache.php?
set_transient(BASE . 'cart_idempotency_' . $cart_id, $idempotency_key, HOUR_IN_SECONDS);
// Validate required fields
$required = ['source_id', 'amount', 'items', 'customer'];
foreach ($required as $field) {
if (empty($data[$field])) {
- return [
- 'success' => false,
- 'message' => "Missing required field: {$field}"
- ];
+ return $this->validationError(['message' => "Missing required field: {$field}"]);
}
}
@@ -133,26 +128,23 @@
set_transient(BASE . 'cart_order_' . $cart_id, $result, HOUR_IN_SECONDS);
- return $result;
+ return $this->success($result);
} catch (Exception $e) {
$this->logError('Payment processing failed', [
'error' => $e->getMessage(),
'idempotency_key' => $data['idempotency_key']
]);
-
- return [
- 'success' => false,
- 'message' => $e->getMessage()
- ];
+ return $this->error($e->getMessage());
}
}
- public function getSavedCards($request): array
+ public function getSavedCards(WP_REST_Request $request):WP_REST_Response
{
- $user_id = get_current_user_id();
- if (!$user_id) {
- return ['success' => false, 'message' => 'Not logged in'];
+ $data = $request->get_params();
+ $user_id = absint($data['user']??0);
+ if ($user_id === 0) {
+ return $this->validationError(['message' => 'Not logged in']);
}
$square = JVB()->connect('square');
@@ -161,27 +153,25 @@
$square_customer_id = get_user_meta($user_id, BASE . '_square_customer_id', true);
if (!$square_customer_id) {
- return ['success' => true, 'cards' => []];
+ return $this->success(['cards' => []]);
}
// Fetch cards from Square (2025-compliant - separate endpoint)
$cards_response = $square->getRequest('cards?customer_id=' . $square_customer_id);
if (is_wp_error($cards_response)) {
- return ['success' => false, 'message' => 'Failed to fetch cards'];
+ return $this->error('Failed to fetch cards');
}
- return [
- 'success' => true,
- 'cards' => $cards_response['cards'] ?? []
- ];
+ return $this->success(['cards' => $cards_response['cards']??[]]);
}
- public function getOrderHistory($request): array
+ public function getOrderHistory(WP_REST_Request $request):WP_REST_Response
{
- $user_id = get_current_user_id();
- if (!$user_id) {
- return ['success' => false, 'message' => 'Not logged in'];
+ $data = $request->get_params();
+ $user_id = absint($data['user']??0);
+ if ($user_id === 0) {
+ return $this->validationError(['message' => 'Not logged in']);
}
// Get orders from custom post type
@@ -195,25 +185,17 @@
$order_data = [];
foreach ($orders as $order) {
- $meta = new \JVBase\meta\MetaManager($order->ID, 'post');
- $order_data[] = [
+ $meta = Meta::forPost($order->ID);
+ $fields = $meta->getAll(['square_order_id', 'status', 'amount', 'items', 'created_at', 'pickup_time']);
+ $order_data[] = array_merge([
'wp_order_id' => $order->ID,
- 'square_order_id' => $meta->getValue('square_order_id'),
- 'status' => $meta->getValue('status'),
- 'amount' => $meta->getValue('amount'),
- 'items' => $meta->getValue('items'),
- 'created_at' => $meta->getValue('created_at'),
- 'pickup_time' => $meta->getValue('pickup_time')
- ];
+ ], $fields);
}
- return [
- 'success' => true,
- 'orders' => $order_data
- ];
+ return $this->success(['orders' => $order_data]);
}
- public function getOrderStatus($request): array
+ public function getOrderStatus(WP_REST_Request $request):WP_REST_Response
{
$order_id = $request->get_param('order_id');
@@ -221,19 +203,12 @@
$wp_order_id = get_option(BASE . 'square_order_map_' . $order_id);
if (!$wp_order_id) {
- return ['success' => false, 'message' => 'Order not found'];
+ return $this->error('Order not found');
}
- $meta = new \JVBase\meta\MetaManager($wp_order_id, 'post');
+ $meta = Meta::forPost($wp_order_id);
+ $fields = $meta->getAll(['status', 'fulfillment_status', 'pickup_time', 'items']);
- return [
- 'success' => true,
- 'order' => [
- 'status' => $meta->getValue('status'),
- 'fulfillment_status' => $meta->getValue('fulfillment_status'),
- 'pickup_time' => $meta->getValue('pickup_time'),
- 'items' => $meta->getValue('items')
- ]
- ];
+ return $this->success(['order' => $fields]);
}
}
diff --git a/inc/rest/routes/Invitations.php b/inc/rest/routes/Invitations.php
index 6fc4a2a..7cccb3e 100644
--- a/inc/rest/routes/Invitations.php
+++ b/inc/rest/routes/Invitations.php
@@ -1,1392 +1,332 @@
<?php
namespace JVBase\rest\routes;
-use JVBase\JVB;
-use JVBase\rest\RestRouteManager;
-use Exception;
-use JVBase\utility\Features;
+use JVBase\rest\Rest;
+use JVBase\managers\CustomTable;
+use JVBase\rest\Route;
+use WP_REST_Request;
use WP_REST_Response;
-use WP_Error;
if (!defined('ABSPATH')) {
- exit; // Exit if accessed directly
+ exit;
}
-// TODO: Get this to work with the constants setup
-/***
- * WORKFLOW:
- * 1) Verified user (userA) invites user
- * a) USER EXISTS -> notify user they're already up
- * b) USER DOESN'T EXIST:
- * i) check if user exists in invitation table
- * ii) if they exist, add userA to inviters
- * - if status is expired, resent email invite and set status to 'pending'
- * iii) if they don't exist, add to table
- * iv) once invited user registers:
- * - set status to 'accepted', add new_user_id
- * - set user as verified
- * - if user was invited to a specific shop, pass user along to that shop
+
+/**
+ * Invitations Route Manager
+ *
+ * Handles user invitations:
+ * - Global invitations (user to user, based on JVB_MEMBERSHIP['can_invite'])
+ * - Term invitations (to ownable content taxonomies with 'invitable' flag)
*/
-class Invitations extends RestRouteManager
+class Invitations extends Rest
{
- protected string $tableName;
- protected array $inviteTypes;
- protected $wpdb;
- protected string $prefix;
- protected array $tableNames;
- protected int $expiryDays = 14; // Invitations expire after 14 days
+ protected array $inviteConfig;
+ protected CustomTable $table;
- public function __construct()
- {
- $this->cache_name = 'invitations';
- parent::__construct();
- global $wpdb;
- $this->inviteTypes = jvbInviteTableTypes();
- $this->tableNames = jvbInviteTables();
- $this->wpdb = $wpdb;
- $this->prefix = $wpdb->prefix;
+ public function __construct()
+ {
+ $this->cacheName = 'invitations';
+ parent::__construct();
- // Add hooks for processing accepted invitations
- add_action('user_register', [$this, 'checkInvitation'], 10, 1);
+ // Get invitation configuration
+ $this->inviteConfig = JVB()->invitations()->getInviteConfig();
+ $this->table = CustomTable::for('invitations');
-
- add_filter('jvbLoginLabels', [$this, 'modifyLoginLabels'], 10, 2);
-
-
-
- add_action('jvb_daily_maintenance', [$this, 'cleanupExpiredInvitations']);
-
- // Add filter for bulk operation handling
- add_filter(BASE . 'handle_bulk_operation', [ $this, 'processOperation' ], 10, 3);
- }
-
- /**
- * Registers the routes for invitations
- * @return void
- */
- public function registerRoutes():void
- {
- register_rest_route($this->namespace, '/invitations', [
- [
- 'methods' => 'GET',
- 'callback' => [$this, 'getInvitations'],
- 'permission_callback' => [$this, 'checkPermission']
- ],
- [
- 'methods' => 'POST',
- 'callback' => [$this, 'createInvitationRequest'],
- 'permission_callback' => [$this, 'checkPermission']
- ]
- ]);
- }
-
- protected function buildParams(object $request):array
- {
- $data = $request->get_params();
- $role = (array_key_exists('role', $data) && array_key_exists($data['role'], $this->tableNames)) ? $data['role'] : false;
- $toTerm = (array_key_exists('to_term', $data)) ? (int)$data['to_term'] : false;
- $taxonomy = (array_key_exists('taxonomy', $data) && in_array($data['taxonomy'], $this->inviteTypes[$role]['to_terms']??[])) ? $data['taxonomy'] : false;
-
- return [
- 'user' => (array_key_exists('user', $data)) ? (int)$data['user'] : false,
- 'role' => $role,
- 'to_term' => $toTerm,
- 'taxonomy' => $taxonomy,
- 'status' => array_key_exists('status', $data) && in_array($data['status'], ['all', 'pending', 'accepted', 'rejected', 'expired', 'revoked']) ? $data['status'] : 'all',
- 'page' => array_key_exists('page', $data) ? (int)$data['page'] : 1,
- ];
- }
- /**
- * @param object $request the request object
- *
- * @return WP_REST_Response
- */
- public function getInvitations(object $request): WP_REST_Response
- {
- $args = $this->buildParams($request);
- if ($args['user']) {
- if (!$this->userCheck($args['user'])) {
- return new WP_REST_Response([
- 'success' => false,
- 'message' => 'Looks like you are not who you say you are'
- ]);
- }
- if (!$this->isVerifiedUser($args['user'])) {
- return new WP_REST_Response([
- 'success' => false,
- 'message' => 'Sorry, you don\'t have permission to do this.',
- ]);
- }
- return $this->getUserInvitations($args);
- } elseif ($args['to_term']) {
- if (!$this->checkTerm($args)) {
- return new WP_REST_Response([
- 'success' => false,
- 'message' => 'Looks like this '.$args['taxonomy'].' does not exist'
- ]);
- }
- return $this->getTermInvitations($args);
- }
-
- return new WP_REST_Response([
- 'success' => false,
- 'message' => 'Invalid request'
- ]);
- }
-
- public function getTermInvitations(array $args):WP_REST_Response
- {
- if (!$this->checkTerm($args)) {
- return new WP_REST_Response([
- 'success' => false,
- 'message' => 'Invalid shop'
- ]);
- }
-
- if (!user_can($args['user'], 'manage_'.$args['taxonomy'].'_'.$args['to_term'])) {
- return new WP_REST_Response([
- 'success' => false,
- 'message' => 'You do not have permission to view invitations for this '.$args['taxonomy']
- ]);
- }
-
- $key = $this->cache->generateKey($args);
-
- $cache = $this->cache->get($key);
- if ($cache) {
- return new WP_REST_Response($cache);
- }
-
- $per_page = 20;
-
- $conditions = [];
- $params = [];
-
- //Filter by term
- $conditions[] = "to_{$args['taxonomy']} = %d";
- $params[] = $args['to_term'];
-
- if ($args['status'] !== 'all') {
- $conditions[] = "status = %s";
- $params[] = $args['status'];
- }
-
- $where = !empty($conditions) ? " WHERE " .implode(' AND ', $conditions) : "";
-
- //Count total for pagination
- $count_query = "SELECT COUNT(*) FROM {$this->tableNames[$args['role']]} {$where}";
- $total = $this->wpdb->get_var($this->wpdb->prepare($count_query, $params));
-
- //Get paginated invitations
- $offset = ($args['page'] - 1) * $per_page;
- $query = $count_query." ORDER BY created_at DESC LIMIT %d OFFSET %d";
-
- //Add pagination
- $pagination = array_merge($params, [$per_page, $offset]);
- $invitations = $this->wpdb->get_results($this->wpdb->prepare($query, $pagination));
-
- $formatted = [];
- foreach ($invitations as $invitation) {
- $formatted[] = $this->formatInvitation($invitation);
- }
-
- $return = [
- 'invitations' => $formatted,
- 'total' => (int)$total,
- 'pages' => ceil($total /$per_page),
- 'page' => $args['page'],
- 'per_page' => $per_page
- ];
-
- $this->cache->set($key, $return);
- return new WP_REST_Response($return);
- }
-
- protected function buildInvitationArgs(object $request):array
- {
- $data = $request->get_params();
-
- $user = (array_key_exists('user', $data) && $this->userCheck($data['user'])) ? (int) $data['user'] : false;
- if (!$user) {
- return [];
- }
- $role = jvbUserRole($user);
- $args = [
- 'user' => $user,
- 'role' => $role,
- 'action' => (array_key_exists('action', $data) && in_array($data['action'], ['refresh', 'revoke', 'create'])) ? $data['action'] : false,
- 'inviteID' => (array_key_exists('refresh', $data)) ? (int) $data['refresh'] : false,
- ];
-
- $allowed = $this->inviteTypes[$role];
- if (count($allowed) > 1) {
- $inviteAs = (array_key_exists('type', $data) && in_array($data['type'], $allowed)) ? $data['type'] : false;
- } else {
- $invitedAs = $allowed[0];
- }
-
- if (array_key_exists('invites', $data)) {
- $invites = [];
- foreach ($data['invites'] as $invite) {
- $temp = [
- 'invited_id' => (array_key_exists('invited_id', $invite) && $this->userCheck($invite['invited_id'])) ? $invite['invited_id'] : false,
- 'to_term' => (array_key_exists('to_term', $invite)) ? (int) $invite['to_term'] : false,
- 'taxonomy' => (array_key_exists('taxonomy', $invite) && in_array($invite['taxonomy'], $this->inviteTypes[$role]['to_terms']??[])) ? $invite['taxonomy'] : false,
- 'invited_name' => (array_key_exists('name', $invite) && is_string($invite['name'])) ? sanitize_text_field($invite['name']) : false,
- 'invited_email' => (array_key_exists('email', $invite) && is_email($invite['email'])) ? sanitize_email($invite['email']) : false,
- ];
- if ($temp['invited_id'] || ($temp['invited_email'] && $temp['invited_name'])) {
- $invites[$invitedAs][] = $data;
- }
- }
- $args['invites'] = $invites;
- }
- if (!$invitedAs && !empty($args['invites'])) {
- unset($args['invites']);
- }
-
- return $args;
- }
- /**
- * @param object $request The Request Object
- *
- * @return WP_REST_Response
- */
- public function createInvitationRequest(object $request):WP_REST_Response
- {
- $args = $this->buildInvitationArgs($request);
-
- $error = '';
- if (!$args['user']) {
- $error = 'User ID doesn\'t match up.... are you a bot?';
- } elseif (Features::forMembership()->has('member_verified') && !user_can($args['user'], 'skip_moderation')) {
- $error = 'Only verified users can send invitations.';
- } elseif (!$args['role']) {
- $error = 'It doesn\'t look like you can invite users.';
- }
- if ($error !== '') {
- return new WP_REST_Response([
- 'success' => false,
- 'message' => $error
- ]);
- }
-
- switch ($args['action']) {
- case 'revoke':
- return $this->revokeInvite($args);
- case 'refresh':
- return $this->resendInvite($args);
- }
-
- //Inviting to content taxonomy (ie: shop)
- $artist = jvbContentFromUser($args['user']);
- foreach ($args['invites'] as $index => $invite) {
- if ($invite['to_term'] && $invite['taxonomy']) {
- if (!$artist[$invite['taxonomy']] || $artist[$invite['taxonomy']['id'] !== $invite['term_id']]) {
- $args['invites'][$index]['to_term'] = false;
- $args['invites'][$index]['taxonomy'] = false;
- }
- }
- }
-
- if (!empty($args['invites']??[])) {
- JVB()->queue()->queueOperation(
- 'invitation_create',
- $args['user'],
- [
- 'invitations' => $args['invites'],
- ],
- [
- 'count' => count($args['invites']),
- 'priority' => 'high',
- 'chunk_size' => 20,
- 'chunk_key' => 'invitations'
- ]
- );
-
- return new WP_REST_Response([
- 'success' => true,
- 'message' => 'Processing ' . count($args['invites']) . ' invitations',
- ]);
- }
- return new WP_REST_Response([
- 'success' => false,
- 'message' => 'No invitations sent.'
- ]);
- }
-
- /**
- * Revoke an invitation
- *
- * @params array $args
- * @return array Response with success or error message
- */
- public function revokeInvite(array $args): array
- {
- $invitation = $this->getInvitationByUser($args);
-
- if (!$invitation || is_wp_error($invitation)) {
- return [
- 'success' => false,
- 'result' => 'Invitation not found'
- ];
- }
-
- // Check if invitation can be revoked (only pending invitations)
- if ($invitation['status'] !== 'pending' && $invitation['status'] !== 'expired') {
- return [
- 'success' => true,
- 'result' => 'Only pending or expired invitations can be revoked'
- ];
- }
-
- // Check if the user is one of the inviters
- $inviters = json_decode($invitation['inviters'], true);
- $user_is_inviter = false;
- $updated_inviters = [];
-
- foreach ($inviters as $inviter) {
- if (intval($inviter['user_id']) === $args['user']) {
- $user_is_inviter = true;
- } else {
- // Keep other inviters
- $updated_inviters[] = $inviter;
- }
- }
-
- if (!$user_is_inviter) {
- return [
- 'success' => false,
- 'return' => 'You are not authorized to revoke this invitation'
- ];
- }
-
- // If there are still other inviters, just update the inviters list
- if (!empty($updated_inviters)) {
- $this->wpdb->update(
- $this->tableNames[$args['role']],
- [
- 'inviters' => json_encode($updated_inviters),
- 'updated_at' => current_time('mysql')
- ],
- ['id' => $invitation['id']]
- );
-
- return [
- 'success' => true,
- 'result' => 'You have been removed from the inviters list but the invitation is still active with other inviters',
- ];
- }
-
- // If no inviters left, mark the invitation as revoked
- $this->wpdb->update(
- $this->tableNames[$args['role']],
- [
- 'status' => 'revoked',
- 'updated_at' => current_time('mysql')
- ],
- ['id' => $invitation['id'] ]
- );
-
- $this->sendRevocationEmail($invitation['email'], $invitation['name']);
-
- return [
- 'success' => true,
- 'result' => 'Invitation has been successfully revoked'
- ];
- }
-
- /**
- * Resend an expired invitation
- *
- * @param array $args Args, as defined in buildInvitationArgs())
- * @return WP_REST_Response Response with success or error message
- */
- public function resendInvite(array $args): WP_REST_Response
- {
- $invitation_id = isset($args['inviteID']) ? intval($args['inviteID']) : 0;
- $user_id = isset($args['user']) ? intval($args['user']) : 0;
-
- if (!$invitation_id || !$user_id) {
- return new WP_REST_Response([
- 'success' => false,
- 'message' => 'Missing invitation ID or user ID'
- ]);
- }
-
- // Get the invitation
- $invitation = $this->wpdb->get_row($this->wpdb->prepare(
- "SELECT * FROM {$this->tableNames[$args['role']]} WHERE id = %d",
- $invitation_id
- ), ARRAY_A);
-
- if (!$invitation) {
- return new WP_REST_Response([
- 'success' => false,
- 'message' => 'Invitation not found'
- ]);
- }
-
- // Check if the invitation is expired or pending
- if (!in_array($invitation['status'], ['expired', 'pending'])) {
- return new WP_REST_Response([
- 'success' => false,
- 'message' => 'Only expired or pending invitations can be resent'
- ]);
- }
-
- // Check if the user is one of the inviters
- $inviters = json_decode($invitation['inviters'], true);
- $user_is_inviter = false;
-
- foreach ($inviters as &$inviter) {
- if (intval($inviter['user_id']) === $user_id) {
- $user_is_inviter = true;
- // Update the invited_at timestamp for this inviter
- $inviter['invited_at'] = current_time('mysql');
- break;
- }
- }
-
- if (!$user_is_inviter) {
- return new WP_REST_Response([
- 'success' => false,
- 'message' => 'You are not authorized to resend this invitation'
- ]);
- }
-
- // Generate a new token
- $token = wp_generate_password(32, false);
-
- // Set new expiration date
- $expires_at = date('Y-m-d H:i:s', strtotime("+{$this->expiryDays} days"));
-
- // Update the invitation
- $this->wpdb->update(
- $this->tableNames[$args['role']],
- [
- 'invitation_token' => $token,
- 'status' => 'pending',
- 'expires_at' => $expires_at,
- 'inviters' => json_encode($inviters),
- 'updated_at' => current_time('mysql')
- ],
- ['id' => $invitation_id]
- );
-
- // Send the invitation email again
- $name = $invitation['name'];
- $email = $invitation['email'];
- $role = $invitation['role'];
- $terms = $this->getInvitationTerms($invitation, $role);
-
-
- $result = $this->sendInvitationEmail($name, $email, $token, $user_id, $terms, $role);
-
- if (!$result) {
- return new WP_REST_Response([
- 'success' => false,
- 'message' => 'Failed to send invitation email'
- ]);
- }
-
- return new WP_REST_Response([
- 'success' => true,
- 'message' => 'Invitation has been successfully resent',
- 'expires_at' => $expires_at
- ]);
- }
-
- protected function getInvitationTerms(object|array $invitation, string $role) {
- if (is_object($invitation)) {
- $invitation = json_decode(json_encode($invitation), true);
- }
- $terms = [];
- foreach ($this->inviteTypes[$role]['to_terms'] as $taxonomy) {
- $terms[$taxonomy] = $invitation['to_'.$taxonomy];
- }
- return $terms;
+ // Cache connections
+ $this->cache
+ ->connect('user')
+ ->connect('taxonomy');
}
- /**
- * Create or update an invitation
- * @param string $name Name of person being invited
- * @param string $email Email of person being invited
- * @param int $inviter_id User ID of the person inviting
- * @param string|false $role
- * @param int|false $termID Optional shop ID
- * @param string|false $taxonomy Optional taxonomy
- * @param bool $send_email whether to send email right away
- * @return WP_Error|array
- *
- */
- public function createInvitation(
- string $name,
- string $email,
- int $inviter_id,
- string|false $role = false,
- int|false $termID = false,
- string|false $taxonomy = false,
- bool $send_email = true
- ):WP_Error|array {
- error_log('Creating Invitation with data: '.print_r([
- 'name' => $name,
- 'email' => $email,
- 'inviter ID'=> $inviter_id,
- 'termID' => $termID,
- 'taxonomy' => $taxonomy,
- 'role' => $role
- ], true));
- // Sanitize and validate email
- $email = sanitize_email($email);
- if (!is_email($email)) {
- error_log('Invalid email');
- return new WP_Error('invalid_email', 'Invalid email address');
- }
+ public function registerRoutes(): void
+ {
+ Route::for('invitations')
+ ->get([$this, 'getInvitations'])
+ ->args([
+ 'user' => 'int|required',
+ 'to_term' => 'int',
+ 'taxonomy' => 'string',
+ 'status' => 'string|enum:all,pending,accepted,rejected,expired,revoked|default:all',
+ 'page' => 'int|default:1|min:1'
+ ])
+ ->auth('user')
+ ->rateLimit(20)
- // Check if inviter is verified
- if (Features::forMembership()->has('member_verified') && !$this->isVerifiedUser($inviter_id)) {
- error_log('Unverified Artist');
- return new WP_Error('unauthorized', 'Only verified artists can send invitations');
- }
+ ->post([$this, 'createInvitationRequest'])
+ ->args([
+ 'user' => 'int|required',
+ 'action' => 'string|enum:create,revoke,refresh|default:create',
+ 'invites' => 'array',
+ 'invitation_id' => 'int'
+ ])
+ ->auth('verified')
+ ->rateLimit(10, 300);
+ }
- if ($termID) {
- // Check if shop exists if specified
- if ($this->checkTerm(['term_id' => $termID, 'taxonomy' => $taxonomy])) {
- error_log('Invalid Taxonomy');
- return new WP_Error('invalid_term', 'The specified term does not exist');
- }
- }
+ /**
+ * Get invitations for a user or term
+ */
+ public function getInvitations(WP_REST_Request $request): WP_REST_Response
+ {
+ $userID = $request->get_param('user');
+ $termID = $request->get_param('to_term');
+ $taxonomy = $request->get_param('taxonomy');
- if (!$role || !array_key_exists($role, $this->inviteTypes)) {
- return new WP_Error('invalid_role', 'No role was set');
- }
-
- // Check if user already exists
- $invite = !email_exists($email);
-
- // Get existing invitation if any
- $existing = $this->wpdb->get_row($this->wpdb->prepare(
- "SELECT * FROM {$this->tableNames[$role]} WHERE email = %s",
- $email
- ), ARRAY_A);
-
- // Generate token
- $token = wp_generate_password(32, false);
-
- // Set expiration date
- $expires_at = date('Y-m-d H:i:s', strtotime("+{$this->expiryDays} days"));
-
- if ($existing) {
- // Update existing invitation
- $inviters = json_decode($existing['inviters'], true);
-
- // Check if this inviter already invited
- $inviter_exists = false;
- foreach ($inviters as $inviter) {
- if ($inviter['user_id'] == $inviter_id) {
- $inviter_exists = true;
- // Update the invited_at timestamp
- $inviter['invited_at'] = current_time('mysql');
- break;
- }
- }
-
- if (!$inviter_exists) {
- // Add new inviter
- $inviters[] = [
- 'user_id' => $inviter_id,
- 'invited_at' => current_time('mysql')
- ];
- }
-
- // Prepare update data
- $update_data = [
- 'inviters' => json_encode($inviters),
- 'status' => 'pending',
- 'expires_at' => $expires_at,
- 'updated_at' => current_time('mysql'),
- ];
- // Set shop_id if provided and not already set
- $check = 'to_'.$taxonomy;
- if ($termID && $existing[$check] !== $termID) {
- $update_data[$check] = $termID;
- }
-
- // If invitation was expired, generate new token
- if ($existing['status'] === 'expired') {
- $update_data['invitation_token'] = $token;
- } else {
- $token = $existing['invitation_token'];
- }
-
- $this->wpdb->update(
- $this->tableNames[$role],
- $update_data,
- ['id' => $existing['id']]
- );
-
- $invitation_id = $existing['id'];
- } else {
- // Create new invitation
- $inviters = [[
- 'user_id' => $inviter_id,
- 'invited_at' => current_time('mysql')
- ]];
-
- $insert_data = [
- 'name' => sanitize_text_field($name),
- 'email' => $email,
- 'invitation_token' => $token,
- 'status' => 'pending',
- 'inviters' => json_encode($inviters),
- 'expires_at' => $expires_at,
- 'created_at' => current_time('mysql')
- ];
- // Add shop if provided
- if ($termID) {
- $insert_data['to_'.$taxonomy] = $termID;
- }
-
- $this->wpdb->insert(
- $this->tableNames[$role],
- $insert_data
- );
-
- $invitation_id = $this->wpdb->insert_id;
- }
-
- error_log('On to invitation email send:');
- // Send invitation email
- if ($invite && $send_email) {
- $this->sendInvitationEmail($name, $email, $token, $inviter_id, [$taxonomy => $termID], $role);
- }
-
- return [
- 'id' => $invitation_id,
- 'email' => $email,
- 'token' => $token,
- 'expires_at' => $expires_at
- ];
- }
-
- /**
- * Validate an invitation token
- * @param string $token the generated token
- * @param string $email the email of the invited person
- * @param string $role the role
- * @return object $invitation or error
- */
- public function validateInvitation(string $token, string $email, string $role):object
- {
- if (!array_key_exists($role, $this->inviteTypes)) {
- return new WP_Error('invalid_role', 'Invalid role type');
+ // Validate user
+ if (get_current_user_id() !== $userID) {
+ return $this->unauthorized('Invalid user');
}
- $table = $this->wpdb->prefix . $this->tableNames[$role];
- // Get invitation by token and email
- $invitation = $this->wpdb->get_row($this->wpdb->prepare(
- "SELECT * FROM $table
- WHERE invitation_token = %s
- AND email = %s
- AND status = 'pending'",
- $token,
- $email
- ));
+ $args = [
+ 'user' => $userID,
+ 'to_term' => $termID,
+ 'taxonomy' => $taxonomy ? jvbNoBase($taxonomy) : null,
+ 'status' => $request->get_param('status'),
+ 'page' => $request->get_param('page')
+ ];
- if (!$invitation) {
- return new WP_Error('invalid_invitation', 'Invalid invitation token or email');
- }
+ // Check cache
+ $key = $this->cache->generateKey($args);
+ if ($cached = $this->cache->get($key)) {
+ return $this->success($cached);
+ }
- // Check if expired
- if (strtotime($invitation->expires_at) < time()) {
- return new WP_Error('expired_invitation', 'This invitation has expired');
- }
+ // Get appropriate invitations
+ $result = ($args['to_term'] && $args['taxonomy'])
+ ? $this->getTermInvitations($args)
+ : $this->getUserInvitations($args);
- return $invitation;
- }
+ // Cache result
+ $this->cache->set($key, $result);
- /**
- * Send invitation email to the new artist
- * @param string $name The invited person's name
- * @param string $email The invited person's email
- * @param string $token The randomly generated password
- * @param int $inviter_id The User ID of the one inviting
- * @param int|null $shopID The optional shop ID to be invited to
- * @return bool Whether or not the invitation was sent successfully
- */
- protected function sendInvitationEmail(string $name, string $email, string $token, int $inviter_id, array $terms, string|null $role = null):bool
- {
- $inviter = get_userdata($inviter_id);
- $inviter_name = jvbGetUsername($inviter_id);
- $inviter_name = $inviter_name ?: $inviter->display_name;
+ return $this->success($result);
+ }
- $siteName = get_bloginfo('name');
+ /**
+ * Get invitations for a specific term
+ */
+ protected function getTermInvitations(array $args): array
+ {
+ // Check permission
+ if (!JVB()->roles()->isManager($args['user'], $args['to_term'])) {
+ return $this->forbidden('You cannot view invitations for this ' . $args['taxonomy'])->get_data();
+ }
- $subject = apply_filters('jvbInvitationSubject',
- sprintf(
- "%s invited you to join %s!",
- $inviter_name,
- $siteName
- ),
- $inviter_name
+ $perPage = 20;
+ $offset = ($args['page'] - 1) * $perPage;
+
+ // Build query
+ $where = ['to_' . $args['taxonomy'] => $args['to_term']];
+ if ($args['status'] !== 'all') {
+ $where['status'] = $args['status'];
+ }
+
+ // Fluent CustomTable usage
+ $total = $this->table
+ ->where($where)
+ ->countResults();
+
+ $invitations = $this->table
+ ->where($where)
+ ->orderBy('created_at')
+ ->limit($perPage, $offset)
+ ->getResults();
+
+ return [
+ 'invitations' => array_map([$this, 'formatInvitation'], $invitations),
+ 'total' => $total,
+ 'pages' => ceil($total / $perPage),
+ 'page' => $args['page'],
+ 'per_page' => $perPage
+ ];
+ }
+
+ /**
+ * Get invitations sent by a user
+ */
+ protected function getUserInvitations(array $args): array
+ {
+ $perPage = 20;
+ $offset = ($args['page'] - 1) * $perPage;
+
+ // Use raw query for JSON search
+ $query = "SELECT * FROM {$this->table->getFullTableName()}
+ WHERE JSON_SEARCH(inviters, 'one', %s, NULL, '$[*].user_id') IS NOT NULL";
+ $params = [$args['user']];
+
+ if ($args['status'] !== 'all') {
+ $query .= " AND status = %s";
+ $params[] = $args['status'];
+ }
+
+ $query .= " ORDER BY created_at DESC LIMIT %d OFFSET %d";
+ $params = array_merge($params, [$perPage, $offset]);
+
+ $invitations = $this->table->queryResults($query, $params);
+
+ // Get count
+ $countQuery = str_replace('SELECT *', 'SELECT COUNT(*)',
+ substr($query, 0, strrpos($query, 'ORDER BY')));
+ $countParams = array_slice($params, 0, -2);
+ $total = (int) $this->table->queryVar($countQuery, $countParams);
+
+ return [
+ 'invitations' => array_map([$this, 'formatInvitation'], $invitations),
+ 'total' => $total,
+ 'pages' => ceil($total / $perPage),
+ 'page' => $args['page'],
+ 'per_page' => $perPage
+ ];
+ }
+
+ /**
+ * Create invitation request - queues invitations for processing
+ */
+ public function createInvitationRequest(WP_REST_Request $request): WP_REST_Response
+ {
+ $userID = $request->get_param('user');
+ $action = $request->get_param('action');
+
+ // Validate user
+ if (get_current_user_id() !== $userID) {
+ return $this->unauthorized('Invalid user');
+ }
+
+ // Handle actions
+ return match($action) {
+ 'revoke' => $this->revokeInvite($userID, $request->get_params()),
+ 'refresh' => $this->resendInvite($userID, $request->get_params()),
+ default => $this->queueInvitations($userID, $request->get_param('invites'))
+ };
+ }
+
+ protected function queueInvitations(int $userID, array $invites): WP_REST_Response
+ {
+ if (empty($invites)) {
+ return $this->error('No invitations provided');
+ }
+
+ // Validate invitations
+ $validated = $this->validateInvitations($userID, $invites);
+
+ if (empty($validated)) {
+ return $this->error('No valid invitations to send');
+ }
+
+ // Queue using fluent interface
+ $op = JVB()->queue()->add(
+ 'invitation_create',
+ $userID,
+ ['invitations' => $validated],
+ [
+ 'priority' => 'high',
+ 'chunk_key' => 'invitations',
+ 'chunk_size' => 20
+ ]
+ );
+ return $this->queued($op['operation_id']);
+ }
+
+ /**
+ * Validate and sanitize invitation data
+ */
+ protected function validateInvitations(int $userID, array $invites): array
+ {
+ return JVB()->invitations()->validateInvitations($userID, $invites);
+ }
+
+
+
+
+ /**
+ * Revoke an invitation
+ */
+ protected function revokeInvite(int $userID, array $data): WP_REST_Response
+ {
+ $invitationID = (int) ($data['invitation_id'] ?? 0);
+
+ if (!$invitationID) {
+ return $this->error('Invitation ID required');
+ }
+
+ $op = JVB()->queue()->add(
+ 'invitation_revoke',
+ $userID,
+ ['invitation_id' => $invitationID],
+ ['priority' => 'high']
);
- $signup_url = add_query_arg([
- 'invite' => $token,
- 'email' => urlencode($email),
- 'name' => $name,
- 'role' => $role
- ], wp_registration_url());
+ return $this->queued($op['operation_id']);
+ }
+
+ /**
+ * Resend an invitation
+ */
+ protected function resendInvite(int $userID, array $data): WP_REST_Response
+ {
+ $invitationID = (int) ($data['invitation_id'] ?? 0);
+
+ if (!$invitationID) {
+ return $this->error('Invitation ID required');
+ }
+
+ $op = JVB()->queue()->add(
+ 'invitation_resend',
+ $userID,
+ ['invitation_id' => $invitationID],
+ ['priority' => 'high']
+ );
+
+ if (is_wp_error($op)) {
+ return $this->error($op->get_error_message());
+ }
+ return $this->queued($op['operation_id']);
+ }
- // Get shop name if applicable
- $toContentTax = [];
- if (!empty ($terms)) {
- foreach ($terms as $taxonomy => $termID) {
+
+ /**
+ * Format invitation for API response
+ */
+ protected function formatInvitation(object $invitation): array
+ {
+ $inviters = json_decode($invitation->inviters ?? '[]', true) ?: [];
+
+ $formatted = [
+ 'id' => (int) $invitation->id,
+ 'name' => $invitation->name,
+ 'email' => $invitation->email,
+ 'invited_role' => $invitation->invited_role,
+ 'status' => $invitation->status,
+ 'expires_at' => $invitation->expires_at,
+ 'accepted_at' => $invitation->accepted_at ?? null,
+ 'created_at' => $invitation->created_at,
+ 'inviters' => array_map(fn($inviter) => [
+ 'id' => (int) $inviter['user_id'],
+ 'name' => jvbGetUsername($inviter['user_id']),
+ 'invited_at' => $inviter['invited_at']
+ ], $inviters)
+ ];
+
+ // Add term information if present
+ foreach (JVB()->roles()->getInvitableTaxonomies() as $taxonomy) {
+ $column = 'to_' . $taxonomy;
+ if (isset($invitation->$column) && $invitation->$column) {
+ $termID = (int) $invitation->$column;
$term = get_term($termID, BASE . $taxonomy);
+
if ($term && !is_wp_error($term)) {
- $toContentTax[] = sprintf(
- "<p>%s has also invited you to join %s. You'll be automatically added to this %s when you register.</p>",
- $inviter_name,
- html_entity_decode($term->name),
- $taxonomy
- );
+ $formatted['term'] = [
+ 'id' => $termID,
+ 'name' => $term->name,
+ 'taxonomy' => $taxonomy
+ ];
+ break; // Only show first term
}
}
}
- $toContentTax = implode(' ', $toContentTax);
- $button = JVB()->email()->button($signup_url, 'Join the Scene!');
- $link = JVB()->email()->link($signup_url);
- $signature = JVB()->email()->signature();
-
- $message = sprintf(
- '<p>Hi %s!</p>
- <p>%s has invited you to join them on %s.</p>
-
- <h2>Interested?</h2>
- <p>Join in by clicking the button below:</p>
- %s
- <p>Or by copying and pasting the link below into your browser:</p>
- %s
- <div class="divider"></div>
- %s
- <p>This invitation expires in %d days.</p>
- <p>Ink on, %s</p>
- %s
- ',
- $name,
- $inviter_name,
- $siteName,
- $button,
- $link,
- $name,
- $toContentTax,
- $this->expiryDays,
- $signature
- );
- $message = apply_filters('jvbInvitationMessage',
- $message,
- $name,
- $inviter_name,
- $role,
- $termID,
- $taxonomy,
- $toContentTax,
- $this->expiryDays,
- $button,
- $link,
- $signature,
- );
-
-
- $success = JVB()->email()->sendEmail($email, $subject, $message);
-
-
- if (!$success) {
- // Log the invitation
- JVB()->error()->log(
- 'invitation_email',
- 'Invitation not sent',
- [
- 'email' => $email,
- 'inviter_id' => $inviter_id,
- 'token' => $token
- ],
- 'info'
- );
- }
-
- return $success;
- }
-
- /**
- * Send revocation email notification
- * @param string $email the invited person's email
- * @param string $name the invited person's name
- * @return bool Whether or not the email was sent
- */
- protected function sendRevocationEmail(string $email, string $name):bool
- {
- $siteName = get_bloginfo('name');
- $subject = apply_filters(
- 'jvbInvitationRevokedSubject',
- sprintf(
- '[%s] Your invitation has been revoked',
- $siteName
- )
- );
- $content = apply_filters(
- 'jvbInvitationRevokedMessage',
- sprintf(
- '<p>Hey %s,</p>
- <p>This is to let you know that your invitation to join %s has been revoked.</p>
- <p>If you believe this was done in error, please contact the person who invited you, the site admin, or try registering yourself.</p>',
- $name,
- $siteName
- ),
- $name
- );
-
- $success = JVB()->email()->sendEmail($email, $subject, $content, 'INVITATION REVOKED');
- if (!$success) {
- JVB()->error()->log(
- 'invitation_revoke_email',
- 'Invitation not sent',
- [
- 'email' => $email,
- 'name' => $name,
- ],
- 'info'
- );
- }
- return $success;
- }
-
- /**
- * Verify an invitation token
- * @param string $token The randomly generated token
- * @param string $email The invited person's email
- * @return bool|object False on failure. Invitation object if success
- */
- public function verifyInvitation(string $token, string $email, string $role):bool|object
- {
- $invitation = $this->wpdb->get_row($this->wpdb->prepare(
- "SELECT * FROM {$this->tableNames[$role]}
- WHERE invitation_token = %s AND email = %s AND status = 'pending' AND expires_at > NOW()",
- $token,
- $email
- ));
-
- if (!$invitation) {
- return false;
- }
-
- return $invitation;
- }
-
- /**
- * Mark an invitation as accepted
- * @param string $token The randomly generated token
- * @param string $email The invited person's email
- * @param int $user_id The invited person's user ID
- * @return bool whether or not it was successfully accepted
- */
- public function acceptInvitation(string $token, string $email, int $user_id):bool
- {
- $role = jvbUserRole($user_id);
- $invitation = $this->verifyInvitation($token, $email, $role);
-
- if (!$invitation) {
- return false;
- }
-
- // Update invitation status
- $this->wpdb->update(
- $this->tableNames[$role],
- [
- 'status' => 'accepted',
- 'new_user_id' => $user_id,
- 'accepted_at' => current_time('mysql'),
- 'updated_at' => current_time('mysql')
- ],
- ['id' => $invitation->id]
- );
-
- // Set user role to artist with can_publish=false (needs verification)
- $user = get_userdata($user_id);
- // Set the user's verification status
- $user->add_cap('skip_moderation', true);
-
- // If there's a shop to add the artist to, do that now
- if (!empty($invitation->to_shop)) {
- JVB()->routes('shopInvite')->addArtistToShop($user_id, $invitation->to_shop);
- }
-
- // Notify inviters
- $this->notifyInvitersOfAcceptance($invitation, $user_id);
-
- return true;
- }
-
- /**
- * Notify all inviters that the invitation was accepted
- * @param object $invitation The invitation object
- * @param int $user_id the newly added user id
- * @return void
- */
- protected function notifyInvitersOfAcceptance(object $invitation, int $user_id):void
- {
- $inviters = json_decode($invitation->inviters, true);
- $user_data = get_userdata($user_id);
-
- foreach ($inviters as $inviter) {
- JVB()->notification()->addNotification(
- $inviter['user_id'],
- 'artist_joined',
- [
- 'invited_email' => $invitation->email,
- 'user_id' => $user_id,
- 'display_name' => $user_data->display_name
- ]
- );
- }
- }
-
- /**
- * Check if a registered user has a pending invitation. Accept invitation if so
- * @param int $user_id The user ID to check
- * @return void
- */
- public function checkInvitation(int $user_id):void
- {
- $user = get_userdata($user_id);
-
- if (!$user) {
- return;
- }
-
- // Check if there's a token and email in the request
- $token = isset($_GET['invite']) ? sanitize_text_field($_GET['invite']) : '';
- $email = isset($_GET['email']) ? sanitize_email($_GET['email']) : '';
-
- if ($token && $email && $email === $user->user_email) {
- $this->acceptInvitation($token, $email, $user_id);
- }
- }
-
- /**
- * Clean up expired invitations
- * @return void
- */
- public function cleanupExpiredInvitations():void
- {
- global $wpdb;
-
- $wpdb->query($wpdb->prepare(
- "UPDATE {$this->tableName}
- SET status = 'expired', updated_at = %s
- WHERE status = 'pending' AND expires_at < NOW()",
- current_time('mysql')
- ));
- }
-
- /**
- * Get invitations sent by a specific user
- * @param array $args built by buildParams()
- * @return WP_REST_Response
- */
- public function getUserInvitations(array $args):WP_REST_Response
- {
- if (!$this->checkUser($args['user'])) {
- return new WP_REST_Response([
- 'success' => false,
- 'message' => 'Invalid user'
- ]);
- }
-
- $key = $this->cache->generateKey($args);
- $cache = $this->cache->get($key);
- if ($cache) {
- return new WP_REST_Response($cache);
- }
-
- $per_page = 20;
-
- // Build query conditions
- $conditions = [];
- $params = [];
-
- $conditions[] = "inviters LIKE %s";
- $params[] = '%"'.$args['user'].'"%';
-
- // Filter by status
- if ($args['status'] !== 'all') {
- $conditions[] = "status = %s";
- $params[] = $args['status'];
- }
-
- $where = !empty($conditions) ? "WHERE " . implode(' AND ', $conditions) : "";
-
- // Count total invitations for pagination
- $count_query = "SELECT COUNT(*) FROM {$this->tableNames[$args['role']]} {$where}";
- $total = $this->wpdb->get_var($this->wpdb->prepare($count_query, $params));
-
- // Get paginated invitations
- $offset = ($args['page'] - 1) * $per_page;
- $query = "SELECT * FROM {$this->tableNames[$args['role']]} {$where} ORDER BY created_at DESC LIMIT %d OFFSET %d";
-
- // Add pagination parameters
- $pagination_params = array_merge($params, [$per_page, $offset]);
- $invitations = $this->wpdb->get_results($this->wpdb->prepare($query, $pagination_params));
-
- // Format invitations for response
- $formatted = [];
- foreach ($invitations as $invitation) {
- $formatted[] = $this->formatInvitation($invitation);
- }
-
- $return = [
- 'invitations' => $formatted,
- 'total' => (int)$total,
- 'pages' => ceil($total / $per_page),
- 'page' => $args['page'],
- 'per_page' => $per_page
- ];
-
- $this->cache->set($key, $return);
-
- return new WP_REST_Response($return);
- }
-
- /**
- * Get a specific invitation by its ID
- *
- * @param int $invitationID The invitation ID to fetch
- * @param string $role
- * @return array|WP_Error The formatted invitation or an error
- */
- protected function getInvitation(int $invitationID, string $role):array|WP_Error
- {
- // Validate invitation ID
- $invitationID = intval($invitationID);
- if (!$invitationID) {
- return new WP_Error('invalid_id', 'Invalid invitation ID');
- }
-
- // Try to get from cache first
- $cached = $this->cache->get($invitationID);
- if ($cached) {
- return $cached;
- }
-
- // Query the database
- $invitation = $this->wpdb->get_row($this->wpdb->prepare(
- "SELECT * FROM {$this->tableNames[$role]} WHERE id = %d",
- $invitationID
- ));
-
- // Return error if not found
- if (!$invitation) {
- return new WP_Error('not_found', 'Invitation not found');
- }
-
- // Format the invitation for response
- $formatted = $this->formatInvitation($invitation);
-
- // Cache the result
- $this->cache->set($invitationID, $formatted);
-
- return $formatted;
- }
-
- /**
- * Get invitations for a specific user by their email or user ID
- *
- * @param int|string $identifier Either user ID or email of the invited person
- * @param bool $include_token Whether to include the token in the response
- * @return array|WP_Error The formatted invitations or an error
- */
- public function getInvitationByUser(int|string $identifier):array|WP_Error
- {
- // Try to get from cache first
- $cached = $this->cache->get($identifier);
- if ($cached) {
- return $cached;
- }
- global $wpdb;
-
- // Determine if we have a user ID or email
- if (is_numeric($identifier)) {
- // We have a user ID
- $userID = intval($identifier);
-
- // Query by user ID
- $invitation = $wpdb->get_row($wpdb->prepare(
- "SELECT * FROM {$this->tableName} WHERE new_user_id = %d",
- $userID
- ));
- } else {
- // We have an email
- $email = sanitize_email($identifier);
- if (!is_email($email)) {
- return new WP_Error('invalid_email', 'Invalid email address');
- }
-
- // Query by email
- $invitation = $wpdb->get_row($wpdb->prepare(
- "SELECT * FROM {$this->tableName} WHERE email = %s",
- $email
- ));
- }
-
- // Return error if not found
- if (!$invitation) {
- return new WP_Error('not_found', 'No invitations found for this user');
- }
-
- // Format the invitation for response
- $formattedInvitation = $this->formatInvitation($invitation);
-
- $this->cache->set($identifier, $formattedInvitation);
-
- return $formattedInvitation;
- }
-
- /**
- * Format invitation for API response
- * @param object $invitation The invitation object
- * @param bool $include_token whether or not to include the token in response
- * @return array The formatted invitation
- */
- protected function formatInvitation(object $invitation, bool $include_token = false):array
- {
- // Parse inviters JSON
- $inviters = json_decode($invitation->inviters ?? '[]', true) ?: [];
-
- // Format inviters with names
- $inviter_details = [];
- foreach ($inviters as $inviter_id) {
- $inviter_details[] = [
- 'id' => $inviter_id,
- 'name' => jvbGetUsername($inviter_id)
- ];
- }
-
- // Build formatted invitation
- $formatted = [
- 'id' => $invitation->id,
- 'name' => $invitation->name,
- 'email' => $invitation->email,
- 'status' => $invitation->status,
- 'expires_at' => $invitation->expires_at,
- 'accepted_at' => $invitation->accepted_at,
- 'created_at' => $invitation->created_at,
- 'updated_at' => $invitation->updated_at,
- 'inviters' => $inviters
- ];
-
- // Include shop if assigned
- if (!empty($invitation->to_shop)) {
- $shop = get_term($invitation->to_shop, BASE . 'shop');
- if ($shop && !is_wp_error($shop)) {
- $formatted['shop'] = [
- 'id' => $shop->term_id,
- 'name' => $shop->name
- ];
- }
- }
-
- // Include token if needed (only for validation)
- if ($include_token) {
- $formatted['token'] = $invitation->invitation_token;
- }
-
- // Add registration URL for convenience
- $formatted['registration_url'] = add_query_arg([
- 'token' => $invitation->invitation_token,
- 'email' => urlencode($invitation->email)
- ], home_url('/register/'));
-
- return $formatted;
- }
-
- /**
- * @param WP_Error|array $result The WP_Error to replace, if this is the operation type we're looking for
- * @param object $operation The operation object
- * @param array $data The data to process
- * @return WP_Error|array WP_Error or array of processed data
- *
- */
- public function processOperation(WP_Error|array $result, object $operation, array $data):array|WP_Error
- {
- switch ($operation->type) {
- case 'invitation_create':
- return $this->processInvitations($data, $operation->user_id);
- case 'invitation_revoke':
- return $this->revokeInvite(
- $data['invited']
- );
- }
- return $result;
- }
-
- /**
- * Process a batch of invitations with transaction support
- *
- * @param array $data Array of invitation data ['role' => $invites ]
- * @param int $user_id User ID of the inviter
- * @return array Result data with success/failure information
- */
- public function processInvitations(array $data, int $user_id):array
- {
- if (!$this->checkUser($user_id)) {
- return [
- 'success' => false,
- 'result' => 'Invalid User',
- ];
- }
-
- // Start transaction
- $this->wpdb->query('START TRANSACTION');
-
- $results = [
- 'success' => [],
- 'failed' => []
- ];
-
- try {
- foreach ($data as $role => $invitations) {
- foreach ($invitations as $invite) {
- if (!$invite['invited_name'] || !$invite['invited_email']) {
- $results['failed'][] = [
- 'email' => $invite['invited_email'],
- 'name' => $invite['invited_name'],
- 'reason' => 'Invalid name or email'
- ];
- continue;
- }
-
- if ($invite['to_term'] && !$this->checkTerm($invite)) {
- $results['failed'][] = [
- 'email' => $invite['invited_email'],
- 'name' => $invite['invited_name'],
- 'reason' => 'Invalid taxonomy to add to'
- ];
- }
-
- // Create invitation (modify your existing method to avoid sending emails yet)
- $result = $this->createInvitation($invite['invited_name'], $invite['invited_email'], $user_id, $role, $invite['to_term'], $invite['taxonomy'], false);
-
- if (is_wp_error($result)) {
- $results['failed'][] = [
- 'email' => $invite['invited_email'],
- 'name' => $invite['invited_name'],
- 'reason' => $result->get_error_message()
- ];
- } else {
- $results['success'][] = [
- 'email' => $invite['invited_email'],
- 'name' => $invite['invited_name'],
- 'id' => $result['id'],
- 'to_term' => $invite['to_term'],
- 'taxonomy' => $invite['taxonomy'],
- 'role' => $role,
- 'expires_at' => $result['expires_at']
- ];
- }
- }
- }
-
- // If we've processed at least one invitation successfully, commit
- if (!empty($results['success'])) {
- $this->wpdb->query('COMMIT');
-
- // Now send emails for successful invitations
- foreach ($results['success'] as $invitation) {
- $this->sendInvitationEmail(
- $invitation['name'],
- $invitation['email'],
- $invitation['token'],
- $user_id,
- [$invitation['taxonomy'] => $invitation['to_term']],
- $invitation['role']
- );
- }
- } else {
- // No successful invitations, roll back
- $this->wpdb->query('ROLLBACK');
- }
-
- return [
- 'success' => count($results['success']) > count($results['failed']),
- 'results' => $results
- ];
-
- } catch (Exception $e) {
- // Handle error and roll back transaction
- $this->wpdb->query('ROLLBACK');
-
- JVB()->error()->log(
- 'invitation_create',
- 'Error processing batch invitations: ' . $e->getMessage(),
- [
- 'user_id' => $user_id,
- 'error' => $e->getMessage()
- ],
- 'error'
- );
-
- return [
- 'success' => false,
- 'result' => [
- 'failed' => $invitations,
- 'error' => $e->getMessage()
- ]
- ];
- }
- }
-
- public function modifyLoginLabels(array $labels, array $get_params): array
- {
- // Only modify if invitation params present
- if (!array_key_exists('invite', $get_params) || !array_key_exists('email', $get_params)) {
- return $labels;
- }
- $email = sanitize_email($get_params['email']);
- $token = sanitize_text_field($get_params['invite']);
- $user = email_exists($email);
- if (!$user) {
- return $labels;
- }
- $role = jvbUserRole($user);
- // Get invitation data
- $data = $this->verifyInvitation(
- $token,
- $email,
- $role,
- );
-
- if (!$data) {
- return $labels;
- }
-
- // Build custom message
- $inviters = json_decode($data->inviters, true);
- $name = $data->name;
- $names = array_map(function($inviter) {
- $artist = jvbContentFromUser((int)$inviter['user_id']);
- return $artist['name'] ?: $artist['display_name'];
- }, $inviters);
-
- $message = count($names) > 1
- ? 'are already here, and have invited you to join in!'
- : ' is already here, and invited you to join in!';
-
- // Modify labels
- $labels['title'] = 'Join the Scene, ' . $data->name;
- $labels['description'] = [jvbCommaList($names) . ' ' . $message];
-
- return $labels;
+ return $formatted;
}
+
}
diff --git a/inc/rest/routes/LoginRoutes.php b/inc/rest/routes/LoginRoutes.php
index e775960..aa31fa0 100644
--- a/inc/rest/routes/LoginRoutes.php
+++ b/inc/rest/routes/LoginRoutes.php
@@ -1,210 +1,507 @@
<?php
namespace JVBase\rest\routes;
-use JVBase\rest\RestRouteManager;
+use JVBase\rest\Rest;
+use JVBase\rest\Route;
use JVBase\utility\Features;
use WP_REST_Request;
use WP_REST_Response;
use WP_Error;
-use WP_Session_Tokens;
use WP_User;
if (!defined('ABSPATH')) {
exit;
}
-class LoginRoutes extends RestRouteManager
+/**
+ * Login Routes
+ *
+ * Handles all authentication-related endpoints with session security
+ */
+class LoginRoutes extends Rest
{
protected ?string $requestId = null;
+ protected bool $hasMagicLink = false;
public function __construct()
{
- $this->cache_name = 'auth';
- $this->cache_ttl = WEEK_IN_SECONDS;
+ $this->cacheName = 'auth';
+ $this->cacheTtl = WEEK_IN_SECONDS;
parent::__construct();
+
+ $this->hasMagicLink = Features::forSite()->has('magicLink');
}
public function registerRoutes(): void
{
- // Login endpoint
- register_rest_route($this->namespace, '/auth/login', [
- 'methods' => 'POST',
- 'callback' => [$this, 'handleLogin'],
- 'permission_callback' => [$this, 'checkRateLimit']
- ]);
+ // Auth status endpoint
+ Route::for('auth/status')
+ ->get([$this, 'getAuthStatus'])
+ ->auth('public')
+ ->rateLimit(60, 60);
- // Logout endpoint
- register_rest_route($this->namespace, '/auth/logout', [
- 'methods' => 'POST',
- 'callback' => [$this, 'handleLogout'],
- 'permission_callback' => 'is_user_logged_in'
- ]);
+ // Standard login
+ Route::for('auth/login')
+ ->post([$this, 'handleLogin'])
+ ->args([
+ 'user_email' => 'email|required',
+ 'user_password' => 'string|required',
+ 'remember_me' => 'boolean',
+ 'redirect_to' => 'string',
+ ])
+ ->auth('public')
+ ->rateLimit(5, 300);
- // Check auth status
- register_rest_route($this->namespace, '/auth/status', [
- 'methods' => 'GET',
- 'callback' => [$this, 'getAuthStatus'],
- 'permission_callback' => '__return_true'
- ]);
+ // User registration
+ Route::for('auth/register')
+ ->post([$this, 'handleRegister'])
+ ->args([
+ 'user_name' => 'string|required',
+ 'user_email' => 'email|required',
+ 'user_select' => 'string',
+ 'referral_code' => 'string',
+ 'redirect_to' => 'string',
+ ])
+ ->auth('public')
+ ->rateLimit(3, 3600);
// Request password reset
- register_rest_route($this->namespace, '/auth/lostpassword', [
- 'methods' => 'POST',
- 'callback' => [$this, 'requestPasswordReset'],
- 'permission_callback' => [$this, 'checkRateLimit'],
- 'args' => [
- 'user_email' => [
- 'required' => true,
- 'type' => 'string',
- 'format' => 'email',
- 'sanitize_callback' => 'sanitize_email'
- ]
- ]
- ]);
+ Route::for('auth/lostpassword')
+ ->post([$this, 'handleLostPassword'])
+ ->args([
+ 'user_email' => 'email|required',
+ ])
+ ->auth('public')
+ ->rateLimit(3, 3600);
- // Complete password reset (with token)
- register_rest_route($this->namespace, '/auth/reset-password', [
- 'methods' => 'POST',
- 'callback' => [$this, 'resetPassword'],
- 'permission_callback' => [$this, 'checkRateLimit'],
- 'args' => [
- 'token' => [
- 'required' => true,
- 'type' => 'string',
- 'sanitize_callback' => 'sanitize_text_field'
- ],
- 'user_email' => [
- 'required' => true,
- 'type' => 'string',
- 'format' => 'email',
- 'sanitize_callback' => 'sanitize_email'
- ],
- 'password' => [
- 'required' => true,
- 'type' => 'string'
- ]
- ]
- ]);
+ // Reset password with token
+ Route::for('auth/resetpass')
+ ->post([$this, 'handleResetPassword'])
+ ->args([
+ 'key' => 'string|required',
+ 'login' => 'string|required',
+ 'pass1' => 'string|required',
+ 'pass2' => 'string|required',
+ ])
+ ->auth('public')
+ ->rateLimit(5, 300);
- register_rest_route($this->namespace, '/auth/register', [
- 'methods' => 'POST',
- 'callback' => [$this, 'handleRegister'],
- 'permission_callback' => [$this, 'checkRateLimit'],
- ]);
-
- // Refresh session
- register_rest_route($this->namespace, '/auth/refresh', [
- 'methods' => 'POST',
- 'callback' => [$this, 'refreshSession'],
- 'permission_callback' => 'is_user_logged_in'
- ]);
-
- register_rest_route($this->namespace, '/auth/email', [
- 'methods' => 'POST',
- 'callback' => [$this, 'checkEmailExists'],
- 'permission_callback' => [$this, 'checkRateLimit'],
- ]);
- }
-
- public function handleLogin(WP_REST_Request $request): WP_REST_Response
- {
- $data = $request->get_params();
- // Verify Turnstile
- if (!$this->verifyTurnstile($data['cf-turnstile-response'] ?? '')) {
- return $this->error('Security verification failed', 'turnstile_failed', 403);
+ // Magic link endpoint
+ if ($this->hasMagicLink) {
+ Route::for('auth/magic')
+ ->post([$this, 'handleMagicLink'])
+ ->args([
+ 'user_email' => 'email|required',
+ 'type' => 'string|enum:login,signup,referral',
+ 'redirect_to' => 'string',
+ ])
+ ->auth('public')
+ ->rateLimit(5, 3600);
}
- $username = sanitize_email($data['user_email'] ?? '');
- $password = $data['user_password'] ?? '';
- $remember = (bool)($data['remember_me'] ?? false);
-
- // Check for account lockout
- $lockout = $this->checkAccountLockout($username);
- if (is_wp_error($lockout)) {
- return $this->error(
- $lockout->get_error_message(),
- 'account_locked',
- 429
- );
- }
- return $this->login($username, $password, $remember, $request);
- }
-
- public function login(string $username, string $password, bool $remember, ?WP_REST_Request $request = null):WP_REST_Response|bool
- {
- // Attempt login
- $user = wp_signon([
- 'user_login' => $username,
- 'user_password' => $password,
- 'remember' => $remember
- ], is_ssl());
-
-
- if (is_wp_error($user)) {
- // Track failed attempt
- $this->trackFailedLogin($username);
-
- return ($request) ? $this->error(
- 'Invalid username or password',
- 'login_failed',
- 401
- ) : false;
- }
- // Clear failed attempts on success
- $this->clearFailedAttempts($username);
-
- // Set auth cookie with remember me flag
- wp_set_current_user($user->ID);
- wp_set_auth_cookie($user->ID, $remember, is_ssl());
-
-
-
- // Store session fingerprint for hijacking protection
- if ($request) {
- $this->storeSessionFingerprint($user->ID, $request);
- }
-
- // Trigger WordPress login action
- do_action('wp_login', $user->user_login, $user);
-
- // Audit log
- $this->auditLog('user_login', [
- 'user_id' => $user->ID,
- 'remember' => $remember
- ]);
-
- return ($request) ? $this->success([
- 'message' => 'Login successful',
- 'user' => $this->formatUserData($user),
- 'redirect' => $this->getRedirect($user, $request->get_param('redirect_to')),
- 'auth' => $this->buildAuth($user->ID)
- ]) : true;
- }
-
- protected function getUserNonces(int $userID):array {
- $nonces = [
- 'wp_rest' => wp_create_nonce('wp_rest'),
- ];
- if (Features::forSite()->has('dashboard')) {
- $nonces['dash'] = wp_create_nonce('dash-'.$userID);
- }
- if (Features::forSite()->has('favourites')) {
- $nonces['favourites'] = wp_create_nonce('favourites-'.$userID);
- }
- if (Features::anyContentHas('karma') ||
- Features::anyTaxonomyHas('karma') ||
- Features::anyUserHas('karma')) {
- $nonces['votes'] = wp_create_nonce('votes-'.$userID);
- }
- if (Features::forSite()->has('notifications')) {
- $nonces['notifications'] = wp_create_nonce('notifications-'.$userID);
- }
- return $nonces;
+ // Logout endpoint
+ Route::for('auth/logout')
+ ->post([$this, 'handleLogout'])
+ ->auth('logged_in')
+ ->rateLimit(10, 60);
}
/**
- * Handle logout request
+ * Get authentication status
+ */
+ public function getAuthStatus(WP_REST_Request $request): WP_REST_Response
+ {
+ $user = wp_get_current_user();
+ $authenticated = $user->exists();
+
+ $response = [
+ 'authenticated' => $authenticated,
+ 'user' => false,
+ 'nonces' => [
+ 'wp_rest' => wp_create_nonce('wp_rest'),
+ ],
+ 'session_id' => session_id() ?: wp_generate_password(32, false),
+ ];
+
+ if ($authenticated) {
+ // Validate session fingerprint
+ if (!$this->validateSessionFingerprint($user->ID, $request)) {
+ wp_logout();
+ $response['authenticated'] = false;
+ $response['session_invalid'] = true;
+ } else {
+ $response['user'] = [
+ 'id' => $user->ID,
+ 'name' => $user->display_name,
+ 'email' => $user->user_email,
+ 'roles' => $user->roles,
+ 'link' => get_user_meta($user->ID, BASE . 'link', true),
+ ];
+ }
+ }
+
+ return $this->success($response);
+ }
+
+ /**
+ * Handle standard login
+ */
+ public function handleLogin(WP_REST_Request $request): WP_REST_Response
+ {
+ $email = sanitize_email($request->get_param('user_email'));
+ $password = $request->get_param('user_password');
+ $remember = (bool) $request->get_param('remember_me');
+ $redirect_to = $request->get_param('redirect_to');
+
+ // Verify Turnstile
+ if (!$this->verifyTurnstile($request->get_param('cf-turnstile-response') ?? '')) {
+ return $this->error(
+ 'Security verification failed. Please try again.',
+ 'turnstile_failed',
+ 403
+ );
+ }
+
+ // Attempt authentication
+ $user = wp_authenticate($email, $password);
+
+ if (is_wp_error($user)) {
+ $this->auditLog('login_failed', [
+ 'email' => $email,
+ 'error' => $user->get_error_code(),
+ ]);
+
+ return $this->error(
+ $this->getLoginErrorMessage($user),
+ $user->get_error_code(),
+ 401
+ );
+ }
+
+ // Set auth cookie
+ wp_clear_auth_cookie();
+ wp_set_current_user($user->ID);
+ wp_set_auth_cookie($user->ID, $remember);
+
+ // Store session fingerprint
+ $this->storeSessionFingerprint($user->ID, $request);
+
+ do_action('wp_login', $user->user_login, $user);
+
+ $this->auditLog('login_success', [
+ 'user_id' => $user->ID,
+ 'email' => $email,
+ ]);
+
+ // Get redirect URL
+ $redirect = $this->getRedirectUrl($user, $redirect_to);
+
+ // Return auth data for frontend
+ return $this->success([
+ 'message' => 'Login successful',
+ 'redirect' => $redirect,
+ 'auth' => [
+ 'authenticated' => true,
+ 'user' => [
+ 'id' => $user->ID,
+ 'name' => $user->display_name,
+ 'email' => $user->user_email,
+ 'roles' => $user->roles,
+ 'link' => get_user_meta($user->ID, BASE . 'link', true),
+ ],
+ 'nonces' => [
+ 'wp_rest' => wp_create_nonce('wp_rest'),
+ ],
+ 'session_id' => session_id() ?: wp_generate_password(32, false),
+ ]
+ ]);
+ }
+
+ /**
+ * Handle user registration
+ */
+ public function handleRegister(WP_REST_Request $request): WP_REST_Response
+ {
+ $email = sanitize_email($request->get_param('user_email'));
+ $name = sanitize_text_field($request->get_param('user_name'));
+ $user_select = sanitize_text_field($request->get_param('user_select') ?? 'subscriber');
+ $referral_code = sanitize_text_field($request->get_param('referral_code') ?? '');
+
+ // Verify Turnstile
+ if (!$this->verifyTurnstile($request->get_param('cf-turnstile-response') ?? '')) {
+ return $this->error(
+ 'Security verification failed. Please try again.',
+ 'turnstile_failed',
+ 403
+ );
+ }
+
+ // Check if email already exists
+ if (email_exists($email)) {
+ return $this->error(
+ 'An account with this email already exists.',
+ 'email_exists',
+ 400,
+ 'user_email'
+ );
+ }
+
+ // Validate role selection
+ $role = $this->validateUserRole($user_select);
+ if (is_wp_error($role)) {
+ return $this->error(
+ $role->get_error_message(),
+ 'invalid_role',
+ 400,
+ 'user_select'
+ );
+ }
+
+ // Create user account
+ $user_id = wp_create_user(
+ $email,
+ wp_generate_password(20, true, true),
+ $email
+ );
+
+ if (is_wp_error($user_id)) {
+ $this->logError('Registration failed', [
+ 'email' => $email,
+ 'error' => $user_id->get_error_message(),
+ ]);
+
+ return $this->error(
+ 'Failed to create account. Please try again.',
+ 'registration_failed',
+ 500
+ );
+ }
+
+ // Set user details
+ $user = get_user_by('ID', $user_id);
+ $user->set_role($role);
+
+ wp_update_user([
+ 'ID' => $user_id,
+ 'display_name' => $name,
+ 'first_name' => $name,
+ ]);
+
+ // Process referral code if provided
+ if (!empty($referral_code) && Features::forSite()->has('referrals')) {
+ $this->processReferralCode($user_id, $referral_code);
+ }
+
+ // Process additional registration fields
+ $this->processRegistrationFields($user_id, $request->get_params());
+
+ do_action('user_register', $user_id, $request->get_params());
+
+ // Send magic link for email verification
+ if ($this->hasMagicLink) {
+ JVB()->magicLink()->sendMagicLink($email, 'signup', [
+ 'name' => $name,
+ 'role' => $role,
+ ]);
+ }
+
+ $this->auditLog('registration_success', [
+ 'user_id' => $user_id,
+ 'email' => $email,
+ 'role' => $role,
+ ]);
+
+ return $this->success([
+ 'message' => 'Registration successful! Check your email to complete setup.',
+ 'title' => 'Success!',
+ 'description' => [
+ 'See your email for next steps',
+ '(Check your spam folder if you cannot find it after a couple minutes.)'
+ ]
+ ]);
+ }
+
+ /**
+ * Handle lost password request
+ */
+ public function handleLostPassword(WP_REST_Request $request): WP_REST_Response
+ {
+ $email = sanitize_email($request->get_param('user_email'));
+
+ // Verify Turnstile
+ if (!$this->verifyTurnstile($request->get_param('cf-turnstile-response') ?? '')) {
+ return $this->error(
+ 'Security verification failed. Please try again.',
+ 'turnstile_failed',
+ 403
+ );
+ }
+
+ // Check if user exists
+ $user = get_user_by('email', $email);
+ if (!$user) {
+ // Don't reveal if email exists for security
+ return $this->success([
+ 'message' => 'If that email address is in our system, we\'ve sent a password reset link.',
+ 'title' => 'Success!',
+ 'description' => ['Check your email for reset instructions']
+ ]);
+ }
+
+ // Use magic link if available, otherwise standard WP reset
+ if ($this->hasMagicLink) {
+ $result = JVB()->magicLink()->sendMagicLink($email, 'reset');
+
+ if (is_wp_error($result)) {
+ $this->logError('Magic link send failed', [
+ 'email' => $email,
+ 'error' => $result->get_error_message(),
+ ]);
+ }
+ } else {
+ // Standard WordPress password reset
+ $key = get_password_reset_key($user);
+ if (is_wp_error($key)) {
+ $this->logError('Reset key generation failed', [
+ 'email' => $email,
+ 'error' => $key->get_error_message(),
+ ]);
+ } else {
+ $this->sendPasswordResetEmail($user, $key);
+ }
+ }
+
+ $this->auditLog('password_reset_requested', [
+ 'user_id' => $user->ID,
+ 'email' => $email,
+ ]);
+
+ return $this->success([
+ 'message' => 'Check your email for reset instructions.',
+ 'title' => 'Success!',
+ 'description' => ['Check your email for reset instructions']
+ ]);
+ }
+
+ /**
+ * Handle password reset with token
+ */
+ public function handleResetPassword(WP_REST_Request $request): WP_REST_Response
+ {
+ $key = sanitize_text_field($request->get_param('key'));
+ $login = sanitize_text_field($request->get_param('login'));
+ $pass1 = $request->get_param('pass1');
+ $pass2 = $request->get_param('pass2');
+
+ // Verify passwords match
+ if ($pass1 !== $pass2) {
+ return $this->error(
+ 'Passwords do not match.',
+ 'password_mismatch',
+ 400,
+ 'pass2'
+ );
+ }
+
+ // Validate password strength
+ if (strlen($pass1) < 8) {
+ return $this->error(
+ 'Password must be at least 8 characters.',
+ 'password_weak',
+ 400,
+ 'pass1'
+ );
+ }
+
+ // Verify reset key
+ $user = check_password_reset_key($key, $login);
+ if (is_wp_error($user)) {
+ return $this->error(
+ 'Invalid or expired reset link.',
+ 'invalid_key',
+ 400
+ );
+ }
+
+ // Reset password
+ reset_password($user, $pass1);
+
+ $this->auditLog('password_reset_completed', [
+ 'user_id' => $user->ID,
+ ]);
+
+ return $this->success([
+ 'message' => 'Password reset successful! You can now log in.',
+ 'redirect' => wp_login_url(),
+ ]);
+ }
+
+ /**
+ * Handle magic link request
+ */
+ public function handleMagicLink(WP_REST_Request $request): WP_REST_Response
+ {
+ if (!$this->hasMagicLink) {
+ return $this->error(
+ 'Magic link authentication is not enabled.',
+ 'feature_disabled',
+ 400
+ );
+ }
+
+ $email = sanitize_email($request->get_param('user_email'));
+ $type = sanitize_text_field($request->get_param('type') ?? 'login');
+ $redirect_to = $request->get_param('redirect_to');
+
+ // Verify Turnstile
+ if (!$this->verifyTurnstile($request->get_param('cf-turnstile-response') ?? '')) {
+ return $this->error(
+ 'Security verification failed. Please try again.',
+ 'turnstile_failed',
+ 403
+ );
+ }
+
+ $context = [];
+ if ($redirect_to) {
+ $context['redirect_to'] = esc_url_raw($redirect_to);
+ }
+
+ // Send magic link
+ $result = JVB()->magicLink()->sendMagicLink($email, $type, $context);
+
+ if (is_wp_error($result)) {
+ $this->logError('Magic link send failed', [
+ 'email' => $email,
+ 'type' => $type,
+ 'error' => $result->get_error_message(),
+ ]);
+
+ return $this->error(
+ $result->get_error_message(),
+ $result->get_error_code(),
+ 400
+ );
+ }
+
+ $this->auditLog('magic_link_sent', [
+ 'email' => $email,
+ 'type' => $type,
+ ]);
+
+ return $this->success([
+ 'message' => 'Check your email for a magic link to sign in!',
+ 'title' => 'Success!',
+ 'description' => [
+ 'We\'ve sent you an email with a magic link.',
+ 'Click it to sign in instantly!',
+ ]
+ ]);
+ }
+
+ /**
+ * Handle logout
*/
public function handleLogout(WP_REST_Request $request): WP_REST_Response
{
@@ -213,534 +510,251 @@
// Clear session fingerprint
$this->clearSessionFingerprint($user_id);
- // Audit log
- $this->auditLog('user_logout', ['user_id' => $user_id]);
-
- // WordPress logout
wp_logout();
+ $this->auditLog('logout', [
+ 'user_id' => $user_id,
+ ]);
+
return $this->success([
'message' => 'Logged out successfully',
- 'redirect' => $this->getRedirect(get_userdata($user_id), $request->get_param('redirect_to'), 'logout')
+ 'redirect' => home_url('/login/'),
]);
}
- protected function buildAuth(?int $user = null): array
+ /************************************************************
+ * SESSION FINGERPRINTING
+ *
+ * Detects session hijacking by validating that session hasn't
+ * moved to a different device/network. Uses IP class (not full IP)
+ * to allow mobile network changes without breaking sessions.
+ ************************************************************/
+
+ /**
+ * Store session fingerprint for hijacking detection
+ */
+ protected function storeSessionFingerprint(int $user_id, WP_REST_Request $request): void
{
- if (is_user_logged_in()) {
- $user = ($user) ?: get_current_user_id();
- return [
- 'authenticated' => true,
- 'user' => $user,
- 'nonces' => $this->getUserNonces($user),
- 'session_id' => $this->getSessionId($user)
- ];
+ if (!defined('JVB_SESSION_FINGERPRINT') || !JVB_SESSION_FINGERPRINT) {
+ return;
}
- return [
- 'authenticated' => false,
- 'currentUser' => false,
- 'nonces' => [
- 'wp_rest' => wp_create_nonce('wp_rest')
- ],
- 'session_id' => null
- ];
+ $fingerprint = $this->generateSessionFingerprint($request);
+ update_user_meta($user_id, BASE . 'session_fingerprint', $fingerprint);
+ update_user_meta($user_id, BASE . 'session_timestamp', time());
}
/**
- * Get unique session identifier that changes on login/logout
+ * Generate session fingerprint for hijacking detection
*/
- protected function getSessionId(int $user_id): string
+ protected function generateSessionFingerprint(WP_REST_Request $request): string
{
- $token = wp_get_session_token(); // Current session token
-
- if (!$token) {
- // Fallback to a hash based on user ID and current timestamp
- // This will be replaced once the session token is available
- return md5($user_id . time());
- }
-
- return md5($token);
+ return hash('sha256', implode('|', [
+ $request->get_header('User-Agent') ?? '',
+ // Use IP class instead of full IP to allow for mobile network changes
+ $this->getIPClass(
+ $request->get_header('X-Forwarded-For')
+ ?: $request->get_header('X-Real-IP')
+ ?: $_SERVER['REMOTE_ADDR'] ?? ''
+ )
+ ]));
}
/**
- * Get current authentication status
+ * Get IP class (first 3 octets) for session validation
+ * Allows for minor IP changes (common with mobile networks)
*/
- public function getAuthStatus(WP_REST_Request $request): WP_REST_Response
+ protected function getIPClass(string $ip): string
{
-
- $responseData = $this->buildAuth();
-
- $response = $this->success($responseData);
-
- // Add caching headers
- $response->header('Cache-Control', 'private, max-age=300'); // 5 minutes
- $response->header('Vary', 'Cookie'); // Important for nginx
-
- return $response;
+ $parts = explode('.', $ip);
+ return implode('.', array_slice($parts, 0, 3));
}
/**
- * Request password reset
+ * Validate session fingerprint against stored value
*/
- public function requestPasswordReset(WP_REST_Request $request): WP_REST_Response
+ protected function validateSessionFingerprint(int $user_id, WP_REST_Request $request): bool
{
- $email = $request->get_param('user_email')??'';
- $email = sanitize_email($email);
-
- $token = $request->get_param('cf-turnstile-response')??'';
- if (!$this->verifyTurnstile($token)){
- return $this->error('Security verification failed', 'turnstile_failed', 403);
+ // Only enforce if enabled in config
+ if (!defined('JVB_SESSION_FINGERPRINT') || !JVB_SESSION_FINGERPRINT) {
+ return true;
}
- // Use WordPress's built-in function
- $result = retrieve_password($email);
+ $stored = get_user_meta($user_id, BASE . 'session_fingerprint', true);
+ $current = $this->generateSessionFingerprint($request);
- // Log but don't expose
- if (is_wp_error($result)) {
- $this->auditLog('password_reset_failed', [
- 'email' => $email,
- 'reason' => $result->get_error_code()
- ]);
- } else {
- $this->auditLog('password_reset_sent', [
- 'email' => $email
- ]);
+ if (empty($stored)) {
+ // First request - store fingerprint
+ update_user_meta($user_id, BASE . 'session_fingerprint', $current);
+ update_user_meta($user_id, BASE . 'session_timestamp', time());
+ return true;
}
- return $this->success([
- 'message' => 'If an account exists with this email, you will receive a password reset link.'
- ]);
+ // Compare using timing-safe comparison
+ return hash_equals($stored, $current);
}
/**
- * Complete password reset with token
+ * Clear session fingerprint (call on logout)
*/
- public function resetPassword(WP_REST_Request $request): WP_REST_Response
+ protected function clearSessionFingerprint(int $user_id): void
{
- $key = sanitize_text_field($request->get_param('key'));
- $login = sanitize_text_field($request->get_param('login'));
- $password = $request->get_param('password');
-
- if (empty($key) || empty($login)) {
- return $this->error('Invalid reset link', 'invalid_key', 400);
- }
-
- // Use WordPress's native key verification
- $user = check_password_reset_key($key, $login);
-
- if (is_wp_error($user)) {
- return $this->error(
- 'Invalid or expired reset link',
- 'invalid_token',
- 400
- );
- }
-
- // Validate password strength
- $validation = $this->validatePassword($password);
- if (is_wp_error($validation)) {
- return $this->error(
- $validation->get_error_message(),
- 'weak_password',
- 400
- );
- }
-
- // Reset the password
- reset_password($user, $password);
-
- // Log them in
- wp_set_current_user($user->ID);
- wp_set_auth_cookie($user->ID, true);
-
- if (session_status() === PHP_SESSION_ACTIVE) {
- session_regenerate_id(true);
- }
- // Store session fingerprint
- $this->storeSessionFingerprint($user->ID, $request);
-
- // Audit log
- $this->auditLog('password_reset_complete', [
- 'user_id' => $user->ID
- ]);
-
- return $this->success([
- 'message' => 'Password reset successfully',
- 'redirect' => home_url('/dash')
- ]);
+ delete_user_meta($user_id, BASE . 'session_fingerprint');
+ delete_user_meta($user_id, BASE . 'session_timestamp');
}
- /**
- * Refresh session (extends session duration)
- */
- public function refreshSession(WP_REST_Request $request): WP_REST_Response
- {
- $user_id = get_current_user_id();
-
- // Validate session fingerprint
- if (!$this->validateSessionFingerprint($user_id, $request)) {
- wp_logout();
- return $this->unauthorized('Session validation failed');
- }
-
- // Refresh auth cookie
- wp_set_auth_cookie($user_id, true);
-
- return $this->success([
- 'message' => 'Session refreshed'
- ]);
- }
-
-
- public function handleRegister(WP_REST_Request $request): WP_REST_Response
- {
- $data = $request->get_json_params();
-
- // Duplicate submission check
- if (!$this->checkRequestId($data['request_id'] ?? '')) {
- return $this->error('Duplicate request detected', 'duplicate_request', 409);
- }
-
- // Verify Turnstile
- if (!$this->verifyTurnstile($data['cf-turnstile-response'] ?? '')) {
- return $this->error('Security verification failed', 'turnstile_failed', 403);
- }
-
- $name = sanitize_text_field($data['name'] ?? '');
- $email = sanitize_email($data['email'] ?? '');
- $referral_code = $request->get_param('referral_code')??'';
- $user_type = sanitize_text_field($data['user_select'] ?? 'subscriber');
-
- // Validate fields
- if (empty($name)) {
- return $this->error('Name is required', 'missing_name', 400, 'name');
- }
-
- if (empty($email)) {
- return $this->error('Email is required', 'missing_email', 400, 'email');
- }
-
- // Check if role can register
- if ($user_type !== 'subscriber') {
- if (!isset(JVB_USER[$user_type]) || empty(JVB_USER[$user_type]['can_register'])) {
- return $this->error('Invalid account type', 'invalid_user_type', 400, 'user_select');
- }
- }
-
- // Check if email exists
- if (email_exists($email)) {
- return $this->error('Email already registered', 'duplicate_email', 400, 'email');
- }
-
- // Validate referral code if provided
- $referrer_id = null;
- if ($referral_code) {
- $code = strtoupper(sanitize_text_field($referral_code));
- $referrer = JVB()->referrals()->getUserByReferralCode($code);
-
- if (!$referrer) {
- return $this->error('Invalid referral code', 'invalid_code', 400);
- }
-
- $referrer_id = $referrer->ID;
- }
-
- // Allow WP plugins to add registration errors
- $errors = new WP_Error();
- $errors = apply_filters('registration_errors', $errors, $email, $email);
-
- if ($errors->has_errors()) {
- return $this->error(
- $errors->get_error_message(),
- $errors->get_error_code(),
- 400
- );
- }
-
- // Update user data
- $role = ($referrer_id) ? get_option(BASE . 'referral_role', BASE . 'client') : jvbCheckBase($user_type);
- $userData = [
- 'user_login' => $email,
- 'user_email' => $email,
- 'display_name' => $name,
- 'first_name' => strtok($name, ' '),
- 'role' => $role
- ];
-
- // Add password if provided, otherwise generate one
- $password = $request->get_param('password');
- if ($password) {
- $userData['user_pass'] = $password;
- } else {
- $userData['user_pass'] = wp_generate_password(20, true, true);
- }
-
- $user_id = wp_insert_user($userData);
-
- if (is_wp_error($user_id)) {
- return $this->error(
- $user_id->get_error_message(),
- 'registration_failed',
- 500
- );
- }
-
- // Process referral if code was provided
- if ($referrer_id) {
- update_user_meta($user_id, BASE . 'pending_referral_code', $referral_code);
- }
-
- // Set role
- $user = get_userdata($user_id);
- if ($user_type !== 'subscriber') {
-
- // Check if needs approval
- if (Features::forMembership()->has('memberVerified') &&
- in_array($role, JVB_MEMBERSHIP['memberVerified'] ?? [])) {
- $user->add_cap('skip_moderation', false);
- update_user_meta($user_id, BASE . 'pending_approval', true);
- }
- }
-
- if (Features::forUser($user_type)->has('namedDirectory')) {
- $upload_dir = wp_upload_dir();
- $user_directory = $user_type.'/'.$user_id;
- $target_dir = $upload_dir['basedir'].'/'.$user_directory;
-
- wp_mkdir_p($target_dir);
- }
-
- // Process additional fields from form
- foreach ($data as $key => $value) {
- if (in_array($key, ['name', 'email', 'action', 'request_id', 'user_select', 'cf-turnstile-response'])) {
- continue;
- }
- update_user_meta($user_id, BASE . $key, sanitize_text_field($value));
- }
-
- $redirect = $this->getRedirect($user, $request->get_param('redirect_to')??get_home_url(null,'/dash'), 'register');
-
- // Handle token handlers
- do_action('jvbUserRegistered', $user_id, $email, $data);
- $magic_link_result = JVB()->magicLink()?->sendMagicLink(
- $email,
- 'login',
- [
- 'user_id' => $user_id,
- 'redirect' => $redirect
- ]
- );
-
- if (is_wp_error($magic_link_result)) {
- return $this->error(
- 'Account created but failed to send verification email. Please use password reset.',
- 'magic_link_failed',
- 500
- );
- }
-
- return $this->success([
- 'message' => 'Registration successful! Check your email.',
- 'user_id' => $user_id,
- 'redirect' => $redirect
- ]);
- }
/**************************************************************
- HELPERS
+ * HELPERS
**************************************************************/
+
/**
- * Format user data for response
+ * Get redirect URL after login
*/
- protected function formatUserData(WP_User $user): array
+ protected function getRedirectUrl(WP_User $user, ?string $redirect_to = null): string
{
- return [
- 'id' => $user->ID,
- 'username' => $user->user_login,
- 'email' => $user->user_email,
- 'display_name' => $user->display_name,
- 'roles' => $user->roles,
- 'capabilities' => [
- 'manage_options' => user_can($user, 'manage_options'),
- 'skip_moderation' => user_can($user, 'skip_moderation'),
- // Add other relevant capabilities
- ]
- ];
- }
-
- protected function getRedirect(WP_User $user, ?string $url=null, string $context = 'login'):string
- {
- if (!empty($url)) {
- $url = sanitize_url($url);
- if (wp_validate_redirect($url)) {
- return $url;
- }
+ // Use provided redirect if safe
+ if ($redirect_to && wp_validate_redirect($redirect_to, false)) {
+ return esc_url_raw($redirect_to);
}
- // Redirect to custom dashboard for members
- if (function_exists('isOurPeople') && isOurPeople()) {
- return home_url('/dash');
- }
-
- // Admins can go to wp-admin if they want (but only if not using custom dashboard)
+ // Default redirect based on user capability
if (user_can($user, 'manage_options')) {
return admin_url();
}
- $custom_redirect = get_option(BASE . 'after_'.$context.'_redirect');
- if ($custom_redirect) {
- return $custom_redirect;
+ if (isOurPeople($user->ID)) {
+ return home_url('/dash/');
}
return home_url();
}
/**
- * Validate password strength
+ * Get user-friendly login error message
*/
- protected function validatePassword(string $password): bool|WP_Error
+ protected function getLoginErrorMessage(WP_Error $error): string
{
- if (strlen($password) < 8) {
- return new WP_Error(
- 'weak_password',
- 'Password must be at least 8 characters long'
- );
- }
+ $code = $error->get_error_code();
- // Add additional strength requirements as needed
- // - Must contain uppercase
- // - Must contain number
- // - Must contain special character
-
- return true;
- }
-
- /**
- * Check if account is locked out due to failed attempts
- */
- protected function checkAccountLockout(string $username): bool|WP_Error
- {
- if (!defined('JVB_MAX_LOGIN_ATTEMPTS')) {
- return true;
- }
-
- $cache_key = 'login_attempts_' . md5($username);
- $attempts = $this->cache->get($cache_key);
-
- if (!$attempts || !isset($attempts['count'])) {
- return true;
- }
-
- if ($attempts['count'] >= JVB_MAX_LOGIN_ATTEMPTS) {
- $lockout_duration = defined('JVB_LOCKOUT_DURATION')
- ? JVB_LOCKOUT_DURATION
- : 15 * MINUTE_IN_SECONDS;
-
- $time_remaining = $lockout_duration - (time() - $attempts['timestamp']);
-
- if ($time_remaining > 0) {
- return new WP_Error(
- 'account_locked',
- sprintf(
- 'Too many failed login attempts. Please try again in %d minutes.',
- ceil($time_remaining / 60)
- )
- );
- }
-
- // Lockout expired - clear attempts
- $this->cache->forget($cache_key);
- return true;
- }
-
- return true;
- }
-
- /**
- * Track failed login attempt
- */
- protected function trackFailedLogin(string $username): void
- {
- $cache_key = 'login_attempts_' . md5($username);
- $attempts = $this->cache->get($cache_key) ?: [
- 'count' => 0,
- 'timestamp' => time()
+ $messages = [
+ 'invalid_email' => 'Invalid email address.',
+ 'invalid_username' => 'Invalid email address.',
+ 'incorrect_password' => 'Incorrect password.',
+ 'empty_password' => 'Please enter your password.',
+ 'empty_username' => 'Please enter your email address.',
];
- $attempts['count']++;
- $attempts['timestamp'] = time();
-
- $lockout_duration = defined('JVB_LOCKOUT_DURATION')
- ? JVB_LOCKOUT_DURATION
- : 15 * MINUTE_IN_SECONDS;
-
- $this->cache->set($cache_key, $attempts, $lockout_duration);
-
- // Audit log
- $this->auditLog('failed_login', [
- 'username' => $username,
- 'attempts' => $attempts['count']
- ]);
+ return $messages[$code] ?? 'Login failed. Please check your credentials.';
}
/**
- * Clear failed login attempts on successful login
+ * Validate user role selection during registration
*/
- protected function clearFailedAttempts(string $username): void
+ protected function validateUserRole(string $user_select): string|WP_Error
{
- $cache_key = 'login_attempts_' . md5($username);
- $this->cache->forget($cache_key);
- }
-
-
-
-
-
-
-
-
-
-
- public function checkEmailExists(WP_REST_Request $request): WP_REST_Response
- {
- $data = $request->get_json_params();
- $email = sanitize_email($data['email'] ?? '');
-
- return $this->success([
- 'exists' => is_email($email) && email_exists($email)
- ]);
- }
- /***********************************************************************
- * HELPER METHODS
- ***********************************************************************/
-
- protected function checkRequestId(string $request_id): bool
- {
- if (empty($request_id)) {
- return true;
+ // Default to subscriber
+ if (empty($user_select) || $user_select === 'subscriber') {
+ return 'subscriber';
}
- $cache_key = 'request_' . $request_id;
- if (get_transient($cache_key)) {
- return false;
+ // Check if role is valid and can register
+ $role_config = JVB_USER[$user_select] ?? null;
+
+ if (!$role_config) {
+ return new WP_Error('invalid_role', 'Invalid role selected.');
}
- set_transient($cache_key, true, 60);
- return true;
- }
+ if (!($role_config['can_register'] ?? false)) {
+ return new WP_Error('role_not_allowed', 'This role cannot be selected during registration.');
+ }
+ return BASE . $user_select;
+ }
/**
- * Helper to return error response
+ * Process referral code during registration
*/
- protected function error(string $message, string $code, int $status = 400, ?string $field = null): WP_REST_Response
+ protected function processReferralCode(int $user_id, string $referral_code): void
{
- if ($this->requestId) {
- delete_transient('request_'.$this->requestId);
- $this->requestId = null;
+ if (!Features::forSite()->has('referrals')) {
+ return;
}
- return parent::error($message, $code, $status, $field);
+
+ try {
+ JVB()->referrals()->processReferralCode($user_id, $referral_code);
+ } catch (\Exception $e) {
+ $this->logError('Referral processing failed', [
+ 'user_id' => $user_id,
+ 'code' => $referral_code,
+ 'error' => $e->getMessage(),
+ ], 'warning');
+ }
}
+ /**
+ * Process additional registration fields
+ */
+ protected function processRegistrationFields(int $user_id, array $data): void
+ {
+ // Get registration form configuration
+ $form_fields = get_option(BASE . 'registration_form_fields', []);
+ foreach ($form_fields as $field_name => $field_config) {
+ // Skip system fields
+ if (in_array($field_name, ['user_name', 'user_email', 'user_select', 'referral_code'])) {
+ continue;
+ }
+
+ // Save field value if present
+ if (isset($data[$field_name]) && !empty($data[$field_name])) {
+ $value = $data[$field_name];
+
+ // Sanitize based on field type
+ if (isset($field_config['type'])) {
+ $value = $this->sanitizeFieldValue($value, $field_config['type']);
+ }
+
+ update_user_meta($user_id, BASE . $field_name, $value);
+ }
+ }
+ }
+
+ /**
+ * Sanitize field value based on type
+ */
+ protected function sanitizeFieldValue(mixed $value, string $type):string|int
+ {
+ switch ($type) {
+ case 'email':
+ return sanitize_email($value);
+ case 'url':
+ return esc_url_raw($value);
+ case 'textarea':
+ return sanitize_textarea_field($value);
+ case 'number':
+ return absint($value);
+ default:
+ return sanitize_text_field($value);
+ }
+ }
+
+ /**
+ * Send password reset email (fallback if magic links not available)
+ */
+ protected function sendPasswordResetEmail(WP_User $user, string $key): bool
+ {
+ $reset_url = network_site_url(
+ "wp-login.php?action=rp&key=$key&login=" . rawurlencode($user->user_login),
+ 'login'
+ );
+
+ $subject = 'Password Reset Request';
+ $message = sprintf(
+ "Hello %s,\n\nYou requested a password reset. Click the link below to reset your password:\n\n%s\n\nIf you didn't request this, please ignore this email.",
+ $user->display_name,
+ $reset_url
+ );
+
+ return wp_mail($user->user_email, $subject, $message);
+ }
}
diff --git a/inc/rest/routes/MagicLinkRoutes.php b/inc/rest/routes/MagicLinkRoutes.php
index 95addfd..efc6571 100644
--- a/inc/rest/routes/MagicLinkRoutes.php
+++ b/inc/rest/routes/MagicLinkRoutes.php
@@ -12,6 +12,7 @@
}
/**
+ * @deprecated all bundled in with LoginRoutes.php
* Magic Link REST Routes
*
* Handles API endpoints for passwordless authentication
diff --git a/inc/rest/routes/NewsRoutes.php b/inc/rest/routes/NewsRoutes.php
index 57f875a..bb9caae 100644
--- a/inc/rest/routes/NewsRoutes.php
+++ b/inc/rest/routes/NewsRoutes.php
@@ -1,9 +1,11 @@
<?php
namespace JVBase\rest\routes;
-use JVBase\rest\RestRouteManager;
-use JVBase\meta\MetaManager;
+use JVBase\rest\PermissionHandler;
+use JVBase\rest\Rest;
+use JVBase\meta\Meta;
use JVBase\managers\NewsRelationships;
+use JVBase\rest\Route;
use WP_Query;
use WP_Error;
use WP_REST_Request;
@@ -13,16 +15,14 @@
exit; // Exit if accessed directly
}
-class NewsRoutes extends RestRouteManager
+class NewsRoutes extends Rest
{
protected int $per_page;
protected bool|object $manager = false;
public function __construct()
{
- $this->cache_name = 'news';
+ $this->cacheName = 'news';
parent::__construct();
- $this->action = 'dash-';
- $this->per_page = 20;
add_filter(BASE.'handle_bulk_operation', [$this, 'processOperation'], 10, 3);
}
@@ -33,18 +33,40 @@
*/
public function registerRoutes():void
{
- register_rest_route($this->namespace, '/news', [
- [
- 'methods' => 'GET',
- 'callback' => [$this, 'getNews'],
- 'permission_callback' => [$this, 'checkPermission']
- ],
- [
- 'methods' => 'POST',
- 'callback' => [$this, 'handleNewsOperation'],
- 'permission_callback' => [$this, 'checkPermission']
- ]
- ]);
+ Route::for('news')
+ ->get([$this, 'getNews'])
+ ->args([
+ 'page' => 'integer|default:1|min:1',
+ 'shop' => 'integer',
+ 'type' => 'integer',
+ 'artist' => 'array',
+ 'orderby' => 'string|enum:date,title,name,popularity,karma,random|default:date',
+ 'order' => 'string|enum:ASC,DESC|default:DESC',
+ 'date-filter' => 'string|enum:today,week,month,year',
+ 'per_page' => 'integer|default:20|min:1|max:100',
+ 'dateFrom' => 'string',
+ 'dateTo' => 'string',
+ 'watched' => 'boolean',
+ ])
+ ->auth(PermissionHandler::combine(['user','nonce', ['actionNonce'=>'dash-']]))
+ ->rateLimit(20)
+ ->post([$this, 'handleNewsOperation'])
+ ->args([
+ 'user' => 'integer|required',
+ 'id' => 'string|required',
+ 'post_title' => 'string|required',
+ 'post_excerpt' => 'string',
+ 'post_content' => 'string|required',
+ 'type' => 'integer',
+ ])
+ ->auth(PermissionHandler::combine(['user','nonce',['actionNonce'=>'dash-']]))
+ ->rateLimit(30);
+
+ Route::for(Route::pattern('news/{id}'))
+ ->get([$this, 'getNewsItem'])
+ ->arg('id', 'integer|required')
+ ->auth(PermissionHandler::combine(['user','nonce', ['actionNonce'=>'dash-']]))
+ ->rateLimit(30);
}
/**
@@ -56,8 +78,8 @@
{
$queue = JVB()->queue();
$data = $request->get_params();
- $user = $data['user'];
- $operationID = $data['id'];
+ $user = absint($data['user']);
+ $operationID = sanitize_text_field($data['id']);
unset($data['user']);
unset($data['id']);
@@ -66,16 +88,13 @@
$user,
$data,
[
- 'operation_id' => 'u'.$user.'_'.$operationID,
+ 'operation_id' => $operationID,
'priority' => 'high',
'notification' => true,
]
);
+ return $this->queued($operationID);
- return new WP_REST_Response([
- 'success' => true,
- 'message' => 'Queued for processing.'
- ]);
}
/**
@@ -89,7 +108,7 @@
$key = $this->cache->generateKey($args);
$cache = $this->cache->get($key);
if ($cache) {
- return new WP_REST_Response($cache);
+ return $this->success($cache);
}
$args['post_type'] = BASE.'news';
@@ -105,7 +124,7 @@
$this->cache->set($key, $results);
- return new WP_REST_Response($results);
+ return $this->success($results);
}
/**
@@ -367,9 +386,9 @@
unset($data['type']);
if ($ID) {
- $meta = new MetaManager($ID, 'post');
+ $meta = Meta::forPost($ID);
foreach ($data as $key => $value) {
- $m = $meta->updateValue($key, $value);
+ $m = $meta->set($key, $value);
$result[$key] = $m;
}
}
@@ -381,4 +400,32 @@
'result' => $result
];
}
+
+ /**
+ * Get a single news item by ID
+ *
+ * @param WP_REST_Request $request
+ * @return WP_REST_Response
+ */
+ public function getNewsItem(WP_REST_Request $request): WP_REST_Response
+ {
+ $id = absint($request->get_param('id'));
+
+ $cache = $this->cache->get($id);
+ if ($cache) {
+ return $this->success($cache);
+ }
+
+ $post = get_post($id);
+
+ if (!$post || $post->post_type !== BASE.'news' || $post->post_status !== 'publish') {
+ return $this->error('News item not found', 'not_found', 404);
+ }
+
+ $item = $this->formatItem($post);
+
+ $this->cache->set($id, $item);
+
+ return $this->success($item);
+ }
}
diff --git a/inc/rest/routes/NotificationsRoutes.php b/inc/rest/routes/NotificationsRoutes.php
index 0ac3889..f523b62 100644
--- a/inc/rest/routes/NotificationsRoutes.php
+++ b/inc/rest/routes/NotificationsRoutes.php
@@ -1,1755 +1,1489 @@
<?php
namespace JVBase\rest\routes;
-use JVBase\JVB;
-use JVBase\rest\RestRouteManager;
+use JVBase\managers\Cache;
+use JVBase\managers\CustomTable;
+use JVBase\rest\Rest;
+use JVBase\rest\Route;
use WP_REST_Request;
use WP_REST_Response;
use WP_Error;
use Exception;
if (!defined('ABSPATH')) {
- exit; // Exit if accessed directly
+ exit; // Exit if accessed directly
}
/**
- * Step 1: Build status/order/filter params
- * Step 2: Get all regular notifications
- * Step 3: Get all Content notifications
- * Step 4: Get all Approval notifications
- * Step 5: Merge in order of created date
- * Step 6: Return result
+ * Notification Routes Handler
+ *
+ * Manages user notifications including regular notifications, content notifications,
+ * and approval notifications. Provides endpoints for reading, marking, and dismissing.
*/
-class NotificationsRoutes extends RestRouteManager
+class NotificationsRoutes extends Rest
{
- protected int $user_id;
- protected array $notification_types = [];
- protected object $manager;
- protected array $typeMap = [
- 'favourite' => [
- 'new_favourite',
- 'list_shared',
- ],
- 'artist' => [
- 'new_artist',
- 'new_tattoo',
- 'new_piercing',
- 'new_event',
- 'new_update',
+ protected int $user_id;
+ protected array $notification_types = [];
+ protected object $manager;
- ],
- 'partner' => [
- 'new_partner',
- 'new_offer',
- ],
- 'shop' => [
- 'new_shop',
- 'shop_update',
- 'shop_accepted',
- 'artist_request',
- ],
- 'event' => [
- 'new_event',
- 'event_reminder',
- ],
- 'news' => [
- 'new_update',
- ],
- 'system' => [
- 'system_message',
- 'artist_approved',
- 'artist_invitation',
- 'artist_request',
- 'shop_accepted',
- 'shop_rejected',
- 'new_term',
- 'term_approved',
- 'term_rejected',
- ],
- ];
- protected array $notificationTableMap = [
- // Regular notifications
- 'notifications' => [
- 'new_favourite',
- 'artist_approved',
- 'artist_rejected',
- 'artist_invitation',
- 'shop_invitation',
- 'artist_request',
- 'shop_accepted',
- 'shop_rejected',
- 'new_term',
- 'term_approved',
- 'term_rejected',
- 'list_shared',
- 'system_message'
- ],
+ protected array $typeMap = [
+ 'favourite' => [
+ 'new_favourite',
+ 'list_shared',
+ ],
+ 'artist' => [
+ 'new_artist',
+ 'new_tattoo',
+ 'new_piercing',
+ 'new_event',
+ 'new_update',
+ ],
+ 'partner' => [
+ 'new_partner',
+ 'new_offer',
+ ],
+ 'shop' => [
+ 'new_shop',
+ 'shop_update',
+ 'shop_accepted',
+ 'artist_request',
+ ],
+ 'event' => [
+ 'new_event',
+ 'event_reminder',
+ ],
+ 'news' => [
+ 'new_update',
+ ],
+ 'system' => [
+ 'system_message',
+ 'artist_approved',
+ 'artist_invitation',
+ 'artist_request',
+ 'shop_accepted',
+ 'shop_rejected',
+ 'new_term',
+ 'term_approved',
+ 'term_rejected',
+ ],
+ ];
- // Content notifications (from artists the user follows)
- 'content_notifications' => [
- 'new_artist',
- 'new_tattoo',
- 'new_piercing',
- 'new_event',
- 'new_update',
- 'new_partner',
- 'new_offer',
- 'new_shop',
- 'shop_update',
- 'event_reminder'
- ]
- ];
+ protected array $notificationTableMap = [
+ 'notifications' => [
+ 'new_favourite',
+ 'artist_approved',
+ 'artist_rejected',
+ 'artist_invitation',
+ 'shop_invitation',
+ 'artist_request',
+ 'shop_accepted',
+ 'shop_rejected',
+ 'new_term',
+ 'term_approved',
+ 'term_rejected',
+ 'list_shared',
+ 'system_message'
+ ],
+ 'content_notifications' => [
+ 'new_artist',
+ 'new_tattoo',
+ 'new_piercing',
+ 'new_event',
+ 'new_update',
+ 'new_partner',
+ 'new_offer',
+ 'new_shop',
+ 'shop_update',
+ 'event_reminder'
+ ]
+ ];
- public function __construct()
- {
- $this->cache_name = 'notifications';
- parent::__construct();
+ protected CustomTable $notifications;
+ protected CustomTable $metrics;
- $allTypes = [];
- foreach ($this->typeMap as $key => $values) {
- $allTypes = array_unique(array_merge($allTypes, $values));
- }
+ public function __construct()
+ {
+ $this->cacheName = 'notifications';
+ $this->cacheTtl = HOUR_IN_SECONDS;
+ parent::__construct();
- $this->typeMap['all'] = $allTypes;
+ // Connect notifications cache to user cache
+ // When user data changes, notification cache should invalidate
+ $this->cache->connect('user');
- $this->user_id = get_current_user_id();
- $this->action = 'notifications-';
+ $this->notifications = CustomTable::for('notifications');
+ $this->metrics = CustomTable::for('notification_metrics');
- add_action('init', [$this, 'init']);
+ // Build complete type map
+ $allTypes = [];
+ foreach ($this->typeMap as $key => $values) {
+ $allTypes = array_unique(array_merge($allTypes, $values));
+ }
+ $this->typeMap['all'] = $allTypes;
- add_filter(BASE.'handle_bulk_operation', [$this, 'processOperation'], 10, 3);
- }
- /**
- * Format a notification for display
- *
- * @param object $notification Notification object
- *
- * @return array Formatted notification
- */
- protected function formatNotification(object $notification):array
- {
- $config = $this->notification_types[$notification->type] ?? [];
- $context = json_decode($notification->context ?? '{}', true);
+ $this->user_id = get_current_user_id();
- // Get action user's name if available
- $acting_user_name = null;
- if ($notification->action_user_id) {
- $acting_user_name = jvbShareName($notification->action_user_id);
- }
+ add_action('init', [$this, 'init']);
+ add_filter(BASE.'handle_bulk_operation', [$this, 'processOperation'], 10, 3);
+ }
- return [
- 'id' => $notification->id,
- 'type' => $notification->type,
- 'message' => $notification->message,
- 'created_at' => $notification->created_at,
- 'status' => $notification->status,
- 'requires_action' => (bool)$notification->requires_action,
- 'action_taken' => (bool)$notification->action_taken,
- 'icon' => $config['icon'] ?? 'info',
- 'priority' => $notification->priority,
- 'target' => [
- 'id' => $notification->target_id,
- 'type' => $notification->target_type
- ],
- 'context' => $context,
- 'acting_user' => $notification->action_user_id ? [
- 'id' => $notification->action_user_id,
- 'name' => $acting_user_name
- ] : null
- ];
- }
+ /**
+ * Set up required parameters
+ */
+ public function init(): void
+ {
+ $this->manager = JVB()->notification();
+ $this->notification_types = $this->manager->getNotificationTypes();
+ }
- /**
- * Set up required paramaters
- * @return void
- */
- public function init()
- {
- $this->manager = JVB()->notification();
- $this->notification_types = $this->manager->getNotificationTypes();
- }
+ /**
+ * Register notification routes
+ */
+ public function registerRoutes(): void
+ {
+ // Get user notifications
+ Route::for('notifications')
+ ->get([$this, 'getNotifications'])
+ ->args([
+ 'user' => 'integer|required',
+ 'type' => 'string',
+ 'status' => 'string|enum:unread,read,actioned,dismissed',
+ 'limit' => 'integer|default:20|min:1|max:100',
+ 'offset' => 'integer|default:0',
+ ])
+ ->auth('user')
+ ->rateLimit(30);
- /**
- * Registers notification routes
- * @return void
- */
- public function registerRoutes():void
- {
- register_rest_route($this->namespace, '/notifications', [
- [
- 'methods' => 'GET',
- 'callback' => [$this, 'getNotifications'],
- 'permission_callback' => [$this, 'checkPermission']
- ],
- [
- 'methods' => 'POST',
- 'callback' => [$this, 'updateNotifications'],
- 'permission_callback' => [$this, 'checkPermission']
- ]
- ]);
- }
+ // Mark as read
+ Route::for('notifications/read')
+ ->post([$this, 'markRead'])
+ ->args([
+ 'user' => 'integer|required',
+ 'notification_id' => 'integer|required',
+ ])
+ ->auth('user')
+ ->rateLimit(30);
+ // Mark all as read
+ Route::for('notifications/read-all')
+ ->post([$this, 'markAllRead'])
+ ->args([
+ 'user' => 'integer|required',
+ 'type' => 'string',
+ ])
+ ->auth('user')
+ ->rateLimit(10);
- /**
- * @param int $ID
- * @param string $content
- *
- * @return string
- */
- protected function getItemLink(int $ID, string $content):string
- {
- switch ($content) {
- case BASE.'artist':
- case BASE.'artwork':
- case BASE.'event':
- case BASE.'news':
- case BASE.'offer':
- case BASE.'partner':
- case BASE.'piercing':
- case BASE.'tattoo':
- return get_permalink($ID);
- default:
- return get_term_link($ID, BASE.$content);
- }
- }
+ // Mark as actioned
+ Route::for('notifications/action')
+ ->post([$this, 'markActioned'])
+ ->args([
+ 'user' => 'integer|required',
+ 'notification_id' => 'integer|required',
+ ])
+ ->auth('user')
+ ->rateLimit(30);
- /**
- * Get notification actions
- *
- * @param string $type Notification type
- * @param array $data Notification data
- * @param object $notification Full notification object
- *
- * @return array Actions available for this notification
- */
- protected function getNotificationActions(string $type, array $data, object $notification = null):array
- {
- error_log('Data for actions: '.print_r($data, true));
+ // Dismiss notification
+ Route::for('notifications/dismiss')
+ ->post([$this, 'markDismissed'])
+ ->args([
+ 'user' => 'integer|required',
+ 'notification_id' => 'integer|required',
+ ])
+ ->auth('user')
+ ->rateLimit(30);
- $actions = [];
+ // Get unread count
+ Route::for('notifications/count')
+ ->get([$this, 'getUnreadCount'])
+ ->args([
+ 'user' => 'integer|required',
+ 'type' => 'string',
+ ])
+ ->auth('user')
+ ->rateLimit(60);
+ }
- switch ($type) {
- case 'artist_approved':
- case 'artist_rejected':
- case 'shop_approved':
- case 'shop_rejected':
- case 'term_approved':
- case 'term_rejected':
- case 'system_message':
- //No extra action needed
- break;
- case 'artist_invitation':
- $actions[] = [
- 'icon' => 'upvote',
- 'label' => 'Approve',
- 'action' => 'acceptInvitation',
- ];
- $actions[] = [
- 'icon' => 'downvote',
- 'label' => 'Reject',
- 'action' => 'reject_invitation',
- ];
- break;
- case 'artist_request':
- $actions[] = [
- 'icon' => 'upvote',
- 'label' => 'Approve',
- 'action' => 'accept_to_shop',
- ];
- $actions[] = [
- 'icon' => 'downvote',
- 'label' => 'Reject',
- 'action' => 'reject_to_shop',
- ];
- break;
- case 'artist_approval':
- $actions[] = [
- 'icon' => 'upvote',
- 'label' => 'Approve',
- 'action' => 'accept_artist',
- ];
- $actions[] = [
- 'icon' => 'downvote',
- 'label' => 'Reject',
- 'action' => 'reject_artist',
- ];
- break;
+ // =========================================================================
+ // GET OPERATIONS
+ // =========================================================================
- case 'new_term':
- $actions[] = [
- 'icon' => 'upvote',
- 'label' => 'Approve',
- 'action' => 'approve_term',
- ];
- $actions[] = [
- 'icon' => 'downvote',
- 'label' => 'Reject',
- 'action' => 'reject_term',
- ];
- break;
+ /**
+ * Get notifications for a user
+ */
+ public function getNotifications(WP_REST_Request $request): WP_REST_Response
+ {
+ $user_id = absint($request->get_param('user'));
+ $type = sanitize_text_field($request->get_param('type') ?? '');
+ $status = sanitize_text_field($request->get_param('status') ?? '');
+ $limit = absint($request->get_param('limit'));
+ $offset = absint($request->get_param('offset'));
- case 'list_shared':
- if (!empty($data['list_id'])) {
- $actions[] = [
- 'icon' => 'list-heart',
- 'label' => 'View List',
- 'url' => home_url("/dash/favourites/{$data['list_id']}"),
- ];
- }
- break;
- default:
- $actions[] = [
- 'icon' => 'link',
- 'label' => 'View',
- 'url' => $this->getItemLink($data['target_id'], $data['target_type']),
- ];
- break;
- }
-
- $actions[] = [
- 'icon' => 'close',
- 'label' => 'Dismiss',
- 'action' => 'dismiss_notification'
- ];
- // Allow customization via filter
- return apply_filters('jvb_notification_actions', $actions, $type, $data, $notification);
- }
-
- /**
- * @param int $user_id
- * @param array $data
- *
- * @return array
- */
- protected function getSanitizedData(int $user_id, array $data):array
- {
- $status = (array_key_exists('status', $data)) ? $data['status'] : 'unread';
- $limit = (array_key_exists('limit', $data)) ? $data['limit'] : 20;
- $offset = (array_key_exists('page', $data)) ? $data['page'] : 1;
- $type = (array_key_exists('type', $data)) ? $data['type'] : 'all';
-
- // Validate and sanitize status
- $allowed_statuses = ['unread', 'read', 'actioned', 'dismissed', 'all'];
- if (!in_array($status, $allowed_statuses)) {
- $this->logError("Invalid notification status", [
- 'status' => $status,
- 'user' => $user_id
- ], 'warning');
- $status = 'unread'; // Default to unread if invalid
- }
-
- if (!in_array($type, array_keys($this->typeMap))) {
- $this->logError("Invalid notification type", [
- 'type' => $type,
- 'user' => $user_id
- ], 'warning');
- $type = 'all';
- }
-
- // Validate and sanitize limit and offset
- $limit = absint($limit);
- if ($limit <= 0 || $limit > 100) {
- $limit = 20; // Use reasonable default if invalid
- }
-
- $offset = absint($offset);
- if ($offset < 0) {
- $offset = 1;
- }
-
- $return = [
- 'status' => $status,
- 'limit' => $limit,
- 'page' => $offset,
- 'type' => $type
- ];
- if (array_key_exists('grouped', $data)) {
- $return['grouped'] = $data['grouped'];
- }
- return $return;
- }
- /**
- * Get notifications for a user
- *
- * @param WP_REST_Request $request
- * @return WP_REST_Response
- */
- public function getNotifications(WP_REST_Request $request): WP_REST_Response
- {
- $data = $request->get_params();
- $user_id = $data['user'];
- if (!$this->userCheck($user_id)) {
- $this->logError("Invalid user ID for notifications", ['user' => $user_id], 'warning');
- return new WP_REST_Response([
- 'success' => false,
- 'message' => 'User doesn\'t match. Are you a bot?'
- ]);
+ if (!$this->checkUser($user_id)) {
+ return $this->unauthorized();
}
+ $cacheKey = compact('user_id', 'type', 'status', 'limit', 'offset');
- $params = $this->getSanitizedData($user_id, $data);
- $params['user'] = $user_id;
- $key = $this->cache->generateKey($params);
- // Check HTTP cache headers (includes notification types in timestamp check)
- $cache_check = $this->checkHeaders($request, $key);
- if ($cache_check) {
- return $cache_check;
+ $result = $this->cache->remember($cacheKey, function() use ($user_id, $type, $status, $limit, $offset) {
+ // Build where conditions
+ $where = ['owner_id' => $user_id];
+ if ($type) $where['type'] = $type;
+ if ($status) $where['status'] = $status;
+
+ $items = $this->notifications
+ ->where($where)
+ ->orderBy('created_at', 'DESC')
+ ->limit($limit, $offset)
+ ->getResults();
+
+ $total = $this->notifications->where($where)->countResults();
+
+ // Format notifications
+ $formatted = array_map([$this, 'formatNotification'], $items);
+
+ return [
+ 'items' => $formatted,
+ 'total' => $total,
+ 'has_more' => ($offset + $limit) < $total
+ ];
+ });
+
+ return $this->success($result);
+ }
+
+ /**
+ * Get unread notification count
+ */
+ public function getUnreadCount(WP_REST_Request $request): WP_REST_Response
+ {
+ $user_id = absint($request->get_param('user'));
+ $type = sanitize_text_field($request->get_param('type') ?? '');
+
+ if (!$this->checkUser($user_id)) {
+ return $this->unauthorized();
}
- // Step 1: Build status/order/filter params
- $status = $params['status'];
- $limit = $params['limit'];
- $offset = $params['page'];
- $type = $params['type'];
+ $cacheKey = compact('user_id', 'type');
- // Try cache first with validated parameters
- $cached = $this->cache->get($key);
- if ($cached) {
- $response = new WP_REST_Response($cached);
- return $this->addCacheHeaders($response);
- }
+ $count = $this->cache->remember($cacheKey, function() use ($user_id, $type) {
+ $where = ['owner_id' => $user_id, 'status' => 'unread'];
+ if ($type) $where['type'] = $type;
- try {
- // Step 2: Get regular notifications
- $regular_notifications = $this->getRegularNotifications($user_id, $params);
+ return $this->notifications->where($where)->countResults();
+ });
- // Step 3: Get content notifications
- $content_notifications = $this->getContentNotifications($user_id, $status, $limit, $offset);
+ return $this->success(['count' => $count]);
+ }
- // Step 4: Get approval notifications
- $approval_notifications = $this->getApprovalNotifications($user_id, $status);
+ /**
+ * Get grouped notifications for a user
+ */
+ public function getGroupedNotifications(
+ int $user_id,
+ string $status,
+ int $limit,
+ int $offset,
+ string $type
+ ): array {
+ $cacheKey = compact('user_id', 'status', 'limit', 'offset', 'type');
- // Step 5: Merge in order of created date
- $notifications = array_merge(
- $regular_notifications,
- $content_notifications,
- $approval_notifications
- );
+ return $this->cache->remember($cacheKey, function() use ($user_id, $status, $limit, $offset, $type) {
+ $time_window = '24 HOUR';
- usort($notifications, function ($a, $b) {
- $date_a = strtotime($a['created_at'] ?? $a['date'] ?? date('Y-m-d H:i:s'));
- $date_b = strtotime($b['created_at'] ?? $b['date'] ?? date('Y-m-d H:i:s'));
- return $date_b - $date_a; // Sort from newest to oldest
- });
+ // Build type filter
+ $typeFilter = '';
+ $typeValues = [];
- // Apply pagination
- $total_count = count($notifications);
- $notifications = array_slice($notifications, 0, $limit);
+ if ($type !== 'all' && isset($this->typeMap[$type])) {
+ $types = $this->typeMap[$type];
+ if (!empty($types)) {
+ $placeholders = implode(',', array_fill(0, count($types), '%s'));
+ $typeFilter = "AND type IN ({$placeholders})";
+ $typeValues = $types;
+ }
+ }
- // Step 6: Return result
- $response = [
- 'notifications' => $notifications,
- 'pagination' => [
- 'total' => $total_count,
- 'page' => $offset,
- 'per_page' => $limit,
- 'pages' => ceil($total_count / $limit)
- ],
- 'has_more' => ($offset * $limit + count($notifications)) < $total_count
- ];
+ // Build status filter
+ $statusFilter = '';
+ if ($status === 'read') {
+ $statusFilter = "AND status IN ('read', 'actioned')";
+ } elseif ($status !== 'all') {
+ $statusFilter = "AND status = %s";
+ $typeValues[] = $status;
+ }
- // Cache the result
- $this->cache->set($key, $response);
- $response = new WP_REST_Response($response);
- return $this->addCacheHeaders($response);
- } catch (Exception $e) {
- $this->logError("Error retrieving notifications", [
- 'user_id' => $user_id,
- 'error' => $e->getMessage()
- ]);
-
- return new WP_REST_Response([
- 'notifications' => [],
- 'pagination' => [
- 'total' => 0,
- 'page' => $offset,
- 'per_page' => $limit,
- 'pages' => 0
- ],
- 'has_more' => false
- ]);
- }
- }
-
- /**
- * Get regular notifications from the notifications table
- *
- * @param int $user_id User ID
- * @param array $params Filter parameters
- * @return array Array of formatted notifications
- */
- protected function getRegularNotifications(int $user_id, array $params): array
- {
- $status = $params['status'];
- $limit = $params['limit'];
- $offset = $params['page'];
- $type = $params['type'];
-
- // Try to get from cache first with validated parameters
- $cache_key = "user_{$user_id}_regular_notifications_{$status}_{$type}_{$limit}_{$offset}";
- $cached = $this->cache->get($cache_key);
- if ($cached) {
- return $cached;
- }
-
- global $wpdb;
- $notifications_table = $wpdb->prefix . BASE . 'notifications';
-
- // Build status condition
- $status_condition = "1=1";
- if ($status === 'unread') {
- $status_condition = "status = 'unread'";
- } elseif ($status === 'read') {
- $status_condition = "status IN ('read', 'actioned')";
- } elseif ($status !== 'all') {
- $status_condition = $wpdb->prepare("status = %s", $status);
- }
-
- // Build type condition
- $type_condition = "1=1";
- if ($type !== 'all' && isset($this->typeMap[$type])) {
- $types = $this->typeMap[$type];
- if (!empty($types)) {
- $placeholders = implode(',', array_fill(0, count($types), '%s'));
- $type_condition = $wpdb->prepare("type IN ($placeholders)", $types);
- }
- }
-
- // Get notifications
- $notifications = $wpdb->get_results(
- $wpdb->prepare(
- "SELECT * FROM {$notifications_table}
- WHERE owner_id = %d AND {$status_condition} AND {$type_condition}
- ORDER BY created_at DESC",
- $user_id
- )
- );
-
- // Format notifications
- $formatted = [];
- foreach ($notifications as $notification) {
- $formatted[] = $this->formatNotification($notification);
- }
-
- // Cache the results
- $this->cache->set($cache_key, $formatted, 'notifications_' . $user_id);
-
- return $formatted;
- }
-
- /**
- * Get approval notifications from the approval_requests table
- *
- * @param int $user_id User ID
- * @param string $status Filter by status
- * @return array Array of formatted approval notifications
- */
- protected function getApprovalNotifications(int $user_id, string $status): array
- {
- // Try to get from cache first
- $cache_key = "user_{$user_id}_approval_notifications_{$status}";
- $cached = $this->cache->get($cache_key);
-
- if ($cached) {
- return $cached;
- }
-
- global $wpdb;
- $formatted = [];
-
- // Build status condition
- $status_condition = "1=1";
- if ($status === 'unread') {
- $status_condition = "a.status = 'pending'";
- } elseif ($status === 'read') {
- $status_condition = "a.status IN ('approved', 'rejected')";
- } elseif ($status !== 'all') {
- $status_condition = $wpdb->prepare("a.status = %s", $status);
- }
-
- if ($this->isVerifiedUser($user_id)) {
- $approvals = jvbApprovalTypes();
- foreach ($approvals as $type => $config) {
- $table = $wpdb->prefix.BASE.'approval_'.$type.'requests';
- $votes = $wpdb->prefix.BASE.'approval_'.$type.'votes';
-
- $approvals = $wpdb->get_results(
- $wpdb->prepare(
- "SELECT a.*,
- COALESCE(v.vote, 'none') as user_vote
- FROM {$table} a
- LEFT JOIN {$votes} v ON a.id = v.request_id AND v.user_id = %d
- WHERE a.user_id != %d
- AND {$status_condition}
- ORDER BY a.created_at DESC",
- $user_id,
- $user_id
- )
- );
- // Now filter out requests created by the current user
- foreach ($approvals as $approval) {
- $requested_by = json_decode($approval->requested_by, true);
-
- // Skip if the current user is the requester
- if (is_array($requested_by) && in_array($user_id, $requested_by)) {
- continue;
- }
-
- $formatted[] = $this->formatApprovalNotification($approval);
- }
- }
- }
-
- // Cache the results
- $this->cache->set($cache_key, $formatted, 'approvals');
-
- return $formatted;
- }
-
- /**
- * Format an approval request as a notification
- *
- * @param object $approval Approval request object
- * @return array Formatted notification
- */
- protected function formatApprovalNotification(object $approval): array
- {
- $data = json_decode($approval->data ?? '{}', true);
- $type_labels = [
- 'artist_approval' => 'Artist Verification',
- 'term_suggestion' => 'Term Suggestion'
- ];
-
- $status_labels = [
- 'pending' => 'Pending',
- 'approved' => 'Approved',
- 'rejected' => 'Rejected',
- 'expired' => 'Expired'
- ];
-
- $icon = ($approval->type === 'artist_approval') ? 'artist' : 'style';
-
- $message = '';
- if ($approval->type === 'artist_approval') {
- if ($approval->requested_by == $approval->target_id) {
- $message = "Your artist verification is {$status_labels[$approval->status]}";
- } else {
- $name = isset($data['display_name']) ? $data['display_name'] : 'An artist';
- $message = "{$name} is requesting verification";
- }
- } elseif ($approval->type === 'term_suggestion') {
- $term_name = $data['term_name'] ?? 'A term';
- $taxonomy = $data['taxonomy'] ?? '';
- $taxonomy_name = str_replace(BASE, '', $taxonomy);
-
- if ($approval->requested_by == get_current_user_id()) {
- $message = "Your {$taxonomy_name} suggestion '{$term_name}' is {$status_labels[$approval->status]}";
- } else {
- $message = "New {$taxonomy_name} suggestion: '{$term_name}'";
- }
- }
-
- return [
- 'id' => 'approval_' . $approval->id,
- 'type' => $approval->type,
- 'message' => $message,
- 'created_at' => $approval->created_at,
- 'status' => $approval->status,
- 'requires_action' => ($approval->status === 'pending' && $approval->requested_by != get_current_user_id()),
- 'action_taken' => !empty($approval->user_vote) && $approval->user_vote !== 'none',
- 'icon' => $icon,
- 'priority' => 'high',
- 'target' => [
- 'id' => $approval->target_id,
- 'type' => $approval->target_type
- ],
- 'context' => $data,
- 'approval_data' => [
- 'required_approvals' => $approval->required_approvals,
- 'current_approvals' => $approval->current_approvals,
- 'expires_at' => $approval->expires_at
- ]
- ];
- }
-
- /**
- * Determine which table a notification type belongs to
- *
- * @param string $notificationType The notification type
- * @return string The table name ('notifications' or 'content_notifications')
- */
- protected function getTableForNotificationType(string $notificationType): string
- {
- foreach ($this->notificationTableMap as $table => $types) {
- if (in_array($notificationType, $types)) {
- return $table;
- }
- }
- // Default to the main notifications table if type is unknown
- return 'notifications';
- }
-
- /**
- * Get grouped notifications for a user
- * @param int $user_id User ID
- * @param string $status notification status
- * @param int $limit number of notifications to fetch
- * @param int $offset page to fetch
- * @param string $type notification type to fetch
- * @return array Grouped notifications with pagination info
- */
- public function getGroupedNotifications(int $user_id, string $status, int $limit, int $offset, string $type):array
- {
- $cache_key = "user_{$user_id}_grouped_notifications_{$status}_{$type}_{$limit}_{$offset}";
- $cached = $this->cache->get($cache_key);
- if ($cached !== false) {
- return $cached;
- }
-
- global $wpdb;
-
- // Build status condition
- $status_condition = "1=1";
- if ($status === 'unread') {
- $status_condition = "status = 'unread'";
- } elseif ($status === 'read') {
- $status_condition = "status IN ('read', 'actioned')";
- } elseif ($status !== 'all') {
- $status_condition = $wpdb->prepare("status = %s", $status);
- }
-
- // Build type condition
- $type_condition = "1=1";
- if ($type !== 'all' && isset($this->typeMap[$type])) {
- $types = $this->typeMap[$type];
- if (!empty($types)) {
- $placeholders = implode(',', array_fill(0, count($types), '%s'));
- $type_condition = $wpdb->prepare("type IN ($placeholders)", $types);
- }
- }
- try {
- // Time window for grouping (e.g., last 24 hours)
- $time_window = '24 HOUR';
-
- $table = $wpdb->prefix.BASE.'notifications';
- // Count notifications by action_user_id and type
- $grouped_counts = $wpdb->get_results(
- $wpdb->prepare(
- "SELECT
+ // Get grouped notifications
+ $grouped = $this->notifications->queryResults(
+ "SELECT
action_user_id,
type,
COUNT(*) as count,
MAX(created_at) as latest_time,
MIN(id) as first_id,
GROUP_CONCAT(target_type) as target_types
- FROM {$table}
+ FROM {table}
WHERE owner_id = %d
AND action_user_id IS NOT NULL
- AND {$status_condition}
- AND {$type_condition}
AND created_at > DATE_SUB(NOW(), INTERVAL {$time_window})
+ {$statusFilter}
+ {$typeFilter}
GROUP BY action_user_id, type
ORDER BY latest_time DESC
LIMIT %d OFFSET %d",
- $user_id,
- $limit,
- $offset
- )
- );
+ array_merge([$user_id], $typeValues, [$limit, $offset])
+ );
-
- // Get total count for pagination
- $total_count = $wpdb->get_var(
- $wpdb->prepare(
- "SELECT COUNT(DISTINCT CONCAT(action_user_id, '_', type))
- FROM {$table}
+ // Get total count
+ $total = $this->notifications->queryVar(
+ "SELECT COUNT(DISTINCT CONCAT(action_user_id, '_', type))
+ FROM {table}
WHERE owner_id = %d
AND action_user_id IS NOT NULL
- AND {$status_condition}
- AND {$type_condition}
- AND created_at > DATE_SUB(NOW(), INTERVAL {$time_window})",
- $user_id
- )
- );
+ AND created_at > DATE_SUB(NOW(), INTERVAL {$time_window})
+ {$statusFilter}
+ {$typeFilter}",
+ array_merge([$user_id], $typeValues)
+ );
- // Format the grouped notifications
- $formatted = [];
- foreach ($grouped_counts as $group) {
- // Get acting user name
- $acting_user_name = jvbGetUsername($group->action_user_id);
+ // Format results
+ $formatted = [];
+ foreach ($grouped as $group) {
+ if ($group->count > 1) {
+ // Grouped notification
+ $target_types = explode(',', $group->target_types);
+ $formatted[] = [
+ 'id' => 'group_' . $group->first_id,
+ 'type' => $group->type,
+ 'message' => $this->buildGroupedMessage(
+ jvbGetUsername($group->action_user_id),
+ $group->type,
+ $group->count,
+ $target_types
+ ),
+ 'created_at' => $group->latest_time,
+ 'is_grouped' => true,
+ 'group_count' => $group->count,
+ ];
+ } else {
+ // Single notification
+ $notification = $this->notifications
+ ->where([
+ 'owner_id' => $user_id,
+ 'action_user_id' => $group->action_user_id,
+ 'type' => $group->type
+ ])
+ ->orderBy('created_at', 'DESC')
+ ->first();
- if ($group->count > 1) {
- // Get unique target types for better message formatting
- $target_types = array_unique(explode(',', $group->target_types));
+ if ($notification) {
+ $formatted[] = $this->formatNotification($notification);
+ }
+ }
+ }
- // Build a grouped notification
- $message = $this->buildGroupedMessage(
- $acting_user_name,
- $group->type,
- $group->count,
- $target_types
- );
+ return [
+ 'notifications' => $formatted,
+ 'pagination' => [
+ 'total' => (int)$total,
+ 'page' => $offset,
+ 'per_page' => $limit,
+ 'pages' => ceil($total / $limit)
+ ],
+ 'has_more' => ($offset + $limit) < $total
+ ];
+ });
+ }
- $formatted[] = [
- 'id' => 'group_' . $group->first_id,
- 'type' => $group->type,
- 'message' => $message,
- 'created_at' => $group->latest_time,
- 'timestamp' => strtotime($group->latest_time),
- 'status' => $status,
- 'icon' => $this->notification_types[$group->type]['icon'] ?? 'info',
- 'priority' => $this->notification_types[$group->type]['priority'] ?? 'normal',
- 'is_grouped' => true,
- 'group_count' => $group->count,
- 'acting_user' => [
- 'id' => $group->action_user_id,
- 'name' => $acting_user_name
- ],
- 'target_types' => $target_types
- ];
- } else {
- // Get the single notification details
- $table = $wpdb->prefix.BASE.'notifications';
- $notification = $wpdb->get_row(
- $wpdb->prepare(
- "SELECT * FROM {$table}
- WHERE owner_id = %d
- AND action_user_id = %d
- AND type = %s
- ORDER BY created_at DESC
- LIMIT 1",
- $user_id,
- $group->action_user_id,
- $group->type
- )
- );
+ /**
+ * Get regular notifications from the notifications table
+ */
+ protected function getRegularNotifications(int $user_id, array $params): array
+ {
+ $cacheKey = compact('user_id', 'params');
- if ($notification) {
- $formatted[] = $this->formatNotification($notification);
- }
- }
- }
+ return $this->cache->remember($cacheKey, function() use ($user_id, $params) {
+ $status = $params['status'];
+ $type = $params['type'];
+ $limit = $params['limit'];
+ $offset = $params['page'];
- // Prepare response with pagination info
- $response = [
- 'notifications' => $formatted,
- 'pagination' => [
- 'total' => (int)$total_count,
- 'page' => $offset,
- 'per_page' => $limit,
- 'pages' => ceil($total_count / $limit)
- ],
- 'has_more' => ($offset + $limit) < $total_count
- ];
+ // Build base query
+ $where = ['owner_id' => $user_id];
- // Cache the results
- $this->cache->set($cache_key, $response, 'notifications_' . $user_id);
+ // Handle status filter
+ if ($status === 'read') {
+ // For multiple statuses, use raw query
+ $notifications = $this->getNotificationsWithMultipleStatuses(
+ $user_id,
+ ['read', 'actioned'],
+ $type,
+ $limit,
+ $offset
+ );
+ } else {
+ // Single status - use fluent builder
+ if ($status !== 'all') {
+ $where['status'] = $status;
+ }
- return $response;
- } catch (Exception $e) {
- $this->logError("Error retrieving grouped notifications", [
- 'user_id' => $user_id,
- 'status' => $status,
- 'error' => $e->getMessage()
- ]);
+ // Handle type filter
+ if ($type !== 'all' && isset($this->typeMap[$type])) {
+ $types = $this->typeMap[$type];
+ if (!empty($types)) {
+ // Multiple types - use raw query
+ return $this->getNotificationsByTypes(
+ $user_id,
+ $types,
+ $status,
+ $limit,
+ $offset
+ );
+ }
+ }
- return [
- 'notifications' => [],
- 'pagination' => [
- 'total' => 0,
- 'page' => $offset,
- 'per_page' => $limit,
- 'pages' => 0
- ],
- 'has_more' => false
- ];
- }
- }
+ // Simple query - use fluent builder
+ $notifications = $this->notifications
+ ->where($where)
+ ->orderBy('created_at', 'DESC')
+ ->limit($limit, $offset)
+ ->getResults();
+ }
- /**
- * Build a message for grouped notifications
- *
- * @param string $user_name Acting user's name
- * @param string $type Notification type
- * @param int $count Number of grouped notifications
- * @param array $target_types Types of targets involved
- * @return string Formatted message
- */
- protected function buildGroupedMessage(string $user_name, string $type, int $count, array $target_types = []):string
- {
- switch ($type) {
- case 'new_favourite':
- // If we have a single target type
- if (count($target_types) === 1) {
- $content_type = $this->manager->getContentTypeLabel($target_types[0]);
- return "{$user_name} favourited {$count} of your {$content_type}";
- }
- return "{$user_name} favourited {$count} of your items";
+ // Format notifications
+ return array_map([$this, 'formatNotification'], $notifications);
+ });
+ }
- case 'artist_request':
- return "{$user_name} wants to join your shop";
+ /**
+ * Get notifications with multiple status values
+ */
+ protected function getNotificationsWithMultipleStatuses(
+ int $user_id,
+ array $statuses,
+ string $type,
+ int $limit,
+ int $offset
+ ): array {
+ $placeholders = implode(',', array_fill(0, count($statuses), '%s'));
+ $params = array_merge([$user_id], $statuses);
- // Add more cases for other notification types
- default:
- return "{$user_name} has {$count} notifications for you";
- }
- }
+ $typeCondition = '';
+ if ($type !== 'all' && isset($this->typeMap[$type])) {
+ $types = $this->typeMap[$type];
+ if (!empty($types)) {
+ $typePlaceholders = implode(',', array_fill(0, count($types), '%s'));
+ $typeCondition = "AND type IN ({$typePlaceholders})";
+ $params = array_merge($params, $types);
+ }
+ }
+ $params[] = $limit;
+ $params[] = $offset;
- /**
- * Build Notification request
- *
- * @param WP_REST_Request $request
- * @return array Sanitized parameters for checking the cache
- */
- protected function buildParams(WP_REST_Request $request):array
- {
- $request = $request->get_params();
- return [
- 'status' => (array_key_exists('status', $request) && in_array($request['status'], ['all', 'unread', 'expired'])) ? $request['status'] : 'unread',
- 'user_id' => (array_key_exists('user', $request) && is_int($request['user'])) ? $request['user'] : get_current_user_id(),
- 'page' => (array_key_exists('page', $request) && is_numeric($request['page'])) ? $request['page'] : 1,
- 'type' => (array_key_exists('type', $request) && in_array($request['type'], array_keys($this->manager->notification_types))) ? $request['type'] : 'all',
- ];
- }
+ return $this->notifications->queryResults(
+ "SELECT * FROM {table}
+ WHERE owner_id = %d
+ AND status IN ({$placeholders})
+ {$typeCondition}
+ ORDER BY created_at DESC
+ LIMIT %d OFFSET %d",
+ $params
+ );
+ }
- /**
- * @param WP_REST_Request $request
- *
- * @return WP_REST_Response
- */
- public function updateNotifications(WP_REST_Request $request):WP_REST_Response
- {
- $data = $request->get_params();
- $action = $request->get_param('action');
- $notificationID = (array_key_exists('notification', $data) && is_int($data['notification'])) ? $data['notification'] : false;
- $args = $this->buildParams($request);
- $data = [];
- $error = '';
- switch ($action) {
- case 'mark_as_read':
- if (is_null($notificationID)) {
- return new WP_REST_Response([
- 'success' => false,
- 'message' => 'Notification ID is required'
- ]);
- }
- $data = [
- 'user_id' => $args['user_id'],
- 'notification_id' => $notificationID,
- ];
- break;
- case 'mark_all_as_read':
- $data = [
- 'user_id' => $args['user_id'],
- ];
- if ($request->get_param('notification_ids')) {
- //To bulk mark all ids
- $data['notification_ids'] = array_map('intval', $request->get_param('notification_ids'));
- }
- if ($request->get_param('type')) {
- //To bulk select all items by type
- $data['type'] = $request->get_param('type');
- }
- break;
- case 'approve_artist':
- case 'reject_artist':
- //TODO: hook into the approval routes already set up
- $handler = JVB()->routes('approvals');
- $ID = $handler->getVerificationDetails($args['user_id']);
- $notes = isset($request['notes']) ? sanitize_text_field($request['notes']) : '';
- $handler->voteForArtist($args['user_id'], $ID['request'], $action==='approve_artist', $notes);
+ /**
+ * Get notifications by multiple types
+ */
+ protected function getNotificationsByTypes(
+ int $user_id,
+ array $types,
+ string $status,
+ int $limit,
+ int $offset
+ ): array {
+ $placeholders = implode(',', array_fill(0, count($types), '%s'));
+ $params = array_merge([$user_id], $types, [$limit, $offset]);
- $this->markActioned($notificationID, $args['user_id']);
- break;
- case 'acceptInvitation':
- case 'reject_invitation':
- //TODO: hook into the shop routes already set up
- $handler = JVB()->routes('shop');
- $notification_data = $this->getNotification($notificationID);
+ $statusCondition = '';
+ if ($status !== 'all') {
+ $statusCondition = "AND status = %s";
+ array_splice($params, -2, 0, [$status]);
+ }
- if ($action === 'acceptInvitation') {
- $result = $handler->acceptShopInvitation($args['user_id'], $args['shop_id']);
- } else {
- $result = $handler->declineShopInvitation($args['user_id'], $args['shop_id']);
- }
+ return $this->notifications->queryResults(
+ "SELECT * FROM {table}
+ WHERE owner_id = %d
+ AND type IN ({$placeholders})
+ {$statusCondition}
+ ORDER BY created_at DESC
+ LIMIT %d OFFSET %d",
+ $params
+ );
+ }
- $this->markActioned($notificationID, $args['user_id']);
- break;
- case 'accept_to_shop':
- case 'reject_to_shop':
- $handler = JVB()->routes('shop');
+ /**
+ * Get approval notifications from the approval_requests table
+ */
+ protected function getApprovalNotifications(int $user_id, string $status): array
+ {
+ $cacheKey = compact('user_id', 'status');
- if ($action === 'accept_to_shop') {
- $result = $handler->addArtistToShop($args['user_id'], $args['shop_id']);
- } else {
- //TODO: notify requester that their request has been denied
- }
+ return $this->cache->remember($cacheKey, function() use ($user_id, $status) {
+ if (!$this->isVerifiedUser($user_id)) {
+ return [];
+ }
- $this->markActioned($notificationID, $args['user_id']);
- break;
- case 'approve_term':
- case 'reject_term':
- $handler = JVB()->routes('approvals');
- $notification_data = $this->getNotification($notificationID);
- $notes = isset($request['notes']) ? sanitize_text_field($request['notes']) : '';
+ global $wpdb;
+ $formatted = [];
- if ($action === 'approve_term') {
- $result = $handler->approveTerm($args['user_id'], $notification_data, $notes);
- } else {
- $result = $handler->rejectTerm($args['user_id'], $notification_data, $notes);
- }
+ // Build status condition
+ $statusCondition = "1=1";
+ if ($status === 'unread') {
+ $statusCondition = "a.status = 'pending'";
+ } elseif ($status === 'read') {
+ $statusCondition = "a.status IN ('approved', 'rejected')";
+ } elseif ($status !== 'all') {
+ $statusCondition = $wpdb->prepare("a.status = %s", $status);
+ }
- $this->markActioned($notificationID, $args['user_id']);
- break;
- case 'dismiss_notification':
- $data = [
- 'user_id' => $args['user_id'],
- 'notification_id' => $notificationID,
- ];
- break;
+ $approvals = jvbApprovalTypes();
+ foreach ($approvals as $type => $config) {
+ $table = $wpdb->prefix . BASE . 'approval_' . $type . 'requests';
+ $votes = $wpdb->prefix . BASE . 'approval_' . $type . 'votes';
- default:
- $error = 'Invalid action';
+ $approvalRequests = $wpdb->get_results(
+ $wpdb->prepare(
+ "SELECT a.*,
+ COALESCE(v.vote, 'none') as user_vote
+ FROM {$table} a
+ LEFT JOIN {$votes} v ON a.id = v.request_id AND v.user_id = %d
+ WHERE a.user_id != %d
+ AND {$statusCondition}
+ ORDER BY a.created_at DESC",
+ $user_id,
+ $user_id
+ )
+ );
- }
+ // Filter out requests created by current user
+ foreach ($approvalRequests as $approval) {
+ $requested_by = json_decode($approval->requested_by, true);
- if (!empty($data)) {
- JVB()->queue()->queueOperation(
- 'notification_'.$action,
- $args['user_id'],
- $data
- );
- return new WP_REST_Response([
- 'success' => true,
- 'message' => __('Notification queued for processing', 'jvb')
- ]);
- } else {
- return new WP_REST_Response([
- 'success' => false,
- 'message' => __('Error: '.$error, 'jvb')
- ]);
- }
- }
+ if (is_array($requested_by) && in_array($user_id, $requested_by)) {
+ continue;
+ }
- /**
- * Get notification data by ID
- * @param int $notification_id Notification ID
- * @return array|false Notification data or false if not found
- */
- protected function getNotification(int $notification_id):array|false
- {
- global $wpdb;
- $table = $wpdb->prefix.BASE.'notifications';
- $notification = $wpdb->get_row(
- $wpdb->prepare(
- "SELECT * FROM {$table} WHERE id = %d",
- $notification_id
- ),
- ARRAY_A
- );
+ $formatted[] = $this->formatApprovalNotification($approval);
+ }
+ }
- if (!$notification) {
- return false;
- }
+ return $formatted;
+ });
+ }
- // Parse context data
- $context = !empty($notification['context'])
- ? json_decode($notification['context'], true)
- : [];
+ /**
+ * Get content notifications for a user
+ */
+ public function getContentNotifications(
+ int $user_id,
+ string $status = 'unread',
+ int $limit = 20,
+ int $offset = 0
+ ): array {
+ if (!$this->checkUser($user_id)) {
+ return [];
+ }
- return array_merge($notification, $context);
- }
+ $cacheKey = compact('user_id', 'status', 'limit', 'offset');
- /**
- * @param WP_Error|array $result
- * @param object $operation
- * @param array $data
- *
- * @return int|mixed
- */
- public function processOperation(WP_Error|array $result, object $operation, array $data):WP_Error|array
- {
- switch ($operation->type) {
- case 'notification_mark_as_read':
- $result = $this->markRead($data['notification_id'], $operation->user_id);
- break;
- case 'notification_mark_all_as_read':
- $result = $this->markAllRead($data);
- break;
- case 'notification_dismiss_notification':
- $result = $this->markDismissed($data['notification_id'], $operation->user_id);
- break;
- }
- return $result;
- }
+ return $this->cache->remember($cacheKey, function() use ($user_id, $status, $limit, $offset) {
+ global $wpdb;
- /**
- * Mark all notifications as read for a user
- *
- * @param array $data Data containing user_id and optional filters
- *
- * @return array Number of notifications marked as read
- */
- public function markAllRead(array $data):array
- {
- $user_id = $data['user_id'];
- $type = $data['type'] ?? null;
- $notification_ids = $data['notification_ids'] ?? null;
+ // Build status condition
+ $statusCondition = "1=1";
+ if ($status === 'unread') {
+ $statusCondition = "seen.status = 'unread'";
+ } elseif ($status === 'read') {
+ $statusCondition = "seen.status = 'read'";
+ } elseif ($status !== 'all') {
+ $statusCondition = $wpdb->prepare("seen.status = %s", $status);
+ }
- global $wpdb;
- $table = $wpdb->prefix . BASE . 'notifications';
+ $notifications = $wpdb->get_results(
+ $wpdb->prepare(
+ "SELECT seen.*, content.*
+ FROM {$wpdb->prefix}" . BASE . "notifications_user_seen AS seen
+ JOIN {$wpdb->prefix}" . BASE . "notifications_content AS content
+ ON seen.content_notification_id = content.id
+ WHERE seen.user_id = %d AND {$statusCondition}
+ ORDER BY content.date DESC, content.created_at DESC
+ LIMIT %d OFFSET %d",
+ $user_id,
+ $limit,
+ $offset
+ )
+ );
- $where = [
- 'owner_id = %d',
- "status = 'unread'"
- ];
- $params = [$user_id];
+ // Format content notifications
+ return array_map([$this, 'formatContentNotification'], $notifications);
+ });
+ }
- // Add type filter if specified
- if ($type) {
- $where[] = "type = %s";
- $params[] = $type;
- }
+ // =========================================================================
+ // UPDATE OPERATIONS
+ // =========================================================================
- // Add specific IDs filter if provided
- if ($notification_ids && is_array($notification_ids)) {
- $id_placeholders = implode(',', array_fill(0, count($notification_ids), '%d'));
- $where[] = "id IN ($id_placeholders)";
- $params = array_merge($params, $notification_ids);
- }
+ /**
+ * Mark notification as read
+ */
+ public function markRead(WP_REST_Request $request): WP_REST_Response
+ {
+ $user_id = absint($request->get_param('user'));
+ $notification_id = absint($request->get_param('notification_id'));
- $where_clause = implode(' AND ', $where);
+ if (!$this->checkUser($user_id)) {
+ return $this->unauthorized();
+ }
- // Update all matching notifications
- $updated = $wpdb->query($wpdb->prepare(
- "UPDATE $table
- SET status = 'read',
- read_at = %s,
- updated_at = %s
- WHERE $where_clause",
- array_merge(
- [current_time('mysql'), current_time('mysql')],
- $params
- )
- ));
+ try {
+ $result = $this->notifications->transaction(function($table) use ($notification_id, $user_id) {
+ // Verify ownership
+ $notification = $table
+ ->where(['id' => $notification_id, 'owner_id' => $user_id])
+ ->first();
- if ($updated) {
- $this->trackNotificationMetrics(0, $user_id, 'batch_read', ['count' => $updated]);
- $this->clearNotificationCache($user_id);
- }
+ if (!$notification) {
+ throw new Exception('Invalid notification');
+ }
- return [
- 'success' => true,
- 'result' => $updated
- ];
- }
+ $updated = $table->update(
+ [
+ 'status' => 'read',
+ 'read_at' => current_time('mysql')
+ ],
+ ['id' => $notification_id]
+ );
- /**
- * Mark a notification as read
- *
- * @param int $notification_id Notification ID
- * @param int $user_id User ID making the request (for security)
- *
- * @return array Success or failure
- */
- public function markRead(int $notification_id, int $user_id):array
- {
- if (!$this->checkUser($user_id)) {
- return [
- 'success' => false,
- 'result' => 'Invalid user'
- ];
- }
- global $wpdb;
+ if (!$updated) {
+ throw new Exception('Failed to update notification');
+ }
+
+ return $updated;
+ });
+
+ $this->trackMetrics($notification_id, $user_id, 'read');
+ $this->clearUserCache($user_id);
+
+ return $this->success(['updated' => $result]);
+
+ } catch (Exception $e) {
+ return $this->error($e->getMessage());
+ }
+ }
+
+ /**
+ * Mark all notifications as read
+ */
+ public function markAllRead(WP_REST_Request $request): WP_REST_Response
+ {
+ $user_id = absint($request->get_param('user'));
+ $type = sanitize_text_field($request->get_param('type') ?? '');
+
+ if (!$this->checkUser($user_id)) {
+ return $this->unauthorized();
+ }
+
+ try {
+ $where = ['owner_id' => $user_id, 'status' => 'unread'];
+ if ($type) $where['type'] = $type;
+
+ $updated = $this->notifications
+ ->where($where)
+ ->updateResults([
+ 'status' => 'read',
+ 'read_at' => current_time('mysql')
+ ]);
+
+ if ($updated) {
+ $this->trackMetrics(0, $user_id, 'batch_read', ['count' => $updated]);
+ $this->clearUserCache($user_id);
+ }
+
+ return $this->success(['updated' => $updated]);
+
+ } catch (Exception $e) {
+ return $this->error($e->getMessage());
+ }
+ }
+
+ /**
+ * Mark notification as actioned
+ */
+ public function markActioned(WP_REST_Request $request): WP_REST_Response
+ {
+ $user_id = absint($request->get_param('user'));
+ $notification_id = absint($request->get_param('notification_id'));
+
+ if (!$this->checkUser($user_id)) {
+ return $this->unauthorized();
+ }
+
+ try {
+ $result = $this->notifications->transaction(function($table) use ($notification_id, $user_id) {
+ // Verify ownership and requires action
+ $notification = $table
+ ->where(['id' => $notification_id, 'owner_id' => $user_id, 'requires_action' => 1])
+ ->first();
+
+ if (!$notification) {
+ throw new Exception('Invalid notification or does not require action');
+ }
+
+ $updated = $table->update(
+ [
+ 'status' => 'actioned',
+ 'action_taken' => 1,
+ 'actioned_at' => current_time('mysql')
+ ],
+ ['id' => $notification_id]
+ );
+
+ if (!$updated) {
+ throw new Exception('Failed to update notification');
+ }
+
+ return $updated;
+ });
+
+ $this->trackMetrics($notification_id, $user_id, 'actioned');
+ $this->clearUserCache($user_id);
+
+ return $this->success(['message' => 'Notification actioned']);
+
+ } catch (Exception $e) {
+ return $this->error($e->getMessage());
+ }
+ }
+
+ /**
+ * Dismiss notification
+ */
+ public function markDismissed(WP_REST_Request $request): WP_REST_Response
+ {
+ $user_id = absint($request->get_param('user'));
+ $notification_id = absint($request->get_param('notification_id'));
+
+ if (!$this->checkUser($user_id)) {
+ return $this->unauthorized();
+ }
+
+ try {
+ $result = $this->notifications->transaction(function($table) use ($notification_id, $user_id) {
+ // Verify ownership
+ $notification = $table
+ ->where(['id' => $notification_id, 'owner_id' => $user_id])
+ ->first();
+
+ if (!$notification) {
+ throw new Exception('Invalid notification');
+ }
+
+ $updated = $table->update(
+ [
+ 'status' => 'dismissed',
+ 'dismissed_at' => current_time('mysql')
+ ],
+ ['id' => $notification_id]
+ );
+
+ if (!$updated) {
+ throw new Exception('Failed to update notification');
+ }
+
+ return $updated;
+ });
+
+ $this->trackMetrics($notification_id, $user_id, 'dismissed');
+ $this->clearUserCache($user_id);
+
+ return $this->success(['updated' => $result]);
+
+ } catch (Exception $e) {
+ return $this->error($e->getMessage());
+ }
+ }
+
+ /**
+ * Mark content notification as read
+ */
+ public function markContentNotificationRead(int $seen_id, int $user_id): array
+ {
+ if (!$this->checkUser($seen_id) || !$this->checkUser($user_id)) {
+ return [
+ 'success' => false,
+ 'message' => 'Invalid User ID'
+ ];
+ }
+
+ try {
+ global $wpdb;
+ $table = $wpdb->prefix . BASE . 'notifications_user_seen';
+
+ return $wpdb->query('START TRANSACTION') &&
+ $this->updateContentNotificationStatus($table, $seen_id, $user_id, 'read');
+
+ } catch (Exception $e) {
+ global $wpdb;
+ $wpdb->query('ROLLBACK');
+ $this->logError('markContentNotificationRead exception', [
+ 'seen_id' => $seen_id,
+ 'user_id' => $user_id,
+ 'error' => $e->getMessage()
+ ]);
+ return [
+ 'success' => false,
+ 'message' => $e->getMessage()
+ ];
+ }
+ }
+
+ /**
+ * Dismiss content notification
+ */
+ public function dismissContentNotification(int $seen_id, int $user_id): array
+ {
+ if (!$this->checkUser($seen_id) || !$this->checkUser($user_id)) {
+ return [
+ 'success' => false,
+ 'message' => 'Invalid User ID'
+ ];
+ }
+
+ try {
+ global $wpdb;
+ $table = $wpdb->prefix . BASE . 'notifications_user_seen';
+
+ // Verify ownership
+ $seen_record = $wpdb->get_row(
+ $wpdb->prepare(
+ "SELECT * FROM {$table} WHERE id = %d AND user_id = %d",
+ $seen_id,
+ $user_id
+ )
+ );
+
+ if (!$seen_record) {
+ return [
+ 'success' => false,
+ 'message' => 'No notification found'
+ ];
+ }
+
+ $result = $wpdb->update(
+ $table,
+ ['status' => 'dismissed'],
+ ['id' => $seen_id]
+ );
+
+ if ($result !== false) {
+ $this->clearUserCache($user_id);
+ }
+
+ return [
+ 'success' => $result !== false,
+ 'message' => 'Operation completed',
+ ];
+
+ } catch (Exception $e) {
+ return [
+ 'success' => false,
+ 'message' => $e->getMessage()
+ ];
+ }
+ }
+
+ // =========================================================================
+ // QUEUE OPERATIONS
+ // =========================================================================
+
+ /**
+ * Process queued notification operations
+ */
+ public function processOperation(WP_Error|array $result, object $operation, array $data): WP_Error|array
+ {
+ switch ($operation->type) {
+ case 'notification_mark_as_read':
+ $result = $this->markReadQueued($data['notification_id'], $operation->user_id);
+ break;
+ case 'notification_mark_all_as_read':
+ $result = $this->markAllReadQueued($data);
+ break;
+ case 'notification_dismiss_notification':
+ $result = $this->markDismissedQueued($data['notification_id'], $operation->user_id);
+ break;
+ }
+ return $result;
+ }
+
+ /**
+ * Update notification operations (legacy endpoint)
+ */
+ public function updateNotifications(WP_REST_Request $request): WP_REST_Response
+ {
+ $data = $request->get_params();
+ $action = $request->get_param('action');
+ $notificationID = absint($data['notification'] ?? 0);
+ $args = $this->buildParams($request);
+ $queueData = [];
+ $error = '';
+
+ switch ($action) {
+ case 'mark_as_read':
+ if (!$notificationID) {
+ return $this->validationError(['notification' => 'Notification ID is required']);
+ }
+ $queueData = [
+ 'user_id' => $args['user_id'],
+ 'notification_id' => $notificationID,
+ ];
+ break;
+
+ case 'mark_all_as_read':
+ $queueData = ['user_id' => $args['user_id']];
+ if ($request->get_param('notification_ids')) {
+ $queueData['notification_ids'] = array_map('intval', $request->get_param('notification_ids'));
+ }
+ if ($request->get_param('type')) {
+ $queueData['type'] = $request->get_param('type');
+ }
+ break;
+
+ case 'dismiss_notification':
+ $queueData = [
+ 'user_id' => $args['user_id'],
+ 'notification_id' => $notificationID,
+ ];
+ break;
+
+ default:
+ $error = 'Invalid action';
+ }
+
+ if (!empty($queueData)) {
+ JVB()->queue()->queueOperation(
+ 'notification_' . $action,
+ $args['user_id'],
+ $queueData
+ );
+ return $this->success(['message' => __('Notification queued for processing', 'jvb')]);
+ }
+
+ return $this->error($error ?: 'Unknown error');
+ }
+
+ // =========================================================================
+ // FORMATTING HELPERS
+ // =========================================================================
+
+ /**
+ * Format a notification for display
+ */
+ protected function formatNotification(object $notification): array
+ {
+ $config = $this->notification_types[$notification->type] ?? [];
+ $context = json_decode($notification->context ?? '{}', true);
+
+ // Get action user's name if available
+ $acting_user_name = null;
+ if ($notification->action_user_id) {
+ $acting_user_name = jvbShareName($notification->action_user_id);
+ }
- //TODO: We need to set up a system to check the main notification table, but also the content notification table
- // Verify ownership
- $table = $wpdb->prefix . BASE . 'notifications';
- $notification = $wpdb->get_row(
- $wpdb->prepare(
- "SELECT * FROM {$table}
- WHERE id = %d AND owner_id = %d",
- $notification_id,
- $user_id
- )
- );
+ return [
+ 'id' => $notification->id,
+ 'type' => $notification->type,
+ 'message' => $notification->message,
+ 'created_at' => $notification->created_at,
+ 'status' => $notification->status,
+ 'requires_action' => (bool)$notification->requires_action,
+ 'action_taken' => (bool)$notification->action_taken,
+ 'icon' => $config['icon'] ?? 'info',
+ 'priority' => $notification->priority,
+ 'target' => [
+ 'id' => $notification->target_id,
+ 'type' => $notification->target_type
+ ],
+ 'context' => $context,
+ 'acting_user' => $notification->action_user_id ? [
+ 'id' => $notification->action_user_id,
+ 'name' => $acting_user_name
+ ] : null,
+ 'actions' => $this->getNotificationActions($notification->type, (array)$notification, $notification)
+ ];
+ }
- if (!$notification) {
- return [
- 'success' => false,
- 'result' => 'Invalid notification'
- ];
- }
+ /**
+ * Format an approval request as a notification
+ */
+ protected function formatApprovalNotification(object $approval): array
+ {
+ $data = json_decode($approval->data ?? '{}', true);
+ $type_labels = [
+ 'artist_approval' => 'Artist Verification',
+ 'term_suggestion' => 'Term Suggestion'
+ ];
- // Mark as read
- $result = $wpdb->update(
- $wpdb->prefix . BASE . 'notifications',
- [
- 'status' => 'read',
- 'read_at' => current_time('mysql'),
- 'updated_at' => current_time('mysql')
- ],
- ['id' => $notification_id]
- );
+ $status_labels = [
+ 'pending' => 'Pending',
+ 'approved' => 'Approved',
+ 'rejected' => 'Rejected',
+ 'expired' => 'Expired'
+ ];
- if ($result) {
- $this->clearNotificationCache($user_id);
- }
+ $icon = ($approval->type === 'artist_approval') ? 'artist' : 'style';
- return [
- 'success' => $result !== false,
- 'result' => $result
- ];
- }
+ $message = '';
+ if ($approval->type === 'artist_approval') {
+ if ($approval->requested_by == $approval->target_id) {
+ $message = "Your artist verification is {$status_labels[$approval->status]}";
+ } else {
+ $name = $data['display_name'] ?? 'An artist';
+ $message = "{$name} is requesting verification";
+ }
+ } elseif ($approval->type === 'term_suggestion') {
+ $term_name = $data['term_name'] ?? 'A term';
+ $taxonomy = $data['taxonomy'] ?? '';
+ $taxonomy_name = str_replace(BASE, '', $taxonomy);
- /**
- * Mark a notification as actioned
- *
- * @param int $notification_id Notification ID
- * @param int $user_id User ID making the request
- *
- * @return array
- */
- public function markActioned(int $notification_id, int $user_id):array
- {
- if (!$this->checkUser($user_id)) {
- return [
- 'success' => false,
- 'message' => 'Invalid user'
- ];
- }
- global $wpdb;
+ if ($approval->requested_by == get_current_user_id()) {
+ $message = "Your {$taxonomy_name} suggestion '{$term_name}' is {$status_labels[$approval->status]}";
+ } else {
+ $message = "New {$taxonomy_name} suggestion: '{$term_name}'";
+ }
+ }
- //TODO: We need to set up a system to check the main notification table, but also the content notification table
- // Start a transaction
- $wpdb->query('START TRANSACTION');
+ return [
+ 'id' => 'approval_' . $approval->id,
+ 'type' => $approval->type,
+ 'message' => $message,
+ 'created_at' => $approval->created_at,
+ 'status' => $approval->status,
+ 'requires_action' => ($approval->status === 'pending' && $approval->requested_by != get_current_user_id()),
+ 'action_taken' => !empty($approval->user_vote) && $approval->user_vote !== 'none',
+ 'icon' => $icon,
+ 'priority' => 'high',
+ 'target' => [
+ 'id' => $approval->target_id,
+ 'type' => $approval->target_type
+ ],
+ 'context' => $data,
+ 'approval_data' => [
+ 'required_approvals' => $approval->required_approvals,
+ 'current_approvals' => $approval->current_approvals,
+ 'expires_at' => $approval->expires_at
+ ]
+ ];
+ }
- try {
- $table = $wpdb->prefix.BASE.'notifications';
- // Verify ownership and that notification requires action
- $notification = $wpdb->get_row(
- $wpdb->prepare(
- "SELECT * FROM {$table}
- WHERE id = %d AND user_id = %d AND requires_action = 1
- FOR UPDATE", // Lock the row
- $notification_id,
- $user_id
- )
- );
+ /**
+ * Format a content notification for display
+ */
+ protected function formatContentNotification(object $notification): array
+ {
+ // Get artist data
+ $artist_data = jvbContentFromUser($notification->user_id);
- if (!$notification) {
- $wpdb->query('ROLLBACK');
- $this->logError("Failed to action notification - not found or not owned", [
- 'notification_id' => $notification_id,
- 'user_id' => $user_id
- ], 'warning');
- return [
- 'success' => false,
- 'message' => 'No notification found, or invalid owner'
- ];
- }
+ // Parse JSON data
+ $new_items = json_decode($notification->new_items, true) ?: [];
+ $updated_items = json_decode($notification->updated_items, true) ?: [];
- // Mark as actioned
- $result = $wpdb->update(
- $wpdb->prefix . BASE . 'notifications',
- [
- 'status' => 'actioned',
- 'action_taken' => 1,
- 'actioned_at' => current_time('mysql'),
- 'updated_at' => current_time('mysql')
- ],
- ['id' => $notification_id]
- );
+ // Count items by type
+ $counts_by_type = [];
+ $total_new = 0;
- if ($result !== false) {
- $wpdb->query('COMMIT');
- $this->clearNotificationCache($user_id);
- return [
- 'success' => true,
- 'message' => 'Notification actioned'
- ];
- } else {
- $wpdb->query('ROLLBACK');
- $this->logError("Database error marking notification as actioned", [
- 'notification_id' => $notification_id,
- 'user_id' => $user_id,
- 'db_error' => $wpdb->last_error
- ]);
- return [
- 'success' => false,
- 'message' => 'Error'
- ];
- }
- } catch (Exception $e) {
- $wpdb->query('ROLLBACK');
- $this->logError("Exception marking notification as actioned", [
- 'notification_id' => $notification_id,
- 'user_id' => $user_id,
- 'error' => $e->getMessage()
- ]);
- return [
- 'success' => false,
- 'message' => $e->getMessage(),
- ];
- }
- }
+ foreach ($new_items as $type => $ids) {
+ $clean_type = str_replace(BASE, '', $type);
+ $counts_by_type[$clean_type] = [
+ 'new' => count($ids),
+ 'updated' => 0
+ ];
+ $total_new += count($ids);
+ }
- /**
- * Dismiss a notification
- *
- * @param int $notification_id Notification ID
- * @param int $user_id User ID making the request
- *
- * @return array Success or failure
- */
- public function markDismissed(int $notification_id, int $user_id):array{
- if (!$this->checkUser($user_id)) {
- return [
- 'success' => false,
- 'result' => 'Invalid User',
- ];
- }
- global $wpdb;
+ foreach ($updated_items as $type => $ids) {
+ $clean_type = str_replace(BASE, '', $type);
+ if (!isset($counts_by_type[$clean_type])) {
+ $counts_by_type[$clean_type] = ['new' => 0];
+ }
+ $counts_by_type[$clean_type]['updated'] = count($ids);
+ }
- // Verify ownership
- $table = $wpdb->prefix.BASE.'notifications';
- $notification = $wpdb->get_row(
- $wpdb->prepare(
- "SELECT * FROM {$table}
- WHERE id = %d AND user_id = %d",
- $notification_id,
- $user_id
- )
- );
-//TODO: We need to set up a system to check the main notification table, but also the content notification table
- if (!$notification) {
- return [
- 'success' => false,
- 'result' => 'Invalid notification'
- ];
- }
+ // Build summary text
+ $summary = [];
+ foreach ($counts_by_type as $type => $counts) {
+ if ($counts['new'] > 0) {
+ $label = $counts['new'] === 1 ? $type : $this->pluralize($type);
+ $summary[] = "{$counts['new']} new {$label}";
+ }
+ if ($counts['updated'] > 0) {
+ $label = $counts['updated'] === 1 ? $type : $this->pluralize($type);
+ $summary[] = "{$counts['updated']} updated {$label}";
+ }
+ }
- // Mark as dismissed
- $result = $wpdb->update(
- $wpdb->prefix . BASE . 'notifications',
- [
- 'status' => 'dismissed',
- 'updated_at' => current_time('mysql')
- ],
- [ 'id' => $notification_id ]
- );
+ return [
+ 'id' => $notification->id,
+ 'seen_id' => $notification->content_notification_id,
+ 'status' => $notification->status,
+ 'date' => $notification->date,
+ 'artist' => $artist_data,
+ 'new_items' => $new_items,
+ 'updated_items' => $updated_items,
+ 'summary' => implode(', ', $summary),
+ 'total_new' => $total_new,
+ 'total_updated' => $notification->total_items - $total_new,
+ 'counts_by_type' => $counts_by_type,
+ 'has_profile_update' => (bool)$notification->has_profile_update,
+ 'created_at' => $notification->created_at
+ ];
+ }
- if ($result) {
- $this->clearNotificationCache($user_id);
- }
+ /**
+ * Build a message for grouped notifications
+ */
+ protected function buildGroupedMessage(string $user_name, string $type, int $count, array $target_types = []): string
+ {
+ switch ($type) {
+ case 'new_favourite':
+ if (count($target_types) === 1) {
+ $content_type = $this->manager->getContentTypeLabel($target_types[0]);
+ return "{$user_name} favourited {$count} of your {$content_type}";
+ }
+ return "{$user_name} favourited {$count} of your items";
- return [
- 'success' => $result !== false,
- 'result' => $result
- ];
- }
+ case 'artist_request':
+ return "{$user_name} wants to join your shop";
- /***
- * Content notifications (in a separate database))
- */
- /**
- * Get content notifications for a user
- *
- * @param int $user_id User ID
- * @param string $status Status filter (unread, read, all)
- * @param int $limit Maximum number of notifications to return
- * @param int $offset Pagination offset
- *
- * @return array Content notifications
- */
- public function getContentNotifications(
- int $user_id,
- string $status = 'unread',
- int $limit = 20,
- int $offset = 0
- ):array {
- if (!$this->checkUser($user_id)) {
- return [];
- }
- // Try cache first
- $cache_key = "user_{$user_id}_content_notifications_{$status}_{$limit}_{$offset}";
- $cached = $this->cache->get($cache_key);
+ default:
+ return "{$user_name} has {$count} notifications for you";
+ }
+ }
- if ($cached !== false) {
- return $cached;
- }
+ // =========================================================================
+ // HELPER METHODS
+ // =========================================================================
- global $wpdb;
+ /**
+ * Get notification actions
+ */
+ protected function getNotificationActions(string $type, array $data, object $notification = null): array
+ {
+ $actions = [];
- // Build status condition
- $status_condition = "1=1";
- if ($status === 'unread') {
- $status_condition = "seen.status = 'unread'";
- } elseif ($status === 'read') {
- $status_condition = "seen.status = 'read'";
- } elseif ($status !== 'all') {
- $status_condition = $wpdb->prepare("seen.status = %s", $status);
- }
+ switch ($type) {
+ case 'artist_approved':
+ case 'artist_rejected':
+ case 'shop_approved':
+ case 'shop_rejected':
+ case 'term_approved':
+ case 'term_rejected':
+ case 'system_message':
+ // No extra action needed
+ break;
- // Get content notifications this user has seen records for
+ case 'artist_invitation':
+ $actions[] = [
+ 'icon' => 'upvote',
+ 'label' => 'Approve',
+ 'action' => 'acceptInvitation',
+ ];
+ $actions[] = [
+ 'icon' => 'downvote',
+ 'label' => 'Reject',
+ 'action' => 'reject_invitation',
+ ];
+ break;
- $notifications = $wpdb->get_results(
- $wpdb->prepare(
- "SELECT seen.*, content.*
- FROM {$wpdb->prefix}{$this->base}notifications_user_seen AS seen
- JOIN {$wpdb->prefix}{$this->base}notifications_content AS content
- ON seen.content_notification_id = content.id
- WHERE seen.user_id = %d AND {$status_condition}
- ORDER BY content.date DESC, content.created_at DESC
- LIMIT %d OFFSET %d",
- $user_id,
- $limit,
- $offset
- )
- );
+ case 'artist_request':
+ $actions[] = [
+ 'icon' => 'upvote',
+ 'label' => 'Approve',
+ 'action' => 'accept_to_shop',
+ ];
+ $actions[] = [
+ 'icon' => 'downvote',
+ 'label' => 'Reject',
+ 'action' => 'reject_to_shop',
+ ];
+ break;
- // Format content notifications
- $formatted = [];
- foreach ($notifications as $notification) {
- $formatted[] = $this->formatContentNotification($notification);
- }
+ case 'new_term':
+ $actions[] = [
+ 'icon' => 'upvote',
+ 'label' => 'Approve',
+ 'action' => 'approve_term',
+ ];
+ $actions[] = [
+ 'icon' => 'downvote',
+ 'label' => 'Reject',
+ 'action' => 'reject_term',
+ ];
+ break;
- // Cache the results
- $this->cache->set($cache_key, $formatted, 'notifications_' . $user_id);
+ case 'list_shared':
+ if (!empty($data['list_id'])) {
+ $actions[] = [
+ 'icon' => 'list-heart',
+ 'label' => 'View List',
+ 'url' => home_url("/dash/favourites/{$data['list_id']}"),
+ ];
+ }
+ break;
- return $formatted;
- }
+ default:
+ if (!empty($data['target_id']) && !empty($data['target_type'])) {
+ $actions[] = [
+ 'icon' => 'link',
+ 'label' => 'View',
+ 'url' => $this->getItemLink($data['target_id'], $data['target_type']),
+ ];
+ }
+ break;
+ }
- /**
- * Format a content notification for display
- *
- * @param object $notification Content notification object
- *
- * @return array Formatted content notification
- */
- protected function formatContentNotification(object $notification):array
- {
- // Get artist data
- $artist_data = jvbContentFromUser($notification->user_id);
+ $actions[] = [
+ 'icon' => 'close',
+ 'label' => 'Dismiss',
+ 'action' => 'dismiss_notification'
+ ];
- // Parse JSON data
- $new_items = json_decode($notification->new_items, true) ?: [];
- $updated_items = json_decode($notification->updated_items, true) ?: [];
+ return apply_filters('jvb_notification_actions', $actions, $type, $data, $notification);
+ }
- // Count items by type
- $counts_by_type = [];
- $total_new = 0;
+ /**
+ * Get item link for notification target
+ */
+ protected function getItemLink(int $ID, string $content): string
+ {
+ switch ($content) {
+ case BASE.'artist':
+ case BASE.'artwork':
+ case BASE.'event':
+ case BASE.'news':
+ case BASE.'offer':
+ case BASE.'partner':
+ case BASE.'piercing':
+ case BASE.'tattoo':
+ return get_permalink($ID);
+ default:
+ return get_term_link($ID, BASE.$content);
+ }
+ }
- foreach ($new_items as $type => $ids) {
- $clean_type = str_replace(BASE, '', $type);
- $counts_by_type[ $clean_type ] = [
- 'new' => count($ids),
- 'updated' => 0
- ];
- $total_new += count($ids);
- }
+ /**
+ * Get notification data by ID
+ */
+ protected function getNotification(int $notification_id): array|false
+ {
+ $notification = $this->notifications->where(['id' => $notification_id])->first();
- foreach ($updated_items as $type => $ids) {
- $clean_type = str_replace(BASE, '', $type);
- if (!isset($counts_by_type[ $clean_type ])) {
- $counts_by_type[ $clean_type ] = [
- 'new' => 0
- ];
- }
- $counts_by_type[ $clean_type ]['updated'] = count($ids);
- }
+ if (!$notification) {
+ return false;
+ }
- // Build summary text
- $summary = [];
- foreach ($counts_by_type as $type => $counts) {
- if ($counts['new'] > 0) {
- $label = $counts['new'] === 1 ? $type : $this->pluralize($type);
- $summary[] = "{$counts['new']} new {$label}";
- }
- if ($counts['updated'] > 0) {
- $label = $counts['updated'] === 1 ? $type : $this->pluralize($type);
- $summary[] = "{$counts['updated']} updated {$label}";
- }
- }
+ $context = !empty($notification->context)
+ ? json_decode($notification->context, true)
+ : [];
- return [
- 'id' => $notification->id,
- 'seen_id' => $notification->content_notification_id,
- 'status' => $notification->status,
- 'date' => $notification->date,
- 'artist' => $artist_data,
- 'new_items' => $new_items,
- 'updated_items' => $updated_items,
- 'summary' => implode(', ', $summary),
- 'total_new' => $total_new,
- 'total_updated' => $notification->total_items - $total_new,
- 'counts_by_type' => $counts_by_type,
- 'has_profile_update' => (bool) $notification->has_profile_update,
- 'created_at' => $notification->created_at
- ];
- }
+ return array_merge((array)$notification, $context);
+ }
- /**
- * Mark a content notification as read
- *
- * @param int $seen_id User seen record ID
- * @param int $user_id User ID making the request
- *
- * @return array Success or failure
- */
- public function markContentNotificationRead(int $seen_id, int $user_id):array
- {
- // Validate input parameters
- if (!$this->checkUser($seen_id)) {
- $this->logError("Invalid seen ID", [
- 'seen_id' => $seen_id,
- 'user_id' => $user_id
- ], 'warning');
- return [
- 'success' => false,
- 'message' => 'Invalid User ID'
- ];
- }
+ /**
+ * Build notification request parameters
+ */
+ protected function buildParams(WP_REST_Request $request): array
+ {
+ $params = $request->get_params();
- if (!$this->checkUser($user_id)) {
- $this->logError("Invalid user ID", [
- 'seen_id' => $seen_id,
- 'user_id' => $user_id
- ], 'warning');
- return [
- 'success' => false,
- 'message' => 'Invalid User ID'
- ];
- }
+ return [
+ 'status' => in_array($params['status'] ?? '', ['all', 'unread', 'expired'])
+ ? $params['status']
+ : 'unread',
+ 'user_id' => absint($params['user'] ?? get_current_user_id()),
+ 'page' => absint($params['page'] ?? 1),
+ 'type' => in_array($params['type'] ?? '', array_keys($this->manager->notification_types ?? []))
+ ? $params['type']
+ : 'all',
+ ];
+ }
- global $wpdb;
- $table = $wpdb->prefix . BASE . 'notifications_user_seen';
+ /**
+ * Determine which table a notification type belongs to
+ */
+ protected function getTableForNotificationType(string $notificationType): string
+ {
+ foreach ($this->notificationTableMap as $table => $types) {
+ if (in_array($notificationType, $types)) {
+ return $table;
+ }
+ }
+ return 'notifications';
+ }
- // Start transaction
- $wpdb->query('START TRANSACTION');
+ /**
+ * Simple pluralization helper
+ */
+ protected function pluralize(string $word): string
+ {
+ $irregular = [
+ 'tattoo' => 'tattoos',
+ 'piercing' => 'piercings',
+ 'artwork' => 'artwork',
+ 'news' => 'news',
+ 'offer' => 'offers',
+ 'event' => 'events'
+ ];
- try {
- // Verify ownership with row locking
- $seen_record = $wpdb->get_row(
- $wpdb->prepare(
- "SELECT * FROM {$table}
- WHERE id = %d AND user_id = %d
- FOR UPDATE",
- $seen_id,
- $user_id
- )
- );
+ if (isset($irregular[$word])) {
+ return $irregular[$word];
+ }
- if (!$seen_record) {
- $wpdb->query('ROLLBACK');
- $this->logError("Seen record not found or not owned by user", [
- 'seen_id' => $seen_id,
- 'user_id' => $user_id
- ], 'warning');
- return [
- 'success' => false,
- 'message' => 'Seen record not found or not owned by user'
- ];
- }
+ if (substr($word, -1) === 'y') {
+ return substr($word, 0, -1) . 'ies';
+ }
- // Mark as read
- $result = $wpdb->update(
- $table,
- [
- 'status' => 'read',
- 'read_at' => current_time('mysql')
- ],
- ['id' => $seen_id]
- );
+ return $word . 's';
+ }
- if ($result !== false) {
- $wpdb->query('COMMIT');
- $this->clearNotificationCache($user_id);
- return [
- 'success' => true,
- 'message' => 'Successfully marked as seen'
- ];
- } else {
- $wpdb->query('ROLLBACK');
- $this->logError("Database error marking content notification as read", [
- 'seen_id' => $seen_id,
- 'user_id' => $user_id,
- 'db_error' => $wpdb->last_error
- ]);
- return [
- 'success' => false,
- 'message' => 'Something went wrong...'
- ];
- }
- } catch (Exception $e) {
- $wpdb->query('ROLLBACK');
- $this->logError("Exception marking content notification as read", [
- 'seen_id' => $seen_id,
- 'user_id' => $user_id,
- 'error' => $e->getMessage()
- ]);
- return [
- 'success' => false,
- 'message' => $e->getMessage()
- ];
- }
- }
+ /**
+ * Track notification metrics
+ */
+ protected function trackMetrics(int $notification_id, int $user_id, string $action, array $details = []): void
+ {
+ try {
+ $this->metrics->create([
+ 'notification_id' => $notification_id,
+ 'user_id' => $user_id,
+ 'action' => $action,
+ 'action_source' => 'web',
+ 'action_details' => !empty($details) ? json_encode($details) : null,
+ ]);
+ } catch (Exception $e) {
+ // Don't fail the request if metrics tracking fails
+ $this->logError('trackMetrics failed', [
+ 'error' => $e->getMessage(),
+ 'user_id' => $user_id,
+ 'action' => $action
+ ]);
+ }
+ }
+ /**
+ * Clear notification cache for a user
+ */
+ protected function clearUserCache(int $user_id): void
+ {
+ Cache::invalidateItem('notifications', $user_id);
+ }
- /**
- * Dismiss a content notification
- *
- * @param int $seen_id User seen record ID
- * @param int $user_id User ID making the request
- *
- * @return array Success or failure
- */
- public function dismissContentNotification( int $seen_id, int $user_id ):array
- {
- // Validate input parameters
- if (!$this->checkUser($seen_id)) {
- $this->logError("Invalid seen ID", [
- 'seen_id' => $seen_id,
- 'user_id' => $user_id
- ], 'warning');
- return [
- 'success' => false,
- 'message' => 'Invalid User ID'
- ];
- }
+ /**
+ * Update content notification status (helper for transactions)
+ */
+ private function updateContentNotificationStatus(string $table, int $seen_id, int $user_id, string $status): array
+ {
+ global $wpdb;
- if (!$this->checkUser($user_id)) {
- $this->logError("Invalid user ID", [
- 'seen_id' => $seen_id,
- 'user_id' => $user_id
- ], 'warning');
- return [
- 'success' => false,
- 'message' => 'Invalid User ID'
- ];
- }
- global $wpdb;
+ // Verify ownership with row locking
+ $seen_record = $wpdb->get_row(
+ $wpdb->prepare(
+ "SELECT * FROM {$table} WHERE id = %d AND user_id = %d FOR UPDATE",
+ $seen_id,
+ $user_id
+ )
+ );
- // Verify ownership
- $seen_record = $wpdb->get_row(
- $wpdb->prepare(
- "SELECT * FROM {$wpdb->prefix}{$this->base}notifications_user_seen
- WHERE id = %d AND user_id = %d",
- $seen_id,
- $user_id
- )
- );
+ if (!$seen_record) {
+ $wpdb->query('ROLLBACK');
+ return [
+ 'success' => false,
+ 'message' => 'Seen record not found or not owned by user'
+ ];
+ }
- if (!$seen_record) {
- return [
- 'success' => false,
- 'message' => 'No notification found'
- ];
- }
+ // Update status
+ $result = $wpdb->update(
+ $table,
+ [
+ 'status' => $status,
+ 'read_at' => current_time('mysql')
+ ],
+ ['id' => $seen_id]
+ );
- // Mark as dismissed
- $result = $wpdb->update(
- $wpdb->prefix . BASE . 'notifications_user_seen',
- [
- 'status' => 'dismissed'
- ],
- [ 'id' => $seen_id ]
- );
+ if ($result !== false) {
+ $wpdb->query('COMMIT');
+ $this->clearUserCache($user_id);
+ return [
+ 'success' => true,
+ 'message' => 'Successfully marked as ' . $status
+ ];
+ }
- if ($result) {
- $this->clearNotificationCache($user_id);
- }
+ $wpdb->query('ROLLBACK');
+ return [
+ 'success' => false,
+ 'message' => 'Database error'
+ ];
+ }
- return [
- 'success' => $result !== false,
- 'message' => 'Operation completed',
- ];
- }
+ /**
+ * Queued operation helpers (for backwards compatibility)
+ */
+ private function markReadQueued(int $notification_id, int $user_id): array
+ {
+ $updated = $this->notifications->update(
+ ['status' => 'read', 'read_at' => current_time('mysql')],
+ ['id' => $notification_id, 'owner_id' => $user_id]
+ );
- /**
- * Simple pluralization helper
- *
- * @param string $word Word to pluralize
- * @return string Pluralized word
- */
- protected function pluralize(string $word): string
- {
- $irregular = [
- 'tattoo' => 'tattoos',
- 'piercing' => 'piercings',
- 'artwork' => 'artwork',
- 'news' => 'news',
- 'offer' => 'offers',
- 'event' => 'events'
- ];
+ if ($updated) {
+ $this->clearUserCache($user_id);
+ }
- if (isset($irregular[$word])) {
- return $irregular[$word];
- }
+ return ['success' => $updated !== false];
+ }
- // Simple pluralization rules
- if (substr($word, -1) === 'y') {
- return substr($word, 0, -1) . 'ies';
- }
+ private function markAllReadQueued(array $data): array
+ {
+ $where = ['owner_id' => $data['user_id'], 'status' => 'unread'];
+ if (!empty($data['type'])) {
+ $where['type'] = $data['type'];
+ }
- return $word . 's';
- }
- /**
- * Track notification metrics for analytics
- *
- * @param int $notification_id Notification ID (0 for batch operations)
- * @param int $user_id User ID
- * @param string $action Action taken (read, dismiss, etc.)
- * @param array $details Additional details
- * @return bool Success or failure
- */
- protected function trackNotificationMetrics(int $notification_id, int $user_id, string $action, array $details = []): bool
- {
- global $wpdb;
- $metrics_table = $wpdb->prefix . BASE . 'notification_metrics';
+ $updated = $this->notifications
+ ->where($where)
+ ->updateResults(['status' => 'read', 'read_at' => current_time('mysql')]);
- try {
- return $wpdb->insert(
- $metrics_table,
- [
- 'notification_id' => $notification_id,
- 'user_id' => $user_id,
- 'action' => $action,
- 'action_source' => 'web',
- 'action_details' => json_encode($details),
- 'created_at' => current_time('mysql')
- ]
- ) !== false;
- } catch (Exception $e) {
- $this->logError("Failed to track notification metrics", [
- 'user_id' => $user_id,
- 'action' => $action,
- 'error' => $e->getMessage()
- ]);
- return false;
- }
- }
+ if ($updated) {
+ $this->clearUserCache($data['user_id']);
+ }
- /**
- * Clear notification cache for a user, for all notification types
- *
- * @param int $user_id User ID
- * @return void
- */
- protected function clearNotificationCache(int $user_id): void
- {
- // Clear regular notifications cache
- $this->cache->invalidate("user_{$user_id}_notifications_");
- $this->cache->invalidate("user_{$user_id}_regular_notifications_");
+ return ['success' => $updated !== false, 'count' => $updated];
+ }
- // Clear content notifications cache
- $this->cache->invalidate("user_{$user_id}_content_notifications_");
+ private function markDismissedQueued(int $notification_id, int $user_id): array
+ {
+ $updated = $this->notifications->update(
+ ['status' => 'dismissed', 'dismissed_at' => current_time('mysql')],
+ ['id' => $notification_id, 'owner_id' => $user_id]
+ );
- // Clear approval notifications cache
- $this->cache->invalidate("user_{$user_id}_approval_notifications_");
+ if ($updated) {
+ $this->clearUserCache($user_id);
+ }
- // Clear merged notifications cache
- $this->cache->invalidate("user_{$user_id}_merged_notifications_");
- }
+ return ['success' => $updated !== false];
+ }
}
diff --git a/inc/rest/routes/OptionsRoutes.php b/inc/rest/routes/OptionsRoutes.php
index a4a1ead..2bb2c2c 100644
--- a/inc/rest/routes/OptionsRoutes.php
+++ b/inc/rest/routes/OptionsRoutes.php
@@ -1,39 +1,38 @@
<?php
namespace JVBase\rest\routes;
-use JVBase\JVB;
-use JVBase\rest\RestRouteManager;
+use JVBase\rest\PermissionHandler;
+use JVBase\rest\Rest;
use JVBase\managers\Cache;
-use JVBase\meta\MetaManager;
-use JVBase\meta\MetaSanitizer;
+use JVBase\meta\Meta;
+use JVBase\rest\Route;
use WP_REST_Request;
use WP_REST_Response;
use WP_Error;
-use Exception;
if (!defined('ABSPATH')) {
exit; // Exit if accessed directly
}
-class OptionsRoutes extends RestRouteManager
+class OptionsRoutes extends Rest
{
public function __construct()
{
parent::__construct();
- $this->action = 'dash-';
add_filter(BASE.'handle_bulk_operation', [$this, 'processOperation'], 10, 3);
}
- public function registerRoutes()
+ public function registerRoutes():void
{
- register_rest_route($this->namespace, '/options', [
- [
- 'methods' => 'POST',
- 'callback' => [$this, 'saveOptions'],
- 'permission_callback' => [$this, 'checkPermission']
- ]
- ]);
+ Route::for('options')
+ ->post([$this, 'saveOptions'])
+ ->auth(PermissionHandler::combine(['user', ['actionNonce' => 'dash-']]))
+ ->rateLimit(3)
+ ->args([
+ 'user' => 'int|required',
+ 'id' => 'string|required',
+ ]);
}
public function saveOptions(WP_REST_Request $request):WP_REST_Response
@@ -41,16 +40,13 @@
$data = $request->get_params();
$user = $data['user'];
if ($user && !user_can($user, 'manage_options')) {
- return new WP_REST_Response([
- 'success' => 'false',
- 'message' => 'User cannot modify options'
- ]);
+ return $this->error('User Cannot modify options');
}
unset($data['user']);
$operationID = $data['id'];
unset($data['id']);
- $queue = JVB()->queue();
- $queue->queueOperation(
+
+ JVB()->queue()->queueOperation(
'update_options',
$user,
$data,
@@ -60,11 +56,7 @@
]
);
-
- return new WP_REST_Response([
- 'success' => 'true',
- 'message' => 'Probably'
- ]);
+ return $this->queued($operationID);
}
@@ -87,7 +79,7 @@
'success' => []
];
- $meta = new MetaManager(null, 'options');
+ $meta = Meta::forOptions('options');
$fields = jvbGetFields('options');
$allowedFields = array_filter($data,
diff --git a/inc/rest/routes/QueueRoutes.php b/inc/rest/routes/QueueRoutes.php
index 3654ae0..21fa98f 100644
--- a/inc/rest/routes/QueueRoutes.php
+++ b/inc/rest/routes/QueueRoutes.php
@@ -1,25 +1,24 @@
<?php
namespace JVBase\rest\routes;
-use Exception;
-use JVBase\JVB;
use JVBase\managers\Cache;
-use JVBase\rest\RestRouteManager;
+use JVBase\managers\queue\Operation;
+use JVBase\rest\Rest;
+use JVBase\rest\Route;
+use JVBase\rest\Response;
use WP_REST_Request;
use WP_REST_Response;
-use DateTime;
-use DateTimeZone;
if (!defined('ABSPATH')) {
exit; // Exit if accessed directly
}
-class QueueRoutes extends RestRouteManager
+class QueueRoutes extends Rest
{
public function __construct()
{
- $this->cache_name = 'queue';
- $this->cache_ttl = 300;
+ $this->cacheName = 'queue';
+ $this->cacheTtl = 300;
parent::__construct();
if (JVB_TESTING) {
@@ -33,52 +32,46 @@
*/
public function registerRoutes():void
{
- register_rest_route($this->namespace, '/queue', [
- [
- 'methods' => 'GET',
- 'callback' => [$this, 'getQueue'],
- 'permission_callback' => [$this, 'checkPermission'],
- 'args' => [
- 'status' => [
- 'type' => 'string',
- 'enum' => ['all', 'queued', 'pending', 'processing', 'completed', 'failed', 'failed_permanent'],
- 'default' => 'all'
- ],
- 'ids' => [
- 'required' => false,
- 'type' => 'string',
- 'description' => 'Comma-separated list of operation IDs'
- ],
- 'limit' => [
- 'type' => 'integer',
- 'default' => 50,
- 'minimum' => 1,
- 'maximum' => 100
- ]
- ]
- ],
- [
- 'methods' => 'POST',
- 'callback' => [$this, 'handleAction'],
- 'permission_callback' => [$this, 'checkPermission'],
- 'args' => [
- 'ids' => [
- 'required' => true,
- 'type' => 'array',
- 'items' => [
- 'type' => 'string'
- ],
- 'description' => 'Array of operation IDs (single or multiple)'
- ],
- 'action' => [
- 'required' => true,
- 'type' => 'string',
- 'enum' => ['dismiss', 'retry', 'cancel'],
- 'description' => 'Action to perform on the operations'
- ]
- ]
- ]
- ]);
+ // Main queue endpoint - GET and POST
+ Route::for('queue')
+ ->get([$this, 'getQueue'])
+ ->args([
+ 'status' => 'string|enum:all,queued,pending,processing,completed,failed,failed_permanent|default:all',
+ 'ids' => 'string',
+ 'limit' => 'integer|default:50|min:1|max:100',
+ ])
+ ->auth('user')
+ ->rateLimit(30)
+ ->post([$this, 'handleAction'])
+ ->args([
+ 'ids' => 'array|required',
+ 'action' => 'string|required|enum:dismiss,retry,cancel',
+ ])
+ ->auth('user')
+ ->rateLimit(30);
+
+ // Poll endpoint
+ Route::for('queue/poll')
+ ->get([$this, 'pollQueue'])
+ ->args([
+ 'since' => 'string',
+ 'ids' => 'string',
+ ])
+ ->auth('user')
+ ->rateLimit(15);
+
+ // Errors endpoint
+ Route::for('queue/errors')
+ ->get([$this, 'getOperationErrors'])
+ ->auth('user')
+ ->rateLimit(15);
+
+ // Single operation with dynamic ID
+ Route::for(Route::pattern('queue/{id}'))
+ ->get([$this, 'getOperation'])
+ ->arg('id', 'string|required')
+ ->auth('user')
+ ->rateLimit(15);
}
/**
@@ -89,93 +82,180 @@
*/
public function getQueue(WP_REST_Request $request): WP_REST_Response
{
- $user_id = $request->get_param('user');
- $status = sanitize_text_field($request->get_param('status'));
- $ids = $request->get_param('ids');
- $limit = intval($request->get_param('limit'));
- // Use base class user-specific header checking
- $key = $this->cache->generateKey(['user'=> $user_id, 'status'=> $status, 'ids'=> $ids, 'limit'=> $limit]);
- $cache_check = $this->checkHeaders($request, $key);
- if ($cache_check) {
- return $cache_check;
+ $params = $request->get_params();
+ $user_id = absint($params['user']);
+ $status = sanitize_text_field($params['status']);
+ $ids = array_map('trim', array_map('sanitize_text_field', explode(',', $params['ids'])));
+ $limit = absint($params['limit']);
+
+ $cacheKey = $this->cache->generateKey(compact('user_id', 'status', 'ids', 'limit'));
+ if ($cached = $this->checkHeaders($request, $cacheKey)) {
+ return $cached;
}
- // Build filters for getUserOperations
+ $data = $this->cache->remember($cacheKey, function() use ($user_id, $params) {
+ $filters = $this->buildFilters($params);
+ $operations = JVB()->queue()->getUserOperations($user_id, $filters);
+
+ return [
+ 'items' => array_map([$this, 'formatOperation'], $operations),
+ 'total' => count($operations),
+ 'queue_stats' => $this->getQueueStats($user_id),
+ 'server_time' => date('c'),
+ ];
+ });
+
+ $response = Response::success($data);
+ return $this->addCacheHeaders($response);
+ }
+
+ private function buildFilters(array $params): array
+ {
$filters = [
'not_dismissed' => true,
- 'limit' => $limit ?: 50,
+ 'limit' => min(absint($params['limit'] ?? 50), 100),
];
- if ($status && $status !== 'all') {
- $filters['state'] = $status;
+ if (!empty($params['status']) && $params['status'] !== 'all') {
+ $filters['state'] = $params['status'];
}
+ if (!empty($params['ids'])) {
+ $filters['ids'] = array_map('trim', array_map('sanitize_text_field', explode(',', $params['ids'])));
+ }
+
+ return $filters;
+ }
+
+ /**
+ * Update operation status (dismiss or retry)
+ *
+ * @param WP_REST_Request $request
+ * @return WP_REST_Response
+ */
+ public function handleAction(WP_REST_Request $request): WP_REST_Response
+ {
+ $data = $request->get_params();
+ $ids = array_map('trim', array_map('sanitize_text_field', explode(',', $data['ids'])));
+ $action = sanitize_text_field($data['action'] ?? '');
+ $user_id = absint($data['user']);
+
+ // Validate input
+ if (empty($ids)) {
+ return Response::validationError(['ids' => 'Missing or invalid operation IDs']);
+ }
+
+ if (!in_array($action, ['dismiss', 'retry', 'cancel'])) {
+ return Response::validationError(['action' => 'Invalid action. Must be: dismiss, retry, or cancel']);
+ }
+
+ // Get operations via Queue - verifies ownership
+ $operations = JVB()->queue()->getUserOperations($user_id, [
+ 'ids' => $ids,
+ 'limit' => count($ids),
+ ]);
+
+ if (empty($operations)) {
+ return Response::notFound('No valid operations found');
+ }
+
+ $result = $this->processAction($action, $operations, $user_id);
+
+ if ($result['success']) {
+ Cache::touch($user_id);
+ }
+
+ return Response::success($result);
+ }
+
+ public function pollQueue(WP_REST_Request $request): WP_REST_Response
+ {
+ $userId = $request->get_param('user');
+ $since = $request->get_param('since');
+ $ids = $request->get_param('ids');
+
+ $filters = ['not_dismissed' => true, 'limit' => 50];
+
if (!empty($ids)) {
$filters['ids'] = array_map('trim', explode(',', $ids));
}
- // Get operations via Queue
- $operations = JVB()->queue()->getUserOperations($user_id, $filters);
+ $operations = JVB()->queue()->getUserOperations($userId, $filters);
- // Format operations for API response
- $formatted = array_map([$this, 'formatOperationFromObject'], $operations);
+ if ($since) {
+ $sinceTime = strtotime($since);
+ $operations = array_filter($operations, function($op) use ($sinceTime) {
+ return strtotime($op->completedAt ?? $op->startedAt ?? $op->scheduledAt) > $sinceTime;
+ });
+ }
- $response = new WP_REST_Response([
- 'items' => $formatted,
- 'total' => count($formatted),
- 'timestamp' => date('c'),
- 'has_more' => count($formatted) === ($limit ?: 50),
- 'queue_stats' => $this->getQueueStats($user_id),
- 'server_time' => date('c')
+ $items = array_map(fn($op) => [
+ 'id' => $op->id,
+ 'status' => $this->mapStateToStatus($op->state, $op->outcome),
+ 'progress_count' => $op->processedItems,
+ 'count' => $op->totalItems,
+ 'updated_at' => $this->formatTimestamp($op->completedAt ?? $op->startedAt ?? $op->scheduledAt),
+ 'error_message' => $op->errorMessage,
+ ], $operations);
+
+ return Response::success([
+ 'items' => array_values($items),
+ 'server_time' => date('c'),
+ 'has_active' => count(array_filter($items, fn($i) => in_array($i['status'], ['pending', 'processing']))) > 0,
+ ]);
+ }
+
+ public function getOperationErrors(WP_REST_Request $request): WP_REST_Response
+ {
+ $user_id = absint($request->get_param('user'));
+ $operations = JVB()->queue()->getUserOperations($user_id, [
+ 'state' => 'completed',
+ 'outcome' => ['failed', 'failed_permanent', 'partial'],
+ 'has_errors' => true,
+ 'order_by' => 'updated_at DESC',
+ 'limit' => 20,
]);
- return $this->addCacheHeaders($response);
+ $formatted = array_map(fn($op) => [
+ 'id' => $op->id,
+ 'type' => $op->type,
+ 'error_message' => $op->errorMessage,
+ 'failed_items' => $op->failedItems ?? [],
+ 'retries' => $op->retries,
+ 'created_at' => $op->scheduledAt,
+ 'updated_at' => $op->completedAt,
+ ], $operations);
+
+ return Response::collection($formatted);
+ }
+
+ public function getOperation(WP_REST_Request $request): WP_REST_Response
+ {
+ $id = $request->get_param('id');
+ $userId = $request->get_param('user');
+
+ $op = JVB()->queue()->get($id);
+
+ if (!$op || $op->userId !== $userId) {
+ return Response::notFound('Operation not found');
+ }
+
+ return Response::item($this->formatOperation($op, true), 'operation');
}
/**
* Get queue statistics for user
*/
- protected function getQueueStats(int $user_id): array
+ private function getQueueStats(int $userId): array
{
- $stats = JVB()->queue()->getUserStats($user_id);
-
- // Add frontend-only statuses that don't exist in backend
- return array_merge([
- 'queued' => 0,
- 'localProcessing' => 0,
- 'uploading' => 0,
- ], $stats);
+ return array_merge(
+ ['queued' => 0, 'localProcessing' => 0, 'uploading' => 0],
+ JVB()->queue()->getUserStats($userId)
+ );
}
- /**
- * Map backend state/outcome to frontend status
- * Backend uses: state (pending, scheduled, processing, completed) + outcome (pending, success, partial, failed, failed_permanent)
- * Frontend expects: queued, pending, processing, completed, failed, failed_permanent
- */
- protected function mapStateToStatus(string $state, ?string $outcome): string
- {
- // If completed, check outcome for failure states
- if ($state === 'completed') {
- return match($outcome) {
- 'failed' => 'failed',
- 'failed_permanent' => 'failed_permanent',
- 'partial' => 'completed', // or could be 'partial' if JS supports it
- default => 'completed'
- };
- }
-
- // Map other states directly
- return match($state) {
- 'scheduled' => 'pending',
- default => $state
- };
- }
-
- /**
- * Format Operation object for API response
- */
- protected function formatOperationFromObject(\JVBase\managers\queue\Operation $op): array
+ private function formatOperation(Operation $op, bool $full = false): array
{
$formatted = [
'id' => $op->id,
@@ -183,35 +263,55 @@
'status' => $this->mapStateToStatus($op->state, $op->outcome),
'progress_count' => $op->processedItems,
'count' => $op->totalItems,
- 'retries' => $op->retries,
- 'data' => $op->requestData,
- 'result' => $op->result ?? [],
+ 'title' => $this->getOperationTitle($op->type, $op->requestData),
+ 'created_at' => $this->formatTimestamp($op->scheduledAt),
+ 'updated_at' => $this->formatTimestamp($op->completedAt ?? $op->startedAt ?? $op->scheduledAt),
];
- $formatted['created_at'] = $this->formatTimestamp($op->scheduledAt);
- $formatted['updated_at'] = $this->formatTimestamp($op->completedAt ?? $op->startedAt ?? $op->scheduledAt);
-
- if ($op->state === 'completed' && $op->completedAt) {
- $formatted['completed_at'] = $this->formatTimestamp($op->completedAt);
+ if ($op->processedItems > 0 && $op->totalItems > 0) {
+ $formatted['progress_percentage'] = round(($op->processedItems / $op->totalItems) * 100);
}
if ($op->errorMessage) {
$formatted['error_message'] = $op->errorMessage;
}
- if ($formatted['count'] > 0) {
- $formatted['progress_percentage'] = round(
- ($formatted['progress_count'] / $formatted['count']) * 100
- );
- }
+ if ($full) {
+ $formatted += [
+ 'data' => $op->requestData,
+ 'result' => $op->result ?? [],
+ 'retries' => $op->retries,
+ 'user_dismissed' => $op->userDismissed,
+ ];
- $formatted['title'] = $this->getOperationTitle($op->type, $op->requestData);
- $formatted['user_dismissed'] = $op->userDismissed;
+ if ($op->state === 'completed' && $op->completedAt) {
+ $formatted['completed_at'] = $this->formatTimestamp($op->completedAt);
+ }
+ }
return $formatted;
}
/**
+ * Map backend state/outcome to frontend status
+ * Backend uses: state (pending, scheduled, processing, completed) + outcome (pending, success, partial, failed, failed_permanent)
+ * Frontend expects: queued, pending, processing, completed, failed, failed_permanent
+ */
+ private function mapStateToStatus(string $state, ?string $outcome): string
+ {
+ if ($state === 'completed') {
+ return match ($outcome) {
+ 'failed' => 'failed',
+ 'failed_permanent' => 'failed_permanent',
+ default => 'completed',
+ };
+ }
+
+ return $state === 'scheduled' ? 'pending' : $state;
+ }
+
+
+ /**
* Get human-readable operation title
*/
protected function getOperationTitle(string $type, array $data): string
@@ -242,164 +342,39 @@
return $base_title;
}
- /**
- * Update operation status (dismiss or retry)
- *
- * @param WP_REST_Request $request
- * @return WP_REST_Response
- */
- public function handleAction(WP_REST_Request $request): WP_REST_Response
- {
- $data = $request->get_json_params();
- $ids = $data['ids'] ?? [];
- $action = $data['action'] ?? '';
- $user_id = (int)$data['user'];
- // Validate input
- if (empty($ids) || !is_array($ids)) {
- return new WP_REST_Response([
- 'success' => false,
- 'message' => 'Missing or invalid operation IDs'
- ], 400);
- }
- if (!in_array($action, ['dismiss', 'retry', 'cancel'])) {
- return new WP_REST_Response([
- 'success' => false,
- 'message' => 'Invalid action. Must be: dismiss, retry, or cancel'
- ], 400);
- }
-
- // Get operations via Queue - verifies ownership
- $operations = JVB()->queue()->getUserOperations($user_id, [
- 'ids' => $ids,
- 'limit' => count($ids),
- ]);
-
- if (empty($operations)) {
- return new WP_REST_Response([
- 'success' => false,
- 'message' => 'No valid operations found'
- ], 404);
- }
-
- $result = $this->processQueueAction($action, $operations, $user_id);
-
- if ($result['success']) {
- Cache::touch($user_id);
- }
-
- return new WP_REST_Response($result);
- }
-
- protected function processQueueAction(string $action, array $operations, int $user_id): array
+ private function processAction(string $action, array $operations, int $userId): array
{
$queue = JVB()->queue();
- $processed_count = 0;
- $errors = [];
- $valid_ids = [];
+ $processed = 0;
+ $processedIds = [];
foreach ($operations as $op) {
- try {
- $result = match($action) {
- 'dismiss' => $queue->dismiss($op->id),
- 'retry' => $queue->retry($op->id, $user_id),
- 'cancel' => $queue->cancel($op->id, $user_id),
- default => false,
- };
+ $result = match ($action) {
+ 'dismiss' => $queue->dismiss($op->id),
+ 'retry' => $queue->retry($op->id, $userId),
+ 'cancel' => $queue->cancel($op->id, $userId),
+ default => false,
+ };
- if ($result) {
- $processed_count++;
- $valid_ids[] = $op->id;
- } else {
- // Only add errors for meaningful failures
- if ($action === 'retry' && ($op->state !== 'completed' || !in_array($op->outcome, ['failed', 'failed_permanent']))) {
- $errors[] = "Operation {$op->id} cannot be retried (state: {$op->state}, outcome: {$op->outcome})";
- }
- // Silently skip cancel failures (can't cancel processing/completed)
- }
- } catch (Exception $e) {
- $errors[] = "Error processing operation {$op->id}: " . $e->getMessage();
+ if ($result) {
+ $processed++;
+ $processedIds[] = $op->id;
}
}
- $message = $this->getActionMessage($action, $processed_count);
- if (!empty($errors)) {
- $message .= ". Errors: " . implode(', ', $errors);
- }
+ $pastTense = ['dismiss' => 'dismissed', 'retry' => 'retried', 'cancel' => 'cancelled'];
return [
- 'success' => $processed_count > 0,
+ 'success' => $processed > 0,
'action' => $action,
- 'processed_count' => $processed_count,
+ 'processed_count' => $processed,
'total_requested' => count($operations),
- 'processed_ids' => $valid_ids,
- 'errors' => $errors,
- 'message' => $message
- ];
- }
-
- /**
- * Get user-friendly message for action results
- */
- protected function getActionMessage(string $action, int $count): string
- {
- if ($count === 0) {
- return "No operations were {$action}ed";
- }
-
- $past_tense = [
- 'dismiss' => 'dismissed',
- 'retry' => 'retried',
- 'cancel' => 'cancelled'
- ];
-
- return "{$count} operation" . ($count === 1 ? '' : 's') . " {$past_tense[$action]}";
- }
-
- public function getOperationErrors(WP_REST_Request $request): WP_REST_Response
- {
- $user_id = get_current_user_id();
-
- $operations = JVB()->queue()->getUserOperations($user_id, [
- 'state' => 'completed',
- 'outcome' => ['failed', 'failed_permanent', 'partial'],
- 'has_errors' => true,
- 'order_by' => 'updated_at DESC',
- 'limit' => 20,
- ]);
-
- $formatted = array_map(function($op) {
- return [
- 'id' => $op->id,
- 'type' => $op->type,
- 'error_message' => $op->errorMessage,
- 'failed_items' => $op->failedItems ?? [],
- 'retries' => $op->retries,
- 'created_at' => $op->scheduledAt,
- 'updated_at' => $op->completedAt,
- 'error_details' => $this->parseErrorMessage($op->errorMessage ?? ''),
- ];
- }, $operations);
-
- return new WP_REST_Response([
- 'errors' => $formatted,
- 'total' => count($formatted)
- ]);
- }
- protected function parseErrorMessage(string $error_message): array
- {
- if (str_contains($error_message, ' | ')) {
- $parts = explode(' | ', $error_message);
- return [
- 'original_error' => $parts[0] ?? '',
- 'cleanup_reason' => $parts[1] ?? ''
- ];
- }
-
- return [
- 'original_error' => $error_message,
- 'cleanup_reason' => null
+ 'processed_ids' => $processedIds,
+ 'message' => $processed > 0
+ ? "{$processed} operation(s) {$pastTense[$action]}"
+ : "No operations were {$pastTense[$action]}",
];
}
}
diff --git a/inc/rest/routes/ReferralRoutes.php b/inc/rest/routes/ReferralRoutes.php
index 6f920c8..965dca1 100644
--- a/inc/rest/routes/ReferralRoutes.php
+++ b/inc/rest/routes/ReferralRoutes.php
@@ -2,9 +2,10 @@
namespace JVBase\rest\routes;
use JVBase\importers\JaneAppClientImporter;
-use JVBase\managers\JaneSalesImporter;
-use JVBase\managers\MagicLinkManager;
-use JVBase\rest\RestRouteManager;
+use JVBase\importers\JaneAppSalesImporter;
+use JVBase\managers\CustomTable;
+use JVBase\rest\Rest;
+use JVBase\rest\Route;
use WP_REST_Request;
use WP_REST_Response;
use WP_Error;
@@ -16,130 +17,82 @@
/**
* REST API routes for referral system
*/
-class ReferralRoutes extends RestRouteManager
+class ReferralRoutes extends Rest
{
- protected string $referrals_table;
- protected string $rewards_table;
- protected $wpdb;
+ protected CustomTable $referrals;
+ protected CustomTable $rewards;
+ protected CustomTable $treatments;
public function __construct()
{
- $this->route = 'referrals';
- $this->cache_name = 'referrals';
+ $this->cacheName = 'referrals';
+ $this->cacheTtl = HOUR_IN_SECONDS;
parent::__construct();
- global $wpdb;
- $this->wpdb = $wpdb;
- $this->referrals_table = $wpdb->prefix . BASE . 'referrals';
- $this->rewards_table = $wpdb->prefix . BASE . 'referral_rewards';
+ $this->referrals = CustomTable::for('referrals');
+ $this->rewards = CustomTable::for('referral_rewards');
+ $this->treatments = CustomTable::for('referral_treatments');
add_filter(BASE.'handle_bulk_operation', [$this, 'processOperation'], 10, 3);
}
public function registerRoutes(): void
{
- /**
- * Main referrals endpoint
- * GET: List referrals with filters
- * POST: Perform actions (invite, consulted, treated, remove, resend)
- */
- register_rest_route($this->namespace, "/{$this->route}", [
- [
- 'methods' => 'GET',
- 'callback' => [$this, 'getReferrals'],
- 'permission_callback' => [$this, 'checkPermission'],
- 'args' => [
- 'user' => ['type' => 'integer', 'sanitize_callback' => 'absint'],
- 'status' => ['type' => 'string', 'enum' => ['all', 'pending', 'consulted', 'treated', 'unused', 'registered', 'completed']],
- 'date_start' => ['type' => 'string'],
- 'date_end' => ['type' => 'string'],
- 'limit' => ['type' => 'integer', 'default' => 50],
- 'offset' => ['type' => 'integer', 'default' => 0],
- 'format' => ['type' => 'string', 'enum' => ['simple', 'formatted'], 'default' => 'formatted'],
- 'search' => ['type' => 'string']
- ]
- ],
- [
- 'methods' => 'POST',
- 'callback' => [$this, 'handleAction'],
- 'permission_callback' => [$this, 'checkPermission'],
- 'args' => [
- 'action' => [
- 'required' => true,
- 'type' => 'string',
- 'enum' => ['invite', 'consulted', 'treated', 'remove', 'resend']
- ]
- ]
- ]
- ]);
+ // Main referrals endpoint - list and manage referrals
+ Route::for('referrals')
+ ->get([$this, 'getReferrals'])
+ ->args([
+ 'user' => 'integer',
+ 'status' => 'string|enum:all,pending,consulted,treated,unused,registered,completed|default:all',
+ 'date_start' => 'string',
+ 'date_end' => 'string',
+ 'limit' => 'integer|default:50',
+ 'offset' => 'integer|default:0',
+ 'search' => 'string'
+ ])
+ ->rateLimit()
+ ->post([$this, 'handleAction'])
+ ->args([
+ 'action' => 'string|required|enum:invite,consulted,treated,remove,resend'
+ ])
+ ->auth('user')
+ ->rateLimit(10);
- /**
- * Referral code endpoint
- * GET: Get user's referral code
- * POST: Validate a referral code
- */
- register_rest_route($this->namespace, "/{$this->route}/code", [
- [
- 'methods' => 'GET',
- 'callback' => [$this, 'getCode'],
- 'permission_callback' => [$this, 'checkPermission'],
- 'args' => [
- 'user' => ['type' => 'integer', 'sanitize_callback' => 'absint']
- ]
- ],
- [
- 'methods' => 'POST',
- 'callback' => [$this, 'validateCode'],
- 'permission_callback' => '__return_true', // Public endpoint
- 'args' => [
- 'code' => ['required' => true, 'type' => 'string']
- ]
- ]
- ]);
+ // Referral code endpoint
+ Route::for('referrals/code')
+ ->get([$this, 'getCode'])
+ ->args(['user' => 'integer'])
+ ->auth('user')
+ ->rateLimit(30)
+ ->post([$this, 'validateCode'])
+ ->args(['code' => 'string|required'])
+ ->auth('public')
+ ->rateLimit(10);
- /**
- * Stats endpoint
- * GET: Get user's referral statistics
- */
- register_rest_route($this->namespace, "/{$this->route}/stats", [
- 'methods' => 'GET',
- 'callback' => [$this, 'getStats'],
- 'permission_callback' => [$this, 'checkPermission'],
- 'args' => [
- 'user' => ['type' => 'integer', 'sanitize_callback' => 'absint'],
- ]
- ]);
+ // Stats endpoint
+ Route::for('referrals/stats')
+ ->get([$this, 'getStats'])
+ ->args(['user' => 'integer'])
+ ->auth('user')
+ ->rateLimit(30);
- /**
- * Settings endpoint (admin only)
- */
- register_rest_route($this->namespace, "/{$this->route}/settings", [
- [
- 'methods' => 'GET',
- 'callback' => [$this, 'getSettings'],
- 'permission_callback' => [$this, 'checkAdminPermission']
- ],
- [
- 'methods' => 'POST',
- 'callback' => [$this, 'updateSettings'],
- 'permission_callback' => [$this, 'checkAdminPermission']
- ]
- ]);
+ // Settings endpoint (admin only)
+ Route::for('referrals/settings')
+ ->get([$this, 'getSettings'])
+ ->post([$this, 'updateSettings'])
+ ->auth('admin')
+ ->rateLimit(10);
- /**
- * CSV Upload endpoints (admin only)
- */
- register_rest_route($this->namespace, "/{$this->route}/upload-clients", [
- 'methods' => 'POST',
- 'callback' => [$this, 'handleClientUpload'],
- 'permission_callback' => [$this, 'checkAdminPermission']
- ]);
+ // CSV Upload endpoints (admin only)
+ Route::for('referrals/upload-clients')
+ ->post([$this, 'handleClientUpload'])
+ ->auth('admin')
+ ->rateLimit(3);
- register_rest_route($this->namespace, "/{$this->route}/upload-sales", [
- 'methods' => 'POST',
- 'callback' => [$this, 'handleSalesUpload'],
- 'permission_callback' => [$this, 'checkAdminPermission']
- ]);
+ Route::for('referrals/upload-sales')
+ ->post([$this, 'handleSalesUpload'])
+ ->auth('admin')
+ ->rateLimit(3);
}
/**
@@ -152,19 +105,16 @@
{
$user_id = $request->get_param('user');
- // Determine scope
+ // Determine scope: admin without user param gets all referrals
if (!$user_id) {
$current_user_id = get_current_user_id();
- $is_admin = current_user_can('manage_options');
- if ($is_admin) {
- // Admin with no user param = get all referrals
+ if (current_user_can('manage_options')) {
return $this->getAllReferrals($request);
}
$user_id = $current_user_id;
}
-
- // Get user's referrals
+ // Build cache key
$args = [
'status' => $request->get_param('status') ?? 'all',
'limit' => $request->get_param('limit') ?? 50,
@@ -172,14 +122,15 @@
'date_start' => $request->get_param('date_start'),
'date_end' => $request->get_param('date_end'),
];
+ $cache_key = "user_{$user_id}_" . $this->cache->generateKey($args);
- $cache_key = "ref_{$user_id}_" . md5(serialize($args));
- // Check headers for 304 Not Modified
+ // Check 304 Not Modified
$cache_check = $this->checkHeaders($request, $cache_key);
if ($cache_check instanceof WP_REST_Response) {
- return $cache_check; // Returns 304 if not modified
+ return $cache_check;
}
+ // Get referrals from manager
$referrals = JVB()->referrals()->getUserReferrals($user_id, $args);
$data = [
@@ -187,10 +138,7 @@
'total' => count($referrals)
];
- // Create response with cache headers
$response = $this->success($data);
-
- // Add ETag and Last-Modified headers
return $this->addCacheHeaders($response, $cache_key, $data);
}
@@ -217,51 +165,53 @@
*/
protected function actionInvite(WP_REST_Request $request): WP_REST_Response
{
- $data = $request->get_params();
- error_log('Send Referral Invitations:'.print_r($data, true));
$user = absint($request->get_param('user'));
- if (!$this->checkUser($user)) {
- return new WP_REST_Response([
- 'success' => false,
- 'message' => 'No user found'
- ]);
+ if (!$user || !get_userdata($user)) {
+ return $this->error('Invalid user', 'invalid_user', 400);
}
+
+ //Additional check to not send too many emails in an hour
+ $user = absint($request->get_param('user'));
+ $transient_key = "referral_invite_limit_{$user}";
+ $recent_invites = get_transient($transient_key) ?: 0;
+
+ if ($recent_invites >= 20) { // Max 5 batch invites per hour
+ return $this->error('Too many invitations sent. Please try again later.', 'rate_limit', 429);
+ }
+ set_transient($transient_key, $recent_invites + 1, HOUR_IN_SECONDS);
+
$subject = sanitize_text_field($request->get_param('subject'));
$message = sanitize_textarea_field($request->get_param('message'));
$invitations = $request->get_param('invite');
- // Validate invitation format
- foreach ($invitations as $key => $invite) {
- if (!array_key_exists('name', $invite) || !array_key_exists('email', $invite)) {
- unset($invitations[$key]);
- } else {
- $temp = [
- 'name' => sanitize_text_field($invite['name']),
- 'email' => sanitize_email($invite['email'])
+ // Validate and sanitize invitations
+ $sanitized_invitations = [];
+ foreach ($invitations as $invite) {
+ if (isset($invite['name'], $invite['email'])) {
+ $sanitized_invitations[] = [
+ 'name' => sanitize_text_field($invite['name']),
+ 'email' => sanitize_email($invite['email'])
];
- $invitations[$key] = $temp;
}
}
+ if (empty($sanitized_invitations)) {
+ return $this->error('No valid invitations provided', 'no_invitations', 400);
+ }
+
$operationID = sanitize_text_field($request->get_param('id'));
- $operation = JVB()->queue()->queueOperation(
+ JVB()->queue()->queueOperation(
'referral_invite',
$user,
[
'subject' => $subject,
'message' => $message,
- 'invitations' => $invitations
+ 'invitations' => $sanitized_invitations
],
- [
- 'operation_id' => $operationID
- ]
+ ['operation_id' => $operationID]
);
- return new WP_REST_Response([
- 'success' => true,
- 'message' => 'Queued for Processing',
- 'operation' => $operationID
- ]);
+ return $this->queued($operationID, 'Referral invitations queued');
}
/**
@@ -270,7 +220,7 @@
protected function actionUpdateStatus(WP_REST_Request $request, string $status): WP_REST_Response
{
if (!current_user_can('manage_options')) {
- return $this->error('Admin permission required', 'unauthorized', 403);
+ return $this->forbidden('Admin permission required');
}
$referral_id = $request->get_param('referral_id');
@@ -278,13 +228,10 @@
return $this->error('referral_id required', 'missing_id', 400);
}
- $referral = $this->wpdb->get_row($this->wpdb->prepare(
- "SELECT * FROM {$this->referrals_table} WHERE id = %d",
- $referral_id
- ));
-
+ // Get referral using CustomTable
+ $referral = $this->referrals->get(['id' => $referral_id]);
if (!$referral) {
- return $this->error('Referral not found', 'not_found', 404);
+ return $this->notFound('Referral not found');
}
// Update status
@@ -295,26 +242,19 @@
$update_data['treatment_count'] = ($referral->treatment_count ?? 0) + 1;
}
- $updated = $this->wpdb->update(
- $this->referrals_table,
- $update_data,
- ['id' => $referral_id],
- array_fill(0, count($update_data), '%s'),
- ['%d']
- );
+ $updated = $this->referrals->update($update_data, ['id' => $referral_id]);
-
-
- if ($updated) {
- // Also create rewards if treated
+ if ($updated !== false) {
+ // Create rewards if treated
if ($status === 'treated') {
$this->createRewards($referral);
}
+
+ $this->cache->flush();
+ return $this->success(['message' => "Referral marked as {$status}"]);
}
- $this->cache->flush();
-
- return $this->success(['message' => "Referral marked as {$status}"]);
+ return $this->error('Failed to update referral', 'update_failed', 500);
}
/**
@@ -327,19 +267,16 @@
return $this->error('referral_id required', 'missing_id', 400);
}
- $referral = $this->wpdb->get_row($this->wpdb->prepare(
- "SELECT * FROM {$this->referrals_table} WHERE id = %d",
- $referral_id
- ));
-
+ // Get referral
+ $referral = $this->referrals->get(['id' => $referral_id]);
if (!$referral) {
- return $this->error('Referral not found', 'not_found', 404);
+ return $this->notFound('Referral not found');
}
// Check ownership
$current_user_id = get_current_user_id();
if ($referral->referrer_id != $current_user_id && !current_user_can('manage_options')) {
- return $this->error('Unauthorized', 'unauthorized', 403);
+ return $this->forbidden('Unauthorized');
}
// Can only remove pending referrals
@@ -347,7 +284,7 @@
return $this->error('Can only remove pending referrals', 'invalid_status', 400);
}
- $this->wpdb->delete($this->referrals_table, ['id' => $referral_id], ['%d']);
+ $this->referrals->delete(['id' => $referral_id]);
$this->cache->flush();
return $this->success(['message' => 'Referral removed']);
@@ -364,14 +301,15 @@
}
$current_user_id = get_current_user_id();
- $referral = $this->wpdb->get_row($this->wpdb->prepare(
- "SELECT * FROM {$this->referrals_table} WHERE id = %d AND referrer_id = %d",
- $referral_id,
- $current_user_id
- ));
+
+ // Get referral with ownership check
+ $referral = $this->referrals->where([
+ 'id' => $referral_id,
+ 'referrer_id' => $current_user_id
+ ])->first();
if (!$referral) {
- return $this->error('Referral not found', 'not_found', 404);
+ return $this->notFound('Referral not found');
}
// Check rate limit (once per week)
@@ -399,9 +337,7 @@
return $this->error($result->get_error_message(), 'send_failed', 500);
}
- // Set rate limit
set_transient($transient_key, time(), WEEK_IN_SECONDS);
-
return $this->success(['message' => 'Invitation resent']);
}
@@ -415,7 +351,7 @@
// Check permission
if ($user_id != get_current_user_id() && !current_user_can('manage_options')) {
- return $this->error('Unauthorized', 'unauthorized', 403);
+ return $this->forbidden('Unauthorized');
}
$code = JVB()->referrals()->getUserReferralCode($user_id);
@@ -466,11 +402,10 @@
*/
public function getStats(WP_REST_Request $request): WP_REST_Response
{
- $user_id = $request->get_param('user');
-
+ $user_id = $request->get_param('user') ?? get_current_user_id();
$cache_key = "stats_{$user_id}";
- // Check for 304 Not Modified
+ // Check 304 Not Modified
$cache_check = $this->checkHeaders($request, $cache_key);
if ($cache_check instanceof WP_REST_Response) {
return $cache_check;
@@ -479,8 +414,6 @@
$stats = JVB()->referrals()->getUserStats($user_id);
$response = $this->success(['items' => [$stats]]);
-
- // Add cache headers (5 minutes for stats)
return $this->addCacheHeaders($response, $cache_key, $stats, 5 * MINUTE_IN_SECONDS);
}
@@ -521,9 +454,12 @@
*/
protected function getAllReferrals(WP_REST_Request $request): WP_REST_Response
{
+ global $wpdb;
+
$where = ['1=1'];
$where_params = [];
+ // Build WHERE conditions
$status = $request->get_param('status');
if ($status && $status !== 'all') {
$where[] = 'status = %s';
@@ -542,10 +478,8 @@
$search = $request->get_param('search');
if (!empty($search)) {
- $search_term = '%' . $this->wpdb->esc_like($search) . '%';
- $where[] = '(r.referee_name LIKE %s OR r.referee_email LIKE %s OR r.referral_code LIKE %s OR u.display_name LIKE %s OR ru.display_name LIKE %s OR ru.user_email LIKE %s)';
- $where_params[] = $search_term;
- $where_params[] = $search_term;
+ $search_term = '%' . $wpdb->esc_like($search) . '%';
+ $where[] = '(r.referee_name LIKE %s OR r.referee_email LIKE %s OR r.referral_code LIKE %s OR u.display_name LIKE %s)';
$where_params[] = $search_term;
$where_params[] = $search_term;
$where_params[] = $search_term;
@@ -558,16 +492,16 @@
$where_params[] = $limit;
$where_params[] = $offset;
+ // Use CustomTable's query method
$query = "SELECT r.*, u.display_name as referrer_name
- FROM {$this->referrals_table} r
- LEFT JOIN {$this->wpdb->users} u ON r.referrer_id = u.ID
+ FROM {table} r
+ LEFT JOIN {$wpdb->users} u ON r.referrer_id = u.ID
WHERE " . implode(' AND ', $where) . "
ORDER BY referred_at DESC
LIMIT %d OFFSET %d";
- $items = $this->wpdb->get_results($this->wpdb->prepare($query, $where_params));
+ $items = $this->referrals->queryResults($query, $where_params);
- error_log('All Referrals result: '.print_r($items, true));
return $this->success([
'items' => $items,
'total' => count($items)
@@ -582,47 +516,29 @@
$settings = JVB()->referrals()->getRewardSettings();
// Referrer reward
- $this->wpdb->insert(
- $this->rewards_table,
- [
- 'referral_id' => $referral->id,
- 'user_id' => $referral->referrer_id,
- 'reward_type' => 'referrer',
- 'amount' => $settings['referrer_reward_amount'],
- 'reward_calculation' => $settings['referrer_reward_type'],
- 'status' => 'available',
- 'created_at' => current_time('mysql')
- ],
- ['%d', '%d', '%s', '%f', '%s', '%s', '%s']
- );
+ $this->rewards->insert([
+ 'referral_id' => $referral->id,
+ 'user_id' => $referral->referrer_id,
+ 'reward_type' => 'referrer',
+ 'amount' => $settings['referrer_reward_amount'],
+ 'reward_calculation' => $settings['referrer_reward_type'],
+ 'status' => 'available'
+ ]);
// Referee reward
if ($referral->referee_id) {
- $this->wpdb->insert(
- $this->rewards_table,
- [
- 'referral_id' => $referral->id,
- 'user_id' => $referral->referee_id,
- 'reward_type' => 'referee',
- 'amount' => $settings['referee_reward_amount'],
- 'reward_calculation' => $settings['referee_reward_type'],
- 'status' => 'available',
- 'created_at' => current_time('mysql')
- ],
- ['%d', '%d', '%s', '%f', '%s', '%s', '%s']
- );
+ $this->rewards->insert([
+ 'referral_id' => $referral->id,
+ 'user_id' => $referral->referee_id,
+ 'reward_type' => 'referee',
+ 'amount' => $settings['referee_reward_amount'],
+ 'reward_calculation' => $settings['referee_reward_type'],
+ 'status' => 'available'
+ ]);
}
}
/**
- * Check admin permission
- */
- public function checkAdminPermission(WP_REST_Request $request): bool
- {
- return current_user_can('manage_options') && parent::checkPermission($request);
- }
-
- /**
* Process queued referral operations
*/
public function processOperation(WP_Error|array $result, object $operation, array $data): array|WP_Error
@@ -637,22 +553,22 @@
$data['subject'],
$data['message']
);
+
if ($result['success']) {
$this->cache->flush();
}
- // Build summary message
- $textResult = 'Sent invitations. ';
- $textResult .= 'Success: ' . count($result['result']['success']) . '. ';
- $textResult .= 'Failed: ' . count($result['result']['failed']) . '.';
-
return [
- 'success' => true,
- 'message' => $textResult,
- 'details' => [
+ 'success' => true,
+ 'message' => sprintf(
+ 'Sent invitations. Success: %d. Failed: %d.',
+ count($result['result']['success']),
+ count($result['result']['failed'])
+ ),
+ 'details' => [
'successful' => $result['result']['success'],
- 'failed' => $result['result']['failed'],
- 'total' => count($data['invitations'])
+ 'failed' => $result['result']['failed'],
+ 'total' => count($data['invitations'])
]
];
}
@@ -662,22 +578,14 @@
*/
public function handleClientUpload(WP_REST_Request $request): WP_REST_Response
{
- // Access files from $_FILES directly for REST API uploads
if (empty($_FILES['file'])) {
- return new WP_REST_Response([
- 'success' => false,
- 'message' => 'No file uploaded'
- ], 400);
+ return $this->error('No file uploaded', 'no_file', 400);
}
$file = $_FILES['file'];
- // Check for upload errors
if ($file['error'] !== UPLOAD_ERR_OK) {
- return new WP_REST_Response([
- 'success' => false,
- 'message' => 'File upload error: ' . $file['error']
- ], 400);
+ return $this->error('File upload error: ' . $file['error'], 'upload_error', 400);
}
// Validate file type
@@ -687,18 +595,12 @@
finfo_close($finfo);
if (!in_array($mime_type, $allowed_types) && !in_array($file['type'], $allowed_types)) {
- return new WP_REST_Response([
- 'success' => false,
- 'message' => 'File must be a CSV'
- ], 400);
+ return $this->error('File must be a CSV', 'invalid_file_type', 400);
}
// Validate file size (10MB max)
if ($file['size'] > 10 * 1024 * 1024) {
- return new WP_REST_Response([
- 'success' => false,
- 'message' => 'File size exceeds 10MB limit'
- ], 400);
+ return $this->error('File size exceeds 10MB limit', 'file_too_large', 400);
}
// Import using JaneAppClientImporter
@@ -715,33 +617,20 @@
$result = $importer->importFromCSV($file['tmp_name'], $options);
if (is_wp_error($result)) {
- return new WP_REST_Response([
- 'success' => false,
- 'message' => $result->get_error_message()
- ], 500);
+ return $this->error($result->get_error_message(), 'import_failed', 500);
}
- // Build detailed message
- $message = sprintf(
- 'Import complete: %d created, %d updated, %d skipped',
- $result['created'],
- $result['updated'],
- $result['skipped']
- );
-
- $details = [];
- if (!empty($result['skipped_details'])) {
- $details = $result['skipped_details'];
- }
-
- // Clear cache
$this->cache->flush();
- return new WP_REST_Response([
- 'success' => true,
- 'message' => $message,
+ return $this->success([
+ 'message' => sprintf(
+ 'Import complete: %d created, %d updated, %d skipped',
+ $result['created'],
+ $result['updated'],
+ $result['skipped']
+ ),
'stats' => $result,
- 'skipped_details' => $details
+ 'skipped_details' => $result['skipped_details'] ?? []
]);
}
@@ -750,22 +639,14 @@
*/
public function handleSalesUpload(WP_REST_Request $request): WP_REST_Response
{
- // Access files from $_FILES directly for REST API uploads
if (empty($_FILES['file'])) {
- return new WP_REST_Response([
- 'success' => false,
- 'message' => 'No file uploaded'
- ], 400);
+ return $this->error('No file uploaded', 'no_file', 400);
}
$file = $_FILES['file'];
- // Check for upload errors
if ($file['error'] !== UPLOAD_ERR_OK) {
- return new WP_REST_Response([
- 'success' => false,
- 'message' => 'File upload error: ' . $file['error']
- ], 400);
+ return $this->error('File upload error: ' . $file['error'], 'upload_error', 400);
}
// Validate file type
@@ -775,40 +656,25 @@
finfo_close($finfo);
if (!in_array($mime_type, $allowed_types) && !in_array($file['type'], $allowed_types)) {
- return new WP_REST_Response([
- 'success' => false,
- 'message' => 'File must be a CSV'
- ], 400);
+ return $this->error('File must be a CSV', 'invalid_file_type', 400);
}
// Validate file size (10MB max)
if ($file['size'] > 10 * 1024 * 1024) {
- return new WP_REST_Response([
- 'success' => false,
- 'message' => 'File size exceeds 10MB limit'
- ], 400);
+ return $this->error('File size exceeds 10MB limit', 'file_too_large', 400);
}
- // Import using JaneSalesImporter
- $importer = new JaneSalesImporter();
- $options = [
- 'skip_existing' => true
- ];
-
- $result = $importer->importFromCSV($file['tmp_name'], $options);
+ // Import using JaneAppSalesImporter
+ $importer = new JaneAppSalesImporter();
+ $result = $importer->importFromCSV($file['tmp_name'], ['skip_existing' => true]);
if (is_wp_error($result)) {
- return new WP_REST_Response([
- 'success' => false,
- 'message' => $result->get_error_message()
- ], 500);
+ return $this->error($result->get_error_message(), 'import_failed', 500);
}
- // Clear cache
$this->cache->flush();
- return new WP_REST_Response([
- 'success' => true,
+ return $this->success([
'message' => 'Sales imported successfully',
'stats' => $result
]);
diff --git a/inc/rest/routes/ResponseRoutes.php b/inc/rest/routes/ResponseRoutes.php
index 31cbd9d..00c15a3 100644
--- a/inc/rest/routes/ResponseRoutes.php
+++ b/inc/rest/routes/ResponseRoutes.php
@@ -2,714 +2,510 @@
namespace JVBase\rest\routes;
use JVBase\JVB;
-use JVBase\rest\RestRouteManager;
-use JVBase\managers\Cache;
+use JVBase\rest\Rest;
+use JVBase\managers\CustomTable;
use WP_REST_Request;
use WP_REST_Response;
use WP_Error;
if (!defined('ABSPATH')) {
- exit; // Exit if accessed directly
+ exit;
}
-class ResponseRoutes extends RestRouteManager
+
+/**
+ * Response Routes
+ *
+ * Handles threaded responses/comments for news items
+ */
+class ResponseRoutes extends Rest
{
- protected int $per_page;
- protected false|object $manager = false;
+ protected int $perPage = 20;
+ protected CustomTable $table;
+ protected CustomTable $karmaTable;
- public function __construct()
- {
- $this->cache_name = 'responses';
- parent::__construct();
- $this->action = 'dash-';
- $this->per_page = 20;
+ public function __construct()
+ {
+ $this->cacheName = 'responses';
+ $this->cacheTtl = 1800; // 30 minutes
+ parent::__construct();
- add_filter(BASE.'handle_bulk_operation', [$this, 'processOperation'], 10, 3);
- add_action('deleted_user', [$this, 'handleUserDeletion'], 10, 1);
- }
+ $this->table = CustomTable::for('responses');
+ $this->karmaTable = CustomTable::for('karma_response');
- /**
- * Registers response routes
- * @return void
- */
- public function registerRoutes():void
- {
- register_rest_route($this->namespace, '/response', [
- [
- 'methods' => 'GET',
- 'callback' => [$this, 'getResponses'],
- 'permission_callback' => [$this, 'checkPermission']
- ],
- [
- 'methods' => 'POST',
- 'callback' => [$this, 'handleResponseActions'],
- 'permission_callback' => [$this, 'checkPermission']
- ]
- ]);
- }
+ add_filter(BASE.'handle_bulk_operation', [$this, 'processOperation'], 10, 3);
+ add_action('deleted_user', [$this, 'handleUserDeletion'], 10, 1);
+ }
- /**
- * Get responses for a news post
- * @param WP_REST_Request $request
- * @return WP_REST_Response
- */
- public function getResponses(WP_REST_Request $request):WP_REST_Response
- {
- $item_id = (int) $request->get_param('item_id');
- error_log('Item ID: '.print_r($item_id, true));
- if (!$item_id) {
- return new WP_REST_Response([
- 'success'=> false,
- 'message' => 'Missing item ID'
- ]);
- }
+ public function registerRoutes(): void
+ {
+ register_rest_route($this->namespace, '/response', [
+ [
+ 'methods' => 'GET',
+ 'callback' => [$this, 'getResponses'],
+ 'permission_callback' => 'is_user_logged_in'
+ ],
+ [
+ 'methods' => 'POST',
+ 'callback' => [$this, 'handleResponseActions'],
+ 'permission_callback' => 'is_user_logged_in'
+ ]
+ ]);
+ }
- // Build query args
- $args = $this->buildQueryArgs($request);
- error_log('Args: '.print_r($args, true));
- $responses = $this->getItemResponse($item_id, $args);
- error_log('Responses: '.print_r($responses, true));
+ /**
+ * Get responses for an item
+ */
+ public function getResponses(WP_REST_Request $request): WP_REST_Response
+ {
+ $item_id = (int) $request->get_param('item_id');
- // Return formatted response
- return new WP_REST_Response($responses);
- }
+ if (!$item_id) {
+ return $this->error('Missing item ID', 'missing_item_id');
+ }
- /**
- * @param int $ID
- * @param string $postType
- *
- * @return void
- */
- protected function clearItemCache(int $ID, string $postType):void
- {
- $args = [
+ // Build query args
+ $args = $this->buildQueryArgs($request);
+ $cacheKey = $this->cache->generateKey(array_merge(['item_id' => $item_id], $args));
- BASE.'news' => $ID,
- 'post_type' => BASE.$postType,
- 'page' => 1,
- 'per_page' => 20,
- 'orderby' => 'created_at',
- 'order' => 'DESC'
- ];
- $key = $this->cache->generateKey($args);
- $this->cache->invalidate($key);
- }
+ // Check headers for 304 Not Modified
+ $headerCheck = $this->checkHeaders($request, $cacheKey);
+ if ($headerCheck) {
+ return $headerCheck;
+ }
- /**
- * @param int $ID
- * @param array $args
- *
- * @return array|WP_Error
- */
- public function getItemResponse(int $ID, array $args = []):array|WP_Error
- {
+ // Check cache
+ $cached = $this->cache->get($cacheKey);
+ if ($cached) {
+ return $this->success($cached);
+ }
- $default = [
- 'post_type' => BASE.'news',
- 'page' => 1,
- 'per_page' => 20,
- 'orderby' => 'created_at',
- 'order' => 'DESC'
- ];
+ // Get responses
+ $responses = $this->getItemResponse($item_id, $args);
- $args = wp_parse_args($default, $args);
+ if (is_wp_error($responses)) {
+ return $this->notFound($responses->get_error_message());
+ }
- $key = $this->cache->generateKey(array_merge([$args['post_type'] => $ID], $args));
- $check = $this->cache->get($key);
+ // Cache and return
+ $this->cache->set($cacheKey, $responses);
+ return $this->success($responses);
+ }
- if ($check) {
- return $check;
- }
+ /**
+ * Handle response actions (create/update/delete)
+ */
+ public function handleResponseActions(WP_REST_Request $request): WP_REST_Response
+ {
+ $data = $request->get_params();
+ $user_id = (int) ($data['user'] ?? 0);
- // Verify post exists and is of correct type
- $post = get_post($ID);
- if (!$post || $post->post_type !== $args['post_type']) {
- return new WP_Error(
- self::ERROR_NOT_FOUND,
- 'Item not found',
- ['status' => 404]
- );
- }
+ if (!$this->userCheck($user_id)) {
+ return $this->unauthorized('User verification failed');
+ }
- // Execute the query
- global $wpdb;
- $table = $wpdb->prefix . BASE . 'responses';
+ $operation_id = $data['id'] ?? uniqid('response_');
+ $action = sanitize_text_field($data['action'] ?? '');
- // Get total count
- $total_query = "SELECT COUNT(*) FROM $table WHERE item_id = %d";
- $total_args = [$ID];
+ if (!in_array($action, ['create', 'delete', 'update'])) {
+ return $this->error('Invalid action', 'invalid_action');
+ }
- // Add parent filter if specified
- if (isset($args['parent_id'])) {
- $total_query .= " AND parent_id " . ($args['parent_id'] === null ? "IS NULL" : "= %d");
- if ($args['parent_id'] !== null) {
- $total_args[] = $args['parent_id'];
- }
- }
+ // Validate required fields for create
+ if ($action === 'create') {
+ if (!isset($data['item_id'], $data['response'], $data['content'])) {
+ return $this->error('Missing required fields', 'missing_fields');
+ }
+ }
- $total_items = $wpdb->get_var($wpdb->prepare($total_query, $total_args));
+ // Prepare data for queue
+ $queue_data = match($action) {
+ 'create' => [
+ 'item_id' => (int) $data['item_id'],
+ 'parent_id' => isset($data['parent_id']) ? (int) $data['parent_id'] : null,
+ 'response' => wp_kses_post($data['response']),
+ 'content' => sanitize_text_field($data['content'])
+ ],
+ 'update' => [
+ 'response_id' => (int) $data['response_id'],
+ 'response' => isset($data['response']) ? wp_kses_post($data['response']) : null,
+ 'status' => isset($data['status']) && in_array($data['status'], ['published', 'hidden', 'flagged'])
+ ? $data['status']
+ : null
+ ],
+ 'delete' => [
+ 'response_id' => (int) $data['response_id']
+ ]
+ };
- // Build main query
- $query = "SELECT r.*,
- COALESCE((SELECT COUNT(*) FROM $table WHERE parent_id = r.id), 0) as reply_count,
- COALESCE((SELECT SUM(CASE WHEN vote = 'up' THEN 1 ELSE 0 END) FROM {$wpdb->prefix}" . BASE . "karma_response WHERE item_id = r.id), 0) as upvotes,
- COALESCE((SELECT SUM(CASE WHEN vote = 'down' THEN 1 ELSE 0 END) FROM {$wpdb->prefix}" . BASE . "karma_response WHERE item_id = r.id), 0) as downvotes
- FROM $table r
- WHERE r.item_id = %d";
+ // Queue the operation
+ JVB()->queue()->queueOperation(
+ $action . '_response',
+ $user_id,
+ $queue_data,
+ [
+ 'operation_id' => 'u' . $user_id . '_' . $operation_id,
+ 'priority' => 'high'
+ ]
+ );
- $query_args = [$ID];
+ return $this->queued($operation_id);
+ }
- // Apply parent filter
- if (isset($args['parent_id'])) {
- $query .= " AND r.parent_id " . ($args['parent_id'] === null ? "IS NULL" : "= %d");
- if ($args['parent_id'] !== null) {
- $query_args[] = $args['parent_id'];
- }
- }
+ /**
+ * Get responses with karma calculations
+ */
+ protected function getItemResponse(int $item_id, array $args = []): array|WP_Error
+ {
+ $defaults = [
+ 'page' => 1,
+ 'per_page' => $this->perPage,
+ 'orderby' => 'created_at',
+ 'order' => 'DESC',
+ 'parent_id' => null
+ ];
- // Apply order
- $order_column = in_array($args['orderby'], ['created_at', 'updated_at'])
- ? "r." . $args['orderby']
- : $args['orderby'];
+ $args = wp_parse_args($args, $defaults);
- $query .= " ORDER BY $order_column " . $args['order'];
+ // Verify post exists
+ $post = get_post($item_id);
+ if (!$post) {
+ return new WP_Error('not_found', 'Item not found');
+ }
- // Apply pagination
- $query .= " LIMIT %d OFFSET %d";
- $query_args[] = $args['per_page'];
- $query_args[] = ($args['page'] - 1) * $args['per_page'];
+ // Build query with karma calculations
+ $query = "
+ SELECT r.*,
+ COALESCE((SELECT COUNT(*) FROM {$this->table->getFullTableName()} WHERE parent_id = r.id), 0) as reply_count,
+ COALESCE((SELECT COUNT(*) FROM {$this->karmaTable->getFullTableName()} WHERE item_id = r.id AND vote = 'up'), 0) as upvotes,
+ COALESCE((SELECT COUNT(*) FROM {$this->karmaTable->getFullTableName()} WHERE item_id = r.id AND vote = 'down'), 0) as downvotes
+ FROM {$this->table->getFullTableName()} r
+ WHERE r.item_id = %d
+ ";
- // Get responses
- $responses = $wpdb->get_results($wpdb->prepare($query, $query_args));
+ $query_args = [$item_id];
- // Format responses
- $items = array_map([$this, 'formatItem'], $responses);
+ // Filter by parent_id
+ if (isset($args['parent_id'])) {
+ $query .= " AND r.parent_id " . ($args['parent_id'] === null ? "IS NULL" : "= %d");
+ if ($args['parent_id'] !== null) {
+ $query_args[] = $args['parent_id'];
+ }
+ }
- // Calculate pagination
- $total_pages = ceil($total_items / $args['per_page']);
+ // Get total count for pagination
+ $count_query = str_replace(
+ ['SELECT r.*,', 'COALESCE((SELECT COUNT(*) FROM', 'as reply_count,', 'as upvotes,', 'as downvotes'],
+ ['SELECT COUNT(*)', '', '', '', ''],
+ $query
+ );
+ $count_query = preg_replace('/COALESCE\([^)]+\)[^,]*,?/', '', $count_query);
+ $total_items = (int) $this->table->queryVar($count_query, $query_args);
- $return = [
- 'items' => $items,
- 'has_more' => $args['page'] < $total_pages,
- 'total_items' => (int) $total_items,
- 'total_pages' => $total_pages
- ];
+ // Add ordering
+ $order_column = in_array($args['orderby'], ['created_at', 'updated_at'])
+ ? "r.{$args['orderby']}"
+ : $args['orderby'];
- $this->cache->set($key, $return);
- return $return;
- }
+ $query .= " ORDER BY {$order_column} {$args['order']}";
- /**
- * Create a new response
- * @param WP_REST_Request $request
- * @return WP_REST_Response
- */
- public function handleResponseActions(WP_REST_Request $request):WP_REST_Response
- {
- $data = $request->get_params();
- $user_id = (int)$data['user'];
- if (!$this->userCheck($data['user'])) {
- return new WP_REST_Response([
- 'success' => false,
- 'message' => 'User doesn\'t match. Bot?'
- ]);
- }
- $operation_id = $data['id'] ?? uniqid('response_');
+ // Add pagination
+ $query .= " LIMIT %d OFFSET %d";
+ $query_args[] = $args['per_page'];
+ $query_args[] = ($args['page'] - 1) * $args['per_page'];
+ // Get responses
+ $responses = $this->table->queryResults($query, $query_args);
- // Validate required fields
- if (!array_key_exists('item_id', $data) || !array_key_exists('response', $data) || !array_key_exists('content', $data)) {
- error_log('Not enough data');
- return new WP_REST_Response([
- 'success' => false,
- 'message' => 'Missing required information.'
- ]);
- }
+ // Format responses
+ $items = array_map([$this, 'formatItem'], $responses);
- // Prepare data for queue
- $queue_data = [
- 'item_id' => (int) $data['item_id'],
- 'parent_id' => array_key_exists('parent_id', $data) ? (int) $data['parent_id'] : null,
- 'response' => wp_kses_post($data['response']),
- 'content'=> sanitize_text_field($data['content'])
- ];
+ return [
+ 'items' => $items,
+ 'has_more' => $args['page'] < ceil($total_items / $args['per_page']),
+ 'total_items' => $total_items,
+ 'total_pages' => (int) ceil($total_items / $args['per_page'])
+ ];
+ }
- error_log('Queue Data: '.print_r($queue_data, true));
+ /**
+ * Process queue operations
+ */
+ public function processOperation(WP_Error|array $result, object $operation, array $data): WP_Error|array
+ {
+ if (!in_array($operation->type, ['create_response', 'update_response', 'delete_response'])) {
+ return $result;
+ }
- $action = sanitize_text_field($data['action']);
- error_log('Action: '.print_r($action, true));
- if (!in_array($action, ['create', 'delete', 'update'])) {
- return new WP_REST_Response([
- 'success' => false,
- 'message' => 'Invalid action'
- ]);
- }
- error_log('Sanitized action. Here we go!');
+ return match($operation->type) {
+ 'create_response' => $this->createResponse($operation, $data),
+ 'update_response' => $this->updateResponse($data),
+ 'delete_response' => $this->deleteResponse($data),
+ default => $result
+ };
+ }
- // Add to queue
- $operation = JVB()->queue()->queueOperation(
- $action.'_response',
- $user_id,
- $queue_data,
- [
- 'operation_id' => 'u' . $user_id . '_' . $operation_id,
- 'priority' => 'high'
- ]
- );
- error_log('Queued for processing');
+ /**
+ * Create a new response
+ */
+ protected function createResponse(object $operation, array $data): array
+ {
+ $response_id = $this->table->insert([
+ 'item_id' => $data['item_id'],
+ 'content' => $data['content'],
+ 'user_id' => $operation->user_id,
+ 'parent_id' => $data['parent_id'],
+ 'response' => $data['response'],
+ 'status' => 'published'
+ ]);
- return new WP_REST_Response([
- 'success' => true,
- 'message' => 'Item queued for processing'
- ]);
- }
+ if (!$response_id) {
+ $this->logError('Failed to insert response', [
+ 'data' => $data,
+ 'error' => $this->table->getLastError()
+ ]);
- /**
- * Update a response
- * @param WP_REST_Request $request
- * @return WP_REST_Response
- */
- public function updateResponse(WP_REST_Request $request):WP_REST_Response
- {
- $id = (int) $request->get_param('id');
- $data = $request->get_params();
- $user_id = (int) $data['user'] ?? get_current_user_id();
- $operation_id = $data['id'] ?? uniqid('response_update_');
+ return ['success' => false, 'result' => 'Failed to create response'];
+ }
+ // Send notifications
+ $this->sendNotifications($data['item_id'], $data['parent_id'], $response_id, $operation->user_id);
- // Verify response exists
- global $wpdb;
- $table = $wpdb->prefix . BASE . 'responses';
- $response = $wpdb->get_row($wpdb->prepare("SELECT * FROM $table WHERE id = %d", $id));
+ // Clear cache
+ $this->cache->flush();
- if (!$response) {
- return new WP_REST_Response([
- 'success' => false,
- 'message' => 'Item not found.'
- ]);
- }
+ return ['success' => true, 'result' => $response_id];
+ }
- // Check ownership or admin rights
- if ($response->user_id != $user_id) {
- return new WP_REST_Response([
- 'success' => false,
- 'message' => 'You do not have permission to delete this response'
- ]);
- }
+ /**
+ * Update a response
+ */
+ protected function updateResponse(array $data): array
+ {
+ $update_data = [];
- // Prepare data for queue
- $queue_data = [
- 'response_id' => $id,
- 'response' => !empty($data['response']) ? wp_kses_post($data['response']) : null,
- 'status' => !empty($data['status']) && in_array($data['status'], ['published', 'hidden', 'flagged'])
- ? $data['status']
- : null
- ];
+ if (isset($data['response'])) {
+ $update_data['response'] = $data['response'];
+ $update_data['updated_at'] = current_time('mysql');
+ }
- // Add to queue
- $operation = JVB()->queue()->queueOperation(
- 'update_response',
- $user_id,
- $queue_data,
- [
- 'operation_id' => 'u' . $user_id . '_' . $operation_id,
- 'priority' => 'high',
- 'notification' => false,
- ]
- );
+ if (isset($data['status'])) {
+ $update_data['status'] = $data['status'];
+ }
- return new WP_REST_Response($operation);
- }
+ if (empty($update_data)) {
+ return ['success' => false, 'result' => 'No fields to update'];
+ }
- /**
- * Delete a response
- * @param WP_REST_Request $request
- * @return WP_REST_Response
- */
- public function deleteResponse(WP_REST_Request $request):WP_REST_Response
- {
- $id = (int) $request->get_param('id');
- $user_id = get_current_user_id();
- $operation_id = $request->get_param('id') ?? uniqid('response_delete_');
+ $updated = $this->table->update(
+ $update_data,
+ ['id' => $data['response_id']]
+ );
- // Verify response exists
- global $wpdb;
- $table = $wpdb->prefix . BASE . 'responses';
- $response = $wpdb->get_row($wpdb->prepare("SELECT * FROM $table WHERE id = %d", $id));
+ if ($updated === false) {
+ $this->logError('Failed to update response', [
+ 'data' => $data,
+ 'error' => $this->table->getLastError()
+ ]);
- if (!$response) {
- return new WP_REST_Response([
- 'success' => false,
- 'message' => 'Response not found',
- ]);
- }
-
- // Check ownership or admin rights
- if ($response->user_id != $user_id) {
- return new WP_REST_Response([
- 'success' => false,
- 'msesage' => 'You do not have permission to delete this resposne',
- ]);
- }
-
- // Add to queue
- $operation = JVB()->queue()->queueOperation(
- 'delete_response',
- $user_id,
- ['response_id' => $id],
- [
- 'operation_id' => 'u' . $user_id . '_' . $operation_id,
- 'priority' => 'high',
- 'notification' => false,
- ]
- );
-
- return new WP_REST_Response([
- 'success' => true,
- 'message' => 'Queued for processing'
- ]);
- }
-
- /**
- * Process operations from the queue
- * @param WP_Error|array $result
- * @param object $operation
- * @param array $data
- * @return WP_Error|array
- */
- public function processOperation(WP_Error|array $result, object $operation, array $data):WP_Error|array
- {
- if (!in_array($operation->type, ['create_response', 'update_response', 'delete_response'])) {
- return $result;
- }
-
- global $wpdb;
- $table = $wpdb->prefix . BASE . 'responses';
-
- switch ($operation->type) {
- case 'create_response':
- // Create new response
- $inserted = $wpdb->insert(
- $table,
- [
- 'item_id' => $data['item_id'],
- 'content' => $data['content'],
- 'user_id' => $operation->user_id,
- 'parent_id' => $data['parent_id'],
- 'response' => $data['response'],
- 'status' => 'published',
- 'created_at' => current_time('mysql'),
- 'updated_at' => current_time('mysql')
- ],
- ['%d', '%d', '%d', '%s', '%s', '%s', '%s']
- );
-
- if (!$inserted) {
- error_log('Did not insert'.print_r($wpdb->last_error, true));
- JVB()->error()->log(
- '[ResponseRoutes]:processOperation',
- 'Failed to insert response',
- ['data' => $data, 'error' => $wpdb->last_error],
- 'error'
- );
- return [
- 'success' => false,
- 'result' => 'Failed to create response'
- ];
- }
-
- $response_id = $wpdb->insert_id;
- error_log('Response ID: '.print_r($response_id, true));
-
- // Send notification to post author
- $post = get_post($data['item_id']);
- if ($post && $post->post_author != $operation->user_id) {
- JVB()->notification()->addNotification(
- $post->post_author,
- 'new_response',
- [
- 'message' => 'Someone responded to your post',
- 'item_id' => $data['item_id'],
- 'response_id' => $response_id
- ]
- );
- }
-
- // Send notification to parent response author if this is a reply
- if ($data['parent_id']) {
- $parent = $wpdb->get_row($wpdb->prepare(
- "SELECT user_id FROM $table WHERE id = %d",
- $data['parent_id']
- ));
-
- if ($parent && $parent->user_id != $operation->user_id) {
- JVB()->notification()->addNotification(
- $parent->user_id,
- 'response_reply',
- [
- 'message' => 'Someone replied to your response',
- 'item_id' => $data['item_id'],
- 'response_id' => $response_id
- ]
- );
- }
- }
-
- $this->cache->forget($data['item_id']);
- return ['success' => true, 'result' => $response_id];
-
- case 'update_response':
- $update_data = [];
- $update_format = [];
-
- if (array_key_exists('response', $data)) {
- $update_data['response'] = $data['response'];
- $update_data['updated_at'] = current_time('mysql');
- $update_format[] = '%s';
- $update_format[] = '%s';
- }
-
- if (array_key_exists('status', $data)) {
- $update_data['status'] = $data['status'];
- $update_format[] = '%s';
- }
-
- if (empty($update_data)) {
- return ['success' => false, 'result' => 'No fields to update'];
- }
+ return ['success' => false, 'result' => 'Failed to update response'];
+ }
- $updated = $wpdb->update(
- $table,
- $update_data,
- ['id' => $data['response_id']],
- $update_format,
- ['%d']
- );
+ $this->cache->flush();
+ return ['success' => true, 'result' => $updated];
+ }
- if ($updated === false) {
- JVB()->error()->log(
- '[ResponseRoutes]:processOperation',
- 'Failed to update response',
- ['data' => $data, 'error' => $wpdb->last_error],
- 'error'
- );
- return [
- 'success' => false,
- 'result' => 'Failed to update response'
- ];
- }
+ /**
+ * Delete a response (or mark as deleted if it has replies)
+ */
+ protected function deleteResponse(array $data): array
+ {
+ $response = $this->table->get(['id' => $data['response_id']]);
- $this->cache->forget($data['item_id']);
- $this->cache->flush();
- return ['success' => true, 'result' => $updated];
+ if (!$response) {
+ return ['success' => false, 'result' => 'Response not found'];
+ }
- case 'delete_response':
- // Get response info before deleting
- $response = $wpdb->get_row($wpdb->prepare(
- "SELECT * FROM $table WHERE id = %d",
- $data['response_id']
- ));
+ // Check if it has replies
+ $has_replies = $this->table->where(['parent_id' => $data['response_id']])->countResults() > 0;
- if (!$response) {
- return ['success' => false, 'result' => 'Response not found'];
- }
+ if ($has_replies) {
+ // Don't delete, just mark as deleted
+ $updated = $this->table->update(
+ [
+ 'response' => '[ deleted ]',
+ 'status' => 'deleted',
+ 'updated_at' => current_time('mysql')
+ ],
+ ['id' => $data['response_id']]
+ );
- // Check if this response has replies
- $has_replies = $wpdb->get_var($wpdb->prepare(
- "SELECT COUNT(*) FROM $table WHERE parent_id = %d",
- $data['response_id']
- )) > 0;
+ $this->cache->flush();
+ return ['success' => true, 'result' => $updated];
+ }
- if ($has_replies) {
- // Don't delete, just mark as deleted and replace content
- $updated = $wpdb->update(
- $table,
- [
- 'response' => '[ deleted ]',
- 'status' => 'deleted',
- 'updated_at' => current_time('mysql')
- ],
- ['id' => $data['response_id']],
- ['%s', '%s', '%s'],
- ['%d']
- );
- $this->cache->flush();
- return ['success' => true, 'result' => $updated ];
- } else {
- // No replies, safe to actually delete
- $deleted = $wpdb->delete(
- $table,
- ['id' => $data['response_id']],
- ['%d']
- );
+ // No replies, safe to delete
+ $deleted = $this->table->delete(['id' => $data['response_id']]);
- if ($deleted === false) {
- JVB()->error()->log(
- '[ResponseRoutes]:processOperation',
- 'Failed to delete response',
- ['data' => $data, 'error' => $wpdb->last_error],
- 'error'
- );
- return [
- 'success' => false,
- 'result' => 'Failed to delete response'
- ];
- }
+ if ($deleted === false) {
+ $this->logError('Failed to delete response', [
+ 'data' => $data,
+ 'error' => $this->table->getLastError()
+ ]);
- $this->cache->forget($data['item_id']);
- $this->cache->flush();
- return ['success' => true, 'result' => $deleted];
- }
- }
+ return ['success' => false, 'result' => 'Failed to delete response'];
+ }
+ $this->cache->flush();
+ return ['success' => true, 'result' => $deleted];
+ }
- return $result;
- }
+ /**
+ * Send notifications for new responses
+ */
+ protected function sendNotifications(int $item_id, ?int $parent_id, int $response_id, int $user_id): void
+ {
+ // Notify post author
+ $post = get_post($item_id);
+ if ($post && $post->post_author != $user_id) {
+ JVB()->notification()->addNotification(
+ $post->post_author,
+ 'new_response',
+ [
+ 'message' => 'Someone responded to your post',
+ 'item_id' => $item_id,
+ 'response_id' => $response_id
+ ]
+ );
+ }
- /**
- * Build query arguments from request parameters
- * @param WP_REST_Request $request
- * @return array
- */
- protected function buildQueryArgs(WP_REST_Request $request):array
- {
- $page = max(1, (int) $request->get_param('page') ?? 1);
- $per_page = min(100, max(1, (int) $request->get_param('per_page') ?? $this->per_page));
+ // Notify parent response author if this is a reply
+ if ($parent_id) {
+ $parent = $this->table->get(['id' => $parent_id]);
- $args = [
- 'page' => $page,
- 'per_page' => $per_page,
- 'orderby' => 'created_at',
- 'order' => 'DESC'
- ];
+ if ($parent && $parent->user_id != $user_id) {
+ JVB()->notification()->addNotification(
+ $parent->user_id,
+ 'response_reply',
+ [
+ 'message' => 'Someone replied to your response',
+ 'item_id' => $item_id,
+ 'response_id' => $response_id
+ ]
+ );
+ }
+ }
+ }
- // Apply parent filter (null means top-level responses)
- if ($request->has_param('parent_id')) {
- $parent_id = $request->get_param('parent_id');
- $args['parent_id'] = $parent_id === '0' ? null : (int) $parent_id;
- }
+ /**
+ * Build query arguments from request
+ */
+ protected function buildQueryArgs(WP_REST_Request $request): array
+ {
+ $page = max(1, (int) ($request->get_param('page') ?? 1));
+ $per_page = min(100, max(1, (int) ($request->get_param('per_page') ?? $this->perPage)));
- // Apply ordering
- if ($request->has_param('orderby')) {
- $orderby = $request->get_param('orderby');
- $valid_orderby = ['created_at', 'updated_at', 'upvotes', 'downvotes'];
+ $args = [
+ 'page' => $page,
+ 'per_page' => $per_page,
+ 'orderby' => 'created_at',
+ 'order' => 'DESC'
+ ];
- if (in_array($orderby, $valid_orderby)) {
- $args['orderby'] = $orderby;
- }
- }
+ // Parent filter (null = top-level only)
+ if ($request->has_param('parent_id')) {
+ $parent_id = $request->get_param('parent_id');
+ $args['parent_id'] = $parent_id === '0' ? null : (int) $parent_id;
+ }
- if ($request->has_param('order')) {
- $order = strtoupper($request->get_param('order'));
- if (in_array($order, ['ASC', 'DESC'])) {
- $args['order'] = $order;
- }
- }
+ // Ordering
+ if ($request->has_param('orderby')) {
+ $orderby = $request->get_param('orderby');
+ $valid = ['created_at', 'updated_at', 'upvotes', 'downvotes'];
- return $args;
- }
+ if (in_array($orderby, $valid)) {
+ $args['orderby'] = $orderby;
+ }
+ }
- /**
- * @param int $itemID
- * @param int $ID
- *
- * @return array
- */
- protected function getChildren(int $itemID, int $ID):array
- {
- return $this->getItemResponse($itemID, ['parent_id' => $ID]);
- }
- /**
- * Format a response object for API output
- * @param object $response
- * @return array
- */
- protected function formatItem(object $response):array
- {
- if ($response->is_user_deleted) {
- // For deleted users, show anonymous info
- $formatted = [
- 'id' => (int) $response->id,
- 'item_id' => (int) $response->item_id,
- 'parent_id' => $response->parent_id ? (int) $response->parent_id : null,
- 'response' => $response->response,
- 'status' => $response->status,
- 'created_at' => $response->created_at,
- 'updated_at' => $response->updated_at,
- 'children' => $this->getChildren($response->item_id, $response->id),
- 'user' => [
- 'id' => null,
- 'name' => '[deleted user]',
- 'is_deleted' => true
- ],
- 'upvotes' => (int) ($response->upvotes ?? 0),
- 'downvotes' => (int) ($response->downvotes ?? 0),
- 'karma' => (int) ($response->upvotes ?? 0) - (int) ($response->downvotes ?? 0)
- ];
- } else {
- // Normal user processing as before
- error_log('Response: '.print_r($response, true));
- $artist = jvbContentFromUser($response->user_id);
+ if ($request->has_param('order')) {
+ $order = strtoupper($request->get_param('order'));
+ if (in_array($order, ['ASC', 'DESC'])) {
+ $args['order'] = $order;
+ }
+ }
- $formatted = [
- 'id' => (int) $response->id,
- 'item_id' => (int) $response->item_id,
- 'parent_id' => $response->parent_id ? (int) $response->parent_id : null,
- 'response' => $response->response,
- 'status' => $response->status,
- 'created_at' => $response->created_at,
- 'updated_at' => $response->updated_at,
- 'children' => $this->getChildren($response->item_id, $response->id),
- 'upvotes' => (int) ($response->upvotes ?? 0),
- 'downvotes' => (int) ($response->downvotes ?? 0),
- 'karma' => (int) ($response->upvotes ?? 0) - (int) ($response->downvotes ?? 0)
+ return $args;
+ }
- ];
+ /**
+ * Get child responses recursively
+ */
+ protected function getChildren(int $item_id, int $parent_id): array
+ {
+ return $this->getItemResponse($item_id, ['parent_id' => $parent_id]);
+ }
- // Add artist info if available
- if (!empty($artist)) {
- $formatted['artist'] = [
- 'name' => $artist['name'],
- 'url' => $artist['url'],
- 'shop' => $artist['shop'] ?? null
- ];
- }
- }
+ /**
+ * Format response for API output
+ */
+ protected function formatItem(object $response): array
+ {
+ $formatted = [
+ 'id' => (int) $response->id,
+ 'item_id' => (int) $response->item_id,
+ 'parent_id' => $response->parent_id ? (int) $response->parent_id : null,
+ 'response' => $response->response,
+ 'status' => $response->status,
+ 'created_at' => $response->created_at,
+ 'updated_at' => $response->updated_at,
+ 'children' => $this->getChildren($response->item_id, $response->id),
+ 'upvotes' => (int) ($response->upvotes ?? 0),
+ 'downvotes' => (int) ($response->downvotes ?? 0),
+ 'karma' => (int) ($response->upvotes ?? 0) - (int) ($response->downvotes ?? 0),
+ 'reply_count' => (int) ($response->reply_count ?? 0)
+ ];
- // Add reply count if available (for both deleted and non-deleted users)
- if (isset($response->reply_count)) {
- $formatted['reply_count'] = (int) $response->reply_count;
- }
+ // Handle deleted users
+ if ($response->is_user_deleted) {
+ $formatted['user'] = [
+ 'id' => null,
+ 'name' => '[deleted user]',
+ 'is_deleted' => true
+ ];
+ } else {
+ // Add artist info
+ $artist = jvbContentFromUser($response->user_id);
+ if (!empty($artist)) {
+ $formatted['artist'] = [
+ 'name' => $artist['name'],
+ 'url' => $artist['url'],
+ 'shop' => $artist['shop'] ?? null
+ ];
+ }
+ }
- return $formatted;
- }
+ return $formatted;
+ }
- /**
- * Handle user deletion by anonymizing their responses
- * @param int $user_id The deleted user ID
- */
- public function handleUserDeletion(int $user_id):void
- {
- global $wpdb;
- $table = $wpdb->prefix . BASE . 'news_responses';
+ /**
+ * Handle user deletion by anonymizing responses
+ */
+ public function handleUserDeletion(int $user_id): void
+ {
+ $updated = $this->table->update(
+ [
+ 'is_user_deleted' => 1,
+ 'updated_at' => current_time('mysql')
+ ],
+ ['user_id' => $user_id]
+ );
- // Anonymize all responses by this user
- $wpdb->update(
- $table,
- [
- 'is_user_deleted' => 1,
- // Keep user_id intact for internal tracking, but mark as deleted
- 'updated_at' => current_time('mysql')
- ],
- ['user_id' => $user_id],
- ['%d', '%s'],
- ['%d']
- );
+ $this->logError(
+ 'Anonymized responses for deleted user',
+ ['user_id' => $user_id, 'count' => $updated],
+ 'info'
+ );
- JVB()->error()->log(
- 'news_responses',
- 'Anonymized responses for deleted user',
- ['user_id' => $user_id, 'count' => $wpdb->rows_affected],
- 'info'
- );
- }
+ $this->cache->flush();
+ }
}
diff --git a/inc/rest/routes/SEORoutes.php b/inc/rest/routes/SEORoutes.php
index e1da79c..e5154ed 100644
--- a/inc/rest/routes/SEORoutes.php
+++ b/inc/rest/routes/SEORoutes.php
@@ -1,10 +1,11 @@
<?php
namespace JVBase\rest\routes;
-use JVBase\rest\RestRouteManager;
+use JVBase\rest\Rest;
use JVBase\managers\Cache;
use JVBase\managers\SEO\ConfigManager;
use JVBase\managers\SEO\SchemaBuilder;
+use JVBase\rest\Route;
use WP_REST_Request;
use WP_REST_Response;
use WP_Error;
@@ -19,13 +20,13 @@
* Handles REST API endpoints for SEO configuration
* Works with FormController.js for unified form handling
*/
-class SEORoutes extends RestRouteManager
+class SEORoutes extends Rest
{
protected SchemaBuilder $registry;
public function __construct()
{
- $this->cache_name = 'schema';
+ $this->cacheName = 'schema';
parent::__construct();
$this->registry = SchemaBuilder::getInstance();
}
@@ -35,42 +36,21 @@
*/
public function registerRoutes(): void
{
- // Main SEO endpoint - handles save, reset, preview
- register_rest_route($this->namespace, '/seo', [
- [
- 'methods' => 'POST',
- 'callback' => [$this, 'handleSEO'],
- 'permission_callback' => fn() => current_user_can('manage_options'),
- 'args' => [
- 'action' => [
- 'required' => false,
- 'type' => 'string',
- 'default' => 'save',
- 'enum' => ['save', 'reset', 'preview']
- ],
- 'context' => [
- 'required' => true,
- 'type' => 'string',
- 'description' => 'site, business, or content/taxonomy/user type'
- ]
- ]
- ]
- ]);
+ Route::for('seo')
+ ->post([$this, 'handleSEO'])
+ ->auth('admin')
+ ->args([
+ 'action' => 'string|required|enum:save,reset,preview',
+ 'context'=> 'string|required'
+ ])
+ ->rateLimit(30);
- // Get fields for a schema type (for dynamic type switching)
- register_rest_route($this->namespace, '/seo/fields', [
- [
- 'methods' => 'GET',
- 'callback' => [$this, 'getFields'],
- 'permission_callback' => fn() => current_user_can('manage_options'),
- 'args' => [
- 'type' => [
- 'required' => true,
- 'type' => 'string'
- ]
- ]
- ]
- ]);
+ Route::for('seo/fields')
+ ->get([$this, 'getFields'])
+ ->auth('admin')
+ ->args([
+ 'type'=>'string|required'
+ ]);
}
/**
@@ -140,11 +120,7 @@
// Invalidate cache
$this->cache->flush();
- return new WP_REST_Response([
- 'success' => true,
- 'status' => 'completed',
- 'message' => ucfirst($context) . ' settings saved successfully'
- ]);
+ return$this->success(['message'=>ucfirst($context).' settings saved successfully']);
}
/**
@@ -189,11 +165,7 @@
// Invalidate cache
$this->cache->flush();
- return new WP_REST_Response([
- 'success' => true,
- 'status' => 'completed',
- 'message' => 'Configuration saved successfully'
- ]);
+ return $this->success(['status'=> 'completed', 'message' => 'Configuration saved successfully']);
}
/**
@@ -226,8 +198,7 @@
// Invalidate cache
$this->cache->flush();
- return new WP_REST_Response([
- 'success' => true,
+ return $this->success([
'status' => 'completed',
'message' => 'Reset to defaults successfully',
'meta' => $config->meta(),
@@ -261,8 +232,7 @@
$schema[$fieldName] = $value;
}
- return new WP_REST_Response([
- 'success' => true,
+ return $this->success([
'schema' => $schema
]);
}
@@ -275,10 +245,10 @@
{
$type = $request->get_param('type');
- // Get MetaManager field definitions from registry
+ // Get Meta field definitions from registry
$fields = $this->registry->getMetaConfigForType($type);
- return new WP_REST_Response($fields);
+ return $this->success($fields);
}
/**
diff --git a/inc/rest/routes/SettingsRoutes.php b/inc/rest/routes/SettingsRoutes.php
index ff4faaa..f49c2bd 100644
--- a/inc/rest/routes/SettingsRoutes.php
+++ b/inc/rest/routes/SettingsRoutes.php
@@ -2,10 +2,12 @@
namespace JVBase\rest\routes;
use JVBase\JVB;
-use JVBase\rest\RestRouteManager;
+use JVBase\rest\PermissionHandler;
+use JVBase\rest\Rest;
use JVBase\managers\Cache;
-use JVBase\meta\MetaManager;
-use JVBase\meta\MetaSanitizer;
+use JVBase\meta\Meta;
+use JVBase\meta\Sanitizer;
+use JVBase\rest\Route;
use WP_REST_Request;
use WP_REST_Response;
use WP_Error;
@@ -14,15 +16,15 @@
if (!defined('ABSPATH')) {
exit; // Exit if accessed directly
}
-class SettingsRoutes extends RestRouteManager
+class SettingsRoutes extends Rest
{
protected int $count;
public function __construct()
{
- $this->cache_name = 'user_settings';
+ $this->cacheName = 'user_settings';
parent::__construct();
- $this->action = 'dash-';
- $this->operation_type = 'user_settings';
+// $this->action = 'dash-';
+// $this->operation_type = 'user_settings';
$this->count = 1;
add_filter(BASE.'handle_bulk_operation', [$this, 'processOperation'], 10, 3);
}
@@ -33,12 +35,10 @@
*/
public function registerRoutes():void
{
- register_rest_route($this->namespace, '/settings', [
- [
- 'methods' => 'POST',
- 'callback' => [$this, 'saveSettings'],
- 'permission_callback' => [$this, 'checkPermission']
- ]]);
+ Route::for('settings')
+ ->post([$this, 'saveSettings'])
+ ->auth(PermissionHandler::combine(['admin', ['actionNonce' => 'dash-']]))
+ ->rateLimit(20);
}
/**
@@ -52,17 +52,14 @@
$data = $request->get_params();
error_log('User: '.print_r($data['user'], true));
error_log('Settings routes data: '.print_r($data, true));
- $user_id = (int)$data['user'];
- if (!$this->userCheck($user_id)) {
- return new WP_REST_Response([
- 'success' => false,
- 'message' => 'Looks like you are not who you say you are. Bot?'
- ]);
+ $user_id = absint($data['user']??0);
+ if ($user_id === 0) {
+ return $this->unauthorized();
}
$fields = JVB()->getFields('user');
- $meta = new MetaSanitizer();
+ $meta = new Sanitizer();
$add = [];
global $types;
foreach ($data as $name => $value) {
@@ -96,9 +93,7 @@
]
);
- // Return standardized response
- return new WP_REST_Response([
- 'success' =>true,
+ return $this->success([
'message' => 'Request received and queued',
'status' => 'queued'
]);
@@ -119,9 +114,9 @@
$user_id = $operation->user_id;
- $meta = new MetaManager($user_id, 'user');
+ $meta = Meta::forUser($user_id);
$results = [];
- $tempMeta = new MetaManager(null, 'options');
+ $tempMeta = Meta::forOptions('options');
foreach ($data as $name => $value) {
if ($name == 'notification_preferences') {
return $this->saveNotificationPreferences($user_id, $value);
@@ -141,9 +136,9 @@
if ($name !== 'digest_override') { // digest_override should always reset to 'off'
if(array_key_exists($name, JVB_OPTIONS)) {
- $results[] = $tempMeta->updateValue($name, $value);
+ $results[] = $tempMeta->set($name, $value);
} else {
- $results[] = $meta->updateValue($name, $value);
+ $results[] = $meta->set($name, $value);
}
}
@@ -163,6 +158,7 @@
*/
protected function saveNotificationPreferences(int $user_id, array $data):array|WP_Error
{
+ //TODO: This should be in with Notifications.php manager
//TODO: Check what cache we are using to store user's preferences for the daily/weekly/monthly sendouts
global $wpdb;
$table = $wpdb->prefix . BASE . 'notification_preferences';
diff --git a/inc/rest/routes/ShopRoutes.php b/inc/rest/routes/ShopRoutes.php
index 1290231..4f69614 100644
--- a/inc/rest/routes/ShopRoutes.php
+++ b/inc/rest/routes/ShopRoutes.php
@@ -5,7 +5,7 @@
use JVBase\managers\ImageGenerator;
use JVBase\managers\UploadManager;
use JVBase\rest\RestRouteManager;
-use JVBase\meta\MetaManager;
+use JVBase\meta\Meta;
use WP_REST_Request;
use WP_REST_Response;
use WP_Error;
@@ -16,6 +16,7 @@
}
/***
+ * @deprecated
* WORKFLOW:
* 1) Users with 'manage_shop' permissions can invite folks to this shop
* 2) If user is already a registered member:
@@ -148,7 +149,7 @@
];
}
- $meta = new MetaManager($shop, BASE.'shop');
+ $meta = Meta::forTerm($shop);
$allowed = jvbGetFields('shop', 'term');
$setData = array_filter(
$data,
@@ -166,7 +167,7 @@
unset($setData['shop']);
}
if (array_intersect(array_keys($data), ['image_portrait', 'shop', 'city', 'type', 'top_style','display_name'])) {
- if (array_key_exists('image_portrait', $data) || $meta->getValue('image_portrait') !== '') {
+ if (array_key_exists('image_portrait', $data) || $meta->get('image_portrait') !== '') {
$this->checkGenerateThumbnail($user_id, $this->buildThumbnailData($user_id));
}
}
@@ -184,13 +185,13 @@
// } else {
// if ($name === 'shop') {
// JVB()->routes('shop')->requestShopAdmission($operation->user_id, $value);
-// $results[] = $meta->updateValue('requested_shop', $value);
+// $results[] = $meta->set('requested_shop', $value);
// }
//
// }
//
// if (array_intersect(array_keys($data), ['image_portrait', 'shop', 'city', 'type', 'top_style','display_name'])) {
-// if (array_key_exists('image_portrait', $data) || $meta->getValue('image_portrait') !== '') {
+// if (array_key_exists('image_portrait', $data) || $meta->get('image_portrait') !== '') {
// $this->checkGenerateThumbnail($user_id, $this->buildThumbnailData($user_id));
// }
// }
@@ -226,8 +227,8 @@
$data['name'] = $shop->name;
}
if (!array_key_exists('city', $data)) {
- $meta = new MetaManager($data['shop'], 'term');
- $city = $meta->getValue('city');
+ $meta = Meta::forTerm($data['shop']);
+ $city = $meta->get('city');
if ($city !== '') {
$city = get_term($city, BASE.'city');
if ($city && !is_wp_error($city)) {
@@ -531,7 +532,7 @@
return;
}
// Get shop managers
- $shopMeta = new MetaManager($shopID, 'term');
+ $shopMeta = Meta::forTerm($shopID, 'term');
$owners = $shopMeta->getAll(['managers', 'owner']);
$owners = array_unique(array_merge($owners['managers'], $owners['owner']));
@@ -1218,7 +1219,7 @@
// Create a notification for the shop owner/manager
$shop_name = get_term($shop_id, BASE . 'shop')->name;
- $shop_meta = new MetaManager($shop_id, 'term');
+ $shop_meta = Meta::forTerm($shop_id);
$owners = $shop_meta->getAll(['owner', 'managers']);
$owners = array_unique(array_merge(explode(',', $owners['owner']),explode(',', $owners['managers'])));
@@ -1345,10 +1346,10 @@
$userLink = get_user_meta($user, BASE . 'link', true);
- $userMeta = new MetaManager($user, 'user');
- $artistMeta = new MetaManager($userLink, 'post');
+ $userMeta = Meta::forUser($user);
+ $artistMeta = Meta::forPost($userLink);
- $shopMeta = new MetaManager($shop, 'term');
+ $shopMeta = Meta::forTerm($shop);
error_log('Setting '.$type.' permissions:'.print_r([
'userLink' => $userLink,
@@ -1365,7 +1366,7 @@
$user->add_cap('manage_shop', $grant);
$user->add_cap('manage_shop_'.$shop, $grant);
- $shops = $userMeta->getValue($uMeta);
+ $shops = $userMeta->get($uMeta);
$shops = ($shops === '') ? [] : explode(',', $shops);
if ($grant) {
if (!in_array($shop, $shops)) {
@@ -1381,17 +1382,17 @@
$shops = implode(',', $shops);
- $userMeta->updateValue($uMeta, $shops);
+ $userMeta->set($uMeta, $shops);
- $artistMeta->updateValue($aMeta, $shops);
+ $artistMeta->set($aMeta, $shops);
- $owners = $shopMeta->getValue($sMeta);
+ $owners = $shopMeta->get($sMeta);
$owners = ($owners === '') ? [] : explode(',', $shops);
if (!in_array($user->ID, $owners)) {
$owners[] = $user->ID;
}
$owners = implode(',', $owners);
- $shopMeta->updateValue($sMeta, $owners);
+ $shopMeta->set($sMeta, $owners);
return true;
}
diff --git a/inc/rest/routes/TermRoutes.php b/inc/rest/routes/TermRoutes.php
index 438139e..7f03d49 100644
--- a/inc/rest/routes/TermRoutes.php
+++ b/inc/rest/routes/TermRoutes.php
@@ -2,9 +2,10 @@
namespace JVBase\rest\routes;
use JVBase\JVB;
-use JVBase\rest\RestRouteManager;
+use JVBase\rest\Rest;
use JVBase\managers\TaxonomyRelationships;
use JVBase\managers\UserTermsManager;
+use JVBase\rest\Route;
use JVBase\utility\Features;
use WP_REST_Request;
use WP_REST_Response;
@@ -13,18 +14,19 @@
if (!defined('ABSPATH')) {
exit; // Exit if accessed directly
}
-class TermRoutes extends RestRouteManager
+class TermRoutes extends Rest
{
protected object $term_index_manager;
protected int $per_page;
public function __construct()
{
- $this->cache_name = 'terms';
+ $this->cacheName = 'terms';
parent::__construct();
if (JVB_TESTING) {
$this->cache->flush();
}
+ $this->cache->connect('taxonomy', true);
$this->per_page = 20;
add_action('edited_term', [$this, 'deleteTermPath']);
@@ -37,60 +39,29 @@
*/
public function registerRoutes():void
{
- register_rest_route($this->namespace, '/terms', [
- [
- 'methods' => 'GET',
- 'callback' => [$this, 'handleTermSelectionRequest'],
- 'permission_callback' => [$this, 'checkPermission'],
- 'args' => [
- 'page' => [
- 'required' => true,
- 'type' => 'integer',
- 'default' => 1,
- ],
- 'taxonomy' => [
- 'required' => true,
- 'type' => 'string'
- ],
- 'search' => [
- 'required' => false,
- 'type' => 'string'
- ],
- 'parent' => [
- 'required' => false,
- 'type' => 'integer',
- 'default' => 0
- ]
- ]
- ],
- [
- 'methods' => 'POST',
- 'callback' => [$this, 'createTermRequest'],
- 'permission_callback' => [$this, 'checkPermission'],
- 'args' => [
- 'taxonomy' => [
- 'required' => true,
- 'type' => 'string'
- ],
- 'name' => [
- 'required' => true,
- 'type' => 'string'
- ],
- 'parent' => [
- 'required' => false,
- 'type' => 'integer',
- 'default' => 0
- ]
- ]
- ]
- ]);
+ Route::for('terms')
+ ->get([$this, 'handleTermSelectionRequest'])
+ ->auth('public')
+ ->rateLimit()
+ ->args([
+ 'page' => 'int|required',
+ 'taxonomy' => 'string|required',
+ 'search' => 'string',
+ 'parent' => 'int'
+ ])
+ ->post([$this, 'createTermRequest'])
+ ->auth('isVerified')
+ ->rateLimit(30)
+ ->args([
+ 'taxonomy' => 'string|required',
+ 'name' => 'string|required',
+ 'parent' => 'int|default:0',
+ ]);
- //TODO: Just wrap this up to the normal GET request
- register_rest_route($this->namespace, '/terms/check', [
- 'methods' => 'GET',
- 'callback' => [$this, 'getTermDetails'],
- 'permission_callback' => [$this, 'checkPermission'],
- ]);
+ Route::for('terms/check')
+ ->get([$this,'getTermDetails'])
+ ->auth('public')
+ ->rateLimit();
}
/**
@@ -104,10 +75,7 @@
$term = get_term($term_id);
if (is_wp_error($term)) {
- return new WP_REST_Response([
- 'success' => false,
- 'message' => $term
- ]);
+ return $this->error('No term found');
}
$data = [
@@ -142,7 +110,7 @@
}
}
- return new WP_REST_Response($data);
+ return $this->success($data);
}
/**
@@ -150,31 +118,33 @@
*
* @return WP_REST_Response
*/
- public function getTermDetails(WP_REST_Request $request):WP_REST_Response
- {
- $data = $request->get_params();
- // Collect all taxonomies being queried
+ public function getTermDetails(WP_REST_Request $request): WP_REST_Response
+ {
+ $data = $request->get_params();
$taxonomies = array_keys($data);
- // Check HTTP cache headers
- $cache_check = $this->checkHeaders($request, $taxonomies);
+ $cache_check = $this->checkHeaders($request, $this->cache->generateKey(['termDetails' => $data]));
if ($cache_check) {
return $cache_check;
}
- $terms = [];
- foreach ($data as $tax => $IDs) {
- $args = [
- 'taxonomy' => BASE.$tax,
- 'include' => $IDs
- ];
- $terms[$tax] = $this->formatTerms($args, BASE.$tax);
- }
- $response = new WP_REST_Response([
- 'items' => $terms,
- ]);
- return $this->addCacheHeaders($response);
- }
+ $terms = $this->cache->remember(
+ $this->cache->generateKey(['termDetails' => $data]),
+ function() use ($data) {
+ $result = [];
+ foreach ($data as $tax => $IDs) {
+ $args = [
+ 'taxonomy' => BASE . $tax,
+ 'include' => $IDs
+ ];
+ $result[$tax] = $this->formatTerms($args, BASE . $tax);
+ }
+ return $result;
+ }
+ );
+
+ return $this->addCacheHeaders($this->success(['items' => $terms]));
+ }
/**
* @param WP_REST_Request $request
@@ -207,7 +177,7 @@
$key = $this->cache->generateKey($args);
$cached = $this->cache->get($key);
if ($cached) {
- $response = new WP_REST_Response($cached);
+ $response = $this->success($cached);
return $this->addCacheHeaders($response);
}
@@ -216,7 +186,7 @@
'items' => $formatted
];
$this->cache->set($key, $response);
- $response = new WP_REST_Response($response);
+ $response = $this->success($response);
return $this->addCacheHeaders($response);
}
if (array_key_exists('content', $data)) {
@@ -237,16 +207,7 @@
$per_page = 25;
if (!taxonomy_exists($taxonomy)) {
- return new WP_REST_Response([
- 'items' => [],
- 'pagination' => [
- 'page' => 1,
- 'per_page' => $per_page,
- 'total_pages' => 0,
- 'total_terms' => 0
- ],
- 'has_more' => false
- ]);
+ return $this->emptyResult();
}
$tax_obj = get_taxonomy($taxonomy);
@@ -279,16 +240,7 @@
$related = $manager->getUserTermIDs($userID, $taxonomy);
if (empty($related)) {
- $response = new WP_REST_Response([
- 'items' => [],
- 'pagination' => [
- 'page' => 1,
- 'per_page' => $per_page,
- 'total_pages' => 0,
- 'total_terms' => 0,
- ],
- 'has_more' => false
- ]);
+ $response = $this->emptyResult();
return $this->addCacheHeaders($response);
}
@@ -302,16 +254,7 @@
$related = $manager->getRelatedTerms($ID, BASE.$request->get_param('taxonomy'));
if (empty($related)) {
- $response = new WP_REST_Response([
- 'items' => [],
- 'pagination' => [
- 'page' => 1,
- 'per_page' => $per_page,
- 'total_pages' => 0,
- 'total_terms' => 0
- ],
- 'has_more' => false
- ]);
+ $response = $this->emptyResult();
return $this->addCacheHeaders($response);
}
$args['tax_query'] = [
@@ -361,16 +304,7 @@
$args['include'] = $related_term_ids;
} else {
// No related terms found, return empty result
- $response = new WP_REST_Response([
- 'items' => [],
- 'pagination' => [
- 'page' => 1,
- 'per_page' => $per_page,
- 'total_pages' => 0,
- 'total_terms' => 0
- ],
- 'has_more' => false
- ]);
+ $response = $this->emptyResult();
return $this->addCacheHeaders($response);
}
@@ -382,7 +316,7 @@
$cache = $this->cache->get($key);
if ($cache) {
- $response = new WP_REST_Response($cache);
+ $response = $this->success($cache);
return $this->addCacheHeaders($response);
}
@@ -411,7 +345,7 @@
];
$this->cache->set($key, $response);
- $response = new WP_REST_Response($response);
+ $response = $this->success($response);
return $this->addCacheHeaders($response);
}
@@ -449,7 +383,7 @@
'has_more' => true,
];
- $response = new WP_REST_Response($response);
+ $response = $this->success($response);
return $this->addCacheHeaders($response);
}
@@ -525,74 +459,56 @@
*
* @return WP_REST_Response
*/
- public function handleTermSearch(WP_REST_Request $request):WP_REST_Response
- {
- $taxonomy = BASE.$request->get_param('taxonomy');
- $search = $request->get_param('search');
- $page = $request->get_param('page') ?? 1;
- $per_page = $request->get_param('per_page') ?? 20;
+ public function handleTermSearch(WP_REST_Request $request): WP_REST_Response
+ {
+ $taxonomy = BASE . $request->get_param('taxonomy');
+ $search = $request->get_param('search');
+ $page = $request->get_param('page') ?? 1;
+ $per_page = $request->get_param('per_page') ?? 20;
- // When searching, we want to search across all terms regardless of hierarchy
- $args = [
- 'taxonomy' => $taxonomy,
- 'hide_empty' => true,
- 'search' => $search,
- 'search_columns' => ['name', 'slug'],
- 'fields' => 'all',
- 'number' => $per_page,
- 'offset' => ($page - 1) * $per_page,
- ];
+ $args = [
+ 'taxonomy' => $taxonomy,
+ 'hide_empty' => true,
+ 'search' => $search,
+ 'search_columns' => ['name', 'slug'],
+ 'fields' => 'all',
+ 'number' => $per_page,
+ 'offset' => ($page - 1) * $per_page,
+ ];
- $key = $this->cache->generateKey($args);
- $cache = $this->cache->get($key);
- if ($cache) {
- return new WP_REST_Response($cache);
- }
+ $data = $this->cache->remember(
+ $this->cache->generateKey($args),
+ function() use ($args, $taxonomy, $page, $per_page) {
+ $terms = get_terms($args);
- $terms = get_terms($args);
+ if (is_wp_error($terms)) {
+ return $this->emptyResult($page, $per_page);
+ }
- if (is_wp_error($terms)) {
- return new WP_REST_Response([
- 'items' => [],
- 'pagination' => [
- 'page' => 0,
- 'per_page' => 20,
- 'total_pages' => 0,
- 'total_terms' => 0
- ],
- 'has_more' => false
- ]);
- }
+ $formatted_terms = array_map(
+ fn($term) => $this->formatSingleTerm($term, $taxonomy),
+ $terms
+ );
- // Get total count for pagination
- $count_args = array_merge($args, ['fields' => 'count']);
- $total_terms = wp_count_terms($count_args);
+ $count_args = array_merge($args, ['fields' => 'count']);
+ $total_terms = wp_count_terms($count_args);
+ $total_pages = ceil($total_terms / $per_page);
- $formatted_terms = [];
- foreach ($terms as $term) {
- // Search results show path, so includeChildren = false for performance
- $formatted_terms[] = $this->formatSingleTerm($term, $taxonomy, false);
- }
+ return [
+ 'items' => $formatted_terms,
+ 'pagination' => [
+ 'page' => (int)$page,
+ 'per_page' => (int)$per_page,
+ 'total_pages' => $total_pages,
+ 'total_terms' => (int)$total_terms
+ ],
+ 'has_more' => $page < $total_pages
+ ];
+ }
+ );
- // Calculate pagination info
- $total_pages = ceil($total_terms / $per_page);
- $has_more = $page < $total_pages;
-
- $response = [
- 'items' => $formatted_terms,
- 'pagination' => [
- 'page' => (int)$page,
- 'per_page' => (int)$per_page,
- 'total_pages' => $total_pages,
- 'total_terms' => (int)$total_terms
- ],
- 'has_more' => $has_more
- ];
-
- $this->cache->set($key, $response);
-
- return new WP_REST_Response($response);
- }
+ return $this->addCacheHeaders($this->success($data));
+ }
/**
* @param int $termID
@@ -686,7 +602,8 @@
$cache = $this->cache->get($cache_key);
// Try cache first
if ($cache !== false) {
- return new WP_REST_Response($cache);
+ $response = $this->success($cache);
+ return $this->addCacheHeaders($response);
}
try {
@@ -785,156 +702,53 @@
// Cache results
$this->cache->set($cache_key, $results);
-
- return new WP_REST_Response($results);
+ $response = $this->success($results);
+ return $this->addCacheHeaders($response);
} catch (Exception $e) {
- return new WP_REST_Response([
- 'success' => false,
- 'message' => $e->getMessage()
- ]);
+ return $this->error('Error getting terms for content: '.$e->getMessage(), 'get_terms_for_content');
}
}
+
/**
* @param WP_REST_Request $request
*
* @return WP_REST_Response
*/
- public function handleTermsRequest(WP_REST_Request $request):WP_REST_Response
- {
- $taxonomy = BASE.$request->get_param('taxonomy');
- $search = $request->get_param('search');
- $page = max(1, intval($request->get_param('page')));
- $per_page = $request->get_param('per_page') ?: $this->per_page;
+ public function createTermRequest(WP_REST_Request $request): WP_REST_Response
+ {
+ $user_id = get_current_user_id();
+ $taxonomy = $request->get_param('taxonomy');
+ $name = sanitize_text_field($request->get_param('name'));
+ $parent = (int)$request->get_param('parent') ?: 0;
- // Create cache key
- $cache_key = "terms_{$taxonomy}_" . md5("{$search}_{$page}_{$per_page}");
- $cache = $this->cache->get($cache_key);
- if ($cache) {
- return new WP_REST_Response($cache);
- }
+ try {
+ $existing = term_exists($name, jvbCheckBase($taxonomy), $parent);
- try {
- // Build query args
- $args = [
- 'taxonomy' => $taxonomy,
- 'hide_empty' => true,
- 'orderby' => $search ? 'name' : 'count',
- 'order' => $search ? 'ASC' : 'DESC',
- 'number' => $per_page,
- 'offset' => ($page - 1) * $per_page,
- 'fields' => 'all'
- ];
-
- // Add search if provided
- if ($search) {
- $args['search'] = $search;
- }
-
- // Get terms
- $terms = get_terms($args);
-
- if (is_wp_error($terms)) {
- return new WP_REST_Response([
- 'items' => [],
- 'pagination' => [
- 'page' => 0,
- 'per_page' => 20,
- 'total_pages' => 0,
- 'total_terms' => 0
- ],
- 'has_more' => 0
- ]);
- }
-
- // Check if taxonomy is hierarchical
- $is_hierarchical = is_taxonomy_hierarchical($taxonomy);
-
- // Format terms
- $formatted_terms = [];
- foreach ($terms as $term) {
- $formatted_terms[] = $this->formatSingleTerm($term, $taxonomy, false);
+ if ($existing) {
+ $term = get_term($existing['term_id'], jvbCheckBase($taxonomy));
+ return $this->success(['message' => 'Term already exists', 'term' => [
+ 'id' => $term->term_id,
+ 'name' => html_entity_decode($term->name),
+ 'path' => $this->getTermPath($term->term_id, $term->name, $taxonomy)
+ ]]);
}
- // Get total for pagination
- $total_args = array_merge($args, ['fields' => 'count', 'number' => '']);
- $total = wp_count_terms($taxonomy, $total_args);
- $total_pages = ceil($total / $per_page);
-
- $results = [
- 'items' => $formatted_terms,
- 'pagination' => [
- 'page' => (int)$page,
- 'per_page' => (int)$per_page,
- 'total_pages' => $total_pages,
- 'total_terms' => (int)$total
- ],
- 'has_more' => $page < $total_pages
- ];
-
- // Cache results
- $this->cache->set($cache_key, $results);
-
- return new WP_REST_Response($results);
-
- } catch (Exception $e) {
- return new WP_REST_Response([
- 'success' => false,
- 'message' => $e->getMessage()
- ]);
- }
- }
-
- /**
- * @param WP_REST_Request $request
- *
- * @return WP_REST_Response
- */
- public function createTermRequest(WP_REST_Request $request):WP_REST_Response
- {
- $user_id = get_current_user_id();
- $taxonomy = $request->get_param('taxonomy');
- $name = sanitize_text_field($request->get_param('name'));
- $parent = (int)$request->get_param('parent') ?: 0;
-
-
- try {
- // Check if term already exists
- $existing = term_exists($name, jvbCheckBase($taxonomy), $parent);
-
- if ($existing) {
-
- $term = get_term($existing['term_id'], jvbCheckBase($taxonomy));
- error_log('Existing Term: '.print_r($term, true));
- return new WP_REST_Response([
- 'success' => false,
- 'message' => 'Term already exists',
- 'term' => [
- 'id' => $term->term_id,
- 'name' => html_entity_decode($term->name),
- 'path' => $this->getTermPath($term->term_id, $term->name, $taxonomy)
- ]
- ]);
- }
-
-
if (Features::forMembership()->has('term_approval')) {
- error_log('Term Approval required');
- // Get approval routes instance
$approval_routes = JVB()->routes('approvals');
- // Create approval request
$request_id = $approval_routes->createTermApprovalRequest(
$user_id,
$taxonomy,
sanitize_title($name),
absint($parent)
);
+
if (!$request_id) {
throw new Exception('Failed to create approval request');
}
- $return = [
- 'success' => true,
+
+ return $this->success([
'message' => 'Term suggestion submitted for approval',
'term' => [
'id' => 'pending_' . $request_id,
@@ -942,50 +756,50 @@
'pending' => true,
'request_id' => $request_id
]
- ];
- } else {
- error_log('Creating new Term: ');
- $termID = wp_insert_term(
- $name,
- jvbCheckBase($taxonomy),
- [
- 'parent' => absint($parent)
- ]
- );
- error_log('Result: '.print_r($termID, true));
-
- if (is_wp_error($termID)) {
- throw new Exception('Failed to create new term');
- }
-
- $return = [
- 'success' => true,
- 'message' => $name.' created successfully',
- 'term' => [
- 'id' => $termID['term_id'],
- 'name' => $name,
- 'path' => $this->getTermPath($termID['term_id'], $name, $taxonomy)
- ]
- ];
+ ], 202); // 202 Accepted for pending approval
}
- return new WP_REST_Response($return);
+ $termID = wp_insert_term(
+ $name,
+ jvbCheckBase($taxonomy),
+ ['parent' => absint($parent)]
+ );
- } catch (Exception $e) {
- JVB()->error()->log(
- 'terms',
- 'Term creation failed: ' . $e->getMessage(),
- [
- 'user_id' => $user_id,
- 'taxonomy' => $taxonomy,
- 'name' => $name
- ]
- );
+ if (is_wp_error($termID)) {
+ throw new Exception($termID->get_error_message());
+ }
- return new WP_REST_Response([
- 'success' => false,
- 'message' => $e->getMessage()
- ], 500);
- }
- }
+ return $this->success([
+ 'message' => $name . ' created successfully',
+ 'term' => [
+ 'id' => $termID['term_id'],
+ 'name' => $name,
+ 'path' => $this->getTermPath($termID['term_id'], $name, $taxonomy)
+ ]
+ ], 201); // 201 Created
+
+ } catch (Exception $e) {
+ JVB()->error()->log(
+ 'terms',
+ 'Term creation failed: ' . $e->getMessage(),
+ ['user_id' => $user_id, 'taxonomy' => $taxonomy, 'name' => $name]
+ );
+
+ return $this->error($e->getMessage(), 'term_creation_failed', 500);
+ }
+ }
+
+ protected function emptyResult(int $page = 1, int $per_page = 20):WP_REST_Response
+ {
+ return $this->success([
+ 'items' => [],
+ 'pagination' => [
+ 'page' => $page,
+ 'per_page' => $per_page,
+ 'total_pages' => 0,
+ 'total_terms' => 0
+ ],
+ 'has_more' => false
+ ]);
+ }
}
diff --git a/inc/rest/routes/UploadRoutes.php b/inc/rest/routes/UploadRoutes.php
index c0016bf..f519fa7 100644
--- a/inc/rest/routes/UploadRoutes.php
+++ b/inc/rest/routes/UploadRoutes.php
@@ -1,12 +1,13 @@
<?php
namespace JVBase\rest\routes;
-use JVBase\JVB;
use JVBase\managers\queue\executors\UploadExecutor;
use JVBase\managers\queue\TypeConfig;
-use JVBase\rest\RestRouteManager;
-use JVBase\meta\MetaManager;
+use JVBase\rest\PermissionHandler;
+use JVBase\rest\Rest;
+use JVBase\meta\Meta;
use JVBase\managers\UploadManager;
+use JVBase\rest\Route;
use JVBase\utility\Features;
use WP_REST_Request;
use WP_REST_Response;
@@ -16,12 +17,11 @@
if (!defined('ABSPATH')) {
exit; // Exit if accessed directly
}
-class UploadRoutes extends RestRouteManager
+class UploadRoutes extends Rest
{
public function __construct()
{
- $this->action = 'dash-';
parent::__construct();
add_action('init', [$this, 'registerUploadExecutors'], 5);
@@ -85,50 +85,28 @@
*/
public function registerRoutes():void
{
- // Main upload endpoint
- register_rest_route($this->namespace, '/uploads', [
- 'methods' => 'POST',
- 'callback' => [$this, 'handleUpload'],
- 'permission_callback' => [$this, 'checkPermission']
- ]);
+ Route::for('uploads')
+ ->post([$this, 'handleUpload'])
+ ->auth(PermissionHandler::combine(['nonce']))
+ ->rateLimit(30);
+ Route::for('uploads/groups')
+ ->post([$this, 'handleGroupingRequest'])
+ ->auth(PermissionHandler::combine(['nonce']))
+ ->rateLimit(30)
+ ->args([
+ 'id' => 'string|required',
+ 'content' => 'string|required',
+ 'user' => 'int|required'
+ ]);
- register_rest_route($this->namespace, '/uploads/groups', [
- 'methods' => 'POST',
- 'callback' => [$this, 'handleGroupingRequest'],
- 'permission_callback' => [$this, 'checkPermission'],
- 'args' => [
- 'id' => [
- 'required' => true,
- 'type' => 'string',
- 'description' => 'Original upload operation ID'
- ],
- 'content' => [
- 'required' => true,
- 'type' => 'string'
- ],
- 'user' => [
- 'required' => true,
- 'type' => 'integer'
- ],
- ]
- ]);
-
- register_rest_route($this->namespace, '/uploads/meta', [
- 'methods' => 'POST',
- 'callback' => [$this, 'handleMetadataUpdate'],
- 'permission_callback' => [$this, 'checkPermission'],
- 'args' => [
- 'user' => [
- 'type' => 'integer',
- 'required' => true
- ],
- 'items' => [
- 'type' => 'array',
- 'required' => true,
- 'description' => 'Direct attachment IDs (for updates after completion)',
- ],
- ]
- ]);
+ Route::for('uploads/meta')
+ ->post([$this, 'handleMetadataUpdate'])
+ ->auth(PermissionHandler::combine(['nonce']))
+ ->rateLimit(30)
+ ->args([
+ 'user' => 'int|required',
+ 'items' => 'array|required'
+ ]);
}
/**
@@ -186,7 +164,7 @@
$args['term_id'] = absint($value);
}
break;
- // Field Name, as defined for MetaManager.php
+ // Field Name, as defined for Meta.php
case 'field_name':
if (is_string($value)) {
$args['field_name'] = sanitize_text_field($value);
@@ -268,13 +246,12 @@
$files = $request->get_file_params();
$args = $this->buildUploadArgs($request);
- if (!$args['content'] || !$args['user']) {
- $this->logError('Missing required data');
- return new WP_REST_Response([
- 'success' => false,
- 'message' => 'Missing required data'
- ]);
+ if (!$args['user']) {
+ return $this->unauthorized();
+ }
+ if (!$args['content']) {
+ return $this->validationError(['message' => 'Missing content']);
}
// Step 1: Secure all uploaded files
@@ -282,21 +259,13 @@
if (empty($secured_files)) {
$this->logError('No valid files to upload');
- return new WP_REST_Response([
- 'success' => false,
- 'message' => 'No valid files to upload'
- ]);
+ return $this->error('No valid files to upload');
}
// Step 2: Queue for processing via OperationQueue
$operation_id = $this->queueProcessing($secured_files, $args);
- return new WP_REST_Response([
- 'success' => true,
- 'operation_id' => $operation_id,
- 'file_count' => count($secured_files),
- 'message' => 'Files secured and queued for processing'
- ], 200);
+ return $this->queued($operation_id, 'Files secured and queued for processing');
} catch (Exception $e) {
// Error handling...
@@ -309,12 +278,7 @@
'trace' => $e->getTraceAsString()
]
);
-
- return $this->sendResponse(
- false,
- ['error_code' => 'upload_failed'],
- 'Upload processing failed: ' . $e->getMessage()
- );
+ return $this->error($e->getMessage());
}
}
@@ -602,23 +566,23 @@
$attachment_ids = array_column($results, 'attachment_id');
if (array_key_exists('post_id', $data)) {
- $meta = new MetaManager($data['post_id'], 'post');
+ $meta = Meta::forPost($data['post_id']);
} elseif (array_key_exists('term_id', $data)) {
- $meta = new MetaManager($data['term_id'], 'term');
+ $meta = Meta::forTerm($data['term_id']);
} else {
$link = (int)get_user_meta($data['user'], BASE.'link');
- $meta = new MetaManager($link, 'post');
+ $meta = Meta::forPost($link);
}
// Get existing value
- $existing = $meta->getValue($data['field_name']);
+ $existing = $meta->get($data['field_name']);
$existing_ids = !empty($existing) ? explode(',', $existing) : [];
// Merge with new IDs
$all_ids = array_unique(array_merge($existing_ids, $attachment_ids));
// Update with comma-separated string
- $meta->updateValue($data['field_name'], implode(',', $all_ids));
+ $meta->set($data['field_name'], implode(',', $all_ids));
}
/**
@@ -1113,7 +1077,7 @@
$response['operation_id'] = $operation_id;
}
- return new WP_REST_Response($response);
+ return $this->success($response);
}
@@ -1126,23 +1090,21 @@
$files = $request->get_file_params();
$args = $this->buildUploadArgs($request);
- if (!$args['content'] || !$args['user'] || !$args['posts']) {
- $this->logError('Missing required data');
- return new WP_REST_Response([
- 'success' => false,
- 'message' => 'Missing required data'
- ]);
+ if (!array_key_exists('user', $args) || $args['user'] === 0){
+ return $this->unauthorized();
+ }
+ if (!array_key_exists('content', $args) || empty($args['content'])) {
+ return $this->validationError(['message'=>'Missing required content']);
+ }
+ if (!array_key_exists('posts', $args) || empty($args['posts'])) {
+ return $this->validationError(['message' => 'Missing posts required']);
}
// Secure files to temporary storage
$secured_files = $this->secureFiles($files, $args);
if (empty($secured_files['files'])) {
- return $this->sendResponse(
- false,
- ['error_code' => 'no_files'],
- 'No valid files to upload'
- );
+ return $this->error('No valid files to upload');
}
// Queue file upload operation
@@ -1168,7 +1130,7 @@
]
);
- JVB()->queue()->queueOperation(
+ $ID = JVB()->queue()->queueOperation(
'process_upload_groups',
$args['user'],
$args,
@@ -1179,17 +1141,7 @@
]
);
- return $this->sendResponse(
- true,
- [
- 'operation_id' => $args['id'],
- 'upload_operation_id' => $args['upload'],
- 'post_count' => count($args['posts']),
- 'file_count' => count($secured_files['files'])
- ],
- 'Files uploaded and posts queued for creation'
- );
-
+ return $this->queued($ID['operation_id'], 'Files uploaded and posts queued for creation');
} catch (Exception $e) {
JVB()->error()->log(
'[UploadRoutes]:handleGroupingRequest',
@@ -1199,12 +1151,7 @@
'trace' => $e->getTraceAsString()
]
);
-
- return $this->sendResponse(
- false,
- ['error_code' => 'grouping_failed'],
- 'Grouping operation failed: ' . $e->getMessage()
- );
+ return $this->error('Grouping operation failed: '.$e->getMessage());
}
}
@@ -1321,12 +1268,12 @@
// Set gallery images
if (!empty($gallery_attachment_ids)) {
- $meta = new MetaManager($new_post_id, 'post');
+ $meta = Meta::forPost($new_post_id);
$fields = jvbGetFields($content, 'post');
foreach ($fields as $name => $config) {
if ($config['type'] === 'gallery') {
- $meta->updateValue($name, implode(',', $gallery_attachment_ids));
+ $meta->set($name, implode(',', $gallery_attachment_ids));
break;
}
}
@@ -1730,25 +1677,25 @@
// Determine meta type
if (!empty($data['post_id'])) {
- $meta = new MetaManager($data['post_id'], 'post');
+ $meta = Meta::forPost($data['post_id']);
} elseif (!empty($data['term_id'])) {
- $meta = new MetaManager($data['term_id'], 'term');
+ $meta = Meta::forTerm($data['term_id']);
} elseif (!empty($data['user'])) {
$link = (int)get_user_meta($data['user'], BASE.'link', true);
- $meta = new MetaManager($link, 'post');
+ $meta = Meta::forPost($link);
} else {
return;
}
// Get existing value
- $existing = $meta->getValue($data['field_name']);
+ $existing = $meta->get($data['field_name']);
$existing_ids = !empty($existing) ? explode(',', $existing) : [];
// Merge with new IDs
$all_ids = array_unique(array_merge($existing_ids, $attachment_ids));
// Update with comma-separated string
- $meta->updateValue($data['field_name'], implode(',', $all_ids));
+ $meta->set($data['field_name'], implode(',', $all_ids));
}
/**
@@ -1836,11 +1783,11 @@
set_post_thumbnail($post_id, $attachment_id);
} else {
// Others go to gallery
- $meta = new MetaManager($post_id, 'post');
- $existing = $meta->getValue('gallery');
+ $meta = Meta::forPost($post_id);
+ $existing = $meta->get('gallery');
$existing_ids = !empty($existing) ? explode(',', $existing) : [];
$existing_ids[] = $attachment_id;
- $meta->updateValue('gallery', implode(',', $existing_ids));
+ $meta->set('gallery', implode(',', $existing_ids));
}
}
@@ -1868,15 +1815,15 @@
set_post_thumbnail($post_id, $attachment_id);
} elseif (str_starts_with($mime_type, 'video/')) {
// Save to video field
- $meta = new MetaManager($post_id, 'post');
- $meta->updateValue('video', $attachment_id);
+ $meta = Meta::forPost($post_id);
+ $meta->set('video', $attachment_id);
} else {
// Documents - save to documents field
- $meta = new MetaManager($post_id, 'post');
- $existing = $meta->getValue('documents');
+ $meta = Meta::forPost($post_id);
+ $existing = $meta->get('documents');
$existing_ids = !empty($existing) ? explode(',', $existing) : [];
$existing_ids[] = $attachment_id;
- $meta->updateValue('documents', implode(',', $existing_ids));
+ $meta->set('documents', implode(',', $existing_ids));
}
}
}
diff --git a/inc/rest/routes/VoteRoutes.php b/inc/rest/routes/VoteRoutes.php
index e314f46..1ddf49f 100644
--- a/inc/rest/routes/VoteRoutes.php
+++ b/inc/rest/routes/VoteRoutes.php
@@ -1,7 +1,11 @@
<?php
namespace JVBase\rest\routes;
-use JVBase\JVB;
-use JVBase\rest\RestRouteManager;
+
+use JVBase\managers\CustomTable;
+use JVBase\rest\Response;
+use JVBase\rest\Rest;
+use JVBase\rest\Route;
+use JVBase\utility\Features;
use WP_REST_Request;
use WP_REST_Response;
use WP_Error;
@@ -10,12 +14,12 @@
if (!defined('ABSPATH')) {
exit; // Exit if accessed directly
}
-class VoteRoutes extends RestRouteManager
+class VoteRoutes extends Rest
{
public function __construct()
{
- $this->cache_name = 'karma';
- $this->cache_ttl = 86400;
+ $this->cacheName = 'karma';
+ $this->cacheTtl = DAY_IN_SECONDS;
parent::__construct();
add_filter(BASE.'handle_bulk_operation', [$this, 'processOperation'], 10, 3);
}
@@ -26,18 +30,23 @@
*/
public function registerRoutes():void
{
- register_rest_route($this->namespace, 'vote', [
- [
- 'methods' => 'POST',
- 'callback' => [$this, 'handleVote'],
- 'permission_callback' => [$this, 'checkPermission']
- ],
- [
- 'methods' => 'GET',
- 'callback' => [$this, 'getVotes'],
- 'permission_callback' => [$this, 'checkPermission']
- ]
- ]);
+ Route::for('vote')
+ ->post([$this, 'handleVote'])
+ ->args([
+ 'user' => 'integer|required',
+ 'id' => 'string|required',
+ 'item_id' => 'integer|required',
+ 'content' => 'string|required',
+ 'vote' => 'string|required|enum:up,down',
+ ])
+ ->auth('user')
+ ->rateLimit(10)
+ ->get([$this, 'getVotes'])
+ ->args([
+ 'user' => 'integer',
+ ])
+ ->auth('user')
+ ->rateLimit(30);
}
/**
@@ -47,42 +56,45 @@
*/
public function handleVote(WP_REST_Request $request):WP_REST_Response
{
- global $karma;
- if (!array_key_exists($request->get_param('content'), $karma)) {
- return new WP_REST_Response([
- 'success' => false,
- 'message' => __('Invalid content', 'jvb'),
- ]);
- }
- $vote = $request->get_param('vote');
- if (!$request->get_param('item_id') || !in_array($vote, ['up', 'down'])) {
- return new WP_REST_Response([
- 'success' => false,
- 'message' => __('Invalid item or vote attempt', 'jvb'),
- ]);
+ $content = sanitize_text_field($request->get_param('content')??'');
+ if ((!Features::forContent($content)->has('karma') && !Features::forTaxonomy($content)->has('karma') && !Features::forUser($content)->has('karma'))) {
+ return Response::validationError(['message' => __('Invalid content', 'jvb')]);
+ }
+
+ $vote = sanitize_text_field($request->get_param('vote')??'');
+ $itemID = absint($request->get_param('item_id')??0);
+ if ($itemID === 0 || !in_array($vote, ['up', 'down'])) {
+ return Response::validationError(['message' => __('Invalid item or vote attempt', 'jvb')]);
}
- //cursory sanitization
- $user = (int) $request->get_param('user');
- if (!$this->userCheck($user)) {
- return new WP_REST_Response([
- 'success' => false,
- 'message' => __('User doesn\'t match. Bot?', 'jvb'),
- ]);
- }
+ $user = absint($request->get_param('user'));
+ if (!$this->userCheck($user)) {
+ return Response::validationError(['message' => __('User doesn\'t match. Bot?', 'jvb')]);
+ }
+
$operation = sanitize_text_field($request->get_param('id'));
+ $type = match(true) {
+ array_key_exists($content, JVB_CONTENT) => 'post',
+ array_key_exists($content, JVB_TAXONOMY) => 'term',
+ array_key_exists($content, JVB_USER) => 'user',
+ default => false
+ };
+ if (!$type) {
+ return Response::validationError(['message' => __('Invalid content type', 'jvb')]);
+ }
+
$data = [
'user' => $user,
- 'item_id' => (int) $request->get_param('item_id'),
- 'content' => sanitize_text_field($request->get_param('content')),
- 'vote' => sanitize_text_field($vote),
+ 'item_id' => $itemID,
+ 'content' => $content,
+ 'type' => $type,
+ 'vote' => $vote,
];
error_log('Final Vote Data: '.print_r($data, true));
error_log('Operation: '.print_r($operation, true));
- $queue = JVB()->queue();
- $queue->queueOperation(
+ $operationID = JVB()->queue()->add(
'karmic',
$user,
$data,
@@ -91,33 +103,30 @@
'operation_id' => $operation,
]
);
-
- return new WP_REST_Response([
- 'success' => true,
- 'message' => __('Operation queued', 'jvb'),
- 'operation_id' => $operation
- ]);
+ return $this->queued($operationID['operation_id']);
}
- /**
- * @param WP_Error|array $result
- * @param object $operation
- * @param array $data
- *
- * @return WP_Error|array
- */
+ /**
+ * @param WP_Error|array $result
+ * @param object $operation
+ * @param array $data
+ *
+ * @return WP_Error|array
+ * @throws Exception
+ */
public function processOperation(WP_Error|array $result, object $operation, array $data):WP_Error|array
{
if ($operation->type !== 'karmic') {
return $result;
}
- // Get parameters from request
- global $karma;
//Check if item exists
- $item = ($karma[$data['content']] === 'term') ?
- get_term($data['item_id'], BASE.$data['content']) :
- get_post($data['item_id']);
+ $item = match ($data['type']) {
+ 'post' => get_post($data['item_id']),
+ 'term' => get_term($data['item_id'], jvbCheckBase($data['content'])),
+ 'user' => get_userdata($data['item_id']),
+ default => false
+ };
if (!$item || is_wp_error($item)) {
return [
'success' => false,
@@ -125,122 +134,79 @@
];
}
- global $wpdb;
- $table_name = $wpdb->prefix . BASE . 'karma_' . $data['content'];
- $key = $data['user'];
- error_log('Processing: '.print_r($data, true));
- // Check if user has already voted on this post
- $existing_vote = $wpdb->get_var(
- $wpdb->prepare(
- "SELECT vote FROM {$table_name} WHERE item_id = %d AND user_id = %d",
- $data['item_id'],
- $data['user']
- )
- );
+ $table = CustomTable::for('karma_' . $data['content']);
- // Begin transaction for data integrity
- $wpdb->query('START TRANSACTION');
+ return $table->transaction(function($table) use ($data) {
+ // Check existing vote
+ $existing = $table->where([
+ 'item_id' => $data['item_id'],
+ 'user_id' => $data['user']
+ ])->first();
- try {
- // Initialize response data
- $response_data = [
- 'item_id' => $data['item_id'],
- 'previous_vote' => $existing_vote,
- 'new_vote' => $data['vote'],
- 'updated' => false
- ];
+ $existing_vote = $existing->vote ?? null;
+ $new_vote = $data['vote'];
- error_log('Existing: '.print_r($existing_vote, true));
- error_log('New: '.print_r($data['vote'], true));
+ // No previous vote - insert new
+ if ($existing_vote === null) {
+ $inserted = $table->create([
+ 'item_id' => $data['item_id'],
+ 'user_id' => $data['user'],
+ 'vote' => $new_vote,
+ ]);
- // If user hasn't voted before
- if ($existing_vote === null) {
- // Insert new vote
- $inserted = $wpdb->insert(
- $table_name,
- [
- 'item_id' => $data['item_id'],
- 'user_id' => $data['user'],
- 'vote' => $data['vote'],
- 'date' => current_time('mysql')
- ],
- ['%d', '%d', '%s', '%s']
- );
+ if (!$inserted) {
+ throw new Exception('Failed to record vote');
+ }
- if (!$inserted) {
- throw new Exception('Failed to record vote');
- }
+ $this->updateVoteCount($data['content'], $data['type'], $data['item_id'], $new_vote, 1);
+ $this->cache->invalidate($data['user']);
- // Increment the appropriate vote counter
- $this->updateVoteCount($data['content'], $data['item_id'], $data['vote'], 1);
+ return [
+ 'success' => true,
+ 'result' => __('Vote recorded', 'jvb'),
+ ];
+ }
- $response_data['updated'] = true;
- $this->cache->invalidate($key);
- } elseif ($existing_vote !== $data['vote']) {
- // If user is changing their vote
- // Update existing vote
- $updated = $wpdb->update(
- $table_name,
- [
- 'vote' => $data['vote'],
- ],
- [
+ // Changing vote
+ if ($existing_vote !== $new_vote) {
+ $updated = $table->where([
+ 'item_id' => $data['item_id'],
+ 'user_id' => $data['user']
+ ])->updateResults(['vote' => $new_vote]);
- 'item_id' => $data['item_id'],
- 'user_id' => $data['user'],
- ],
- ['%s'],
- ['%d', '%d']
- );
+ if (!$updated) {
+ throw new Exception('Failed to update vote');
+ }
- if (!$updated) {
- throw new Exception('Failed to update vote');
- }
+ // Decrement old, increment new
+ $this->updateVoteCount($data['content'], $data['type'], $data['item_id'], $existing_vote, -1);
+ $this->updateVoteCount($data['content'], $data['type'], $data['item_id'], $new_vote, 1);
+ $this->cache->invalidate($data['user']);
- $this->updateVoteCount($data['content'], $data['item_id'], $existing_vote, -1);
+ return [
+ 'success' => true,
+ 'result' => __('Vote updated', 'jvb'),
+ ];
+ }
- // Increment new vote type
- $this->updateVoteCount($data['content'], $data['item_id'], $data['vote'], 1);
+ // Toggle off - remove vote
+ $deleted = $table->where([
+ 'item_id' => $data['item_id'],
+ 'user_id' => $data['user']
+ ])->deleteResults();
- $response_data['updated'] = true;
- $this->cache->invalidate($key);
- } else {
- // If user is clicking the same vote again (toggle off)
- // Remove the vote
- $deleted = $wpdb->delete(
- $table_name,
- [
- 'item_id' => $data['item_id'],
- 'user_id' => $data['user']
- ],
- ['%d', '%d']
- );
+ if (!$deleted) {
+ throw new Exception('Failed to remove vote');
+ }
- if (!$deleted) {
- throw new Exception('Failed to remove vote');
- }
+ $this->updateVoteCount($data['content'], $data['type'], $data['item_id'], $existing_vote, -1);
+ $this->cache->delete($data['user']);
- // Decrement the vote counter
- $this->updateVoteCount($data['content'], $data['item_id'], $data['vote'], -1);
-
- $response_data['new_vote'] = null;
- $response_data['updated'] = true;
- $this->cache->invalidate($key);
- }
-
- $wpdb->query('COMMIT');
-
- return [
- 'success' => true,
- 'result' => __('Vote handled', 'jvb'),
- ];
- } catch (Exception $e) {
- $wpdb->query('ROLLBACK');
- return [
- 'success' => false,
- 'result' => $e->getMessage()
- ];
- }
+ return [
+ 'success' => true,
+ 'result' => __('Vote removed', 'jvb'),
+ ];
+ });
}
/**
@@ -251,51 +217,52 @@
*
* @return void
*/
- protected function updateVoteCount(string $content, int $ID, string $vote, int $value):void
+ protected function updateVoteCount(string $content, string $type, int $ID, string $vote, int $value):void
{
- global $karma;
+ $key = ($vote === 'down') ? BASE.'downvotes' : BASE.'upvotes';
- $key = ($vote==='down') ? BASE.'downvotes' : BASE.'upvotes';
+ switch ($type) {
+ case 'post':
+ $old = (int) get_post_meta($ID, $key, true);
+ $new = max(0, $old + $value);
+ update_post_meta($ID, $key, $new);
- switch ($karma[$content]) {
- case 'post':
- $old = (int) get_post_meta($ID, $key, true);
- $new = max(0, $old + $value);
- update_post_meta($ID, $key, $new);
- $up = (int) get_post_meta($ID, BASE.'upvotes', true);
- $down = (int) get_post_meta($ID, BASE.'downvotes', true);
- update_post_meta($ID, BASE.'karma', $up - $down);
- break;
- case 'term':
- $old = (int) get_term_meta($ID, $key, true);
- $new = max(0, $old + $value);
- update_term_meta($ID, $key, $new);
- $up = (int) get_term_meta($ID, BASE.'upvotes', true);
- $down = (int) get_term_meta($ID, BASE.'downvotes', true);
- update_term_meta($ID, BASE.'karma', $up - $down);
- break;
- case 'user':
- $old = (int) get_user_meta($ID, $key, true);
- $new = max(0, $old + $value);
- update_user_meta($ID, $key, $new);
- $up = (int) get_user_meta($ID, BASE.'upvotes', true);
- $down = (int) get_user_meta($ID, BASE.'downvotes', true);
- update_user_meta($ID, BASE.'karma', $up - $down);
- break;
- case 'response':
- // Direct table update for responses
- global $wpdb;
- $table = $wpdb->prefix . BASE . 'responses';
+ $up = (int) get_post_meta($ID, BASE.'upvotes', true);
+ $down = (int) get_post_meta($ID, BASE.'downvotes', true);
+ update_post_meta($ID, BASE.'karma', $up - $down);
+ break;
- // Update vote count
- $field = str_replace(BASE, '', $key);
- $wpdb->query($wpdb->prepare(
- "UPDATE $table SET $field = GREATEST(0, $field + %d), karma = (upvotes - downvotes) WHERE id = %d",
- $value,
- $ID
- ));
- break;
- }
+ case 'term':
+ $old = (int) get_term_meta($ID, $key, true);
+ $new = max(0, $old + $value);
+ update_term_meta($ID, $key, $new);
+
+ $up = (int) get_term_meta($ID, BASE.'upvotes', true);
+ $down = (int) get_term_meta($ID, BASE.'downvotes', true);
+ update_term_meta($ID, BASE.'karma', $up - $down);
+ break;
+
+ case 'user':
+ $old = (int) get_user_meta($ID, $key, true);
+ $new = max(0, $old + $value);
+ update_user_meta($ID, $key, $new);
+
+ $up = (int) get_user_meta($ID, BASE.'upvotes', true);
+ $down = (int) get_user_meta($ID, BASE.'downvotes', true);
+ update_user_meta($ID, BASE.'karma', $up - $down);
+ break;
+
+ case 'response':
+ $field = str_replace(BASE, '', $key);
+ CustomTable::for('responses')->query(
+ "UPDATE {table}
+ SET $field = GREATEST(0, $field + %d),
+ karma = (upvotes - downvotes)
+ WHERE id = %d",
+ [$value, $ID]
+ );
+ break;
+ }
}
/**
@@ -303,47 +270,39 @@
*
* @return WP_REST_Response
*/
- public function getVotes(WP_REST_Request $request):WP_REST_Response
- {
- $user = $request->get_param('user')??get_current_user_id();
- $cache = $this->cache->get($user);
- if ($cache) {
- return new WP_REST_Response($cache);
- }
+ public function getVotes(WP_REST_Request $request): WP_REST_Response
+ {
+ $user = absint($request->get_param('user') ?? get_current_user_id());
+ $cache = $this->cache->get($user);
+ if ($cache) {
+ return Response::success($cache);
+ }
- global $wpdb;
- $votes = [];
+ $votes = [];
- foreach (jvbGlobalKarma() as $type => $content_types) {
- foreach ($content_types as $content_type) {
- $table_name = $wpdb->prefix . BASE . 'karma_'. $content_type;
+ foreach (jvbGlobalKarma() as $type => $content_types) {
+ foreach ($content_types as $content_type) {
+ $table = CustomTable::for('karma_' . $content_type);
- // Skip if table doesn't exist
- if ($wpdb->get_var("SHOW TABLES LIKE '$table_name'") != $table_name) {
- continue;
- }
+ // Skip if table doesn't exist
+ global $wpdb;
+ if ($wpdb->get_var("SHOW TABLES LIKE '{$table->getFullTableName()}'") != $table->getFullTableName()) {
+ continue;
+ }
- $results = $wpdb->get_results(
- $wpdb->prepare(
- "SELECT item_id, vote, date
- FROM {$table_name}
- WHERE user_id = %d",
- $user
- )
- );
+ $results = $table->where(['user_id' => $user])->getResults();
- if ($results && !is_wp_error($results)) {
- foreach ($results as $vote) {
- $votes[$content_type][$vote->item_id] = $vote->vote;
- }
- }
- }
- }
+ if (!empty($results)) {
+ foreach ($results as $vote) {
+ $votes[$content_type][$vote->item_id] = $vote->vote;
+ }
+ }
+ }
+ }
- // Store in cache
- $this->cache->set($user, $votes);
+ $this->cache->set($user, $votes);
- return new WP_REST_Response($votes);
- }
+ return Response::success($votes);
+ }
}
diff --git a/inc/templates.php b/inc/templates.php
index dd24b96..cf3505d 100644
--- a/inc/templates.php
+++ b/inc/templates.php
@@ -1,4 +1,7 @@
<?php
+
+use JVBase\meta\Form;
+
if (!defined('ABSPATH')) {
exit;
}
@@ -31,25 +34,27 @@
*/
function jvbGetReplyToTemplate():string
{
- $meta = new JVBase\meta\MetaManager();
- ob_start();
- $meta->render('form', 'response', ['quill' => true, 'label'=>'Your Response']);
- $textarea = ob_get_clean();
- return '
+
+
+ $textarea = Form::render('response', null, ['type' => 'textarea','quill' => true, 'label'=>'Your Response']);
+ return sprintf('
<dialog class="create-response">
<div class="wrap col">
<h2>Write your Response</h2>
<div class="original"></div>
<div class="reply">
- '.$textarea.'
+ %s
</div>
<div class="actions row">
- <button type="button" class="cancel">'.jvbIcon('x', ['title'=>'Cancel']).'</button>
+ <button type="button" class="cancel">%s</button>
<button type="submit" class="create">Reply</button>
</div>
</div>
</dialog>
- ';
+ ',
+ $textarea,
+ jvbIcon('x', ['title'=>'Cancel'])
+ );
}
/**
diff --git a/inc/ui/CRUDSkeleton.php b/inc/ui/CRUDSkeleton.php
index 515c0d5..380a25b 100644
--- a/inc/ui/CRUDSkeleton.php
+++ b/inc/ui/CRUDSkeleton.php
@@ -2,8 +2,7 @@
namespace JVBase\ui;
use JVBase\managers\UserTermsManager;
-use JVBase\meta\MetaForm;
-use JVBase\meta\MetaManager;
+use JVBase\meta\Form;
use WP_User;
if (!defined('ABSPATH')) {
@@ -121,10 +120,6 @@
protected $dataSourceCallback = null;
protected array $templates = [];
- // Metadata handling
- protected ?MetaManager $meta = null;
- protected ?MetaForm $form = null;
-
// UI Options
protected array $stuck = []; // Fields that stick when scrolling
protected bool $showHeader = true;
@@ -498,14 +493,6 @@
return $this;
}
- /**
- * Initialize meta handling
- */
- public function initMeta(string $objectType = 'post', ?string $content = null): self {
- $this->meta = new MetaManager(null, $objectType, $content ?? $this->dataType);
- $this->form = new MetaForm();
- return $this;
- }
/**
* Build the configuration array
@@ -540,7 +527,7 @@
$config = $this->build();
$classes = array_merge(['dashboard-page', $this->dataType], $this->additionalClasses);
- ob_start();
+// ob_start();
?>
<div class="<?= esc_attr(implode(' ', $classes)) ?>" data-type="<?= esc_attr($this->dataType) ?>">
<?php
@@ -553,7 +540,7 @@
?>
</div>
<?php
- echo ob_get_clean();
+// echo ob_get_clean();
}
/**
@@ -580,16 +567,13 @@
* Render uploader section
*/
protected function renderUploader(): void {
- if (!$this->meta) {
- return;
- }
?>
<details open class="uploader">
<summary class="row btw"><?= esc_html($this->uploaderConfig['label'] ?? 'Upload Files') ?></summary>
<?php
- $this->meta->render(
- 'form',
+ echo Form::render(
'new_' . $this->dataType,
+ null,
$this->uploaderConfig
);
?>
@@ -1057,9 +1041,9 @@
$temp = array_filter($this->fields, function ($field) {
return in_array($field, $this->timelineUniqueFields);
}, ARRAY_FILTER_USE_KEY);
- $form = new MetaForm();
+
echo '<template class="timelineItem">';
- $form->renderImagePreview(null,['fields' => $temp]);
+ echo Form::renderImagePreview(null, ['fields' => $temp]);
echo '</template>';
}
if (!array_key_exists('empty', $templates)) {
@@ -1265,7 +1249,7 @@
$config['autocomplete'] = true;
}
- echo $this->meta->render('form', $name, $config);
+ echo Form::render($name, null, $config);
echo $makeThisDetailed ? '</details>' : '';
} else {
echo '<p></p>';
@@ -1348,7 +1332,7 @@
$config['autocomplete'] = true;
}
?>
- <?php $this->meta->render('form', $name, $config); ?>
+ <?= Form::render($name, null, $config); ?>
<?= $makeThisDetailed ? '</details>' : '' ?>
</td>
<?php
@@ -1376,7 +1360,7 @@
?>
<td class="field show-<?= esc_attr($name) ?>" data-field="<?= esc_attr($name) ?>" data-field-type="<?=$config['type']?>"<?=(in_array($name, $this->stuck)) ? ' data-stuck':''?>>
<?= $makeThisDetailed ? '<details><summary class="row btw">See Value</summary>' : '' ?>
- <?php $this->meta->render('form', $name, $config); ?>
+ <?= Form::render($name, null, $config); ?>
<?= $makeThisDetailed ? '</details>' : '' ?>
</td>
<?php
@@ -1600,9 +1584,9 @@
foreach ($first as $f) {
if (array_key_exists($f, $fields)) {
if ($tabs) {
- $tabs['basic']['content'] .= $this->meta->render('form', $f, $fields[$f], false, true);
+ $tabs['basic']['content'] .= Form::render($f, null, $fields[$f]);
} else {
- $this->meta->render('form', $f, $fields[$f]);
+ Form::render($f, null, $fields[$f]);
}
unset($fields[$f]);
@@ -1627,12 +1611,12 @@
if (in_array($field['type'], ['taxonomy', 'selector'])) {
$field = array_merge($field, $this->taxConfig($field['taxonomy'], $field['label']));
}
- $content .= $this->form->render($slug, null, $field, false, true);
+ $content .= Form::render($slug, null, $field);
}
}
- $content .= $this->meta->render('form', 'timeline', $config, false,true);
+ $content .= Form::render('timeline', null, $config);
$tabs['progression']['content'] = $content;
$fields = $this->nonTimelineFields;
@@ -1640,12 +1624,12 @@
foreach ($fields as $n => $config) {
if ($tabs) {
$section = (array_key_exists('section', $config)) ? $config['section'] : 'basic';
- $tabs[$section]['content'] .= $this->meta->render('form', $n, $config, false, true);
+ $tabs[$section]['content'] .= Form::render($n,null, $config);
} else {
if (in_array($config['type'], ['taxonomy', 'selector'])) {
$config = array_merge($config, $this->taxConfig($config['taxonomy'], $config['label']));
}
- $this->meta->render('form', $n, $config);
+ Form::render($n, null, $config);
}
}
@@ -1657,6 +1641,7 @@
</form>
<?php
return ob_get_clean();
+// return '';
}
protected function renderEditModal():void
@@ -1690,12 +1675,7 @@
<div class="taxonomies">
<?php
foreach ($this->taxonomies as $taxonomy => $config) {
- $this->form->renderSelectorField(
- 'bulk-edit-'.$taxonomy,
- '',
- $this->taxConfig($taxonomy, $config['label']),
- 'taxonomy'
- );
+ echo Form::render('bulk-edit-'.$taxonomy, '', $this->taxConfig($taxonomy, $config['label']));
}
?>
</div>
@@ -1706,7 +1686,7 @@
return array_key_exists('bulkEdit', $field);
});
foreach ($fields as $fieldName => $config) {
- $this->meta->render('form', $fieldName, $config);
+ echo Form::render($fieldName, null, $config);
}
?>
</div>
diff --git a/src/summary/render.php b/src/summary/render.php
index ca5cde1..f0e00c7 100644
--- a/src/summary/render.php
+++ b/src/summary/render.php
@@ -1,6 +1,8 @@
<?php
use JVBase\managers\Cache;
+use JVBase\meta\Meta;
+use JVBase\meta\Render;
if (!defined('ABSPATH')) {
exit; // Exit if accessed directly
@@ -39,7 +41,7 @@
}
ob_start();
- $meta = new JVBase\meta\MetaManager((int)$current->ID, 'post');
+ $meta = Meta::forPost($current->ID);
$artist = jvbContentFromUser((int)$current->post_author);
$sections = JVB_CONTENT[jvbNoBase($current->post_type)]['sections']??[];
@@ -85,7 +87,7 @@
</li>
</ul>
<?php endif; ?>
- <?php $styles = $meta->getValue('top_styles');
+ <?php $styles = $meta->get('top_styles');
if (!empty($styles)) {
?>
<ul class="term-list style">
@@ -117,10 +119,10 @@
</summary>
<div class="columns stack-small">
<div class="column">
- <?php $meta->render('render', 'image_portrait'); ?>
+ <?= Render::renderFrom($meta, 'image_portrait'); ?>
</div>
<div class="column">
- <?php $meta->render('render', 'short_bio'); ?>
+ <?= Render::renderFrom($meta, 'short_bio'); ?>
</div>
</div>
<div id="styles">
@@ -136,15 +138,15 @@
</div>
<div id="about">
- <?php $meta->render('render', 'bio')?>
+ <?= Render::renderFrom($meta, 'bio')?>
</div>
</details>
</section>
<section id="contact" class="">
<h2>Contact <?=$artist['name']?></h2>
<?php
- echo jvbRenderContactInfo($current->ID, $meta);
- echo jvbRenderLinks($current->ID, $meta);
+ echo jvbRenderContactInfo($current->ID, 'post');
+ echo jvbRenderLinks($current->ID, 'post');
?>
</section>
<?php
@@ -160,23 +162,14 @@
$cache = Cache::for('shop_bio', WEEK_IN_SECONDS)->connect('taxonomy');
$key = $current->term_id;
$cached = $cache->get($key);
- $cached = false;
if ($cached !== false) {
return $cached;
}
ob_start();
- $meta = new JVBase\meta\MetaManager($current->term_id, 'term');
- $rating = $meta->getValue('average_rating');
- $opened = $meta->getValue('established');
- $about = $meta->getValue('bio');
- $location = $meta->getValue('location');
- $hours = $meta->getValue('hours');
- $specialty = $meta->getValue('specialties');
- $awards = $meta->getValue('awards');
- $reviews = $meta->getValue('reviews');
-
+ $meta = Meta::forTerm($current->term_id);
+ $fields = $meta->getAll(['average_rating', 'established', 'bio','location','hours','specialties','awards','reviews']);
?>
<nav id="shop" class="on-this-page index">
<label>Jump to:
@@ -186,35 +179,35 @@
</label>
<ul>
<li><a href="#top" title="Back to Top"><?=jvbIcon('caret-circle-up')?></a></li> <?php
- if ($rating !== 'none') {
+ if ($fields['rating'] !== 'none') {
?>
<li><a href="#rating">Rating</a></li>
<?php
- } elseif ($opened !== '') {
+ } elseif ($fields['opened'] !== '') {
?>
<li><a href="#opened">Opened</a></li>
<?php
- } elseif ($location !== '') {
+ } elseif ($fields['location'] !== '') {
?>
<li><a href="#location">Location</a></li>
<?php
- } elseif ($about !== '') {
+ } elseif ($fields['about'] !== '') {
?>
<li><a href="#about">About</a></li>
<?php
- } elseif ($hours !== '') {
+ } elseif ($fields['hours'] !== '') {
?>
<li><a href="#hours">Hours</a></li>
<?php
- } elseif ($specialty !== '') {
+ } elseif ($fields['specialties'] !== '') {
?>
<li><a href="#specialties">Specialties</a></li>
<?php
- } elseif ($awards !== '') {
+ } elseif ($fields['awards'] !== '') {
?>
<li><a href="#awards">Awards</a></li>
<?php
- } elseif ($reviews !== '') {
+ } elseif ($fields['reviews'] !== '') {
?>
<li><a href="#reviews">Reviews</a></li>
<?php
@@ -227,17 +220,17 @@
<header id="top">
<div class="columns stack-small">
<div class="column">
- <?=jvbFormatImage($meta->getValue('image'))?>
+ <?=jvbFormatImage($meta->get('image'))?>
</div>
<div class="column">
<h1>
- <small><?= (get_term((int)$meta->getValue('city'), BASE.'city')) ?
- get_term((int)$meta->getValue('city'), BASE.'city')->name :
+ <small><?= (get_term((int)$meta->get('city'), BASE.'city')) ?
+ get_term((int)$meta->get('city'), BASE.'city')->name :
'Edmonton'?>'s Best Tattoo Shops</small>
<?=$current->name?>
</h1>
- <?= jvbFormatRating($current->term_id, $meta) ?>
- <?php $meta->render('render', 'slogan'); ?>
+ <?= jvbFormatRating($current->term_id, 'term') ?>
+ <?= Render::renderFrom($meta, 'slogan'); ?>
</div>
</div>
</header>
@@ -247,33 +240,33 @@
<h2>Learn More About <?=$current->name?></h2>
</summary>
<div class="map">
- <?php $meta->render('render', 'location'); ?>
+ <?= Render::renderFrom($meta, 'location'); ?>
</div>
<div class="short-bio">
- <?php $meta->render('render', 'short_bio'); ?>
+ <?= Render::renderFrom($meta, 'short_bio'); ?>
</div>
<div class="contact">
<h3>Contact:</h3>
<?php
- echo jvbRenderContactInfo($current->term_id, $meta);
- echo jvbRenderLinks($current->term_id, $meta);
+ echo jvbRenderContactInfo($current->term_id, 'term');
+ echo jvbRenderLinks($current->term_id, 'term');
?>
</div>
<div id="about">
- <?php $meta->render('render', 'bio')?>
+ <?= Render::renderFrom($meta, 'bio')?>
</div>
</details>
</section>
<section id="contact" class="">
<h2>Contact </h2>
<?php
- echo jvbRenderContactInfo($current->term_id, $meta);
- echo jvbRenderLinks($current->term_id, $meta);
+ echo jvbRenderContactInfo($current->term_id, 'term');
+ echo jvbRenderLinks($current->term_id, 'term');
?>
</section>
- <?= jvbRenderHours($current->term_id, $meta)?>
+ <?= jvbRenderHours($current->term_id, 'term')?>
<?php
@@ -306,7 +299,7 @@
$title = '';
}
- $meta = new JVBase\meta\MetaManager($current->ID, 'term');
+ $meta = Meta::forTerm($current->ID);
$fields = JVB_TAXONOMY[$tax]['fields']??[];
?>
diff --git a/templates/dashboard/sections/news.php b/templates/dashboard/sections/news.php
index 242f4c5..29e7757 100644
--- a/templates/dashboard/sections/news.php
+++ b/templates/dashboard/sections/news.php
@@ -1,6 +1,7 @@
<?php
use JVBase\managers\Cache;
+use JVBase\meta\Form;
if (!defined('ABSPATH')) {
exit; // Exit if accessed directly
@@ -259,9 +260,9 @@
</div>
<?php
$handler = JVB()->getContent('news');
- $meta = new JVBase\meta\MetaManager();
+
foreach ($handler->getFields() as $field_name => $field_config) {
- $meta->render('form', $field_name, $field_config);
+ echo Form::render($field_name, null, $field_config);
}
?>
--
Gitblit v1.10.0