Compare commits

..

7 Commits

Author SHA1 Message Date
f87d16021e feat: 移动端奖品卡片恢复库存显示与奖品名称
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-22 20:18:04 +08:00
3b3878ea5c refactor: 奖品命名去除"纪念章"字样
奖品不一定是纪念章(可能是实物、折扣券、体验券等),
统一改为"XX · 专属奖品"。新增一次性数据清理脚本,
同步改写存量 Prize 与 Redemption 历史快照。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-20 22:10:38 +08:00
f84815611d feat: dev 2026-04-20 18:06:35 +08:00
f2c71ff91a feat: 新增视频播放页面
- 新增 /video/:id 路由与 VideoPage 组件
- 视频文件通过 /uploads/videos/{id}.mp4 静态访问
- .gitignore 排除 videos 目录,避免大文件入库

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-20 17:34:03 +08:00
bcb167b67d refactor: 奖品卡片仅展示说明文案,移动端隐藏库存显示
- StampPopup / RedeemModal 的 Reward 卡片去掉 prize.name 行,
  将 description 作为主要文案展示
- 移动端 RedeemModal 隐藏「剩余库存 X 枚」与「奖品库存已耗尽」
  文案,库存扣减与 sold-out 状态的后端逻辑保持不变
- 确认兑换弹窗内的 Reward 卡片同步调整

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 17:27:46 +08:00
2c179cd19a 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>
2026-04-20 17:16:50 +08:00
394b643304 refactor: 兑换机制改为一图章一奖品并引入库存
- 废弃 RedemptionRule(集 N 换 1),新增 Prize 表与 Stamp 1:1 关联
- Redemption 记录直接绑定到 stampId + prizeId + prizeName 快照
- 兑换事务用 updateMany + stock>0 条件作乐观锁
- 兑换后保留 Collection 记录,图章持续彩色点亮并标记"已兑换"
- 用户端入口改为点击已收集图章弹出兑换,库存为 0 时按钮禁用
- 管理后台删除 /admin/rules,奖品编辑嵌入 StampForm

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-20 15:30:28 +08:00
30 changed files with 970 additions and 788 deletions

1
.gitignore vendored
View File

