Jake Vanderwerf
2026-08-25 3d25ad7de4fdbb3eba2cde2a62422e6be5d79a0a
inc/managers/Referrals.php
File was renamed from inc/managers/ReferralManager.php
@@ -1,7 +1,13 @@
<?php
namespace JVBase\managers;
use JVBase\Instance;
use JVBase\integrations\services\Cloudflare;
use JVBase\meta\Form;
use JVBase\meta\Meta;
use JVBase\meta\Sanitizer;
use JVBase\rest\Rest;
use JVBase\rest\routes\ReferralRoutes;
use JVBase\ui\CRUDSkeleton;
use JVBase\ui\Tabs;
use JVBase\base\Site;
@@ -18,9 +24,10 @@
 * Handles user referral codes, tracking, rewards, and reporting.
 * Keeps referrals separate from invitations system.
 */
class ReferralManager
class Referrals
{
   protected MagicLinkManager $magic_link;
   use Instance;
   protected MagicLinks $magic_link;
   protected Cache $cache;
   protected Cache $requestCache;
   protected Cache $statsCache;
@@ -33,6 +40,7 @@
   protected CustomTable $janeClients;
   protected CustomTable $rewards;
   protected CustomTable $treatments;
   protected Rest $route;
   // Default reward settings
   protected array $default_settings = [
@@ -48,10 +56,11 @@
   protected array $settings;
   public function __construct()
   private function __construct()
   {
      $this->route = ReferralRoutes::getInstance();
      $this->defineTables();
      $this->role = Site::getDefaultReferralRole();
      $this->role = Site::getReferralRole();
      $this->default_settings['referral_role'] = $this->role;
      $this->cache = Cache::for('referrals', WEEK_IN_SECONDS);
@@ -88,9 +97,6 @@
      // Schedule cron jobs for reports
      $this->registerCronJobs();
      // Add admin bar label for referral page
      add_action('admin_bar_menu', [$this, 'addReferralPageLabel'], 999);
      // Add admin notice to referral page edit screen
      add_action('admin_notices', [$this, 'showReferralPageNotice']);
@@ -102,6 +108,8 @@
      add_action('admin_init', [$this, 'registerSettings']);
      // Handle admin page form submission
      add_filter('jvb_admin_page_submission', [$this, 'handleAdminSubmission'], 10, 3);
      add_filter(BASE.'handle_bulk_operation', [$this, 'processOperation'], 10, 3);
   }
   protected function defineTables():void
@@ -118,14 +126,15 @@
         $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',
            'from_user' => "{$table->getUserIDType()} NOT NULL",     //The person doing the referring
            'to_user'   => "{$table->getUserIDType()} DEFAULT NULL", //The person being referred
            'to_name'   => 'varchar(255) DEFAULT NULL',
            'to_email'  => 'varchar(255) DEFAULT NULL',
            'to_phone'  => 'varchar(50) DEFAULT NULL',
            'referral_code'=> 'varchar(50) NOT NULL',
            'status' => "ENUM('pending', 'consulted', 'treated', 'cancelled') DEFAULT 'pending'",
            'status' => "ENUM('invited', 'pending', 'consulted', 'treated', 'cancelled') DEFAULT 'pending'",
            'created_at'=> 'datetime DEFAULT CURRENT_TIMESTAMP',
            'last_invited'=> 'datetime DEFAULT CURRENT_TIMESTAMP',
            'consulted_at'=> 'datetime DEFAULT NULL',
            'treated_at'=> 'datetime DEFAULT NULL',
            'treatment_count' => 'int DEFAULT 0',
@@ -134,8 +143,9 @@
         $table->setKeys([
            ['key' => 'PRIMARY', 'value' => 'id'],
            ['key' => 'UNIQUE', 'value' => '(`to_user`)'],
//          ['key' => 'UNIQUE', 'value' => '(`to_user`)'],
            'from_user (`from_user`)',
            'to_user (`to_user`)',
            'status (`status`)',
            'code (`referral_code`)',
            'date (`created_at`)',
@@ -329,8 +339,8 @@
         'jvb-data-store',
      ];
      if (Site::hasIntegration('cloudflare') && JVB()->connect('cloudflare')->isSetUp()) {
         JVB()->connect('cloudflare')->enqueueTurnstileScripts();
      if (Site::hasIntegration('cloudflare') && Cloudflare::getInstance()->isSetUp()) {
         Cloudflare::getInstance()->enqueueTurnstileScripts();
      }
      if (is_singular(BASE.'dash')) {
         $requirements[] = 'jvb-form';
@@ -489,109 +499,140 @@
    *
    * @param int $user_id
    * @param array $userData
    * @return bool;
    * @return void
    */
   public function processReferral(int $user_id, array $userData): bool
   public function processReferral(int $user_id, array $userData):void
   {
      $referral = $this->referrals->get(['to_user' => $user_id]);
      if (empty($referral)) {
         $referral = $this->referrals->get(['to_email' => $userData['email']]);
      if (!array_key_exists('ref', $_POST) || empty($_POST['ref'])) {
         return;
      }
      if (empty($referral)) {
         // Check session/cookie if not in meta
         if (session_status() === PHP_SESSION_NONE) {
            session_start();
      $code = sanitize_text_field($_POST['ref']);
      $referredBy = $this->codes->pluck('user_id', ['code' => $code]);
      if (!empty($referredBy)) {
         $this->createReferralFromID($referredBy[0], $user_id, $code);
         }
         $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)) {
         return false; // No referral code - regular registration
      }
      // Find the from_user
      $from_user = $this->codes->pluck('user_id', ['code' => $referral['referral_code']]);
      if (empty($from_user)) {
         //This should not happen, but whatever
         return false;
      }
      // Check if this email already has a referral record
      if ($this->isEmailInvited($referral['to_email'])) {
         return false;
      }
      $from_user = $from_user[0];
      $record = $this->referrals->findOrCreate([
         'to_user'   => $user_id,
         'referral_code'   => $referral['referral_code'],
      ], [
         'from_user'    => $from_user,
         '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;
      }
      // Clean up temp data
      delete_user_meta($user_id, BASE . 'pending_referral_code');
      if (isset($_SESSION[BASE . 'referral_code'])) {
         unset($_SESSION[BASE . 'referral_code']);
      }
      if (isset($_COOKIE[BASE . 'referral_code'])) {
         setcookie(BASE . 'referral_code', '', time() - 3600, '/');
      }
      // Clear caches
      $this->cache->flush();
      // Fire action for tracking
      do_action('jvb_referral_processed', $user_id, $from_user->ID, $referral['referral_code']);
      // Send notification to from_user
      $this->sendReferrerNotification($from_user->ID, $userData['display_name']);
      return true;
   }
   /**
    * Create a referral record in the database
    *
    * @param int $from_user_id
    * @param int $to_user_id
    * @param string $code
    * @return int|false
    * @param int $from_user_id The person sending the referral
    * @param string $name  The person who was referred
    * @param string $email The person who was referred's email
    * @param ?string $phone The person who was referred's phone (optional)
    * @param ?string $code The person sending the referral's custom code
    * @return array
    */
   public function createReferral(int $from_user_id, int $to_user_id, string $code)
   public function createReferralFromFields(int $from_user_id, string $name, string $email, ?string $phone = null, ?string $code = null):array
   {
      $user = get_user_by('ID', $to_user_id);
      if (!get_userdata($from_user_id)) {
         return [
            'success'   => false,
            'message'   => 'From user does not exist',
         ];
      }
      return $this->referrals->findOrCreate([
         [
            'to_user' => $to_user_id,
         ],
         [
      if (filter_var($email, FILTER_VALIDATE_EMAIL)) {
         if(email_exists($email)) {
            return [
               'success'   => false,
               'message'   => 'Email already exists',
            ];
         }
      }
      if ($phone && !empty($this->referrals->pluck('to_phone', ['to_phone' => Sanitizer::sanitizePhone($phone)]))) {
         return [
            'success'   => false,
            'message'   => 'Phone already used'
         ];
      }
      $insert = [
            'from_user' => $from_user_id,
            'to_name' => $user->display_name,
         'referral_code'   => is_null($code)? $this->getUserReferralCode($from_user_id) : sanitize_text_field($code),
         'to_name'      => $name,
         'to_email'     => $email,
         'status'    => 'invited',
      ];
      if ($phone) {
         $insert['to_phone'] = $phone;
      }
      $created = $this->referrals->insert($insert);
      $this->statsCache->forget($from_user_id);
      return [
         'success'   => (bool) $created,
         'message'   => 'Referral created'
      ];
   }
   /**
    * Create a referral record in the database
    *
    * @param int $from_user_id The person sending the referral
    * @param int $to_user_id The person who was referred
    * @param ?string $code Custom code, if not default
    * @return array
    */
   public function createReferralFromID(int $from_user_id, int $to_user_id, ?string $code = null):array
   {
      $user = get_userdata($to_user_id);
      $code = !is_null($code) ? sanitize_text_field($code) : $this->getUserReferralCode($to_user_id);
      $where = [
            'to_email' => $user->user_email,
            'to_phone' => get_user_meta($to_user_id, BASE . 'phone', true) ?: '',
            'referral_code' => $code,
         'to_name'   => $user->first_name ?: $user->display_name
      ];
      $phone = false;
      if (array_key_exists('phone', $_POST) && !empty($_POST['phone'])) {
         $phone = Sanitizer::sanitizePhone($_POST['phone']);
         if (!empty($phone)) {
            $where = [
               'to_phone'  => $phone,
            ];
         }
      } elseif (!empty(Meta::forUser($to_user_id)->get('phone'))) {
         $phone = Meta::forUser($to_user_id)->get('phone');
      }
      $where = [
         'OR'  => $where,
         'status' => 'invited'
      ];
      $update = [
         'from_user' => $from_user_id,
         'to_user'   => $to_user_id,
         'to_name'   => $user->first_name ?: $user->display_name,
         'to_email'  => $user->user_email,
            'status' => 'pending',
         ]
      ]);
         'referral_code'   => $code
      ];
      if ($phone) {
         $update ['to_phone'] = $phone;
      }
      $existing = $this->referrals->get($where);
      if ($existing) {
         if ($existing->from_user != $from_user_id) {
            $this->referrals->update([
               'status' => 'cancelled',
            ], $where);
         } else {
            $updated = $this->referrals->update($update, $where);
            $this->sendReferrerNotification($from_user_id, $update['to_name']);
            return [
               'success'   => (bool) $updated,
               'message'   => $updated ? 'Successfully created referral' : 'Referral could not be updated'
            ];
         }
      }
      $created = $this->referrals->insert($update);
      $this->statsCache->forget($from_user_id);
      return [
         'success'   => (bool) $created,
         'message'   => $created ? 'Successfully created referral' : 'Referral could not be added'
      ];
   }
   /**
@@ -714,35 +755,49 @@
         'order' => 'DESC'
      ];
      $where = [
         'from_user' => $user_id,
      ];
      foreach ($this->referrals->getColumnKeys() as $col) {
         if (array_key_exists($col, $args)) {
            if ($col === 'status' && $args[$col] !== 'all') {
               $where[$col] = $args[$col];
            } else {
               $where[$col] = $args[$col];
            }
         }
      }
      $args = wp_parse_args($args, $defaults);
      $args['status'] = strtolower($args['status']);
      $tableArgs = [
         'where' => [
            'from_user' => $user_id,
         ],
         'where'  => $where,
         'per_page'     => $args['per_page'],
         'page'      => $args['page'],
         'orderby'   => $args['orderby'],
         'order'     => $args['order']
      ];
      if (in_array($args['status'], ['pending', 'consulted', 'treated', 'cancelled'])){
      if ($args['status'] !== 'all'){
         $tableArgs['status'] = $args['status'];
      }
      $referrals = $this->referrals->getMany($tableArgs);
      $referrals = $this->referrals->getMany($tableArgs, false);
      return array_map(function($referral) {
         $last_invite = get_transient('referral_last_invite_' . md5($referral->to_email));
         $can_resend = !$last_invite || (time() - $last_invite) > WEEK_IN_SECONDS;
         $can_resend = $this->checkCanResendEmail($referral->id);
         $status = match($referral->status) {
            'consulted' => 'Awaiting Treatment',
            'treated'   => 'Rewarded!',
            'invited'   => 'Invited, but has not used',
            default => 'Pending',
         };
         return [
            'id'        => $referral->id,
            'to_name'   => $referral->to_name,
            'to_email'  => $referral->to_email,
            'referred_at'  => JVB()->routes('referral')->formatTimestamp($referral->referred_at),
            'referred_at'  => ReferralRoutes::getInstance()->formatTimestamp($referral->referred_at),
            'referral_status'=> $status,
            'can_resend'   => $can_resend
         ];
@@ -750,6 +805,17 @@
   }
   protected function checkCanResendEmail(int|object $referral): bool
   {
      if (is_int($referral)) {
         $referral = $this->referrals->get(['id'=>$referral]);
         if (!$referral) {
            return false;
         }
      }
      return (time() - $referral->updated_at) > WEEK_IN_SECONDS;
   }
   /**
    * Get referral statistics for a user
    *
@@ -761,25 +827,15 @@
      return $this->statsCache->remember(
         $user_id,
         function() use ($user_id) {
            $stats = $this->referrals->queryResults(
               "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 {table}
                WHERE from_user = %d",
               [$user_id],
               ARRAY_A
            );
            $stats = $stats[0] ?? [];
            $rewards = $this->rewards->queryVar(
               "SELECT SUM(amount) FROM {table} WHERE user_id = %d AND reward_type = 'from_user'",
               [$user_id]
            );
            $stats['total_rewards'] = floatval($rewards ?? 0);
            $stats = [
               'total'     => $this->referrals->count(['user_id' => $user_id]),
               'invited'   => $this->referrals->count(['user_id' => $user_id, 'status' => 'invited']),
               'pending'   => $this->referrals->count(['user_id' => $user_id, 'status' => 'pending']),
               'consulted' =>$this->referrals->count(['user_id' => $user_id, 'status' => 'consulted']),
               'treated'   => $this->referrals->count(['user_id' => $user_id, 'status' => 'treated']),
               'total_rewards'   => $this->rewards->sum('amount', ['user_id' => $user_id]),
               'available_rewards'  => $this->rewards->sum('amount', ['user_id' => $user_id, 'redeemed_at' => ['NOT NULL', null]]),
            ];
            $stats['user_id'] = $user_id;
            return $stats;
         }
@@ -852,15 +908,15 @@
         return;
      }
      $content = JVB()->email()->h1('Daily Referral Report');
      $content .= JVB()->email()->stat(
      $content = Emails::getInstance()->h1('Daily Referral Report');
      $content .= Emails::getInstance()->stat(
         count($new_referrals),
         count($new_referrals) === 1 ? 'New Referral' : 'New Referrals',
         'From ' . $yesterday
      );
      $content .= JVB()->email()->spacer(20);
      $content .= JVB()->email()->h2('New Referrals');
      $content .= Emails::getInstance()->spacer(20);
      $content .= Emails::getInstance()->h2('New Referrals');
      // Build list of referrals
      foreach ($new_referrals as $ref) {
@@ -871,12 +927,12 @@
         );
         $cardContent .= sprintf(
            '<p style="font-size:13px;color:%s;">Referred by: %s | Code: %s</p>',
            JVB()->email()->colours['dark-200'],
            Emails::getInstance()->colours['dark-200'],
            esc_html($ref->from_name),
            JVB()->email()->badge($ref->referral_code, 'info')
            Emails::getInstance()->badge($ref->referral_code, 'info')
         );
         $content .= JVB()->email()->card($cardContent);
         $content .= Emails::getInstance()->card($cardContent);
      }
      $to = get_option('admin_email');
@@ -887,7 +943,7 @@
         count($new_referrals) !== 1 ? 's' : ''
      );
      JVB()->email()->sendEmail($to, $subject, $content, 'DAILY REPORT');
      Emails::getInstance()->sendEmail($to, $subject, $content, 'DAILY REPORT');
   }
   /**
@@ -895,31 +951,29 @@
    */
   public function sendWeeklyReport(): void
   {
      $top_from_users = $this->getTopReferrers(10, 'week');
      $total_referrals = $this->referrals->queryVar(
         "SELECT COUNT(*) FROM {table} WHERE created_at >= DATE_SUB(NOW(), INTERVAL 1 WEEK)"
      );
      $total_referrals = $this->referrals->count(['created_at' => ['>=', "DATE_SUB(NOW(), INTERVAL 1 WEEK)"]]);
      if ($total_referrals == 0) {
         return;
      }
      $top_from_users = $this->getTopReferrers(10, 'week');
      $content = JVB()->email()->h1('Weekly Referral Summary');
      $content .= JVB()->email()->stat(
      $content = Emails::getInstance()->h1('Weekly Referral Summary');
      $content .= Emails::getInstance()->stat(
         $total_referrals,
         'Total Referrals',
         'This week'
      );
      $content .= JVB()->email()->spacer(30);
      $content .= JVB()->email()->h2('Top 10 Referrers');
      $content .= Emails::getInstance()->spacer(30);
      $content .= Emails::getInstance()->h2('Top 10 Referrers');
      // Leaderboard style
      $rank = 1;
      foreach ($top_from_users as $from_user) {
         $rankBadge = $rank <= 3
            ? JVB()->email()->badge('#' . $rank, $rank === 1 ? 'success' : 'info')
            : '<span style="font-weight:600;color:' . JVB()->email()->colours['dark-200'] . ';">#' . $rank . '</span>';
            ? Emails::getInstance()->badge('#' . $rank, $rank === 1 ? 'success' : 'info')
            : '<span style="font-weight:600;color:' . Emails::getInstance()->colours['dark-200'] . ';">#' . $rank . '</span>';
         $cardContent = sprintf(
            '<p>%s <strong>%s</strong></p>',
@@ -928,19 +982,19 @@
         );
         $stats = [
            JVB()->email()->stat($from_user->referral_count, 'Total Referrals'),
            JVB()->email()->stat($from_user->treated_count, 'Treated')
            Emails::getInstance()->stat($from_user->referral_count, 'Total Referrals'),
            Emails::getInstance()->stat($from_user->treated_count, 'Treated')
         ];
         $cardContent .= JVB()->email()->grid($stats, 2);
         $cardContent .= Emails::getInstance()->grid($stats, 2);
         $content .= JVB()->email()->card($cardContent);
         $content .= Emails::getInstance()->card($cardContent);
         $rank++;
      }
      $to = get_option('admin_email');
      $subject = '[' . get_bloginfo('name') . '] Weekly Referral Summary - ' . date('F j, Y');
      JVB()->email()->sendEmail($to, $subject, $content, 'WEEKLY SUMMARY');
      Emails::getInstance()->sendEmail($to, $subject, $content, 'WEEKLY SUMMARY');
   }
   /**
@@ -1005,7 +1059,7 @@
         ];
      }
      $content .= JVB()->email()->table($from_users, 'Top 10 Referrers This Week');
      $content .= Emails::getInstance()->table($from_users, 'Top 10 Referrers This Week');
      return $content;
   }
@@ -1220,7 +1274,7 @@
   function getUnloggedInReferral(): string
   {
      ob_start();
      JVB()->connect('cloudflare')->renderTurnstile();
      Cloudflare::getInstance()->renderTurnstile();
      $turnstile = ob_get_clean();
      $reward_text = $this->getRewardText(true);
@@ -1512,8 +1566,8 @@
         <p>Or click the button below:</p>
         %s
         </div>',
            JVB()->email()->link($code),
            JVB()->email()->button($share_url, 'Share Your Code')
            Emails::getInstance()->link($code),
            Emails::getInstance()->button($share_url, 'Share Your Code')
         );
      }
@@ -1555,31 +1609,39 @@
         return new WP_Error('invalid_email', 'Invalid email address');
      }
      // Check if already registered
      if (email_exists($invitee_email)) {
         return new WP_Error('user_exists', 'This person already has an account');
      }
      // Get from_user info
      $from_user = get_user_by('ID', $user_id);
      $from_user = get_userdata($user_id);
      $referral_code = $this->getUserReferralCode($user_id);
      if ($referral_code) {
         return $referral_code;
      // Record the invitation attempt (for rate limiting only)
      if ($this->emailAlreadyTreated($invitee_email)) {
         return [
            'success'   => false,
            'message'   => 'User already treated'
         ];
      }
      // Record the invitation attempt (for rate limiting only)
      $this->recordInvitationAttempt($user_id, $invitee_email, $invitee_name);
      // Create registration URL with token (opens sidebar with prefilled form)
      $token_data = [
         'name' => sanitize_text_field($invitee_name),
         'email' => $invitee_email,
         'expires' => time() + (30 * DAY_IN_SECONDS)
      ];
      $record = $this->referrals->findOrCreate(
         [
            'from_user' => $user_id,
            'to_email'  => $invitee_email,
            'referral_code' => $referral_code,
         ],
         [
            'to_name'   => $invitee_name,
            'status' => 'invited',
            'last_invited' => current_time('mysql')
         ]
      );
      // Encode the token
      $token = base64_encode(json_encode($token_data));
      $registration_url = add_query_arg([
         'ref' => $referral_code,
         'rname' => sanitize_text_field($invitee_name),
@@ -1606,11 +1668,11 @@
            esc_html(get_bloginfo('name')),
            nl2br(esc_html($message)),
            esc_html($reward_text),
            JVB()->email()->button($registration_url, 'Register & Get Your Reward')
            Emails::getInstance()->button($registration_url, 'Register & Get Your Reward')
         );
      // Send email
      $sent = JVB()->email()->sendEmail(
      $sent = Emails::getInstance()->sendEmail(
         $invitee_email,
         $subject,
         $email_content
@@ -1642,26 +1704,27 @@
         'failed' => []
      ];
      foreach ($invitations as $invite) {
         $email = $invite['email'] ?? '';
         $name = $invite['name'] ?? '';
         if (empty($email) || empty($name)) {
            $results['failed'][] = [
               'email' => $email,
               'name' => $name,
               'reason' => 'Missing email or name'
            ];
            continue;
      $invitations = array_filter($invitations, function ($invite) {
         if (!array_key_exists('email', $invite) || !filter_var($invite['email'], FILTER_VALIDATE_EMAIL)) {
            return false;
         }
         if (!array_key_exists('name', $invite) || empty ($invite['name'])) {
            return false;
         }
         return true;
      });
      foreach ($invitations as $invite) {
         $email = $invite['email'];
         $name = $invite['name'];
         $result = $this->sendReferralInvitation($user_id, $email, $name, $subject, $message);
         if (is_wp_error($result)) {
         if (!$result['success']) {
            $results['failed'][] = [
               'email' => $email,
               'name' => $name,
               'reason' => $result->get_error_message()
               'reason' => $result['message']
            ];
         } else {
            $results['success'][] = [
@@ -1693,98 +1756,25 @@
    */
   protected function checkEmailRateLimit(int $user_id):bool|string
   {
      $hourly_key = 'referral_invites_hour_' . $user_id;
      $count = (int) get_transient($hourly_key);
      $key = BASE.'referral_email_limit';
      $stored = get_user_meta($user_id, $key, true);
      $count = empty($stored) ? 0 : count($stored);
      if ($count >= 15) {
         return 'hourly_limit_reached';
         return false;
      }
      set_transient($hourly_key, $count + 1, HOUR_IN_SECONDS);
      $stored = empty($stored) ? [] : $stored;
      $stored[] = time();
      $stored = array_filter($stored, function($time) {
         return time() - $time < HOUR_IN_SECONDS;
      });
      update_user_meta($user_id, $key, $stored);
      return true;
   }
   /**
    * Check if an email has already been invited
    *
    * @param string $email
    * @return bool
    */
   protected function isEmailInvited(string $email): bool
   {
      // Check invitation tracking table
      $invitation_key = 'referral_invite_' . md5($email);
      $invited = get_transient($invitation_key);
      if ($invited) {
         return true;
      }
      // Check if there's a pending referral for this email
      $existing = $this->referrals->pluck('id', ['to_email' => $email]);
      return !empty($existing);
   }
   /**
    * Record invitation attempt (for tracking and preventing duplicates)
    *
    * @param int $user_id
    * @param string $email
    * @param string $name
    */
   protected function recordInvitationAttempt(int $user_id, string $email, string $name): void
   {
      // Store for 30 days (same as magic link invitation validity)
      $invitation_key = 'referral_invite_' . md5($email);
      $data = [
         'inviter_id' => $user_id,
         'email' => $email,
         'name' => $name,
         'sent_at' => current_time('mysql')
      ];
      set_transient($invitation_key, $data, 30 * DAY_IN_SECONDS);
      // Also log in user meta for tracking
      $sent_invites = get_user_meta($user_id, BASE . 'referral_invites_sent', true) ?: [];
      $sent_invites[] = [
         'email' => $email,
         'name' => $name,
         'sent_at' => current_time('mysql')
      ];
      update_user_meta($user_id, BASE . 'referral_invites_sent', $sent_invites);
   }
   /**
    * Get user's invitation stats
    *
    * @param int $user_id
    * @return array
    */
   public function getUserInvitationStats(int $user_id): array
   {
      $sent_invites = get_user_meta($user_id, BASE . 'referral_invites_sent', true) ?: [];
      // Count invites sent in last hour
      $one_hour_ago = strtotime('-1 hour');
      $recent_count = 0;
      foreach ($sent_invites as $invite) {
         if (strtotime($invite['sent_at']) > $one_hour_ago) {
            $recent_count++;
         }
      }
      return [
         'total_sent' => count($sent_invites),
         'sent_last_hour' => $recent_count,
         'remaining_this_hour' => max(0, 15 - $recent_count),
         'can_send_more' => $recent_count < 15
      ];
   }
   /**
    * Export referrals for Jane App cross-reference
    *
    * @param string $start_date Y-m-d format
@@ -1871,22 +1861,12 @@
         'menu_title' => 'Referrals',
         'capability' => 'manage_options',
         'menu_slug' => BASE . 'referral-admin',
         'callback' => [self::class, 'renderAdminPageStatic']
         'callback' => [self::class, 'renderAdminPage']
      ];
      AdminPages::addSubPage(BASE.'referral-admin', $subpage);
   }
   /**
    * Static wrapper for renderAdminPage
    * Called by WordPress when admin page is rendered
    */
   public static function renderAdminPageStatic(): void
   {
      // Get the properly initialized instance from JVB singleton
      JVB()->referrals()->renderAdminPage();
   }
   /**
    * Register settings
    */
   public function registerSettings(): void
@@ -2496,95 +2476,42 @@
    */
   protected function renderReferralStats(bool $wrapCard = false): string
   {
      global $wpdb; // Use fresh global instead of stored reference
      $isAdmin = current_user_can('manage_options');
      $current = get_current_user_id();
      $types = ['total' => 'Total Referrals', 'invited' => 'Invited', 'pending' => 'Pending', 'treated' => 'Treated'];
      ob_start();
      $out = '<table class="widefat">';
      foreach ($types as $type => $label) {
         $status = $type === 'total' ? [] : ['status' => $type];
      // Get fresh table name references
      $referrals_table = $wpdb->prefix . BASE . 'referrals';
      // Use proper WordPress prepare even for COUNT
      $total_referrals = $wpdb->get_var(
         $wpdb->prepare(
            "SELECT COUNT(*) FROM `{$referrals_table}` WHERE 1=%d",
            1
         )
         $count = $isAdmin ?
            $this->referrals->count($status) :
            $this->referrals->count(array_merge(
               ['from_user' => $current],
               $status
            ));
         $out .= sprintf(
         '<tr>
               <th>%s</th>
               <td>%s</td>
            </tr>',
            $label,
            esc_html($count)
      );
      $pending_referrals = $wpdb->get_var(
         $wpdb->prepare(
            "SELECT COUNT(*) FROM `{$referrals_table}` WHERE status = %s",
            'pending'
         )
      );
      $treated_referrals = $wpdb->get_var(
         $wpdb->prepare(
            "SELECT COUNT(*) FROM `{$referrals_table}` WHERE status = %s",
            'treated'
         )
      );
      ?>
      <table class="widefat">
         <tr>
            <th>Total Referrals</th>
            <td><?= esc_html($total_referrals ?? 0) ?></td>
         </tr>
         <tr>
            <th>Pending</th>
            <td><?= esc_html($pending_referrals ?? 0) ?></td>
         </tr>
         <tr>
            <th>Treated</th>
            <td><?= esc_html($treated_referrals ?? 0) ?></td>
         </tr>
      </table>
      <?php
      $table = ob_get_clean();
      }
      $out .= '</table>';
      if ($wrapCard) {
         $table = '<div class="card">
         $out = '<div class="card">
            <h2>Referral Statistics</h2>
            ' . $table . '
            ' . $out . '
        </div>';
      }
      return $table;
      return $out;
   }
   /**
    * Add "Referral Page" label to admin bar
    *
    * @param WP_Admin_Bar $wp_admin_bar
    */
   public function addReferralPageLabel($wp_admin_bar): void
   {
      if (!is_admin()) {
         return;
      }
      if (!$this->referralPage) {
         $this->referralPage = $this->getReferralPageId();
      }
      if (!$this->referralPage) {
         return;
      }
      global $pagenow, $post;
      // Check if we're editing the referral page
      if ('post.php' === $pagenow && $post && $post->ID === $this->referralPage) {
         $wp_admin_bar->add_node([
            'id' => 'referral-page',
            'parent' => 'top-secondary',
            'title' => __('Referral Page', 'jvbase'),
            'meta' => [
               'class' => 'referral-page-notice'
            ]
         ]);
      }
   }
   /**
    * Get the referral page ID
@@ -2612,7 +2539,7 @@
      }
      if ($post->ID === $this->referralPage) {
         echo '<div class="notice notice-info">';
         echo '<p>' . __('This page is designated as the <strong>Referral Page</strong>.', 'jvbase') . '</p>';
         echo '<p>' . __('This page is designated as the <strong>Referral Page</strong>.', 'jvb') . '</p>';
         echo '</div>';
      }
   }
@@ -2826,7 +2753,7 @@
    * @param array $post_data POST data
    * @return array|null Result array or null if not our page
    */
   public function handleAdminSubmission($result, string $page_slug, array $post_data): ?array
   public function handleAdminSubmission(mixed $result, string $page_slug, array $post_data): ?array
   {
      // Only handle our page
      if ($page_slug !== BASE . 'referral-admin') {
@@ -2961,7 +2888,7 @@
         home_url('/dash/referrals')
      );
      JVB()->email()->sendEmail(
      Emails::getInstance()->sendEmail(
         $from_user->user_email,
         $subject,
         $message
@@ -3033,11 +2960,14 @@
      return ob_get_clean();
   }
   public function updateStatus(int $referral_id, string $status): bool|WP_Error
   public function updateStatus(int $referral_id, string $status):array
   {
      $referral = $this->referrals->get(['id' => $referral_id]);
      if (!$referral) {
         return new WP_Error('not_found', 'Referral not found');
         return [
            'success'   => false,
            'message'   => 'Referral not found'
         ];
      }
      $data = ['status' => $status, "{$status}_at" => current_time('mysql')];
@@ -3046,48 +2976,78 @@
      }
      $result = $this->referrals->update($data, ['id' => $referral_id]);
      if ($result === false) {
         return new WP_Error('update_failed', 'Failed to update referral status');
      if (!$result) {
         return [
            'success'   => false,
            'message'   => 'Failed to update referral status'
         ];
      }
      if ($status === 'treated') {
         $this->createRewardRecords($referral_id);
      }
      $this->cache->flush();
      return true;
      $this->cache->forget($referral_id);
      return [
         'success'   => true,
         'message'   => 'Successfully set status to '.$status
      ];
   }
   public function removeReferral(int $referral_id, int $user_id): bool|WP_Error
   public function removeReferral(int $referral_id, int $user_id):array
   {
      $referral = $this->referrals->get(['id' => $referral_id]);
      if (!$referral) {
         return new WP_Error('not_found', 'Referral not found');
         return [
            'success'   => false,
            'message'   => 'Referral not found'
         ];
      }
      if ($referral->from_user != $user_id && !current_user_can('manage_options')) {
         return new WP_Error('unauthorized', 'Unauthorized');
         return [
            'success'   => false,
            'message'   => 'Unauthorized'
         ];
      }
      if ($referral->status !== 'pending') {
         return new WP_Error('invalid_status', 'Can only remove pending referrals');
      if (!in_array($referral->status, ['invited', 'pending'])) {
         return [
            'success'   => false,
            'message'   => 'Cannot remove a completed or cancelled referral'
         ];
      }
      $this->referrals->delete(['id' => $referral_id]);
      $this->cache->flush();
      return true;
      $this->cache->forget($referral_id);
      return [
         'success' => true,
         'message'   => 'Successfully removed referral'
      ];
   }
   public function resendInvitation(int $referral_id, int $user_id): bool|WP_Error
   public function resendInvitation(int $referral_id, int $user_id):array
   {
      $referral = $this->referrals->where(['id' => $referral_id, 'from_user' => $user_id])->first();
      if (!$referral) {
         return new WP_Error('not_found', 'Referral not found');
         return [
            'success'   => false,
            'message'   => 'Referral not found'
         ];
      }
      $transient_key = 'referral_last_invite_' . md5($referral->to_email);
      if (get_transient($transient_key)) {
         return new WP_Error('rate_limit', 'Can only resend once per week');
      if (!$this->checkCanResendEmail($referral)) {
         $days = (time() - $referral->last_invited) / DAY_IN_SECONDS;
         $days = match (true) {
            $days <= 1 => 'until tomorrow',
            $days <= 3 => 'a couple days',
            default => absint($days).' days'
         };
         return [
            'success'   => false,
            'message'   => 'Can\'t resend email. Wait '.$days.'.'
         ];
      }
      $result = $this->sendReferralInvitation(
@@ -3098,15 +3058,13 @@
         'Just a friendly reminder about my invitation!'
      );
      if (is_wp_error($result)) {
         return $result;
      return [
         'success'   => (bool) $result,
         'message'   => $result ? 'Sent email' : 'Could not send email'
      ];
      }
      set_transient($transient_key, time(), WEEK_IN_SECONDS);
      return true;
   }
   public function getAllReferrals(array $args = []): array
   public function getReferrals(array $args = []): array
   {
      $conditions = ['1=1'];
      $values = [];
@@ -3145,5 +3103,60 @@
         $values
      );
   }
   public function emailAlreadyTreated(string $email):bool
   {
      if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
         return false;
      }
      if (email_exists($email)) {
         return false;
      }
      $referral = $this->referrals->get([
         'to_email'  => $email,
         'status' => [
            'IN',
            ['pending', 'consulted', 'treated', 'cancelled']
         ]
      ]);
      return !empty($referral);
   }
   /**
    * Process queued referral operations
    */
   public function processOperation(WP_Error|array $result, object $operation, array $data): array|WP_Error
   {
      if ($operation->type !== 'referral_invite') {
         return $result;
      }
      $result = $this->sendBatchReferralInvitations(
         $operation->user_id,
         $data['invitations'],
         $data['subject'],
         $data['message']
      );
      if ($result['success']) {
         $this->cache->flush();
      }
      return [
         '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'])
         ]
      ];
   }
}