diff --git a/.cursor/skills/writing-x-posts/SKILL.md b/.cursor/skills/writing-x-posts/SKILL.md index 6b5c9fa..55d07e0 100644 --- a/.cursor/skills/writing-x-posts/SKILL.md +++ b/.cursor/skills/writing-x-posts/SKILL.md @@ -12,7 +12,7 @@ Create engaging X (Twitter) posts and threads that capture attention, drive enga ### Brevity is Power X rewards concise, punchy content. Every word must earn its place. If you can say it in fewer words, do it. -### One Idea Per Tweet +### One Idea Per Tweeta Single tweets should contain one complete thought. Threads expand on ideas but each tweet should still stand alone. ### Hook or Die diff --git a/actions/content-plan.test.ts b/actions/content-plan.test.ts index 4358394..b26196e 100644 --- a/actions/content-plan.test.ts +++ b/actions/content-plan.test.ts @@ -154,9 +154,12 @@ describe('createPlan', () => { mockFrom.mockImplementation((table: string) => { if (table === 'subscriptions') { return createChainMock({ - data: { id: 'sub-1', plans: { slug: 'basic-monthly' } }, + data: { id: 'sub-1', plans: { slug: 'basic-monthly', max_posts_per_generation: 1 } }, }); } + if (table === 'credits') { + return createChainMock({ data: { balance: 50 } }); + } return createChainMock({ data: null }); }); @@ -183,7 +186,7 @@ describe('createPlan', () => { const result = await createPlan(validCreatePlanInput); expect(result.success).toBe(false); - expect(result.error).toBe('Insufficient credits'); + expect(result.error).toContain('Insufficient credits'); }); it('should create plan and posts successfully', async () => { @@ -306,6 +309,9 @@ describe('regeneratePost', () => { it('should regenerate post successfully', async () => { mockGetUser.mockResolvedValue({ data: { user: mockUser } }); mockFrom.mockImplementation((table: string) => { + if (table === 'subscriptions') { + return createChainMock({ data: { id: 'sub-1', plans: { slug: 'pro-monthly', max_posts_per_generation: 5 } } }); + } if (table === 'posts') { const chain = createChainMock({ data: { @@ -347,6 +353,9 @@ describe('regeneratePost', () => { it('should include memory context in regenerate prompt', async () => { mockGetUser.mockResolvedValue({ data: { user: mockUser } }); mockFrom.mockImplementation((table: string) => { + if (table === 'subscriptions') { + return createChainMock({ data: { id: 'sub-1', plans: { slug: 'pro-monthly', max_posts_per_generation: 5 } } }); + } if (table === 'posts') { const chain = createChainMock({ data: { @@ -520,6 +529,12 @@ describe('schedulePlan', () => { if (table === 'content_plans') { return createChainMock({ data: { id: 'plan-123' } }); } + if (table === 'subscriptions') { + return createChainMock({ data: { id: 'sub-1', plans: { slug: 'pro-monthly', max_posts_per_generation: 5 } } }); + } + if (table === 'credits') { + return createChainMock({ data: { balance: 50 } }); + } if (table === 'posts') { const makeChainable = (): Record> => { const c: Record> = {}; @@ -563,6 +578,12 @@ describe('schedulePlan', () => { if (table === 'content_plans') { return createChainMock({ data: { id: 'plan-123' } }); } + if (table === 'subscriptions') { + return createChainMock({ data: { id: 'sub-1', plans: { slug: 'pro-monthly', max_posts_per_generation: 5 } } }); + } + if (table === 'credits') { + return createChainMock({ data: { balance: 50 } }); + } if (table === 'posts') { const makeChainable = (): Record> => { const c: Record> = {}; @@ -587,6 +608,7 @@ describe('schedulePlan', () => { return createChainMock({ data: null }); }); + await schedulePlan({ planId: '550e8400-e29b-41d4-a716-446655440000', platformConnectionIds: [twConnId], @@ -615,6 +637,12 @@ describe('schedulePlan', () => { if (table === 'content_plans') { return createChainMock({ data: { id: 'plan-123' } }); } + if (table === 'subscriptions') { + return createChainMock({ data: { id: 'sub-1', plans: { slug: 'pro-monthly', max_posts_per_generation: 5 } } }); + } + if (table === 'credits') { + return createChainMock({ data: { balance: 50 } }); + } if (table === 'posts') { const makeChainable = (): Record> => { const c: Record> = {}; @@ -659,6 +687,12 @@ describe('schedulePlan', () => { if (table === 'content_plans') { return createChainMock({ data: { id: 'plan-123' } }); } + if (table === 'subscriptions') { + return createChainMock({ data: { id: 'sub-1', plans: { slug: 'pro-monthly', max_posts_per_generation: 5 } } }); + } + if (table === 'credits') { + return createChainMock({ data: { balance: 50 } }); + } if (table === 'posts') { const makeChainable = (): Record> => { const c: Record> = {}; diff --git a/actions/content-plan.ts b/actions/content-plan.ts index 21018be..1b5abcd 100644 --- a/actions/content-plan.ts +++ b/actions/content-plan.ts @@ -2,9 +2,11 @@ import { revalidatePath } from 'next/cache'; +import { checkAccess } from '@/lib/access'; import { callAI } from '@/lib/ai'; import { buildContentPlanSystemPrompt, buildRegeneratePostSystemPrompt } from '@/lib/prompts'; -import { CONTENT_PLAN_GENERATION, CONTENT_PLAN_LIMITS, CREDIT_FORMULA, PLAN_SLUGS } from '@/lib/constants'; +import { CONTENT_PLAN_GENERATION, CONTENT_PLAN_LIMITS, CREDIT_FORMULA, PLAN_SLUGS, TRIAL_LIMITS } from '@/lib/constants'; +import { mapMemoryItem } from '@/lib/mappers'; import { createServerClient } from '@/lib/supabase/server'; import { createPlanSchema, @@ -171,42 +173,6 @@ function distributeTimesInRange( return times.sort(); } -type PlanTier = 'pro' | 'ultra'; - -interface IPlanAccessResult { - allowed: boolean; - error?: string; - tier?: PlanTier; - maxDays?: number; - maxPostsPerDay?: number; -} - -async function checkPlanAccess(supabase: ReturnType extends Promise ? T : never, userId: string): Promise { - const { data: subscription } = await supabase - .from('subscriptions') - .select('*, plans(*)') - .eq('user_id', userId) - .in('status', ['trialing', 'active']) - .single(); - - if (!subscription) { - return { allowed: false, error: 'No active subscription' }; - } - - const planSlug = subscription.plans?.slug as string | undefined; - const isBasic = planSlug === PLAN_SLUGS.BASIC_MONTHLY || planSlug === PLAN_SLUGS.BASIC_YEARLY; - - if (isBasic) { - return { allowed: false, error: 'Content Plans require Pro or Ultra plan. Please upgrade.' }; - } - - const isUltra = planSlug === PLAN_SLUGS.ULTRA_MONTHLY || planSlug === PLAN_SLUGS.ULTRA_YEARLY; - const tier: PlanTier = isUltra ? 'ultra' : 'pro'; - const limits = isUltra ? CONTENT_PLAN_LIMITS.ULTRA : CONTENT_PLAN_LIMITS.PRO; - - return { allowed: true, tier, maxDays: limits.maxDays, maxPostsPerDay: limits.maxPostsPerDay }; -} - // ─── Actions ───────────────────────────────────────────────────────────────── export async function createPlan(data: CreatePlanFormData): Promise { @@ -222,43 +188,51 @@ export async function createPlan(data: CreatePlanFormData): Promise (access.maxDays ?? 7)) { + if (numberOfDays > maxDays) { return { success: false, - error: `Your plan allows a maximum of ${access.maxDays} days. Please select a shorter date range.`, + error: `Your plan allows a maximum of ${maxDays} days. Please select a shorter date range.`, }; } - if (validated.postsPerDay > (access.maxPostsPerDay ?? 3)) { + if (validated.postsPerDay > maxPostsPerDay) { return { success: false, - error: `Your plan allows a maximum of ${access.maxPostsPerDay} posts per day.`, + error: `Your plan allows a maximum of ${maxPostsPerDay} posts per day.`, }; } - // Check credits - const { data: credits } = await supabase - .from('credits') - .select('balance') - .eq('user_id', user.id) - .single(); - - if (!credits || credits.balance <= 0) { - return { success: false, error: 'Insufficient credits' }; - } - // Fetch memory items const { data: globalMemory } = await supabase .from('memory_items') @@ -276,7 +250,7 @@ export async function createPlan(data: CreatePlanFormData): Promise) : null; }; const [sphere, platform, style] = await Promise.all([ @@ -335,20 +309,29 @@ export async function createPlan(data: CreatePlanFormData): Promise) : null; }; const [sphere, platform, style] = await Promise.all([ @@ -546,19 +524,29 @@ export async function regeneratePost(data: RegeneratePostFormData): Promise) : null; }; const [sphere, platform, style] = await Promise.all([ @@ -717,19 +700,29 @@ export async function regeneratePlan(data: RegeneratePlanFormData): Promise { const result = await generateVariations(validInput); expect(result.success).toBe(false); - expect(result.error).toContain('No active subscription'); + expect(result.error).toMatch(/free trial|subscribe/i); }); it('should return error when credits are insufficient', async () => { diff --git a/actions/generation.ts b/actions/generation.ts index 9b144d2..2b86a71 100644 --- a/actions/generation.ts +++ b/actions/generation.ts @@ -1,7 +1,9 @@ 'use server'; +import { checkAccess } from '@/lib/access'; import { callAI } from '@/lib/ai'; -import { CREDIT_FORMULA } from '@/lib/constants'; +import { CREDIT_FORMULA, TRIAL_LIMITS } from '@/lib/constants'; +import { mapMemoryItem } from '@/lib/mappers'; import { assembleSystemPrompt, parseVariations } from '@/lib/prompts'; import { createServerClient } from '@/lib/supabase/server'; import { generationSchema } from '@/lib/validations/generation'; @@ -33,33 +35,18 @@ export async function generateVariations(data: GenerationFormData): Promise) : null; }; const [sphere, platform, style] = await Promise.all([ @@ -124,19 +111,29 @@ export async function generateVariations(data: GenerationFormData): Promise ({ +const { mockGetUser, mockFrom, mockAdminFrom, mockRevalidatePath, mockPublishTweet, mockRefreshTwitterToken, mockPublishLinkedInPost, mockRefreshLinkedInToken, mockCheckAccess } = vi.hoisted(() => ({ mockGetUser: vi.fn(), mockFrom: vi.fn(), mockAdminFrom: vi.fn(), @@ -9,6 +9,7 @@ const { mockGetUser, mockFrom, mockAdminFrom, mockRevalidatePath, mockPublishTwe mockRefreshTwitterToken: vi.fn(), mockPublishLinkedInPost: vi.fn(), mockRefreshLinkedInToken: vi.fn(), + mockCheckAccess: vi.fn(), })); vi.mock('@/lib/supabase/server', () => ({ @@ -37,11 +38,21 @@ vi.mock('@/lib/linkedin', () => ({ refreshLinkedInToken: mockRefreshLinkedInToken, })); +vi.mock('@/lib/access', () => ({ + checkAccess: mockCheckAccess, +})); + import { publishPost, schedulePost } from './publishing'; beforeEach(() => { vi.clearAllMocks(); mockAdminFrom.mockReturnValue(createMockChain()); + mockCheckAccess.mockResolvedValue({ + allowed: true, + mode: 'paid', + subscription: { planSlug: 'pro-monthly', maxPostsPerGeneration: 5 }, + creditBalance: 100, + }); }); // --- Helpers --- diff --git a/actions/publishing.ts b/actions/publishing.ts index e383f1d..362c199 100644 --- a/actions/publishing.ts +++ b/actions/publishing.ts @@ -2,6 +2,7 @@ import { revalidatePath } from 'next/cache'; +import { checkAccess } from '@/lib/access'; import { ensureFreshToken, publishToPlatform, @@ -69,6 +70,12 @@ export async function publishPost(data: IPublishInput): Promise return { success: false, error: 'Unauthorized', results: [] }; } + // Verify subscription or trial access + const access = await checkAccess(supabase, user.id); + if (!access.allowed) { + return { success: false, error: access.error, results: [] }; + } + // 1. Create or update post record let postId = data.postId; @@ -241,6 +248,15 @@ export async function publishPost(data: IPublishInput): Promise .eq('user_id', user.id); } + // 6. Deduct trial use if on trial + if (hasAnySuccess && access.mode === 'trial') { + await supabase.rpc('deduct_trial_use', { + p_user_id: user.id, + p_action: 'publish', + p_description: `Published to ${processedResults.filter((r) => r.success).map((r) => r.platform).join(', ')}`, + }); + } + revalidatePath('/app'); return { @@ -260,6 +276,12 @@ export async function schedulePost(data: IScheduleInput): Promise ({ post_id: postId as string, platform_connection_id: connectionId, - community_id: c.community_id, + community_id: c.community_id as string | null, share_with_followers: data.shareWithFollowers ?? false, status: 'pending' as const, })); @@ -385,6 +407,15 @@ export async function schedulePost(data: IScheduleInput): Promise { + const supabase = await createServerClient(); + const { data: { user } } = await supabase.auth.getUser(); + + if (!user) { + return { success: false, error: 'Unauthorized' }; + } + + // Check no active subscription + const { data: subscription } = await supabase + .from('subscriptions') + .select('id') + .eq('user_id', user.id) + .in('status', ['trialing', 'active']) + .single(); + + if (subscription) { + return { success: false, error: 'You already have an active subscription.' }; + } + + // Check trial not already activated + const { data: profile } = await supabase + .from('profiles') + .select('trial_uses_remaining') + .eq('id', user.id) + .single(); + + if (profile?.trial_uses_remaining !== null) { + return { success: false, error: 'Free trial has already been activated.' }; + } + + // Activate trial + const { error } = await supabase + .from('profiles') + .update({ trial_uses_remaining: FREE_TRIAL_USES }) + .eq('id', user.id); + + if (error) { + return { success: false, error: 'Failed to activate trial. Please try again.' }; + } + + revalidatePath('/app', 'layout'); + + return { success: true }; +} diff --git a/app/(auth)/login/page.tsx b/app/(auth)/login/page.tsx index f256f40..719d9e1 100644 --- a/app/(auth)/login/page.tsx +++ b/app/(auth)/login/page.tsx @@ -68,7 +68,7 @@ function LoginForm() { router.push('/app'); } - async function handleOAuth(provider: 'google' | 'github') { + async function handleOAuth(provider: 'google' | 'x' | 'linkedin_oidc') { await supabase.auth.signInWithOAuth({ provider, options: { redirectTo: `${window.location.origin}/callback` }, @@ -171,7 +171,7 @@ function LoginForm() { -
+
+ {/* handleOAuth('linkedin_oidc')}*/} + {/* className="w-full"*/} + {/*>*/} + {/* */} + {/* LinkedIn*/} + {/**/}

