Files
leopost/.planning/phases/01-foundation-auth/01-06-PLAN.md
Michele bd3e1074a8 docs(01): create phase 1 plans - Foundation & Auth
Phase 01: Foundation & Auth
- 6 plans in 4 execution waves
- Wave 1: Project setup (01) + Database schema (02) [parallel]
- Wave 2: Email/password auth (03) + Google OAuth (04) [parallel]
- Wave 3: Middleware & route protection (05)
- Wave 4: Subscription management UI (06)

Requirements covered: AUTH-01, AUTH-02, AUTH-03
Ready for execution

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-31 03:12:38 +01:00

20 KiB

phase, plan, type, wave, depends_on, files_modified, autonomous, must_haves
phase plan type wave depends_on files_modified autonomous must_haves
01-foundation-auth 06 execute 4
01-05
src/app/(dashboard)/subscription/page.tsx
src/app/actions/subscription.ts
src/components/subscription/plan-card.tsx
src/lib/plans.ts
true
truths artifacts key_links
User can view all available plans (Free, Creator, Pro)
User can see their current plan highlighted
User can switch to a different plan
Plan change updates profile in database
Plan features are displayed clearly
path provides min_lines
src/app/(dashboard)/subscription/page.tsx Subscription management page 30
path provides exports
src/app/actions/subscription.ts Server action for plan switching
switchPlan
path provides exports
src/components/subscription/plan-card.tsx Reusable plan display component
PlanCard
from to via pattern
src/components/subscription/plan-card.tsx src/app/actions/subscription.ts switchPlan action switchPlan
from to via pattern
src/app/actions/subscription.ts profiles table update plan_id update.*plan_id
Implement subscription management allowing users to view plans and switch between them.

Purpose: Enable users to view and change their subscription plan per AUTH-03 requirement. Payment integration deferred to later phase.

Output: Working subscription page where users can view all plans and switch their plan.

<execution_context> @C:\Users\miche.claude/get-shit-done/workflows/execute-plan.md @C:\Users\miche.claude/get-shit-done/templates/summary.md </execution_context>

@.planning/PROJECT.md @.planning/ROADMAP.md @.planning/phases/01-foundation-auth/01-RESEARCH.md @.planning/phases/01-foundation-auth/01-02-SUMMARY.md @.planning/phases/01-foundation-auth/01-05-SUMMARY.md Task 1: Create plan utilities and types src/lib/plans.ts src/types/database.ts Create type definitions at src/types/database.ts:
```typescript
export interface Plan {
  id: string
  name: 'free' | 'creator' | 'pro'
  display_name: string
  display_name_it: string
  price_monthly: number
  features: PlanFeatures
  created_at: string
}

export interface PlanFeatures {
  posts_per_month: number
  ai_models: string[]
  social_accounts: number
  image_generation: boolean
  automation: boolean | 'manual' | 'full'
}

export interface Profile {
  id: string
  tenant_id: string
  plan_id: string
  email: string
  full_name: string | null
  avatar_url: string | null
  created_at: string
  updated_at: string
  plans?: Plan
}
```

Create plan utilities at src/lib/plans.ts:

```typescript
import { PlanFeatures } from '@/types/database'

export const PLAN_DISPLAY_ORDER = ['free', 'creator', 'pro'] as const

// Feature display names in Italian
export const FEATURE_LABELS: Record<keyof PlanFeatures, string> = {
  posts_per_month: 'Post al mese',
  ai_models: 'Modelli AI',
  social_accounts: 'Account social',
  image_generation: 'Generazione immagini',
  automation: 'Automazione',
}

export function formatFeatureValue(
  key: keyof PlanFeatures,
  value: PlanFeatures[keyof PlanFeatures]
): string {
  if (typeof value === 'boolean') {
    return value ? 'Incluso' : 'Non incluso'
  }

  if (Array.isArray(value)) {
    return value.length.toString()
  }

  if (key === 'automation') {
    if (value === 'manual') return 'Solo manuale'
    if (value === 'full') return 'Completa'
    return 'Non inclusa'
  }

  return value.toString()
}

export function formatPrice(cents: number): string {
  if (cents === 0) return 'Gratis'
  return `€${(cents / 100).toFixed(0)}/mese`
}

export function getPlanBadgeColor(planName: string): string {
  switch (planName) {
    case 'pro':
      return 'bg-purple-100 text-purple-800 border-purple-200'
    case 'creator':
      return 'bg-blue-100 text-blue-800 border-blue-200'
    default:
      return 'bg-gray-100 text-gray-800 border-gray-200'
  }
}
```

