feat: 落地打赏(个人码捐赠)模式并将付费面改为免费+赞助(无ICP证合规)
- 新增 Donation 模型/迁移/后端 API(GET,POST /donations) - 新增前端 /donate 页:收款码+感谢留言+感谢墙 - 会员页/技能广场/用量包 下架付费,改免费开放+赞助入口 - 沙箱用量耗尽引导至 /donate 赞助 - 导航与页脚新增 赞助/联盟返佣 入口,补充 i18n
This commit is contained in:
@@ -0,0 +1,225 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Heart, QrCode, MessageCircle, Send } from 'lucide-react';
|
||||
import { API_BASE } from '@/lib/config';
|
||||
import { apiFetch } from '@/lib/auth';
|
||||
import { useT } from '@/i18n';
|
||||
|
||||
const ALIPAY_QR = process.env.NEXT_PUBLIC_DONATE_ALIPAY_QR || '';
|
||||
const WECHAT_QR = process.env.NEXT_PUBLIC_DONATE_WECHAT_QR || '';
|
||||
|
||||
interface Donation {
|
||||
id: number;
|
||||
name: string | null;
|
||||
message: string | null;
|
||||
channel: string;
|
||||
amount: number | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
function channelLabel(t: ReturnType<typeof useT>, channel: string) {
|
||||
if (channel === 'WECHAT') return t.donate.wechat;
|
||||
return t.donate.alipay;
|
||||
}
|
||||
|
||||
function QrCard({
|
||||
t,
|
||||
label,
|
||||
src,
|
||||
}: {
|
||||
t: ReturnType<typeof useT>;
|
||||
label: string;
|
||||
src: string;
|
||||
}) {
|
||||
return (
|
||||
<Card className="p-6 flex flex-col items-center text-center">
|
||||
<div className="w-12 h-12 bg-rose-100 dark:bg-rose-900/30 rounded-xl flex items-center justify-center mb-4">
|
||||
<QrCode className="w-6 h-6 text-rose-600 dark:text-rose-400" />
|
||||
</div>
|
||||
<h3 className="font-semibold text-foreground mb-1">{label}</h3>
|
||||
{src ? (
|
||||
<div className="mt-3 border border-border rounded-xl p-3 bg-white">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img src={src} alt={label} className="w-44 h-44 object-contain mx-auto" />
|
||||
</div>
|
||||
) : (
|
||||
<p className="mt-3 text-sm text-muted-foreground">{t.donate.noQr}</p>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export default function DonatePage() {
|
||||
const t = useT();
|
||||
const [donations, setDonations] = useState<Donation[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [name, setName] = useState('');
|
||||
const [message, setMessage] = useState('');
|
||||
const [channel, setChannel] = useState<'ALIPAY' | 'WECHAT'>('ALIPAY');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [done, setDone] = useState(false);
|
||||
const [error, setError] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetch(`${API_BASE}/donations`)
|
||||
.then((r) => r.json())
|
||||
.then((data) => setDonations(data.donations || []))
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setSubmitting(true);
|
||||
setError(false);
|
||||
try {
|
||||
const res = await apiFetch(`${API_BASE}/donations`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
name: name.trim() || undefined,
|
||||
message: message.trim() || undefined,
|
||||
channel,
|
||||
}),
|
||||
});
|
||||
if (res.ok) {
|
||||
setDone(true);
|
||||
setName('');
|
||||
setMessage('');
|
||||
const data = await res.json();
|
||||
if (data.donation) {
|
||||
setDonations((prev) => [data.donation, ...prev].slice(0, 20));
|
||||
}
|
||||
} else {
|
||||
setError(true);
|
||||
}
|
||||
} catch {
|
||||
setError(true);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
|
||||
<div className="text-center mb-10">
|
||||
<div className="inline-flex w-14 h-14 bg-rose-100 dark:bg-rose-900/30 rounded-2xl items-center justify-center mb-4">
|
||||
<Heart className="w-7 h-7 text-rose-600 dark:text-rose-400" />
|
||||
</div>
|
||||
<h1 className="text-3xl font-bold text-foreground">{t.donate.title}</h1>
|
||||
<p className="mt-3 text-muted-foreground max-w-2xl mx-auto">{t.donate.desc}</p>
|
||||
</div>
|
||||
|
||||
<Card className="p-8 mb-8 text-center bg-gradient-to-br from-rose-50 to-white dark:from-rose-950/20 dark:to-background">
|
||||
<h2 className="text-xl font-semibold text-foreground">{t.donate.supportTitle}</h2>
|
||||
<p className="mt-2 text-muted-foreground max-w-xl mx-auto">{t.donate.supportDesc}</p>
|
||||
<p className="mt-4 text-sm text-muted-foreground">{t.donate.qrNote}</p>
|
||||
</Card>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 mb-10">
|
||||
<QrCard t={t} label={t.donate.alipay} src={ALIPAY_QR} />
|
||||
<QrCard t={t} label={t.donate.wechat} src={WECHAT_QR} />
|
||||
</div>
|
||||
|
||||
<Card className="p-6 mb-10">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<MessageCircle className="w-5 h-5 text-brand-600 dark:text-brand-400" />
|
||||
<h2 className="text-lg font-semibold text-foreground">{t.donate.leaveMessage}</h2>
|
||||
</div>
|
||||
{done ? (
|
||||
<div className="text-center py-6">
|
||||
<p className="text-green-600 dark:text-green-400 font-medium">{t.donate.submitSuccess}</p>
|
||||
<Button variant="outline" className="mt-4" onClick={() => setDone(false)}>
|
||||
{t.donate.leaveMessage}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<input
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder={t.donate.namePlaceholder}
|
||||
maxLength={40}
|
||||
className="w-full rounded-lg border border-border bg-background px-3 py-2 text-foreground outline-none focus:border-brand-500"
|
||||
/>
|
||||
<textarea
|
||||
value={message}
|
||||
onChange={(e) => setMessage(e.target.value)}
|
||||
placeholder={t.donate.messagePlaceholder}
|
||||
maxLength={280}
|
||||
rows={3}
|
||||
className="w-full rounded-lg border border-border bg-background px-3 py-2 text-foreground outline-none focus:border-brand-500 resize-none"
|
||||
/>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<span className="text-sm text-muted-foreground">{t.donate.channel}</span>
|
||||
<label className="flex items-center gap-1.5 text-sm cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
name="channel"
|
||||
checked={channel === 'ALIPAY'}
|
||||
onChange={() => setChannel('ALIPAY')}
|
||||
/>
|
||||
{t.donate.alipay}
|
||||
</label>
|
||||
<label className="flex items-center gap-1.5 text-sm cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
name="channel"
|
||||
checked={channel === 'WECHAT'}
|
||||
onChange={() => setChannel('WECHAT')}
|
||||
/>
|
||||
{t.donate.wechat}
|
||||
</label>
|
||||
</div>
|
||||
{error && <p className="text-sm text-red-600">{t.donate.submitFailed}</p>}
|
||||
<Button type="submit" disabled={submitting} className="gap-2">
|
||||
<Send className="w-4 h-4" />
|
||||
{submitting ? t.donate.submitting : t.donate.submit}
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-foreground mb-4">{t.donate.thanksWall}</h2>
|
||||
<p className="text-sm text-muted-foreground mb-4">{t.donate.thanksWallDesc}</p>
|
||||
{loading ? (
|
||||
<div className="space-y-3">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<Skeleton key={i} className="h-16 w-full rounded-xl" />
|
||||
))}
|
||||
</div>
|
||||
) : donations.length === 0 ? (
|
||||
<Card className="p-8 text-center text-muted-foreground">{t.common.noData}</Card>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
{donations.map((d) => (
|
||||
<Card key={d.id} className="p-4">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className="font-medium text-foreground">
|
||||
{d.name || t.donate.anonymous}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t.donate.via.replace('{channel}', channelLabel(t, d.channel))}
|
||||
</span>
|
||||
</div>
|
||||
{d.message && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t.donate.says}:{d.message}
|
||||
</p>
|
||||
)}
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="mt-10 text-xs text-muted-foreground text-center max-w-2xl mx-auto">
|
||||
{t.donate.disclaimer}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,16 +2,12 @@
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { apiFetch } from '@/lib/auth';
|
||||
import { useAuth } from '@/lib/auth-context';
|
||||
import PaymentModal from '@/components/ui/payment-modal';
|
||||
import { useT } from '@/i18n';
|
||||
import { API_BASE } from '@/lib/config';
|
||||
import { ShoppingBag, CheckCircle, Lock, Sparkles } from 'lucide-react';
|
||||
import { ShoppingBag, CheckCircle, Heart } from 'lucide-react';
|
||||
|
||||
interface MarketplaceSkill {
|
||||
id: string; name: string; description: string; icon: string;
|
||||
@@ -19,221 +15,108 @@ interface MarketplaceSkill {
|
||||
price: number | null; purchased: boolean; sortOrder: number;
|
||||
}
|
||||
|
||||
type FilterMode = 'all' | 'free' | 'premium' | 'purchased';
|
||||
|
||||
export default function MarketplacePage() {
|
||||
const t = useT();
|
||||
const router = useRouter();
|
||||
const { isLoggedIn } = useAuth();
|
||||
const [skills, setSkills] = useState<MarketplaceSkill[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [filter, setFilter] = useState<FilterMode>('all');
|
||||
const [payLoading, setPayLoading] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState<string | null>(null);
|
||||
const [paymentModal, setPaymentModal] = useState<{
|
||||
open: boolean; orderNo: string; payResult: any; payChannel: 'wxpay' | 'alipay';
|
||||
}>({ open: false, orderNo: '', payResult: {}, payChannel: 'alipay' });
|
||||
|
||||
useEffect(() => {
|
||||
const url = `${API_BASE}/skills/marketplace`;
|
||||
fetch(url, { credentials: 'include' })
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
.then((r) => r.json())
|
||||
.then((data) => {
|
||||
setSkills(Array.isArray(data) ? data : []);
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
const filtered = skills.filter(s => {
|
||||
if (filter === 'free') return !s.price;
|
||||
if (filter === 'premium') return !!s.price;
|
||||
if (filter === 'purchased') return s.purchased;
|
||||
return true;
|
||||
});
|
||||
|
||||
async function handleBuy(skill: MarketplaceSkill) {
|
||||
if (!isLoggedIn) { router.push('/auth'); return; }
|
||||
setPayLoading(skill.id);
|
||||
setSuccess(null);
|
||||
try {
|
||||
const res = await apiFetch('/orders/create', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
amount: skill.price,
|
||||
planType: 'SKILL',
|
||||
skillId: skill.id,
|
||||
payChannel: 'alipay',
|
||||
}),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.order && data.payResult) {
|
||||
const payUrl = data.payResult.payUrl || data.payResult.redirectUrl;
|
||||
const qrCode = data.payResult.qrcode || data.payResult.codeUrl;
|
||||
if (payUrl || qrCode) {
|
||||
setPaymentModal({ open: true, orderNo: data.order.orderNo, payResult: data.payResult, payChannel: 'alipay' });
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
setPayLoading(null);
|
||||
}
|
||||
|
||||
function handlePaymentPaid() {
|
||||
setPaymentModal(prev => ({ ...prev, open: false }));
|
||||
setSuccess(t.marketplace.purchaseSuccess);
|
||||
}
|
||||
|
||||
const filters: { key: FilterMode; label: string }[] = [
|
||||
{ key: 'all', label: t.common.viewAll },
|
||||
{ key: 'free', label: t.marketplace.free },
|
||||
{ key: 'premium', label: t.marketplace.locked },
|
||||
{ key: 'purchased', label: t.marketplace.purchased },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
|
||||
<div className="mb-8">
|
||||
<div className="mb-6">
|
||||
<h1 className="text-3xl font-bold text-foreground">{t.marketplace.title}</h1>
|
||||
<p className="mt-2 text-muted-foreground">{t.marketplace.desc}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2 mb-8">
|
||||
{filters.map(f => (
|
||||
<button
|
||||
key={f.key}
|
||||
onClick={() => setFilter(f.key)}
|
||||
className={`px-4 py-1.5 rounded-full text-sm font-medium transition-colors ${
|
||||
filter === f.key
|
||||
? 'bg-foreground text-background'
|
||||
: 'bg-muted text-muted-foreground hover:text-foreground'
|
||||
}`}
|
||||
>
|
||||
{f.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{success && (
|
||||
<div className="bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-xl p-4 mb-6 flex items-center gap-3">
|
||||
<CheckCircle className="h-5 w-5 text-green-600 shrink-0" />
|
||||
<span className="text-sm text-green-800 dark:text-green-300">{success}</span>
|
||||
<div className="flex items-center justify-between gap-3 bg-rose-50 dark:bg-rose-950/20 border border-rose-200 dark:border-rose-900 rounded-xl p-4 mb-8">
|
||||
<div className="flex items-center gap-3">
|
||||
<Heart className="w-5 h-5 text-rose-600 dark:text-rose-400 shrink-0" />
|
||||
<p className="text-sm text-foreground">{t.marketplace.freeNote}</p>
|
||||
</div>
|
||||
)}
|
||||
<Link
|
||||
href="/donate"
|
||||
className="shrink-0 text-sm font-medium text-rose-600 dark:text-rose-400 hover:underline"
|
||||
>
|
||||
{t.donate.toDonate} →
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{[1,2,3,4,5,6].map(i => <Skeleton key={i} className="h-52 rounded-2xl" />)}
|
||||
{[1, 2, 3, 4, 5, 6].map((i) => (
|
||||
<Skeleton key={i} className="h-52 rounded-2xl" />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{filtered.map(skill => {
|
||||
const isPremium = !!skill.price;
|
||||
const isOwned = skill.purchased;
|
||||
return (
|
||||
<div key={skill.id}
|
||||
className={`bg-card rounded-2xl border-2 p-6 transition-all hover:shadow-md flex flex-col ${
|
||||
isPremium && !isOwned ? 'border-amber-500/40' : 'border-border'
|
||||
}`}
|
||||
>
|
||||
{isPremium && !isOwned && (
|
||||
<div className="flex items-center gap-1.5 text-xs font-medium text-amber-600 dark:text-amber-400 mb-2">
|
||||
<Sparkles className="h-3.5 w-3.5" />
|
||||
<span>{t.marketplace.locked}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-start gap-3 mb-3">
|
||||
<span className="text-3xl">{skill.icon}</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-semibold text-foreground">{skill.name}</h3>
|
||||
<p className="text-sm text-muted-foreground mt-0.5 line-clamp-2">{skill.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<span className={`text-xs px-2 py-0.5 rounded-full ${
|
||||
skill.difficulty === 'beginner' ? 'bg-green-100 text-green-700' :
|
||||
skill.difficulty === 'intermediate' ? 'bg-yellow-100 text-yellow-700' :
|
||||
'bg-red-100 text-red-700'
|
||||
}`}>
|
||||
{(t.skills as any)[skill.difficulty]}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">{(t.skills.categories as any)[skill.category] || skill.category}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-1 mb-4">
|
||||
{skill.tags.slice(0, 3).map(tag => (
|
||||
<span key={tag} className="text-xs px-1.5 py-0.5 bg-muted text-muted-foreground rounded">{tag}</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-auto flex items-center gap-2">
|
||||
{isOwned ? (
|
||||
<>
|
||||
<Badge variant="secondary" className="gap-1">
|
||||
<CheckCircle className="h-3.5 w-3.5" />
|
||||
{t.marketplace.purchased}
|
||||
</Badge>
|
||||
<Button asChild size="sm" variant="default" className="ml-auto">
|
||||
<Link href={`/sandbox?skill=${skill.id}`}>
|
||||
{t.skills.apply}
|
||||
</Link>
|
||||
</Button>
|
||||
</>
|
||||
) : isPremium ? (
|
||||
<>
|
||||
<span className="text-lg font-bold text-foreground">¥{skill.price}</span>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => handleBuy(skill)}
|
||||
disabled={payLoading === skill.id}
|
||||
className="ml-auto"
|
||||
>
|
||||
{payLoading === skill.id ? (
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="animate-spin h-3 w-3 border-2 border-current border-t-transparent rounded-full" />
|
||||
{t.marketplace.buying}
|
||||
</span>
|
||||
) : (
|
||||
<span className="flex items-center gap-1">
|
||||
<ShoppingBag className="h-3.5 w-3.5" />
|
||||
{t.marketplace.buy.replace('{price}', String(skill.price))}
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button asChild size="sm" variant="outline" className="ml-auto">
|
||||
<Link href={`/sandbox?skill=${skill.id}`}>
|
||||
{t.skills.apply}
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
{skills.map((skill) => (
|
||||
<div
|
||||
key={skill.id}
|
||||
className="bg-card rounded-2xl border-2 border-border p-6 transition-all hover:shadow-md flex flex-col"
|
||||
>
|
||||
<div className="flex items-start gap-3 mb-3">
|
||||
<span className="text-3xl">{skill.icon}</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-semibold text-foreground">{skill.name}</h3>
|
||||
<p className="text-sm text-muted-foreground mt-0.5 line-clamp-2">{skill.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<span
|
||||
className={`text-xs px-2 py-0.5 rounded-full ${
|
||||
skill.difficulty === 'beginner'
|
||||
? 'bg-green-100 text-green-700'
|
||||
: skill.difficulty === 'intermediate'
|
||||
? 'bg-yellow-100 text-yellow-700'
|
||||
: 'bg-red-100 text-red-700'
|
||||
}`}
|
||||
>
|
||||
{(t.skills as any)[skill.difficulty]}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{(t.skills.categories as any)[skill.category] || skill.category}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-1 mb-4">
|
||||
{skill.tags.slice(0, 3).map((tag) => (
|
||||
<span key={tag} className="text-xs px-1.5 py-0.5 bg-muted text-muted-foreground rounded">
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-auto flex items-center gap-2">
|
||||
<Badge variant="secondary" className="gap-1">
|
||||
<CheckCircle className="h-3.5 w-3.5" />
|
||||
{t.marketplace.free}
|
||||
</Badge>
|
||||
<Button asChild size="sm" variant="default" className="ml-auto">
|
||||
<Link href={`/sandbox?skill=${skill.id}`}>{t.skills.apply}</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && filtered.length === 0 && (
|
||||
{!loading && skills.length === 0 && (
|
||||
<div className="text-center py-20">
|
||||
<ShoppingBag className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
|
||||
<p className="text-muted-foreground">{t.common.noData}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<PaymentModal
|
||||
open={paymentModal.open}
|
||||
orderNo={paymentModal.orderNo}
|
||||
payResult={paymentModal.payResult}
|
||||
payChannel={paymentModal.payChannel}
|
||||
onClose={() => setPaymentModal(prev => ({ ...prev, open: false }))}
|
||||
onPaid={handlePaymentPaid}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,240 +4,101 @@ import { useEffect, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { apiFetch } from '../../../lib/auth';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import PaymentModal from '@/components/ui/payment-modal';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { useT } from '@/i18n';
|
||||
import { toast } from 'sonner';
|
||||
import { isWeChatBrowser } from '@/lib/wechat';
|
||||
|
||||
interface PayResult {
|
||||
gatewayOrderId?: string;
|
||||
payUrl?: string;
|
||||
qrcode?: string;
|
||||
codeUrl?: string;
|
||||
redirectUrl?: string;
|
||||
status?: string;
|
||||
}
|
||||
|
||||
interface Subscription {
|
||||
id: number; plan: string; startDate: string; endDate: string; status: string;
|
||||
}
|
||||
|
||||
interface Order {
|
||||
id: number; orderNo: string; amount: number; planType: string; status: string; payChannel?: string; createdAt: string;
|
||||
}
|
||||
|
||||
const PLANS = [
|
||||
{ id: 'FREE', nameKey: 'planFree', price: 0, period: '', popular: false, features: ['featureSandboxFree', 'featureModelsFree', 'featurePromptsFree', 'featureCoursesFree', 'featureAdsFree'] },
|
||||
{ id: 'MONTHLY', nameKey: 'planMonthly', price: 49.9, period: 'perMonth', popular: true, features: ['featureSandboxPro', 'featureModelsPro', 'featurePromptsPro', 'featureCoursesPro', 'featureAdsPro'] },
|
||||
{ id: 'YEARLY', nameKey: 'planYearly', price: 299, period: 'perYear', popular: false, features: ['featureSandboxUnlimited', 'featureModelsPremium', 'featurePromptsPremium', 'featureCoursesPremium', 'featureAdsPremium'] },
|
||||
] as const;
|
||||
|
||||
const FEATURE_LABELS = ['featureSandbox', 'featureModels', 'featurePrompts', 'featureCourses', 'featureAds'] as const;
|
||||
import { Heart, Link2, Sparkles } from 'lucide-react';
|
||||
|
||||
export default function MemberPage() {
|
||||
const t = useT();
|
||||
const [subscription, setSubscription] = useState<Subscription | null>(null);
|
||||
const [orders, setOrders] = useState<Order[]>([]);
|
||||
const [quota, setQuota] = useState<{ used: number; remaining: number; dailyLimit: number } | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [payLoading, setPayLoading] = useState<string | null>(null);
|
||||
const [payChannel, setPayChannel] = useState<'wxpay' | 'alipay'>('alipay');
|
||||
const [paymentModal, setPaymentModal] = useState<{
|
||||
open: boolean; orderNo: string; payResult: PayResult; payChannel: 'wxpay' | 'alipay';
|
||||
}>({ open: false, orderNo: '', payResult: {}, payChannel: 'alipay' });
|
||||
|
||||
useEffect(() => { loadData(); }, []);
|
||||
|
||||
async function loadData() {
|
||||
try {
|
||||
const [subRes, ordersRes, quotaRes] = await Promise.all([
|
||||
apiFetch('/subscriptions/current').catch(() => ({ ok: false })),
|
||||
apiFetch('/orders'),
|
||||
apiFetch('/sandbox/quota'),
|
||||
]);
|
||||
if (subRes.ok) setSubscription(await (subRes as Response).json());
|
||||
const ordersData = await ordersRes.json();
|
||||
setOrders(ordersData.items || []);
|
||||
const quotaData = await quotaRes.json();
|
||||
if (quotaData.remaining !== undefined) setQuota(quotaData);
|
||||
} catch { toast.error("加载失败") }
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
async function handleSubscribe(planType: string) {
|
||||
setPayLoading(planType);
|
||||
try {
|
||||
const body: Record<string, any> = {
|
||||
amount: planType === 'MONTHLY' ? 49.9 : 299,
|
||||
planType,
|
||||
payChannel,
|
||||
};
|
||||
|
||||
const res = await apiFetch('/orders/create', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const data = await res.json();
|
||||
|
||||
if (data.order && data.payResult) {
|
||||
const payUrl = data.payResult.payUrl || data.payResult.redirectUrl;
|
||||
const qrCode = data.payResult.qrcode || data.payResult.codeUrl;
|
||||
|
||||
if (payUrl || qrCode) {
|
||||
setPaymentModal({
|
||||
open: true,
|
||||
orderNo: data.order.orderNo,
|
||||
payResult: data.payResult,
|
||||
payChannel,
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch { toast.error("加载失败") }
|
||||
setPayLoading(null);
|
||||
}
|
||||
|
||||
function handlePaymentPaid() {
|
||||
setPaymentModal(prev => ({ ...prev, open: false }));
|
||||
loadData();
|
||||
}
|
||||
useEffect(() => {
|
||||
apiFetch('/sandbox/quota')
|
||||
.then((r) => r.json())
|
||||
.then((data) => {
|
||||
if (data.remaining !== undefined) setQuota(data);
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
if (loading) return (
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
|
||||
<div className="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
|
||||
<Skeleton className="h-8 w-48 mb-2" />
|
||||
<Skeleton className="h-5 w-64 mb-8" />
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
|
||||
{[1,2,3].map(i => <Skeleton key={i} className="h-72 rounded-2xl" />)}
|
||||
</div>
|
||||
<Skeleton className="h-40 rounded-2xl mb-6" />
|
||||
<Skeleton className="h-48 rounded-2xl" />
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
|
||||
<div className="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
|
||||
<div className="mb-8">
|
||||
<Link href="/my" className="text-sm text-muted-foreground hover:text-brand-600 mb-2 inline-block">← 返回我的</Link>
|
||||
<Link href="/my" className="text-sm text-muted-foreground hover:text-brand-600 mb-2 inline-block">← {t.myLearning.back}</Link>
|
||||
<h1 className="text-3xl font-bold text-foreground">{t.member.title}</h1>
|
||||
<p className="mt-2 text-muted-foreground">{t.member.desc}</p>
|
||||
</div>
|
||||
|
||||
{quota && (
|
||||
<div className="bg-card rounded-2xl border border-border p-6 mb-8">
|
||||
<Card className="p-6 mb-6">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm font-medium text-foreground">{t.member.dailyQuota}</span>
|
||||
<span className="text-sm text-muted-foreground">{t.member.used.replace('{n}', String(quota.used))} / {quota.dailyLimit}</span>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{t.member.used.replace('{n}', String(quota.used))} / {quota.dailyLimit}
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-full bg-muted rounded-full h-3">
|
||||
<div className="bg-brand-600 h-3 rounded-full transition-all" style={{ width: `${Math.min((quota.used / quota.dailyLimit) * 100, 100)}%` }} />
|
||||
<div
|
||||
className="bg-brand-600 h-3 rounded-full transition-all"
|
||||
style={{ width: `${Math.min((quota.used / quota.dailyLimit) * 100, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
|
||||
{PLANS.map(plan => {
|
||||
const isCurrent = subscription?.plan === plan.id;
|
||||
return (
|
||||
<div key={plan.id} className={`bg-card rounded-2xl border-2 p-6 flex flex-col ${isCurrent ? 'border-brand-600' : plan.popular ? 'border-brand-400' : 'border-border'}`}>
|
||||
{plan.popular && !isCurrent && (
|
||||
<span className="self-start text-xs font-medium px-2 py-0.5 bg-brand-600 text-white rounded-full mb-3">{t.member.popular}</span>
|
||||
)}
|
||||
{isCurrent && (
|
||||
<span className="self-start text-xs font-medium px-2 py-0.5 bg-green-600 text-white rounded-full mb-3">{t.member.currentPlan_badge}</span>
|
||||
)}
|
||||
<h3 className="text-lg font-semibold text-foreground mb-1">{t.member[plan.nameKey]}</h3>
|
||||
<div className="mb-4">
|
||||
{plan.price > 0 ? (
|
||||
<span className="text-3xl font-bold text-foreground">{plan.price === 49.9 ? t.member.priceMonthly : t.member.priceYearly}<span className="text-sm font-normal text-muted-foreground">{plan.price > 0 ? t.member[plan.period as keyof typeof t.member] : ''}</span></span>
|
||||
) : (
|
||||
<span className="text-2xl font-bold text-foreground">¥0</span>
|
||||
)}
|
||||
<Card className="p-8 mb-6 bg-gradient-to-br from-brand-50 to-white dark:from-brand-950/20 dark:to-background text-center">
|
||||
<div className="inline-flex w-12 h-12 bg-brand-100 dark:bg-brand-900/30 rounded-2xl items-center justify-center mb-4">
|
||||
<Sparkles className="w-6 h-6 text-brand-600 dark:text-brand-400" />
|
||||
</div>
|
||||
<h2 className="text-xl font-semibold text-foreground">{t.member.freeUser}</h2>
|
||||
<p className="mt-2 text-muted-foreground max-w-xl mx-auto">{t.member.benefits}</p>
|
||||
</Card>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<Link href="/donate" className="block">
|
||||
<Card className="p-6 h-full hover:shadow-md transition-all flex flex-col">
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<div className="w-10 h-10 bg-rose-100 dark:bg-rose-900/30 rounded-xl flex items-center justify-center">
|
||||
<Heart className="w-5 h-5 text-rose-600 dark:text-rose-400" />
|
||||
</div>
|
||||
<div className="space-y-2 flex-1 mb-6">
|
||||
{(plan.id === 'FREE' ? FEATURE_LABELS : FEATURE_LABELS).map((f, fi) => (
|
||||
<div key={f} className="grid grid-cols-[1fr_auto] gap-x-2 text-sm">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<span className="text-green-600 text-xs shrink-0">✓</span>
|
||||
<span className="text-muted-foreground truncate">{t.member[f]}</span>
|
||||
</div>
|
||||
<span className="text-xs text-foreground font-medium text-right">{t.member[plan.features[fi]]}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{plan.id !== 'FREE' && (
|
||||
<div className="space-y-3">
|
||||
{!isCurrent && (
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => { setPayChannel('alipay'); handleSubscribe(plan.id); }}
|
||||
disabled={payLoading === plan.id}
|
||||
className={`flex-1 py-2.5 rounded-xl text-sm font-medium transition-all disabled:opacity-50 ${
|
||||
plan.popular
|
||||
? 'bg-blue-500 text-white hover:bg-blue-600'
|
||||
: 'border border-border text-foreground hover:bg-accent'
|
||||
}`}
|
||||
>
|
||||
{payLoading === plan.id ? t.member.processing : t.member.alipay}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setPayChannel('wxpay'); handleSubscribe(plan.id); }}
|
||||
disabled={payLoading === plan.id}
|
||||
className={`flex-1 py-2.5 rounded-xl text-sm font-medium transition-all disabled:opacity-50 ${
|
||||
plan.popular
|
||||
? 'bg-brand-600 text-white hover:bg-brand-700'
|
||||
: 'border border-border text-foreground hover:bg-accent'
|
||||
}`}
|
||||
>
|
||||
{payLoading === plan.id ? t.member.processing : t.member.wechatPay}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{isCurrent && (
|
||||
<button disabled
|
||||
className="w-full py-2.5 rounded-xl text-sm font-medium bg-muted text-muted-foreground cursor-default">
|
||||
{t.member.currentPlan_badge}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<h3 className="font-semibold text-foreground">{t.donate.supportUs}</h3>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<p className="text-sm text-muted-foreground flex-1">{t.donate.supportDesc}</p>
|
||||
<span className="mt-4 text-sm font-medium text-brand-600 dark:text-brand-400">
|
||||
{t.donate.toDonate} →
|
||||
</span>
|
||||
</Card>
|
||||
</Link>
|
||||
<Link href="/affiliate" className="block">
|
||||
<Card className="p-6 h-full hover:shadow-md transition-all flex flex-col">
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<div className="w-10 h-10 bg-brand-100 dark:bg-brand-900/30 rounded-xl flex items-center justify-center">
|
||||
<Link2 className="w-5 h-5 text-brand-600 dark:text-brand-400" />
|
||||
</div>
|
||||
<h3 className="font-semibold text-foreground">{t.donate.affiliate}</h3>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground flex-1">{t.affiliate.desc}</p>
|
||||
<span className="mt-4 text-sm font-medium text-brand-600 dark:text-brand-400">
|
||||
{t.affiliate.title} →
|
||||
</span>
|
||||
</Card>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="bg-card rounded-2xl border border-border p-6">
|
||||
<h2 className="text-lg font-semibold text-foreground mb-4">{t.member.orderHistory}</h2>
|
||||
{orders.length === 0 ? (
|
||||
<p className="text-muted-foreground text-center py-8">{t.member.noOrders}</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{orders.map(order => (
|
||||
<div key={order.id} className="flex items-center justify-between p-4 rounded-xl border border-border">
|
||||
<div>
|
||||
<div className="font-medium text-foreground">{t.member[order.planType === 'YEARLY' ? 'yearly' : 'monthly']}</div>
|
||||
<div className="text-sm text-muted-foreground">{new Date(order.createdAt).toLocaleDateString()}</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="font-semibold text-foreground">¥{order.amount}</div>
|
||||
<div className="flex items-center gap-2 justify-end">
|
||||
{order.payChannel && (
|
||||
<span className="text-xs text-muted-foreground">{order.payChannel === 'alipay' ? '支付宝' : '微信'}</span>
|
||||
)}
|
||||
<span className={`text-xs px-2 py-0.5 rounded ${order.status === 'PAID' ? 'bg-green-100 text-green-700' : order.status === 'PENDING' ? 'bg-yellow-100 text-yellow-700' : 'bg-muted text-muted-foreground'}`}>
|
||||
{order.status}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<PaymentModal
|
||||
open={paymentModal.open}
|
||||
orderNo={paymentModal.orderNo}
|
||||
payResult={paymentModal.payResult}
|
||||
payChannel={paymentModal.payChannel}
|
||||
onPaid={handlePaymentPaid}
|
||||
onClose={() => setPaymentModal(prev => ({ ...prev, open: false }))}
|
||||
/>
|
||||
<p className="mt-8 text-xs text-muted-foreground text-center max-w-2xl mx-auto">
|
||||
{t.donate.disclaimer}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,11 +4,11 @@ import { useEffect, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { apiFetch } from '@/lib/auth';
|
||||
import { useAuth } from '@/lib/auth-context';
|
||||
import PaymentModal from '@/components/ui/payment-modal';
|
||||
import { useT } from '@/i18n';
|
||||
import { Zap, CheckCircle } from 'lucide-react';
|
||||
import { Zap, Heart, ArrowLeft } from 'lucide-react';
|
||||
|
||||
interface Quota {
|
||||
dailyLimit: number;
|
||||
@@ -18,79 +18,45 @@ interface Quota {
|
||||
totalRemaining: number;
|
||||
}
|
||||
|
||||
const PACKAGES = [
|
||||
{ id: 'PACKAGE_10', planType: 'PACKAGE', amount: 4.9, quotaAmount: 10, labelKey: 'package10' as const, descKey: 'package10Desc' as const },
|
||||
{ id: 'PACKAGE_50', planType: 'PACKAGE', amount: 9.9, quotaAmount: 50, labelKey: 'package50' as const, descKey: 'package50Desc' as const, popular: true },
|
||||
{ id: 'PACKAGE_300', planType: 'PACKAGE', amount: 49, quotaAmount: 300, labelKey: 'package300' as const, descKey: 'package300Desc' as const },
|
||||
];
|
||||
|
||||
export default function PackagesPage() {
|
||||
const t = useT();
|
||||
const { isLoggedIn } = useAuth();
|
||||
const [quota, setQuota] = useState<Quota | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [payLoading, setPayLoading] = useState<string | null>(null);
|
||||
const [payChannel] = useState<'wxpay' | 'alipay'>('alipay');
|
||||
const [paymentModal, setPaymentModal] = useState<{
|
||||
open: boolean; orderNo: string; payResult: any; payChannel: 'wxpay' | 'alipay';
|
||||
}>({ open: false, orderNo: '', payResult: {}, payChannel: 'alipay' });
|
||||
const [success, setSuccess] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoggedIn) { setLoading(false); return; }
|
||||
if (!isLoggedIn) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
apiFetch('/sandbox/quota')
|
||||
.then(r => r.json())
|
||||
.then(data => setQuota(data))
|
||||
.then((r) => r.json())
|
||||
.then((data) => setQuota(data))
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false));
|
||||
}, [isLoggedIn]);
|
||||
|
||||
async function handleBuy(pkg: typeof PACKAGES[0]) {
|
||||
setPayLoading(pkg.id);
|
||||
setSuccess(null);
|
||||
try {
|
||||
const res = await apiFetch('/orders/create', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ amount: pkg.amount, planType: pkg.planType, payChannel }),
|
||||
});
|
||||
const data = await res.json();
|
||||
|
||||
if (data.order && data.payResult) {
|
||||
const payUrl = data.payResult.payUrl || data.payResult.redirectUrl;
|
||||
const qrCode = data.payResult.qrcode || data.payResult.codeUrl;
|
||||
|
||||
if (payUrl || qrCode) {
|
||||
setPaymentModal({ open: true, orderNo: data.order.orderNo, payResult: data.payResult, payChannel });
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
setPayLoading(null);
|
||||
}
|
||||
|
||||
function handlePaymentPaid() {
|
||||
setPaymentModal(prev => ({ ...prev, open: false }));
|
||||
setSuccess(t.packages.purchaseSuccess.replace('{n}', '50'));
|
||||
apiFetch('/sandbox/quota').then(r => r.ok && r.json()).then(d => d && setQuota(d));
|
||||
}
|
||||
|
||||
if (!isLoggedIn) {
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-20 text-center">
|
||||
<Zap className="h-12 w-12 text-brand-600 mx-auto mb-4" />
|
||||
<h1 className="text-2xl font-bold text-foreground mb-2">{t.packages.title}</h1>
|
||||
<p className="text-muted-foreground mb-6">{t.packages.desc}</p>
|
||||
<Button asChild><Link href="/auth">{t.common.login}</Link></Button>
|
||||
<Button asChild>
|
||||
<Link href="/auth">{t.common.login}</Link>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
|
||||
<Link href="/practices" className="text-sm text-muted-foreground hover:text-foreground mb-4 inline-block">
|
||||
← {t.practices.title}
|
||||
<Link
|
||||
href="/practices"
|
||||
className="text-sm text-muted-foreground hover:text-foreground mb-4 inline-block"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4 inline mr-1" />
|
||||
{t.practices.title}
|
||||
</Link>
|
||||
|
||||
<div className="mb-8">
|
||||
@@ -98,77 +64,47 @@ export default function PackagesPage() {
|
||||
<p className="mt-2 text-muted-foreground">{t.packages.desc}</p>
|
||||
</div>
|
||||
|
||||
{quota && (
|
||||
<div className="bg-card rounded-2xl border border-border p-6 mb-8">
|
||||
<h2 className="font-semibold text-foreground mb-3">{t.packages.currentQuota}</h2>
|
||||
<div className="flex flex-wrap gap-6">
|
||||
<div>
|
||||
<span className="text-sm text-muted-foreground">{t.packages.dailyQuota.replace('{used}', String(quota.used)).replace('{limit}', String(quota.dailyLimit))}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-sm text-muted-foreground">{t.packages.extraQuota.replace('{n}', String(quota.extra))}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{success && (
|
||||
<div className="bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-xl p-4 mb-6 flex items-center gap-3">
|
||||
<CheckCircle className="h-5 w-5 text-green-600 shrink-0" />
|
||||
<span className="text-sm text-green-800 dark:text-green-300">{success}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
{PACKAGES.map(pkg => (
|
||||
<div key={pkg.id} className={`bg-card rounded-2xl border-2 p-6 relative transition-all hover:shadow-md ${
|
||||
pkg.popular ? 'border-brand-600' : 'border-border'
|
||||
}`}>
|
||||
{pkg.popular && (
|
||||
<span className="absolute -top-3 left-1/2 -translate-x-1/2 bg-brand-600 text-white text-xs font-medium px-3 py-0.5 rounded-full">
|
||||
{t.packages.popular}
|
||||
</span>
|
||||
)}
|
||||
<div className="text-center mb-4">
|
||||
<div className="text-3xl font-bold text-foreground">{t.packages.price.replace('{price}', String(pkg.amount))}</div>
|
||||
<div className="text-sm text-muted-foreground mt-1">{t.packages.quota.replace('{n}', String(pkg.quotaAmount))}</div>
|
||||
</div>
|
||||
<div className="text-center mb-6">
|
||||
<div className="font-medium text-foreground">{t.packages[pkg.labelKey]}</div>
|
||||
<div className="text-xs text-muted-foreground mt-1">{t.packages[pkg.descKey]}</div>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => handleBuy(pkg)}
|
||||
disabled={payLoading === pkg.id}
|
||||
className={`w-full ${pkg.popular ? '' : 'variant-outline'}`}
|
||||
variant={pkg.popular ? 'default' : 'outline'}
|
||||
>
|
||||
{payLoading === pkg.id ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="animate-spin h-4 w-4 border-2 border-current border-t-transparent rounded-full" />
|
||||
{t.packages.buying}
|
||||
{loading ? (
|
||||
<Skeleton className="h-32 rounded-2xl mb-8" />
|
||||
) : (
|
||||
quota && (
|
||||
<Card className="p-6 mb-8">
|
||||
<h2 className="font-semibold text-foreground mb-3">{t.packages.currentQuota}</h2>
|
||||
<div className="flex flex-wrap gap-6">
|
||||
<div>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{t.packages.dailyQuota
|
||||
.replace('{used}', String(quota.used))
|
||||
.replace('{limit}', String(quota.dailyLimit))}
|
||||
</span>
|
||||
) : t.packages.buy}
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{t.packages.extraQuota.replace('{n}', String(quota.extra))}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
)}
|
||||
|
||||
<div className="mt-8 text-center">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t.packages.orUpgrade}{' '}
|
||||
<Link href="/my/member" className="text-brand-600 hover:underline">{t.member.title}</Link>
|
||||
</p>
|
||||
</div>
|
||||
<Card className="p-8 mb-8 bg-gradient-to-br from-rose-50 to-white dark:from-rose-950/20 dark:to-background text-center">
|
||||
<div className="inline-flex w-12 h-12 bg-rose-100 dark:bg-rose-900/30 rounded-2xl items-center justify-center mb-4">
|
||||
<Heart className="w-6 h-6 text-rose-600 dark:text-rose-400" />
|
||||
</div>
|
||||
<h2 className="text-xl font-semibold text-foreground">{t.donate.supportTitle}</h2>
|
||||
<p className="mt-2 text-muted-foreground max-w-xl mx-auto">{t.donate.supportDesc}</p>
|
||||
<Button asChild className="mt-5 gap-2">
|
||||
<Link href="/donate">
|
||||
<Heart className="w-4 h-4" />
|
||||
{t.donate.toDonate}
|
||||
</Link>
|
||||
</Button>
|
||||
</Card>
|
||||
|
||||
<PaymentModal
|
||||
open={paymentModal.open}
|
||||
orderNo={paymentModal.orderNo}
|
||||
payResult={paymentModal.payResult}
|
||||
payChannel={paymentModal.payChannel}
|
||||
onClose={() => setPaymentModal(prev => ({ ...prev, open: false }))}
|
||||
onPaid={handlePaymentPaid}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground text-center max-w-2xl mx-auto">
|
||||
{t.donate.disclaimer}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -772,29 +772,26 @@ function SandboxPage() {
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
|
||||
<div className="border-t border-border p-4">
|
||||
{quota && quota.totalRemaining <= 0 ? (
|
||||
<div className="bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-xl p-3 mb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="text-sm font-medium text-amber-800 dark:text-amber-300">{t.sandbox.quotaExhausted}</div>
|
||||
<div className="text-xs text-amber-600 dark:text-amber-400 mt-0.5">{t.packages.buyMore}</div>
|
||||
<div className="border-t border-border p-4">
|
||||
{quota && quota.totalRemaining <= 0 ? (
|
||||
<div className="bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-xl p-3 mb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="text-sm font-medium text-amber-800 dark:text-amber-300">{t.sandbox.quotaExhausted}</div>
|
||||
<div className="text-xs text-amber-600 dark:text-amber-400 mt-0.5">{t.sandbox.quotaExhaustedHint}</div>
|
||||
</div>
|
||||
<Link href="/donate"
|
||||
className="px-3 py-1.5 text-xs font-medium bg-rose-600 text-white rounded-lg hover:bg-rose-700 shrink-0">
|
||||
{t.donate.toDonate}
|
||||
</Link>
|
||||
</div>
|
||||
<Link href="/practices/packages"
|
||||
className="px-3 py-1.5 text-xs font-medium bg-brand-600 text-white rounded-lg hover:bg-brand-700 shrink-0">
|
||||
{t.packages.title}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
) : quota && (
|
||||
<div className="text-xs text-muted-foreground mb-2 flex items-center gap-3">
|
||||
<span>{t.sandbox.dailyQuota.replace('{used}', String(quota.used)).replace('{remaining}', String(quota.dailyRemaining))}</span>
|
||||
{quota.extra > 0 && <span className="text-brand-600">{t.packages.extraQuota.replace('{n}', String(quota.extra))}</span>}
|
||||
{quota.dailyRemaining <= 0 && quota.extra > 0 && (
|
||||
<Link href="/practices/packages" className="text-brand-600 hover:underline text-[10px]">{t.packages.buyMore}</Link>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
) : quota && (
|
||||
<div className="text-xs text-muted-foreground mb-2 flex items-center gap-3">
|
||||
<span>{t.sandbox.dailyQuota.replace('{used}', String(quota.used)).replace('{remaining}', String(quota.dailyRemaining))}</span>
|
||||
{quota.extra > 0 && <span className="text-brand-600">{t.packages.extraQuota.replace('{n}', String(quota.extra))}</span>}
|
||||
</div>
|
||||
)}
|
||||
{uploadedImages.length > 0 && (
|
||||
<div className="flex gap-2 mb-2 flex-wrap">
|
||||
{uploadedImages.map((img, i) => (
|
||||
|
||||
Reference in New Issue
Block a user