feat: 落地页改造 + 集章弹窗全状态品牌/奖品说明

- 落地页:顶部改为活动海报,底部替换为「活动规则」5 条编号列表
- 集章收集弹窗 (StampPopup):新增奖品规则卡片展示 Prize 信息
- 集章册 (AlbumPage / StampGrid):所有状态图章均可点击查看详情
- 兑换弹窗 (RedeemModal):新增 uncollected 分支,统一承载未收集/
  已集齐/已兑换三种状态;新增可选品牌说明区
- 后端 /api/stamps/:id 补充返回 prize 字段
- 管理后台字段标签改名:备注 → 品牌说明;奖品描述 → 奖品说明
- 新增一次性脚本 update-brand-rules,批量写入 16 条品牌权益文案

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-20 17:16:50 +08:00
parent 394b643304
commit 2c179cd19a
11 changed files with 296 additions and 161 deletions

View File

@@ -10,7 +10,8 @@
"db:push": "prisma db push", "db:push": "prisma db push",
"db:seed": "pnpm --filter @stamp/server seed", "db:seed": "pnpm --filter @stamp/server seed",
"db:seed-articles": "pnpm --filter @stamp/server seed-articles", "db:seed-articles": "pnpm --filter @stamp/server seed-articles",
"db:seed-music": "pnpm --filter @stamp/server seed-music" "db:seed-music": "pnpm --filter @stamp/server seed-music",
"db:update-brand-rules": "pnpm --filter @stamp/server update-brand-rules"
}, },
"engines": { "engines": {
"node": ">=20" "node": ">=20"

View File

@@ -9,7 +9,8 @@
"start": "node dist/index.js", "start": "node dist/index.js",
"seed": "tsx src/seed.ts", "seed": "tsx src/seed.ts",
"seed-articles": "tsx src/seed-articles.ts", "seed-articles": "tsx src/seed-articles.ts",
"seed-music": "tsx src/seed-music.ts" "seed-music": "tsx src/seed-music.ts",
"update-brand-rules": "tsx src/scripts/update-brand-rules.ts"
}, },
"dependencies": { "dependencies": {
"@stamp/shared": "workspace:*", "@stamp/shared": "workspace:*",

View File

@@ -54,7 +54,10 @@ router.get("/", optionalAuth, async (req, res) => {
}); });
router.get("/:id", async (req, res) => { router.get("/:id", async (req, res) => {
const stamp = await prisma.stamp.findUnique({ where: { id: req.params.id } }); const stamp = await prisma.stamp.findUnique({
where: { id: req.params.id },
include: { prize: true },
});
if (!stamp) { if (!stamp) {
res.status(404).json({ success: false, error: { code: "NOT_FOUND", message: "图章不存在" } }); res.status(404).json({ success: false, error: { code: "NOT_FOUND", message: "图章不存在" } });
return; return;
@@ -68,6 +71,15 @@ router.get("/:id", async (req, res) => {
imageColor: stamp.imageColor, imageColor: stamp.imageColor,
imageGrey: stamp.imageGrey, imageGrey: stamp.imageGrey,
sortOrder: stamp.sortOrder, sortOrder: stamp.sortOrder,
prize: stamp.prize
? {
id: stamp.prize.id,
name: stamp.prize.name,
description: stamp.prize.description,
stock: stamp.prize.stock,
enabled: stamp.prize.enabled,
}
: null,
}, },
}); });
}); });

View File

