From aa88ed34c07bd3bc9418628ba1340b0be144ee2b Mon Sep 17 00:00:00 2001 From: Kingsuite Date: Sun, 29 Mar 2026 22:04:41 +0100 Subject: [PATCH] feature:implement Add subscription trial tracking with automatic expiry alerts --- backend/scripts/018_add_trial_tracking.sql | 41 +++++ backend/src/routes/subscriptions.ts | 52 ++++++ backend/src/services/email-service.ts | 166 ++++++++++++++----- backend/src/services/monitoring-service.ts | 54 ++++++ backend/src/services/reminder-engine.ts | 97 ++++++++++- backend/src/services/subscription-service.ts | 149 +++++++++++++++++ backend/src/types/reminder.ts | 6 + backend/src/types/subscription.ts | 15 ++ client/components/pages/subscriptions.tsx | 112 ++++++++++++- 9 files changed, 643 insertions(+), 49 deletions(-) create mode 100644 backend/scripts/018_add_trial_tracking.sql diff --git a/backend/scripts/018_add_trial_tracking.sql b/backend/scripts/018_add_trial_tracking.sql new file mode 100644 index 0000000..3365c01 --- /dev/null +++ b/backend/scripts/018_add_trial_tracking.sql @@ -0,0 +1,41 @@ +-- Add trial tracking columns to subscriptions table +ALTER TABLE public.subscriptions + ADD COLUMN IF NOT EXISTS is_trial BOOLEAN DEFAULT FALSE, + ADD COLUMN IF NOT EXISTS trial_ends_at TIMESTAMPTZ, + ADD COLUMN IF NOT EXISTS trial_converts_to_price DECIMAL(10,2), + ADD COLUMN IF NOT EXISTS credit_card_required BOOLEAN DEFAULT FALSE; + +-- Index for efficient trial expiry queries +CREATE INDEX IF NOT EXISTS subscriptions_trial_ends_at_idx + ON public.subscriptions(trial_ends_at) + WHERE is_trial = TRUE; + +-- Table to track trial conversion events +CREATE TABLE IF NOT EXISTS public.trial_conversion_events ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + subscription_id UUID NOT NULL REFERENCES public.subscriptions(id) ON DELETE CASCADE, + user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, + conversion_type TEXT NOT NULL CHECK (conversion_type IN ('intentional', 'automatic', 'cancelled')), + -- intentional = user clicked "Keep", automatic = auto-charged, cancelled = user cancelled before charge + reminder_count INTEGER DEFAULT 0, -- how many trial reminders were sent + acted_on_reminder BOOLEAN DEFAULT FALSE, -- did user act after receiving a reminder? + saved_by_syncro BOOLEAN DEFAULT FALSE, -- trial cancelled before auto-charge thanks to SYNCRO reminder + converted_price DECIMAL(10,2), + created_at TIMESTAMPTZ DEFAULT timezone('utc'::text, now()) NOT NULL +); + +-- Enable RLS +ALTER TABLE public.trial_conversion_events ENABLE ROW LEVEL SECURITY; + +CREATE POLICY "trial_conversion_events_select_own" + ON public.trial_conversion_events FOR SELECT + USING (auth.uid() = user_id); + +CREATE POLICY "trial_conversion_events_insert_own" + ON public.trial_conversion_events FOR INSERT + WITH CHECK (auth.uid() = user_id); + +-- Indexes +CREATE INDEX IF NOT EXISTS trial_conversion_events_user_id_idx ON public.trial_conversion_events(user_id); +CREATE INDEX IF NOT EXISTS trial_conversion_events_subscription_id_idx ON public.trial_conversion_events(subscription_id); +CREATE INDEX IF NOT EXISTS trial_conversion_events_saved_by_syncro_idx ON public.trial_conversion_events(saved_by_syncro) WHERE saved_by_syncro = TRUE; diff --git a/backend/src/routes/subscriptions.ts b/backend/src/routes/subscriptions.ts index 2e2bab9..321c6af 100644 --- a/backend/src/routes/subscriptions.ts +++ b/backend/src/routes/subscriptions.ts @@ -718,4 +718,56 @@ router.post("/bulk", validateBulkSubscriptionOwnership, async (req: Authenticate } }); +/** + * POST /api/subscriptions/:id/trial/convert + * Intentionally convert trial to paid (user clicked "Keep My Subscription") + */ +router.post("/:id/trial/convert", validateSubscriptionOwnership, async (req: AuthenticatedRequest, res: Response) => { + try { + const result = await subscriptionService.convertTrialToPaid( + req.user!.id, + Array.isArray(req.params.id) ? req.params.id[0] : req.params.id, + ); + res.json({ + success: true, + data: result.subscription, + blockchain: { + synced: result.syncStatus === "synced", + transactionHash: result.blockchainResult?.transactionHash, + error: result.blockchainResult?.error, + }, + }); + } catch (error) { + logger.error("Trial convert error:", error); + const statusCode = error instanceof Error && error.message.includes("not found") ? 404 : 500; + res.status(statusCode).json({ success: false, error: error instanceof Error ? error.message : "Failed to convert trial" }); + } +}); + +/** + * POST /api/subscriptions/:id/trial/cancel + * Cancel a trial before auto-charge + */ +router.post("/:id/trial/cancel", validateSubscriptionOwnership, async (req: AuthenticatedRequest, res: Response) => { + try { + const result = await subscriptionService.cancelTrial( + req.user!.id, + Array.isArray(req.params.id) ? req.params.id[0] : req.params.id, + ); + res.json({ + success: true, + data: result.subscription, + blockchain: { + synced: result.syncStatus === "synced", + transactionHash: result.blockchainResult?.transactionHash, + error: result.blockchainResult?.error, + }, + }); + } catch (error) { + logger.error("Trial cancel error:", error); + const statusCode = error instanceof Error && error.message.includes("not found") ? 404 : 500; + res.status(statusCode).json({ success: false, error: error instanceof Error ? error.message : "Failed to cancel trial" }); + } +}); + export default router; \ No newline at end of file diff --git a/backend/src/services/email-service.ts b/backend/src/services/email-service.ts index 1786e92..8e85f97 100644 --- a/backend/src/services/email-service.ts +++ b/backend/src/services/email-service.ts @@ -176,7 +176,10 @@ export class EmailService { } if (reminderType === 'trial_expiry') { - return `⏰ ${subscription.name} trial expires soon`; + if (daysBefore === 0) { + return `⚠️ Your ${subscription.name} trial ends TODAY — don't get charged!`; + } + return `⚠️ Your ${subscription.name} trial ends in ${daysBefore} day${daysBefore > 1 ? 's' : ''} — don't get charged!`; } return `🔔 ${subscription.name} reminder`; @@ -185,53 +188,130 @@ export class EmailService { /** * Generate email HTML template */ - private getEmailTemplate(payload: NotificationPayload): string { - const { subscription, daysBefore, renewalDate, reminderType } = payload; - const renewalDateFormatted = new Date(renewalDate).toLocaleDateString( - 'en-US', - { year: 'numeric', month: 'long', day: 'numeric' } - ); + /** + * Generate email HTML template + */ + private getEmailTemplate(payload: NotificationPayload): string { + const { subscription, daysBefore, renewalDate, reminderType } = payload; + const renewalDateFormatted = new Date(renewalDate).toLocaleDateString( + 'en-US', + { year: 'numeric', month: 'long', day: 'numeric' } + ); - return ` - - - - - - Subscription Reminder - - -
-

Subscription Reminder

-
- -
-

${this.getEmailSubject(payload).replace(/[📅⚠️⏰🔔]/g, '')}

- -
-

Service: ${subscription.name}

-

Category: ${subscription.category}

-

Price: $${subscription.price.toFixed(2)}/${subscription.billing_cycle}

-

Renewal Date: ${renewalDateFormatted}

- ${daysBefore > 0 ? `

Days Remaining: ${daysBefore}

` : ''} + if (reminderType === 'trial_expiry') { + return this.getTrialEmailTemplate(payload, renewalDateFormatted); + } + + return ` + + + + + + Subscription Reminder + + +
+

Subscription Reminder

- ${subscription.renewal_url ? ` -
- - Manage Subscription - +
+

${this.getEmailSubject(payload).replace(/[📅⚠️⏰🔔]/g, '')}

+ +
+

Service: ${subscription.name}

+

Category: ${subscription.category}

+

Price: $${subscription.price.toFixed(2)}/${subscription.billing_cycle}

+

Renewal Date: ${renewalDateFormatted}

+ ${daysBefore > 0 ? `

Days Remaining: ${daysBefore}

` : ''} +
+ + ${subscription.renewal_url ? ` + + ` : ''} + +

+ This is an automated reminder from Synchro. You're receiving this because you have a subscription renewal coming up. +

- ` : ''} + + + `.trim(); + } + + /** + * Generate trial-specific email HTML template + */ + private getTrialEmailTemplate(payload: NotificationPayload, renewalDateFormatted: string): string { + const { subscription, daysBefore } = payload; + const chargePrice = subscription.trial_converts_to_price ?? subscription.price; + const urgencyColor = daysBefore <= 1 ? '#dc2626' : daysBefore <= 3 ? '#ea580c' : '#d97706'; + const urgencyText = daysBefore === 0 + ? 'Your trial ends TODAY at midnight' + : `Your trial ends in ${daysBefore} day${daysBefore > 1 ? 's' : ''}`; + + const nextDayFormatted = new Date( + new Date(payload.renewalDate).getTime() + 24 * 60 * 60 * 1000 + ).toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' }); + + const cancelUrl = subscription.renewal_url ? sanitizeUrl(subscription.renewal_url) : null; + const keepUrl = sanitizeUrl(subscription.website_url || subscription.renewal_url || '#'); + + return ` + + + + + + Trial Ending Soon + + +
+

Free Trial Ending

+

${subscription.name}

+
+ +
+

⚠️ ${urgencyText}

+
+ +
+
+

Service: ${subscription.name}

+

FREE trial expires: ${renewalDateFormatted}

+

If you don't cancel: You'll be charged $${chargePrice.toFixed(2)}/${subscription.billing_cycle} starting ${nextDayFormatted}.

+
+ + + + ${cancelUrl ? ` + + ` : ''} + + +
+ + Cancel Trial Now → + + + + Keep My Subscription → + +
+ +

+ This reminder was sent by SYNCRO to help you avoid unexpected charges. +

+
+ + + `.trim(); + } -

- This is an automated reminder from Synchro. You're receiving this because you have a subscription renewal coming up. -

-
- - - `.trim(); - } /** * Generate plain text email diff --git a/backend/src/services/monitoring-service.ts b/backend/src/services/monitoring-service.ts index dbedb4d..89c6d20 100644 --- a/backend/src/services/monitoring-service.ts +++ b/backend/src/services/monitoring-service.ts @@ -8,6 +8,14 @@ export interface SubscriptionMetrics { total_monthly_revenue: number; } +export interface TrialMetrics { + active_trials: number; + trials_expiring_in_7_days: number; + saved_by_syncro: number; // trials cancelled before auto-charge after receiving a reminder + intentional_conversions: number; + automatic_conversions: number; +} + export interface RenewalMetrics { total_delivery_attempts: number; success_rate: number; @@ -136,6 +144,52 @@ export class MonitoringService { throw error; } } + + /** + * Get trial-specific metrics including "saved by SYNCRO" count + */ + async getTrialMetrics(): Promise { + try { + const now = new Date().toISOString(); + const in7Days = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(); + + const [ + { count: activeTrials }, + { count: expiringTrials }, + { data: conversionEvents }, + ] = await Promise.all([ + supabase + .from('subscriptions') + .select('*', { count: 'exact', head: true }) + .eq('is_trial', true) + .in('status', ['active', 'trial']) + .gt('trial_ends_at', now), + supabase + .from('subscriptions') + .select('*', { count: 'exact', head: true }) + .eq('is_trial', true) + .in('status', ['active', 'trial']) + .gt('trial_ends_at', now) + .lte('trial_ends_at', in7Days), + supabase + .from('trial_conversion_events') + .select('conversion_type, saved_by_syncro'), + ]); + + const events = conversionEvents ?? []; + + return { + active_trials: activeTrials ?? 0, + trials_expiring_in_7_days: expiringTrials ?? 0, + saved_by_syncro: events.filter((e) => e.saved_by_syncro).length, + intentional_conversions: events.filter((e) => e.conversion_type === 'intentional').length, + automatic_conversions: events.filter((e) => e.conversion_type === 'automatic').length, + }; + } catch (error) { + logger.error('Error fetching trial metrics:', error); + throw error; + } + } } export const monitoringService = new MonitoringService(); diff --git a/backend/src/services/reminder-engine.ts b/backend/src/services/reminder-engine.ts index 38ab6bd..65d1a07 100644 --- a/backend/src/services/reminder-engine.ts +++ b/backend/src/services/reminder-engine.ts @@ -101,12 +101,16 @@ export class ReminderEngine { return; } - const renewalDate = subscription.active_until || new Date().toISOString(); + const renewalDate = reminder.reminder_type === 'trial_expiry' + ? (subscription.trial_ends_at || new Date().toISOString()) + : (subscription.active_until || new Date().toISOString()); const payload: NotificationPayload = { - title: `${subscription.name} Renewal Reminder`, - body: `${subscription.name} will renew in ${reminder.days_before} day${ - reminder.days_before > 1 ? "s" : "" - }`, + title: reminder.reminder_type === 'trial_expiry' + ? `${subscription.name} Trial Ending Soon` + : `${subscription.name} Renewal Reminder`, + body: reminder.reminder_type === 'trial_expiry' + ? `Your ${subscription.name} trial ends in ${reminder.days_before} day${reminder.days_before > 1 ? 's' : ''}` + : `${subscription.name} will renew in ${reminder.days_before} day${reminder.days_before > 1 ? 's' : ''}`, subscription, reminderType: reminder.reminder_type, daysBefore: reminder.days_before, @@ -454,12 +458,95 @@ export class ReminderEngine { } logger.info("Reminder scheduling completed"); + + // Also schedule trial-specific reminders + await this.scheduleTrialReminders(); } catch (error) { logger.error("Error scheduling reminders:", error); throw error; } } + /** + * Schedule trial expiry reminders with aggressive windows: 14, 7, 3, 1, 0 days + * Credit-card-required trials get the 14-day early warning + */ + async scheduleTrialReminders(): Promise { + const TRIAL_REMINDER_WINDOWS = [14, 7, 3, 1, 0]; + + logger.info("Scheduling trial expiry reminders"); + + try { + const { data: trials, error } = await supabase + .from("subscriptions") + .select("*") + .eq("is_trial", true) + .in("status", ["active", "trial"]) + .not("trial_ends_at", "is", null) + .gt("trial_ends_at", new Date().toISOString()); + + if (error) { + logger.error("Failed to fetch trial subscriptions:", error); + throw error; + } + + if (!trials || trials.length === 0) { + logger.info("No active trials with future end dates"); + return; + } + + logger.info(`Found ${trials.length} active trials to schedule reminders for`); + + for (const subscription of trials) { + const trialEndsAt = new Date(subscription.trial_ends_at); + const today = new Date(); + today.setHours(0, 0, 0, 0); + + // Non-credit-card trials skip the 14-day window + const windows = subscription.credit_card_required + ? TRIAL_REMINDER_WINDOWS + : TRIAL_REMINDER_WINDOWS.filter((d) => d !== 14); + + for (const days of windows) { + const reminderDate = new Date(trialEndsAt); + reminderDate.setDate(reminderDate.getDate() - days); + reminderDate.setHours(0, 0, 0, 0); + + if (reminderDate >= today) { + const { data: existing } = await supabase + .from("reminder_schedules") + .select("id") + .eq("subscription_id", subscription.id) + .eq("reminder_type", "trial_expiry") + .eq("days_before", days) + .eq("status", "pending") + .single(); + + if (!existing) { + await supabase.from("reminder_schedules").insert({ + subscription_id: subscription.id, + user_id: subscription.user_id, + reminder_date: reminderDate.toISOString().split("T")[0], + reminder_type: "trial_expiry", + days_before: days, + status: "pending", + }); + + logger.debug( + `Scheduled trial reminder for subscription ${subscription.id} (${days} days before trial end)`, + ); + } + } + } + } + + logger.info("Trial reminder scheduling completed"); + } catch (error) { + logger.error("Error scheduling trial reminders:", error); + throw error; + } + } + // --------------------------------------------------------------------------- // Private helpers // --------------------------------------------------------------------------- diff --git a/backend/src/services/subscription-service.ts b/backend/src/services/subscription-service.ts index 7628b3d..3babe6e 100644 --- a/backend/src/services/subscription-service.ts +++ b/backend/src/services/subscription-service.ts @@ -629,6 +629,155 @@ async resumeSubscription( throw error; } } + + /** + * Convert a trial to a paid subscription (intentional — user clicked "Keep") + * Logs the conversion event and marks the subscription as active + */ + async convertTrialToPaid( + userId: string, + subscriptionId: string, + ): Promise { + return await DatabaseTransaction.execute(async (client) => { + const { data: subscription, error: fetchError } = await client + .from("subscriptions") + .select("*") + .eq("id", subscriptionId) + .eq("user_id", userId) + .single(); + + if (fetchError || !subscription) { + throw new Error("Subscription not found or access denied"); + } + + if (!subscription.is_trial) { + throw new Error("Subscription is not a trial"); + } + + const convertedPrice = subscription.trial_converts_to_price ?? subscription.price; + + const { data: updated, error: updateError } = await client + .from("subscriptions") + .update({ + is_trial: false, + status: "active", + price: convertedPrice, + trial_ends_at: null, + updated_at: new Date().toISOString(), + }) + .eq("id", subscriptionId) + .eq("user_id", userId) + .select() + .single(); + + if (updateError) throw new Error(`Update failed: ${updateError.message}`); + + // Count how many trial reminders were sent + const { count: reminderCount } = await client + .from("reminder_schedules") + .select("*", { count: "exact", head: true }) + .eq("subscription_id", subscriptionId) + .eq("reminder_type", "trial_expiry") + .eq("status", "sent"); + + await client.from("trial_conversion_events").insert({ + subscription_id: subscriptionId, + user_id: userId, + conversion_type: "intentional", + reminder_count: reminderCount ?? 0, + acted_on_reminder: (reminderCount ?? 0) > 0, + saved_by_syncro: false, + converted_price: convertedPrice, + }); + + logger.info("Trial converted to paid (intentional)", { subscriptionId, userId }); + + let blockchainResult; + let syncStatus: "synced" | "partial" | "failed" = "synced"; + try { + blockchainResult = await blockchainService.syncSubscription(userId, subscriptionId, "update", updated); + if (!blockchainResult.success) syncStatus = "partial"; + } catch (e) { + syncStatus = "partial"; + blockchainResult = { success: false, error: e instanceof Error ? e.message : String(e) }; + } + + return { subscription: updated, blockchainResult, syncStatus }; + }); + } + + /** + * Cancel a trial before auto-charge — logs as "saved by SYNCRO" if reminders were sent + */ + async cancelTrial( + userId: string, + subscriptionId: string, + ): Promise { + return await DatabaseTransaction.execute(async (client) => { + const { data: subscription, error: fetchError } = await client + .from("subscriptions") + .select("*") + .eq("id", subscriptionId) + .eq("user_id", userId) + .single(); + + if (fetchError || !subscription) { + throw new Error("Subscription not found or access denied"); + } + + if (!subscription.is_trial) { + throw new Error("Subscription is not a trial"); + } + + const { data: updated, error: updateError } = await client + .from("subscriptions") + .update({ + status: "cancelled", + is_trial: false, + updated_at: new Date().toISOString(), + }) + .eq("id", subscriptionId) + .eq("user_id", userId) + .select() + .single(); + + if (updateError) throw new Error(`Update failed: ${updateError.message}`); + + // Check if any trial reminders were sent (user was warned by SYNCRO) + const { count: reminderCount } = await client + .from("reminder_schedules") + .select("*", { count: "exact", head: true }) + .eq("subscription_id", subscriptionId) + .eq("reminder_type", "trial_expiry") + .eq("status", "sent"); + + const savedBySyncro = (reminderCount ?? 0) > 0 && !!subscription.credit_card_required; + + await client.from("trial_conversion_events").insert({ + subscription_id: subscriptionId, + user_id: userId, + conversion_type: "cancelled", + reminder_count: reminderCount ?? 0, + acted_on_reminder: (reminderCount ?? 0) > 0, + saved_by_syncro: savedBySyncro, + converted_price: null, + }); + + logger.info("Trial cancelled", { subscriptionId, userId, savedBySyncro }); + + let blockchainResult; + let syncStatus: "synced" | "partial" | "failed" = "synced"; + try { + blockchainResult = await blockchainService.syncSubscription(userId, subscriptionId, "cancel", updated); + if (!blockchainResult.success) syncStatus = "partial"; + } catch (e) { + syncStatus = "partial"; + blockchainResult = { success: false, error: e instanceof Error ? e.message : String(e) }; + } + + return { subscription: updated, blockchainResult, syncStatus }; + }); + } } export const subscriptionService = new SubscriptionService(); diff --git a/backend/src/types/reminder.ts b/backend/src/types/reminder.ts index 5cc4732..0d0632d 100644 --- a/backend/src/types/reminder.ts +++ b/backend/src/types/reminder.ts @@ -46,6 +46,12 @@ export interface Subscription { expired_at: string | null; created_at: string; updated_at: string; + // Trial tracking + is_trial: boolean; + trial_ends_at: string | null; + trial_converts_to_price: number | null; + credit_card_required: boolean; + website_url: string | null; } export interface UserProfile { diff --git a/backend/src/types/subscription.ts b/backend/src/types/subscription.ts index 1de1bcd..d87ee20 100644 --- a/backend/src/types/subscription.ts +++ b/backend/src/types/subscription.ts @@ -27,6 +27,11 @@ export interface Subscription { paused_at: string | null; resume_at: string | null; pause_reason: string | null; + // Trial tracking fields + is_trial: boolean; + trial_ends_at: string | null; + trial_converts_to_price: number | null; + credit_card_required: boolean; } export interface SubscriptionCreateInput { @@ -44,6 +49,11 @@ export interface SubscriptionCreateInput { notes?: string; tags?: string[]; email_account_id?: string; + // Trial fields + is_trial?: boolean; + trial_ends_at?: string; + trial_converts_to_price?: number; + credit_card_required?: boolean; } export interface SubscriptionUpdateInput { @@ -64,6 +74,11 @@ export interface SubscriptionUpdateInput { paused_at?: string | null; resume_at?: string | null; pause_reason?: string | null; + // Trial fields + is_trial?: boolean; + trial_ends_at?: string | null; + trial_converts_to_price?: number | null; + credit_card_required?: boolean; } /** Allowlist of fields a user is permitted to update. diff --git a/client/components/pages/subscriptions.tsx b/client/components/pages/subscriptions.tsx index 0e160b6..6314d62 100644 --- a/client/components/pages/subscriptions.tsx +++ b/client/components/pages/subscriptions.tsx @@ -1,7 +1,7 @@ "use client" import { useState, useEffect } from "react" -import { Edit2, Trash2, Mail, Clock, Copy, Lock, Users, Calendar, Check } from "lucide-react" +import { Edit2, Trash2, Mail, Clock, Copy, Lock, Users, Calendar, Check, AlertTriangle, X } from "lucide-react" import { useDebounce } from "@/hooks/use-debounce" import { VirtualizedList } from "@/components/ui/virtualized-list" import { EmptyState } from "@/components/ui/empty-state" @@ -19,6 +19,8 @@ interface SubscriptionsPageProps { emailAccounts?: any[] duplicates?: any[] unusedSubscriptions?: any[] + onCancelTrial?: (id: number) => void + onConvertTrial?: (id: number) => void } export default function SubscriptionsPage({ @@ -34,6 +36,8 @@ export default function SubscriptionsPage({ emailAccounts = [], duplicates = [], unusedSubscriptions = [], + onCancelTrial, + onConvertTrial, }: SubscriptionsPageProps) { const [searchTerm, setSearchTerm] = useState("") const debouncedSearchTerm = useDebounce(searchTerm, 300) @@ -115,6 +119,11 @@ export default function SubscriptionsPage({ const hasNoSubscriptions = !subscriptions || subscriptions.length === 0 const hasNoResults = filtered.length === 0 && subscriptions && subscriptions.length > 0 + // Active trials sorted by urgency (soonest expiry first) + const activeTrials = (subscriptions || []) + .filter((s: any) => s.isTrial && s.trialEndsAt) + .sort((a: any, b: any) => new Date(a.trialEndsAt).getTime() - new Date(b.trialEndsAt).getTime()) + if (hasNoSubscriptions) { return ( + {/* Active Trials Section */} + {activeTrials.length > 0 && ( +
+

+

+
+ {activeTrials.map((sub: any) => { + const daysLeft = Math.ceil((new Date(sub.trialEndsAt).getTime() - Date.now()) / (1000 * 60 * 60 * 24)) + const urgencyColor = daysLeft <= 1 ? "text-red-600" : daysLeft <= 3 ? "text-orange-500" : "text-yellow-600" + const urgencyBg = daysLeft <= 1 ? (darkMode ? "bg-red-900/20 border-red-700" : "bg-red-50 border-red-200") : daysLeft <= 3 ? (darkMode ? "bg-orange-900/20 border-orange-700" : "bg-orange-50 border-orange-200") : (darkMode ? "bg-yellow-900/20 border-yellow-700" : "bg-yellow-50 border-yellow-200") + return ( +
+
+ +
+
+

{sub.name}

+ Trial +
+

+ {daysLeft === 0 ? "Expires TODAY at midnight" : `Expires in ${daysLeft} day${daysLeft > 1 ? "s" : ""}`} +

+ {sub.priceAfterTrial && ( +

+ Auto-charges ${sub.priceAfterTrial}/{sub.billingCycle || "month"} after trial +

+ )} +
+
+
+
+

+ {daysLeft === 0 ? "Today" : `${daysLeft}d`} +

+

remaining

+
+ {onCancelTrial && ( + + )} + {onConvertTrial && ( + + )} +
+
+ ) + })} +
+
+ )} + {/* Subscriptions List */} {!hasNoResults && ( <> @@ -338,6 +420,8 @@ export default function SubscriptionsPage({ darkMode={darkMode} isDuplicate={duplicates.some((dup: any) => dup.subscriptions.some((s: any) => s.id === sub.id))} unusedInfo={unusedSubscriptions.find((unused: any) => unused.id === sub.id)} + onCancelTrial={onCancelTrial} + onConvertTrial={onConvertTrial} /> )} /> @@ -354,6 +438,8 @@ export default function SubscriptionsPage({ darkMode={darkMode} isDuplicate={duplicates.some((dup: any) => dup.subscriptions.some((s: any) => s.id === sub.id))} unusedInfo={unusedSubscriptions.find((unused: any) => unused.id === sub.id)} + onCancelTrial={onCancelTrial} + onConvertTrial={onConvertTrial} /> ))}
@@ -436,6 +522,8 @@ interface SubscriptionCardProps { darkMode?: boolean isDuplicate?: boolean unusedInfo?: any + onCancelTrial?: (id: number) => void + onConvertTrial?: (id: number) => void } function SubscriptionCard({ @@ -447,6 +535,8 @@ function SubscriptionCard({ darkMode, isDuplicate, unusedInfo, + onCancelTrial, + onConvertTrial, }: SubscriptionCardProps) { const statusLabel = sub.status === "expiring" @@ -561,6 +651,26 @@ function SubscriptionCard({
+ {sub.isTrial && onCancelTrial && ( + + )} + {sub.isTrial && onConvertTrial && ( + + )}