00a3904eba
- 新增 Donation 模型/迁移/后端 API(GET,POST /donations) - 新增前端 /donate 页:收款码+感谢留言+感谢墙 - 会员页/技能广场/用量包 下架付费,改免费开放+赞助入口 - 沙箱用量耗尽引导至 /donate 赞助 - 导航与页脚新增 赞助/联盟返佣 入口,补充 i18n
123 lines
4.6 KiB
TypeScript
123 lines
4.6 KiB
TypeScript
'use client';
|
|
|
|
import { useEffect, useState } from 'react';
|
|
import Link from 'next/link';
|
|
import { Skeleton } from '@/components/ui/skeleton';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Badge } from '@/components/ui/badge';
|
|
import { useT } from '@/i18n';
|
|
import { API_BASE } from '@/lib/config';
|
|
import { ShoppingBag, CheckCircle, Heart } from 'lucide-react';
|
|
|
|
interface MarketplaceSkill {
|
|
id: string; name: string; description: string; icon: string;
|
|
category: string; difficulty: string; tags: string[];
|
|
price: number | null; purchased: boolean; sortOrder: number;
|
|
}
|
|
|
|
export default function MarketplacePage() {
|
|
const t = useT();
|
|
const [skills, setSkills] = useState<MarketplaceSkill[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
|
|
useEffect(() => {
|
|
const url = `${API_BASE}/skills/marketplace`;
|
|
fetch(url, { credentials: 'include' })
|
|
.then((r) => r.json())
|
|
.then((data) => {
|
|
setSkills(Array.isArray(data) ? data : []);
|
|
})
|
|
.catch(() => {})
|
|
.finally(() => setLoading(false));
|
|
}, []);
|
|
|
|
return (
|
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
|
|
<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 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" />
|
|
))}
|
|
</div>
|
|
) : (
|
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
|
{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 && 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>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|