@@ -250,10 +258,18 @@ function GoogleIcon() { ); } -function GithubIcon() { +function TwitterIcon() { return ( + ); +} + +function LinkedInIcon() { + return ( + ); } diff --git a/app/(auth)/register/page.tsx b/app/(auth)/register/page.tsx index 28afa06..f3b47a6 100644 --- a/app/(auth)/register/page.tsx +++ b/app/(auth)/register/page.tsx @@ -80,7 +80,7 @@ export default function RegisterPage() { setIsLoading(false); } - async function handleOAuth(provider: 'google' | 'github') { + async function handleOAuth(provider: 'google' | 'x' | 'linkedin_oidc') { await supabase.auth.signInWithOAuth({ provider, options: { redirectTo: `${window.location.origin}/callback` }, @@ -182,7 +182,7 @@ export default function RegisterPage() {

-
+
+ {/* handleOAuth('linkedin_oidc')}*/} + {/* className="w-full"*/} + {/*>*/} + {/* */} + {/* LinkedIn*/} + {/**/}

@@ -210,6 +218,18 @@ export default function RegisterPage() { Sign in

+ +

+ By creating an account you agree to our{' '} + + Terms of Service + {' '} + and{' '} + + Privacy Policy + + . +

); } @@ -237,10 +257,18 @@ function GoogleIcon() { ); } -function GithubIcon() { +function TwitterIcon() { + return ( + + ); +} + +function LinkedInIcon() { return ( ); } diff --git a/app/app/layout.tsx b/app/app/layout.tsx index ea0fa0f..c31d933 100644 --- a/app/app/layout.tsx +++ b/app/app/layout.tsx @@ -38,7 +38,7 @@ export default async function AppLayout({ children }: IAppLayoutProps): Promise< return (
-
+
{children} diff --git a/app/app/page.tsx b/app/app/page.tsx index 770eb45..7555da8 100644 --- a/app/app/page.tsx +++ b/app/app/page.tsx @@ -1,10 +1,10 @@ import { redirect } from 'next/navigation'; import Composer from '@/components/composer/composer'; -import { mapPlatformConnection, mapTwitterCommunity } from '@/lib/mappers'; +import { mapMemoryItem, mapPlatformConnection, mapTwitterCommunity } from '@/lib/mappers'; import { createServerClient } from '@/lib/supabase/server'; -import type { IMemoryItem, IPlatformConnection, ITwitterCommunity } from '@/types/database'; +import type { IPlatformConnection, ITwitterCommunity } from '@/types/database'; interface IDbPreference { memory_item_id: string; @@ -63,7 +63,7 @@ export default async function ComposerPage(): Promise { .order('created_at', { ascending: true }), ]); - const allItems = (itemsResult.data ?? []) as IMemoryItem[]; + const allItems = ((itemsResult.data ?? []) as Record[]).map(mapMemoryItem); const preferences = (preferencesResult.data ?? []) as IDbPreference[]; const firstName = (profileResult.data?.full_name as string | null)?.split(' ')[0] ?? null; const connections: IPlatformConnection[] = ( @@ -83,9 +83,8 @@ export default async function ComposerPage(): Promise { ); // Spheres & styles: only user-owned (added from template or custom-created) - // Raw Supabase rows have snake_case fields despite the IMemoryItem cast - const spheres = activeItems.filter((i) => i.type === 'sphere' && (i as unknown as Record).user_id !== null); - const styles = activeItems.filter((i) => i.type === 'style' && (i as unknown as Record).user_id !== null); + const spheres = activeItems.filter((i) => i.type === 'sphere' && i.userId !== null); + const styles = activeItems.filter((i) => i.type === 'style' && i.userId !== null); // Platforms: system + user-owned (system platforms always available) const platforms = activeItems.filter((i) => i.type === 'platform'); @@ -93,6 +92,13 @@ export default async function ComposerPage(): Promise { 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') + .eq('id', user.id) + .single(); + const trialUsesRemaining = (profileData?.trial_uses_remaining as number | null) ?? null; + return ( { communities={communities} hasSubscription={hasSubscription} creditBalance={creditBalance} + trialUsesRemaining={trialUsesRemaining} /> ); } diff --git a/app/app/plan/[planId]/page.tsx b/app/app/plan/[planId]/page.tsx index bb3b270..1490b95 100644 --- a/app/app/plan/[planId]/page.tsx +++ b/app/app/plan/[planId]/page.tsx @@ -100,24 +100,16 @@ export default async function PlanDetailPage({ params }: IPlanDetailPageProps): } } - const { data: platformData } = plan.platformId - ? await supabase - .from('memory_items') - .select('metadata') - .eq('id', plan.platformId) - .single() - : { data: null }; - - const platformMetadata = (platformData as Record | null)?.metadata as Record | undefined; - const characterLimit = platformMetadata?.character_limit as number | undefined; - - const [connectionsResult, communitiesResult] = await Promise.all([ + const [connectionsResult, communitiesResult, platformResult] = await Promise.all([ supabase.from('platform_connections').select('*').eq('user_id', user.id), supabase .from('twitter_communities') .select('*') .eq('user_id', user.id) .order('created_at', { ascending: true }), + plan.platformId + ? supabase.from('memory_items').select('metadata').eq('id', plan.platformId).single() + : Promise.resolve({ data: null }), ]); const connections = (connectionsResult.data ?? []).map((row) => @@ -128,6 +120,8 @@ export default async function PlanDetailPage({ params }: IPlanDetailPageProps): mapTwitterCommunity(row as Record), ); + const characterLimit = platformResult.data?.metadata?.character_limit as number | undefined; + // Build day range from plan dates const startDate = new Date(`${plan.weekStartDate}T00:00:00`); const endDate = new Date(`${plan.endDate}T00:00:00`); @@ -185,9 +179,9 @@ export default async function PlanDetailPage({ params }: IPlanDetailPageProps): posts={dayPosts} mediaByPostId={mediaByPostId} planId={plan.id} - characterLimit={characterLimit} connections={connections} communities={communities} + characterLimit={characterLimit} isLast={dayIndex === numberOfDays - 1} /> ); diff --git a/app/app/plan/new/page.tsx b/app/app/plan/new/page.tsx index 3b9c0b3..dbf8d83 100644 --- a/app/app/plan/new/page.tsx +++ b/app/app/plan/new/page.tsx @@ -1,33 +1,52 @@ import { redirect } from 'next/navigation'; import { PlanForm } from '@/components/content-plan/plan-form'; -import { CONTENT_PLAN_LIMITS, PLAN_SLUGS } from '@/lib/constants'; +import { CONTENT_PLAN_LIMITS, PLAN_SLUGS, TRIAL_LIMITS } from '@/lib/constants'; +import { mapMemoryItem } from '@/lib/mappers'; import { createServerClient } from '@/lib/supabase/server'; -import type { IMemoryItem } from '@/types/database'; - export default async function NewPlanPage(): Promise { const supabase = await createServerClient(); const { data: { user } } = await supabase.auth.getUser(); if (!user) redirect('/login'); - 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 trialUsesRemaining = (profileResult.data?.trial_uses_remaining as number | null) ?? null; const planSlug = subscription?.plans?.slug as string | undefined; const isBasicPlan = planSlug === PLAN_SLUGS.BASIC_MONTHLY || planSlug === PLAN_SLUGS.BASIC_YEARLY; + const hasTrialAccess = trialUsesRemaining !== null && trialUsesRemaining > 0; - if (isBasicPlan || !subscription) { + // Block access if: Basic plan, or no subscription and no active trial + if (isBasicPlan || (!subscription && !hasTrialAccess)) { redirect('/app/plan'); } - const isUltra = planSlug === PLAN_SLUGS.ULTRA_MONTHLY || planSlug === PLAN_SLUGS.ULTRA_YEARLY; - const tierLimits = isUltra ? CONTENT_PLAN_LIMITS.ULTRA : CONTENT_PLAN_LIMITS.PRO; + // Resolve limits: paid users get tier-based limits, trial users get Pro-tier limits + let tierLimits: { maxDays: number; maxPostsPerDay: number }; + if (subscription) { + const isUltra = planSlug === PLAN_SLUGS.ULTRA_MONTHLY || planSlug === PLAN_SLUGS.ULTRA_YEARLY; + tierLimits = isUltra ? CONTENT_PLAN_LIMITS.ULTRA : CONTENT_PLAN_LIMITS.PRO; + } else { + tierLimits = { + maxDays: TRIAL_LIMITS.CONTENT_PLAN_MAX_DAYS, + maxPostsPerDay: TRIAL_LIMITS.CONTENT_PLAN_MAX_POSTS_PER_DAY, + }; + } const [itemsResult, creditsResult] = await Promise.all([ supabase @@ -43,13 +62,15 @@ export default async function NewPlanPage(): Promise { .single(), ]); - const allItems = (itemsResult.data ?? []) as IMemoryItem[]; - const creditBalance = (creditsResult.data?.balance as number | null) ?? 0; + const allItems = ((itemsResult.data ?? []) as Record[]).map(mapMemoryItem); + // Trial users have no credits row; treat trial uses as effective balance for UI + const creditBalance = subscription + ? (creditsResult.data?.balance as number | null) ?? 0 + : (trialUsesRemaining ?? 0); // Spheres & styles: only user-owned (added from template or custom-created) - // Raw Supabase rows have snake_case fields despite the IMemoryItem cast - const spheres = allItems.filter((i) => i.type === 'sphere' && (i as unknown as Record).user_id !== null); - const styles = allItems.filter((i) => i.type === 'style' && (i as unknown as Record).user_id !== null); + const spheres = allItems.filter((i) => i.type === 'sphere' && i.userId !== null); + const styles = allItems.filter((i) => i.type === 'style' && i.userId !== null); // Platforms: system + user-owned (system platforms always available) const platforms = allItems.filter((i) => i.type === 'platform'); diff --git a/app/app/settings/billing/page.tsx b/app/app/settings/billing/page.tsx index 0f5b710..bedf452 100644 --- a/app/app/settings/billing/page.tsx +++ b/app/app/settings/billing/page.tsx @@ -8,7 +8,7 @@ export default async function BillingPage(): Promise { const supabase = await createServerClient(); const { data: { user } } = await supabase.auth.getUser(); - const [subscriptionResult, creditsResult, plansResult] = await Promise.all([ + const [subscriptionResult, creditsResult, plansResult, profileResult] = await Promise.all([ supabase .from('subscriptions') .select('*, plans(*)') @@ -25,6 +25,11 @@ export default async function BillingPage(): Promise { .select('*') .eq('is_active', true) .order('price_cents', { ascending: true }), + supabase + .from('profiles') + .select('trial_uses_remaining') + .eq('id', user?.id ?? '') + .single(), ]); const subscription = subscriptionResult.data @@ -36,13 +41,19 @@ export default async function BillingPage(): Promise { const plans = (plansResult.data ?? []).map( (row) => mapPlan(row as Record), ); + const trialUsesRemaining = (profileResult.data?.trial_uses_remaining as number | null) ?? null; const currentPlan = subscription?.plan ?? null; return (
- + ): IMemoryItem { - return { - id: row.id as string, - userId: row.user_id as string | null, - type: row.type as MemoryType, - name: row.name as string | null, - description: row.description as string | null, - content: row.content as string, - icon: row.icon as string | null, - color: row.color as string | null, - isSystem: row.is_system as boolean, - displayOrder: row.display_order as number, - metadata: (row.metadata ?? {}) as Record, - sourceTemplateId: row.source_template_id as string | null, - createdAt: row.created_at as string, - updatedAt: row.updated_at as string, - deletedAt: row.deleted_at as string | null, - }; -} +import type { IMemoryItem } from '@/types/database'; // ─── Page ──────────────────────────────────────────────────────────────────── diff --git a/components/billing/current-plan-card.test.tsx b/components/billing/current-plan-card.test.tsx index 787c8e1..3b77e00 100644 --- a/components/billing/current-plan-card.test.tsx +++ b/components/billing/current-plan-card.test.tsx @@ -140,9 +140,9 @@ describe('CurrentPlanCard', () => { }); it('should render empty state when no subscription', () => { - render(); + render(); - expect(screen.getByText(/no active plan/i)).toBeInTheDocument(); + expect(screen.getByText(/free uses/i)).toBeInTheDocument(); }); it('should show sync button when subscription is active but credits are missing', () => { diff --git a/components/billing/current-plan-card.tsx b/components/billing/current-plan-card.tsx index b9bd4c2..05e82b0 100644 --- a/components/billing/current-plan-card.tsx +++ b/components/billing/current-plan-card.tsx @@ -1,6 +1,12 @@ +'use client'; + import Link from 'next/link'; +import { useTransition } from 'react'; +import { toast } from 'sonner'; +import { activateFreeTrial } from '@/actions/trial'; import { redirectToPortal } from '@/actions/billing'; +import { FREE_TRIAL_USES } from '@/lib/constants'; import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; @@ -12,6 +18,7 @@ interface ICurrentPlanCardProps { subscription: ISubscription | null; plan: IPlan | null; credits: ICredit | null; + trialUsesRemaining: number | null; } function getStatusBadge(subscription: ISubscription): { label: string; variant: 'default' | 'secondary' | 'destructive' | 'outline' } { @@ -44,15 +51,75 @@ function formatDate(dateString: string): string { }); } -export default function CurrentPlanCard({ subscription, plan, credits }: ICurrentPlanCardProps): React.ReactElement { +function ActivateTrialButton(): React.ReactElement { + const [isPending, startTransition] = useTransition(); + + function handleActivate(): void { + startTransition(async () => { + const result = await activateFreeTrial(); + if (result.success) { + toast.success('Free trial activated! You have 5 uses to explore Socio.'); + } else { + toast.error(result.error ?? 'Failed to activate trial'); + } + }); + } + + return ( + + ); +} + +export default function CurrentPlanCard({ subscription, plan, credits, trialUsesRemaining }: ICurrentPlanCardProps): React.ReactElement { if (!subscription || !plan) { + const trialActive = trialUsesRemaining !== null && trialUsesRemaining > 0; + const trialExhausted = trialUsesRemaining === 0; + return ( Current Plan - -

No active plan. Choose a plan below to get started.

+ + {trialActive && ( + <> +
+ Free Trial +
+

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

+ + Upgrade Now + + + )} + {trialExhausted && ( + <> +

+ Your free trial has ended. Choose a plan below to continue. +

+ + View Plans + + + )} + {trialUsesRemaining === null && ( + <> +

+ Start with {FREE_TRIAL_USES} free uses to explore Socio — no credit card required. +

+ + + )}
); @@ -87,7 +154,7 @@ export default function CurrentPlanCard({ subscription, plan, credits }: ICurren

{daysRemaining} days remaining

Upgrade Now diff --git a/components/composer/composer.test.tsx b/components/composer/composer.test.tsx index 72d0fe9..94e5a37 100644 --- a/components/composer/composer.test.tsx +++ b/components/composer/composer.test.tsx @@ -43,6 +43,7 @@ const defaultProps = { connections: [], hasSubscription: true, creditBalance: 100, + trialUsesRemaining: null as number | null, }; describe('Composer', () => { @@ -223,7 +224,7 @@ describe('Composer', () => { }); it('should disable Generate and Post buttons when user has no subscription', async () => { - render(); + render(); const textarea = screen.getByPlaceholderText( 'Write your post or describe what you want to generate...', @@ -234,11 +235,11 @@ describe('Composer', () => { expect(screen.getByRole('button', { name: /^post$/i })).toBeDisabled(); }); - it('should show subscribe banner when user has no subscription', () => { - render(); + it('should show trial ended banner when user has no subscription and trial exhausted', () => { + render(); - expect(screen.getByText(/subscribe to a plan/i)).toBeInTheDocument(); - expect(screen.getByRole('link', { name: /view plans/i })).toHaveAttribute('href', '/app/settings/billing'); + expect(screen.getByText(/free trial has ended/i)).toBeInTheDocument(); + expect(screen.getByRole('link', { name: /view plans/i })).toHaveAttribute('href', '/app/settings/billing#plans'); }); it('should disable Generate and Post buttons when credits are zero', async () => { @@ -257,7 +258,7 @@ describe('Composer', () => { render(); expect(screen.getByText(/no credits left/i)).toBeInTheDocument(); - expect(screen.getByRole('link', { name: /upgrade/i })).toHaveAttribute('href', '/app/settings/billing'); + expect(screen.getByRole('link', { name: /view plans/i })).toHaveAttribute('href', '/app/settings/billing#plans'); }); it('should render post form inside a narrower container', () => { diff --git a/components/composer/composer.tsx b/components/composer/composer.tsx index 4fda345..221d511 100644 --- a/components/composer/composer.tsx +++ b/components/composer/composer.tsx @@ -51,6 +51,7 @@ interface IComposerProps { communities?: ITwitterCommunity[]; hasSubscription: boolean; creditBalance: number; + trialUsesRemaining: number | null; } export default function Composer({ @@ -63,6 +64,7 @@ export default function Composer({ communities = [], hasSubscription, creditBalance, + trialUsesRemaining, }: IComposerProps): React.ReactElement { const [input, setInput] = useState(''); const [selectedSphereId, setSelectedSphereId] = useState(); @@ -79,7 +81,9 @@ export default function Composer({ const selectedPlatform = platforms.find((p) => p.id === selectedPlatformId); const characterLimit = selectedPlatform?.metadata?.character_limit as number | undefined; - const isLocked = !hasSubscription || creditBalance <= 0; + const trialActive = !hasSubscription && trialUsesRemaining !== null && trialUsesRemaining > 0; + const trialNotActivated = !hasSubscription && trialUsesRemaining === null; + const isLocked = (!hasSubscription && !trialActive) || (hasSubscription && creditBalance <= 0); const canGenerate = input.trim().length > 0 && !isGenerating && !isLocked; async function handleGenerate(): Promise { @@ -240,18 +244,29 @@ export default function Composer({ - {isLocked && ( + {trialNotActivated && ( +
+ Try Socio free — 5 AI generations and posts included. + + Activate trial + +
+ )} + {isLocked && !trialNotActivated && (
- {!hasSubscription - ? 'Subscribe to a plan to start generating and posting.' + {!hasSubscription && trialUsesRemaining === 0 + ? 'Your free trial has ended.' : 'You have no credits left this cycle.'} - {!hasSubscription ? 'View plans' : 'Upgrade'} + View plans
)} diff --git a/components/composer/post-input.test.tsx b/components/composer/post-input.test.tsx index d465022..11b7fa3 100644 --- a/components/composer/post-input.test.tsx +++ b/components/composer/post-input.test.tsx @@ -44,39 +44,32 @@ describe('PostInput', () => { expect(textarea).toBeDisabled(); }); - it('should not show character count when no characterLimit provided', () => { + it('should not show progress ring when no characterLimit provided', () => { render(); - expect(screen.queryByTestId('char-count')).not.toBeInTheDocument(); + expect(screen.queryByTestId('char-progress')).not.toBeInTheDocument(); }); - it('should show character count when characterLimit is provided', () => { + it('should show progress ring when characterLimit is provided', () => { render(); - const charCount = screen.getByTestId('char-count'); - expect(charCount).toHaveTextContent('5 / 280'); + expect(screen.getByTestId('char-progress')).toBeInTheDocument(); + expect(screen.getByTestId('char-progress').querySelector('svg')).toBeInTheDocument(); }); - it('should show green color when well under character limit', () => { - render(); - - const charCount = screen.getByTestId('char-count'); - expect(charCount.className).toContain('text-green'); - }); - - it('should show yellow color when near character limit', () => { + it('should show yellow ring when near character limit', () => { const nearLimitText = 'a'.repeat(260); render(); - const charCount = screen.getByTestId('char-count'); - expect(charCount.className).toContain('text-yellow'); + const ring = screen.getByTestId('char-progress'); + expect(ring.className).toContain('text-yellow'); }); - it('should show red color when over character limit', () => { + it('should show red ring when over character limit', () => { const overLimitText = 'a'.repeat(300); render(); - const charCount = screen.getByTestId('char-count'); - expect(charCount.className).toContain('text-red'); + const ring = screen.getByTestId('char-progress'); + expect(ring.className).toContain('text-red'); }); }); diff --git a/components/composer/post-input.tsx b/components/composer/post-input.tsx index cf44061..d451845 100644 --- a/components/composer/post-input.tsx +++ b/components/composer/post-input.tsx @@ -3,8 +3,8 @@ import { useRef, useEffect } from 'react'; import { cn } from '@/lib/utils'; +import { CharProgressRing } from '@/components/ui/char-progress-ring'; -const CHAR_WARNING_THRESHOLD = 0.9; const MIN_HEIGHT = 120; const MAX_HEIGHT = 200; @@ -15,13 +15,6 @@ interface IPostInputProps { characterLimit?: number; } -function getCharCountColor(length: number, limit: number): string { - if (length > limit) return 'text-red-500'; - if (length >= limit * CHAR_WARNING_THRESHOLD) return 'text-yellow-500'; - - return 'text-green-600'; -} - export default function PostInput({ value, onChange, @@ -63,15 +56,12 @@ export default function PostInput({ )} /> {hasCharLimit && ( -
- {value.length} / {characterLimit} -
+ )}
); diff --git a/components/composer/variation-card.test.tsx b/components/composer/variation-card.test.tsx index 89fb2e9..903608c 100644 --- a/components/composer/variation-card.test.tsx +++ b/components/composer/variation-card.test.tsx @@ -52,26 +52,6 @@ describe('VariationCard', () => { ); }); - it('should show character count without color when no limit', () => { - render(); - - expect(screen.getByTestId('variation-char-count')).toHaveTextContent('44'); - }); - - it('should show character count with limit when characterLimit provided', () => { - render(); - - expect(screen.getByTestId('variation-char-count')).toHaveTextContent('44 / 280'); - }); - - it('should show red character count when over limit', () => { - const longContent = 'a'.repeat(300); - render(); - - const charCount = screen.getByTestId('variation-char-count'); - expect(charCount.className).toContain('text-red'); - }); - it('should preserve line breaks in content', () => { render(); @@ -86,4 +66,19 @@ describe('VariationCard', () => { expect(handlePublish).toHaveBeenCalledTimes(1); }); + + it('should not show char progress ring when no characterLimit', () => { + const { container } = render(); + + // No progress ring circle (the ring SVG has two circles; icon SVGs do not) + const circles = container.querySelectorAll('circle'); + expect(circles.length).toBe(0); + }); + + it('should show char progress ring when characterLimit is provided', () => { + const { container } = render(); + + const circles = container.querySelectorAll('circle'); + expect(circles.length).toBeGreaterThan(0); + }); }); diff --git a/components/composer/variation-card.tsx b/components/composer/variation-card.tsx index 4b78caa..d8f4df1 100644 --- a/components/composer/variation-card.tsx +++ b/components/composer/variation-card.tsx @@ -4,10 +4,9 @@ import { useState } from 'react'; import { Check, Copy, MousePointer2, Send } from 'lucide-react'; import { Button } from '@/components/ui/button'; -import { cn } from '@/lib/utils'; +import { CharProgressRing } from '@/components/ui/char-progress-ring'; const COPY_FEEDBACK_DURATION = 2000; -const CHAR_WARNING_THRESHOLD = 0.9; interface IVariationCardProps { content: string; @@ -18,13 +17,6 @@ interface IVariationCardProps { onPublish: () => void; } -function getCharColor(length: number, limit: number): string { - if (length > limit) return 'text-red-500'; - if (length >= limit * CHAR_WARNING_THRESHOLD) return 'text-yellow-500'; - - return 'text-green-600'; -} - export default function VariationCard({ content, index, @@ -41,23 +33,15 @@ export default function VariationCard({ setTimeout(() => setIsCopied(false), COPY_FEEDBACK_DURATION); } - const hasLimit = characterLimit !== undefined; - return (
{index + 1} of {total} -
- {hasLimit ? `${content.length} / ${characterLimit}` : content.length} -
+ {characterLimit !== undefined && ( + + )}
diff --git a/components/composer/variation-list.test.tsx b/components/composer/variation-list.test.tsx index d0c0453..35a1445 100644 --- a/components/composer/variation-list.test.tsx +++ b/components/composer/variation-list.test.tsx @@ -48,14 +48,14 @@ describe('VariationList', () => { }); it('should pass characterLimit to variation cards', () => { - render( + const { container } = render( , ); - expect(screen.getByTestId('variation-char-count')).toHaveTextContent('/ 280'); + expect(container.querySelectorAll('circle').length).toBeGreaterThan(0); }); }); diff --git a/components/content-plan/plan-day-section.tsx b/components/content-plan/plan-day-section.tsx index e83a27a..8f7a057 100644 --- a/components/content-plan/plan-day-section.tsx +++ b/components/content-plan/plan-day-section.tsx @@ -11,9 +11,9 @@ interface IPlanDaySectionProps { posts: IPost[]; mediaByPostId: Record; planId: string; - characterLimit?: number; connections: IPlatformConnection[]; communities?: ITwitterCommunity[]; + characterLimit?: number; isLast?: boolean; } @@ -27,9 +27,9 @@ export function PlanDaySection({ posts, mediaByPostId, planId, - characterLimit, connections, communities = [], + characterLimit, isLast = false, }: IPlanDaySectionProps): React.ReactElement { const formattedDate = formatDate(date); @@ -59,9 +59,9 @@ export function PlanDaySection({ post={post} media={mediaByPostId[post.id] ?? []} planId={planId} - characterLimit={characterLimit} connections={connections} communities={communities} + characterLimit={characterLimit} /> ))}
diff --git a/components/content-plan/plan-post-card.tsx b/components/content-plan/plan-post-card.tsx index 7938452..c7815f8 100644 --- a/components/content-plan/plan-post-card.tsx +++ b/components/content-plan/plan-post-card.tsx @@ -11,10 +11,11 @@ import { PostDateTimePicker } from '@/components/content-plan/post-date-time-pic import PublishModal from '@/components/publishing/publish-modal'; import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; +import { CharProgressRing } from '@/components/ui/char-progress-ring'; import { Input } from '@/components/ui/input'; import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; import { Textarea } from '@/components/ui/textarea'; -import { cn } from '@/lib/utils'; + import type { IPostMediaWithUrl } from '@/app/app/plan/[planId]/page'; import type { IPlatformConnection, IPost, ITwitterCommunity } from '@/types/database'; @@ -29,18 +30,9 @@ interface IPlanPostCardProps { post: IPost; media: IPostMediaWithUrl[]; planId: string; - characterLimit?: number; connections: IPlatformConnection[]; communities?: ITwitterCommunity[]; -} - -// ─── Helpers ───────────────────────────────────────────────────────────────── - -function getCharCountColor(length: number, limit: number | undefined): string { - if (!limit) return 'text-muted-foreground'; - if (length > limit) return 'text-destructive'; - if (length > limit * 0.9) return 'text-yellow-600'; - return 'text-muted-foreground'; + characterLimit?: number; } // ─── Component ─────────────────────────────────────────────────────────────── @@ -49,9 +41,9 @@ export function PlanPostCard({ post, media, planId, - characterLimit, connections, communities = [], + characterLimit, }: IPlanPostCardProps): React.ReactElement { const router = useRouter(); const [isEditing, setIsEditing] = useState(false); @@ -65,7 +57,6 @@ export function PlanPostCard({ const fileInputRef = useRef(null); const isDraft = post.status === 'draft'; - const charCount = post.content.length; async function handleSave(): Promise { const result = await updatePlanPost({ postId: post.id, planId, content: editContent }); @@ -164,20 +155,18 @@ export function PlanPostCard({ onChange={(e) => setEditContent(e.target.value)} className="min-h-[80px] resize-none text-sm" /> -
- - {editContent.length}{characterLimit ? ` / ${characterLimit}` : ''} - -
- - -
+
+ {characterLimit !== undefined && ( + + )} + +
@@ -243,6 +232,9 @@ export function PlanPostCard({ )}
+ {characterLimit !== undefined && ( + + )} {isDraft && ( - - {characterLimit && ( - - {charCount} - - )}
diff --git a/components/layout/header.tsx b/components/layout/header.tsx index 8728bc9..2da4526 100644 --- a/components/layout/header.tsx +++ b/components/layout/header.tsx @@ -12,10 +12,13 @@ import UserMenu from './user-menu'; interface IHeaderProps { profile: IProfile | null; currentPlan?: IPlan | null; + trialUsesRemaining?: number | null; } -export default function Header({ profile, currentPlan }: IHeaderProps): React.ReactElement { +export default function Header({ profile, currentPlan, trialUsesRemaining }: IHeaderProps): React.ReactElement { const hasSubscription = currentPlan !== null && currentPlan !== undefined; + const trialActive = !hasSubscription && trialUsesRemaining !== null && (trialUsesRemaining ?? 0) > 0; + const trialNotActivated = !hasSubscription && trialUsesRemaining === null; return (
@@ -38,9 +41,29 @@ export default function Header({ profile, currentPlan }: IHeaderProps): React.Re
- {/* Desktop: upgrade button + plan badge + user menu */} + {/* Desktop: trial/upgrade button + plan badge + user menu */}
- {!hasSubscription && ( + {trialNotActivated && ( + + )} + {trialActive && ( + <> + + {trialUsesRemaining} free {trialUsesRemaining === 1 ? 'use' : 'uses'} left + + + + )} + {!hasSubscription && !trialNotActivated && !trialActive && (
diff --git a/components/ui/char-progress-ring.tsx b/components/ui/char-progress-ring.tsx new file mode 100644 index 0000000..0e734ec --- /dev/null +++ b/components/ui/char-progress-ring.tsx @@ -0,0 +1,46 @@ +import { cn } from '@/lib/utils'; + +const RING_RADIUS = 8; +const RING_CIRCUMFERENCE = 2 * Math.PI * RING_RADIUS; + +function getRingColor(length: number, limit: number): string { + if (length > limit) return 'text-red-500'; + if (length >= limit * 0.9) return 'text-yellow-500'; + return 'text-muted-foreground/40'; +} + +interface ICharProgressRingProps { + length: number; + limit: number; + size?: number; + className?: string; + 'data-testid'?: string; +} + +export function CharProgressRing({ + length, + limit, + size = 20, + className, + 'data-testid': testId, +}: ICharProgressRingProps): React.ReactElement { + const progress = Math.min(length / limit, 1); + const offset = RING_CIRCUMFERENCE - progress * RING_CIRCUMFERENCE; + const colorClass = getRingColor(length, limit); + + return ( +
+ + + + +
+ ); +} diff --git a/docs/auth.md b/docs/auth.md index 58c4598..3621527 100644 --- a/docs/auth.md +++ b/docs/auth.md @@ -6,7 +6,8 @@ |---|---|---| | Email + Password | Supabase Auth | Standard sign up and sign in | | Google OAuth | Supabase Auth | One-click sign in with Google account | -| GitHub OAuth | Supabase Auth | One-click sign in with GitHub account | +| Twitter OAuth | Supabase Auth | One-click sign in with Twitter/X account | +| LinkedIn OIDC | Supabase Auth | One-click sign in with LinkedIn account | ## Sign-Up Flow @@ -20,7 +21,7 @@ There is no onboarding wizard. New users without an active subscription are redi ## OAuth Callback -After Google or GitHub OAuth, Supabase redirects to `/callback`. The callback route exchanges the auth code for a session, then redirects the user into the app. +After Google, Twitter, or LinkedIn OAuth, Supabase redirects to `/callback`. The callback route exchanges the auth code for a session, then redirects the user into the app. ## Password Reset Flow diff --git a/docs/billing.md b/docs/billing.md index 7dc6680..35f0b0b 100644 --- a/docs/billing.md +++ b/docs/billing.md @@ -12,15 +12,23 @@ Payments are handled by [Polar](https://polar.sh). Polar manages subscriptions, | Pro | $19 | $180/yr | 500/cycle | 10 | Yes (7 days, 3/day) | | Ultra | $59 | $540/yr | 2,000/cycle | Unlimited | Yes (14 days, 5/day) | -All plans start with a **3-day free trial** on Basic. +## Free Trial + +New users can activate a **5-use free trial** — no credit card required. + +- **Activation:** User clicks "Activate Free Trial" on the billing page or "Activate trial" in the composer. Trial does NOT start automatically on signup. +- **Trial state** is stored in `profiles.trial_uses_remaining`: `NULL` = not activated, `0` = exhausted, `>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 +- **After trial ends:** User must subscribe to a paid plan to continue ## Subscription Lifecycle ``` -Sign up → Trial (Basic) → Pick Plan → Active - │ - Canceled → Expires - Revoked → Expired immediately +Sign up → (optional) Activate Free Trial → Pick Plan → Active + │ + Canceled → Expires + Revoked → Expired immediately ``` Subscription statuses: `trialing`, `active`, `past_due`, `canceled`, `expired` @@ -59,9 +67,14 @@ Users can manage their subscription (upgrade, downgrade, cancel, view invoices) ## Subscription Enforcement -Two enforcement points: -1. **Middleware** — checks for active subscription on every request to `/app/*`. Redirects to billing if not found. -2. **Server Actions** — `generateVariations()` and other key actions check subscription status and credit balance before proceeding. +All billable server actions call `checkAccess(supabase, userId)` from `lib/access.ts`, which returns one of three states: +- **paid** — active subscription with credits → full access +- **trial** — no subscription, active trial → trial-limited access, uses `deduct_trial_use()` instead of `deduct_credits()` +- **denied** — no access (trial not activated, exhausted, or subscription has no credits) + +Enforced actions: `generateVariations`, `createPlan`, `regeneratePost`, `regeneratePlan`, `schedulePlan`, `publishPost`, `schedulePost` + +Middleware checks for active subscription or trial on `/app/*` routes — redirects to billing if neither is found. ## Manual Sync @@ -69,6 +82,8 @@ The `/api/billing/sync` endpoint can be called to manually sync subscription sta ## Key Files +- `lib/access.ts` — `checkAccess()` centralized access check for all billable actions +- `actions/trial.ts` — `activateFreeTrial()` server action - `actions/billing.ts` — `getCheckoutUrl()`, `redirectToPortal()` - `lib/billing-service.ts` — `upsertSubscription()`, `cancelSubscription()`, `revokeSubscription()` - `lib/webhook-handlers.ts` — Polar event handlers @@ -77,3 +92,4 @@ The `/api/billing/sync` endpoint can be called to manually sync subscription sta - `app/api/checkout/route.ts` — Checkout redirect - `app/api/portal/route.ts` — Customer portal redirect - `app/app/settings/billing/page.tsx` — Billing settings page +- `supabase/migrations/20260309000001_add_free_trial.sql` — Trial schema migration diff --git a/lib/access.test.ts b/lib/access.test.ts new file mode 100644 index 0000000..9072150 --- /dev/null +++ b/lib/access.test.ts @@ -0,0 +1,133 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +import { checkAccess } from './access'; + +function makeSupabase({ + subscription = null as Record | null, + credits = null as { balance: number } | null, + profile = null as { trial_uses_remaining: number | null } | null, +} = {}) { + return { + from: vi.fn().mockImplementation((table: string) => ({ + select: vi.fn().mockReturnThis(), + eq: vi.fn().mockReturnThis(), + in: vi.fn().mockReturnThis(), + single: vi.fn().mockImplementation(() => { + if (table === 'subscriptions') return Promise.resolve({ data: subscription }); + if (table === 'credits') return Promise.resolve({ data: credits }); + if (table === 'profiles') return Promise.resolve({ data: profile }); + return Promise.resolve({ data: null }); + }), + })), + }; +} + +describe('checkAccess', () => { + const userId = 'user-1'; + + it('returns paid mode when subscription is active and credits > 0', async () => { + const supabase = makeSupabase({ + subscription: { + id: 'sub-1', + plans: { slug: 'pro-monthly', max_posts_per_generation: 5 }, + }, + credits: { balance: 100 }, + }); + + const result = await checkAccess(supabase as never, userId); + + expect(result.allowed).toBe(true); + if (result.allowed) { + expect(result.mode).toBe('paid'); + expect(result.creditBalance).toBe(100); + expect(result.subscription.planSlug).toBe('pro-monthly'); + } + }); + + it('returns denied when subscription exists but credits are 0', async () => { + const supabase = makeSupabase({ + subscription: { + id: 'sub-1', + plans: { slug: 'basic-monthly', max_posts_per_generation: 3 }, + }, + credits: { balance: 0 }, + }); + + const result = await checkAccess(supabase as never, userId); + + expect(result.allowed).toBe(false); + if (!result.allowed) { + expect(result.error).toMatch(/credits/i); + } + }); + + it('returns denied when subscription exists but no credits row', async () => { + const supabase = makeSupabase({ + subscription: { + id: 'sub-1', + plans: { slug: 'basic-monthly', max_posts_per_generation: 3 }, + }, + credits: null, + }); + + const result = await checkAccess(supabase as never, userId); + + expect(result.allowed).toBe(false); + }); + + it('returns trial mode when no subscription and trial uses > 0', async () => { + const supabase = makeSupabase({ + subscription: null, + profile: { trial_uses_remaining: 3 }, + }); + + const result = await checkAccess(supabase as never, userId); + + expect(result.allowed).toBe(true); + if (result.allowed) { + expect(result.mode).toBe('trial'); + if (result.mode === 'trial') { + expect(result.trialUsesRemaining).toBe(3); + } + } + }); + + it('returns denied when no subscription and trial not activated (null)', async () => { + const supabase = makeSupabase({ + subscription: null, + profile: { trial_uses_remaining: null }, + }); + + const result = await checkAccess(supabase as never, userId); + + expect(result.allowed).toBe(false); + if (!result.allowed) { + expect(result.error).toMatch(/trial/i); + } + }); + + it('returns denied when no subscription and trial exhausted (0)', async () => { + const supabase = makeSupabase({ + subscription: null, + profile: { trial_uses_remaining: 0 }, + }); + + const result = await checkAccess(supabase as never, userId); + + expect(result.allowed).toBe(false); + if (!result.allowed) { + expect(result.error).toMatch(/ended/i); + } + }); + + it('returns denied when no subscription and no profile', async () => { + const supabase = makeSupabase({ + subscription: null, + profile: null, + }); + + const result = await checkAccess(supabase as never, userId); + + expect(result.allowed).toBe(false); + }); +}); diff --git a/lib/access.ts b/lib/access.ts new file mode 100644 index 0000000..8514d06 --- /dev/null +++ b/lib/access.ts @@ -0,0 +1,99 @@ +import type { SupabaseClient } from '@supabase/supabase-js'; + +// ── Types ──────────────────────────────────────────────────────────────────── + +interface ISubscriptionInfo { + planSlug: string; + maxPostsPerGeneration: number; +} + +interface IAccessResultPaid { + allowed: true; + mode: 'paid'; + subscription: ISubscriptionInfo; + creditBalance: number; +} + +interface IAccessResultTrial { + allowed: true; + mode: 'trial'; + trialUsesRemaining: number; +} + +interface IAccessResultDenied { + allowed: false; + error: string; +} + +export type AccessResult = IAccessResultPaid | IAccessResultTrial | IAccessResultDenied; + +// ── Main Function ──────────────────────────────────────────────────────────── + +/** + * Centralized access check for all billable actions. + * + * Returns one of three states: + * - paid: user has active subscription with credits + * - trial: user has no subscription but active trial with uses remaining + * - denied: no access (with user-facing error message) + */ +export async function checkAccess( + supabase: SupabaseClient, + userId: string, +): Promise { + // 1. Check for active subscription + const { data: subscription } = await supabase + .from('subscriptions') + .select('*, plans(*)') + .eq('user_id', userId) + .in('status', ['trialing', 'active']) + .single(); + + if (subscription) { + // Has subscription — check credits + const { data: credits } = await supabase + .from('credits') + .select('balance') + .eq('user_id', userId) + .single(); + + const balance = (credits?.balance as number | null) ?? 0; + + if (balance <= 0) { + return { allowed: false, error: 'Insufficient credits. Please upgrade your plan.' }; + } + + return { + allowed: true, + mode: 'paid', + subscription: { + planSlug: (subscription.plans?.slug as string) ?? '', + maxPostsPerGeneration: (subscription.plans?.max_posts_per_generation as number) ?? 1, + }, + creditBalance: balance, + }; + } + + // 2. No subscription — check trial + const { data: profile } = await supabase + .from('profiles') + .select('trial_uses_remaining') + .eq('id', userId) + .single(); + + const trialUses = (profile?.trial_uses_remaining as number | null) ?? null; + + if (trialUses === null) { + return { allowed: false, error: 'Activate your free trial or subscribe to a plan to get started.' }; + } + + if (trialUses <= 0) { + return { allowed: false, error: 'Your free trial has ended. Subscribe to a plan to continue.' }; + } + + return { + allowed: true, + mode: 'trial', + trialUsesRemaining: trialUses, + }; +} diff --git a/lib/constants.test.ts b/lib/constants.test.ts index 24effc2..ea98074 100644 --- a/lib/constants.test.ts +++ b/lib/constants.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect } from 'vitest'; -import { PLAN_SLUGS, LIMITS, CREDIT_FORMULA, TRIAL_DAYS } from './constants'; +import { PLAN_SLUGS, LIMITS, CREDIT_FORMULA, FREE_TRIAL_USES, TRIAL_LIMITS } from './constants'; describe('PLAN_SLUGS', () => { it('should have all plan slugs defined', () => { @@ -17,9 +17,17 @@ describe('PLAN_SLUGS', () => { }); }); -describe('TRIAL_DAYS', () => { - it('should be 3 days', () => { - expect(TRIAL_DAYS).toBe(3); +describe('FREE_TRIAL_USES', () => { + it('should be 5 uses', () => { + expect(FREE_TRIAL_USES).toBe(5); + }); +}); + +describe('TRIAL_LIMITS', () => { + it('should have correct trial limits', () => { + expect(TRIAL_LIMITS.MAX_VARIATIONS).toBe(1); + 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 f73919c..1424ef2 100644 --- a/lib/constants.ts +++ b/lib/constants.ts @@ -7,7 +7,13 @@ export const PLAN_SLUGS = { ULTRA_YEARLY: 'ultra-yearly', } as const; -export const TRIAL_DAYS = 3; +export const FREE_TRIAL_USES = 5; + +export const TRIAL_LIMITS = { + MAX_VARIATIONS: 1, + CONTENT_PLAN_MAX_DAYS: 7, + CONTENT_PLAN_MAX_POSTS_PER_DAY: 3, +} as const; export const LIMITS = { MAX_PROMPT_LENGTH: 3000, @@ -31,7 +37,7 @@ export const GEMINI_MODELS = { } as const; export const OPENROUTER_MODELS = { - TWITTER: 'x-ai/grok-4', + TWITTER: 'x-ai/grok-3', LINKEDIN: 'openai/gpt-4o', DEFAULT: 'openai/gpt-4o', } as const; diff --git a/lib/mappers.test.ts b/lib/mappers.test.ts index 1075310..b5a91b0 100644 --- a/lib/mappers.test.ts +++ b/lib/mappers.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect } from 'vitest'; -import { mapCredit, mapPlan, mapPlatformConnection, mapProfile, mapSubscription, mapSubscriptionWithPlan } from './mappers'; +import { mapCredit, mapMemoryItem, mapPlan, mapPlatformConnection, mapProfile, mapSubscription, mapSubscriptionWithPlan } from './mappers'; describe('mapProfile', () => { it('should map snake_case DB row to camelCase IProfile', () => { @@ -18,6 +18,23 @@ describe('mapProfile', () => { expect(result.fullName).toBe('John Doe'); expect(result.avatarUrl).toBe('https://example.com/avatar.png'); expect(result.onboardingCompleted).toBe(true); + expect(result.trialUsesRemaining).toBeNull(); + }); + + it('should map trial_uses_remaining', () => { + const row = { + id: 'user-1', + full_name: 'Jane', + avatar_url: null, + onboarding_completed: false, + trial_uses_remaining: 3, + created_at: '2026-01-01T00:00:00Z', + updated_at: '2026-01-01T00:00:00Z', + }; + + const result = mapProfile(row); + + expect(result.trialUsesRemaining).toBe(3); }); it('should handle null values', () => { @@ -34,6 +51,7 @@ describe('mapProfile', () => { expect(result.fullName).toBeNull(); expect(result.avatarUrl).toBeNull(); + expect(result.trialUsesRemaining).toBeNull(); }); }); @@ -203,6 +221,63 @@ describe('mapPlatformConnection', () => { }); }); +describe('mapMemoryItem', () => { + it('should map snake_case DB row to camelCase IMemoryItem', () => { + const row = { + id: 'item-1', + user_id: null, + type: 'platform', + name: 'Twitter / X', + description: 'System Twitter platform', + content: 'Twitter platform content', + icon: '🐦', + color: '#1DA1F2', + is_system: true, + display_order: 1, + metadata: {}, + source_template_id: null, + created_at: '2026-01-01T00:00:00Z', + updated_at: '2026-01-01T00:00:00Z', + deleted_at: null, + }; + + const result = mapMemoryItem(row); + + expect(result.id).toBe('item-1'); + expect(result.userId).toBeNull(); + expect(result.isSystem).toBe(true); + expect(result.displayOrder).toBe(1); + expect(result.sourceTemplateId).toBeNull(); + expect(result.deletedAt).toBeNull(); + }); + + it('should map user-owned item with userId set', () => { + const row = { + id: 'item-2', + user_id: 'user-1', + type: 'sphere', + name: 'Tech', + description: null, + content: 'Tech sphere content', + icon: null, + color: null, + is_system: false, + display_order: 2, + metadata: {}, + source_template_id: 'tmpl-1', + created_at: '2026-01-01T00:00:00Z', + updated_at: '2026-01-01T00:00:00Z', + deleted_at: null, + }; + + const result = mapMemoryItem(row); + + expect(result.userId).toBe('user-1'); + expect(result.isSystem).toBe(false); + expect(result.sourceTemplateId).toBe('tmpl-1'); + }); +}); + describe('mapSubscriptionWithPlan', () => { it('should map subscription with nested plan', () => { const row = { diff --git a/lib/mappers.ts b/lib/mappers.ts index b55015e..9d1f211 100644 --- a/lib/mappers.ts +++ b/lib/mappers.ts @@ -4,6 +4,7 @@ import type { IContentPlan, ICredit, IGenerationHistoryMessage, + IMemoryItem, IPlan, IPlatformConnection, IPost, @@ -11,6 +12,7 @@ import type { IProfile, ISubscription, ITwitterCommunity, + MemoryType, PlanInterval, SubscriptionStatus, } from '@/types/database'; @@ -26,6 +28,7 @@ export function mapProfile(row: SupabaseRow): IProfile { fullName: (row.full_name as string | null) ?? null, avatarUrl: (row.avatar_url as string | null) ?? null, onboardingCompleted: (row.onboarding_completed as boolean) ?? false, + trialUsesRemaining: (row.trial_uses_remaining as number | null) ?? null, createdAt: row.created_at as string, updatedAt: row.updated_at as string, }; @@ -168,6 +171,26 @@ export function mapPost(row: SupabaseRow): IPost { }; } +export function mapMemoryItem(row: SupabaseRow): IMemoryItem { + return { + id: row.id as string, + userId: (row.user_id as string | null) ?? null, + type: row.type as MemoryType, + name: (row.name as string | null) ?? null, + description: (row.description as string | null) ?? null, + content: row.content as string, + icon: (row.icon as string | null) ?? null, + color: (row.color as string | null) ?? null, + isSystem: (row.is_system as boolean) ?? false, + displayOrder: row.display_order as number, + metadata: (row.metadata as Record) ?? {}, + sourceTemplateId: (row.source_template_id as string | null) ?? null, + createdAt: row.created_at as string, + updatedAt: row.updated_at as string, + deletedAt: (row.deleted_at as string | null) ?? null, + }; +} + export function mapPostMedia(row: SupabaseRow): IPostMedia { return { id: row.id as string, diff --git a/lib/prompts/platforms/twitter.ts b/lib/prompts/platforms/twitter.ts index cc5cce3..e608214 100644 --- a/lib/prompts/platforms/twitter.ts +++ b/lib/prompts/platforms/twitter.ts @@ -1,72 +1,36 @@ export const TWITTER_WRITING_MODULE = ` Write like you're posting from your phone. Lowercase by default. Fragments are fine. Starting mid-thought is fine. -You're not writing content. You're saying something because it's on your mind. Look at it like this: You are in a midday, got an idea or story and want to share it quick, you are not thinking about cool grammar, vocabulary, or complex sentences. +X rewards concise, punchy content. Every word must earn its place. If you can say it in fewer words, do it. Sarcasms are welcomed in X. -Anti-patterns (never do these): +Anti-patterns: - Corporate speak ("excited to share", "proud to announce", "thrilled") -- Fake vulnerability followed by a lesson -- Starting with "Just..." or "So..." as filler -- Ending with "and that's okay" or "and it shows" +- Fake vulnerability followed by a lesson -DO: -- Dry humor over enthusiasm. Understatement over exclamation. -- Sarcasm -- Real frustration over manufactured positivity. -- Leave things unresolved sometimes. Not every post needs a takeaway. -- Lowercase signals casual. Capitalization signals you're making a point. -- One idea per tweet. If you need more, it's a thread. -Hook patterns (adapt, don't copy): -- Confession: admit something most people won't say out loud -- Blunt observation: state something obvious that nobody talks about -- Mid-thought: start as if continuing a conversation ("the part nobody mentions about X...") -- Dry humor: deadpan take on a common experience -- Contradiction: "I [did X] and [unexpected result]" +You have ~1 second to stop the scroll. The first line determines everything—engagement, reach, and whether anyone reads the rest. + +Hook patterns: +1. **Bold statement** - Surprise, shock, or make a claim +2. **Tension** - Highlight a struggle or pain point +3. **Twist** - Flip expectations + +Examples: +Bold claim hook: "I made more in 30 days than I did in a year at my job.", "I built a $100K business with a skill I learned in a weekend.". +Data hook: "I analyzed 1,000 viral threads. 90% follow this exact pattern:". +Transformation Hook: "6 months ago I was broke. Today I turned down a $200K offer. Here's what changed:", "Burned out employee → thriving founder.". +Contrarian Hook: "Most productivity advice is garbage. Here's what actually works:" +Question Hook: "Why do smart people make terrible decisions?", "What if everything you know about success is wrong?". +Story Hook: "The investor looked at me and said, 'This will never work.'" -Under 280 characters. Under 200 is better — it hits harder. -Blank lines between distinct thoughts. White space is structure. -No hashtags unless the user specifically asks. -No emojis unless the tone demands exactly one. -If the idea is too big for one tweet, structure as a thread where each tweet stands alone. +- Better under 280 characters. +- Blank lines between distinct thoughts. +- No emojis unless the tone demands exactly one. +- White space and short sentences aid scanning - - -rewrote our auth this weekend. - -800 lines down to 200. - -should've done it months ago. - ---- - -most people aren't bad at time management. they're bad at saying no. - ---- - -spent 3 hours debugging a bug that was a missing comma. this is the job. - ---- - -hot take: your app doesn't need auth on day one. ship the thing first. - ---- - -the difference between a senior and junior dev isn't knowledge. it's knowing what to ignore. - ---- - -launched to zero fanfare. 2 signups. one was me. - -still shipped though. - ---- - -i don't have a morning routine. i have coffee and panic. - `; diff --git a/supabase/migrations/20260309000001_add_free_trial.sql b/supabase/migrations/20260309000001_add_free_trial.sql new file mode 100644 index 0000000..3ea6317 --- /dev/null +++ b/supabase/migrations/20260309000001_add_free_trial.sql @@ -0,0 +1,109 @@ +-- Migration: Add free trial system (5 uses, manual activation) +-- Replaces Polar-managed 3-day trial with self-managed usage-based trial + +-- ============================================================================= +-- 1. Add trial_uses_remaining to profiles +-- ============================================================================= + +ALTER TABLE public.profiles +ADD COLUMN trial_uses_remaining integer DEFAULT NULL; + +-- NULL = not activated, 0 = exhausted, >0 = active + +-- ============================================================================= +-- 2. Create deduct_trial_use() — atomic trial use deduction +-- ============================================================================= + +CREATE OR REPLACE FUNCTION public.deduct_trial_use( + p_user_id uuid, + p_action text, + p_description text DEFAULT 'Trial use' +) +RETURNS boolean AS $$ +DECLARE + current_remaining int; + new_remaining int; +BEGIN + SELECT trial_uses_remaining INTO current_remaining + FROM public.profiles + WHERE id = p_user_id + FOR UPDATE; + + IF current_remaining IS NULL OR current_remaining <= 0 THEN + RETURN false; + END IF; + + new_remaining := current_remaining - 1; + + UPDATE public.profiles + SET trial_uses_remaining = new_remaining, updated_at = now() + WHERE id = p_user_id; + + RETURN true; +END; +$$ LANGUAGE plpgsql SECURITY DEFINER; + +-- ============================================================================= +-- 3. Update check_plan_limit() to handle trial users +-- ============================================================================= + +CREATE OR REPLACE FUNCTION public.check_plan_limit( + p_user_id uuid, + p_memory_type public.memory_type +) +RETURNS boolean AS $$ +DECLARE + plan_limit int; + current_count int; + trial_remaining int; +BEGIN + -- Get the limit from the user's active plan + SELECT + CASE p_memory_type + WHEN 'sphere' THEN p.max_spheres + WHEN 'platform' THEN p.max_custom_platforms + WHEN 'style' THEN p.max_custom_styles + ELSE NULL + END INTO plan_limit + FROM public.subscriptions s + JOIN public.plans p ON s.plan_id = p.id + WHERE s.user_id = p_user_id + AND s.status IN ('trialing', 'active') + LIMIT 1; + + -- If no subscription, check if trial user → apply Basic-tier limits + IF plan_limit IS NULL THEN + SELECT pr.trial_uses_remaining INTO trial_remaining + FROM public.profiles pr + WHERE pr.id = p_user_id; + + IF trial_remaining IS NOT NULL AND trial_remaining > 0 THEN + -- Trial users get Basic-tier limits + CASE p_memory_type + WHEN 'sphere' THEN plan_limit := 3; + WHEN 'platform' THEN plan_limit := 1; + WHEN 'style' THEN plan_limit := 1; + ELSE RETURN true; + END CASE; + ELSE + -- No subscription and no trial → deny + RETURN false; + END IF; + END IF; + + -- null = unlimited + IF plan_limit IS NULL THEN + RETURN true; + END IF; + + -- Count current items of this type + SELECT count(*) INTO current_count + FROM public.memory_items + WHERE user_id = p_user_id + AND type = p_memory_type + AND is_system = false + AND deleted_at IS NULL; + + RETURN current_count < plan_limit; +END; +$$ LANGUAGE plpgsql SECURITY DEFINER; diff --git a/types/database.ts b/types/database.ts index 792eaa8..159f5a9 100644 --- a/types/database.ts +++ b/types/database.ts @@ -36,6 +36,7 @@ export interface IProfile { fullName: string | null; avatarUrl: string | null; onboardingCompleted: boolean; + trialUsesRemaining: number | null; createdAt: string; updatedAt: string; }