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