diff --git a/actions/generation.test.ts b/actions/generation.test.ts index a7da089..8f11763 100644 --- a/actions/generation.test.ts +++ b/actions/generation.test.ts @@ -164,7 +164,7 @@ describe('generateVariations', () => { expect(mockCallAI).toHaveBeenCalledTimes(1); const callArgs = mockCallAI.mock.calls[0][0]; - expect(callArgs.system).toContain('social media post'); + expect(callArgs.system).toContain('Generate exactly'); expect(callArgs.messages[0].content).toBe(validInput.input); }); diff --git a/actions/trial.test.ts b/actions/trial.test.ts new file mode 100644 index 0000000..397908c --- /dev/null +++ b/actions/trial.test.ts @@ -0,0 +1,170 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +const { mockGetUser, mockFrom, mockRevalidatePath, mockServiceFrom } = vi.hoisted(() => ({ + mockGetUser: vi.fn(), + mockFrom: vi.fn(), + mockRevalidatePath: vi.fn(), + mockServiceFrom: vi.fn(), +})); + +vi.mock('@/lib/supabase/server', () => ({ + createServerClient: vi.fn().mockResolvedValue({ + auth: { getUser: mockGetUser }, + from: mockFrom, + }), + createServiceRoleClient: vi.fn().mockReturnValue({ + from: mockServiceFrom, + }), +})); + +vi.mock('next/cache', () => ({ + revalidatePath: mockRevalidatePath, +})); + +import { activateFreeTrial } from './trial'; + +const MOCK_USER = { + id: 'user-123', + user_metadata: { full_name: 'Test User' }, +}; + +function chainSubscriptions(data: { id?: string } | null): ReturnType { + const single = vi.fn().mockResolvedValue({ data }); + const in_ = vi.fn().mockReturnValue({ single }); + const eq = vi.fn().mockReturnValue({ in: in_ }); + const select = vi.fn().mockReturnValue({ eq }); + return { select, eq, in: in_, single }; +} + +function chainProfilesSelect(data: { trial_uses_remaining: number | null }, error: { code?: string; message?: string } | null = null): ReturnType { + const single = vi.fn().mockResolvedValue({ data, error }); + const eq = vi.fn().mockReturnValue({ single }); + const select = vi.fn().mockReturnValue({ eq }); + return { select, eq, single }; +} + +function chainProfilesSelectNoRows(): ReturnType { + return chainProfilesSelect(null as unknown as { trial_uses_remaining: number | null }, { code: 'PGRST116', message: 'No rows found' }); +} + +function chainProfilesUpdate(updated: Array<{ id: string }> | null, error: { message?: string } | null = null): ReturnType { + const select = vi.fn().mockResolvedValue({ data: updated, error }); + const eq = vi.fn().mockReturnValue({ select }); + const update = vi.fn().mockReturnValue({ eq }); + return { update, eq, select }; +} + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe('activateFreeTrial', () => { + it('should return error when user is not authenticated', async () => { + mockGetUser.mockResolvedValue({ data: { user: null } }); + + const result = await activateFreeTrial(); + + expect(result.success).toBe(false); + expect(result.error).toBe('Unauthorized'); + }); + + it('should return error when user already has active subscription', async () => { + mockGetUser.mockResolvedValue({ data: { user: MOCK_USER } }); + mockFrom + .mockReturnValueOnce(chainSubscriptions({ id: 'sub-1' })); + + const result = await activateFreeTrial(); + + expect(result.success).toBe(false); + expect(result.error).toBe('You already have an active subscription.'); + }); + + it('should return error when trial has already been activated', async () => { + mockGetUser.mockResolvedValue({ data: { user: MOCK_USER } }); + mockFrom + .mockReturnValueOnce(chainSubscriptions(null)) + .mockReturnValueOnce(chainProfilesSelect({ trial_uses_remaining: 3 })); + + const result = await activateFreeTrial(); + + expect(result.success).toBe(false); + expect(result.error).toBe('Free trial has already been activated.'); + }); + + it('should return error when profile update returns database error', async () => { + mockGetUser.mockResolvedValue({ data: { user: MOCK_USER } }); + mockFrom + .mockReturnValueOnce(chainSubscriptions(null)) + .mockReturnValueOnce(chainProfilesSelect({ trial_uses_remaining: null })) + .mockReturnValueOnce(chainProfilesUpdate(null, { message: 'Permission denied' })); + + const result = await activateFreeTrial(); + + expect(result.success).toBe(false); + expect(result.error).toContain('Failed to activate trial'); + }); + + it('should return error when update affects zero rows', async () => { + mockGetUser.mockResolvedValue({ data: { user: MOCK_USER } }); + mockFrom + .mockReturnValueOnce(chainSubscriptions(null)) + .mockReturnValueOnce(chainProfilesSelect({ trial_uses_remaining: null })) + .mockReturnValueOnce(chainProfilesUpdate([])); + + const result = await activateFreeTrial(); + + expect(result.success).toBe(false); + expect(result.error).toContain('Could not update your account'); + expect(result.error).toContain('signing out and back in'); + }); + + it('should activate trial and revalidate when profile exists and update succeeds', async () => { + mockGetUser.mockResolvedValue({ data: { user: MOCK_USER } }); + mockFrom + .mockReturnValueOnce(chainSubscriptions(null)) + .mockReturnValueOnce(chainProfilesSelect({ trial_uses_remaining: null })) + .mockReturnValueOnce(chainProfilesUpdate([{ id: MOCK_USER.id }])); + + const result = await activateFreeTrial(); + + expect(result.success).toBe(true); + expect(result.error).toBeUndefined(); + expect(mockFrom).toHaveBeenCalledWith('subscriptions'); + expect(mockFrom).toHaveBeenCalledWith('profiles'); + expect(mockRevalidatePath).toHaveBeenCalledWith('/app', 'layout'); + }); + + it('should return error when profile fetch fails with non-PGRST116 error', async () => { + mockGetUser.mockResolvedValue({ data: { user: MOCK_USER } }); + mockFrom + .mockReturnValueOnce(chainSubscriptions(null)) + .mockReturnValueOnce( + chainProfilesSelect(null as unknown as { trial_uses_remaining: number | null }, { code: 'PGRST500', message: 'Internal error' }), + ); + + const result = await activateFreeTrial(); + + expect(result.success).toBe(false); + expect(result.error).toBe('Could not load your profile. Please try again.'); + }); + + it('should ensure profile exists when missing (PGRST116) then activate trial', async () => { + mockGetUser.mockResolvedValue({ data: { user: MOCK_USER } }); + const mockInsert = vi.fn().mockResolvedValue({ error: null }); + mockServiceFrom.mockReturnValue({ insert: mockInsert }); + mockFrom + .mockReturnValueOnce(chainSubscriptions(null)) + .mockReturnValueOnce(chainProfilesSelectNoRows()) + .mockReturnValueOnce(chainProfilesSelect({ trial_uses_remaining: null })) + .mockReturnValueOnce(chainProfilesUpdate([{ id: MOCK_USER.id }])); + + const result = await activateFreeTrial(); + + expect(result.success).toBe(true); + expect(mockServiceFrom).toHaveBeenCalledWith('profiles'); + expect(mockInsert).toHaveBeenCalledWith({ + id: MOCK_USER.id, + full_name: 'Test User', + }); + }); +}); diff --git a/actions/trial.ts b/actions/trial.ts index 9d49242..d897a97 100644 --- a/actions/trial.ts +++ b/actions/trial.ts @@ -3,13 +3,23 @@ import { revalidatePath } from 'next/cache'; import { FREE_TRIAL_USES } from '@/lib/constants'; -import { createServerClient } from '@/lib/supabase/server'; +import { createServerClient, createServiceRoleClient } from '@/lib/supabase/server'; + +const PGRST_NO_ROWS = 'PGRST116'; interface ITrialResult { success: boolean; error?: string; } +function buildTrialErrorMessage(supabaseError: { message?: string; code?: string } | null): string { + const fallback = 'Failed to activate trial. Please try again.'; + if (process.env.NODE_ENV !== 'development' || !supabaseError?.message) { + return fallback; + } + return `${fallback} (${supabaseError.message})`; +} + export async function activateFreeTrial(): Promise { const supabase = await createServerClient(); const { data: { user } } = await supabase.auth.getUser(); @@ -30,25 +40,62 @@ export async function activateFreeTrial(): Promise { return { success: false, error: 'You already have an active subscription.' }; } - // Check trial not already activated - const { data: profile } = await supabase + // Check trial not already activated; ensure profile exists if missing + const { data: profile, error: profileError } = await supabase .from('profiles') .select('trial_uses_remaining') .eq('id', user.id) .single(); - if (profile?.trial_uses_remaining !== null) { + if (profileError?.code === PGRST_NO_ROWS) { + const service = createServiceRoleClient(); + await service + .from('profiles') + .insert({ + id: user.id, + full_name: (user.user_metadata?.full_name as string | undefined) ?? null, + }); + // Retry profile fetch; if still missing or trial already set, handle below + const { data: retryProfile } = await supabase + .from('profiles') + .select('trial_uses_remaining') + .eq('id', user.id) + .single(); + if (retryProfile !== null && retryProfile !== undefined && retryProfile.trial_uses_remaining !== null && retryProfile.trial_uses_remaining !== undefined) { + return { success: false, error: 'Free trial has already been activated.' }; + } + } else if (profileError !== null && profileError !== undefined) { + console.error('[activateFreeTrial] profile fetch failed', { + code: profileError.code, + message: profileError.message, + }); + return { success: false, error: 'Could not load your profile. Please try again.' }; + } else if (profile !== null && profile !== undefined && profile.trial_uses_remaining !== null && profile.trial_uses_remaining !== undefined) { return { success: false, error: 'Free trial has already been activated.' }; } // Activate trial - const { error } = await supabase + const { data: updated, error } = await supabase .from('profiles') .update({ trial_uses_remaining: FREE_TRIAL_USES }) - .eq('id', user.id); + .eq('id', user.id) + .select('id'); if (error) { - return { success: false, error: 'Failed to activate trial. Please try again.' }; + console.error('[activateFreeTrial] profile update failed', { + code: error.code, + message: error.message, + details: error.details, + }); + return { success: false, error: buildTrialErrorMessage(error) }; + } + + const noRowsUpdated = updated === null || (Array.isArray(updated) && updated.length === 0); + if (noRowsUpdated) { + return { + success: false, + error: 'Could not update your account. Try signing out and back in, or contact support.', + }; } revalidatePath('/app', 'layout'); diff --git a/app/(auth)/callback/route.ts b/app/(auth)/callback/route.ts index 02aac63..65488df 100644 --- a/app/(auth)/callback/route.ts +++ b/app/(auth)/callback/route.ts @@ -41,12 +41,31 @@ export async function GET(request: NextRequest): Promise { }, ); - const { error } = await supabase.auth.exchangeCodeForSession(code); + const { data, error } = await supabase.auth.exchangeCodeForSession(code); if (error) { return NextResponse.redirect(new URL('/login?error=auth', baseUrl)); } + // After Twitter login, redirect to the platform connection flow if not yet connected. + // This ensures sign-in with Twitter also grants posting permissions (tweet.write). + if (data.user?.app_metadata?.provider === 'x' && next === '/app') { + const { data: existing } = await supabase + .from('platform_connections') + .select('id') + .eq('user_id', data.user.id) + .eq('platform', 'twitter') + .maybeSingle(); + + if (!existing) { + const response = NextResponse.redirect(new URL('/api/connect/twitter/authorize', baseUrl)); + for (const { name, value, options } of pendingCookies) { + response.cookies.set(name, value, options); + } + return response; + } + } + const response = NextResponse.redirect(new URL(next, baseUrl)); for (const { name, value, options } of pendingCookies) { diff --git a/app/app/page.tsx b/app/app/page.tsx index 7555da8..3e5234e 100644 --- a/app/app/page.tsx +++ b/app/app/page.tsx @@ -1,6 +1,7 @@ import { redirect } from 'next/navigation'; import Composer from '@/components/composer/composer'; +import { TRIAL_LIMITS } from '@/lib/constants'; import { mapMemoryItem, mapPlatformConnection, mapTwitterCommunity } from '@/lib/mappers'; import { createServerClient } from '@/lib/supabase/server'; @@ -88,10 +89,6 @@ export default async function ComposerPage(): Promise { // Platforms: system + user-owned (system platforms always available) const platforms = activeItems.filter((i) => i.type === 'platform'); - const hasSubscription = subscriptionResult.data !== null; - const maxVariations = subscriptionResult.data?.plans?.max_posts_per_generation ?? 1; - const creditBalance = (creditsResult.data?.balance as number | null) ?? 0; - const { data: profileData } = await supabase .from('profiles') .select('trial_uses_remaining') @@ -99,6 +96,15 @@ export default async function ComposerPage(): Promise { .single(); const trialUsesRemaining = (profileData?.trial_uses_remaining as number | null) ?? null; + const hasSubscription = subscriptionResult.data !== null; + const isTrial = !hasSubscription && trialUsesRemaining !== null && trialUsesRemaining > 0; + const maxVariations = hasSubscription + ? (subscriptionResult.data?.plans?.max_posts_per_generation ?? 1) + : (isTrial ? TRIAL_LIMITS.MAX_VARIATIONS : 1); + const creditBalance = hasSubscription + ? ((creditsResult.data?.balance as number | null) ?? 0) + : (trialUsesRemaining ?? 0); + return ( 0; - if (isBasicPlan || !subscription) { + if (isBasicPlan || (!subscription && !hasTrialAccess)) { redirect('/app/plan'); } diff --git a/app/app/plan/page.tsx b/app/app/plan/page.tsx index 6183b93..0d14921 100644 --- a/app/app/plan/page.tsx +++ b/app/app/plan/page.tsx @@ -3,9 +3,9 @@ import { redirect } from 'next/navigation'; import { PlanCard } from '@/components/content-plan/plan-card'; import { Button } from '@/components/ui/button'; +import { PLAN_SLUGS } from '@/lib/constants'; import { mapContentPlan, mapPost } from '@/lib/mappers'; import { createServerClient } from '@/lib/supabase/server'; -import { PLAN_SLUGS } from '@/lib/constants'; import type { IContentPlan, IPost } from '@/types/database'; @@ -21,18 +21,28 @@ export default async function PlansPage(): Promise { if (!user) redirect('/login'); - // Check subscription tier - const { data: subscription } = await supabase - .from('subscriptions') - .select('*, plans(*)') - .eq('user_id', user.id) - .in('status', ['trialing', 'active']) - .single(); - + const [subscriptionResult, profileResult] = await Promise.all([ + supabase + .from('subscriptions') + .select('*, plans(*)') + .eq('user_id', user.id) + .in('status', ['trialing', 'active']) + .single(), + supabase + .from('profiles') + .select('trial_uses_remaining') + .eq('id', user.id) + .single(), + ]); + + const subscription = subscriptionResult.data; const planSlug = subscription?.plans?.slug as string | undefined; const isBasicPlan = planSlug === PLAN_SLUGS.BASIC_MONTHLY || planSlug === PLAN_SLUGS.BASIC_YEARLY; + const trialUsesRemaining = (profileResult.data?.trial_uses_remaining as number | null) ?? null; + const hasTrialAccess = trialUsesRemaining !== null && trialUsesRemaining > 0; - if (isBasicPlan || !subscription) { + // Block only if Basic plan, or no subscription and no active trial + if (isBasicPlan || (!subscription && !hasTrialAccess)) { return (
diff --git a/app/app/settings/billing/page.tsx b/app/app/settings/billing/page.tsx index bedf452..116dcb4 100644 --- a/app/app/settings/billing/page.tsx +++ b/app/app/settings/billing/page.tsx @@ -57,6 +57,7 @@ export default async function BillingPage(): Promise {
diff --git a/components/billing/credits-card.test.tsx b/components/billing/credits-card.test.tsx index d418c39..22a87e7 100644 --- a/components/billing/credits-card.test.tsx +++ b/components/billing/credits-card.test.tsx @@ -66,9 +66,22 @@ describe('CreditsCard', () => { expect(screen.getByText(/0 \/ 600 remaining/)).toBeInTheDocument(); }); - it('should render empty state when no credits', () => { + it('should render empty state when no credits and no trial', () => { render(); expect(screen.getByText(/no credit data/i)).toBeInTheDocument(); }); + + it('should show trial usage in credits card when no credits and trial active', () => { + render(); + + expect(screen.getByText(/5 of 5 free uses remaining/)).toBeInTheDocument(); + expect(screen.getByRole('progressbar')).toBeInTheDocument(); + }); + + it('should show singular "use" when one trial use remaining', () => { + render(); + + expect(screen.getByText(/1 of 5 free use remaining/)).toBeInTheDocument(); + }); }); diff --git a/components/billing/credits-card.tsx b/components/billing/credits-card.tsx index 6c8a797..f08f6fe 100644 --- a/components/billing/credits-card.tsx +++ b/components/billing/credits-card.tsx @@ -1,4 +1,5 @@ import { cn } from '@/lib/utils'; +import { FREE_TRIAL_USES } from '@/lib/constants'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import type { ICredit } from '@/types/database'; @@ -6,6 +7,7 @@ import type { ICredit } from '@/types/database'; interface ICreditsCardProps { credits: ICredit | null; creditsPerCycle: number; + trialUsesRemaining?: number | null; } function getProgressColor(balance: number, total: number): string { @@ -27,8 +29,10 @@ function formatDate(dateString: string): string { }); } -export default function CreditsCard({ credits, creditsPerCycle }: ICreditsCardProps): React.ReactElement { - if (!credits) { +export default function CreditsCard({ credits, creditsPerCycle, trialUsesRemaining = null }: ICreditsCardProps): React.ReactElement | null { + const showTrialUsage = credits === null && trialUsesRemaining !== null && trialUsesRemaining !== undefined; + + if (!credits && !showTrialUsage) { return ( @@ -41,6 +45,48 @@ export default function CreditsCard({ credits, creditsPerCycle }: ICreditsCardPr ); } + if (showTrialUsage) { + const remaining = trialUsesRemaining ?? 0; + const percentage = FREE_TRIAL_USES > 0 ? Math.round((remaining / FREE_TRIAL_USES) * 100) : 0; + const colorClass = getProgressColor(remaining, FREE_TRIAL_USES); + + return ( + + +
+ Credits + + {remaining} of {FREE_TRIAL_USES} free {remaining === 1 ? 'use' : 'uses'} remaining + +
+
+ +
+
+
+

+ Free trial — no credit card required +

+ + + ); + } + + if (!credits) return null; + const percentage = creditsPerCycle > 0 ? Math.round((credits.balance / creditsPerCycle) * 100) : 0; diff --git a/components/billing/current-plan-card.tsx b/components/billing/current-plan-card.tsx index 05e82b0..f72e9c0 100644 --- a/components/billing/current-plan-card.tsx +++ b/components/billing/current-plan-card.tsx @@ -58,7 +58,7 @@ function ActivateTrialButton(): React.ReactElement { startTransition(async () => { const result = await activateFreeTrial(); if (result.success) { - toast.success('Free trial activated! You have 5 uses to explore Socio.'); + toast.success('Free trial activated!'); } else { toast.error(result.error ?? 'Failed to activate trial'); } @@ -82,15 +82,12 @@ export default function CurrentPlanCard({ subscription, plan, credits, trialUses Current Plan - + {trialActive && ( <>
Free Trial
-

- {trialUsesRemaining} of {FREE_TRIAL_USES} free {trialUsesRemaining === 1 ? 'use' : 'uses'} remaining. -

{ expect(screen.getByText('$59')).toBeInTheDocument(); }); - it('should switch to annual prices when Annual tab is clicked', async () => { + it('should switch to annual price only for Pro when Pro Annual tab is clicked', async () => { const user = userEvent.setup(); render(); await user.click(screen.getByRole('tab', { name: /annual/i })); - expect(screen.getByText('$90')).toBeInTheDocument(); + expect(screen.getByText('$9')).toBeInTheDocument(); expect(screen.getByText('$180')).toBeInTheDocument(); - expect(screen.getByText('$540')).toBeInTheDocument(); + expect(screen.getByText('$59')).toBeInTheDocument(); + expect(screen.getByText(/\/yr/)).toBeInTheDocument(); }); it('should mark the current plan with Current Plan button', () => { @@ -206,18 +207,18 @@ describe('PlanComparison', () => { expect(perYearLabels.length).toBeGreaterThanOrEqual(1); }); - it('should show discount percentage for annual plans', async () => { + it('should show discount percentage only for Pro annual with primary highlight', async () => { const user = userEvent.setup(); render(); await user.click(screen.getByRole('tab', { name: /annual/i })); - expect(screen.getByText(/save 17%/i)).toBeInTheDocument(); expect(screen.getByText(/save 21%/i)).toBeInTheDocument(); - expect(screen.getByText(/save 24%/i)).toBeInTheDocument(); + expect(screen.queryByText(/save 17%/i)).not.toBeInTheDocument(); + expect(screen.queryByText(/save 24%/i)).not.toBeInTheDocument(); }); - it('should not show discount percentage for monthly plans', () => { + it('should not show discount badge for monthly plans', () => { render(); expect(screen.queryByText(/save/i)).not.toBeInTheDocument(); diff --git a/components/billing/plan-comparison.tsx b/components/billing/plan-comparison.tsx index 6e78b13..c62bcee 100644 --- a/components/billing/plan-comparison.tsx +++ b/components/billing/plan-comparison.tsx @@ -29,6 +29,8 @@ interface IPlanTier { const TIER_ORDER = ['basic', 'pro', 'ultra']; +const PRO_TIER_KEY = 'pro'; + function groupPlansByTier(plans: IPlan[]): IPlanTier[] { const tierMap = new Map(); @@ -124,7 +126,7 @@ function calculateAnnualDiscount(monthlyPrice: number, yearlyPrice: number): num } export default function PlanComparison({ plans, currentPlanSlug }: IPlanComparisonProps): React.ReactElement { - const [interval, setInterval] = useState('monthly'); + const [proInterval, setProInterval] = useState('monthly'); const [isPending, startTransition] = useTransition(); if (plans.length === 0) { @@ -153,29 +155,22 @@ export default function PlanComparison({ plans, currentPlanSlug }: IPlanComparis return (
-
-

Plans

- setInterval(value as BillingInterval)} - > - - Monthly - Annual - - -
+

Plans

{tiers.map((tier) => { - const plan = interval === 'annual' ? tier.yearly : tier.monthly; + const isPro = tier.tierKey === PRO_TIER_KEY; + const plan = isPro && proInterval === 'annual' && tier.yearly + ? tier.yearly + : tier.monthly; if (!plan) return null; const ctaLabel = getCtaLabel(currentPlanSlug, plan.slug); const isCurrentPlan = ctaLabel === 'Current Plan'; const features = getFeatures(plan); - const discountPercent = interval === 'annual' && tier.monthly && tier.yearly + const showAnnualOption = isPro && tier.monthly !== null && tier.yearly !== null; + const discountPercent = showAnnualOption && proInterval === 'annual' && tier.monthly && tier.yearly ? calculateAnnualDiscount(tier.monthly.priceCents, tier.yearly.priceCents) : 0; @@ -197,18 +192,40 @@ export default function PlanComparison({ plans, currentPlanSlug }: IPlanComparis )} {tier.name} -
- - {formatPrice(plan.priceCents)} - - - /{interval === 'monthly' ? 'mo' : 'yr'} - -
- {discountPercent > 0 && ( - - Save {discountPercent}% - + {showAnnualOption ? ( + setProInterval(value as BillingInterval)} + > + + Monthly + Annual + +
+ + {formatPrice(plan.priceCents)} + + + /{proInterval === 'monthly' ? 'mo' : 'yr'} + +
+ {discountPercent > 0 && ( + + Save {discountPercent}% + + )} +
+ ) : ( + <> +
+ + {formatPrice(plan.priceCents)} + + /mo +
+ )}
diff --git a/components/content-plan/plan-form.tsx b/components/content-plan/plan-form.tsx index f455d02..a83e489 100644 --- a/components/content-plan/plan-form.tsx +++ b/components/content-plan/plan-form.tsx @@ -6,7 +6,6 @@ import { toast } from 'sonner'; import { createPlan } from '@/actions/content-plan'; import { - CreditEstimate, DateRangeChip, GeneratePlanButton, PostsPerDayChip, @@ -179,13 +178,7 @@ export function PlanForm({ style={{ minHeight: `${MIN_HEIGHT}px` }} /> -
- +
) {
0` = active - **What counts as a use:** Generate post, create content plan, regenerate post/plan, publish to platform, schedule post or plan -- **Trial limits:** 1 variation per generation, 7-day content plans, 3 posts/day +- **Trial limits:** 3 variations per generation, 7-day content plans, 3 posts/day - **After trial ends:** User must subscribe to a paid plan to continue +## Troubleshooting + +If a user sees **"Failed to activate trial"** when clicking Activate Free Trial, suggest they sign out and sign back in. If it persists, support should verify that a profile row exists for the user and that RLS allows the user to update their own profile (e.g. session present and `auth.uid()` matches the profile `id`). Server logs include the underlying Supabase error when the update fails. + ## Subscription Lifecycle ``` diff --git a/lib/constants.test.ts b/lib/constants.test.ts index ea98074..7f7aacd 100644 --- a/lib/constants.test.ts +++ b/lib/constants.test.ts @@ -25,7 +25,7 @@ describe('FREE_TRIAL_USES', () => { describe('TRIAL_LIMITS', () => { it('should have correct trial limits', () => { - expect(TRIAL_LIMITS.MAX_VARIATIONS).toBe(1); + expect(TRIAL_LIMITS.MAX_VARIATIONS).toBe(3); expect(TRIAL_LIMITS.CONTENT_PLAN_MAX_DAYS).toBe(7); expect(TRIAL_LIMITS.CONTENT_PLAN_MAX_POSTS_PER_DAY).toBe(3); }); diff --git a/lib/constants.ts b/lib/constants.ts index 1424ef2..ac9742c 100644 --- a/lib/constants.ts +++ b/lib/constants.ts @@ -10,7 +10,7 @@ export const PLAN_SLUGS = { export const FREE_TRIAL_USES = 5; export const TRIAL_LIMITS = { - MAX_VARIATIONS: 1, + MAX_VARIATIONS: 3, CONTENT_PLAN_MAX_DAYS: 7, CONTENT_PLAN_MAX_POSTS_PER_DAY: 3, } as const;