Jake Vanderwerf
2026-05-11 ac444cba221832c012c0435fdc8339fe9f37febb
inc/managers/ReferralManager.php
@@ -1,12 +1,10 @@
<?php
namespace JVBase\managers;
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;
use JVBase\base\Site;
use WP_User;
use WP_Error;
@@ -24,11 +22,19 @@
{
   protected $wpdb;
   protected MagicLinkManager $magic_link;
   protected CacheManager $cache;
   protected Cache $cache;
   protected Cache $requestCache;
   protected Cache $statsCache;
   protected string $referrals_table;
   protected ?int $referralPage = null;
   protected string $rewards_table;
   protected CustomTable $referrals;
   protected CustomTable $codes;
   protected CustomTable $janeClients;
   protected CustomTable $rewards;
   protected CustomTable $treatments;
   // Default reward settings
   protected array $default_settings = [
      'referrer_reward_applies_to' => 'per_user',  // 'per_user' or 'flat_total'
@@ -37,18 +43,28 @@
      'referee_reward_type' => 'percentage',  // 'percentage' or 'fixed'
      'referee_reward_amount' => 20,  // 20% or $20
      'referee_reward_applies_to' => 'first_order',  // 'first_order' or 'all_orders'
      'referral_role'   => BASE.'client'
   ];
   protected string $role = BASE.'client';
   protected string $role;
   protected array $settings;
   public function __construct()
   {
      $this->defineTables();
      $this->role = Site::getDefaultReferralRole();
      $this->default_settings['referral_role'] = $this->role;
      global $wpdb;
      $this->wpdb = $wpdb;
      $this->cache = CacheManager::for('referrals', WEEK_IN_SECONDS);
      $this->cache = Cache::for('referrals', WEEK_IN_SECONDS);
      $this->requestCache = Cache::for('referral_requests', WEEK_IN_SECONDS)->connect('referrals', true);
      $this->statsCache = Cache::for('referral_stats', WEEK_IN_SECONDS)->connect('referrals', true);
      if (JVB_TESTING) {
         $this->cache->flush();
         $this->requestCache->flush();
         $this->statsCache->flush();
      }
      $this->referrals_table = $wpdb->prefix . BASE . 'referrals';
      $this->rewards_table = $wpdb->prefix . BASE . 'referral_rewards';
@@ -58,7 +74,7 @@
      add_action('jvbUserRegistered', [$this, 'processRegistrationToken'], 10, 3);
      add_action('jvb_add_token_inputs', [$this, 'addLoginInputs'], 10, 1);
      add_action('jvbUserRegistered', [$this, 'processReferral'], 10, 1);
      add_action('user_register', [$this, 'processReferral'], 10, 1);
      // Add meta boxes for admin to manage referrals
      add_action('show_user_profile', [$this, 'displayUserReferralInfo']);
@@ -92,6 +108,182 @@
      add_filter('jvb_admin_page_submission', [$this, 'handleAdminSubmission'], 10, 3);
   }
   protected function defineTables():void
   {
      $this->defineReferralsTable();
      $this->defineCodeTable();
      $this->defineJaneClientsTable();
      $this->defineRewardsTable();
      $this->defineTreatmentsTable();
   }
      protected function defineReferralsTable():void
      {
         $table = CustomTable::for('referrals');
         $table->setColumns([
            'id'     => 'bigint(20) unsigned NOT NULL AUTO_INCREMENT',
            'from_user' => "{$table->getUserIDType()} NOT NULL",
            'to_user'   => "{$table->getUserIDType()} NOT NULL",
            'to_name'   => 'varchar(255) NOT NULL',
            'to_email'  => 'varchar(255) NOT NULL',
            'to_phone'  => 'varchar(50) NOT NULL',
            'referral_code'=> 'varchar(50) NOT NULL',
            'status' => "ENUM('pending', 'consulted', 'treated', 'cancelled') DEFAULT 'pending'",
            'created_at'=> 'datetime DEFAULT CURRENT_TIMESTAMP',
            'consulted_at'=> 'datetime DEFAULT NULL',
            'treated_at'=> 'datetime DEFAULT NULL',
            'treatment_count' => 'int DEFAULT 0',
            'notes'     => 'text DEFAULT NULL',
         ]);
         $table->setKeys([
            ['key' => 'PRIMARY', 'value' => 'id'],
            ['key' => 'UNIQUE', 'value' => '(`to_user`)'],
            'from_user (`from_user`)',
            'status (`status`)',
            'code (`referral_code`)',
            'date (`created_at`)',
            'consult (`consulted_at`)'
         ]);
         $base = BASE;
         $table->setConstraints([
            "CONSTRAINT `{$base}referral_from_user_fk` FOREIGN KEY (`from_user`)
            REFERENCES `{$table->getUserTable()}` (`ID`) ON DELETE CASCADE",
            "CONSTRAINT `{$base}referral_to_user_fk` FOREIGN KEY (`to_user`)
            REFERENCES `{$table->getUserTable()}` (`ID`) ON DELETE CASCADE"
         ]);
         $table->defineTable();
         $this->referrals = $table;
      }
      protected function defineCodeTable():void
      {
         $table = CustomTable::for('referrals_codes');
         $table->setColumns([
            'id'     => 'bigint(20) unsigned NOT NULL AUTO_INCREMENT',
            'user_id'   => "{$table->getUserIDType()} NOT NULL",
            'code'      => 'varchar(50) NOT NULL',
            'created_at'=> 'datetime DEFAULT CURRENT_TIMESTAMP',
         ]);
         $table->setKeys([
            ['key' => 'PRIMARY', 'value' => '(`id`)'],
            ['key' => 'UNIQUE', 'value' => '(`code`)'],
            'user (`user_id`)',
         ]);
         $base = BASE;
         $table->setConstraints([
            "CONSTRAINT `{$base}referral_code_user_fk` FOREIGN KEY (`user_id`)
               REFERENCES `{$table->getUserTable()}` (`ID`) ON DELETE CASCADE",
         ]);
         $table->defineTable();
         $this->codes = $table;
      }
      protected function defineJaneClientsTable():void
      {
         $table = CustomTable::for('referrals_jane_clients');
         $table->setColumns([
            'id'        => 'bigint(20) unsigned NOT NULL AUTO_INCREMENT',
            'patient_guid' => 'varchar(50) NOT NULL',
            'user_id'      => "{$table->getUserIDType()} NOT NULL",
            'first_name'   => 'varchar(100) NOT NULL',
            'last_name'    => 'varchar(100) NOT NULL',
            'email'        => 'varchar(255) NOT NULL',
            'imported_at'  => 'datetime DEFAULT CURRENT_TIMESTAMP',
            'updated_at'   => 'datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP',
         ]);
         $table->setKeys([
            ['key' => 'PRIMARY', 'value' => '(`id`)'],
            ['key' => 'UNIQUE', 'value' => '(`patient_guid`)'],
            'user (`user_id`)',
            'email (`email`)',
         ]);
         $base = BASE;
         $table->setConstraints([
            "CONSTRAINT `{$base}jane_clients_user` FOREIGN KEY (`user_id`)
            REFERENCES `{$table->getUserTable()}` (`ID`) ON DELETE CASCADE",
         ]);
         $table->defineTable();
         $this->janeClients = $table;
      }
      protected function defineRewardsTable():void
      {
         $table = CustomTable::for('referrals_rewards');
         $table->setColumns([
            'id'           => 'bigint(20) unsigned NOT NULL AUTO_INCREMENT',
            'referral_id'     => 'bigint(20) unsigned NOT NULL',
            'user_id'         => "{$table->getUserIDType()} NOT NULL",
            'reward_type'     => "ENUM('referrer', 'referee') NOT NULL",
            'amount'       => 'decimal(10,2) NOT NULL',
            'reward_calculation'=> "ENUM('percentage', 'fixed')",
            'status'       => "ENUM('available', 'redeemed', 'expired', 'cancelled') DEFAULT 'available'",
            'created_at'      => 'datetime NOT NULL DEFAULT CURRENT_TIMESTAMP',
            'updated_at'      => 'datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP',
            'redeemed_at'     => 'datetime DEFAULT NULL',
            'expires_at'      => 'datetime DEFAULT NULL',
            'notes'           => 'text DEFAULT NULL',
         ]);
         $table->setKeys([
            ['key' => 'PRIMARY', 'value' => '(`id`)'],
            'referral (`referral_id`)',
            'user (`user_id`)',
            'status (`status`)',
            'type (`reward_type`)'
         ]);
         $base = BASE;
         $table->setConstraints([
            "CONSTRAINT `{$base}reward_referral` FOREIGN KEY (`referral_id`)
            REFERENCES `{$this->referrals->getFullTableName()}` (`id`) ON DELETE CASCADE",
            "CONSTRAINT `{$base}reward_user` FOREIGN KEY (`user_id`)
            REFERENCES `{$table->getUserTable()}` (`ID`) ON DELETE CASCADE"
         ]);
         $table->defineTable();
         $this->rewards = $table;
      }
      protected function defineTreatmentsTable():void
      {
         $table = CustomTable::for('referral_treatments');
         $table->setColumns([
            'id'        => 'bigint(20) unsigned NOT NULL AUTO_INCREMENT',
            'referral_id'  => 'bigint(20) unsigned NOT NULL',
            'user_id'      => "{$table->getUserIDType()} NOT NULL",
            'treatment_type'=> 'varchar(100) NOT NULL',     //Tier 1-6, Brows, etc
            'treatment_date'=> 'datetime NOT NULL',
            'invoice_number'=> 'varchar(50) DEFAULT NULL',
            'amount'    => 'decimal(10,2) DEFAULT NULL',
            'status'    => "ENUM('completed', 'no_show', 'cancelled') DEFAULT 'completed'",
            'imported_at'  => 'datetime DEFAULT CURRENT_TIMESTAMP',
         ]);
         $table->setKeys([
            ['key' => 'PRIMARY', 'value' => '(`id`)'],
            'referral (`referral_id`)',
            'user (`user_id`)',
            'date (`treatment_date`)',
            'type (`treatment_type`)',
         ]);
         $base = BASE;
         $table->setConstraints([
            "CONSTRAINT `{$base}treatment_referral` FOREIGN KEY (`referral_id`)
            REFERENCES `{$this->referrals->getFullTableName()}` (`id`) ON DELETE CASCADE",
            "CONSTRAINT `{$base}treatment_user` FOREIGN KEY (`user_id`)
            REFERENCES `{$table->getUserTable()}` (`ID`) ON DELETE CASCADE"
         ]);
         $table->defineTable();
         $this->treatments = $table;
      }
   public function getSettings():array
   {
      return $this->settings;
@@ -141,12 +333,18 @@
         'jvb-data-store',
      ];
      if (Features::hasIntegration('cloudflare') && JVB()->connect('cloudflare')->isSetUp()) {
         $requirements[] = 'cloudflare-turnstile';
      if (Site::hasIntegration('cloudflare') && JVB()->connect('cloudflare')->isSetUp()) {
         JVB()->connect('cloudflare')->enqueueTurnstileScripts();
      }
      if (is_singular(BASE.'dash')) {
         $requirements[] = 'jvb-form';
         $requirements[] = 'jvb-view';
         wp_enqueue_script('jvb-referral-admin',
         JVB_URL.'assets/js/min/referralAdmin.min.js',
         ['jvb-referral'],
         '1.0.0',
         true);
      }
      wp_enqueue_script(
         'jvb-referral',
@@ -175,6 +373,26 @@
      }
   }
   public function createCode(int $user_id, string $code):string|false
   {
      $code = sanitize_title($code);
      $existing = $this->codes->get(['code' => $code]);
      if ($existing) {
         if ($existing['user_id'] !== $user_id) {
            return false;
         }
         return $code;
      }
      $success = $this->codes->insert([
         'user_id'   => $user_id,
         'code'      => $code
      ]);
      if ($success) {
         return $code;
      }
      return false;
   }
   /**
    * Generate or get existing referral code for a user
    *
@@ -182,32 +400,35 @@
    * @param string|null $custom_code Optional custom code
    * @return string|WP_Error
    */
   public function getUserReferralCode(int $user_id, ?string $custom_code = null)
   public function getUserReferralCode(int $user_id, ?string $custom_code = null):string|false
   {
      $user = get_userdata($user_id);
      if (!$user) {
         return new WP_Error('invalid_user', 'User not found');
         return false;
      }
      // Check if user already has a code
      $existing_code = get_user_meta($user_id, BASE . 'referral_code', true);
      if ($existing_code && !$custom_code) {
         return $existing_code;
      $existing = $this->codes->pluck('code', ['user_id' => $user_id],'created_at', 'DESC');
      if (!empty($existing) && !$custom_code) {
         return $existing[0];
      }
      if ($custom_code && !empty($existing) && !in_array($custom_code, $existing)) {
         $test = $this->createCode($user_id, $custom_code);
         if ($test) {
            return $this->codes->pluck('code', ['user_id' => $user_id], 'created_at', 'DESC')[0];
         } else {
            return $existing[0];
         }
      }
      // Generate new code if custom provided or none exists
      $code = $custom_code ?: $this->generateReferralCode($user);
      $code = $this->generateReferralCode($user);
      // Validate uniqueness
      if ($this->isCodeTaken($code, $user_id)) {
         return new WP_Error('code_taken', 'This referral code is already in use');
      $success = $this->createCode($user_id, $code);
      if ($success) {
         return $this->codes->pluck('code', ['user_id' => $user_id], 'created_at', 'DESC')[0];
      }
      // Save the code
      update_user_meta($user_id, BASE . 'referral_code', $code);
      return $code;
      return false;
   }
   /**
@@ -240,24 +461,11 @@
    * Check if a referral code is already taken
    *
    * @param string $code
    * @param int|null $exclude_user_id
    * @return bool
    */
   protected function isCodeTaken(string $code, ?int $exclude_user_id = null): bool
   protected function isCodeTaken(string $code): bool
   {
      $args = [
         'meta_key' => BASE . 'referral_code',
         'meta_value' => $code,
         'fields' => 'ID',
         'number' => 1
      ];
      if ($exclude_user_id) {
         $args['exclude'] = [$exclude_user_id];
      }
      $users = get_users($args);
      return !empty($users);
      return (bool) $this->codes->get(['code' => $code]);
   }
   public function processRegistrationToken(int $user_id, string $email, array $data): void
@@ -284,60 +492,58 @@
    * Track a new referral when user registers
    *
    * @param int $user_id
    * @param array $userData
    * @return bool;
    */
   public function processReferral(int $user_id): bool
   public function processReferral(int $user_id, array $userData): bool
   {
      // Try to get code from user meta first (set during registration)
      $referral_code = get_user_meta($user_id, BASE . 'pending_referral_code', true);
      $referral = $this->referrals->get(['to_user' => $user_id]);
      if (empty($referral_code)) {
      if (empty($referral)) {
         $referral = $this->referrals->get(['to_email' => $userData['email']]);
      }
      if (empty($referral)) {
         // Check session/cookie if not in meta
         if (session_status() === PHP_SESSION_NONE) {
            session_start();
         }
         $referral_code = $_SESSION[BASE . 'referral_code'] ?? $_COOKIE[BASE . 'referral_code'] ?? '';
         if (!empty ($referral_code)) {
            $referral = [
               'to_user'   => $user_id,
               'referral_code'   => $referral_code,
               'to_email'     => $userData['user_email']
            ];
         }
      }
      if (empty($referral_code)) {
      if (empty($referral)) {
         return false; // No referral code - regular registration
      }
      // Find the referrer
      $referrer = $this->getUserByReferralCode($referral_code);
      if (!$referrer) {
         delete_user_meta($user_id, BASE . 'pending_referral_code');
      $referrer = $this->codes->pluck('user_id', ['code' => $referral['referral_code']]);
      if (empty($referrer)) {
         //This should not happen, but whatever
         return false;
      }
      $referrer = $referrer[0];
      $record = $this->referrals->findOrCreate([
         'to_user'   => $user_id,
         'referral_code'   => $referral['referral_code'],
      ], [
         'from_user'    => $referrer,
         'to_email'     => $referral['to_email'],
         'to_name'      => $userData['first_name'],
//       'to_phone'     =>
         'status'    => 'pending'
      ]);
      if (!$record) {
         error_log('[ReferralManager]::processReferral Could not update record for user: '.print_r($referral, true));
         return false;
      }
      $user = get_userdata($user_id);
      // Check if referral already exists for this user
      $existing = $this->wpdb->get_row($this->wpdb->prepare(
         "SELECT * FROM {$this->referrals_table}
      WHERE referrer_id = %d AND (referee_email = %s OR referee_id = %d)",
         $referrer->ID,
         $user->user_email,
         $user_id
      ));
      if (!$existing) {
         // Create new referral record - referred_at captures registration time
         $this->wpdb->insert(
            $this->referrals_table,
            [
               'referrer_id' => $referrer->ID,
               'referee_id' => $user_id,
               'referee_name' => $user->display_name,
               'referee_email' => $user->user_email,
               'referee_phone' => get_user_meta($user_id, BASE . 'phone', true) ?: '',
               'referral_code' => $referral_code,
               'status' => 'pending', // pending first treatment
               'referred_at' => current_time('mysql') // When they registered
            ],
            ['%d', '%d', '%s', '%s', '%s', '%s', '%s', '%s']
         );
      }
      // Clean up temp data
      delete_user_meta($user_id, BASE . 'pending_referral_code');
@@ -349,13 +555,13 @@
      }
      // Clear caches
      $this->cache->clear();
      $this->cache->flush();
      // Fire action for tracking
      do_action('jvb_referral_processed', $user_id, $referrer->ID, $referral_code);
      do_action('jvb_referral_processed', $user_id, $referrer->ID, $referral['referral_code']);
      // Send notification to referrer
      $this->sendReferrerNotification($referrer->ID, $user->display_name);
      $this->sendReferrerNotification($referrer->ID, $userData['display_name']);
      return true;
   }
@@ -513,19 +719,19 @@
    */
   public function getUserReferrals(int $user_id, array $args = []): array
   {
      return $this->cache->remember(
         $user_id,
      $defaults = [
         'status' => 'all',
         'limit' => 100,
         'offset' => 0,
         'orderby' => 'referred_at',
         'order' => 'DESC'
      ];
      $args = wp_parse_args($args, $defaults);
      return $this->requestCache->remember(
         $this->requestCache->generateKey(array_merge(['user'=>$user_id], $args)),
         function() use ($user_id, $args) {
            $defaults = [
               'status' => 'all',
               'limit' => 100,
               'offset' => 0,
               'orderby' => 'referred_at',
               'order' => 'DESC'
            ];
            $args = wp_parse_args($args, $defaults);
            $where = $this->wpdb->prepare("WHERE referrer_id = %d", $user_id);
            if ($args['status'] !== 'all') {
@@ -569,37 +775,33 @@
    */
   public function getUserStats(int $user_id): array
   {
      $cache_key = 'stats_' . $user_id;
      $cached = $this->cache->get($cache_key);
      if ($cached !== false) {
         return $cached;
      }
      $stats = $this->wpdb->get_row($this->wpdb->prepare(
         "SELECT
      return $this->statsCache->remember(
         $user_id,
         function() use ($user_id) {
            $stats = $this->wpdb->get_row($this->wpdb->prepare(
               "SELECT
         COUNT(*) as code_used,
         SUM(CASE WHEN status IN ('consulted', 'treated') THEN 1 ELSE 0 END) as consultations,
         SUM(CASE WHEN status = 'treated' THEN 1 ELSE 0 END) as treatments,
         SUM(CASE WHEN status = 'pending' THEN 1 ELSE 0 END) as pending
      FROM {$this->referrals_table}
      WHERE referrer_id = %d",
         $user_id
      ), ARRAY_A);
               $user_id
            ), ARRAY_A);
      // Get total rewards earned (available + redeemed)
      $rewards = $this->wpdb->get_var($this->wpdb->prepare(
         "SELECT SUM(amount)
            // Get total rewards earned (available + redeemed)
            $rewards = $this->wpdb->get_var($this->wpdb->prepare(
               "SELECT SUM(amount)
      FROM {$this->rewards_table}
      WHERE user_id = %d AND reward_type = 'referrer'",
         $user_id
      ));
               $user_id
            ));
      $stats['total_rewards'] = floatval($rewards ?? 0);
      $stats['user_id'] = $user_id;
      $this->cache->set($cache_key, $stats, HOUR_IN_SECONDS);
      return $stats;
            $stats['total_rewards'] = floatval($rewards ?? 0);
            $stats['user_id'] = $user_id;
            return $stats;
         }
      );
   }
   /**
@@ -611,20 +813,23 @@
    */
   public function getTopReferrers(int $limit = 10, string $period = 'all'): array
   {
      $where = '';
      return $this->statsCache->remember(
         $this->statsCache->generateKey(['limit'=>$limit, 'period' => $period]),
         function() use ($limit, $period) {
            $where = '';
      if ($period !== 'all') {
         $date_where = match($period) {
            'day' => "referred_at >= DATE_SUB(NOW(), INTERVAL 1 DAY)",
            'week' => "referred_at >= DATE_SUB(NOW(), INTERVAL 1 WEEK)",
            'month' => "referred_at >= DATE_SUB(NOW(), INTERVAL 1 MONTH)",
            default => "1=1"
         };
            if ($period !== 'all') {
               $date_where = match($period) {
                  'day' => "referred_at >= DATE_SUB(NOW(), INTERVAL 1 DAY)",
                  'week' => "referred_at >= DATE_SUB(NOW(), INTERVAL 1 WEEK)",
                  'month' => "referred_at >= DATE_SUB(NOW(), INTERVAL 1 MONTH)",
                  default => "1=1"
               };
         $where = "WHERE {$date_where}";
      }
               $where = "WHERE {$date_where}";
            }
      $query = "SELECT
            $query = "SELECT
                    referrer_id,
                    COUNT(*) as referral_count,
                    SUM(CASE WHEN status = 'treated' THEN 1 ELSE 0 END) as treated_count
@@ -634,16 +839,19 @@
                  ORDER BY referral_count DESC
                  LIMIT {$limit}";
      $results = $this->wpdb->get_results($query);
            $results = $this->wpdb->get_results($query);
      // Enrich with user data
      foreach ($results as &$result) {
         $user = get_user_by('ID', $result->referrer_id);
         $result->user_name = $user ? $user->display_name : 'Unknown';
         $result->user_email = $user ? $user->user_email : '';
      }
            // Enrich with user data
            foreach ($results as &$result) {
               $user = get_user_by('ID', $result->referrer_id);
               $result->user_name = $user ? $user->display_name : 'Unknown';
               $result->user_email = $user ? $user->user_email : '';
            }
      return $results;
            return $results;
         }
      );
   }
   /**
@@ -653,11 +861,8 @@
   {
      $yesterday = date('Y-m-d', strtotime('-1 day'));
      // Get new referrals from yesterday
      $new_referrals = $this->wpdb->get_results($this->wpdb->prepare(
         "SELECT
            r.*,
            u.display_name as referrer_name
         "SELECT r.*, u.display_name as referrer_name
        FROM {$this->referrals_table} r
        JOIN {$this->wpdb->users} u ON r.referrer_id = u.ID
        WHERE DATE(r.referred_at) = %s
@@ -665,48 +870,46 @@
         $yesterday
      ));
      // Only send if there's at least 1 new referral
      if (empty($new_referrals)) {
         return;
      }
      // Build email content
      $content = '<h2>Daily Referral Report</h2>';
      $content .= '<p><strong>' . count($new_referrals) . '</strong> new referral' .
         (count($new_referrals) !== 1 ? 's' : '') . ' yesterday (' . $yesterday . ')</p>';
      $content = JVB()->email()->h1('Daily Referral Report');
      $content .= JVB()->email()->stat(
         count($new_referrals),
         count($new_referrals) === 1 ? 'New Referral' : 'New Referrals',
         'From ' . $yesterday
      );
      $content .= '<table style="width:100%; border-collapse: collapse; margin: 20px 0;">';
      $content .= '<thead><tr style="background: #f5f5f5;">';
      $content .= '<th style="padding: 10px; text-align: left; border: 1px solid #ddd;">Referee</th>';
      $content .= '<th style="padding: 10px; text-align: left; border: 1px solid #ddd;">Email</th>';
      $content .= '<th style="padding: 10px; text-align: left; border: 1px solid #ddd;">Referrer</th>';
      $content .= '<th style="padding: 10px; text-align: left; border: 1px solid #ddd;">Code</th>';
      $content .= '</tr></thead><tbody>';
      $content .= JVB()->email()->spacer(20);
      $content .= JVB()->email()->h2('New Referrals');
      // Build list of referrals
      foreach ($new_referrals as $ref) {
         $content .= '<tr>';
         $content .= sprintf('<td style="padding: 10px; border: 1px solid #ddd;">%s</td>',
            esc_html($ref->referee_name));
         $content .= sprintf('<td style="padding: 10px; border: 1px solid #ddd;">%s</td>',
            esc_html($ref->referee_email));
         $content .= sprintf('<td style="padding: 10px; border: 1px solid #ddd;">%s</td>',
            esc_html($ref->referrer_name));
         $content .= sprintf('<td style="padding: 10px; border: 1px solid #ddd;">%s</td>',
            esc_html($ref->referral_code));
         $content .= '</tr>';
         $cardContent = sprintf(
            '<p><strong>%s</strong> (%s)</p>',
            esc_html($ref->referee_name),
            esc_html($ref->referee_email)
         );
         $cardContent .= sprintf(
            '<p style="font-size:13px;color:%s;">Referred by: %s | Code: %s</p>',
            JVB()->email()->colours['dark-200'],
            esc_html($ref->referrer_name),
            JVB()->email()->badge($ref->referral_code, 'info')
         );
         $content .= JVB()->email()->card($cardContent);
      }
      $content .= '</tbody></table>';
      // Get admin email
      $to = get_option('admin_email');
      $subject = sprintf('[%s] %d New Referral%s',
      $subject = sprintf(
         '[%s] %d New Referral%s',
         get_bloginfo('name'),
         count($new_referrals),
         count($new_referrals) !== 1 ? 's' : '');
         count($new_referrals) !== 1 ? 's' : ''
      );
      JVB()->email()->sendEmail($to, $subject, $content);
      JVB()->email()->sendEmail($to, $subject, $content, 'DAILY REPORT');
   }
   /**
@@ -717,19 +920,50 @@
      $top_referrers = $this->getTopReferrers(10, 'week');
      $total_referrals = $this->wpdb->get_var(
         "SELECT COUNT(*) FROM {$this->referrals_table}
             WHERE referred_at >= DATE_SUB(NOW(), INTERVAL 1 WEEK)"
         WHERE referred_at >= DATE_SUB(NOW(), INTERVAL 1 WEEK)"
      );
      if ($total_referrals == 0) {
         return;
      }
      $content = JVB()->email()->h1('Weekly Referral Summary');
      $content .= JVB()->email()->stat(
         $total_referrals,
         'Total Referrals',
         'This week'
      );
      $content .= JVB()->email()->spacer(30);
      $content .= JVB()->email()->h2('Top 10 Referrers');
      // Leaderboard style
      $rank = 1;
      foreach ($top_referrers as $referrer) {
         $rankBadge = $rank <= 3
            ? JVB()->email()->badge('#' . $rank, $rank === 1 ? 'success' : 'info')
            : '<span style="font-weight:600;color:' . JVB()->email()->colours['dark-200'] . ';">#' . $rank . '</span>';
         $cardContent = sprintf(
            '<p>%s <strong>%s</strong></p>',
            $rankBadge,
            esc_html($referrer->user_name)
         );
         $stats = [
            JVB()->email()->stat($referrer->referral_count, 'Total Referrals'),
            JVB()->email()->stat($referrer->treated_count, 'Treated')
         ];
         $cardContent .= JVB()->email()->grid($stats, 2);
         $content .= JVB()->email()->card($cardContent);
         $rank++;
      }
      $to = get_option('admin_email');
      $subject = '[' . get_bloginfo('name') . '] Weekly Referral Summary - ' . date('F j, Y');
      $message = $this->generateWeeklyReportEmail($top_referrers, $total_referrals);
      JVB()->email()->sendEmail($to, $subject, $message);
      JVB()->email()->sendEmail($to, $subject, $content, 'WEEKLY SUMMARY');
   }
   /**
@@ -740,69 +974,30 @@
    */
   protected function generateCSV(array $referrals): string
   {
      $csv = "Referred By,Referee Name,Referee Email,Referee Phone,Referral Code,Status,Referred At,Treated At\n";
      $cache = Cache::for('referralCSV', HOUR_IN_SECONDS)->connect('referrals');
      return $cache->remember(
         'csv',
         function () use ($referrals) {
            $csv = "Referred By,Referee Name,Referee Email,Referee Phone,Referral Code,Status,Referred At,Treated At\n";
      foreach ($referrals as $referral) {
         $csv .= sprintf(
            '"%s","%s","%s","%s","%s","%s","%s","%s"' . "\n",
            $referral->referrer_name ?? 'Unknown',
            $referral->referee_name,
            $referral->referee_email,
            $referral->referee_phone,
            $referral->referral_code,
            $referral->status,
            $referral->referred_at,
            $referral->treated_at ?? 'Not yet'
         );
      }
            foreach ($referrals as $referral) {
               $csv .= sprintf(
                  '"%s","%s","%s","%s","%s","%s","%s","%s"' . "\n",
                  $referral->referrer_name ?? 'Unknown',
                  $referral->referee_name,
                  $referral->referee_email,
                  $referral->referee_phone,
                  $referral->referral_code,
                  $referral->status,
                  $referral->referred_at,
                  $referral->treated_at ?? 'Not yet'
               );
            }
      return $csv;
   }
   /**
    * Generate HTML email for daily report
    *
    * @param array $referrals
    * @param string $period
    * @return string
    */
   protected function generateReportEmail(array $referrals, string $period): string
   {
      $count = count($referrals);
      $content = sprintf('<p>You have <strong>%d new referral%s</strong> today.</p>',
         $count,
         $count !== 1 ? 's' : ''
            return $csv;
         }
      );
      $content .= '<table style="width:100%; border-collapse: collapse; margin: 20px 0;">';
      $content .= '<thead><tr style="background: #f5f5f5; text-align: left;">';
      $content .= '<th style="padding: 10px; border: 1px solid #ddd;">Referred By</th>';
      $content .= '<th style="padding: 10px; border: 1px solid #ddd;">New User</th>';
      $content .= '<th style="padding: 10px; border: 1px solid #ddd;">Email</th>';
      $content .= '<th style="padding: 10px; border: 1px solid #ddd;">Status</th>';
      $content .= '<th style="padding: 10px; border: 1px solid #ddd;">Time</th>';
      $content .= '</tr></thead><tbody>';
      foreach ($referrals as $referral) {
         $content .= '<tr>';
         $content .= sprintf('<td style="padding: 10px; border: 1px solid #ddd;">%s</td>',
            esc_html($referral->referrer_name ?? 'Unknown'));
         $content .= sprintf('<td style="padding: 10px; border: 1px solid #ddd;">%s</td>',
            esc_html($referral->referee_name));
         $content .= sprintf('<td style="padding: 10px; border: 1px solid #ddd;">%s</td>',
            esc_html($referral->referee_email));
         $content .= sprintf('<td style="padding: 10px; border: 1px solid #ddd;">%s</td>',
            esc_html(ucfirst($referral->status)));
         $content .= sprintf('<td style="padding: 10px; border: 1px solid #ddd;">%s</td>',
            esc_html(date('g:i A', strtotime($referral->referred_at))));
         $content .= '</tr>';
      }
      $content .= '</tbody></table>';
      $content .= '<p><small>See attached CSV for full details.</small></p>';
      return jvbGetEmailTemplate($content, 'Daily Referral Report');
   }
   /**
@@ -820,31 +1015,22 @@
         $total_referrals !== 1 ? 's' : ''
      );
      $content .= '<h3>Top 10 Referrers This Week</h3>';
      $content .= '<table style="width:100%; border-collapse: collapse; margin: 20px 0;">';
      $content .= '<thead><tr style="background: #f5f5f5; text-align: left;">';
      $content .= '<th style="padding: 10px; border: 1px solid #ddd;">Rank</th>';
      $content .= '<th style="padding: 10px; border: 1px solid #ddd;">User</th>';
      $content .= '<th style="padding: 10px; border: 1px solid #ddd;">Total Referrals</th>';
      $content .= '<th style="padding: 10px; border: 1px solid #ddd;">Treated</th>';
      $content .= '</tr></thead><tbody>';
      $referrers = [];
      $rank = 1;
      foreach ($top_referrers as $referrer) {
         $content .= '<tr>';
         $content .= sprintf('<td style="padding: 10px; border: 1px solid #ddd;">%d</td>', $rank++);
         $content .= sprintf('<td style="padding: 10px; border: 1px solid #ddd;">%s</td>',
            esc_html($referrer->user_name));
         $content .= sprintf('<td style="padding: 10px; border: 1px solid #ddd;">%d</td>',
            $referrer->referral_count);
         $content .= sprintf('<td style="padding: 10px; border: 1px solid #ddd;">%d</td>',
            $referrer->treated_count);
         $content .= '</tr>';
         $referrers[] = [
            'label' => '#' . $rank++ . ' - ' . esc_html($referrer->user_name),
            'value' => sprintf(
               '<strong>Total Referrals:</strong> %d | <strong>Treated:</strong> %d',
               $referrer->referral_count,
               $referrer->treated_count
            )
         ];
      }
      $content .= '</tbody></table>';
      $content .= JVB()->email()->table($referrers, 'Top 10 Referrers This Week');
      return jvbGetEmailTemplate($content, 'Weekly Referral Summary');
      return $content;
   }
   /**
@@ -1060,7 +1246,6 @@
      JVB()->connect('cloudflare')->renderTurnstile();
      $turnstile = ob_get_clean();
      $meta = new MetaForm();
      $reward_text = $this->getRewardText(true);
      // Pre-fill code if from referral link
@@ -1071,29 +1256,55 @@
         $referrer_name = $referrer ? strtok($referrer->display_name, ' ') : '';
      }
      $codeForm = '<div class="referral-reward-banner">
      '.jvbIcon('confetti').'
      <h4>Get ' . esc_html($reward_text) . '!</h4>
      ' . ($referrer_name ? '<p>' . esc_html($referrer_name) . ' invited you to join us</p>' : '') . '
   </div>
   <form id="referral-code-form">
            '.jvbFormStatus(). '
    <input type="hidden" name="user_select" value="' . esc_attr(get_option(BASE.'referral_role','client')) . '">
    ' .$meta->return('referral_name', null, [
      $header = sprintf(
         '<header><h2>%sGet %s.</h2></header><h3>Have a code?</h3>%s<p>Enter your referral code to get started!</p>',
         jvbIcon('confetti'),
         esc_html($reward_text),
         ($referrer_name ? '<p>' . esc_html($referrer_name) . ' invited you to join us</p>' : '')
      );
      $codeForm = sprintf(
         '<form id="referral-code-form">
            %s
               <input type="hidden" name="user_select" value="%s">
               %s%s%s%s
               <div class="row">
               <button type="button" class="button-secondary check-code-btn">
                  %s Verify Code
               </button>
               <button type="submit">
                  Get Started
               </button>
            </div>
            <div class="code-status" hidden></div>
            <p class="hint">
               We\'ll send you a link to complete your registration.
            </p>
         </form>
         <div class="success-content" hidden>
            <h3>Check Your Email!</h3>
            <p>We\'ve sent you a magic link to complete your registration. Click the link to activate your account and claim your reward!</p>
            <p class="hint">Can\'t find it? Check your spam folder.</p>
         </div>',
         jvbFormStatus(),
         esc_attr(get_option(BASE.'referral_role','client')),
         Form::render('referral_name', '', [
            'required'  => true,
            'type'      => 'text',
            'label'     => 'Your Name',
            'placeholder'=> 'Mister Meeseeks',
            'autocomplete'=>'name'
         ]).
         $meta->return('referral_email', null, [
         ]),
         Form::render('referral_email', '', [
            'required'  => true,
            'type'      => 'email',
            'label'     => 'Your Email',
            'placeholder'=> 'look@me.com',
            'autocomplete'=> 'email'
         ]).
         $meta->return('referral_code', $prefill_code, [
         ]),
         Form::render('referral_code', $prefill_code, [
            'required'  => true,
            'type'      => 'text',
            'label'     => 'Referral Code',
@@ -1101,54 +1312,53 @@
            'maxLength' => 20,
            'autocomplete'=>'off',
            'data-referrer' => $referrer_name
         ]).'
            <button type="button" class="button-secondary check-code-btn">
               '.jvbIcon('check-circle', ['size' => 16]).' Verify Code
            </button>
            <div class="code-status" hidden></div>
            <button type="submit">
               Get Started
            </button>
         ]),
         $turnstile,
         jvbIcon('check-circle')
      );
            <p class="helper-text">
               We\'ll send you a link to complete your registration.
            </p>
            '.$turnstile.'
         </form>
         <div class="success-content" hidden>
            <h3>Check Your Email!</h3>
            <p>We\'ve sent you a magic link to complete your registration. Click the link to activate your account and claim your reward!</p>
            <p class="hint">Can\'t find it? Check your spam folder.</p>
         </div>';
      $loginHeader = sprintf(
         '<header><h2>%sLogin</h2></header><p>Already have an account?<br>Log in to see your rewards!</p>',
         jvbIcon('sign-in')
      );
      $loginForm = sprintf(
         '<form id="login-form">%s%s%s
         <button type="submit">%sLogin With Magic Link</button>
      </form>
      <div class="success-content" hidden>
         <h3>Check Your Email!</h3>
         <p>We\'ve sent you a magic link to log in - no password required! Click the link in your email to log in.</p>
         <p class="hint">Can\'t find it? Check your spam folder.</p>
      </div>',
         jvbFormStatus(),
      Form::render('login_email', null, [
         'required'  => true,
         'type'      => 'email',
         'label'     => 'Your Email',
         'autocomplete'=>'email'
      ]),
      $turnstile,
         jvbIcon('magic-wand')
      );
      $loginForm = '<form id="login-form">
   '.jvbFormStatus().$meta->return('login_email', null, [
            'required'  => true,
            'type'      => 'email',
            'label'     => 'Your Email',
            'autocomplete'=>'email'
         ]).'
   '.$turnstile.'
   <button type="submit">Login With Magic Link</button>
</form>
<div class="success-content" hidden>
   <h3>Check Your Email!</h3>
   <p>We\'ve sent you a magic link to log in - no password required! Click the link in your email to log in.</p>
   <p class="hint">Can\'t find it? Check your spam folder.</p>
</div>';
      $footer = '<div class="referral-footer">
      <a href="' . wp_login_url() . '" class="text-link">Prefer to use a password?</a>
   </div>';
      $footer = sprintf(
         '<p class="hint">
         <a href="%s" class="text-link">Prefer to use a password?</a>
   </p>',
         wp_login_url()
      );
      $tabs = [
         'enterCode' => [
            'title'  => 'Have a Code?',
            'description'  => [
               'Enter your referral code to get started'
            ],
            'header' => $header,
            'content'   => $codeForm
         ],
         'login'  => [
            'header' => $loginHeader,
            'title'     => 'Login',
            'description'  => [
               'Already have an account? Log in to see your rewards'
@@ -1214,13 +1424,14 @@
   public function getLoggedInReferral(int $user_id): string
   {
      $referral_code = $this->getUserReferralCode($user_id);
      if (is_wp_error($referral_code)) {
      if (!$referral_code) {
         return '';
      }
      $share_url = $this->getShareURL($referral_code);
      ob_start();
      ?>
      <div class="wrap">
      <header>
         <h3>Share the ♡</h3>
         <p>Invite friends. Earn rewards.</p>
@@ -1228,12 +1439,13 @@
      <?php $this->getShareButtons($user_id); ?>
      <div class="copy-section">
      <section class="copy">
         <h4>Your Referral Link</h4>
         <div class="copy-group row btw nowrap">
            <code id="referral-link" class="copy-target"><?= esc_url($share_url) ?></code>
            <button type="button" class="copy-btn" data-target="referral-link" aria-label="Copy referral link">
               <?php echo jvbIcon('copy', ['size' => 16]); ?>
            <button type="button" class="copy-btn" data-target="referral-link" title="Copy referral link">
               <?= jvbIcon('copy'); ?>
               <?= jvbIcon('check-circle'); ?>
            </button>
         </div>
         <p class="hint">Quickest and easiest: autofills your code.</p>
@@ -1242,45 +1454,46 @@
         <h4>Your Code</h4>
         <div class="copy-group row btw nowrap">
            <code id="referral-code" class="copy-target"><?= esc_html($referral_code) ?></code>
            <button type="button" class="copy-btn" data-target="referral-code" aria-label="Copy referral code">
               <?php echo jvbIcon('copy', ['size' => 16]); ?>
            <button type="button" class="copy-btn" data-target="referral-code" title="Copy referral code">
               <?= jvbIcon('copy'); ?>
               <?= jvbIcon('check-circle'); ?>
            </button>
         </div>
         <p class="hint">Manually copy and paste the code</p>
      </div>
      </section>
      <div class="recent-referrals-section">
      <section class="recent-referrals">
         <h4>Recent Referrals</h4>
         <div class="recent-referrals-list" data-user-id="<?= $user_id ?>">
            <div class="loading">Loading...</div>
         </div>
      </div>
      </section>
      <div class="stats-summary">
         <div class="stat-row">
      <section class="stats-summary">
         <div class="row btw">
            <span class="stat-label">Total Referrals</span>
            <span class="stat-value" data-stat="total">-</span>
         </div>
         <div class="stat-row">
         <div class="row btw">
            <span class="stat-label">Successful</span>
            <span class="stat-value" data-stat="treated">-</span>
         </div>
         <div class="stat-row">
         <div class="row btw">
            <span class="stat-label">Pending</span>
            <span class="stat-value" data-stat="pending">-</span>
         </div>
         <div class="stat-row highlight">
         <div class="row btw highlight">
            <span class="stat-label">Available Rewards</span>
            <span class="stat-value" data-stat="rewards">$0.00</span>
         </div>
      </div>
      </section>
      <a href="<?= get_home_url(null, '/dash/referrals')?>" class="view-dashboard-btn">
         Dashboard <?= jvbIcon('arrow-right', ['size' => 16]); ?>
      </a>
      <p class="hint">Bulk-invite your friends via email - the link will pre-fill their name, email, and code!</p>
      </div>
      <?php
      return ob_get_clean();
   }
@@ -1311,7 +1524,7 @@
      $code = $this->getUserReferralCode($user->ID);
      $yourCode = '';
      if (!is_wp_error($code)) {
      if ($code) {
         $share_url = $this->getShareURL($code);
         $yourCode = sprintf(
            '<div class="callout">
@@ -1373,7 +1586,7 @@
      $referrer = get_user_by('ID', $user_id);
      $referral_code = $this->getUserReferralCode($user_id);
      if (is_wp_error($referral_code)) {
      if ($referral_code) {
         return $referral_code;
      }
@@ -2438,10 +2651,11 @@
      // Regular users get their referral dashboard
      $user_id = get_current_user_id();
      $referral_code = get_user_meta($user_id, BASE . 'referral_code', true);
      $referral_code = $this->getUserReferralCode($user_id);
      if (!$referral_code) {
         $referral_code = $this->getUserReferralCode($user_id);
         $user = get_userdata($user_id);
         $referral_code = $this->generateReferralCode($user);
      }
      $referrals = $this->getUserReferrals($user_id, ['limit' => 20]);
@@ -2475,36 +2689,36 @@
      <?php $this->getShareButtons($user_id); ?>
      <!-- Referral Code Card -->
      <div class="card">
         <h3>Share Code</h3>
         <div class="row btw nowrap">
            <code class="code"><?= esc_html($referral_code) ?></code>
            <button class="button copy-btn" data-code="<?= esc_attr($referral_code) ?>">
               Copy Code
            </button>
         </div>
      <details open>
         <summary>Your Code</summary>
         <h3>Share Link</h3>
         <div class="row btw nowrap">
            <code class="share-link">
               <?= home_url('/?ref=' . $referral_code) ?>
            </code>
            <button class="button copy-btn" data-code="<?= home_url('/?ref=' . $referral_code) ?>">
               Copy Link
            <code id="referral-link" class="copy-target"><?= home_url('/?ref=' . $referral_code) ?></code>
            <button type="button" class="copy-btn" data-target="referral-link" title="Copy referral link">
               <?= jvbIcon('copy'); ?>
               <?= jvbIcon('check-circle'); ?>
            </button>
         </div>
      </div>
         <h3>Share Code</h3>
         <div class="row btw nowrap">
            <code id="referral-code" class="copy-target"><?= esc_html($referral_code) ?></code>
            <button type="button" class="copy-btn" data-target="referral-code" title="Copy referral code">
               <?= jvbIcon('copy'); ?>
               <?= jvbIcon('check-circle'); ?>
            </button>
         </div>
      </details>
      <form class="invite">
         <h2>Invite your Friends</h2>
         <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',
            'type' => 'taglist',
            'label' => 'Invite Your Friends',
            'hint' => 'Add friends to send them a referral link',
            'add_label' => 'Add Invite',
            'tag_format' => '{name} ({email})', // or 'first_field', 'all_fields', 'email', etc.
            'tag_format' => '{{name}} ({{email}})', // or 'first_field', 'all_fields', 'email', etc.
            'fields' => [
               'name' => [
                  'type' => 'text',
@@ -2535,14 +2749,14 @@
               'hint'      => 'We\'ll add your code and a link automatically.'
            ]
         ];
         $meta->render('invite', [], $invite);
         echo Form::render('invite', '', $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>
@@ -2582,7 +2796,7 @@
      $crud = new CRUDSkeleton();
      $crud->title('Your Referrals', 'Track friends you\'ve invited and rewards earned')
         ->content('referral', 'Referral', 'Referrals')
         ->initMeta('custom', 'referral')
//       ->initMeta('custom', 'referral')
         ->setFields([
            'referee_name' => [
               'label' => 'Name',
@@ -2657,7 +2871,7 @@
         update_option(BASE . 'referral_page_id', $page_id);
         // Save client import role
         $import_role = sanitize_text_field($post_data[BASE . 'referral_role'] ?? JVB_USER);
         $import_role = sanitize_text_field($post_data[BASE . 'referral_role'] ?? Site::getDefaultReferralRole());
         update_option(BASE . 'referral_role', $import_role);
         // Save reward settings
@@ -2710,7 +2924,7 @@
   public function getShareButtons(int $user_id):void
   {
      $referral_code = $this->getUserReferralCode($user_id);
      if (is_wp_error($referral_code)) {
      if (!$referral_code) {
         return;
      }
@@ -2726,7 +2940,7 @@
      ?>
      <nav class="share">
         <h4>Quick Share</h4>
         <ul class="share-buttons-grid">
         <ul>
            <a href="mailto:?subject=<?php echo urlencode('Check out ' . get_bloginfo('name')); ?>&body=<?php echo urlencode($share_message . ' ' . $share_url); ?>"
               class="button" title="Email">
               <?php echo jvbIcon('envelope'); ?>
@@ -2812,7 +3026,7 @@
      $referrer_first_name = $referrer ? strtok($referrer->display_name, ' ') : 'Your friend';
      // Get reward text
      $reward_text = $this->getRewardText(false); // Just "20% off" or "$25 off"
      $reward_text = $this->getRewardText(); // Just "20% off" or "$25 off"
      $booking_url = apply_filters('jvb_referral_booking_url', home_url('/contact'));
      $estimate_url = apply_filters('jvb_referral_estimate_url', home_url('/estimate'));