These utilities:
- Define TypeScript types for plans
- Provide Italian labels for features
- Format prices and feature values
- Handle plan badge colors
- Both files exist - Types are correctly defined - Utility functions work - No TypeScript errors Plan types and utility functions created. Task 2: Create plan card component and switch action src/components/subscription/plan-card.tsx src/app/actions/subscription.ts Create plan card component at src/components/subscription/plan-card.tsx:
```typescript
'use client'

import { Plan, PlanFeatures } from '@/types/database'
import { formatFeatureValue, formatPrice, FEATURE_LABELS, getPlanBadgeColor } from '@/lib/plans'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'
import { switchPlan } from '@/app/actions/subscription'
import { useTransition } from 'react'

interface PlanCardProps {
  plan: Plan
  isCurrentPlan: boolean
  onPlanChange?: () => void
}

export function PlanCard({ plan, isCurrentPlan, onPlanChange }: PlanCardProps) {
  const [isPending, startTransition] = useTransition()

  const features = plan.features as PlanFeatures

  function handleSwitchPlan() {
    startTransition(async () => {
      const result = await switchPlan(plan.id)
      if (result.success && onPlanChange) {
        onPlanChange()
      }
    })
  }

  // Highlight features to show
  const displayFeatures: (keyof PlanFeatures)[] = [
    'posts_per_month',
    'social_accounts',
    'ai_models',
    'image_generation',
    'automation',
  ]

  return (
    <Card className={`relative ${isCurrentPlan ? 'ring-2 ring-blue-500' : ''}`}>
      {isCurrentPlan && (
        <div className="absolute -top-3 left-1/2 -translate-x-1/2">
          <span className="bg-blue-500 text-white text-xs font-medium px-3 py-1 rounded-full">
            Piano attuale
          </span>
        </div>
      )}

      <CardHeader className="text-center pt-8">
        <div className="mb-2">
          <span className={`inline-block px-3 py-1 text-xs font-medium rounded-full border ${getPlanBadgeColor(plan.name)}`}>
            {plan.display_name_it}
          </span>
        </div>
        <CardTitle className="text-3xl">
          {formatPrice(plan.price_monthly)}
        </CardTitle>
        <CardDescription>
          {plan.name === 'free' && 'Perfetto per iniziare'}
          {plan.name === 'creator' && 'Per creator seri'}
          {plan.name === 'pro' && 'Per professionisti'}
        </CardDescription>
      </CardHeader>

      <CardContent>
        <ul className="space-y-3">
          {displayFeatures.map((featureKey) => {
            const value = features[featureKey]
            const isIncluded = value !== false && value !== 'non incluso'

            return (
              <li key={featureKey} className="flex items-center gap-2">
                <span className={`w-5 h-5 rounded-full flex items-center justify-center text-xs ${
                  isIncluded ? 'bg-green-100 text-green-600' : 'bg-gray-100 text-gray-400'
                }`}>
                  {isIncluded ? '✓' : '—'}
                </span>
                <span className="text-sm">
                  <span className="font-medium">
                    {formatFeatureValue(featureKey, value)}
                  </span>
                  {' '}
                  <span className="text-gray-500">
                    {FEATURE_LABELS[featureKey].toLowerCase()}
                  </span>
                </span>
              </li>
            )
          })}
        </ul>
      </CardContent>

      <CardFooter>
        {isCurrentPlan ? (
          <Button variant="outline" className="w-full" disabled>
            Piano attuale
          </Button>
        ) : (
          <Button
            className="w-full"
            onClick={handleSwitchPlan}
            disabled={isPending}
            variant={plan.name === 'pro' ? 'default' : 'outline'}
          >
            {isPending ? 'Cambio in corso...' : (
              plan.price_monthly === 0 ? 'Passa a Gratuito' : `Passa a ${plan.display_name_it}`
            )}
          </Button>
        )}
      </CardFooter>
    </Card>
  )
}
```

Create subscription action at src/app/actions/subscription.ts:

