@cogito.ai/cli 0.4.2 → 0.4.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +29 -22
- package/dist/index.js +9 -15
- package/dist/templates/web-nextjs/.github/copilot-instructions.md +5 -6
- package/dist/templates/web-nextjs/README.md +25 -24
- package/dist/templates/web-nextjs/apps/docs/.source/browser.ts +1 -1
- package/dist/templates/web-nextjs/apps/docs/.source/server.ts +6 -6
- package/dist/templates/web-nextjs/apps/docs/app/docs/[[...slug]]/page.tsx +1 -6
- package/dist/templates/web-nextjs/apps/docs/app/docs/layout.tsx +1 -4
- package/dist/templates/web-nextjs/apps/docs/app/llms-full.txt/route.ts +3 -1
- package/dist/templates/web-nextjs/apps/docs/next-env.d.ts +1 -1
- package/dist/templates/web-nextjs/apps/web/.env.example +35 -0
- package/dist/templates/web-nextjs/apps/web/.github/copilot-instructions.md +53 -6
- package/dist/templates/web-nextjs/apps/web/.github/skills/impeccable/SKILL.md +55 -0
- package/dist/templates/web-nextjs/apps/web/DESIGN.md +65 -0
- package/dist/templates/web-nextjs/apps/web/messages/en.json +151 -5
- package/dist/templates/web-nextjs/apps/web/messages/zh.json +151 -5
- package/dist/templates/web-nextjs/apps/web/next-env.d.ts +1 -1
- package/dist/templates/web-nextjs/apps/web/next.config.ts +3 -3
- package/dist/templates/web-nextjs/apps/web/package.json +4 -0
- package/dist/templates/web-nextjs/apps/web/src/app/[locale]/(auth)/forgot-password/page.tsx +167 -38
- package/dist/templates/web-nextjs/apps/web/src/app/[locale]/(auth)/login/page.tsx +13 -3
- package/dist/templates/web-nextjs/apps/web/src/app/[locale]/(auth)/reset-password/page.tsx +4 -1
- package/dist/templates/web-nextjs/apps/web/src/app/[locale]/(auth)/signup/page.tsx +18 -17
- package/dist/templates/web-nextjs/apps/web/src/app/[locale]/(protected)/dashboard/page.tsx +1 -5
- package/dist/templates/web-nextjs/apps/web/src/app/[locale]/(protected)/payment/[paymentId]/page.tsx +88 -0
- package/dist/templates/web-nextjs/apps/web/src/app/[locale]/(protected)/payment/checkout/page.tsx +170 -0
- package/dist/templates/web-nextjs/apps/web/src/app/[locale]/(protected)/pricing/page.tsx +120 -0
- package/dist/templates/web-nextjs/apps/web/src/app/[locale]/(protected)/settings/layout.tsx +45 -2
- package/dist/templates/web-nextjs/apps/web/src/app/[locale]/(protected)/settings/profile/page.tsx +2 -8
- package/dist/templates/web-nextjs/apps/web/src/app/[locale]/(protected)/settings/subscription/page.tsx +128 -0
- package/dist/templates/web-nextjs/apps/web/src/app/[locale]/about/page.tsx +3 -6
- package/dist/templates/web-nextjs/apps/web/src/app/[locale]/globals.css +17 -5
- package/dist/templates/web-nextjs/apps/web/src/app/[locale]/help/page.tsx +1 -5
- package/dist/templates/web-nextjs/apps/web/src/app/[locale]/layout.tsx +10 -8
- package/dist/templates/web-nextjs/apps/web/src/app/[locale]/page.tsx +22 -6
- package/dist/templates/web-nextjs/apps/web/src/app/[locale]/privacy/page.tsx +3 -6
- package/dist/templates/web-nextjs/apps/web/src/app/[locale]/terms/page.tsx +1 -5
- package/dist/templates/web-nextjs/apps/web/src/app/api/payments/alipay/create/route.ts +43 -0
- package/dist/templates/web-nextjs/apps/web/src/app/api/payments/alipay/notify/route.ts +105 -0
- package/dist/templates/web-nextjs/apps/web/src/app/api/payments/alipay/query/route.ts +54 -0
- package/dist/templates/web-nextjs/apps/web/src/app/api/payments/alipay/return/route.ts +20 -0
- package/dist/templates/web-nextjs/apps/web/src/app/api/payments/create/route.ts +52 -0
- package/dist/templates/web-nextjs/apps/web/src/app/api/payments/wechat/create/route.ts +58 -0
- package/dist/templates/web-nextjs/apps/web/src/app/api/payments/wechat/notify/route.ts +92 -0
- package/dist/templates/web-nextjs/apps/web/src/app/api/payments/wechat/query/route.ts +54 -0
- package/dist/templates/web-nextjs/apps/web/src/app/auth/callback/route.ts +2 -3
- package/dist/templates/web-nextjs/apps/web/src/components/dashboard/app-sidebar.tsx +13 -16
- package/dist/templates/web-nextjs/apps/web/src/components/dashboard/chart-area-interactive.tsx +122 -146
- package/dist/templates/web-nextjs/apps/web/src/components/dashboard/data-table.tsx +84 -149
- package/dist/templates/web-nextjs/apps/web/src/components/dashboard/nav-documents.tsx +7 -16
- package/dist/templates/web-nextjs/apps/web/src/components/dashboard/nav-main.tsx +4 -4
- package/dist/templates/web-nextjs/apps/web/src/components/dashboard/nav-secondary.tsx +4 -4
- package/dist/templates/web-nextjs/apps/web/src/components/dashboard/nav-user.tsx +12 -21
- package/dist/templates/web-nextjs/apps/web/src/components/dashboard/page.tsx +10 -13
- package/dist/templates/web-nextjs/apps/web/src/components/dashboard/section-cards.tsx +5 -9
- package/dist/templates/web-nextjs/apps/web/src/components/dashboard/site-header.tsx +6 -7
- package/dist/templates/web-nextjs/apps/web/src/components/landing/features.tsx +63 -0
- package/dist/templates/web-nextjs/apps/web/src/components/landing/footer.tsx +48 -0
- package/dist/templates/web-nextjs/apps/web/src/components/landing/header.tsx +97 -0
- package/dist/templates/web-nextjs/apps/web/src/components/landing/hero.tsx +45 -0
- package/dist/templates/web-nextjs/apps/web/src/components/landing/how-it-works.tsx +35 -0
- package/dist/templates/web-nextjs/apps/web/src/components/landing/pricing-teaser.tsx +23 -0
- package/dist/templates/web-nextjs/apps/web/src/components/profile/profile-form.tsx +6 -4
- package/dist/templates/web-nextjs/apps/web/src/components/providers/theme-provider.tsx +16 -0
- package/dist/templates/web-nextjs/apps/web/src/components/ui/alert-dialog.tsx +32 -49
- package/dist/templates/web-nextjs/apps/web/src/components/ui/alert.tsx +16 -23
- package/dist/templates/web-nextjs/apps/web/src/components/ui/avatar.tsx +25 -38
- package/dist/templates/web-nextjs/apps/web/src/components/ui/badge.tsx +16 -18
- package/dist/templates/web-nextjs/apps/web/src/components/ui/breadcrumb.tsx +19 -26
- package/dist/templates/web-nextjs/apps/web/src/components/ui/button.tsx +23 -24
- package/dist/templates/web-nextjs/apps/web/src/components/ui/card.tsx +19 -36
- package/dist/templates/web-nextjs/apps/web/src/components/ui/chart.tsx +60 -94
- package/dist/templates/web-nextjs/apps/web/src/components/ui/checkbox.tsx +8 -11
- package/dist/templates/web-nextjs/apps/web/src/components/ui/collapsible.tsx +5 -17
- package/dist/templates/web-nextjs/apps/web/src/components/ui/command.tsx +25 -48
- package/dist/templates/web-nextjs/apps/web/src/components/ui/dialog.tsx +21 -35
- package/dist/templates/web-nextjs/apps/web/src/components/ui/drawer.tsx +24 -35
- package/dist/templates/web-nextjs/apps/web/src/components/ui/dropdown-menu.tsx +26 -55
- package/dist/templates/web-nextjs/apps/web/src/components/ui/field.tsx +62 -76
- package/dist/templates/web-nextjs/apps/web/src/components/ui/form.tsx +19 -34
- package/dist/templates/web-nextjs/apps/web/src/components/ui/input-otp.tsx +13 -20
- package/dist/templates/web-nextjs/apps/web/src/components/ui/input.tsx +6 -6
- package/dist/templates/web-nextjs/apps/web/src/components/ui/label.tsx +6 -6
- package/dist/templates/web-nextjs/apps/web/src/components/ui/pagination.tsx +21 -42
- package/dist/templates/web-nextjs/apps/web/src/components/ui/popover.tsx +16 -31
- package/dist/templates/web-nextjs/apps/web/src/components/ui/progress.tsx +5 -8
- package/dist/templates/web-nextjs/apps/web/src/components/ui/radio-group.tsx +8 -8
- package/dist/templates/web-nextjs/apps/web/src/components/ui/scroll-area.tsx +10 -12
- package/dist/templates/web-nextjs/apps/web/src/components/ui/select.tsx +26 -41
- package/dist/templates/web-nextjs/apps/web/src/components/ui/separator.tsx +7 -7
- package/dist/templates/web-nextjs/apps/web/src/components/ui/sheet.tsx +29 -38
- package/dist/templates/web-nextjs/apps/web/src/components/ui/sidebar.tsx +157 -189
- package/dist/templates/web-nextjs/apps/web/src/components/ui/skeleton.tsx +3 -3
- package/dist/templates/web-nextjs/apps/web/src/components/ui/slider.tsx +10 -15
- package/dist/templates/web-nextjs/apps/web/src/components/ui/sonner.tsx +13 -7
- package/dist/templates/web-nextjs/apps/web/src/components/ui/switch.tsx +9 -9
- package/dist/templates/web-nextjs/apps/web/src/components/ui/table.tsx +24 -48
- package/dist/templates/web-nextjs/apps/web/src/components/ui/tabs.tsx +21 -31
- package/dist/templates/web-nextjs/apps/web/src/components/ui/textarea.tsx +5 -5
- package/dist/templates/web-nextjs/apps/web/src/components/ui/theme-toggle.tsx +23 -0
- package/dist/templates/web-nextjs/apps/web/src/components/ui/toggle-group.tsx +15 -16
- package/dist/templates/web-nextjs/apps/web/src/components/ui/toggle.tsx +14 -15
- package/dist/templates/web-nextjs/apps/web/src/components/ui/tooltip.tsx +8 -12
- package/dist/templates/web-nextjs/apps/web/src/core/repositories/IAuthRepository.ts +2 -0
- package/dist/templates/web-nextjs/apps/web/src/core/types/auth.ts +1 -3
- package/dist/templates/web-nextjs/apps/web/src/features/auth/actions.ts +57 -1
- package/dist/templates/web-nextjs/apps/web/src/features/auth/index.ts +2 -0
- package/dist/templates/web-nextjs/apps/web/src/features/subscription/alipay/client.ts +36 -0
- package/dist/templates/web-nextjs/apps/web/src/features/subscription/alipay/server.ts +83 -0
- package/dist/templates/web-nextjs/apps/web/src/features/subscription/components/index.ts +4 -0
- package/dist/templates/web-nextjs/apps/web/src/features/subscription/components/pro-badge.tsx +9 -0
- package/dist/templates/web-nextjs/apps/web/src/features/subscription/components/pro-feature-comparison.tsx +70 -0
- package/dist/templates/web-nextjs/apps/web/src/features/subscription/components/quota-warning-banner.tsx +37 -0
- package/dist/templates/web-nextjs/apps/web/src/features/subscription/components/upgrade-button.tsx +42 -0
- package/dist/templates/web-nextjs/apps/web/src/features/subscription/hooks.ts +141 -0
- package/dist/templates/web-nextjs/apps/web/src/features/subscription/payment-helpers.ts +27 -0
- package/dist/templates/web-nextjs/apps/web/src/features/subscription/payment-service.ts +56 -0
- package/dist/templates/web-nextjs/apps/web/src/features/subscription/service.ts +55 -0
- package/dist/templates/web-nextjs/apps/web/src/features/subscription/types.ts +73 -0
- package/dist/templates/web-nextjs/apps/web/src/features/subscription/wechat/client.ts +33 -0
- package/dist/templates/web-nextjs/apps/web/src/features/subscription/wechat/server.ts +147 -0
- package/dist/templates/web-nextjs/apps/web/src/hooks/use-mobile.ts +4 -4
- package/dist/templates/web-nextjs/apps/web/src/i18n/config.ts +1 -1
- package/dist/templates/web-nextjs/apps/web/src/infra/db/SupabaseAuthRepository.ts +48 -4
- package/dist/templates/web-nextjs/apps/web/src/infra/db/client.ts +1 -4
- package/dist/templates/web-nextjs/apps/web/src/lib/supabase/admin.ts +23 -0
- package/dist/templates/web-nextjs/apps/web/src/lib/utils.ts +2 -2
- package/dist/templates/web-nextjs/apps/web/src/lib/validations/auth.ts +13 -0
- package/dist/templates/web-nextjs/apps/web/src/lib/wechat-pay/client.ts +66 -0
- package/dist/templates/web-nextjs/apps/web/src/lib/wechat-pay/crypto.ts +99 -0
- package/dist/templates/web-nextjs/apps/web/src/lib/wechat-pay/types.ts +10 -0
- package/dist/templates/web-nextjs/apps/web/src/styles/tokens.css +58 -0
- package/dist/templates/web-nextjs/pnpm-lock.yaml +319 -0
- package/dist/templates/web-nextjs/supabase/migrations/20250608_add_subscription_tables.sql +125 -0
- package/package.json +1 -1
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useState } from 'react'
|
|
4
|
+
import { useRouter } from 'next/navigation'
|
|
5
|
+
import { useTranslations } from 'next-intl'
|
|
6
|
+
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
|
7
|
+
import { Button } from '@/components/ui/button'
|
|
8
|
+
import {
|
|
9
|
+
Card,
|
|
10
|
+
CardContent,
|
|
11
|
+
CardHeader,
|
|
12
|
+
CardTitle,
|
|
13
|
+
CardDescription,
|
|
14
|
+
CardFooter,
|
|
15
|
+
} from '@/components/ui/card'
|
|
16
|
+
import { Badge } from '@/components/ui/badge'
|
|
17
|
+
import { useSubscriptionPlans, useUserSubscription } from '@/features/subscription/hooks'
|
|
18
|
+
import { createBrowserClient } from '@supabase/ssr'
|
|
19
|
+
|
|
20
|
+
export default function PricingPage() {
|
|
21
|
+
const t = useTranslations('pricing')
|
|
22
|
+
const router = useRouter()
|
|
23
|
+
const [billingCycle, setBillingCycle] = useState<'monthly' | 'yearly'>('monthly')
|
|
24
|
+
|
|
25
|
+
const { data: plans, isLoading: plansLoading } = useSubscriptionPlans()
|
|
26
|
+
const [user, setUser] = useState<{ id: string } | null>(null)
|
|
27
|
+
|
|
28
|
+
// Fetch current user
|
|
29
|
+
useState(() => {
|
|
30
|
+
const supabase = createBrowserClient(
|
|
31
|
+
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
|
32
|
+
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
|
|
33
|
+
)
|
|
34
|
+
supabase.auth.getUser().then(({ data }) => {
|
|
35
|
+
if (data.user) {
|
|
36
|
+
setUser({ id: data.user.id })
|
|
37
|
+
}
|
|
38
|
+
})
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
const { data: currentSubscription } = useUserSubscription(user?.id || '')
|
|
42
|
+
|
|
43
|
+
const handleSelectPlan = (planCode: string) => {
|
|
44
|
+
router.push(`/payment/checkout?plan=${planCode}&cycle=${billingCycle}`)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (plansLoading) {
|
|
48
|
+
return (
|
|
49
|
+
<div className="container mx-auto max-w-6xl px-4 py-10">
|
|
50
|
+
<div className="flex justify-center py-20">
|
|
51
|
+
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" />
|
|
52
|
+
</div>
|
|
53
|
+
</div>
|
|
54
|
+
)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return (
|
|
58
|
+
<div className="container mx-auto max-w-6xl px-4 py-10">
|
|
59
|
+
<div className="text-center mb-10">
|
|
60
|
+
<h1 className="text-3xl font-bold mb-4">{t('title')}</h1>
|
|
61
|
+
<p className="text-muted-foreground">{t('description')}</p>
|
|
62
|
+
</div>
|
|
63
|
+
|
|
64
|
+
<Tabs
|
|
65
|
+
value={billingCycle}
|
|
66
|
+
onValueChange={(v) => setBillingCycle(v as 'monthly' | 'yearly')}
|
|
67
|
+
className="w-full max-w-md mx-auto mb-10"
|
|
68
|
+
>
|
|
69
|
+
<TabsList className="grid w-full grid-cols-2">
|
|
70
|
+
<TabsTrigger value="monthly">{t('monthly')}</TabsTrigger>
|
|
71
|
+
<TabsTrigger value="yearly">{t('yearly')}</TabsTrigger>
|
|
72
|
+
</TabsList>
|
|
73
|
+
</Tabs>
|
|
74
|
+
|
|
75
|
+
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
|
|
76
|
+
{plans?.map((plan) => {
|
|
77
|
+
const price = billingCycle === 'monthly' ? plan.price_monthly : plan.price_yearly
|
|
78
|
+
const isCurrentPlan = currentSubscription?.plan_id === plan.id
|
|
79
|
+
|
|
80
|
+
return (
|
|
81
|
+
<Card
|
|
82
|
+
key={plan.id}
|
|
83
|
+
className={isCurrentPlan ? 'border-primary ring-1 ring-primary' : ''}
|
|
84
|
+
>
|
|
85
|
+
<CardHeader>
|
|
86
|
+
<div className="flex items-center justify-between">
|
|
87
|
+
<CardTitle>{plan.plan_name}</CardTitle>
|
|
88
|
+
{isCurrentPlan && <Badge>{t('currentPlan')}</Badge>}
|
|
89
|
+
{plan.is_featured && <Badge variant="secondary">{t('popular')}</Badge>}
|
|
90
|
+
</div>
|
|
91
|
+
<CardDescription>{plan.description}</CardDescription>
|
|
92
|
+
</CardHeader>
|
|
93
|
+
<CardContent>
|
|
94
|
+
<div className="text-3xl font-bold">
|
|
95
|
+
{price === 0 ? t('free') : `¥${(price / 100).toFixed(2)}`}
|
|
96
|
+
{price > 0 && (
|
|
97
|
+
<span className="text-sm font-normal text-muted-foreground ml-1">
|
|
98
|
+
/{billingCycle === 'monthly' ? t('perMonth') : t('perYear')}
|
|
99
|
+
</span>
|
|
100
|
+
)}
|
|
101
|
+
</div>
|
|
102
|
+
</CardContent>
|
|
103
|
+
<CardFooter>
|
|
104
|
+
{isCurrentPlan ? (
|
|
105
|
+
<Button variant="outline" className="w-full" disabled>
|
|
106
|
+
{t('currentPlan')}
|
|
107
|
+
</Button>
|
|
108
|
+
) : (
|
|
109
|
+
<Button className="w-full" onClick={() => handleSelectPlan(plan.plan_code)}>
|
|
110
|
+
{price === 0 ? t('getStarted') : t('upgrade')}
|
|
111
|
+
</Button>
|
|
112
|
+
)}
|
|
113
|
+
</CardFooter>
|
|
114
|
+
</Card>
|
|
115
|
+
)
|
|
116
|
+
})}
|
|
117
|
+
</div>
|
|
118
|
+
</div>
|
|
119
|
+
)
|
|
120
|
+
}
|
|
@@ -1,9 +1,52 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
1
3
|
import { ReactNode } from 'react'
|
|
4
|
+
import Link from 'next/link'
|
|
5
|
+
import { usePathname } from 'next/navigation'
|
|
6
|
+
import { useTranslations } from 'next-intl'
|
|
7
|
+
import { User, CreditCard } from 'lucide-react'
|
|
8
|
+
import { cn } from '@/lib/utils'
|
|
2
9
|
|
|
3
10
|
export default function SettingsLayout({ children }: { children: ReactNode }) {
|
|
11
|
+
const t = useTranslations('settings')
|
|
12
|
+
const pathname = usePathname()
|
|
13
|
+
|
|
14
|
+
const navItems = [
|
|
15
|
+
{ href: '/settings/profile', label: t('profileTitle'), icon: User },
|
|
16
|
+
{ href: '/settings/subscription', label: t('subscriptionTitle'), icon: CreditCard },
|
|
17
|
+
]
|
|
18
|
+
|
|
4
19
|
return (
|
|
5
|
-
<div className="container mx-auto max-w-
|
|
6
|
-
|
|
20
|
+
<div className="container mx-auto max-w-6xl px-4 py-10">
|
|
21
|
+
<div className="flex flex-col gap-8 md:flex-row">
|
|
22
|
+
{/* Sidebar Navigation */}
|
|
23
|
+
<aside className="w-full md:w-64 flex-shrink-0">
|
|
24
|
+
<nav className="space-y-1">
|
|
25
|
+
{navItems.map((item) => {
|
|
26
|
+
const isActive = pathname === item.href || pathname.startsWith(item.href + '/')
|
|
27
|
+
const Icon = item.icon
|
|
28
|
+
return (
|
|
29
|
+
<Link
|
|
30
|
+
key={item.href}
|
|
31
|
+
href={item.href}
|
|
32
|
+
className={cn(
|
|
33
|
+
'flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors',
|
|
34
|
+
isActive
|
|
35
|
+
? 'bg-primary text-primary-foreground'
|
|
36
|
+
: 'text-muted-foreground hover:bg-muted hover:text-foreground',
|
|
37
|
+
)}
|
|
38
|
+
>
|
|
39
|
+
<Icon className="h-4 w-4" />
|
|
40
|
+
{item.label}
|
|
41
|
+
</Link>
|
|
42
|
+
)
|
|
43
|
+
})}
|
|
44
|
+
</nav>
|
|
45
|
+
</aside>
|
|
46
|
+
|
|
47
|
+
{/* Content */}
|
|
48
|
+
<main className="flex-1 min-w-0">{children}</main>
|
|
49
|
+
</div>
|
|
7
50
|
</div>
|
|
8
51
|
)
|
|
9
52
|
}
|
package/dist/templates/web-nextjs/apps/web/src/app/[locale]/(protected)/settings/profile/page.tsx
CHANGED
|
@@ -7,11 +7,7 @@ import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
|
|
7
7
|
import { Button } from '@/components/ui/button'
|
|
8
8
|
import { Separator } from '@/components/ui/separator'
|
|
9
9
|
|
|
10
|
-
export default async function ProfilePage({
|
|
11
|
-
params,
|
|
12
|
-
}: {
|
|
13
|
-
params: Promise<{ locale: string }>
|
|
14
|
-
}) {
|
|
10
|
+
export default async function ProfilePage({ params }: { params: Promise<{ locale: string }> }) {
|
|
15
11
|
const { locale } = await params
|
|
16
12
|
const user = await getCurrentUser()
|
|
17
13
|
|
|
@@ -49,9 +45,7 @@ export default async function ProfilePage({
|
|
|
49
45
|
<p className="text-sm text-muted-foreground">{email}</p>
|
|
50
46
|
</div>
|
|
51
47
|
</div>
|
|
52
|
-
<p className="text-sm text-muted-foreground">
|
|
53
|
-
Avatar upload feature coming soon.
|
|
54
|
-
</p>
|
|
48
|
+
<p className="text-sm text-muted-foreground">Avatar upload feature coming soon.</p>
|
|
55
49
|
</CardContent>
|
|
56
50
|
</Card>
|
|
57
51
|
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useEffect, useState } from 'react'
|
|
4
|
+
import { useRouter } from 'next/navigation'
|
|
5
|
+
import { useTranslations } from 'next-intl'
|
|
6
|
+
import { Button } from '@/components/ui/button'
|
|
7
|
+
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
|
8
|
+
import { Badge } from '@/components/ui/badge'
|
|
9
|
+
import { Progress } from '@/components/ui/progress'
|
|
10
|
+
import { useUserSubscription, useUserPayments } from '@/features/subscription/hooks'
|
|
11
|
+
import { createBrowserClient } from '@supabase/ssr'
|
|
12
|
+
import type { Payment } from '@/features/subscription/types'
|
|
13
|
+
|
|
14
|
+
export default function SubscriptionPage() {
|
|
15
|
+
const t = useTranslations('subscription')
|
|
16
|
+
const router = useRouter()
|
|
17
|
+
const [userId, setUserId] = useState<string | null>(null)
|
|
18
|
+
|
|
19
|
+
useEffect(() => {
|
|
20
|
+
const supabase = createBrowserClient(
|
|
21
|
+
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
|
22
|
+
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
|
|
23
|
+
)
|
|
24
|
+
supabase.auth.getUser().then(({ data }) => {
|
|
25
|
+
if (data.user) {
|
|
26
|
+
setUserId(data.user.id)
|
|
27
|
+
}
|
|
28
|
+
})
|
|
29
|
+
}, [])
|
|
30
|
+
|
|
31
|
+
const { data: subscription } = useUserSubscription(userId || '')
|
|
32
|
+
const { data: payments } = useUserPayments(userId || '')
|
|
33
|
+
|
|
34
|
+
const handleCancel = async () => {
|
|
35
|
+
if (!userId) return
|
|
36
|
+
const supabase = createBrowserClient(
|
|
37
|
+
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
|
38
|
+
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
|
|
39
|
+
)
|
|
40
|
+
await supabase
|
|
41
|
+
.from('user_subscriptions')
|
|
42
|
+
.update({ cancel_at_period_end: true })
|
|
43
|
+
.eq('user_id', userId)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return (
|
|
47
|
+
<div className="space-y-6">
|
|
48
|
+
<div>
|
|
49
|
+
<h1 className="text-2xl font-bold">{t('title')}</h1>
|
|
50
|
+
<p className="text-muted-foreground">{t('description')}</p>
|
|
51
|
+
</div>
|
|
52
|
+
|
|
53
|
+
<Card>
|
|
54
|
+
<CardHeader>
|
|
55
|
+
<CardTitle>{t('currentPlan')}</CardTitle>
|
|
56
|
+
</CardHeader>
|
|
57
|
+
<CardContent className="space-y-4">
|
|
58
|
+
{subscription ? (
|
|
59
|
+
<>
|
|
60
|
+
<div className="flex items-center justify-between">
|
|
61
|
+
<span className="font-medium">{t('status')}</span>
|
|
62
|
+
<Badge variant={subscription.status === 'active' ? 'default' : 'secondary'}>
|
|
63
|
+
{t(subscription.status)}
|
|
64
|
+
</Badge>
|
|
65
|
+
</div>
|
|
66
|
+
<div className="flex items-center justify-between">
|
|
67
|
+
<span className="font-medium">{t('billingCycle')}</span>
|
|
68
|
+
<span>{t(subscription.billing_cycle)}</span>
|
|
69
|
+
</div>
|
|
70
|
+
{subscription.current_period_end && (
|
|
71
|
+
<div className="flex items-center justify-between">
|
|
72
|
+
<span className="font-medium">{t('expiresOn')}</span>
|
|
73
|
+
<span>{new Date(subscription.current_period_end).toLocaleDateString()}</span>
|
|
74
|
+
</div>
|
|
75
|
+
)}
|
|
76
|
+
{!subscription.cancel_at_period_end && (
|
|
77
|
+
<Button variant="outline" onClick={handleCancel}>
|
|
78
|
+
{t('cancelRenewal')}
|
|
79
|
+
</Button>
|
|
80
|
+
)}
|
|
81
|
+
{subscription.cancel_at_period_end && (
|
|
82
|
+
<p className="text-sm text-muted-foreground">{t('cancelledAtPeriodEnd')}</p>
|
|
83
|
+
)}
|
|
84
|
+
</>
|
|
85
|
+
) : (
|
|
86
|
+
<div className="text-center py-6">
|
|
87
|
+
<p className="text-muted-foreground mb-4">{t('noActiveSubscription')}</p>
|
|
88
|
+
<Button onClick={() => router.push('/pricing')}>{t('upgradeNow')}</Button>
|
|
89
|
+
</div>
|
|
90
|
+
)}
|
|
91
|
+
</CardContent>
|
|
92
|
+
</Card>
|
|
93
|
+
|
|
94
|
+
<Card>
|
|
95
|
+
<CardHeader>
|
|
96
|
+
<CardTitle>{t('paymentHistory')}</CardTitle>
|
|
97
|
+
</CardHeader>
|
|
98
|
+
<CardContent>
|
|
99
|
+
{payments && payments.length > 0 ? (
|
|
100
|
+
<div className="space-y-3">
|
|
101
|
+
{payments.map((payment: Payment) => (
|
|
102
|
+
<div
|
|
103
|
+
key={payment.id}
|
|
104
|
+
className="flex items-center justify-between border-b py-3 last:border-0"
|
|
105
|
+
>
|
|
106
|
+
<div>
|
|
107
|
+
<p className="font-medium">{payment.order_no}</p>
|
|
108
|
+
<p className="text-sm text-muted-foreground">
|
|
109
|
+
{new Date(payment.created_at).toLocaleDateString()}
|
|
110
|
+
</p>
|
|
111
|
+
</div>
|
|
112
|
+
<div className="text-right">
|
|
113
|
+
<p className="font-medium">¥{(payment.amount / 100).toFixed(2)}</p>
|
|
114
|
+
<Badge variant={payment.status === 'paid' ? 'default' : 'secondary'}>
|
|
115
|
+
{t(payment.status)}
|
|
116
|
+
</Badge>
|
|
117
|
+
</div>
|
|
118
|
+
</div>
|
|
119
|
+
))}
|
|
120
|
+
</div>
|
|
121
|
+
) : (
|
|
122
|
+
<p className="text-muted-foreground text-center py-6">{t('noPayments')}</p>
|
|
123
|
+
)}
|
|
124
|
+
</CardContent>
|
|
125
|
+
</Card>
|
|
126
|
+
</div>
|
|
127
|
+
)
|
|
128
|
+
}
|
|
@@ -1,10 +1,6 @@
|
|
|
1
1
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
|
2
2
|
|
|
3
|
-
export default async function AboutPage({
|
|
4
|
-
params,
|
|
5
|
-
}: {
|
|
6
|
-
params: Promise<{ locale: string }>
|
|
7
|
-
}) {
|
|
3
|
+
export default async function AboutPage({ params }: { params: Promise<{ locale: string }> }) {
|
|
8
4
|
const { locale } = await params
|
|
9
5
|
|
|
10
6
|
return (
|
|
@@ -16,7 +12,8 @@ export default async function AboutPage({
|
|
|
16
12
|
</CardHeader>
|
|
17
13
|
<CardContent>
|
|
18
14
|
<p className="text-muted-foreground">
|
|
19
|
-
This page is a placeholder. Replace it with your actual about information before
|
|
15
|
+
This page is a placeholder. Replace it with your actual about information before
|
|
16
|
+
shipping.
|
|
20
17
|
</p>
|
|
21
18
|
</CardContent>
|
|
22
19
|
</Card>
|
|
@@ -1,12 +1,24 @@
|
|
|
1
1
|
@import "../../styles/tw-animate.css";
|
|
2
2
|
@import "../../styles/shadcn-tailwind.css";
|
|
3
3
|
@import "tailwindcss";
|
|
4
|
+
@import "../../styles/tokens.css";
|
|
4
5
|
|
|
5
6
|
@custom-variant dark (&:is(.dark *));
|
|
6
7
|
|
|
7
8
|
@theme inline {
|
|
8
|
-
--font-heading: var(--font-sans);
|
|
9
|
+
--font-heading: var(--font-geist, var(--font-sans));
|
|
9
10
|
--font-sans: var(--font-sans);
|
|
11
|
+
|
|
12
|
+
/* Semantic tokens registered as Tailwind utilities */
|
|
13
|
+
--color-surface: var(--color-surface);
|
|
14
|
+
--color-surface-elevated: var(--color-surface-elevated);
|
|
15
|
+
--color-muted-custom: var(--color-muted);
|
|
16
|
+
--color-muted-foreground-custom: var(--color-muted-foreground);
|
|
17
|
+
--color-border-custom: var(--color-border);
|
|
18
|
+
--color-primary-custom: var(--color-primary);
|
|
19
|
+
--color-primary-foreground-custom: var(--color-primary-foreground);
|
|
20
|
+
|
|
21
|
+
/* shadcn/ui tokens */
|
|
10
22
|
--color-sidebar-ring: var(--sidebar-ring);
|
|
11
23
|
--color-sidebar-border: var(--sidebar-border);
|
|
12
24
|
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
|
@@ -119,11 +131,11 @@
|
|
|
119
131
|
@layer base {
|
|
120
132
|
* {
|
|
121
133
|
@apply border-border outline-ring/50;
|
|
122
|
-
|
|
134
|
+
}
|
|
123
135
|
body {
|
|
124
136
|
@apply bg-background text-foreground;
|
|
125
|
-
|
|
137
|
+
}
|
|
126
138
|
html {
|
|
127
139
|
@apply font-sans;
|
|
128
|
-
|
|
129
|
-
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
@@ -1,10 +1,6 @@
|
|
|
1
1
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
|
2
2
|
|
|
3
|
-
export default async function HelpPage({
|
|
4
|
-
params,
|
|
5
|
-
}: {
|
|
6
|
-
params: Promise<{ locale: string }>
|
|
7
|
-
}) {
|
|
3
|
+
export default async function HelpPage({ params }: { params: Promise<{ locale: string }> }) {
|
|
8
4
|
const { locale } = await params
|
|
9
5
|
|
|
10
6
|
return (
|
|
@@ -1,15 +1,17 @@
|
|
|
1
1
|
import type { Metadata } from 'next'
|
|
2
|
-
import {
|
|
2
|
+
import { Geist } from 'next/font/google'
|
|
3
3
|
import { NextIntlClientProvider } from 'next-intl'
|
|
4
4
|
import { getMessages } from 'next-intl/server'
|
|
5
5
|
import { notFound } from 'next/navigation'
|
|
6
6
|
import { Toaster } from '@/components/ui/sonner'
|
|
7
|
+
import { ThemeProvider } from 'next-themes'
|
|
7
8
|
import { isLocale } from '@/i18n/config'
|
|
8
9
|
import './globals.css'
|
|
9
10
|
|
|
10
|
-
const
|
|
11
|
+
const geist = Geist({
|
|
11
12
|
subsets: ['latin'],
|
|
12
|
-
variable: '--font-
|
|
13
|
+
variable: '--font-geist',
|
|
14
|
+
fallback: ['system-ui', 'sans-serif'],
|
|
13
15
|
})
|
|
14
16
|
|
|
15
17
|
export const metadata: Metadata = {
|
|
@@ -33,11 +35,11 @@ export default async function LocaleLayout({
|
|
|
33
35
|
const messages = await getMessages()
|
|
34
36
|
|
|
35
37
|
return (
|
|
36
|
-
<html lang={locale} suppressHydrationWarning>
|
|
37
|
-
<body className={`${
|
|
38
|
-
<
|
|
39
|
-
{children}
|
|
40
|
-
</
|
|
38
|
+
<html lang={locale} suppressHydrationWarning className={geist.variable}>
|
|
39
|
+
<body className={`${geist.variable} min-h-screen bg-background font-sans antialiased`}>
|
|
40
|
+
<ThemeProvider>
|
|
41
|
+
<NextIntlClientProvider messages={messages}>{children}</NextIntlClientProvider>
|
|
42
|
+
</ThemeProvider>
|
|
41
43
|
<Toaster />
|
|
42
44
|
</body>
|
|
43
45
|
</html>
|
|
@@ -1,12 +1,28 @@
|
|
|
1
1
|
import { useTranslations } from 'next-intl'
|
|
2
|
+
import { Header } from '@/components/landing/header'
|
|
3
|
+
import { Hero } from '@/components/landing/hero'
|
|
4
|
+
import { Features } from '@/components/landing/features'
|
|
5
|
+
import { HowItWorks } from '@/components/landing/how-it-works'
|
|
6
|
+
import { PricingTeaser } from '@/components/landing/pricing-teaser'
|
|
7
|
+
import { Footer } from '@/components/landing/footer'
|
|
2
8
|
|
|
3
|
-
export default function HomePage() {
|
|
4
|
-
|
|
9
|
+
export default function HomePage({ params }: { params: Promise<{ locale: string }> }) {
|
|
10
|
+
return <HomePageContent params={params} />
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
async function HomePageContent({ params }: { params: Promise<{ locale: string }> }) {
|
|
14
|
+
const { locale } = await params
|
|
5
15
|
|
|
6
16
|
return (
|
|
7
|
-
<
|
|
8
|
-
<
|
|
9
|
-
<
|
|
10
|
-
|
|
17
|
+
<div className="flex min-h-screen flex-col">
|
|
18
|
+
<Header locale={locale} />
|
|
19
|
+
<main className="flex-1">
|
|
20
|
+
<Hero locale={locale} />
|
|
21
|
+
<Features />
|
|
22
|
+
<HowItWorks />
|
|
23
|
+
<PricingTeaser />
|
|
24
|
+
</main>
|
|
25
|
+
<Footer locale={locale} />
|
|
26
|
+
</div>
|
|
11
27
|
)
|
|
12
28
|
}
|
|
@@ -1,10 +1,6 @@
|
|
|
1
1
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
|
2
2
|
|
|
3
|
-
export default async function PrivacyPage({
|
|
4
|
-
params,
|
|
5
|
-
}: {
|
|
6
|
-
params: Promise<{ locale: string }>
|
|
7
|
-
}) {
|
|
3
|
+
export default async function PrivacyPage({ params }: { params: Promise<{ locale: string }> }) {
|
|
8
4
|
const { locale } = await params
|
|
9
5
|
|
|
10
6
|
return (
|
|
@@ -16,7 +12,8 @@ export default async function PrivacyPage({
|
|
|
16
12
|
</CardHeader>
|
|
17
13
|
<CardContent>
|
|
18
14
|
<p className="text-muted-foreground">
|
|
19
|
-
This template page is a placeholder. Replace it with your actual privacy policy before
|
|
15
|
+
This template page is a placeholder. Replace it with your actual privacy policy before
|
|
16
|
+
shipping.
|
|
20
17
|
</p>
|
|
21
18
|
</CardContent>
|
|
22
19
|
</Card>
|
|
@@ -1,10 +1,6 @@
|
|
|
1
1
|
import Link from 'next/link'
|
|
2
2
|
|
|
3
|
-
export default async function TermsPage({
|
|
4
|
-
params,
|
|
5
|
-
}: {
|
|
6
|
-
params: Promise<{ locale: string }>
|
|
7
|
-
}) {
|
|
3
|
+
export default async function TermsPage({ params }: { params: Promise<{ locale: string }> }) {
|
|
8
4
|
const { locale } = await params
|
|
9
5
|
|
|
10
6
|
return (
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { NextRequest, NextResponse } from 'next/server'
|
|
2
|
+
import { createAlipayPagePay, createAlipayWapPay } from '@/features/subscription/alipay/server'
|
|
3
|
+
import { getPaymentById } from '@/features/subscription/payment-service'
|
|
4
|
+
|
|
5
|
+
export async function POST(req: NextRequest) {
|
|
6
|
+
try {
|
|
7
|
+
const { paymentId } = (await req.json()) as { paymentId: number }
|
|
8
|
+
|
|
9
|
+
const payment = await getPaymentById(paymentId)
|
|
10
|
+
if (!payment) {
|
|
11
|
+
return NextResponse.json({ error: 'Payment not found' }, { status: 404 })
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const userAgent = req.headers.get('user-agent') || ''
|
|
15
|
+
const isMobile = /Mobile|Android|iPhone/i.test(userAgent)
|
|
16
|
+
|
|
17
|
+
const notifyUrl = `${process.env.NEXT_PUBLIC_APP_URL}/api/payments/alipay/notify`
|
|
18
|
+
const returnUrl = `${process.env.NEXT_PUBLIC_APP_URL}/payment/${payment.id}`
|
|
19
|
+
|
|
20
|
+
if (isMobile) {
|
|
21
|
+
const result = await createAlipayWapPay({
|
|
22
|
+
outTradeNo: payment.order_no,
|
|
23
|
+
totalAmount: String(payment.amount / 100),
|
|
24
|
+
subject: 'Subscription',
|
|
25
|
+
returnUrl,
|
|
26
|
+
notifyUrl,
|
|
27
|
+
})
|
|
28
|
+
return NextResponse.json({ redirectUrl: result.redirectUrl })
|
|
29
|
+
} else {
|
|
30
|
+
const result = await createAlipayPagePay({
|
|
31
|
+
outTradeNo: payment.order_no,
|
|
32
|
+
totalAmount: String(payment.amount / 100),
|
|
33
|
+
subject: 'Subscription',
|
|
34
|
+
returnUrl,
|
|
35
|
+
notifyUrl,
|
|
36
|
+
})
|
|
37
|
+
return NextResponse.json({ formHtml: result.formHtml })
|
|
38
|
+
}
|
|
39
|
+
} catch (error) {
|
|
40
|
+
console.error('Alipay create error:', error)
|
|
41
|
+
return NextResponse.json({ error: 'Failed to create Alipay payment' }, { status: 500 })
|
|
42
|
+
}
|
|
43
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { NextRequest, NextResponse } from 'next/server'
|
|
2
|
+
import { verifyAlipayNotify } from '@/features/subscription/alipay/server'
|
|
3
|
+
import { createAdminClient } from '@/lib/supabase/admin'
|
|
4
|
+
import { getPaymentByOrderNo } from '@/features/subscription/payment-service'
|
|
5
|
+
|
|
6
|
+
export async function POST(req: NextRequest) {
|
|
7
|
+
try {
|
|
8
|
+
const formData = await req.formData()
|
|
9
|
+
const data: Record<string, unknown> = {}
|
|
10
|
+
formData.forEach((value, key) => {
|
|
11
|
+
data[key] = value
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
const isValid = await verifyAlipayNotify(data)
|
|
15
|
+
if (!isValid) {
|
|
16
|
+
return new NextResponse('fail', { status: 400 })
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const outTradeNo = data.out_trade_no as string
|
|
20
|
+
const tradeStatus = data.trade_status as string
|
|
21
|
+
const tradeNo = data.trade_no as string
|
|
22
|
+
const totalAmount = data.total_amount as string
|
|
23
|
+
const appId = data.app_id as string
|
|
24
|
+
const sellerId = data.seller_id as string
|
|
25
|
+
|
|
26
|
+
// Validate critical business fields against the local order
|
|
27
|
+
const payment = await getPaymentByOrderNo(outTradeNo)
|
|
28
|
+
if (!payment) {
|
|
29
|
+
console.warn(`Alipay notify: payment not found for order ${outTradeNo}`)
|
|
30
|
+
return new NextResponse('success', { status: 200 })
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Verify app_id matches our configured app
|
|
34
|
+
if (appId !== process.env.ALIPAY_APP_ID) {
|
|
35
|
+
console.warn(
|
|
36
|
+
`Alipay notify: app_id mismatch. received=${appId}, expected=${process.env.ALIPAY_APP_ID}`,
|
|
37
|
+
)
|
|
38
|
+
return new NextResponse('fail', { status: 400 })
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Verify amount matches the local order (convert to cents for comparison)
|
|
42
|
+
const notifyAmountCents = Math.round(parseFloat(totalAmount) * 100)
|
|
43
|
+
if (notifyAmountCents !== payment.amount) {
|
|
44
|
+
console.warn(
|
|
45
|
+
`Alipay notify: amount mismatch. order=${outTradeNo}, expected=${payment.amount}, received=${notifyAmountCents}`,
|
|
46
|
+
)
|
|
47
|
+
return new NextResponse('fail', { status: 400 })
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Verify seller_id if configured
|
|
51
|
+
if (process.env.ALIPAY_SELLER_ID && sellerId !== process.env.ALIPAY_SELLER_ID) {
|
|
52
|
+
console.warn(`Alipay notify: seller_id mismatch. received=${sellerId}`)
|
|
53
|
+
return new NextResponse('fail', { status: 400 })
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (tradeStatus !== 'TRADE_SUCCESS') {
|
|
57
|
+
return new NextResponse('success', { status: 200 })
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Idempotency: already paid
|
|
61
|
+
if (payment.status === 'paid') {
|
|
62
|
+
return new NextResponse('success', { status: 200 })
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const supabase = createAdminClient()
|
|
66
|
+
|
|
67
|
+
// Update payment with provider_trade_no unique constraint check
|
|
68
|
+
const { error: updateError } = await supabase
|
|
69
|
+
.from('payments')
|
|
70
|
+
.update({
|
|
71
|
+
status: 'paid',
|
|
72
|
+
provider_trade_no: tradeNo,
|
|
73
|
+
paid_at: new Date().toISOString(),
|
|
74
|
+
provider_response: data as Record<string, unknown>,
|
|
75
|
+
})
|
|
76
|
+
.eq('order_no', outTradeNo)
|
|
77
|
+
.eq('status', 'pending')
|
|
78
|
+
|
|
79
|
+
if (updateError) {
|
|
80
|
+
console.error('Alipay notify: failed to update payment', updateError)
|
|
81
|
+
return new NextResponse('fail', { status: 500 })
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Update subscription
|
|
85
|
+
if (payment.subscription_id) {
|
|
86
|
+
const now = new Date()
|
|
87
|
+
const nextMonth = new Date(now)
|
|
88
|
+
nextMonth.setMonth(nextMonth.getMonth() + 1)
|
|
89
|
+
|
|
90
|
+
await supabase
|
|
91
|
+
.from('user_subscriptions')
|
|
92
|
+
.update({
|
|
93
|
+
status: 'active',
|
|
94
|
+
current_period_start: now.toISOString(),
|
|
95
|
+
current_period_end: nextMonth.toISOString(),
|
|
96
|
+
})
|
|
97
|
+
.eq('id', payment.subscription_id)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return new NextResponse('success', { status: 200 })
|
|
101
|
+
} catch (error) {
|
|
102
|
+
console.error('Alipay notify error:', error)
|
|
103
|
+
return new NextResponse('fail', { status: 500 })
|
|
104
|
+
}
|
|
105
|
+
}
|