diff --git a/package-lock.json b/package-lock.json index 2992033..7c973f6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index e368809..fd287ff 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/app/(dashboard)/settings/page.tsx b/src/app/(dashboard)/settings/page.tsx new file mode 100644 index 0000000..bd3025e --- /dev/null +++ b/src/app/(dashboard)/settings/page.tsx @@ -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(null) + const [subscription, setSubscription] = useState(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 ( +
+
+ +
+
+ ) + } + + return ( +
+
+

Settings

+

Manage your account and preferences

+
+ + + + + + Profile + + + + Billing + + + + Notifications + + + + Security + + + + + + + Profile Information + Update your personal information + + +
+
+ + setFormData({ ...formData, full_name: e.target.value })} + placeholder="Enter your name" + /> +
+
+ + +

+ Contact support to change your email +

+
+
+ + + +
+
+

Account Role

+

+ Your current account permissions +

+
+ + {profile?.role || 'user'} + +
+ +
+ +
+
+
+
+ + + + + Subscription & Billing + Manage your subscription and payment methods + + +
+
+
+

Current Plan

+ {subscription?.plan || 'Free'} +
+

+ {subscription?.status === 'active' ? ( + <> + Renews on{' '} + {subscription.current_period_end + ? new Date(subscription.current_period_end).toLocaleDateString() + : 'N/A'} + + ) : ( + 'Upgrade to unlock more features' + )} +

+
+ {subscription?.plan === 'free' ? ( + + ) : ( + + )} +
+ + + +
+

Plan Features

+
+ {subscription?.plan === 'free' ? ( + <> + + + + + + ) : subscription?.plan === 'pro' ? ( + <> + + + + + + ) : ( + <> + + + + + + )} +
+
+
+
+
+ + + + + Notification Preferences + Choose how you want to be notified + + + + setPreferences({ ...preferences, emailNotifications: checked }) + } + /> + + + setPreferences({ ...preferences, weeklyDigest: checked }) + } + /> + + + setPreferences({ ...preferences, marketingEmails: checked }) + } + /> + + + + + + + + Security Settings + Manage your account security + + +
+
+

Password

+

+ Last changed: Never +

+
+ +
+ + + +
+
+

Two-Factor Authentication

+

+ Add an extra layer of security +

+
+ Coming Soon +
+ + + +
+
+

Active Sessions

+

+ 1 active session +

+
+ +
+ + + +
+

Danger Zone

+

+ Permanently delete your account and all data +

+ +
+
+
+
+
+
+ ) +} + +function PlanFeature({ label, value }: { label: string; value: string }) { + return ( +
+ {label} + {value} +
+ ) +} + +function NotificationToggle({ + label, + description, + checked, + onCheckedChange, +}: { + label: string + description: string + checked: boolean + onCheckedChange: (checked: boolean) => void +}) { + return ( +
+
+

{label}

+

{description}

+
+ +
+ ) +} diff --git a/src/components/ui/switch.tsx b/src/components/ui/switch.tsx new file mode 100644 index 0000000..6a2b524 --- /dev/null +++ b/src/components/ui/switch.tsx @@ -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) { + return ( + + + + ) +} + +export { Switch }