@@ -0,0 +1,125 @@
import { prisma } from "@stamp/shared";
type Rule = { matchKey: string; canonicalName: string; description: string };
const RULES: Rule[] = [
{ matchKey: "孟令军", canonicalName: "孟令军炒货",
description: "进店消费 19.9 元,可赠送人气爆款谷物锅巴 1 份。" },
{ matchKey: "春山", canonicalName: "春山酒窖",
description: "全场产品 85 折优惠。" },
{ matchKey: "金陵绣男", canonicalName: "金陵绣男",
description: "全场产品 8 折优惠。" },
{ matchKey: "陶玉梅", canonicalName: "南京陶玉梅服饰(梦幻城店)",
description: "可获得「非遗宋锦书签制作」体验券 1 张。" },
{ matchKey: "LBZ", canonicalName: "LBZ 量不准咖啡",
description: "「三元巷」定制咖啡 8 折优惠。" },
{ matchKey: "芳婆", canonicalName: "芳婆糕团",
description: "单笔消费满 10 元,即赠糕点 1 块,每日限量 66 份,送完即止。" },
{ matchKey: "紫金", canonicalName: "紫金农商银行秦淮支行",
description: "到店拍照打卡即可获得「南京市民俗(非遗)博物馆 甘家大院」参观券 1 张。" },
{ matchKey: "尹氏", canonicalName: "尹氏汤包",
description: "可获得「亲子家庭汤包制作活动」体验券 1 张。" },
{ matchKey: "闲鱼", canonicalName: "闲鱼循环商店",
description: "到店参与寄卖服务,即可加盖闲鱼文创纪念章。" },
{ matchKey: "闽南", canonicalName: "闽南茶叶店",
description: "1. 全店茶叶产品 85 折优惠2. 可获得「茶分享和体验活动」体验券 1 张。" },
{ matchKey: "移动", canonicalName: "南京移动朝天宫双塘分局",
description: "1. 免费手机贴膜 1 次仅限直面屏手机2. 免费领取 80G 流量体验卡 1 张3. 购买 AI 手机返 100 元话费。" },
{ matchKey: "二条", canonicalName: "二条商店",
description: "全店当日单次实付满 380 元送二条原创小鲍挂件 1 个。" },
{ matchKey: "书", canonicalName: "锦创书城",
description: "1. 图书与咖啡产品享 7 折,文创产品享 8 折2. 到店打卡赠送定制书签 1 枚3. 可获得书城线下主题活动体验券 1 张。" },
{ matchKey: "魏", canonicalName: "魏虾神",
description: "到店就餐即赠奶皮子酸奶酪 1 份。" },
{ matchKey: "李记", canonicalName: "李记清真馆",
description: "可获得「亲子包锅贴体验活动」体验券 1 张。" },
{ matchKey: "农家", canonicalName: "农家小院",
description: "(小红书打卡、朋友圈转发)可享菜品 8 折优惠。" },
];
async function main() {
const apply = process.argv.includes("--apply");
const stamps = await prisma.stamp.findMany({ include: { prize: true } });
console.log(`Found ${stamps.length} stamps in DB.\n`);
console.log("Current stamp names:");
stamps.forEach((s) => console.log(` - ${s.name}`));
console.log();
type Entry = { stampId: string; old: string; next: string; desc: string };
const plan: Entry[] = [];
const issues: string[] = [];
const usedStampIds = new Set<string>();
for (const rule of RULES) {
const hits = stamps.filter((s) => s.name.includes(rule.matchKey) && !usedStampIds.has(s.id));
if (hits.length === 0) {
issues.push(`❌ "${rule.matchKey}" → 0 matches (target: ${rule.canonicalName})`);
continue;
}
if (hits.length > 1) {
issues.push(
`⚠️ "${rule.matchKey}" → ${hits.length} matches (${hits.map((h) => h.name).join(", ")}) — 使用更精确的 matchKey`,
);
continue;
}
const stamp = hits[0];
usedStampIds.add(stamp.id);
plan.push({ stampId: stamp.id, old: stamp.name, next: rule.canonicalName, desc: rule.description });
}
console.log("=== Match plan ===");
plan.forEach((p) => {
const rename = p.old !== p.next ? ` [RENAME]` : ``;
console.log(` "${p.old}"${rename}`);
console.log(` ⟶ "${p.next}"`);
console.log(` ${p.desc}\n`);
});
if (issues.length) {
console.log("=== Issues ===");
issues.forEach((i) => console.log(` ${i}`));
console.log("\n请修正 matchKey 后重试。");
process.exit(1);
}
const leftover = stamps.filter((s) => !usedStampIds.has(s.id));
if (leftover.length) {
console.log(`=== 未被映射覆盖的图章(保持不变) ===`);
leftover.forEach((s) => console.log(` - ${s.name}`));
console.log();
}
if (!apply) {
console.log(`DRY RUN — 不写库。传入 --apply 执行写入。`);
return;
}
console.log(`正在写入 ${plan.length} 条...\n`);
for (const p of plan) {
await prisma.$transaction(async (tx) => {
if (p.old !== p.next) {
await tx.stamp.update({ where: { id: p.stampId }, data: { name: p.next } });
}
await tx.prize.upsert({
where: { stampId: p.stampId },
create: {
stampId: p.stampId,
name: `${p.next} · 品牌权益`,
description: p.desc,
stock: 100,
enabled: true,
},
update: { description: p.desc },
});
});
console.log(`${p.next}`);
}
console.log(`\n完成。已更新 ${plan.length} 枚图章。`);
}
main()
.catch((e) => {
console.error(e);
process.exit(1);
})
.finally(() => prisma.$disconnect());

