| File was renamed from inc/managers/ReferralManager.php |
| | |
| | | <?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; |
| | |
| | | * 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; |
| | |
| | | protected CustomTable $janeClients; |
| | | protected CustomTable $rewards; |
| | | protected CustomTable $treatments; |
| | | protected Rest $route; |
| | | |
| | | // Default reward settings |
| | | protected array $default_settings = [ |
| | |
| | | |
| | | 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); |
| | |
| | | // 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']); |
| | | |
| | |
| | | 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 |
| | |
| | | |
| | | $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', |
| | |
| | | |
| | | $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`)', |
| | |
| | | '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'; |
| | |
| | | * |
| | | * @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' |
| | | ]; |
| | | } |
| | | |
| | | /** |
| | |
| | | '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 |
| | | ]; |
| | |
| | | |
| | | } |
| | | |
| | | 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 |
| | | * |
| | |
| | | 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; |
| | | } |
| | |
| | | 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) { |
| | |
| | | ); |
| | | $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'); |
| | |
| | | count($new_referrals) !== 1 ? 's' : '' |
| | | ); |
| | | |
| | | JVB()->email()->sendEmail($to, $subject, $content, 'DAILY REPORT'); |
| | | Emails::getInstance()->sendEmail($to, $subject, $content, 'DAILY REPORT'); |
| | | } |
| | | |
| | | /** |
| | |
| | | */ |
| | | 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>', |
| | |
| | | ); |
| | | |
| | | $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'); |
| | | } |
| | | |
| | | /** |
| | |
| | | ]; |
| | | } |
| | | |
| | | $content .= JVB()->email()->table($from_users, 'Top 10 Referrers This Week'); |
| | | $content .= Emails::getInstance()->table($from_users, 'Top 10 Referrers This Week'); |
| | | |
| | | return $content; |
| | | } |
| | |
| | | function getUnloggedInReferral(): string |
| | | { |
| | | ob_start(); |
| | | JVB()->connect('cloudflare')->renderTurnstile(); |
| | | Cloudflare::getInstance()->renderTurnstile(); |
| | | $turnstile = ob_get_clean(); |
| | | |
| | | $reward_text = $this->getRewardText(true); |
| | |
| | | <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') |
| | | ); |
| | | } |
| | | |
| | |
| | | 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), |
| | |
| | | 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 |
| | |
| | | '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'][] = [ |
| | |
| | | */ |
| | | 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 |
| | |
| | | '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 |
| | |
| | | */ |
| | | 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 |
| | |
| | | } |
| | | 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>'; |
| | | } |
| | | } |
| | |
| | | * @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') { |
| | |
| | | home_url('/dash/referrals') |
| | | ); |
| | | |
| | | JVB()->email()->sendEmail( |
| | | Emails::getInstance()->sendEmail( |
| | | $from_user->user_email, |
| | | $subject, |
| | | $message |
| | |
| | | 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')]; |
| | |
| | | } |
| | | |
| | | $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( |
| | |
| | | '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 = []; |
| | |
| | | $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']) |
| | | ] |
| | | ]; |
| | | } |
| | | |
| | | } |
| | | |