```typescript
'use server'

import { createClient } from '@/lib/supabase/server'
import { revalidatePath } from 'next/cache'

export type SubscriptionActionState = {
  success?: boolean
  error?: string
  message?: string
}

export async function switchPlan(planId: string): Promise<SubscriptionActionState> {
  const supabase = await createClient()

  // Get current user
  const { data: { user }, error: authError } = await supabase.auth.getUser()

  if (authError || !user) {
    return { error: 'Devi essere autenticato per cambiare piano' }
  }

  // Verify plan exists
  const { data: plan, error: planError } = await supabase
    .from('plans')
    .select('id, name, display_name_it')
    .eq('id', planId)
    .single()

  if (planError || !plan) {
    return { error: 'Piano non trovato' }
  }

  // Update user's plan
  const { error: updateError } = await supabase
    .from('profiles')
    .update({ plan_id: planId })
    .eq('id', user.id)

  if (updateError) {
    console.error('Failed to update plan:', updateError)
    return { error: 'Errore durante il cambio piano. Riprova.' }
  }

  // Revalidate pages that show plan info
  revalidatePath('/dashboard')
  revalidatePath('/subscription')

  return {
    success: true,
    message: `Piano cambiato a ${plan.display_name_it}`
  }
}

export async function getCurrentPlan() {
  const supabase = await createClient()

  const { data: { user } } = await supabase.auth.getUser()

  if (!user) {
    return null
  }

  const { data: profile } = await supabase
    .from('profiles')
    .select(`
      plan_id,
      plans (
        id,
        name,
        display_name,
        display_name_it,
        price_monthly,
        features
      )
    `)
    .eq('id', user.id)
    .single()

  return profile?.plans || null
}
```

