Add settings page with profile, billing, notifications, security tabs

- Create comprehensive settings page with 4 tabs
- Profile: name editing with save functionality
- Billing: subscription display with Stripe portal link
- Notifications: toggle switches for email preferences
- Security: password change, 2FA placeholder, account deletion
- Add shadcn Switch component

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2025-12-14 12:37:42 +01:00
parent 54e05173e0
commit eff740704b
4 changed files with 599 additions and 0 deletions

181
package-lock.json generated
View File

@@ -14,13 +14,16 @@
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.13",
"@stripe/stripe-js": "^8.5.3",
"@supabase/ssr": "^0.8.0",
"@supabase/supabase-js": "^2.87.1",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
"lucide-react": "^0.561.0",
"motion": "^12.23.26",
"next": "16.0.10",
"next-themes": "^0.4.6",
"react": "19.2.1",
@@ -2297,6 +2300,91 @@
}
}
},
"node_modules/@radix-ui/react-switch": {
"version": "1.2.6",
"resolved": "https://registry.npmjs.org/@radix-ui/react-switch/-/react-switch-1.2.6.tgz",
"integrity": "sha512-bByzr1+ep1zk4VubeEVViV592vu2lHE2BZY5OnzehZqOOgogN80+mNtCqPkhn2gklJqOpxWgPoYTSnhBCqpOXQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.3",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-use-controllable-state": "1.2.2",
"@radix-ui/react-use-previous": "1.1.1",
"@radix-ui/react-use-size": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-switch/node_modules/@radix-ui/react-context": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz",
"integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-switch/node_modules/@radix-ui/react-primitive": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz",
"integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-slot": "1.2.3"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-switch/node_modules/@radix-ui/react-slot": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.2"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-tabs": {
"version": "1.1.13",
"resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.13.tgz",
@@ -2486,6 +2574,21 @@
}
}
},
"node_modules/@radix-ui/react-use-previous": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-previous/-/react-use-previous-1.1.1.tgz",
"integrity": "sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-use-rect": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.1.tgz",
@@ -4155,6 +4258,16 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/date-fns": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz",
"integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/kossnocorp"
}
},
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
@@ -5049,6 +5162,33 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/framer-motion": {
"version": "12.23.26",
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.23.26.tgz",
"integrity": "sha512-cPcIhgR42xBn1Uj+PzOyheMtZ73H927+uWPDVhUMqxy8UHt6Okavb6xIz9J/phFUHUj0OncR6UvMfJTXoc/LKA==",
"license": "MIT",
"dependencies": {
"motion-dom": "^12.23.23",
"motion-utils": "^12.23.6",
"tslib": "^2.4.0"
},
"peerDependencies": {
"@emotion/is-prop-valid": "*",
"react": "^18.0.0 || ^19.0.0",
"react-dom": "^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@emotion/is-prop-valid": {
"optional": true
},
"react": {
"optional": true
},
"react-dom": {
"optional": true
}
}
},
"node_modules/function-bind": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
@@ -6384,6 +6524,47 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/motion": {
"version": "12.23.26",
"resolved": "https://registry.npmjs.org/motion/-/motion-12.23.26.tgz",
"integrity": "sha512-Ll8XhVxY8LXMVYTCfme27WH2GjBrCIzY4+ndr5QKxsK+YwCtOi2B/oBi5jcIbik5doXuWT/4KKDOVAZJkeY5VQ==",
"license": "MIT",
"dependencies": {
"framer-motion": "^12.23.26",
"tslib": "^2.4.0"
},
"peerDependencies": {
"@emotion/is-prop-valid": "*",
"react": "^18.0.0 || ^19.0.0",
"react-dom": "^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@emotion/is-prop-valid": {
"optional": true
},
"react": {
"optional": true
},
"react-dom": {
"optional": true
}
}
},
"node_modules/motion-dom": {
"version": "12.23.23",
"resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.23.23.tgz",
"integrity": "sha512-n5yolOs0TQQBRUFImrRfs/+6X4p3Q4n1dUEqt/H58Vx7OW6RF+foWEgmTVDhIWJIMXOuNNL0apKH2S16en9eiA==",
"license": "MIT",
"dependencies": {
"motion-utils": "^12.23.6"
}
},
"node_modules/motion-utils": {
"version": "12.23.6",
"resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.23.6.tgz",
"integrity": "sha512-eAWoPgr4eFEOFfg2WjIsMoqJTW6Z8MTUCgn/GZ3VRpClWBdnbjryiA3ZSNLyxCTmCQx4RmYX6jX1iWHbenUPNQ==",
"license": "MIT"
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",

View File

@@ -15,13 +15,16 @@
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.13",
"@stripe/stripe-js": "^8.5.3",
"@supabase/ssr": "^0.8.0",
"@supabase/supabase-js": "^2.87.1",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
"lucide-react": "^0.561.0",
"motion": "^12.23.26",
"next": "16.0.10",
"next-themes": "^0.4.6",
"react": "19.2.1",

View File

@@ -0,0 +1,384 @@
'use client'
import { useState, useEffect } from 'react'
import { createClient } from '@/lib/supabase/client'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { Badge } from '@/components/ui/badge'
import { Switch } from '@/components/ui/switch'
import { Separator } from '@/components/ui/separator'
import { User, CreditCard, Bell, Shield, Loader2, Check, ExternalLink } from 'lucide-react'
import type { Database } from '@/types/database'
type Profile = Database['public']['Tables']['profiles']['Row']
type Subscription = Database['public']['Tables']['subscriptions']['Row']
export default function SettingsPage() {
const [profile, setProfile] = useState<Profile | null>(null)
const [subscription, setSubscription] = useState<Subscription | null>(null)
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [saved, setSaved] = useState(false)
const [formData, setFormData] = useState({
full_name: '',
email: '',
})
const [preferences, setPreferences] = useState({
emailNotifications: true,
marketingEmails: false,
weeklyDigest: true,
})
const supabase = createClient()
useEffect(() => {
async function loadData() {
const { data: { user } } = await supabase.auth.getUser()
if (!user) return
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const [profileRes, subRes] = await Promise.all([
(supabase as any).from('profiles').select('*').eq('id', user.id).single(),
(supabase as any).from('subscriptions').select('*').eq('user_id', user.id).single(),
])
if (profileRes.data) {
setProfile(profileRes.data)
setFormData({
full_name: profileRes.data.full_name || '',
email: profileRes.data.email || '',
})
}
if (subRes.data) {
setSubscription(subRes.data)
}
setLoading(false)
}
loadData()
}, [supabase])
const handleSaveProfile = async () => {
if (!profile) return
setSaving(true)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const { error } = await (supabase as any)
.from('profiles')
.update({ full_name: formData.full_name })
.eq('id', profile.id)
setSaving(false)
if (!error) {
setSaved(true)
setTimeout(() => setSaved(false), 2000)
}
}
const handleManageBilling = async () => {
const response = await fetch('/api/stripe/portal', {
method: 'POST',
})
const data = await response.json()
if (data.url) {
window.location.href = data.url
}
}
if (loading) {
return (
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div className="flex items-center justify-center py-16">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
</div>
)
}
return (
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div className="mb-8">
<h1 className="text-2xl font-bold text-foreground">Settings</h1>
<p className="text-muted-foreground">Manage your account and preferences</p>
</div>
<Tabs defaultValue="profile" className="space-y-6">
<TabsList className="grid w-full grid-cols-4 lg:w-auto lg:inline-grid">
<TabsTrigger value="profile" className="gap-2">
<User className="h-4 w-4" />
<span className="hidden sm:inline">Profile</span>
</TabsTrigger>
<TabsTrigger value="billing" className="gap-2">
<CreditCard className="h-4 w-4" />
<span className="hidden sm:inline">Billing</span>
</TabsTrigger>
<TabsTrigger value="notifications" className="gap-2">
<Bell className="h-4 w-4" />
<span className="hidden sm:inline">Notifications</span>
</TabsTrigger>
<TabsTrigger value="security" className="gap-2">
<Shield className="h-4 w-4" />
<span className="hidden sm:inline">Security</span>
</TabsTrigger>
</TabsList>
<TabsContent value="profile">
<Card>
<CardHeader>
<CardTitle>Profile Information</CardTitle>
<CardDescription>Update your personal information</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="name">Full Name</Label>
<Input
id="name"
value={formData.full_name}
onChange={(e) => setFormData({ ...formData, full_name: e.target.value })}
placeholder="Enter your name"
/>
</div>
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input
id="email"
value={formData.email}
disabled
className="bg-muted"
/>
<p className="text-xs text-muted-foreground">
Contact support to change your email
</p>
</div>
</div>
<Separator />
<div className="flex items-center justify-between">
<div>
<p className="font-medium">Account Role</p>
<p className="text-sm text-muted-foreground">
Your current account permissions
</p>
</div>
<Badge variant={profile?.role === 'admin' ? 'brand' : 'secondary'}>
{profile?.role || 'user'}
</Badge>
</div>
<div className="flex justify-end">
<Button onClick={handleSaveProfile} disabled={saving}>
{saving ? (
<Loader2 className="h-4 w-4 animate-spin mr-2" />
) : saved ? (
<Check className="h-4 w-4 mr-2" />
) : null}
{saved ? 'Saved!' : 'Save Changes'}
</Button>
</div>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="billing">
<Card>
<CardHeader>
<CardTitle>Subscription & Billing</CardTitle>
<CardDescription>Manage your subscription and payment methods</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div className="flex items-center justify-between p-4 border rounded-lg">
<div>
<div className="flex items-center gap-2">
<p className="font-medium">Current Plan</p>
<Badge variant="brand">{subscription?.plan || 'Free'}</Badge>
</div>
<p className="text-sm text-muted-foreground mt-1">
{subscription?.status === 'active' ? (
<>
Renews on{' '}
{subscription.current_period_end
? new Date(subscription.current_period_end).toLocaleDateString()
: 'N/A'}
</>
) : (
'Upgrade to unlock more features'
)}
</p>
</div>
{subscription?.plan === 'free' ? (
<Button variant="brand" asChild>
<a href="/pricing">Upgrade Plan</a>
</Button>
) : (
<Button variant="outline" onClick={handleManageBilling}>
<ExternalLink className="h-4 w-4 mr-2" />
Manage Billing
</Button>
)}
</div>
<Separator />
<div>
<h4 className="font-medium mb-4">Plan Features</h4>
<div className="grid gap-3 sm:grid-cols-2">
{subscription?.plan === 'free' ? (
<>
<PlanFeature label="Projects" value="1" />
<PlanFeature label="AI Messages" value="50/month" />
<PlanFeature label="Templates" value="Basic" />
<PlanFeature label="Support" value="Community" />
</>
) : subscription?.plan === 'pro' ? (
<>
<PlanFeature label="Projects" value="Unlimited" />
<PlanFeature label="AI Messages" value="2,000/month" />
<PlanFeature label="Templates" value="All" />
<PlanFeature label="Support" value="Priority" />
</>
) : (
<>
<PlanFeature label="Projects" value="Unlimited" />
<PlanFeature label="AI Messages" value="10,000/month" />
<PlanFeature label="Team Members" value="Unlimited" />
<PlanFeature label="Support" value="Dedicated" />
</>
)}
</div>
</div>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="notifications">
<Card>
<CardHeader>
<CardTitle>Notification Preferences</CardTitle>
<CardDescription>Choose how you want to be notified</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<NotificationToggle
label="Email Notifications"
description="Receive email updates about your projects"
checked={preferences.emailNotifications}
onCheckedChange={(checked) =>
setPreferences({ ...preferences, emailNotifications: checked })
}
/>
<Separator />
<NotificationToggle
label="Weekly Digest"
description="Get a weekly summary of your project activity"
checked={preferences.weeklyDigest}
onCheckedChange={(checked) =>
setPreferences({ ...preferences, weeklyDigest: checked })
}
/>
<Separator />
<NotificationToggle
label="Marketing Emails"
description="Receive updates about new features and offers"
checked={preferences.marketingEmails}
onCheckedChange={(checked) =>
setPreferences({ ...preferences, marketingEmails: checked })
}
/>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="security">
<Card>
<CardHeader>
<CardTitle>Security Settings</CardTitle>
<CardDescription>Manage your account security</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div className="flex items-center justify-between">
<div>
<p className="font-medium">Password</p>
<p className="text-sm text-muted-foreground">
Last changed: Never
</p>
</div>
<Button variant="outline">Change Password</Button>
</div>
<Separator />
<div className="flex items-center justify-between">
<div>
<p className="font-medium">Two-Factor Authentication</p>
<p className="text-sm text-muted-foreground">
Add an extra layer of security
</p>
</div>
<Badge variant="outline">Coming Soon</Badge>
</div>
<Separator />
<div className="flex items-center justify-between">
<div>
<p className="font-medium">Active Sessions</p>
<p className="text-sm text-muted-foreground">
1 active session
</p>
</div>
<Button variant="outline">View Sessions</Button>
</div>
<Separator />
<div className="pt-4">
<p className="font-medium text-destructive">Danger Zone</p>
<p className="text-sm text-muted-foreground mb-4">
Permanently delete your account and all data
</p>
<Button variant="destructive">Delete Account</Button>
</div>
</CardContent>
</Card>
</TabsContent>
</Tabs>
</div>
)
}
function PlanFeature({ label, value }: { label: string; value: string }) {
return (
<div className="flex items-center justify-between p-3 bg-muted/50 rounded-lg">
<span className="text-sm text-muted-foreground">{label}</span>
<span className="font-medium">{value}</span>
</div>
)
}
function NotificationToggle({
label,
description,
checked,
onCheckedChange,
}: {
label: string
description: string
checked: boolean
onCheckedChange: (checked: boolean) => void
}) {
return (
<div className="flex items-center justify-between">
<div>
<p className="font-medium">{label}</p>
<p className="text-sm text-muted-foreground">{description}</p>
</div>
<Switch checked={checked} onCheckedChange={onCheckedChange} />
</div>
)
}

View File

@@ -0,0 +1,31 @@
"use client"
import * as React from "react"
import * as SwitchPrimitive from "@radix-ui/react-switch"
import { cn } from "@/lib/utils"
function Switch({
className,
...props
}: React.ComponentProps<typeof SwitchPrimitive.Root>) {
return (
<SwitchPrimitive.Root
data-slot="switch"
className={cn(
"peer data-[state=checked]:bg-primary data-[state=unchecked]:bg-input focus-visible:border-ring focus-visible:ring-ring/50 dark:data-[state=unchecked]:bg-input/80 inline-flex h-[1.15rem] w-8 shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
>
<SwitchPrimitive.Thumb
data-slot="switch-thumb"
className={cn(
"bg-background dark:data-[state=unchecked]:bg-foreground dark:data-[state=checked]:bg-primary-foreground pointer-events-none block size-4 rounded-full ring-0 transition-transform data-[state=checked]:translate-x-[calc(100%-2px)] data-[state=unchecked]:translate-x-0"
)}
/>
</SwitchPrimitive.Root>
)
}
export { Switch }