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>
640 lines
20 KiB
Markdown
640 lines
20 KiB
Markdown
---
|
|
phase: 01-foundation-auth
|
|
plan: 06
|
|
type: execute
|
|
wave: 4
|
|
depends_on: ["01-05"]
|
|
files_modified:
|
|
- src/app/(dashboard)/subscription/page.tsx
|
|
- src/app/actions/subscription.ts
|
|
- src/components/subscription/plan-card.tsx
|
|
- src/lib/plans.ts
|
|
autonomous: true
|
|
|
|
must_haves:
|
|
truths:
|
|
- "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"
|
|
artifacts:
|
|
- path: "src/app/(dashboard)/subscription/page.tsx"
|
|
provides: "Subscription management page"
|
|
min_lines: 30
|
|
- path: "src/app/actions/subscription.ts"
|
|
provides: "Server action for plan switching"
|
|
exports: ["switchPlan"]
|
|
- path: "src/components/subscription/plan-card.tsx"
|
|
provides: "Reusable plan display component"
|
|
exports: ["PlanCard"]
|
|
key_links:
|
|
- from: "src/components/subscription/plan-card.tsx"
|
|
to: "src/app/actions/subscription.ts"
|
|
via: "switchPlan action"
|
|
pattern: "switchPlan"
|
|
- from: "src/app/actions/subscription.ts"
|
|
to: "profiles table"
|
|
via: "update plan_id"
|
|
pattern: "update.*plan_id"
|
|
---
|
|
|
|
<objective>
|
|
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.
|
|
</objective>
|
|
|
|
<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>
|
|
|
|
<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
|
|
</context>
|
|
|
|
<tasks>
|
|
|
|
<task type="auto">
|
|
<name>Task 1: Create plan utilities and types</name>
|
|
<files>
|
|
src/lib/plans.ts
|
|
src/types/database.ts
|
|
</files>
|
|
<action>
|
|
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
|
|
</action>
|
|
<verify>
|
|
- Both files exist
|
|
- Types are correctly defined
|
|
- Utility functions work
|
|
- No TypeScript errors
|
|
</verify>
|
|
<done>
|
|
Plan types and utility functions created.
|
|
</done>
|
|
</task>
|
|
|
|
<task type="auto">
|
|
<name>Task 2: Create plan card component and switch action</name>
|
|
<files>
|
|
src/components/subscription/plan-card.tsx
|
|
src/app/actions/subscription.ts
|
|
</files>
|
|
<action>
|
|
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
|
|
</action>
|
|
<verify>
|
|
- PlanCard component renders all plan features
|
|
- switchPlan action updates database
|
|
- Current plan is highlighted
|
|
- Non-current plans have switch button
|
|
</verify>
|
|
<done>
|
|
Plan card component and switch action created.
|
|
</done>
|
|
</task>
|
|
|
|
<task type="auto">
|
|
<name>Task 3: Create subscription page</name>
|
|
<files>
|
|
src/app/(dashboard)/subscription/page.tsx
|
|
</files>
|
|
<action>
|
|
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
|
|
</action>
|
|
<verify>
|
|
- Page loads at /subscription
|
|
- All three plans display
|
|
- Current plan is highlighted
|
|
- Can click to switch plans
|
|
- Feature comparison table is accurate
|
|
</verify>
|
|
<done>
|
|
Complete subscription management page with plan display and switching.
|
|
</done>
|
|
</task>
|
|
|
|
</tasks>
|
|
|
|
<verification>
|
|
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
|
|
</verification>
|
|
|
|
<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>
|
|
|
|
<output>
|
|
After completion, create `.planning/phases/01-foundation-auth/01-06-SUMMARY.md`
|
|
</output>
|