@@ -5,6 +5,7 @@ dist/
.env
uploads/*
!uploads/.gitkeep
packages/server/uploads/videos/
.DS_Store
*.tsbuildinfo
.claude/settings.local.json

View File

@@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## Project Overview
CityWalk 图章收集系统 — 移动端 H5 + 管理后台。游客在城市点位扫描二维码收集图章,集满可兑换奖品兑换后图章清空,支持重复收集
CityWalk 图章收集系统 — 移动端 H5 + 管理后台。游客在城市点位扫描二维码收集图章,每枚图章绑定一个特定奖品(带库存),已收集的图章可兑换对应奖品兑换后图章保持彩色点亮并标记为"已兑换",同一图章不可二次收集或二次兑换
## Commands
@@ -17,7 +17,7 @@ pnpm dev:web # Vite dev server on :5173
pnpm db:generate # Generate Prisma client after schema changes
pnpm db:migrate # Create and apply migrations (prisma migrate dev)
pnpm db:push # Push schema directly (dev only, no migration file)
pnpm db:seed # Seed sample data (9 stamps + 4 redemption rules)
pnpm db:seed # Seed sample data (16 stamps, each with a Prize of stock 100)
# Build
pnpm build # Build all packages
@@ -48,7 +48,6 @@ All endpoints return: `{ success: boolean, data?: T, error?: { code: string, mes
/collect/:stampId → Redirects to /?stamp={stampId} (QR code entry point)
/admin → AdminLogin
/admin/stamps → Stamp CRUD + QR code generation
/admin/rules → Redemption rule CRUD
/admin/redemptions → Redemption history + stats
```
@@ -58,7 +57,7 @@ QR codes encode `/collect/{stampId}` → redirects to `/?stamp={stampId}` → La
### Redemption Transaction
Atomic: `prisma.$transaction` creates Redemption record + deletes all user Collections. The `@@unique([userId, stampId])` constraint resets after deletion, allowing re-collection.
Each `Stamp` has an optional `Prize` (1:1, `Prize.stampId @unique`). Redemption is atomic: inside `prisma.$transaction` we check the user has a `Collection` for the stamp, no existing `Redemption` for (user, stamp), the prize is `enabled`, then `prisma.prize.updateMany({ where: { id, stock: { gt: 0 } }, data: { stock: { decrement: 1 } } })` acts as a stock lock (throws `OUT_OF_STOCK` if zero rows updated) before creating the `Redemption` record with a `prizeName` snapshot. `Collection` rows are **not** deleted — the `@@unique([userId, stampId])` constraints on both `Collection` and `Redemption` naturally block re-collection and re-redemption of the same stamp.
## Critical: Tailwind CSS v4 Layer Architecture

View File

@@ -32,5 +32,5 @@ packages/
server/ Express API认证、图章、兑换、管理
web/ React SPA移动端 H5 + PC 管理后台)
prisma/
schema.prisma 数据模型User, Stamp, Collection, RedemptionRule, Redemption
schema.prisma 数据模型User, Stamp, Prize, Collection, Redemption
```

View File

@@ -10,7 +10,8 @@
"db:push": "prisma db push",
"db:seed": "pnpm --filter @stamp/server seed",
"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": {
"node": ">=20"

View File

@@ -9,7 +9,8 @@
"start": "node dist/index.js",
"seed": "tsx src/seed.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": {
"@stamp/shared": "workspace:*",

View File

@@ -26,7 +26,10 @@ router.use(requireAdmin);
// ===== Stamps CRUD =====
router.get("/stamps", async (_req, res) => {
const stamps = await prisma.stamp.findMany({ orderBy: { sortOrder: "asc" } });
const stamps = await prisma.stamp.findMany({
orderBy: { sortOrder: "asc" },
include: { prize: true },
});
res.json({ success: true, data: stamps });
});
@@ -121,69 +124,58 @@ router.get("/stamps/:id/qrcode", async (req, res) => {
res.json({ success: true, data: { qrDataUrl, collectUrl, stampName: stamp.name } });
});
// ===== Redemption Rules CRUD =====
// ===== Prize (per-stamp) =====
router.get("/rules", async (_req, res) => {
const rules = await prisma.redemptionRule.findMany({ orderBy: { sortOrder: "asc" } });
res.json({ success: true, data: rules });
});
const ruleSchema = z.object({
const prizeSchema = z.object({
name: z.string().min(1, "奖品名称不能为空"),
description: z.string().optional(),
threshold: z.number().int().min(1, "兑换门槛至少为 1"),
stock: z.number().int().min(0, "库存不能为负数"),
enabled: z.boolean().optional(),
sortOrder: z.number().int().optional(),
});
router.post("/rules", async (req, res) => {
const parsed = ruleSchema.safeParse(req.body);
router.put("/stamps/:id/prize", async (req, res) => {
const parsed = prizeSchema.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({ success: false, error: { code: "VALIDATION", message: parsed.error.issues[0].message } });
return;
}
const rule = await prisma.redemptionRule.create({
data: {
name: parsed.data.name,
description: parsed.data.description,
threshold: parsed.data.threshold,
enabled: parsed.data.enabled ?? true,
sortOrder: parsed.data.sortOrder ?? 0,
},
const stamp = await prisma.stamp.findUnique({ where: { id: req.params.id } });
if (!stamp) {
res.status(404).json({ success: false, error: { code: "NOT_FOUND", message: "图章不存在" } });
return;
}
const data = {
name: parsed.data.name,
description: parsed.data.description ?? null,
stock: parsed.data.stock,
enabled: parsed.data.enabled ?? true,
};
const prize = await prisma.prize.upsert({
where: { stampId: stamp.id },
create: { stampId: stamp.id, ...data },
update: data,
});
res.json({ success: true, data: rule });
});
router.put("/rules/:id", async (req, res) => {
const parsed = ruleSchema.partial().safeParse(req.body);
if (!parsed.success) {
res.status(400).json({ success: false, error: { code: "VALIDATION", message: parsed.error.issues[0].message } });
return;
}
const rule = await prisma.redemptionRule.update({
where: { id: req.params.id },
data: parsed.data,
}).catch(() => null);
if (!rule) {
res.status(404).json({ success: false, error: { code: "NOT_FOUND", message: "规则不存在" } });
return;
}
res.json({ success: true, data: rule });
});
router.delete("/rules/:id", async (req, res) => {
await prisma.redemptionRule.delete({ where: { id: req.params.id } }).catch(() => null);
res.json({ success: true, data: null });
res.json({ success: true, data: prize });
});
// ===== Redemption Records & Stats =====
router.get("/redemptions", async (_req, res) => {
const records = await prisma.redemption.findMany({
include: { user: { select: { username: true, phone: true } }, rule: { select: { name: true } } },
include: {
user: { select: { username: true, phone: true } },
stamp: { select: { name: true } },
},
orderBy: { redeemedAt: "desc" },
});
res.json({ success: true, data: records });
const data = records.map((r) => ({
id: r.id,
redeemedAt: r.redeemedAt,
user: r.user,
stampName: r.stamp.name,
prizeName: r.prizeName,
}));
res.json({ success: true, data });
});
router.get("/stats", async (_req, res) => {

View File

@@ -5,17 +5,8 @@ import { requireAuth } from "../middleware/auth.js";
const router = Router();
router.get("/rules", async (_req, res) => {
const rules = await prisma.redemptionRule.findMany({
where: { enabled: true },
orderBy: { sortOrder: "asc" },
select: { id: true, name: true, description: true, threshold: true },
});
res.json({ success: true, data: rules });
});
const redeemSchema = z.object({
ruleId: z.string().uuid("规则 ID 格式不正确"),
stampId: z.string().uuid("图章 ID 格式不正确"),
});
router.post("/redeem", requireAuth, async (req, res) => {
@@ -25,64 +16,94 @@ router.post("/redeem", requireAuth, async (req, res) => {
return;
}
const rule = await prisma.redemptionRule.findUnique({ where: { id: parsed.data.ruleId, enabled: true } });
if (!rule) {
res.status(404).json({ success: false, error: { code: "NOT_FOUND", message: "兑换规则不存在" } });
return;
const { stampId } = parsed.data;
const userId = req.userId!;
try {
const redemption = await prisma.$transaction(async (tx) => {
const collection = await tx.collection.findUnique({
where: { userId_stampId: { userId, stampId } },
});
if (!collection) {
throw new RedeemError("NOT_COLLECTED", "你还没有收集这枚图章", 400);
}
const already = await tx.redemption.findUnique({
where: { userId_stampId: { userId, stampId } },
});
if (already) {
throw new RedeemError("ALREADY_REDEEMED", "你已经兑换过这枚图章对应的奖品", 409);
}
const prize = await tx.prize.findUnique({ where: { stampId } });
if (!prize || !prize.enabled) {
throw new RedeemError("PRIZE_UNAVAILABLE", "该图章暂无可兑换的奖品", 400);
}
const decremented = await tx.prize.updateMany({
where: { id: prize.id, stock: { gt: 0 } },
data: { stock: { decrement: 1 } },
});
if (decremented.count === 0) {
throw new RedeemError("OUT_OF_STOCK", "奖品已兑完", 400);
}
return tx.redemption.create({
data: {
userId,
stampId,
prizeId: prize.id,
prizeName: prize.name,
},
include: { stamp: { select: { name: true } } },
});
});
res.json({
success: true,
data: {
id: redemption.id,
stampId: redemption.stampId,
stampName: redemption.stamp.name,
prizeName: redemption.prizeName,
redeemedAt: redemption.redeemedAt.toISOString(),
},
});
} catch (e) {
if (e instanceof RedeemError) {
res.status(e.status).json({ success: false, error: { code: e.code, message: e.message } });
return;
}
throw e;
}
const collectionCount = await prisma.collection.count({ where: { userId: req.userId! } });
if (collectionCount < rule.threshold) {
res.status(400).json({
success: false,
error: { code: "INSUFFICIENT", message: `需要收集 ${rule.threshold} 枚图章,当前只有 ${collectionCount}` },
});
return;
}
const redemption = await prisma.$transaction(async (tx) => {
// Deduct the oldest N collections (chronological order by collectedAt)
const toDelete = await tx.collection.findMany({
where: { userId: req.userId! },
orderBy: { collectedAt: "asc" },
take: rule.threshold,
select: { id: true },
});
await tx.collection.deleteMany({
where: { id: { in: toDelete.map((c) => c.id) } },
});
const record = await tx.redemption.create({
data: { userId: req.userId!, ruleId: rule.id, stampCount: rule.threshold },
});
return record;
});
res.json({
success: true,
data: {
id: redemption.id,
ruleName: rule.name,
stampCount: redemption.stampCount,
redeemedAt: redemption.redeemedAt.toISOString(),
},
});
});
router.get("/history", requireAuth, async (req, res) => {
const records = await prisma.redemption.findMany({
where: { userId: req.userId! },
include: { rule: { select: { name: true } } },
include: { stamp: { select: { name: true } } },
orderBy: { redeemedAt: "desc" },
});
const data = records.map((r) => ({
id: r.id,
ruleName: r.rule.name,
stampCount: r.stampCount,
stampId: r.stampId,
stampName: r.stamp.name,
prizeName: r.prizeName,
redeemedAt: r.redeemedAt.toISOString(),
}));
res.json({ success: true, data });
});
class RedeemError extends Error {
constructor(
public code: string,
message: string,
public status: number,
) {
super(message);
}
}
export default router;

View File

@@ -8,20 +8,25 @@ router.get("/", optionalAuth, async (req, res) => {
const stamps = await prisma.stamp.findMany({
where: { enabled: true },
orderBy: { sortOrder: "asc" },
include: { prize: true },
});
let collections: Set<string> = new Set();
let collectionMap: Map<string, Date> = new Map();
const collectionMap = new Map<string, Date>();
const redeemedSet = new Set<string>();
if (req.userId) {
const userCollections = await prisma.collection.findMany({
where: { userId: req.userId },
select: { stampId: true, collectedAt: true },
});
userCollections.forEach((c) => {
collections.add(c.stampId);
collectionMap.set(c.stampId, c.collectedAt);
});
const [userCollections, userRedemptions] = await Promise.all([
prisma.collection.findMany({
where: { userId: req.userId },
select: { stampId: true, collectedAt: true },
}),
prisma.redemption.findMany({
where: { userId: req.userId },
select: { stampId: true },
}),
]);
userCollections.forEach((c) => collectionMap.set(c.stampId, c.collectedAt));
userRedemptions.forEach((r) => redeemedSet.add(r.stampId));
}
const data = stamps.map((s) => ({
@@ -31,15 +36,28 @@ router.get("/", optionalAuth, async (req, res) => {
imageColor: s.imageColor,
imageGrey: s.imageGrey,
sortOrder: s.sortOrder,
collected: collections.has(s.id),
collected: collectionMap.has(s.id),
collectedAt: collectionMap.get(s.id)?.toISOString() ?? null,
redeemed: redeemedSet.has(s.id),
prize: s.prize
? {
id: s.prize.id,
name: s.prize.name,
description: s.prize.description,
stock: s.prize.stock,
enabled: s.prize.enabled,
}
: null,
}));
res.json({ success: true, data });
});
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) {
res.status(404).json({ success: false, error: { code: "NOT_FOUND", message: "图章不存在" } });
return;
@@ -53,6 +71,15 @@ router.get("/:id", async (req, res) => {
imageColor: stamp.imageColor,
imageGrey: stamp.imageGrey,
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,73 @@
import { prisma } from "@stamp/shared";
const OLD_SUFFIX = "· 纪念章";
const NEW_SUFFIX = "· 专属奖品";
const OLD_DESC_RE = /^在「(.+)」集到的专属纪念奖品$/;
const NEW_DESC = (brand: string) => `在「${brand}」可兑换的专属奖品`;
async function main() {
const apply = process.argv.includes("--apply");
const prizes = await prisma.prize.findMany();
const prizeNameHits = prizes.filter((p) => p.name.endsWith(OLD_SUFFIX));
const prizeDescHits = prizes.filter((p) => p.description && OLD_DESC_RE.test(p.description));
const redemptions = await prisma.redemption.findMany();
const redemptionHits = redemptions.filter((r) => r.prizeName.endsWith(OLD_SUFFIX));
console.log(`=== Prize.name (${prizeNameHits.length}) ===`);
prizeNameHits.slice(0, 20).forEach((p) => {
const next = p.name.slice(0, -OLD_SUFFIX.length) + NEW_SUFFIX;
console.log(` "${p.name}" → "${next}"`);
});
if (prizeNameHits.length > 20) console.log(` ...共 ${prizeNameHits.length}`);
console.log(`\n=== Prize.description (${prizeDescHits.length}) ===`);
prizeDescHits.slice(0, 20).forEach((p) => {
const m = p.description!.match(OLD_DESC_RE)!;
console.log(` "${p.description}" → "${NEW_DESC(m[1])}"`);
});
if (prizeDescHits.length > 20) console.log(` ...共 ${prizeDescHits.length}`);
console.log(`\n=== Redemption.prizeName (${redemptionHits.length}) ===`);
redemptionHits.slice(0, 20).forEach((r) => {
const next = r.prizeName.slice(0, -OLD_SUFFIX.length) + NEW_SUFFIX;
console.log(` "${r.prizeName}" → "${next}"`);
});
if (redemptionHits.length > 20) console.log(` ...共 ${redemptionHits.length}`);
const total = prizeNameHits.length + prizeDescHits.length + redemptionHits.length;
if (total === 0) {
console.log("\n✓ 没有需要替换的记录。");
return;
}
if (!apply) {
console.log(`\nDRY RUN — 共 ${total} 条待改。传入 --apply 执行写入。`);
return;
}
console.log(`\n正在写入...`);
await prisma.$transaction(async (tx) => {
for (const p of prizeNameHits) {
const next = p.name.slice(0, -OLD_SUFFIX.length) + NEW_SUFFIX;
await tx.prize.update({ where: { id: p.id }, data: { name: next } });
}
for (const p of prizeDescHits) {
const m = p.description!.match(OLD_DESC_RE)!;
await tx.prize.update({ where: { id: p.id }, data: { description: NEW_DESC(m[1]) } });
}
for (const r of redemptionHits) {
const next = r.prizeName.slice(0, -OLD_SUFFIX.length) + NEW_SUFFIX;
await tx.redemption.update({ where: { id: r.id }, data: { prizeName: next } });
}
});
console.log(`\n完成。已更新 ${total} 条。`);
}
main()
.catch((e) => {
console.error(e);
process.exit(1);
})
.finally(() => prisma.$disconnect());

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());

View File

@@ -22,7 +22,7 @@ const stampData = [
async function seed() {
console.log("Seeding database...");
// Clear existing stamps (cascades to collections)
// Clear existing stamps (cascades to collections + prize)
await prisma.stamp.deleteMany();
const stamps = await Promise.all(
@@ -34,34 +34,20 @@ async function seed() {
imageColor: `/uploads/stamps/stamp-${pos}-color.jpg`,
imageGrey: `/uploads/stamps/stamp-${pos}-grey.jpg`,
sortOrder: idx + 1,
prize: {
create: {
name: `${s.name} · 专属奖品`,
description: `在「${s.name}」可兑换的专属奖品`,
stock: 100,
enabled: true,
},
},
},
});
}),
);
console.log(`Created ${stamps.length} stamps`);
// Create redemption rules if none exist
const existingRules = await prisma.redemptionRule.count();
if (existingRules === 0) {
const rules = await Promise.all([
prisma.redemptionRule.create({
data: { name: "纪念书签", description: "精美城市纪念书签一枚", threshold: 4, sortOrder: 1 },
}),
prisma.redemptionRule.create({
data: { name: "手绘明信片套装", description: "一套 5 张手绘城市明信片", threshold: 8, sortOrder: 2 },
}),
prisma.redemptionRule.create({
data: { name: "限定徽章礼盒", description: "城市限定版金属徽章礼盒", threshold: 12, sortOrder: 3 },
}),
prisma.redemptionRule.create({
data: { name: "城市记忆礼包", description: "包含纪念 T 恤、帆布袋和城市画册", threshold: 16, sortOrder: 4 },
}),
]);
console.log(`Created ${rules.length} redemption rules`);
} else {
console.log(`Kept existing ${existingRules} redemption rules`);
}
console.log(`Created ${stamps.length} stamps with prizes`);
console.log("\nStamp IDs for testing:");
stamps.forEach((s) => {

View File

@@ -4,6 +4,14 @@ export type ApiResponse<T = unknown> = {
error?: { code: string; message: string };
};
export type PrizeInfo = {
id: string;
name: string;
description: string | null;
stock: number;
enabled: boolean;
};
export type StampWithStatus = {
id: string;
name: string;
@@ -13,19 +21,15 @@ export type StampWithStatus = {
sortOrder: number;
collected: boolean;
collectedAt: string | null;
};
export type RedemptionRuleInfo = {
id: string;
name: string;
description: string | null;
threshold: number;
redeemed: boolean;
prize: PrizeInfo | null;
};
export type RedemptionRecord = {
id: string;
ruleName: string;
stampCount: number;
stampId: string;
stampName: string;
prizeName: string;
redeemedAt: string;
};

Binary file not shown.

After

Width:  |  Height:  |  Size: 125 KiB

View File

@@ -4,6 +4,7 @@ import LandingPage from "./pages/LandingPage";
import AlbumPage from "./pages/AlbumPage";
import ArticlePage from "./pages/ArticlePage";
import MusicPage from "./pages/MusicPage";
import VideoPage from "./pages/VideoPage";
import AdminLogin from "./admin/AdminLogin";
import AdminGuard from "./admin/AdminGuard";
import AdminLayout from "./admin/AdminLayout";
@@ -11,7 +12,6 @@ import Dashboard from "./admin/Dashboard";
import StampList from "./admin/StampList";
import ArticleList from "./admin/ArticleList";
import MusicList from "./admin/MusicList";
import RuleList from "./admin/RuleList";
import UsersList from "./admin/UsersList";
import RedemptionLog from "./admin/RedemptionLog";
@@ -30,6 +30,7 @@ export default function App() {
<Route path="/collect/:stampId" element={<CollectRedirect />} />
<Route path="/article/:id" element={<ArticlePage />} />
<Route path="/music/:id" element={<MusicPage />} />
<Route path="/video/:id" element={<VideoPage />} />
{/* Admin panel */}
<Route path="/admin" element={<AdminLogin />} />
@@ -39,7 +40,6 @@ export default function App() {
<Route path="/admin/stamps" element={<StampList />} />
<Route path="/admin/articles" element={<ArticleList />} />
<Route path="/admin/music" element={<MusicList />} />
<Route path="/admin/rules" element={<RuleList />} />
<Route path="/admin/users" element={<UsersList />} />
<Route path="/admin/redemptions" element={<RedemptionLog />} />
</Route>

View File

@@ -6,9 +6,8 @@ const navItems = [
{ path: "/admin/stamps", label: "图章管理", eyebrow: "02", tag: "Stamps" },
{ path: "/admin/articles", label: "文章管理", eyebrow: "03", tag: "Articles" },
{ path: "/admin/music", label: "音乐管理", eyebrow: "04", tag: "Music" },
{ path: "/admin/rules", label: "兑换规则", eyebrow: "05", tag: "Rules" },
{ path: "/admin/users", label: "用户管理", eyebrow: "06", tag: "Users" },
{ path: "/admin/redemptions", label: "兑换记录", eyebrow: "07", tag: "Log" },
{ path: "/admin/users", label: "用户管理", eyebrow: "05", tag: "Users" },
{ path: "/admin/redemptions", label: "兑换记录", eyebrow: "06", tag: "Log" },
];
export default function AdminLayout() {

View File

@@ -5,11 +5,10 @@ import { TableCard, TableHeadRow } from "./StampList";
type RedemptionRecord = {
id: string;
userId: string;
stampCount: number;
redeemedAt: string;
user: { username: string; phone: string };
rule: { name: string };
stampName: string;
prizeName: string;
};
type Stats = {
@@ -44,7 +43,7 @@ export default function RedemptionLog() {
return (
<>
<PageHeader
eyebrow="07 · Log"
eyebrow="06 · Log"
title="兑换记录"
caption="账户、图章收集与兑换的完整轨迹"
/>
@@ -119,7 +118,7 @@ export default function RedemptionLog() {
) : (
<table className="w-full">
<thead>
<TableHeadRow cols={["用户", "手机号", "兑换奖品", "扣除枚数", "时间"]} />
<TableHeadRow cols={["用户", "手机号", "图章", "奖品", "时间"]} />
</thead>
<tbody>
{records.map((r, i) => (
@@ -137,16 +136,10 @@ export default function RedemptionLog() {
<span className="text-sm text-[var(--text-secondary)] font-mono">{r.user.phone}</span>
</td>
<td className="px-5 py-4">
<span className="text-sm text-[var(--text-secondary)]">{r.rule.name}</span>
<span className="text-sm text-[var(--text-secondary)]">{r.stampName}</span>
</td>
<td className="px-5 py-4 text-center w-[140px]">
<span
className="inline-flex items-baseline gap-1 text-[var(--terracotta)]"
style={{ fontFamily: "'Playfair Display', serif" }}
>
<span className="text-xl font-semibold leading-none">{r.stampCount}</span>
<span className="text-[10px] tracking-[0.2em] uppercase opacity-70"></span>
</span>
<td className="px-5 py-4">
<span className="text-sm text-[var(--text-primary)] font-medium">{r.prizeName}</span>
</td>
<td className="px-5 py-4 text-right w-[200px]">
<span className="text-xs text-[var(--text-muted)] font-mono">

View File

@@ -1,132 +0,0 @@
import { useState, useEffect } from "react";
import Modal from "./Modal";
import { adminFetch } from "./adminApi";
import { useToast } from "./Toast";
import { Field, ErrorRow, FormFooter, fieldCls } from "./FormPrimitives";
type Rule = {
id: string;
name: string;
description: string | null;
threshold: number;
enabled: boolean;
sortOrder: number;
};
type Props = {
open: boolean;
id: string | null;
onClose: () => void;
onSaved: () => void;
};
export default function RuleForm({ open, id, onClose, onSaved }: Props) {
const toast = useToast();
const [name, setName] = useState("");
const [description, setDescription] = useState("");
const [threshold, setThreshold] = useState(1);
const [sortOrder, setSortOrder] = useState(0);
const [saving, setSaving] = useState(false);
const [error, setError] = useState("");
const isEdit = !!id;
useEffect(() => {
if (!open) return;
setError("");
if (!id) {
setName(""); setDescription(""); setThreshold(1); setSortOrder(0);
return;
}
adminFetch<Rule[]>("/rules").then((rules) => {
const rule = rules.find((r) => r.id === id);
if (rule) {
setName(rule.name);
setDescription(rule.description || "");
setThreshold(rule.threshold);
setSortOrder(rule.sortOrder);
}
});
}, [open, id]);
const handleSave = async () => {
setError("");
if (!name.trim()) return setError("请输入奖品名称");
setSaving(true);
try {
const body = {
name: name.trim(),
description: description.trim() || undefined,
threshold,
sortOrder,
};
if (isEdit) {
await adminFetch(`/rules/${id}`, { method: "PUT", body: JSON.stringify(body) });
} else {
await adminFetch("/rules", { method: "POST", body: JSON.stringify(body) });
}
toast.show("已保存");
onSaved();
onClose();
} catch (e) {
setError(e instanceof Error ? e.message : "保存失败");
} finally {
setSaving(false);
}
};
return (
<Modal
open={open}
onClose={onClose}
size="md"
eyebrow={isEdit ? "Edit Rule" : "New Rule"}
title={isEdit ? "编辑兑换规则" : "添加兑换规则"}
>
<div className="px-7 py-6 space-y-5">
<Field label="奖品名称" required>
<input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="如:城市限定明信片"
className={fieldCls}
/>
</Field>
<Field label="奖品描述">
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
rows={3}
placeholder="选填"
className={fieldCls + " resize-none"}
/>
</Field>
<div className="grid grid-cols-2 gap-5">
<Field label="所需图章数" required hint="≥ 1">
<input
type="number"
min={1}
value={threshold}
onChange={(e) => setThreshold(Number(e.target.value))}
className={fieldCls + " w-full"}
/>
</Field>
<Field label="排序" hint="数字小的在前">
<input
type="number"
value={sortOrder}
onChange={(e) => setSortOrder(Number(e.target.value))}
className={fieldCls + " w-full"}
/>
</Field>
</div>
{error && <ErrorRow text={error} />}
</div>
<FormFooter onCancel={onClose} onSave={handleSave} saving={saving} primaryLabel={isEdit ? "保存" : "创建"} />
</Modal>
);
}

View File

@@ -1,151 +0,0 @@
import { useState, useEffect } from "react";
import { adminFetch } from "./adminApi";
import { useToast } from "./Toast";
import RuleForm from "./RuleForm";
import PageHeader, {
PrimaryButton,
StatusChip,
ActionButton,
EmptyState,
LoadingBlock,
IconEdit,
IconDelete,
} from "./PageHeader";
import { TableCard, TableHeadRow } from "./StampList";
type Rule = {
id: string;
name: string;
description: string | null;
threshold: number;
enabled: boolean;
sortOrder: number;
};
export default function RuleList() {
const toast = useToast();
const [rules, setRules] = useState<Rule[]>([]);
const [loading, setLoading] = useState(true);
const [formState, setFormState] = useState<{ open: boolean; id: string | null }>({ open: false, id: null });
const fetchRules = async () => {
try {
const data = await adminFetch<Rule[]>("/rules");
setRules(data);
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchRules();
}, []);
const handleDelete = async (id: string, name: string) => {
if (!confirm(`确定删除规则「${name}」?`)) return;
try {
await adminFetch(`/rules/${id}`, { method: "DELETE" });
toast.show("已删除");
fetchRules();
} catch (e) {
toast.show(e instanceof Error ? e.message : "删除失败", "error");
}
};
const handleToggle = async (id: string, enabled: boolean) => {
await adminFetch(`/rules/${id}`, {
method: "PUT",
body: JSON.stringify({ enabled: !enabled }),
});
fetchRules();
};
return (
<>
<PageHeader
eyebrow="05 · Rules"
title="兑换规则"
caption="设置可兑换的奖品与所需图章数"
action={<PrimaryButton onClick={() => setFormState({ open: true, id: null })}></PrimaryButton>}
/>
{loading ? (
<LoadingBlock />
) : (
<TableCard>
{rules.length === 0 ? (
<EmptyState
message="尚未创建兑换规则"
action={
<PrimaryButton onClick={() => setFormState({ open: true, id: null })}>
</PrimaryButton>
}
/>
) : (
<table className="w-full">
<thead>
<TableHeadRow cols={["奖品", "描述", "所需图章", "状态", "操作"]} />
</thead>
<tbody>
{rules.map((rule, i) => (
<tr
key={rule.id}
className="border-t border-[var(--border-muted)] hover:bg-white/60 transition-colors animate-admin-row"
style={{ animationDelay: `${Math.min(i, 12) * 0.03}s` }}
>
<td className="px-5 py-4">
<p className="text-[15px] font-medium text-[var(--text-primary)]">{rule.name}</p>
</td>
<td className="px-5 py-4">
<p className="text-sm text-[var(--text-muted)] max-w-[340px] truncate">
{rule.description || "—"}
</p>
</td>
<td className="px-5 py-4 text-center w-[140px]">
<div className="inline-flex items-baseline gap-1.5">
<span
className="text-2xl text-[var(--terracotta)] leading-none"
style={{ fontFamily: "'Playfair Display', serif", fontWeight: 600 }}
>
{rule.threshold}
</span>
<span className="text-[10px] tracking-[0.2em] uppercase text-[var(--text-muted)]">
</span>
</div>
</td>
<td className="px-5 py-4 text-center w-[110px]">
<StatusChip enabled={rule.enabled} onClick={() => handleToggle(rule.id, rule.enabled)} />
</td>
<td className="px-5 py-4 w-[140px]">
<div className="flex items-center justify-end gap-1">
<ActionButton title="编辑" onClick={() => setFormState({ open: true, id: rule.id })}>
{IconEdit}
</ActionButton>
<ActionButton
title="删除"
variant="danger"
onClick={() => handleDelete(rule.id, rule.name)}
>
{IconDelete}
</ActionButton>
</div>
</td>
</tr>
))}
</tbody>
</table>
)}
</TableCard>
)}
<RuleForm
open={formState.open}
id={formState.id}
onClose={() => setFormState({ open: false, id: null })}
onSaved={fetchRules}
/>
</>
);
}

View File

@@ -4,6 +4,14 @@ import { adminFetch } from "./adminApi";
import { useToast } from "./Toast";
import { Field, ErrorRow, FormFooter, HintRow, fieldCls } from "./FormPrimitives";
type Prize = {
id: string;
name: string;
description: string | null;
stock: number;
enabled: boolean;
};
type Stamp = {
id: string;
name: string;
@@ -12,6 +20,7 @@ type Stamp = {
imageGrey: string;
sortOrder: number;
enabled: boolean;
prize: Prize | null;
};
type Props = {
@@ -32,6 +41,11 @@ export default function StampForm({ open, id, onClose, onSaved }: Props) {
const [saving, setSaving] = useState(false);
const [error, setError] = useState("");
const [prizeName, setPrizeName] = useState("");
const [prizeDescription, setPrizeDescription] = useState("");
const [prizeStock, setPrizeStock] = useState(0);
const [prizeEnabled, setPrizeEnabled] = useState(true);
const isEdit = !!currentId;
useEffect(() => {
@@ -44,6 +58,10 @@ export default function StampForm({ open, id, onClose, onSaved }: Props) {
setSortOrder(0);
setImageColor("");
setImageGrey("");
setPrizeName("");
setPrizeDescription("");
setPrizeStock(0);
setPrizeEnabled(true);
return;
}
adminFetch<Stamp[]>("/stamps").then((stamps) => {
@@ -54,6 +72,17 @@ export default function StampForm({ open, id, onClose, onSaved }: Props) {
setSortOrder(stamp.sortOrder);
setImageColor(stamp.imageColor);
setImageGrey(stamp.imageGrey);
if (stamp.prize) {
setPrizeName(stamp.prize.name);
setPrizeDescription(stamp.prize.description || "");
setPrizeStock(stamp.prize.stock);
setPrizeEnabled(stamp.prize.enabled);
} else {
setPrizeName("");
setPrizeDescription("");
setPrizeStock(0);
setPrizeEnabled(true);
}
}
});
}, [open, id]);
@@ -87,6 +116,10 @@ export default function StampForm({ open, id, onClose, onSaved }: Props) {
setError("请输入图章名称");
return;
}
if (isEdit && prizeName.trim() && prizeStock < 0) {
setError("库存不能为负数");
return;
}
setSaving(true);
try {
const payload = { name: name.trim(), note: note.trim() || undefined, sortOrder };
@@ -95,6 +128,17 @@ export default function StampForm({ open, id, onClose, onSaved }: Props) {
method: "PUT",
body: JSON.stringify(payload),
});
if (prizeName.trim()) {
await adminFetch(`/stamps/${currentId}/prize`, {
method: "PUT",
body: JSON.stringify({
name: prizeName.trim(),
description: prizeDescription.trim() || undefined,
stock: prizeStock,
enabled: prizeEnabled,
}),
});
}
toast.show("已保存");
onSaved();
onClose();
@@ -104,7 +148,7 @@ export default function StampForm({ open, id, onClose, onSaved }: Props) {
body: JSON.stringify(payload),
});
setCurrentId(stamp.id);
toast.show("已创建,现在可以上传图片");
toast.show("已创建,现在可以上传图片与配置奖品");
onSaved();
}
} catch (e) {
@@ -121,7 +165,7 @@ export default function StampForm({ open, id, onClose, onSaved }: Props) {
size="md"
eyebrow={isEdit ? "Edit Stamp" : "New Stamp"}
title={isEdit ? "编辑图章" : "添加图章"}
subtitle={isEdit ? "调整信息上传图片" : "先保存基础信息,再上传图章图片"}
subtitle={isEdit ? "调整信息上传图片并配置关联奖品" : "先保存基础信息,再上传图片与配置奖品"}
>
<div className="px-7 py-6 space-y-5">
<Field label="名称" required>
@@ -133,12 +177,12 @@ export default function StampForm({ open, id, onClose, onSaved }: Props) {
/>
</Field>
<Field label="备注">
<Field label="品牌说明" hint="选填,展示在收集弹窗与集章册详情中">
<textarea
value={note}
onChange={(e) => setNote(e.target.value)}
rows={2}
placeholder="选填"
placeholder="例:品牌定位、特色亮点一句话"
className={fieldCls + " resize-none"}
/>
</Field>
@@ -153,22 +197,79 @@ export default function StampForm({ open, id, onClose, onSaved }: Props) {
</Field>
{isEdit ? (
<div className="grid grid-cols-2 gap-5">
<ImageSlot
label="彩色图章"
kind="color"
image={imageColor}
onUpload={(f) => handleUpload(f, "imageColor")}
/>
<ImageSlot
label="灰色图章"
kind="grey"
image={imageGrey}
onUpload={(f) => handleUpload(f, "imageGrey")}
/>
</div>
<>
<div className="grid grid-cols-2 gap-5">
<ImageSlot
label="彩色图章"
kind="color"
image={imageColor}
onUpload={(f) => handleUpload(f, "imageColor")}
/>
<ImageSlot
label="灰色图章"
kind="grey"
image={imageGrey}
onUpload={(f) => handleUpload(f, "imageGrey")}
/>
</div>
<div className="pt-4 mt-2 border-t border-dashed border-[var(--border-muted)]">
<div className="flex items-baseline justify-between mb-3">
<span className="text-[13px] font-medium text-[var(--text-secondary)]"></span>
<span
className="text-[9px] tracking-[0.3em] uppercase text-[var(--gold)]"
style={{ fontFamily: "'Playfair Display', serif" }}
>
Prize
</span>
</div>
<div className="space-y-4">
<Field label="奖品名称" hint="留空表示此图章暂不提供兑换">
<input
value={prizeName}
onChange={(e) => setPrizeName(e.target.value)}
placeholder="如:品牌 8 折券 / 定制书签"
className={fieldCls}
/>
</Field>
<Field label="奖品说明" hint="展示在收集弹窗与兑换页的规则文案">
<textarea
value={prizeDescription}
onChange={(e) => setPrizeDescription(e.target.value)}
rows={3}
placeholder="例:进店消费 19.9 元,可赠送人气爆款谷物锅巴 1 份。"
className={fieldCls + " resize-none"}
/>
</Field>
<div className="flex items-end gap-5">
<Field label="库存" hint="≥ 0兑换后自动扣减">
<input
type="number"
min={0}
value={prizeStock}
onChange={(e) => setPrizeStock(Math.max(0, Number(e.target.value)))}
className={fieldCls + " w-32"}
/>
</Field>
<label className="flex items-center gap-2 pb-[10px] cursor-pointer">
<input
type="checkbox"
checked={prizeEnabled}
onChange={(e) => setPrizeEnabled(e.target.checked)}
className="w-4 h-4 accent-[var(--jade)]"
/>
<span className="text-[13px] text-[var(--text-secondary)]"></span>
</label>
</div>
</div>
</div>
</>
) : (
<HintRow text="保存基础信息后,即可上传图章图片" />
<HintRow text="保存基础信息后,即可上传图章图片并配置关联奖品" />
)}
{error && <ErrorRow text={error} />}

View File

@@ -24,6 +24,13 @@ type Stamp = {
imageGrey: string;
sortOrder: number;
enabled: boolean;
prize: {
id: string;
name: string;
description: string | null;
stock: number;
enabled: boolean;
} | null;
};
export default function StampList() {
@@ -102,7 +109,7 @@ export default function StampList() {
) : (
<table className="w-full">
<thead>
<TableHeadRow cols={["图章", "名称 · 备注", "排序", "状态", "操作"]} />
<TableHeadRow cols={["图章", "名称 · 备注", "奖品 · 库存", "排序", "状态", "操作"]} />
</thead>
<tbody>
{stamps.map((stamp, i) => (
@@ -130,6 +137,32 @@ export default function StampList() {
</p>
)}
</td>
<td className="px-5 py-4 w-[220px]">
{stamp.prize ? (
<>
<p className="text-sm text-[var(--text-primary)] truncate max-w-[200px]">
{stamp.prize.name}
</p>
<p className="text-xs mt-0.5">
<span
style={{
color:
stamp.prize.stock > 0
? "var(--text-muted)"
: "var(--terracotta)",
}}
>
{stamp.prize.stock}
</span>
{!stamp.prize.enabled && (
<span className="ml-2 text-[var(--text-muted)]">· </span>
)}
</p>
</>
) : (
<span className="text-xs text-[var(--text-muted)]/70 italic"></span>
)}
</td>
<td className="px-5 py-4 text-center w-[80px]">
<span
className="text-[13px] text-[var(--text-secondary)]"

View File

@@ -1,26 +1,35 @@
import { useEffect, useState } from "react";
import type { RedemptionRuleInfo } from "@stamp/shared";
import type { StampWithStatus } from "@stamp/shared";
type RedeemModalProps = {
rules: RedemptionRuleInfo[];
collectedCount: number;
onRedeem: (ruleId: string) => Promise<void>;
stamp: StampWithStatus;
onRedeem: (stampId: string) => Promise<void>;
onClose: () => void;
};
const CONFIRM_COUNTDOWN = 5;
export default function RedeemModal({ rules, collectedCount, onRedeem, onClose }: RedeemModalProps) {
const [redeeming, setRedeeming] = useState<string | null>(null);
const [error, setError] = useState("");
const [confirmRuleId, setConfirmRuleId] = useState<string | null>(null);
type Mode = "uncollected" | "redeemed" | "sold-out" | "unavailable" | "ready";
function resolveMode(stamp: StampWithStatus): Mode {
if (!stamp.collected) return "uncollected";
if (stamp.redeemed) return "redeemed";
if (!stamp.prize || !stamp.prize.enabled) return "unavailable";
if (stamp.prize.stock <= 0) return "sold-out";
return "ready";
}
export default function RedeemModal({ stamp, onRedeem, onClose }: RedeemModalProps) {
const [confirming, setConfirming] = useState(false);
const [countdown, setCountdown] = useState(CONFIRM_COUNTDOWN);
const [redeeming, setRedeeming] = useState(false);
const [error, setError] = useState("");
const confirmRule = confirmRuleId ? rules.find((r) => r.id === confirmRuleId) : null;
const mode = resolveMode(stamp);
const prize = stamp.prize;
// 5-second countdown that restarts each time the confirm panel opens
useEffect(() => {
if (!confirmRuleId) return;
if (!confirming) return;
setCountdown(CONFIRM_COUNTDOWN);
const interval = setInterval(() => {
setCountdown((c) => {
@@ -32,45 +41,67 @@ export default function RedeemModal({ rules, collectedCount, onRedeem, onClose }
});
}, 1000);
return () => clearInterval(interval);
}, [confirmRuleId]);
}, [confirming]);
const openConfirm = (ruleId: string) => {
const openConfirm = () => {
if (mode !== "ready") return;
setError("");
setConfirmRuleId(ruleId);
setConfirming(true);
};
const cancelConfirm = () => {
if (redeeming) return;
setConfirmRuleId(null);
setConfirming(false);
};
const doRedeem = async () => {
if (!confirmRule || countdown > 0) return;
setRedeeming(confirmRule.id);
if (countdown > 0 || redeeming || mode !== "ready") return;
setRedeeming(true);
setError("");
try {
await onRedeem(confirmRule.id);
await onRedeem(stamp.id);
onClose();
} catch (e) {
setError(e instanceof Error ? e.message : "兑换失败");
setConfirmRuleId(null);
setConfirming(false);
} finally {
setRedeeming(null);
setRedeeming(false);
}
};
const buttonCopy = () => {
switch (mode) {
case "uncollected":
return "前往点位收集";
case "redeemed":
return "已兑换";
case "sold-out":
return "已兑完";
case "unavailable":
return "暂无奖品";
case "ready":
return "立即兑换";
}
};
const buttonBg = mode === "ready" ? "var(--jade)" : mode === "redeemed" ? "var(--gold)" : "var(--border-muted)";
const buttonColor = mode === "ready" || mode === "redeemed" ? "white" : "var(--text-muted)";
return (
<div className="fixed inset-0 z-50 flex items-end justify-center animate-overlay-fade"
<div
className="fixed inset-0 z-50 flex items-end justify-center animate-overlay-fade"
style={{ backgroundColor: "var(--overlay)" }}
onClick={(e) => {
if (e.target !== e.currentTarget) return;
if (confirmRuleId) return; // Don't dismiss during confirm flow
if (confirming) return;
onClose();
}}
>
<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">
<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">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M18 6L6 18M6 6l12 12" />
@@ -78,57 +109,99 @@ export default function RedeemModal({ rules, collectedCount, onRedeem, onClose }
</button>
</div>
<p className="text-sm text-[var(--text-secondary)] mb-4">
<span className="font-semibold text-[var(--jade)]">{collectedCount}</span>
</p>
{error && (
<p className="text-sm text-[var(--terracotta)] mb-3">{error}</p>
)}
<div className="space-y-3">
{rules.map((rule) => {
const canRedeem = collectedCount >= rule.threshold;
return (
<div
key={rule.id}
className="flex items-center justify-between p-4 rounded-xl border"
style={{
borderColor: canRedeem ? "var(--jade)" : "var(--border-muted)",
backgroundColor: canRedeem ? "rgba(45, 106, 79, 0.05)" : "white",
}}
>
<div className="flex-1 min-w-0 mr-3">
<p className="text-sm font-medium text-[var(--text-primary)] truncate">
{rule.name}
</p>
{rule.description && (
<p className="text-xs text-[var(--text-muted)] mt-0.5 truncate">{rule.description}</p>
)}
<p className="text-xs text-[var(--text-muted)] mt-1">
{rule.threshold}
</p>
</div>
<button
onClick={() => openConfirm(rule.id)}
disabled={!canRedeem || !!redeeming}
className="shrink-0 px-4 py-2 rounded-lg text-sm font-medium transition-colors"
style={{
backgroundColor: canRedeem ? "var(--jade)" : "var(--border-muted)",
color: canRedeem ? "white" : "var(--text-muted)",
}}
>
</button>
</div>
);
})}
{/* Stamp header */}
<div className="flex items-center gap-4 mb-5">
<div
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: 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.collected ? stamp.imageColor : stamp.imageGrey}
alt={stamp.name}
className="w-[92%] h-[92%] object-contain"
style={{ opacity: stamp.collected ? 1 : 0.6 }}
/>
</div>
<div className="min-w-0">
<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>
{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">
{new Date(stamp.collectedAt).toLocaleDateString("zh-CN")}
</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>
)}
{/* Prize card */}
{prize ? (
<div className="rounded-xl border border-[var(--jade)] bg-[rgba(45,106,79,0.05)] p-4 mb-4">
<p className="text-[10px] tracking-[0.2em] text-[var(--jade)] uppercase mb-2">Reward</p>
<p className="text-sm font-medium text-[var(--text-primary)] mb-1">{prize.name}</p>
{prize.description && (
<p className="text-sm text-[var(--text-secondary)] leading-relaxed">{prize.description}</p>
)}
<div className="mt-3 pt-3 border-t border-[var(--jade)]/15 flex items-baseline justify-between">
<span className="text-xs text-[var(--text-muted)]"></span>
<span
className="text-sm font-semibold"
style={{ color: prize.stock > 0 ? "var(--jade)" : "var(--terracotta)" }}
>
{prize.stock}
<span className="text-xs font-normal text-[var(--text-muted)] ml-1"></span>
</span>
</div>
</div>
) : (
<div className="rounded-xl border border-[var(--border-muted)] bg-white p-4 mb-4 text-center">
<p className="text-sm text-[var(--text-muted)]"></p>
</div>
)}
{mode === "uncollected" && (
<p className="text-xs text-[var(--text-muted)] text-center mb-4 leading-relaxed">
线 NFC
</p>
)}
{mode === "redeemed" && (
<p className="text-xs text-[var(--text-muted)] text-center mb-4"></p>
)}
{mode === "sold-out" && (
<p className="text-xs text-[var(--terracotta)] text-center mb-4"></p>
)}
{error && <p className="text-sm text-[var(--terracotta)] mb-3 text-center">{error}</p>}
<button
onClick={openConfirm}
disabled={mode !== "ready"}
className="w-full py-3.5 rounded-xl text-sm font-medium transition-all disabled:cursor-not-allowed"
style={{
backgroundColor: buttonBg,
color: buttonColor,
boxShadow: mode === "ready" ? "0 2px 8px rgba(45,106,79,0.25)" : "none",
}}
>
{buttonCopy()}
</button>
</div>
{/* Confirmation dialog — centered over the sheet, highest priority */}
{confirmRule && (
{/* Confirmation dialog */}
{confirming && prize && (
<div
className="fixed inset-0 z-[60] flex items-center justify-center px-5 py-6 animate-overlay-fade"
style={{ backgroundColor: "rgba(26, 26, 46, 0.6)" }}
@@ -138,17 +211,18 @@ export default function RedeemModal({ rules, collectedCount, onRedeem, onClose }
className="w-full max-w-sm bg-[var(--bg-cream)] rounded-2xl animate-scale-in overflow-hidden"
style={{ boxShadow: "0 24px 60px rgba(26,26,46,0.35)" }}
>
{/* Warning at the top — most prominent, filled terracotta */}
<div
className="px-5 py-4"
style={{
backgroundColor: "var(--terracotta)",
color: "#fff",
}}
>
{/* Warning */}
<div className="px-5 py-4" style={{ backgroundColor: "var(--terracotta)", color: "#fff" }}>
<div className="flex gap-3">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2"
className="shrink-0 mt-0.5">
<svg
width="22"
height="22"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.2"
className="shrink-0 mt-0.5"
>
<path d="M12 9v4M12 17h.01M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z" />
</svg>
<div className="flex-1">
@@ -160,50 +234,37 @@ export default function RedeemModal({ rules, collectedCount, onRedeem, onClose }
</div>
</div>
{/* Body */}
<div className="px-5 pt-5 pb-5">
<h3 className="text-base font-semibold text-[var(--text-primary)] mb-4"></h3>
{/* Reward */}
<div className="rounded-xl border border-[var(--jade)] bg-[rgba(45,106,79,0.05)] p-3.5 mb-3">
<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)]">{confirmRule.name}</p>
{confirmRule.description && (
<p className="text-xs text-[var(--text-muted)] mt-0.5">{confirmRule.description}</p>
<p className="text-[10px] tracking-[0.2em] text-[var(--jade)] uppercase mb-2">Reward</p>
<p className="text-sm font-medium text-[var(--text-primary)] mb-1">{prize.name}</p>
{prize.description && (
<p className="text-sm text-[var(--text-primary)] leading-relaxed">{prize.description}</p>
)}
</div>
{/* Deduction summary */}
<div className="rounded-xl border border-[var(--border-default)] bg-white p-3.5 mb-2">
<div className="flex items-baseline justify-between">
<span className="text-xs text-[var(--text-muted)]"></span>
<span className="text-xl font-semibold text-[var(--terracotta)]">{confirmRule.threshold}</span>
</div>
<p className="text-xs text-[var(--text-muted)] mt-1 leading-relaxed">
{confirmRule.threshold} {" "}
<span className="font-medium text-[var(--text-secondary)]">
{collectedCount - confirmRule.threshold}
</span>{" "}
<p className="text-xs text-[var(--text-muted)] leading-relaxed">
<span className="font-medium text-[var(--text-secondary)]">{stamp.name}</span>
<span className="font-medium text-[var(--jade)]"></span>
</p>
</div>
<p className="text-[11px] text-[var(--text-muted)] text-center mb-4">
</p>
<p className="text-[11px] text-[var(--text-muted)] text-center mb-4"></p>
{/* Buttons */}
<div className="flex gap-2.5">
<button
onClick={cancelConfirm}
disabled={!!redeeming}
disabled={redeeming}
className="flex-1 py-3 rounded-xl text-sm font-medium border border-[var(--border-default)] text-[var(--text-secondary)] bg-white disabled:opacity-40"
>
</button>
<button
onClick={doRedeem}
disabled={countdown > 0 || !!redeeming}
disabled={countdown > 0 || redeeming}
className="flex-[1.3] py-3 rounded-xl text-sm font-medium transition-colors"
style={{
backgroundColor: countdown > 0 || redeeming ? "var(--border-default)" : "var(--jade)",
@@ -211,11 +272,7 @@ export default function RedeemModal({ rules, collectedCount, onRedeem, onClose }
boxShadow: countdown > 0 || redeeming ? "none" : "0 2px 8px rgba(45,106,79,0.25)",
}}
>
{redeeming
? "兑换中..."
: countdown > 0
? `请阅读提示 ${countdown}s`
: "确认兑换"}
{redeeming ? "兑换中..." : countdown > 0 ? `请阅读提示 ${countdown}s` : "确认兑换"}
</button>
</div>
</div>

View File

@@ -3,10 +3,11 @@ type StampCardProps = {
imageColor: string;
imageGrey: string;
collected: boolean;
redeemed?: boolean;
onClick?: () => void;
};
export default function StampCard({ name, imageColor, imageGrey, collected, onClick }: StampCardProps) {
export default function StampCard({ name, imageColor, imageGrey, collected, redeemed, onClick }: StampCardProps) {
const src = collected ? imageColor : imageGrey;
return (
@@ -43,13 +44,22 @@ export default function StampCard({ name, imageColor, imageGrey, collected, onCl
/>
</div>
{collected && (
{collected && !redeemed && (
<div className="absolute top-0 right-0 w-4 h-4 rounded-full bg-[var(--jade)] flex items-center justify-center shadow-sm z-10">
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="white" strokeWidth="3">
<polyline points="20 6 9 17 4 12" />
</svg>
</div>
)}
{redeemed && (
<div
className="absolute -top-1 right-0 px-1.5 py-[1px] rounded-full text-[9px] font-semibold leading-tight shadow-sm z-10"
style={{ backgroundColor: "var(--gold)", color: "white", letterSpacing: "0.05em" }}
>
</div>
)}
</div>
<span

View File

@@ -16,6 +16,7 @@ export default function StampGrid({ stamps, onStampClick }: StampGridProps) {
imageColor={stamp.imageColor}
imageGrey={stamp.imageGrey}
collected={stamp.collected}
redeemed={stamp.redeemed}
onClick={() => onStampClick?.(stamp)}
/>
))}

View File

@@ -1,26 +1,28 @@
import { useNavigate } from "react-router-dom";
import type { PrizeInfo } from "@stamp/shared";
type StampPopupProps = {
name: string;
imageColor: string;
note?: string | null;
prize?: PrizeInfo | null;
status: "preview" | "collected" | "already";
onCollect?: () => 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();
return (
<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)" }}
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 */}
<div className="w-40 h-40 mx-auto mb-4">
<div className="w-36 h-36 mx-auto mb-4">
<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"
style={{
@@ -44,13 +46,34 @@ export default function StampPopup({ name, imageColor, note, status, onCollect,
{/* Stamp name */}
<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-2">Reward</p>
<p className="text-sm font-medium text-[var(--text-primary)] mb-1">{prize.name}</p>
{prize.description && (
<p className="text-sm text-[var(--text-secondary)] leading-relaxed">{prize.description}</p>
)}
<div className="mt-2.5 pt-2.5 border-t border-[var(--jade)]/15 flex items-baseline justify-between">
<span className="text-xs text-[var(--text-muted)]"></span>
<span
className="text-sm font-semibold"
style={{ color: prize.stock > 0 ? "var(--jade)" : "var(--terracotta)" }}
>
{prize.stock}
<span className="text-xs font-normal text-[var(--text-muted)] ml-1"></span>
</span>
</div>
</div>
)}
{/* Status message & action */}
{status === "preview" && (
<button
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)" }}
>

View File

@@ -1,6 +1,6 @@
import { useState, useEffect } from "react";
import { useNavigate } from "react-router-dom";
import type { StampWithStatus, RedemptionRuleInfo, RedemptionRecord } from "@stamp/shared";
import type { StampWithStatus, RedemptionRecord } from "@stamp/shared";
import { apiFetch } from "../lib/api";
import { useAuth } from "../lib/auth";
import StampGrid from "../components/StampGrid";
@@ -11,27 +11,25 @@ export default function AlbumPage() {
const navigate = useNavigate();
const { user, isLoading: authLoading } = useAuth();
const [stamps, setStamps] = useState<StampWithStatus[]>([]);
const [rules, setRules] = useState<RedemptionRuleInfo[]>([]);
const [history, setHistory] = useState<RedemptionRecord[]>([]);
const [loading, setLoading] = useState(true);
const [showRedeem, setShowRedeem] = useState(false);
const [selectedStampId, setSelectedStampId] = useState<string | null>(null);
const [showRegister, setShowRegister] = useState(false);
const collectedCount = stamps.filter((s) => s.collected).length;
const selectedStamp = selectedStampId ? stamps.find((s) => s.id === selectedStampId) ?? null : null;
const fetchData = async () => {
setLoading(true);
try {
const [stampsData, rulesData] = await Promise.all([
apiFetch<StampWithStatus[]>("/stamps"),
apiFetch<RedemptionRuleInfo[]>("/redemption/rules"),
]);
const stampsData = await apiFetch<StampWithStatus[]>("/stamps");
setStamps(stampsData);
setRules(rulesData);
if (user) {
const historyData = await apiFetch<RedemptionRecord[]>("/redemption/history");
setHistory(historyData);
} else {
setHistory([]);
}
} catch {
// Stamps endpoint works without auth
@@ -44,20 +42,20 @@ export default function AlbumPage() {
if (!authLoading) fetchData();
}, [authLoading, user]);
const handleRedeem = async (ruleId: string) => {
const handleRedeem = async (stampId: string) => {
await apiFetch("/redemption/redeem", {
method: "POST",
body: JSON.stringify({ ruleId }),
body: JSON.stringify({ stampId }),
});
await fetchData();
};
const handleRedeemClick = () => {
const handleStampClick = (stamp: StampWithStatus) => {
if (!user) {
setShowRegister(true);
return;
}
setShowRedeem(true);
setSelectedStampId(stamp.id);
};
if (loading || authLoading) {
@@ -108,40 +106,16 @@ export default function AlbumPage() {
style={{ width: stamps.length ? `${(collectedCount / stamps.length) * 100}%` : "0%" }}
/>
</div>
<p className="mt-3 text-[11px] text-[var(--text-muted)] leading-relaxed">
</p>
</div>
{/* Stamp Grid */}
<div className="px-4 pb-6">
<StampGrid stamps={stamps} />
<StampGrid stamps={stamps} onStampClick={handleStampClick} />
</div>
{/* Redeem Section */}
{rules.length > 0 && (() => {
const availableCount = rules.filter((r) => collectedCount >= r.threshold).length;
const canRedeem = availableCount > 0;
return (
<div className="px-6 pb-6">
<button
onClick={handleRedeemClick}
disabled={!canRedeem}
className="w-full py-3.5 rounded-xl text-sm font-medium transition-all disabled:cursor-not-allowed flex items-center justify-center gap-2"
style={{
backgroundColor: canRedeem ? "var(--jade)" : "var(--border-muted)",
color: canRedeem ? "white" : "var(--text-muted)",
boxShadow: canRedeem ? "0 2px 8px rgba(45,106,79,0.25)" : "none",
}}
>
<span>{canRedeem ? "兑换奖品" : "继续收集以解锁奖品"}</span>
{canRedeem && (
<span className="text-xs px-2 py-0.5 rounded-full bg-white/20">
{availableCount}
</span>
)}
</button>
</div>
);
})()}
{/* Redemption History */}
{history.length > 0 && (
<div className="px-6 pb-8">
@@ -149,13 +123,13 @@ export default function AlbumPage() {
<div className="space-y-2">
{history.map((r) => (
<div key={r.id} className="flex items-center justify-between py-2 px-3 bg-white/60 rounded-lg">
<div>
<p className="text-sm text-[var(--text-primary)]">{r.ruleName}</p>
<div className="min-w-0">
<p className="text-sm text-[var(--text-primary)] truncate">{r.prizeName}</p>
<p className="text-xs text-[var(--text-muted)]">
{new Date(r.redeemedAt).toLocaleDateString("zh-CN")}
{r.stampName} · {new Date(r.redeemedAt).toLocaleDateString("zh-CN")}
</p>
</div>
<span className="text-xs text-[var(--jade)]"></span>
<span className="text-xs text-[var(--jade)] shrink-0 ml-3"></span>
</div>
))}
</div>
@@ -163,12 +137,11 @@ export default function AlbumPage() {
)}
{/* Modals */}
{showRedeem && (
{selectedStamp && (
<RedeemModal
rules={rules}
collectedCount={collectedCount}
stamp={selectedStamp}
onRedeem={handleRedeem}
onClose={() => setShowRedeem(false)}
onClose={() => setSelectedStampId(null)}
/>
)}

View File

@@ -1,5 +1,7 @@
import { useState, useEffect, useCallback } from "react";
import type { ReactNode } from "react";
import { useSearchParams, useNavigate } from "react-router-dom";
import type { PrizeInfo } from "@stamp/shared";
import { apiFetch } from "../lib/api";
import { useAuth } from "../lib/auth";
import FloatingButton from "../components/FloatingButton";
@@ -14,14 +16,49 @@ type StampDetail = {
note: string | null;
imageColor: string;
imageGrey: string;
prize: PrizeInfo | null;
};
type CollectState = "idle" | "loading" | "show_stamp" | "needs_register" | "collecting" | "collected" | "already_collected";
const STEPS = [
{ num: "01", title: "前往点位", desc: "跟随路线,探索散落在城市中的文化地标" },
{ num: "02", title: "触碰集章", desc: "手机轻触点位 NFC 芯片,图章即刻收入囊中" },
{ num: "03", title: "兑换好礼", desc: "集满图章,兑换城市限定纪念品" },
const RULES: { num: string; title: string; desc: ReactNode }[] = [
{
num: "01",
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() {
@@ -100,149 +137,66 @@ export default function LandingPage() {
const showRegister = collectState === "needs_register";
return (
<div className="grain-overlay">
{/* ═══════════ HERO ═══════════ */}
<section className="relative min-h-svh flex flex-col items-center justify-center overflow-hidden">
<div className="absolute inset-0 bg-[var(--bg-dark-deep)]" />
<div
className="absolute inset-0"
style={{
backgroundImage: `
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="min-h-svh bg-[var(--bg-cream)]">
{/* ═══════════ POSTER ═══════════ */}
<section className="relative w-full">
<img
src="/poster.jpg"
alt="读城·行走朝天宫"
className="block w-full h-auto select-none"
draggable={false}
/>
<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>
{/* ═══════════ ABOUT ═══════════ */}
<section className="relative bg-[var(--bg-dark)] py-20 px-6 overflow-hidden">
<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="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">
{/* ═══════════ RULES ═══════════ */}
<section className="relative paper-texture px-6 py-14 pb-32">
<div className="relative z-10 max-w-sm mx-auto">
<div className="flex items-center gap-3 mb-3">
<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">
Rules
</span>
</div>
<h2 className="text-[var(--text-primary)] text-2xl leading-snug mb-12"
style={{ fontFamily: "'Playfair Display', serif" }}>
<h2
className="text-[var(--text-primary)] text-2xl leading-snug mb-10"
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">
<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 }}>
{step.num}
<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>
{i < STEPS.length - 1 && <div className="w-px flex-1 min-h-[40px] bg-[var(--gold)]/20" />}
{i < RULES.length - 1 && (
<div className="w-px flex-1 min-h-[40px] bg-[var(--gold)]/20" />
)}
</div>
<div className="pb-10 pt-1.5">
<h3 className="text-base font-semibold text-[var(--text-primary)] mb-1">{step.title}</h3>
<p className="text-sm text-[var(--text-secondary)] leading-relaxed">{step.desc}</p>
<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>
</section>
@@ -254,6 +208,7 @@ export default function LandingPage() {
name={stamp.name}
imageColor={stamp.imageColor}
note={stamp.note}
prize={stamp.prize}
status="preview"
onCollect={handleCollect}
onClose={handleClose}
@@ -264,6 +219,7 @@ export default function LandingPage() {
name={stamp.name}
imageColor={stamp.imageColor}
note={stamp.note}
prize={stamp.prize}
status="collected"
onClose={handleClose}
/>
@@ -273,6 +229,7 @@ export default function LandingPage() {
name={stamp.name}
imageColor={stamp.imageColor}
note={stamp.note}
prize={stamp.prize}
status="already"
onClose={handleClose}
/>

View File

@@ -0,0 +1,22 @@
import { useParams } from "react-router-dom";
export default function VideoPage() {
const { id } = useParams();
const src = id ? `/uploads/videos/${id}.mp4` : "";
return (
<div className="min-h-svh flex items-center justify-center bg-black px-4 py-4">
{id ? (
<video
src={src}
controls
playsInline
preload="metadata"
className="block w-full max-w-[960px] h-auto bg-black"
/>
) : (
<p className="text-white/60 text-sm"></p>
)}
</div>
);
}

View File

@@ -0,0 +1,60 @@
-- CreateTable
CREATE TABLE "Prize" (
"id" TEXT NOT NULL PRIMARY KEY,
"stampId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT,
"stock" INTEGER NOT NULL DEFAULT 0,
"enabled" BOOLEAN NOT NULL DEFAULT true,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "Prize_stampId_fkey" FOREIGN KEY ("stampId") REFERENCES "Stamp" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateIndex
CREATE UNIQUE INDEX "Prize_stampId_key" ON "Prize"("stampId");
-- Backfill: create a default Prize for every existing Stamp
INSERT INTO "Prize" ("id", "stampId", "name", "description", "stock", "enabled", "createdAt", "updatedAt")
SELECT
lower(hex(randomblob(4))) || '-' || lower(hex(randomblob(2))) || '-' || lower(hex(randomblob(2))) || '-' || lower(hex(randomblob(2))) || '-' || lower(hex(randomblob(6))),
s."id",
s."name" || ' · 纪念章',
'在「' || s."name" || '」集到的专属纪念奖品',
100,
true,
CURRENT_TIMESTAMP,
CURRENT_TIMESTAMP
FROM "Stamp" s;
-- Drop legacy rows: old Redemption records (ruleId/stampCount model) cannot be mapped to the new
-- one-stamp-one-prize schema, and RedemptionRule is being retired. Intentional data loss.
DELETE FROM "Redemption";
DELETE FROM "RedemptionRule";
-- RedefineTable Redemption
PRAGMA defer_foreign_keys=ON;
PRAGMA foreign_keys=OFF;
CREATE TABLE "new_Redemption" (
"id" TEXT NOT NULL PRIMARY KEY,
"userId" TEXT NOT NULL,
"stampId" TEXT NOT NULL,
"prizeId" TEXT NOT NULL,
"prizeName" TEXT NOT NULL,
"redeemedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Redemption_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "Redemption_stampId_fkey" FOREIGN KEY ("stampId") REFERENCES "Stamp" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
CONSTRAINT "Redemption_prizeId_fkey" FOREIGN KEY ("prizeId") REFERENCES "Prize" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
);
DROP TABLE "Redemption";
ALTER TABLE "new_Redemption" RENAME TO "Redemption";
CREATE UNIQUE INDEX "Redemption_userId_stampId_key" ON "Redemption"("userId", "stampId");
CREATE INDEX "Redemption_userId_idx" ON "Redemption"("userId");
-- DropTable (now safe, no more FK references)
DROP TABLE "RedemptionRule";
PRAGMA foreign_keys=ON;
PRAGMA defer_foreign_keys=OFF;

View File

@@ -29,6 +29,21 @@ model Stamp {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
collections Collection[]
redemptions Redemption[]
prize Prize?
}
model Prize {
id String @id @default(uuid())
stampId String @unique
name String
description String?
stock Int @default(0)
enabled Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
stamp Stamp @relation(fields: [stampId], references: [id], onDelete: Cascade)
redemptions Redemption[]
}
model Collection {
@@ -43,27 +58,18 @@ model Collection {
@@index([userId])
}
model RedemptionRule {
id String @id @default(uuid())
name String
description String?
threshold Int
enabled Boolean @default(true)
sortOrder Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
redemptions Redemption[]
}
model Redemption {
id String @id @default(uuid())
id String @id @default(uuid())
userId String
ruleId String
stampCount Int
redeemedAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
rule RedemptionRule @relation(fields: [ruleId], references: [id])
stampId String
prizeId String
prizeName String
redeemedAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
stamp Stamp @relation(fields: [stampId], references: [id])
prize Prize @relation(fields: [prizeId], references: [id])
@@unique([userId, stampId])
@@index([userId])
}

BIN
核销指导.pdf Normal file

Binary file not shown.