Binary file not shown.

After

Width:  |  Height:  |  Size: 125 KiB

View File

@@ -177,12 +177,12 @@ export default function StampForm({ open, id, onClose, onSaved }: Props) {
/> />
</Field> </Field>
<Field label="备注"> <Field label="品牌说明" hint="选填,展示在收集弹窗与集章册详情中">
<textarea <textarea
value={note} value={note}
onChange={(e) => setNote(e.target.value)} onChange={(e) => setNote(e.target.value)}
rows={2} rows={2}
placeholder="选填" placeholder="例:品牌定位、特色亮点一句话"
className={fieldCls + " resize-none"} className={fieldCls + " resize-none"}
/> />
</Field> </Field>
@@ -234,12 +234,12 @@ export default function StampForm({ open, id, onClose, onSaved }: Props) {
/> />
</Field> </Field>
<Field label="奖品描述"> <Field label="奖品说明" hint="展示在收集弹窗与兑换页的规则文案">
<textarea <textarea
value={prizeDescription} value={prizeDescription}
onChange={(e) => setPrizeDescription(e.target.value)} onChange={(e) => setPrizeDescription(e.target.value)}
rows={2} rows={3}
placeholder="选填,展示在用户兑换页" placeholder="例:进店消费 19.9 元,可赠送人气爆款谷物锅巴 1 份。"
className={fieldCls + " resize-none"} className={fieldCls + " resize-none"}
/> />
</Field> </Field>

View File

@@ -9,9 +9,10 @@ type RedeemModalProps = {
const CONFIRM_COUNTDOWN = 5; const CONFIRM_COUNTDOWN = 5;
type Mode = "redeemed" | "sold-out" | "unavailable" | "ready"; type Mode = "uncollected" | "redeemed" | "sold-out" | "unavailable" | "ready";
function resolveMode(stamp: StampWithStatus): Mode { function resolveMode(stamp: StampWithStatus): Mode {
if (!stamp.collected) return "uncollected";
if (stamp.redeemed) return "redeemed"; if (stamp.redeemed) return "redeemed";
if (!stamp.prize || !stamp.prize.enabled) return "unavailable"; if (!stamp.prize || !stamp.prize.enabled) return "unavailable";
if (stamp.prize.stock <= 0) return "sold-out"; if (stamp.prize.stock <= 0) return "sold-out";
@@ -70,6 +71,8 @@ export default function RedeemModal({ stamp, onRedeem, onClose }: RedeemModalPro
const buttonCopy = () => { const buttonCopy = () => {
switch (mode) { switch (mode) {
case "uncollected":
return "前往点位收集";
case "redeemed": case "redeemed":
return "已兑换"; return "已兑换";
case "sold-out": case "sold-out":
@@ -96,7 +99,9 @@ export default function RedeemModal({ stamp, onRedeem, onClose }: RedeemModalPro
> >
<div className="w-full max-w-lg bg-[var(--bg-cream)] rounded-t-2xl p-6 pb-10 animate-slide-up safe-bottom relative overflow-hidden"> <div className="w-full max-w-lg bg-[var(--bg-cream)] rounded-t-2xl p-6 pb-10 animate-slide-up safe-bottom relative overflow-hidden">
<div className="flex items-center justify-between mb-5"> <div className="flex items-center justify-between mb-5">
<h3 className="text-lg font-semibold text-[var(--text-primary)]"></h3> <h3 className="text-lg font-semibold text-[var(--text-primary)]">
{mode === "uncollected" ? "品牌权益" : "兑换奖品"}
</h3>
<button onClick={onClose} className="text-[var(--text-muted)] p-1"> <button onClick={onClose} className="text-[var(--text-muted)] p-1">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"> <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M18 6L6 18M6 6l12 12" /> <path d="M18 6L6 18M6 6l12 12" />
@@ -108,20 +113,39 @@ export default function RedeemModal({ stamp, onRedeem, onClose }: RedeemModalPro
<div className="flex items-center gap-4 mb-5"> <div className="flex items-center gap-4 mb-5">
<div <div
className="w-16 h-16 rounded-full bg-white overflow-hidden flex items-center justify-center border border-[var(--border-muted)] shrink-0" className="w-16 h-16 rounded-full bg-white overflow-hidden flex items-center justify-center border border-[var(--border-muted)] shrink-0"
style={{ boxShadow: "0 2px 6px rgba(212,165,116,0.25), inset 0 0 0 1px rgba(212,165,116,0.15)" }} style={{
boxShadow: stamp.collected
? "0 2px 6px rgba(212,165,116,0.25), inset 0 0 0 1px rgba(212,165,116,0.15)"
: "0 1px 3px rgba(0,0,0,0.05)",
}}
> >
<img src={stamp.imageColor} alt={stamp.name} className="w-[92%] h-[92%] object-contain" /> <img
src={stamp.collected ? stamp.imageColor : stamp.imageGrey}
alt={stamp.name}
className="w-[92%] h-[92%] object-contain"
style={{ opacity: stamp.collected ? 1 : 0.6 }}
/>
</div> </div>
<div className="min-w-0"> <div className="min-w-0">
<p className="text-[10px] tracking-[0.25em] uppercase text-[var(--gold)] mb-0.5">Stamp</p> <p className="text-[10px] tracking-[0.25em] uppercase text-[var(--gold)] mb-0.5">Stamp</p>
<p className="text-base font-semibold text-[var(--text-primary)] truncate">{stamp.name}</p> <p className="text-base font-semibold text-[var(--text-primary)] truncate">{stamp.name}</p>
{stamp.collectedAt && ( {mode === "uncollected" ? (
<p className="text-xs text-[var(--text-muted)] mt-0.5"></p>
) : stamp.collectedAt ? (
<p className="text-xs text-[var(--text-muted)] mt-0.5"> <p className="text-xs text-[var(--text-muted)] mt-0.5">
{new Date(stamp.collectedAt).toLocaleDateString("zh-CN")} {new Date(stamp.collectedAt).toLocaleDateString("zh-CN")}
</p> </p>
) : null}
</div>
</div>
{/* Brand description (optional) */}
{stamp.note && (
<div className="rounded-xl border border-[var(--border-muted)] bg-white p-3.5 mb-3">
<p className="text-[10px] tracking-[0.2em] text-[var(--gold)] uppercase mb-1">Brand</p>
<p className="text-xs text-[var(--text-secondary)] leading-relaxed">{stamp.note}</p>
</div>
)} )}
</div>
</div>
{/* Prize card */} {/* Prize card */}
{prize ? ( {prize ? (
@@ -148,6 +172,11 @@ export default function RedeemModal({ stamp, onRedeem, onClose }: RedeemModalPro
</div> </div>
)} )}
{mode === "uncollected" && (
<p className="text-xs text-[var(--text-muted)] text-center mb-4 leading-relaxed">
线 NFC
</p>
)}
{mode === "redeemed" && ( {mode === "redeemed" && (
<p className="text-xs text-[var(--text-muted)] text-center mb-4"></p> <p className="text-xs text-[var(--text-muted)] text-center mb-4"></p>
)} )}

View File

@@ -17,7 +17,7 @@ export default function StampGrid({ stamps, onStampClick }: StampGridProps) {
imageGrey={stamp.imageGrey} imageGrey={stamp.imageGrey}
collected={stamp.collected} collected={stamp.collected}
redeemed={stamp.redeemed} redeemed={stamp.redeemed}
onClick={stamp.collected ? () => onStampClick?.(stamp) : undefined} onClick={() => onStampClick?.(stamp)}
/> />
))} ))}
</div> </div>

View File

@@ -1,26 +1,28 @@
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import type { PrizeInfo } from "@stamp/shared";
type StampPopupProps = { type StampPopupProps = {
name: string; name: string;
imageColor: string; imageColor: string;
note?: string | null; note?: string | null;
prize?: PrizeInfo | null;
status: "preview" | "collected" | "already"; status: "preview" | "collected" | "already";
onCollect?: () => void; onCollect?: () => void;
onClose: () => void; onClose: () => void;
}; };
export default function StampPopup({ name, imageColor, note, status, onCollect, onClose }: StampPopupProps) { export default function StampPopup({ name, imageColor, note, prize, status, onCollect, onClose }: StampPopupProps) {
const navigate = useNavigate(); const navigate = useNavigate();
return ( return (
<div <div
className="fixed inset-0 z-50 flex items-center justify-center animate-overlay-fade" className="fixed inset-0 z-50 flex items-center justify-center animate-overlay-fade px-5"
style={{ backgroundColor: "var(--overlay)" }} style={{ backgroundColor: "var(--overlay)" }}
onClick={(e) => e.target === e.currentTarget && onClose()} onClick={(e) => e.target === e.currentTarget && onClose()}
> >
<div className="w-72 bg-[var(--bg-cream)] rounded-2xl p-6 text-center shadow-[var(--shadow-lg)]"> <div className="w-full max-w-xs bg-[var(--bg-cream)] rounded-2xl p-6 text-center shadow-[var(--shadow-lg)] max-h-[90vh] overflow-y-auto">
{/* Stamp image */} {/* Stamp image */}
<div className="w-40 h-40 mx-auto mb-4"> <div className="w-36 h-36 mx-auto mb-4">
<div <div
className="w-full h-full rounded-full bg-white overflow-hidden flex items-center justify-center border border-[var(--border-muted)] animate-stamp-press" className="w-full h-full rounded-full bg-white overflow-hidden flex items-center justify-center border border-[var(--border-muted)] animate-stamp-press"
style={{ style={{
@@ -44,13 +46,24 @@ export default function StampPopup({ name, imageColor, note, status, onCollect,
{/* Stamp name */} {/* Stamp name */}
<h3 className="text-xl font-semibold text-[var(--text-primary)] mb-1">{name}</h3> <h3 className="text-xl font-semibold text-[var(--text-primary)] mb-1">{name}</h3>
{note && <p className="text-xs text-[var(--text-muted)] mb-4">{note}</p>} {note && <p className="text-xs text-[var(--text-muted)] mb-3 leading-relaxed">{note}</p>}
{/* Prize rule (preview only) */}
{status === "preview" && prize && (
<div className="mt-3 mb-1 rounded-xl border border-[var(--jade)] bg-[rgba(45,106,79,0.05)] p-3.5 text-left">
<p className="text-[10px] tracking-[0.2em] text-[var(--jade)] uppercase mb-1">Reward</p>
<p className="text-sm font-semibold text-[var(--text-primary)]">{prize.name}</p>
{prize.description && (
<p className="text-xs text-[var(--text-secondary)] mt-1 leading-relaxed">{prize.description}</p>
)}
</div>
)}
{/* Status message & action */} {/* Status message & action */}
{status === "preview" && ( {status === "preview" && (
<button <button
onClick={onCollect} onClick={onCollect}
className="w-full py-3 rounded-xl text-sm font-medium text-white mt-2" className="w-full py-3 rounded-xl text-sm font-medium text-white mt-4"
style={{ backgroundColor: "var(--terracotta)" }} style={{ backgroundColor: "var(--terracotta)" }}
> >

View File

@@ -55,7 +55,6 @@ export default function AlbumPage() {
setShowRegister(true); setShowRegister(true);
return; return;
} }
if (!stamp.collected) return;
setSelectedStampId(stamp.id); setSelectedStampId(stamp.id);
}; };
@@ -107,11 +106,9 @@ export default function AlbumPage() {
style={{ width: stamps.length ? `${(collectedCount / stamps.length) * 100}%` : "0%" }} style={{ width: stamps.length ? `${(collectedCount / stamps.length) * 100}%` : "0%" }}
/> />
</div> </div>
{collectedCount > 0 && (
<p className="mt-3 text-[11px] text-[var(--text-muted)] leading-relaxed"> <p className="mt-3 text-[11px] text-[var(--text-muted)] leading-relaxed">
</p> </p>
)}
</div> </div>
{/* Stamp Grid */} {/* Stamp Grid */}

View File

@@ -1,5 +1,7 @@
import { useState, useEffect, useCallback } from "react"; import { useState, useEffect, useCallback } from "react";
import type { ReactNode } from "react";
import { useSearchParams, useNavigate } from "react-router-dom"; import { useSearchParams, useNavigate } from "react-router-dom";
import type { PrizeInfo } from "@stamp/shared";
import { apiFetch } from "../lib/api"; import { apiFetch } from "../lib/api";
import { useAuth } from "../lib/auth"; import { useAuth } from "../lib/auth";
import FloatingButton from "../components/FloatingButton"; import FloatingButton from "../components/FloatingButton";
@@ -14,14 +16,49 @@ type StampDetail = {
note: string | null; note: string | null;
imageColor: string; imageColor: string;
imageGrey: string; imageGrey: string;
prize: PrizeInfo | null;
}; };
type CollectState = "idle" | "loading" | "show_stamp" | "needs_register" | "collecting" | "collected" | "already_collected"; type CollectState = "idle" | "loading" | "show_stamp" | "needs_register" | "collecting" | "collected" | "already_collected";
const STEPS = [ const RULES: { num: string; title: string; desc: ReactNode }[] = [
{ num: "01", title: "前往点位", desc: "跟随路线,探索散落在城市中的文化地标" }, {
{ num: "02", title: "触碰集章", desc: "手机轻触点位 NFC 芯片,图章即刻收入囊中" }, num: "01",
{ num: "03", title: "兑换好礼", desc: "集满图章,兑换城市限定纪念品" }, title: "去朝天宫读城",
desc: "活动期间,用户可在朝天宫街道辖区范围内自由探索,拍摄美食美景,记录你眼中的城南烟火气——红墙下的光影、打钉巷里热腾腾的锅贴、南台巷排队的咖啡店,街角一只晒太阳的猫……",
},
{
num: "02",
title: "线上打卡",
desc: (
<>
{" "}
<span className="text-[var(--terracotta)] font-medium">#</span>
{" "}
<span className="text-[var(--terracotta)] font-medium">#</span>
{" "}线
</>
),
},
{
num: "03",
title: "线下打卡",
desc: '前往任意一家门口贴有"读城窗口"标志的小店,进店后找到活动立牌,触碰 NFC 热点,在对应网页中完成线下打卡。',
},
{
num: "04",
title: "解锁权益",
desc: "用户完成线上打卡、线下打卡后,即可在小店解锁活动权益。活动期间每个账号在同一小店仅享受 1 次权益,权益现场核销,不重复享受,名额有限,先到先得。",
},
{
num: "05",
title: "活动时间",
desc: (
<span className="text-[var(--terracotta)] font-medium">
2026 4 21 2026 5 21
</span>
),
},
]; ];
export default function LandingPage() { export default function LandingPage() {
@@ -100,149 +137,66 @@ export default function LandingPage() {
const showRegister = collectState === "needs_register"; const showRegister = collectState === "needs_register";
return ( return (
<div className="grain-overlay"> <div className="min-h-svh bg-[var(--bg-cream)]">
{/* ═══════════ HERO ═══════════ */} {/* ═══════════ POSTER ═══════════ */}
<section className="relative min-h-svh flex flex-col items-center justify-center overflow-hidden"> <section className="relative w-full">
<div className="absolute inset-0 bg-[var(--bg-dark-deep)]" /> <img
<div src="/poster.jpg"
className="absolute inset-0" alt="读城·行走朝天宫"
style={{ className="block w-full h-auto select-none"
backgroundImage: ` draggable={false}
radial-gradient(ellipse 60% 50% at 50% 40%, rgba(212, 165, 116, 0.08) 0%, transparent 100%),
radial-gradient(circle at 15% 80%, rgba(199, 91, 57, 0.06) 0%, transparent 50%),
radial-gradient(circle at 85% 20%, rgba(45, 106, 79, 0.04) 0%, transparent 40%)
`,
}}
/> />
<div
className="absolute inset-0 opacity-[0.03]"
style={{
backgroundImage: `
linear-gradient(rgba(212, 165, 116, 0.5) 1px, transparent 1px),
linear-gradient(90deg, rgba(212, 165, 116, 0.5) 1px, transparent 1px)
`,
backgroundSize: "60px 60px",
}}
/>
<div className="relative z-10 text-center px-8 flex flex-col items-center">
<div className="animate-fade-in mb-8" style={{ animationDelay: "0.2s" }}>
<div className="inline-flex items-center gap-3">
<span className="block w-8 h-px bg-[var(--gold)]/40" />
<span className="text-[var(--gold)] text-[11px] tracking-[0.4em] uppercase"
style={{ fontFamily: "'Playfair Display', serif" }}>
CityWalk
</span>
<span className="block w-8 h-px bg-[var(--gold)]/40" />
</div>
</div>
<h1 className="animate-fade-in-up text-[var(--text-inverted)] leading-none mb-6"
style={{
animationDelay: "0.4s",
fontSize: "clamp(3rem, 12vw, 4.5rem)",
fontFamily: "'Playfair Display', serif",
fontWeight: 700,
letterSpacing: "-0.02em",
}}>
</h1>
<p className="animate-fade-in-up text-[var(--gold-light)]/70 text-sm leading-relaxed max-w-[260px]"
style={{ animationDelay: "0.6s", letterSpacing: "0.08em" }}>
<br />
</p>
<div className="animate-scale-in mt-14" style={{ animationDelay: "0.9s" }}>
<div className="stamp-seal w-[100px] h-[100px] animate-float">
<div className="w-[100px] h-[100px] rounded-full flex items-center justify-center"
style={{
background: "radial-gradient(circle, rgba(212, 165, 116, 0.12) 0%, rgba(212, 165, 116, 0.02) 70%)",
border: "1.5px solid rgba(212, 165, 116, 0.2)",
}}>
<div className="text-center">
<div className="text-[var(--gold)] text-[10px] tracking-[0.2em] uppercase opacity-60">Stamp</div>
<div className="text-[var(--gold)] text-2xl mt-0.5 opacity-80"
style={{ fontFamily: "'Playfair Display', serif" }}>9</div>
<div className="text-[var(--gold)] text-[9px] tracking-[0.15em] uppercase opacity-50">Collect</div>
</div>
</div>
</div>
</div>
<div className="animate-fade-in mt-16" style={{ animationDelay: "1.4s" }}>
<div className="flex flex-col items-center gap-2">
<span className="text-[var(--gold)]/30 text-[10px] tracking-[0.3em] uppercase"></span>
<div className="w-px h-8 bg-gradient-to-b from-[var(--gold)]/30 to-transparent" />
</div>
</div>
</div>
</section> </section>
{/* ═══════════ ABOUT ═══════════ */} {/* ═══════════ RULES ═══════════ */}
<section className="relative bg-[var(--bg-dark)] py-20 px-6 overflow-hidden"> <section className="relative paper-texture px-6 py-14 pb-32">
<div className="absolute top-0 left-0 right-0 h-px bg-gradient-to-r from-transparent via-[var(--gold)]/20 to-transparent" /> <div className="relative z-10 max-w-sm mx-auto">
<div className="max-w-sm mx-auto">
<div className="flex items-center gap-3 mb-8 animate-fade-in-up">
<span className="block w-6 h-px bg-[var(--gold)]/40" />
<span className="text-[var(--gold)]/50 text-[10px] tracking-[0.3em] uppercase">About</span>
</div>
<h2 className="text-[var(--text-inverted)] text-2xl leading-snug mb-6 animate-fade-in-up"
style={{ fontFamily: "'Playfair Display', serif", animationDelay: "0.1s" }}>
<br /><span className="text-[var(--gold)]"></span>
</h2>
<p className="text-[var(--text-inverted)]/50 text-sm leading-[1.9] animate-fade-in-up"
style={{ animationDelay: "0.2s" }}>
穿
</p>
<div className="ornament-line mt-10" />
<div className="mt-10 grid grid-cols-3 gap-4 stagger-children">
{[
{ num: "9", label: "城市坐标" },
{ num: "4", label: "限定好礼" },
{ num: "∞", label: "重复挑战" },
].map((item) => (
<div key={item.label} className="text-center">
<div className="text-[var(--gold)] text-3xl mb-1.5"
style={{ fontFamily: "'Playfair Display', serif" }}>{item.num}</div>
<div className="text-[var(--text-inverted)]/35 text-[11px] tracking-wider">{item.label}</div>
</div>
))}
</div>
</div>
</section>
{/* ═══════════ HOW IT WORKS ═══════════ */}
<section className="relative paper-texture py-20 px-6 pb-32">
<div className="relative z-10 max-w-sm mx-auto pt-4">
<div className="flex items-center gap-3 mb-3"> <div className="flex items-center gap-3 mb-3">
<span className="block w-6 h-px bg-[var(--text-primary)]/20" /> <span className="block w-6 h-px bg-[var(--text-primary)]/20" />
<span className="text-[var(--text-muted)] text-[10px] tracking-[0.3em] uppercase">How it works</span> <span className="text-[var(--text-muted)] text-[10px] tracking-[0.3em] uppercase">
</div> Rules
<h2 className="text-[var(--text-primary)] text-2xl leading-snug mb-12"
style={{ fontFamily: "'Playfair Display', serif" }}>
</h2>
<div className="space-y-0 stagger-children">
{STEPS.map((step, i) => (
<div key={step.num} className="relative flex gap-5">
<div className="flex flex-col items-center shrink-0">
<div className="w-11 h-11 rounded-full border-2 flex items-center justify-center"
style={{ borderColor: "var(--gold)", background: "rgba(212, 165, 116, 0.06)" }}>
<span className="text-[var(--gold)] text-xs"
style={{ fontFamily: "'Playfair Display', serif", fontWeight: 600 }}>
{step.num}
</span> </span>
</div> </div>
{i < STEPS.length - 1 && <div className="w-px flex-1 min-h-[40px] bg-[var(--gold)]/20" />} <h2
className="text-[var(--text-primary)] text-2xl leading-snug mb-10"
style={{ fontFamily: "'Playfair Display', serif" }}
>
</h2>
<ol className="space-y-0 stagger-children">
{RULES.map((rule, i) => (
<li key={rule.num} className="relative flex gap-5">
<div className="flex flex-col items-center shrink-0">
<div
className="w-11 h-11 rounded-full border-2 flex items-center justify-center"
style={{
borderColor: "var(--gold)",
background: "rgba(212, 165, 116, 0.06)",
}}
>
<span
className="text-[var(--gold)] text-xs"
style={{ fontFamily: "'Playfair Display', serif", fontWeight: 600 }}
>
{rule.num}
</span>
</div> </div>
<div className="pb-10 pt-1.5"> {i < RULES.length - 1 && (
<h3 className="text-base font-semibold text-[var(--text-primary)] mb-1">{step.title}</h3> <div className="w-px flex-1 min-h-[40px] bg-[var(--gold)]/20" />
<p className="text-sm text-[var(--text-secondary)] leading-relaxed">{step.desc}</p> )}
</div> </div>
<div className="pb-8 pt-1.5">
<h3 className="text-base font-semibold text-[var(--text-primary)] mb-1.5">
{rule.title}
</h3>
<p className="text-sm text-[var(--text-secondary)] leading-[1.9]">
{rule.desc}
</p>
</div> </div>
</li>
))} ))}
</div> </ol>
</div> </div>
</section> </section>
@@ -254,6 +208,7 @@ export default function LandingPage() {
name={stamp.name} name={stamp.name}
imageColor={stamp.imageColor} imageColor={stamp.imageColor}
note={stamp.note} note={stamp.note}
prize={stamp.prize}
status="preview" status="preview"
onCollect={handleCollect} onCollect={handleCollect}
onClose={handleClose} onClose={handleClose}
@@ -264,6 +219,7 @@ export default function LandingPage() {
name={stamp.name} name={stamp.name}
imageColor={stamp.imageColor} imageColor={stamp.imageColor}
note={stamp.note} note={stamp.note}
prize={stamp.prize}
status="collected" status="collected"
onClose={handleClose} onClose={handleClose}
/> />
@@ -273,6 +229,7 @@ export default function LandingPage() {
name={stamp.name} name={stamp.name}
imageColor={stamp.imageColor} imageColor={stamp.imageColor}
note={stamp.note} note={stamp.note}
prize={stamp.prize}
status="already" status="already"
onClose={handleClose} onClose={handleClose}
/> />