NOTE: This is a simplified plan switching for v1. In production:
- Payment would be processed before switching to paid plans
- Downgrade might be scheduled for end of billing period
- Proration logic would be needed
- These complexities are deferred per CONTEXT.md
- PlanCard component renders all plan features - switchPlan action updates database - Current plan is highlighted - Non-current plans have switch button Plan card component and switch action created. Task 3: Create subscription page src/app/(dashboard)/subscription/page.tsx Create subscription management page at src/app/(dashboard)/subscription/page.tsx:
```typescript
import { createClient } from '@/lib/supabase/server'
import { redirect } from 'next/navigation'
import { PlanCard } from '@/components/subscription/plan-card'
import { Plan } from '@/types/database'
import { PLAN_DISPLAY_ORDER } from '@/lib/plans'

export default async function SubscriptionPage() {
  const supabase = await createClient()

  const { data: { user } } = await supabase.auth.getUser()

  if (!user) {
    redirect('/login')
  }

  // Get user's current plan
  const { data: profile } = await supabase
    .from('profiles')
    .select('plan_id')
    .eq('id', user.id)
    .single()

  // Get all plans
  const { data: plans, error: plansError } = await supabase
    .from('plans')
    .select('*')
    .order('price_monthly', { ascending: true })

  if (plansError || !plans) {
    return (
      <div className="text-center py-12">
        <p className="text-red-600">Errore nel caricamento dei piani</p>
      </div>
    )
  }

  // Sort plans by our display order
  const sortedPlans = [...plans].sort((a, b) => {
    return PLAN_DISPLAY_ORDER.indexOf(a.name as typeof PLAN_DISPLAY_ORDER[number]) -
           PLAN_DISPLAY_ORDER.indexOf(b.name as typeof PLAN_DISPLAY_ORDER[number])
  })

  return (
    <div className="space-y-8">
      <div>
        <h1 className="text-2xl font-bold text-gray-900">Il tuo abbonamento</h1>
        <p className="text-gray-500 mt-1">
          Scegli il piano piu adatto alle tue esigenze
        </p>
      </div>

      {/* Info banner */}
      <div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
        <p className="text-sm text-blue-800">
          <strong>Nota:</strong> Il pagamento verra implementato nelle prossime versioni.
          Per ora puoi passare liberamente tra i piani per testare le funzionalita.
        </p>
      </div>

      {/* Plans grid */}
      <div className="grid grid-cols-1 md:grid-cols-3 gap-6">
        {sortedPlans.map((plan) => (
          <PlanCard
            key={plan.id}
            plan={plan as Plan}
            isCurrentPlan={plan.id === profile?.plan_id}
          />
        ))}
      </div>

      {/* Feature comparison */}
      <div className="mt-12">
        <h2 className="text-xl font-semibold text-gray-900 mb-4">
          Confronto funzionalita
        </h2>
        <div className="overflow-x-auto">
          <table className="w-full border-collapse">
            <thead>
              <tr className="border-b">
                <th className="text-left py-3 px-4 font-medium text-gray-600">
                  Funzionalita
                </th>
                {sortedPlans.map((plan) => (
                  <th key={plan.id} className="text-center py-3 px-4 font-medium text-gray-900">
                    {plan.display_name_it}
                  </th>
                ))}
              </tr>
            </thead>
            <tbody>
              <FeatureRow
                feature="Post al mese"
                plans={sortedPlans}
                getValue={(p) => (p.features as Plan['features']).posts_per_month.toString()}
              />
              <FeatureRow
                feature="Account social"
                plans={sortedPlans}
                getValue={(p) => (p.features as Plan['features']).social_accounts.toString()}
              />
              <FeatureRow
                feature="Modelli AI"
                plans={sortedPlans}
                getValue={(p) => (p.features as Plan['features']).ai_models.length.toString()}
              />
              <FeatureRow
                feature="Generazione immagini"
                plans={sortedPlans}
                getValue={(p) => (p.features as Plan['features']).image_generation ? '✓' : '—'}
              />
              <FeatureRow
                feature="Automazione"
                plans={sortedPlans}
                getValue={(p) => {
                  const auto = (p.features as Plan['features']).automation
                  if (auto === false) return '—'
                  if (auto === 'manual') return 'Manuale'
                  if (auto === 'full') return 'Completa'
                  return '—'
                }}
              />
            </tbody>
          </table>
        </div>
      </div>

      {/* FAQ */}
      <div className="mt-12">
        <h2 className="text-xl font-semibold text-gray-900 mb-4">
          Domande frequenti
        </h2>
        <div className="space-y-4">
          <FaqItem
            question="Posso cambiare piano in qualsiasi momento?"
            answer="Si, puoi passare a un piano superiore o inferiore quando vuoi. Le modifiche sono immediate."
          />
          <FaqItem
            question="Cosa succede se supero i limiti del mio piano?"
            answer="Riceverai un avviso quando ti avvicini al limite mensile. Non potrai creare nuovi post fino al rinnovo o all'upgrade."
          />
          <FaqItem
            question="Come funziona il pagamento?"
            answer="Il sistema di pagamento verra implementato a breve. Per ora tutti i piani sono disponibili gratuitamente per i test."
          />
        </div>
      </div>
    </div>
  )
}

function FeatureRow({
  feature,
  plans,
  getValue,
}: {
  feature: string
  plans: Plan[]
  getValue: (plan: Plan) => string
}) {
  return (
    <tr className="border-b">
      <td className="py-3 px-4 text-gray-600">{feature}</td>
      {plans.map((plan) => (
        <td key={plan.id} className="text-center py-3 px-4">
          {getValue(plan)}
        </td>
      ))}
    </tr>
  )
}

function FaqItem({ question, answer }: { question: string; answer: string }) {
  return (
    <div className="bg-gray-50 rounded-lg p-4">
      <h3 className="font-medium text-gray-900 mb-2">{question}</h3>
      <p className="text-sm text-gray-600">{answer}</p>
    </div>
  )
}
```

This page:
- Shows all three plans in cards
- Highlights current plan
- Allows switching between plans
- Shows feature comparison table
- Includes FAQ section
- Notes that payment is deferred (transparency)
- All text in Italian
- Page loads at /subscription - All three plans display - Current plan is highlighted - Can click to switch plans - Feature comparison table is accurate Complete subscription management page with plan display and switching. After all tasks complete: 1. Visit /subscription when logged in 2. See all three plans (Free, Creator, Pro) 3. Current plan is highlighted with "Piano attuale" badge 4. Click switch button on different plan -> plan changes 5. Dashboard reflects new plan after switch 6. Feature comparison table shows correct values

<success_criteria>

  • All three plans display with correct pricing
  • User can view their current plan
  • User can switch to a different plan
  • Plan switch updates database (verify in Supabase)
  • Feature limits are clearly displayed
  • All text is in Italian
  • Note about payment deferral is visible </success_criteria>
After completion, create `.planning/phases/01-foundation-auth/01-06-SUMMARY.md`