init: init prok

This commit is contained in:
2026-04-16 15:34:47 +08:00
commit db74381f13
56 changed files with 5850 additions and 0 deletions

5
.env.example Normal file
View File

@@ -0,0 +1,5 @@
DATABASE_URL="file:./dev.db"
JWT_SECRET="change-me-to-a-random-secret"
ADMIN_API_KEY="change-me-to-a-random-key"
SERVER_PORT=3000
SITE_URL="http://localhost:5173"

9
.gitignore vendored Normal file
View File

@@ -0,0 +1,9 @@
node_modules/
dist/
*.db
*.db-journal
.env
uploads/*
!uploads/.gitkeep
.DS_Store
*.tsbuildinfo

85
CLAUDE.md Normal file
View File

@@ -0,0 +1,85 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
CityWalk 图章收集系统 — 移动端 H5 + 管理后台。游客在城市点位扫描二维码收集图章,集满可兑换奖品,兑换后图章清空,支持重复收集。
## Commands
```bash
# Development (需要同时运行两个服务)
pnpm dev:server # Express API on :3000
pnpm dev:web # Vite dev server on :5173
# Database
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)
# Build
pnpm build # Build all packages
```
## Architecture
pnpm monorepo with 3 packages sharing `tsconfig.base.json` (target ES2022, moduleResolution bundler):
- **`packages/shared`** — Prisma client singleton + shared TypeScript types (`ApiResponse<T>`, `StampWithStatus`, etc.)
- **`packages/server`** (port 3000) — Express 5 + Zod validation + JWT auth. Routes: auth, stamps, collection, redemption, admin. File uploads via multer to `packages/server/uploads/`.
- **`packages/web`** (port 5173) — React 19 + Vite 8 + Tailwind CSS 4. Dual interface: mobile H5 (/, /album) + PC admin (/admin/*). Vite proxies `/api` and `/uploads` to server.
### API Response Format
All endpoints return: `{ success: boolean, data?: T, error?: { code: string, message: string } }`
### Authentication
- **Users**: JWT (7-day expiry, single token in localStorage `stamp_token`). Middleware: `requireAuth`, `optionalAuth`.
- **Admin**: API key via `X-Admin-Key` header (matched against `ADMIN_API_KEY` env var). Middleware: `requireAdmin`.
### Frontend Routing
```
/ → LandingPage (also handles /?stamp={id} for collection popup)
/album → AlbumPage (stamp grid + redemption)
/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
```
### Collection Flow
QR codes encode `/collect/{stampId}` → redirects to `/?stamp={stampId}` → LandingPage shows collection overlay. All interactions (register, collect, close) happen as modals on top of the landing page. The stamp ID is stored in `sessionStorage` during registration to resume collection after auth.
### Redemption Transaction
Atomic: `prisma.$transaction` creates Redemption record + deletes all user Collections. The `@@unique([userId, stampId])` constraint resets after deletion, allowing re-collection.
## Critical: Tailwind CSS v4 Layer Architecture
Custom CSS in `packages/web/src/index.css` **must** use `@layer` to avoid overriding Tailwind utilities:
```css
@import "tailwindcss";
@layer base { /* Reset, CSS variables, body/heading fonts */}
@keyframes ... { } /* Keyframes stay OUTSIDE layers */
@layer components { /* .animate-*, .stagger-children, .paper-texture, etc. */}
```
**Why**: In Tailwind CSS v4, unlayered styles always beat `@layer utilities`. If base resets like `* { padding: 0 }` are unlayered, they override Tailwind's `px-4`, `py-3`, etc., breaking all utility spacing across the app.
## Environment
```
DATABASE_URL="file:./dev.db" # SQLite (relative to project root)
JWT_SECRET="..." # JWT signing key
ADMIN_API_KEY="..." # Admin panel access key
SERVER_PORT=3000
SITE_URL="http://localhost:5173" # Used in QR code URL generation
```

36
README.md Normal file
View File

@@ -0,0 +1,36 @@
# CityWalk 图章收集系统
游客在城市不同点位扫描二维码,收集图章,集满兑换奖品。兑换后图章清空,可重复挑战。
## 快速开始
```bash
pnpm install
cp .env.example .env
pnpm db:push
pnpm db:seed
# 启动(需同时运行)
pnpm dev:server # API :3000
pnpm dev:web # 前端 :5173
```
- 用户端http://localhost:5173
- 管理后台http://localhost:5173/admin密钥见 `.env``ADMIN_API_KEY`
## 技术栈
| 前端 | 后端 | 数据库 |
|------|------|--------|
| React 19 + Vite 8 + Tailwind CSS 4 | Express 5 + TypeScript | SQLite (Prisma) |
## 项目结构
```
packages/
shared/ Prisma client + 共享类型
server/ Express API认证、图章、兑换、管理
web/ React SPA移动端 H5 + PC 管理后台)
prisma/
schema.prisma 数据模型User, Stamp, Collection, RedemptionRule, Redemption
```

30
package.json Normal file
View File

@@ -0,0 +1,30 @@
{
"name": "stamp",
"private": true,
"scripts": {
"dev:server": "pnpm --filter @stamp/server dev",
"dev:web": "pnpm --filter @stamp/web dev",
"build": "pnpm -r build",
"db:generate": "prisma generate",
"db:migrate": "prisma migrate dev",
"db:push": "prisma db push",
"db:seed": "pnpm --filter @stamp/server seed"
},
"engines": {
"node": ">=20"
},
"pnpm": {
"onlyBuiltDependencies": [
"@prisma/client",
"@prisma/engines",
"esbuild",
"prisma"
]
},
"devDependencies": {
"prisma": "^6.19.3"
},
"dependencies": {
"@prisma/client": "^6.19.3"
}
}

View File

@@ -0,0 +1,30 @@
{
"name": "@stamp/server",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "tsc",
"start": "node dist/index.js",
"seed": "tsx src/seed.ts"
},
"dependencies": {
"@stamp/shared": "workspace:*",
"cors": "^2.8.5",
"express": "^5.0.0",
"jsonwebtoken": "^9.0.3",
"multer": "^1.4.5-lts.2",
"qrcode": "^1.5.4",
"zod": "^3.24.0"
},
"devDependencies": {
"@types/cors": "^2.8.17",
"@types/express": "^5.0.0",
"@types/jsonwebtoken": "^9.0.10",
"@types/multer": "^1.4.12",
"@types/qrcode": "^1.5.5",
"tsx": "^4.19.0",
"typescript": "~5.9.3"
}
}

View File

@@ -0,0 +1,36 @@
import express from "express";
import cors from "cors";
import path from "path";
import { fileURLToPath } from "url";
import authRoutes from "./routes/auth.js";
import stampRoutes from "./routes/stamps.js";
import redemptionRoutes from "./routes/redemption.js";
import adminRoutes from "./routes/admin.js";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const app = express();
const PORT = process.env.SERVER_PORT || 3000;
app.use(cors());
app.use(express.json());
// Serve uploaded files
app.use("/uploads", express.static(path.join(__dirname, "../uploads")));
// Health check
app.get("/api/health", (_req, res) => {
res.json({ success: true, data: { status: "ok" } });
});
// User-facing routes
app.use("/api/auth", authRoutes);
app.use("/api/stamps", stampRoutes);
app.use("/api/redemption", redemptionRoutes);
// Admin routes
app.use("/api/admin", adminRoutes);
app.listen(PORT, () => {
console.log(`Server running on http://localhost:${PORT}`);
});

View File

@@ -0,0 +1,16 @@
import jwt from "jsonwebtoken";
const SECRET = process.env.JWT_SECRET || "dev-secret";
const EXPIRES_IN = "7d";
export function signToken(userId: string): string {
return jwt.sign({ sub: userId }, SECRET, { expiresIn: EXPIRES_IN });
}
export function verifyToken(token: string): { sub: string } | null {
try {
return jwt.verify(token, SECRET) as { sub: string };
} catch {
return null;
}
}

View File

@@ -0,0 +1,12 @@
import QRCode from "qrcode";
export async function generateQRCodeDataURL(
url: string,
options?: { width?: number }
): Promise<string> {
return QRCode.toDataURL(url, {
width: options?.width ?? 300,
margin: 2,
color: { dark: "#1a1a2e", light: "#ffffff" },
});
}

View File

@@ -0,0 +1,11 @@
import type { Request, Response, NextFunction } from "express";
export function requireAdmin(req: Request, res: Response, next: NextFunction) {
const key = req.headers["x-admin-key"];
const expected = process.env.ADMIN_API_KEY;
if (!expected || key !== expected) {
res.status(403).json({ success: false, error: { code: "FORBIDDEN", message: "管理员权限不足" } });
return;
}
next();
}

View File

@@ -0,0 +1,36 @@
import type { Request, Response, NextFunction } from "express";
import { verifyToken } from "../lib/jwt.js";
declare global {
namespace Express {
interface Request {
userId?: string;
}
}
}
export function requireAuth(req: Request, res: Response, next: NextFunction) {
const header = req.headers.authorization;
if (!header?.startsWith("Bearer ")) {
res.status(401).json({ success: false, error: { code: "UNAUTHORIZED", message: "请先登录" } });
return;
}
const payload = verifyToken(header.slice(7));
if (!payload) {
res.status(401).json({ success: false, error: { code: "TOKEN_INVALID", message: "登录已过期,请重新登录" } });
return;
}
req.userId = payload.sub;
next();
}
export function optionalAuth(req: Request, _res: Response, next: NextFunction) {
const header = req.headers.authorization;
if (header?.startsWith("Bearer ")) {
const payload = verifyToken(header.slice(7));
if (payload) {
req.userId = payload.sub;
}
}
next();
}

View File

@@ -0,0 +1,197 @@
import { Router } from "express";
import { z } from "zod";
import multer from "multer";
import path from "path";
import { fileURLToPath } from "url";
import { prisma } from "@stamp/shared";
import { requireAdmin } from "../middleware/admin.js";
import { generateQRCodeDataURL } from "../lib/qrcode.js";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const uploadsDir = path.join(__dirname, "../../uploads");
const storage = multer.diskStorage({
destination: uploadsDir,
filename: (_req, file, cb) => {
const ext = path.extname(file.originalname);
cb(null, `${Date.now()}-${Math.random().toString(36).slice(2, 8)}${ext}`);
},
});
const upload = multer({ storage, limits: { fileSize: 5 * 1024 * 1024 } });
const router = Router();
router.use(requireAdmin);
// ===== Stamps CRUD =====
router.get("/stamps", async (_req, res) => {
const stamps = await prisma.stamp.findMany({ orderBy: { sortOrder: "asc" } });
res.json({ success: true, data: stamps });
});
const stampSchema = z.object({
name: z.string().min(1, "图章名称不能为空"),
note: z.string().optional(),
imageColor: z.string().optional(),
imageGrey: z.string().optional(),
sortOrder: z.number().int().optional(),
enabled: z.boolean().optional(),
});
router.post("/stamps", async (req, res) => {
const parsed = stampSchema.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({ success: false, error: { code: "VALIDATION", message: parsed.error.issues[0].message } });
return;
}
const stamp = await prisma.stamp.create({
data: {
name: parsed.data.name,
note: parsed.data.note,
imageColor: parsed.data.imageColor ?? "",
imageGrey: parsed.data.imageGrey ?? "",
sortOrder: parsed.data.sortOrder ?? 0,
enabled: parsed.data.enabled ?? true,
},
});
res.json({ success: true, data: stamp });
});
router.put("/stamps/:id", async (req, res) => {
const parsed = stampSchema.partial().safeParse(req.body);
if (!parsed.success) {
res.status(400).json({ success: false, error: { code: "VALIDATION", message: parsed.error.issues[0].message } });
return;
}
const stamp = await prisma.stamp.update({
where: { id: req.params.id },
data: parsed.data,
}).catch(() => null);
if (!stamp) {
res.status(404).json({ success: false, error: { code: "NOT_FOUND", message: "图章不存在" } });
return;
}
res.json({ success: true, data: stamp });
});
router.delete("/stamps/:id", async (req, res) => {
await prisma.stamp.delete({ where: { id: req.params.id } }).catch(() => null);
res.json({ success: true, data: null });
});
// Upload stamp image
router.post("/stamps/:id/upload", upload.single("image"), async (req, res) => {
if (!req.file) {
res.status(400).json({ success: false, error: { code: "NO_FILE", message: "请选择图片" } });
return;
}
const field = req.body.field as string;
if (field !== "imageColor" && field !== "imageGrey") {
res.status(400).json({ success: false, error: { code: "INVALID_FIELD", message: "field 必须是 imageColor 或 imageGrey" } });
return;
}
const imagePath = `/uploads/${req.file.filename}`;
const stamp = await prisma.stamp.update({
where: { id: req.params.id },
data: { [field]: imagePath },
}).catch(() => null);
if (!stamp) {
res.status(404).json({ success: false, error: { code: "NOT_FOUND", message: "图章不存在" } });
return;
}
res.json({ success: true, data: { path: imagePath } });
});
// Generate QR code
router.get("/stamps/:id/qrcode", async (req, res) => {
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 siteUrl = process.env.SITE_URL || "http://localhost:5173";
const collectUrl = `${siteUrl}/collect/${stamp.id}`;
const qrDataUrl = await generateQRCodeDataURL(collectUrl, { width: 400 });
res.json({ success: true, data: { qrDataUrl, collectUrl, stampName: stamp.name } });
});
// ===== Redemption Rules CRUD =====
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({
name: z.string().min(1, "奖品名称不能为空"),
description: z.string().optional(),
threshold: z.number().int().min(1, "兑换门槛至少为 1"),
enabled: z.boolean().optional(),
sortOrder: z.number().int().optional(),
});
router.post("/rules", async (req, res) => {
const parsed = ruleSchema.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,
},
});
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 });
});
// ===== 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 } } },
orderBy: { redeemedAt: "desc" },
});
res.json({ success: true, data: records });
});
router.get("/stats", async (_req, res) => {
const [userCount, collectionCount, redemptionCount] = await Promise.all([
prisma.user.count(),
prisma.collection.count(),
prisma.redemption.count(),
]);
res.json({ success: true, data: { userCount, collectionCount, redemptionCount } });
});
export default router;

View File

@@ -0,0 +1,66 @@
import { Router } from "express";
import { z } from "zod";
import { prisma } from "@stamp/shared";
import { signToken } from "../lib/jwt.js";
import { requireAuth } from "../middleware/auth.js";
const router = Router();
const phoneRegex = /^1[3-9]\d{9}$/;
const registerSchema = z.object({
username: z.string().min(1, "用户名不能为空").max(20, "用户名最长 20 字符"),
phone: z.string().regex(phoneRegex, "手机号格式不正确"),
});
const loginSchema = z.object({
phone: z.string().regex(phoneRegex, "手机号格式不正确"),
});
router.post("/register", async (req, res) => {
const parsed = registerSchema.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({ success: false, error: { code: "VALIDATION", message: parsed.error.issues[0].message } });
return;
}
const { username, phone } = parsed.data;
const existing = await prisma.user.findFirst({
where: { OR: [{ username }, { phone }] },
});
if (existing) {
const field = existing.username === username ? "用户名" : "手机号";
res.status(409).json({ success: false, error: { code: "DUPLICATE", message: `${field}已被注册` } });
return;
}
const user = await prisma.user.create({ data: { username, phone } });
const token = signToken(user.id);
res.json({ success: true, data: { user: { id: user.id, username: user.username, phone: user.phone }, token } });
});
router.post("/login", async (req, res) => {
const parsed = loginSchema.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({ success: false, error: { code: "VALIDATION", message: parsed.error.issues[0].message } });
return;
}
const user = await prisma.user.findUnique({ where: { phone: parsed.data.phone } });
if (!user) {
res.status(404).json({ success: false, error: { code: "NOT_FOUND", message: "该手机号未注册" } });
return;
}
const token = signToken(user.id);
res.json({ success: true, data: { user: { id: user.id, username: user.username, phone: user.phone }, token } });
});
router.get("/me", requireAuth, async (req, res) => {
const user = await prisma.user.findUnique({ where: { id: req.userId } });
if (!user) {
res.status(404).json({ success: false, error: { code: "NOT_FOUND", message: "用户不存在" } });
return;
}
res.json({ success: true, data: { id: user.id, username: user.username, phone: user.phone } });
});
export default router;

View File

@@ -0,0 +1,79 @@
import { Router } from "express";
import { z } from "zod";
import { prisma } from "@stamp/shared";
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 格式不正确"),
});
router.post("/redeem", requireAuth, async (req, res) => {
const parsed = redeemSchema.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.findUnique({ where: { id: parsed.data.ruleId, enabled: true } });
if (!rule) {
res.status(404).json({ success: false, error: { code: "NOT_FOUND", message: "兑换规则不存在" } });
return;
}
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) => {
const record = await tx.redemption.create({
data: { userId: req.userId!, ruleId: rule.id, stampCount: collectionCount },
});
await tx.collection.deleteMany({ where: { userId: req.userId! } });
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 } } },
orderBy: { redeemedAt: "desc" },
});
const data = records.map((r) => ({
id: r.id,
ruleName: r.rule.name,
stampCount: r.stampCount,
redeemedAt: r.redeemedAt.toISOString(),
}));
res.json({ success: true, data });
});
export default router;

View File

@@ -0,0 +1,85 @@
import { Router } from "express";
import { prisma } from "@stamp/shared";
import { optionalAuth, requireAuth } from "../middleware/auth.js";
const router = Router();
router.get("/", optionalAuth, async (req, res) => {
const stamps = await prisma.stamp.findMany({
where: { enabled: true },
orderBy: { sortOrder: "asc" },
});
let collections: Set<string> = new Set();
let collectionMap: Map<string, Date> = new Map();
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 data = stamps.map((s) => ({
id: s.id,
name: s.name,
note: s.note,
imageColor: s.imageColor,
imageGrey: s.imageGrey,
sortOrder: s.sortOrder,
collected: collections.has(s.id),
collectedAt: collectionMap.get(s.id)?.toISOString() ?? null,
}));
res.json({ success: true, data });
});
router.get("/:id", async (req, res) => {
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;
}
res.json({
success: true,
data: {
id: stamp.id,
name: stamp.name,
note: stamp.note,
imageColor: stamp.imageColor,
imageGrey: stamp.imageGrey,
sortOrder: stamp.sortOrder,
},
});
});
router.post("/:id/collect", requireAuth, async (req, res) => {
const stamp = await prisma.stamp.findUnique({ where: { id: req.params.id, enabled: true } });
if (!stamp) {
res.status(404).json({ success: false, error: { code: "NOT_FOUND", message: "图章不存在" } });
return;
}
const existing = await prisma.collection.findUnique({
where: { userId_stampId: { userId: req.userId!, stampId: stamp.id } },
});
if (existing) {
res.status(409).json({ success: false, error: { code: "ALREADY_COLLECTED", message: "你已经收集了这枚图章" } });
return;
}
const collection = await prisma.collection.create({
data: { userId: req.userId!, stampId: stamp.id },
});
res.json({
success: true,
data: { id: collection.id, stampId: collection.stampId, collectedAt: collection.collectedAt.toISOString() },
});
});
export default router;

View File

@@ -0,0 +1,68 @@
import { prisma } from "@stamp/shared";
async function seed() {
console.log("Seeding database...");
// Create sample stamps
const stamps = await Promise.all([
prisma.stamp.create({
data: { name: "古桥印记", note: "始建于明代的石拱桥", imageColor: "/uploads/placeholder-color.svg", imageGrey: "/uploads/placeholder-grey.svg", sortOrder: 1 },
}),
prisma.stamp.create({
data: { name: "老街风韵", note: "百年历史的商业老街", imageColor: "/uploads/placeholder-color.svg", imageGrey: "/uploads/placeholder-grey.svg", sortOrder: 2 },
}),
prisma.stamp.create({
data: { name: "园林雅趣", note: "江南古典园林", imageColor: "/uploads/placeholder-color.svg", imageGrey: "/uploads/placeholder-grey.svg", sortOrder: 3 },
}),
prisma.stamp.create({
data: { name: "茶馆时光", note: "百年老茶馆", imageColor: "/uploads/placeholder-color.svg", imageGrey: "/uploads/placeholder-grey.svg", sortOrder: 4 },
}),
prisma.stamp.create({
data: { name: "水乡晨曲", note: "清晨的水乡渔市", imageColor: "/uploads/placeholder-color.svg", imageGrey: "/uploads/placeholder-grey.svg", sortOrder: 5 },
}),
prisma.stamp.create({
data: { name: "戏台余韵", note: "古戏台与昆曲", imageColor: "/uploads/placeholder-color.svg", imageGrey: "/uploads/placeholder-grey.svg", sortOrder: 6 },
}),
prisma.stamp.create({
data: { name: "巷弄深处", note: "青石板铺就的幽深小巷", imageColor: "/uploads/placeholder-color.svg", imageGrey: "/uploads/placeholder-grey.svg", sortOrder: 7 },
}),
prisma.stamp.create({
data: { name: "月下拱桥", note: "夜晚灯火映照的拱桥", imageColor: "/uploads/placeholder-color.svg", imageGrey: "/uploads/placeholder-grey.svg", sortOrder: 8 },
}),
prisma.stamp.create({
data: { name: "匠心工坊", note: "传统手工艺作坊", imageColor: "/uploads/placeholder-color.svg", imageGrey: "/uploads/placeholder-grey.svg", sortOrder: 9 },
}),
]);
console.log(`Created ${stamps.length} stamps`);
// Create redemption rules
const rules = await Promise.all([
prisma.redemptionRule.create({
data: { name: "纪念书签", description: "精美城市纪念书签一枚", threshold: 3, sortOrder: 1 },
}),
prisma.redemptionRule.create({
data: { name: "手绘明信片套装", description: "一套 5 张手绘城市明信片", threshold: 5, sortOrder: 2 },
}),
prisma.redemptionRule.create({
data: { name: "限定徽章礼盒", description: "城市限定版金属徽章礼盒", threshold: 7, sortOrder: 3 },
}),
prisma.redemptionRule.create({
data: { name: "城市记忆礼包", description: "包含纪念 T 恤、帆布袋和城市画册", threshold: 9, sortOrder: 4 },
}),
]);
console.log(`Created ${rules.length} redemption rules`);
// Print stamp IDs for QR code testing
console.log("\nStamp IDs for testing:");
stamps.forEach((s) => {
console.log(` ${s.name}: /collect/${s.id}`);
});
console.log("\nSeed complete!");
}
seed()
.catch(console.error)
.finally(() => prisma.$disconnect());

View File

@@ -0,0 +1,8 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src"
},
"include": ["src"]
}

View File

View File

@@ -0,0 +1,16 @@
{
"name": "@stamp/shared",
"version": "0.1.0",
"private": true,
"type": "module",
"main": "src/index.ts",
"scripts": {
"build": "tsc",
"db:generate": "prisma generate --schema=../../prisma/schema.prisma",
"db:migrate": "prisma migrate dev --schema=../../prisma/schema.prisma",
"db:push": "prisma db push --schema=../../prisma/schema.prisma"
},
"devDependencies": {
"typescript": "~5.9.3"
}
}

View File

@@ -0,0 +1,3 @@
import { PrismaClient } from "@prisma/client";
export const prisma = new PrismaClient();

View File

@@ -0,0 +1,2 @@
export { prisma } from "./db.js";
export type * from "./types.js";

View File

@@ -0,0 +1,30 @@
export type ApiResponse<T = unknown> = {
success: boolean;
data?: T;
error?: { code: string; message: string };
};
export type StampWithStatus = {
id: string;
name: string;
note: string | null;
imageColor: string;
imageGrey: string;
sortOrder: number;
collected: boolean;
collectedAt: string | null;
};
export type RedemptionRuleInfo = {
id: string;
name: string;
description: string | null;
threshold: number;
};
export type RedemptionRecord = {
id: string;
ruleName: string;
stampCount: number;
redeemedAt: string;
};

View File

@@ -0,0 +1,8 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src"
},
"include": ["src"]
}

18
packages/web/index.html Normal file
View File

@@ -0,0 +1,18 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
<meta name="theme-color" content="#1a1a2e" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Playfair+Display:ital,wght@0,400;0,600;0,700;1,400;1,600&family=Noto+Sans+SC:wght@300;400;500;700&display=swap" rel="stylesheet" />
<title>CityWalk 图章之旅</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

26
packages/web/package.json Normal file
View File

@@ -0,0 +1,26 @@
{
"name": "@stamp/web",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview"
},
"devDependencies": {
"@tailwindcss/vite": "^4.2.2",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1",
"tailwindcss": "^4.2.2",
"typescript": "~5.9.3",
"vite": "^8.0.1"
},
"dependencies": {
"@stamp/shared": "workspace:*",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-router-dom": "^7.13.2"
}
}

48
packages/web/src/App.tsx Normal file
View File

@@ -0,0 +1,48 @@
import { Routes, Route, Navigate, useParams } from "react-router-dom";
import { AuthProvider } from "./lib/auth";
import LandingPage from "./pages/LandingPage";
import AlbumPage from "./pages/AlbumPage";
import AdminLogin from "./admin/AdminLogin";
import AdminGuard from "./admin/AdminGuard";
import AdminLayout from "./admin/AdminLayout";
import StampList from "./admin/StampList";
import StampForm from "./admin/StampForm";
import StampQRCode from "./admin/StampQRCode";
import RuleList from "./admin/RuleList";
import RuleForm from "./admin/RuleForm";
import RedemptionLog from "./admin/RedemptionLog";
function CollectRedirect() {
const { stampId } = useParams();
return <Navigate to={`/?stamp=${stampId}`} replace />;
}
export default function App() {
return (
<AuthProvider>
<Routes>
{/* User-facing mobile H5 */}
<Route path="/" element={<LandingPage />} />
<Route path="/album" element={<AlbumPage />} />
<Route path="/collect/:stampId" element={<CollectRedirect />} />
{/* Admin panel */}
<Route path="/admin" element={<AdminLogin />} />
<Route element={<AdminGuard />}>
<Route element={<AdminLayout />}>
<Route path="/admin/stamps" element={<StampList />} />
<Route path="/admin/stamps/new" element={<StampForm />} />
<Route path="/admin/stamps/:id/edit" element={<StampForm />} />
<Route path="/admin/stamps/:id/qrcode" element={<StampQRCode />} />
<Route path="/admin/rules" element={<RuleList />} />
<Route path="/admin/rules/new" element={<RuleForm />} />
<Route path="/admin/rules/:id/edit" element={<RuleForm />} />
<Route path="/admin/redemptions" element={<RedemptionLog />} />
</Route>
</Route>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</AuthProvider>
);
}

View File

@@ -0,0 +1,7 @@
import { Navigate, Outlet } from "react-router-dom";
export default function AdminGuard() {
const key = sessionStorage.getItem("admin_key");
if (!key) return <Navigate to="/admin" replace />;
return <Outlet />;
}

View File

@@ -0,0 +1,54 @@
import { NavLink, Outlet, useNavigate } from "react-router-dom";
const navItems = [
{ path: "/admin/stamps", label: "图章管理" },
{ path: "/admin/rules", label: "兑换规则" },
{ path: "/admin/redemptions", label: "兑换记录" },
];
export default function AdminLayout() {
const navigate = useNavigate();
const handleLogout = () => {
sessionStorage.removeItem("admin_key");
navigate("/admin");
};
return (
<div className="min-h-screen bg-gray-50 flex">
{/* Sidebar */}
<aside className="w-56 bg-white border-r border-gray-200 flex flex-col shrink-0">
<div className="px-5 py-4 border-b border-gray-200">
<h1 className="text-base font-semibold text-gray-800"></h1>
</div>
<nav className="flex-1 py-3">
{navItems.map((item) => (
<NavLink
key={item.path}
to={item.path}
className={({ isActive }) =>
`block px-5 py-2.5 text-sm transition-colors ${
isActive
? "text-blue-600 bg-blue-50 font-medium border-r-2 border-blue-600"
: "text-gray-600 hover:bg-gray-50"
}`
}
>
{item.label}
</NavLink>
))}
</nav>
<div className="px-5 py-3 border-t border-gray-200">
<button onClick={handleLogout} className="text-sm text-gray-500 hover:text-gray-700">
退
</button>
</div>
</aside>
{/* Main content */}
<main className="flex-1 p-6 overflow-auto">
<Outlet />
</main>
</div>
);
}

View File

@@ -0,0 +1,56 @@
import { useState } from "react";
import { useNavigate } from "react-router-dom";
export default function AdminLogin() {
const navigate = useNavigate();
const [key, setKey] = useState("");
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
const handleLogin = async () => {
setError("");
setLoading(true);
try {
const res = await fetch("/api/admin/stats", { headers: { "X-Admin-Key": key } });
const json = await res.json();
if (json.success) {
sessionStorage.setItem("admin_key", key);
navigate("/admin/stamps");
} else {
setError("密钥不正确");
}
} catch {
setError("连接失败");
} finally {
setLoading(false);
}
};
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
<div className="w-80 bg-white rounded-lg shadow-sm p-6 border border-gray-200">
<h1 className="text-lg font-semibold text-gray-800 mb-4 text-center"></h1>
<div className="space-y-3">
<input
type="password"
value={key}
onChange={(e) => setKey(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleLogin()}
placeholder="输入管理密钥"
className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm
focus:outline-none focus:ring-1 focus:ring-blue-500 focus:border-blue-500"
/>
{error && <p className="text-sm text-red-500">{error}</p>}
<button
onClick={handleLogin}
disabled={loading || !key}
className="w-full py-2 bg-blue-600 text-white text-sm rounded-md
hover:bg-blue-700 disabled:opacity-50 transition-colors"
>
{loading ? "验证中..." : "登录"}
</button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,94 @@
import { useState, useEffect } from "react";
import { adminFetch } from "./adminApi";
type RedemptionRecord = {
id: string;
userId: string;
stampCount: number;
redeemedAt: string;
user: { username: string; phone: string };
rule: { name: string };
};
type Stats = {
userCount: number;
collectionCount: number;
redemptionCount: number;
};
export default function RedemptionLog() {
const [records, setRecords] = useState<RedemptionRecord[]>([]);
const [stats, setStats] = useState<Stats | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
Promise.all([
adminFetch<RedemptionRecord[]>("/redemptions"),
adminFetch<Stats>("/stats"),
])
.then(([recs, st]) => {
setRecords(recs);
setStats(st);
})
.finally(() => setLoading(false));
}, []);
if (loading) return <p className="text-gray-500">...</p>;
return (
<div>
<h2 className="text-lg font-semibold text-gray-800 mb-4"></h2>
{/* Stats */}
{stats && (
<div className="grid grid-cols-3 gap-4 mb-6">
{[
{ label: "注册用户", value: stats.userCount },
{ label: "当前收集数", value: stats.collectionCount },
{ label: "累计兑换", value: stats.redemptionCount },
].map((s) => (
<div key={s.label} className="bg-white rounded-lg border border-gray-200 p-4 text-center">
<p className="text-2xl font-semibold text-gray-800">{s.value}</p>
<p className="text-xs text-gray-500 mt-1">{s.label}</p>
</div>
))}
</div>
)}
{/* Records table */}
<div className="bg-white rounded-lg border border-gray-200 overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-gray-50 border-b border-gray-200">
<tr>
<th className="text-left px-4 py-3 font-medium text-gray-600"></th>
<th className="text-left px-4 py-3 font-medium text-gray-600"></th>
<th className="text-left px-4 py-3 font-medium text-gray-600"></th>
<th className="text-center px-4 py-3 font-medium text-gray-600"></th>
<th className="text-right px-4 py-3 font-medium text-gray-600"></th>
</tr>
</thead>
<tbody>
{records.map((r) => (
<tr key={r.id} className="border-b border-gray-100">
<td className="px-4 py-3 text-gray-800">{r.user.username}</td>
<td className="px-4 py-3 text-gray-500">{r.user.phone}</td>
<td className="px-4 py-3 text-gray-700">{r.rule.name}</td>
<td className="px-4 py-3 text-center text-gray-500">{r.stampCount}</td>
<td className="px-4 py-3 text-right text-gray-500">
{new Date(r.redeemedAt).toLocaleString("zh-CN")}
</td>
</tr>
))}
{records.length === 0 && (
<tr>
<td colSpan={5} className="px-4 py-8 text-center text-gray-400">
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
);
}

View File

@@ -0,0 +1,138 @@
import { useState, useEffect } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { adminFetch } from "./adminApi";
type Rule = {
id: string;
name: string;
description: string | null;
threshold: number;
enabled: boolean;
sortOrder: number;
};
export default function RuleForm() {
const { id } = useParams();
const navigate = useNavigate();
const isEdit = !!id;
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("");
useEffect(() => {
if (!id) 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);
}
});
}, [id]);
const handleSave = async () => {
setError("");
if (!name.trim()) {
setError("请输入奖品名称");
return;
}
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) });
}
navigate("/admin/rules");
} catch (e) {
setError(e instanceof Error ? e.message : "保存失败");
} finally {
setSaving(false);
}
};
return (
<div className="max-w-xl">
<h2 className="text-lg font-semibold text-gray-800 mb-4">
{isEdit ? "编辑兑换规则" : "添加兑换规则"}
</h2>
<div className="bg-white rounded-lg border border-gray-200 p-5 space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1"></label>
<input
value={name}
onChange={(e) => setName(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm
focus:outline-none focus:ring-1 focus:ring-blue-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1"></label>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
rows={2}
className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm
focus:outline-none focus:ring-1 focus:ring-blue-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1"></label>
<input
type="number"
min={1}
value={threshold}
onChange={(e) => setThreshold(Number(e.target.value))}
className="w-24 px-3 py-2 border border-gray-300 rounded-md text-sm
focus:outline-none focus:ring-1 focus:ring-blue-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1"></label>
<input
type="number"
value={sortOrder}
onChange={(e) => setSortOrder(Number(e.target.value))}
className="w-24 px-3 py-2 border border-gray-300 rounded-md text-sm
focus:outline-none focus:ring-1 focus:ring-blue-500"
/>
</div>
{error && <p className="text-sm text-red-500">{error}</p>}
<div className="flex gap-3 pt-2">
<button
onClick={handleSave}
disabled={saving}
className="px-4 py-2 bg-blue-600 text-white text-sm rounded-md
hover:bg-blue-700 disabled:opacity-50"
>
{saving ? "保存中..." : "保存"}
</button>
<button
onClick={() => navigate("/admin/rules")}
className="px-4 py-2 border border-gray-300 text-gray-600 text-sm rounded-md hover:bg-gray-50"
>
</button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,107 @@
import { useState, useEffect } from "react";
import { Link } from "react-router-dom";
import { adminFetch } from "./adminApi";
type Rule = {
id: string;
name: string;
description: string | null;
threshold: number;
enabled: boolean;
sortOrder: number;
};
export default function RuleList() {
const [rules, setRules] = useState<Rule[]>([]);
const [loading, setLoading] = useState(true);
const fetchRules = async () => {
setLoading(true);
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;
await adminFetch(`/rules/${id}`, { method: "DELETE" });
fetchRules();
};
const handleToggle = async (id: string, enabled: boolean) => {
await adminFetch(`/rules/${id}`, {
method: "PUT",
body: JSON.stringify({ enabled: !enabled }),
});
fetchRules();
};
if (loading) return <p className="text-gray-500">...</p>;
return (
<div>
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold text-gray-800"></h2>
<Link
to="/admin/rules/new"
className="px-4 py-2 bg-blue-600 text-white text-sm rounded-md hover:bg-blue-700"
>
</Link>
</div>
<div className="bg-white rounded-lg border border-gray-200 overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-gray-50 border-b border-gray-200">
<tr>
<th className="text-left px-4 py-3 font-medium text-gray-600"></th>
<th className="text-left px-4 py-3 font-medium text-gray-600"></th>
<th className="text-center px-4 py-3 font-medium text-gray-600"></th>
<th className="text-center px-4 py-3 font-medium text-gray-600"></th>
<th className="text-right px-4 py-3 font-medium text-gray-600"></th>
</tr>
</thead>
<tbody>
{rules.map((rule) => (
<tr key={rule.id} className="border-b border-gray-100 hover:bg-gray-50">
<td className="px-4 py-3 text-gray-800">{rule.name}</td>
<td className="px-4 py-3 text-gray-500 max-w-[250px] truncate">{rule.description || "—"}</td>
<td className="px-4 py-3 text-center font-medium text-gray-700">{rule.threshold}</td>
<td className="px-4 py-3 text-center">
<button
onClick={() => handleToggle(rule.id, rule.enabled)}
className={`px-2 py-0.5 rounded text-xs ${
rule.enabled ? "bg-green-100 text-green-700" : "bg-gray-100 text-gray-500"
}`}
>
{rule.enabled ? "启用" : "禁用"}
</button>
</td>
<td className="px-4 py-3 text-right space-x-2">
<Link to={`/admin/rules/${rule.id}/edit`} className="text-blue-600 hover:underline">
</Link>
<button onClick={() => handleDelete(rule.id, rule.name)} className="text-red-500 hover:underline">
</button>
</td>
</tr>
))}
{rules.length === 0 && (
<tr>
<td colSpan={5} className="px-4 py-8 text-center text-gray-400">
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
);
}

View File

@@ -0,0 +1,181 @@
import { useState, useEffect } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { adminFetch } from "./adminApi";
type Stamp = {
id: string;
name: string;
note: string | null;
imageColor: string;
imageGrey: string;
sortOrder: number;
enabled: boolean;
};
export default function StampForm() {
const { id } = useParams();
const navigate = useNavigate();
const isEdit = !!id;
const [name, setName] = useState("");
const [note, setNote] = useState("");
const [sortOrder, setSortOrder] = useState(0);
const [imageColor, setImageColor] = useState("");
const [imageGrey, setImageGrey] = useState("");
const [saving, setSaving] = useState(false);
const [error, setError] = useState("");
useEffect(() => {
if (!id) return;
adminFetch<Stamp[]>("/stamps").then((stamps) => {
const stamp = stamps.find((s) => s.id === id);
if (stamp) {
setName(stamp.name);
setNote(stamp.note || "");
setSortOrder(stamp.sortOrder);
setImageColor(stamp.imageColor);
setImageGrey(stamp.imageGrey);
}
});
}, [id]);
const handleUpload = async (file: File, field: "imageColor" | "imageGrey") => {
if (!id) {
setError("请先保存图章后再上传图片");
return;
}
const formData = new FormData();
formData.append("image", file);
formData.append("field", field);
const data = await adminFetch<{ path: string }>(`/stamps/${id}/upload`, {
method: "POST",
body: formData,
});
if (field === "imageColor") setImageColor(data.path);
else setImageGrey(data.path);
};
const handleSave = async () => {
setError("");
if (!name.trim()) {
setError("请输入图章名称");
return;
}
setSaving(true);
try {
if (isEdit) {
await adminFetch(`/stamps/${id}`, {
method: "PUT",
body: JSON.stringify({ name: name.trim(), note: note.trim() || undefined, sortOrder }),
});
} else {
const stamp = await adminFetch<Stamp>("/stamps", {
method: "POST",
body: JSON.stringify({ name: name.trim(), note: note.trim() || undefined, sortOrder }),
});
navigate(`/admin/stamps/${stamp.id}/edit`, { replace: true });
return;
}
navigate("/admin/stamps");
} catch (e) {
setError(e instanceof Error ? e.message : "保存失败");
} finally {
setSaving(false);
}
};
return (
<div className="max-w-xl">
<h2 className="text-lg font-semibold text-gray-800 mb-4">
{isEdit ? "编辑图章" : "添加图章"}
</h2>
<div className="bg-white rounded-lg border border-gray-200 p-5 space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1"></label>
<input
value={name}
onChange={(e) => setName(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm
focus:outline-none focus:ring-1 focus:ring-blue-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1"></label>
<textarea
value={note}
onChange={(e) => setNote(e.target.value)}
rows={2}
className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm
focus:outline-none focus:ring-1 focus:ring-blue-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1"></label>
<input
type="number"
value={sortOrder}
onChange={(e) => setSortOrder(Number(e.target.value))}
className="w-24 px-3 py-2 border border-gray-300 rounded-md text-sm
focus:outline-none focus:ring-1 focus:ring-blue-500"
/>
</div>
{/* Image uploads - only available after saving */}
{isEdit && (
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1"></label>
{imageColor && (
<img src={imageColor} alt="彩色" className="w-20 h-20 object-contain bg-gray-50 rounded mb-2" />
)}
<input
type="file"
accept="image/*"
onChange={(e) => e.target.files?.[0] && handleUpload(e.target.files[0], "imageColor")}
className="text-xs text-gray-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1"></label>
{imageGrey && (
<img src={imageGrey} alt="灰色" className="w-20 h-20 object-contain bg-gray-50 rounded mb-2" />
)}
<input
type="file"
accept="image/*"
onChange={(e) => e.target.files?.[0] && handleUpload(e.target.files[0], "imageGrey")}
className="text-xs text-gray-500"
/>
</div>
</div>
)}
{!isEdit && (
<p className="text-xs text-gray-400"></p>
)}
{error && <p className="text-sm text-red-500">{error}</p>}
<div className="flex gap-3 pt-2">
<button
onClick={handleSave}
disabled={saving}
className="px-4 py-2 bg-blue-600 text-white text-sm rounded-md
hover:bg-blue-700 disabled:opacity-50"
>
{saving ? "保存中..." : "保存"}
</button>
<button
onClick={() => navigate("/admin/stamps")}
className="px-4 py-2 border border-gray-300 text-gray-600 text-sm rounded-md hover:bg-gray-50"
>
</button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,119 @@
import { useState, useEffect } from "react";
import { Link } from "react-router-dom";
import { adminFetch } from "./adminApi";
type Stamp = {
id: string;
name: string;
note: string | null;
imageColor: string;
imageGrey: string;
sortOrder: number;
enabled: boolean;
};
export default function StampList() {
const [stamps, setStamps] = useState<Stamp[]>([]);
const [loading, setLoading] = useState(true);
const fetchStamps = async () => {
setLoading(true);
try {
const data = await adminFetch<Stamp[]>("/stamps");
setStamps(data);
} finally {
setLoading(false);
}
};
useEffect(() => { fetchStamps(); }, []);
const handleDelete = async (id: string, name: string) => {
if (!confirm(`确定删除图章「${name}」?`)) return;
await adminFetch(`/stamps/${id}`, { method: "DELETE" });
fetchStamps();
};
const handleToggle = async (id: string, enabled: boolean) => {
await adminFetch(`/stamps/${id}`, {
method: "PUT",
body: JSON.stringify({ enabled: !enabled }),
});
fetchStamps();
};
if (loading) return <p className="text-gray-500">...</p>;
return (
<div>
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold text-gray-800"></h2>
<Link
to="/admin/stamps/new"
className="px-4 py-2 bg-blue-600 text-white text-sm rounded-md hover:bg-blue-700"
>
</Link>
</div>
<div className="bg-white rounded-lg border border-gray-200 overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-gray-50 border-b border-gray-200">
<tr>
<th className="text-left px-4 py-3 font-medium text-gray-600"></th>
<th className="text-left px-4 py-3 font-medium text-gray-600"></th>
<th className="text-left px-4 py-3 font-medium text-gray-600"></th>
<th className="text-center px-4 py-3 font-medium text-gray-600"></th>
<th className="text-center px-4 py-3 font-medium text-gray-600"></th>
<th className="text-right px-4 py-3 font-medium text-gray-600"></th>
</tr>
</thead>
<tbody>
{stamps.map((stamp) => (
<tr key={stamp.id} className="border-b border-gray-100 hover:bg-gray-50">
<td className="px-4 py-3">
<div className="w-10 h-10 rounded bg-gray-100 overflow-hidden">
{stamp.imageColor && (
<img src={stamp.imageColor} alt="" className="w-full h-full object-contain" />
)}
</div>
</td>
<td className="px-4 py-3 text-gray-800">{stamp.name}</td>
<td className="px-4 py-3 text-gray-500 max-w-[200px] truncate">{stamp.note || "—"}</td>
<td className="px-4 py-3 text-center text-gray-500">{stamp.sortOrder}</td>
<td className="px-4 py-3 text-center">
<button
onClick={() => handleToggle(stamp.id, stamp.enabled)}
className={`px-2 py-0.5 rounded text-xs ${
stamp.enabled ? "bg-green-100 text-green-700" : "bg-gray-100 text-gray-500"
}`}
>
{stamp.enabled ? "启用" : "禁用"}
</button>
</td>
<td className="px-4 py-3 text-right space-x-2">
<Link to={`/admin/stamps/${stamp.id}/edit`} className="text-blue-600 hover:underline">
</Link>
<Link to={`/admin/stamps/${stamp.id}/qrcode`} className="text-blue-600 hover:underline">
</Link>
<button onClick={() => handleDelete(stamp.id, stamp.name)} className="text-red-500 hover:underline">
</button>
</td>
</tr>
))}
{stamps.length === 0 && (
<tr>
<td colSpan={6} className="px-4 py-8 text-center text-gray-400">
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
);
}

View File

@@ -0,0 +1,96 @@
import { useState, useEffect, useRef } from "react";
import { useParams, Link } from "react-router-dom";
import { adminFetch } from "./adminApi";
type QRData = {
qrDataUrl: string;
collectUrl: string;
stampName: string;
};
export default function StampQRCode() {
const { id } = useParams();
const [data, setData] = useState<QRData | null>(null);
const [loading, setLoading] = useState(true);
const canvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
if (!id) return;
adminFetch<QRData>(`/stamps/${id}/qrcode`)
.then(setData)
.finally(() => setLoading(false));
}, [id]);
// Render composite image (QR code + URL text) to canvas
useEffect(() => {
if (!data || !canvasRef.current) return;
const canvas = canvasRef.current;
const ctx = canvas.getContext("2d")!;
const img = new Image();
img.onload = () => {
const padding = 20;
const textHeight = 40;
canvas.width = img.width + padding * 2;
canvas.height = img.height + padding * 2 + textHeight;
// White background
ctx.fillStyle = "#ffffff";
ctx.fillRect(0, 0, canvas.width, canvas.height);
// QR code
ctx.drawImage(img, padding, padding);
// URL text below
ctx.fillStyle = "#666666";
ctx.font = "12px sans-serif";
ctx.textAlign = "center";
ctx.fillText(data.collectUrl, canvas.width / 2, img.height + padding * 2 + 12);
};
img.src = data.qrDataUrl;
}, [data]);
const handleDownload = () => {
if (!canvasRef.current || !data) return;
const link = document.createElement("a");
link.download = `${data.stampName}-二维码.png`;
link.href = canvasRef.current.toDataURL("image/png");
link.click();
};
if (loading) return <p className="text-gray-500">...</p>;
if (!data) return <p className="text-gray-500"></p>;
return (
<div className="max-w-md">
<div className="flex items-center gap-2 mb-4">
<Link to="/admin/stamps" className="text-gray-400 hover:text-gray-600">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M15 18l-6-6 6-6" />
</svg>
</Link>
<h2 className="text-lg font-semibold text-gray-800">{data.stampName} </h2>
</div>
<div className="bg-white rounded-lg border border-gray-200 p-6 text-center space-y-4">
{/* QR code display */}
<div className="inline-block">
<img src={data.qrDataUrl} alt="二维码" className="w-64 h-64" />
</div>
{/* URL display */}
<p className="text-xs text-gray-500 break-all select-all">{data.collectUrl}</p>
{/* Hidden canvas for composite download */}
<canvas ref={canvasRef} className="hidden" />
<button
onClick={handleDownload}
className="px-4 py-2 bg-blue-600 text-white text-sm rounded-md hover:bg-blue-700"
>
</button>
</div>
</div>
);
}

View File

@@ -0,0 +1,21 @@
type ApiResponse<T> = {
success: boolean;
data?: T;
error?: { code: string; message: string };
};
export async function adminFetch<T>(path: string, options: RequestInit = {}): Promise<T> {
const key = sessionStorage.getItem("admin_key");
if (!key) throw new Error("未登录");
const headers = new Headers(options.headers);
headers.set("X-Admin-Key", key);
if (!headers.has("Content-Type") && !(options.body instanceof FormData)) {
headers.set("Content-Type", "application/json");
}
const res = await fetch(`/api/admin${path}`, { ...options, headers });
const json: ApiResponse<T> = await res.json();
if (!json.success) throw new Error(json.error?.message || "请求失败");
return json.data as T;
}

View File

@@ -0,0 +1,25 @@
import { useNavigate } from "react-router-dom";
export default function FloatingButton() {
const navigate = useNavigate();
return (
<div className="fixed bottom-6 left-0 right-0 z-50 flex justify-center safe-bottom">
<button
onClick={() => navigate("/album")}
className="animate-pulse-glow active:scale-95 transition-transform"
style={{
padding: "14px 44px",
borderRadius: "100px",
background: "linear-gradient(135deg, var(--terracotta) 0%, #d4623f 100%)",
color: "var(--text-inverted)",
fontSize: "15px",
fontWeight: 500,
letterSpacing: "0.15em",
}}
>
</button>
</div>
);
}

View File

@@ -0,0 +1,94 @@
import { useState } from "react";
import type { RedemptionRuleInfo } from "@stamp/shared";
type RedeemModalProps = {
rules: RedemptionRuleInfo[];
collectedCount: number;
onRedeem: (ruleId: string) => Promise<void>;
onClose: () => void;
};
export default function RedeemModal({ rules, collectedCount, onRedeem, onClose }: RedeemModalProps) {
const [redeeming, setRedeeming] = useState<string | null>(null);
const [error, setError] = useState("");
const handleRedeem = async (ruleId: string) => {
if (!confirm("兑换后所有已收集的图章将被清空,确定兑换吗?")) return;
setRedeeming(ruleId);
setError("");
try {
await onRedeem(ruleId);
onClose();
} catch (e) {
setError(e instanceof Error ? e.message : "兑换失败");
} finally {
setRedeeming(null);
}
};
return (
<div className="fixed inset-0 z-50 flex items-end justify-center animate-overlay-fade"
style={{ backgroundColor: "var(--overlay)" }}
onClick={(e) => e.target === e.currentTarget && onClose()}
>
<div className="w-full max-w-lg bg-[var(--bg-cream)] rounded-t-2xl p-6 pb-10 animate-slide-up safe-bottom">
<div className="flex items-center justify-between mb-5">
<h3 className="text-lg font-semibold text-[var(--text-primary)]"></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" />
</svg>
</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={() => handleRedeem(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)",
opacity: redeeming === rule.id ? 0.6 : 1,
}}
>
{redeeming === rule.id ? "兑换中..." : "兑换"}
</button>
</div>
);
})}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,131 @@
import { useState } from "react";
import { useAuth } from "../lib/auth";
type RegisterModalProps = {
onSuccess: () => void;
onClose: () => void;
};
const phoneRegex = /^1[3-9]\d{9}$/;
export default function RegisterModal({ onSuccess, onClose }: RegisterModalProps) {
const { register, login } = useAuth();
const [mode, setMode] = useState<"register" | "login">("register");
const [username, setUsername] = useState("");
const [phone, setPhone] = useState("");
const [error, setError] = useState("");
const [submitting, setSubmitting] = useState(false);
const handleSubmit = async () => {
setError("");
if (!phoneRegex.test(phone)) {
setError("请输入正确的手机号");
return;
}
if (mode === "register" && !username.trim()) {
setError("请输入用户名");
return;
}
setSubmitting(true);
try {
if (mode === "register") {
await register(username.trim(), phone);
} else {
await login(phone);
}
onSuccess();
} catch (e) {
setError(e instanceof Error ? e.message : "操作失败");
} finally {
setSubmitting(false);
}
};
return (
<div
className="fixed inset-0 z-50 flex items-end justify-center animate-overlay-fade"
style={{ backgroundColor: "var(--overlay)" }}
onClick={(e) => e.target === e.currentTarget && onClose()}
>
<div className="w-full max-w-lg bg-[var(--bg-cream)] rounded-t-2xl p-6 pb-10 animate-slide-up safe-bottom">
<div className="flex items-center justify-between mb-5">
<h3 className="text-lg font-semibold text-[var(--text-primary)]">
{mode === "register" ? "注册账号" : "登录"}
</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" />
</svg>
</button>
</div>
<div className="space-y-4">
{mode === "register" && (
<div>
<label className="text-sm text-[var(--text-secondary)] mb-1.5 block"></label>
<input
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder="请输入用户名"
maxLength={20}
className="w-full px-4 py-3 rounded-xl border border-[var(--border-default)]
bg-white text-[var(--text-primary)] text-sm
focus:outline-none focus:border-[var(--gold)]
placeholder:text-[var(--text-muted)]"
/>
</div>
)}
<div>
<label className="text-sm text-[var(--text-secondary)] mb-1.5 block"></label>
<input
type="tel"
value={phone}
onChange={(e) => setPhone(e.target.value.replace(/\D/g, "").slice(0, 11))}
placeholder="请输入手机号"
className="w-full px-4 py-3 rounded-xl border border-[var(--border-default)]
bg-white text-[var(--text-primary)] text-sm
focus:outline-none focus:border-[var(--gold)]
placeholder:text-[var(--text-muted)]"
/>
</div>
{error && <p className="text-sm text-[var(--terracotta)]">{error}</p>}
<button
onClick={handleSubmit}
disabled={submitting}
className="w-full py-3 rounded-xl text-sm font-medium text-white transition-opacity"
style={{
backgroundColor: "var(--terracotta)",
opacity: submitting ? 0.6 : 1,
}}
>
{submitting ? "请稍候..." : mode === "register" ? "注册" : "登录"}
</button>
<p className="text-center text-xs text-[var(--text-muted)]">
{mode === "register" ? (
<>
<button onClick={() => setMode("login")} className="text-[var(--gold)] ml-1">
</button>
</>
) : (
<>
<button onClick={() => setMode("register")} className="text-[var(--gold)] ml-1">
</button>
</>
)}
</p>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,68 @@
type StampCardProps = {
name: string;
imageColor: string;
imageGrey: string;
collected: boolean;
onClick?: () => void;
};
export default function StampCard({ name, imageColor, imageGrey, collected, onClick }: StampCardProps) {
return (
<button
onClick={onClick}
className="flex flex-col items-center gap-2 p-3 rounded-xl transition-transform active:scale-95"
>
{/* Stamp image with perforated border effect */}
<div
className="relative w-full aspect-square rounded-lg overflow-hidden
shadow-[var(--shadow-sm)] border border-[var(--border-muted)]"
style={{
background: collected
? "linear-gradient(135deg, #fdf6ee 0%, #f8eed8 100%)"
: "linear-gradient(135deg, #f0f0f0 0%, #e8e8e8 100%)",
}}
>
{/* Perforated edge effect */}
<div className="absolute inset-0 stamp-border pointer-events-none z-10" />
<img
src={collected ? imageColor : imageGrey}
alt={name}
className="w-full h-full object-contain p-3"
style={collected ? {} : { filter: "grayscale(1) opacity(0.4)" }}
onError={(e) => {
// Fallback for missing images: show placeholder
const target = e.target as HTMLImageElement;
target.style.display = "none";
target.parentElement!.innerHTML += `
<div class="w-full h-full flex items-center justify-center text-3xl"
style="color: ${collected ? "var(--gold)" : "var(--text-muted)"}; opacity: ${collected ? 1 : 0.3}">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<rect x="3" y="3" width="18" height="18" rx="2"/>
<circle cx="12" cy="12" r="4"/>
</svg>
</div>
`;
}}
/>
{/* Collected badge */}
{collected && (
<div className="absolute top-1 right-1 w-5 h-5 rounded-full bg-[var(--jade)] flex items-center justify-center">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="white" strokeWidth="3">
<polyline points="20 6 9 17 4 12" />
</svg>
</div>
)}
</div>
{/* Stamp name */}
<span
className="text-xs font-medium truncate w-full text-center"
style={{ color: collected ? "var(--text-primary)" : "var(--text-muted)" }}
>
{name}
</span>
</button>
);
}

View File

@@ -0,0 +1,24 @@
import type { StampWithStatus } from "@stamp/shared";
import StampCard from "./StampCard";
type StampGridProps = {
stamps: StampWithStatus[];
onStampClick?: (stamp: StampWithStatus) => void;
};
export default function StampGrid({ stamps, onStampClick }: StampGridProps) {
return (
<div className="grid grid-cols-3 gap-3 stagger-children">
{stamps.map((stamp) => (
<StampCard
key={stamp.id}
name={stamp.name}
imageColor={stamp.imageColor}
imageGrey={stamp.imageGrey}
collected={stamp.collected}
onClick={() => onStampClick?.(stamp)}
/>
))}
</div>
);
}

View File

@@ -0,0 +1,94 @@
import { useNavigate } from "react-router-dom";
type StampPopupProps = {
name: string;
imageColor: string;
note?: string | null;
status: "preview" | "collected" | "already";
onCollect?: () => void;
onClose: () => void;
};
export default function StampPopup({ name, imageColor, note, status, onCollect, onClose }: StampPopupProps) {
const navigate = useNavigate();
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center animate-overlay-fade"
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)]">
{/* Stamp image */}
<div className="w-40 h-40 mx-auto mb-4">
<div className="w-full h-full rounded-xl overflow-hidden animate-stamp-press"
style={{ background: "linear-gradient(135deg, #fdf6ee 0%, #f8eed8 100%)" }}
>
<img
src={imageColor}
alt={name}
className="w-full h-full object-contain p-4"
onError={(e) => {
const target = e.target as HTMLImageElement;
target.style.display = "none";
}}
/>
</div>
</div>
{/* 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>}
{/* Status message & action */}
{status === "preview" && (
<button
onClick={onCollect}
className="w-full py-3 rounded-xl text-sm font-medium text-white mt-2"
style={{ backgroundColor: "var(--terracotta)" }}
>
</button>
)}
{status === "collected" && (
<div className="mt-2">
<div className="flex items-center justify-center gap-1.5 text-[var(--jade)] mb-3">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
<polyline points="20 6 9 17 4 12" />
</svg>
<span className="text-sm font-medium">!</span>
</div>
<button
onClick={() => navigate("/album")}
className="w-full py-3 rounded-xl text-sm font-medium text-white"
style={{ backgroundColor: "var(--jade)" }}
>
</button>
</div>
)}
{status === "already" && (
<>
<p className="text-sm text-[var(--text-muted)] mt-2"></p>
<button
onClick={() => navigate("/album")}
className="w-full py-3 rounded-xl text-sm font-medium text-white mt-3"
style={{ backgroundColor: "var(--jade)" }}
>
</button>
</>
)}
<button
onClick={onClose}
className="mt-3 text-xs text-[var(--text-muted)] underline underline-offset-2"
>
{status === "preview" ? "关闭" : "继续浏览"}
</button>
</div>
</div>
);
}

196
packages/web/src/index.css Normal file
View File

@@ -0,0 +1,196 @@
@import "tailwindcss";
/* ===== Base Styles (in @layer base so utilities can override) ===== */
@layer base {
:root {
--bg-cream: #f5f0e8;
--bg-paper: #ece5d8;
--bg-dark: #1a1a2e;
--bg-dark-deep: #10101e;
--text-primary: #1a1a2e;
--text-secondary: #4a4553;
--text-muted: #8a8494;
--text-inverted: #f5f0e8;
--gold: #d4a574;
--gold-light: #e8c9a0;
--gold-hover: #c49464;
--terracotta: #c75b39;
--terracotta-hover: #b04d2f;
--jade: #2d6a4f;
--jade-light: #40916c;
--border-default: #d4cfc5;
--border-muted: #e8e3d9;
--shadow-sm: 0 1px 3px rgba(26, 26, 46, 0.08);
--shadow-md: 0 4px 12px rgba(26, 26, 46, 0.12);
--shadow-lg: 0 8px 24px rgba(26, 26, 46, 0.16);
--overlay: rgba(26, 26, 46, 0.5);
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: "Noto Sans SC", sans-serif;
color: var(--text-primary);
background-color: var(--bg-dark);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
-webkit-tap-highlight-color: transparent;
overscroll-behavior: none;
}
h1, h2, h3, h4 {
font-family: "Playfair Display", serif;
}
}
/* ===== Keyframes ===== */
@keyframes stamp-press {
0% { transform: scale(1.3) rotate(-5deg); opacity: 0; }
40% { transform: scale(0.95) rotate(2deg); opacity: 1; }
60% { transform: scale(1.05) rotate(-1deg); }
80% { transform: scale(0.98) rotate(0.5deg); }
100% { transform: scale(1) rotate(0deg); }
}
@keyframes fade-in-up {
from { opacity: 0; transform: translateY(24px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes fade-in {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes pulse-soft {
0%, 100% { transform: scale(1); box-shadow: 0 4px 12px rgba(199, 91, 57, 0.3); }
50% { transform: scale(1.03); box-shadow: 0 6px 20px rgba(199, 91, 57, 0.45); }
}
@keyframes pulse-glow {
0%, 100% { box-shadow: 0 0 20px rgba(212, 165, 116, 0.15), 0 8px 32px rgba(199, 91, 57, 0.3); }
50% { box-shadow: 0 0 40px rgba(212, 165, 116, 0.25), 0 8px 40px rgba(199, 91, 57, 0.45); }
}
@keyframes slide-up {
from { transform: translateY(100%); }
to { transform: translateY(0); }
}
@keyframes overlay-fade {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes float {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-6px); }
}
@keyframes rotate-slow {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
@keyframes scale-in {
from { opacity: 0; transform: scale(0.85); }
to { opacity: 1; transform: scale(1); }
}
/* ===== Component Classes (in @layer components) ===== */
@layer components {
.safe-bottom {
padding-bottom: env(safe-area-inset-bottom, 0px);
}
.animate-stamp-press { animation: stamp-press 0.6s cubic-bezier(0.34, 1.56, 0.64, 1) both; }
.animate-fade-in-up { opacity: 0; animation: fade-in-up 0.6s cubic-bezier(0.22, 1, 0.36, 1) both; }
.animate-fade-in { opacity: 0; animation: fade-in 0.8s ease-out both; }
.animate-pulse-soft { animation: pulse-soft 2s ease-in-out infinite; }
.animate-pulse-glow { animation: pulse-glow 3s ease-in-out infinite; }
.animate-slide-up { animation: slide-up 0.35s cubic-bezier(0.22, 1, 0.36, 1) both; }
.animate-overlay-fade { animation: overlay-fade 0.25s ease-out both; }
.animate-float { animation: float 4s ease-in-out infinite; }
.animate-scale-in { opacity: 0; animation: scale-in 0.5s cubic-bezier(0.22, 1, 0.36, 1) both; }
/* Stagger children */
.stagger-children > * {
opacity: 0;
animation: fade-in-up 0.5s cubic-bezier(0.22, 1, 0.36, 1) both;
}
.stagger-children > *:nth-child(1) { animation-delay: 0.1s; }
.stagger-children > *:nth-child(2) { animation-delay: 0.2s; }
.stagger-children > *:nth-child(3) { animation-delay: 0.3s; }
.stagger-children > *:nth-child(4) { animation-delay: 0.4s; }
.stagger-children > *:nth-child(5) { animation-delay: 0.5s; }
.stagger-children > *:nth-child(6) { animation-delay: 0.6s; }
.stagger-children > *:nth-child(7) { animation-delay: 0.7s; }
.stagger-children > *:nth-child(8) { animation-delay: 0.8s; }
.stagger-children > *:nth-child(9) { animation-delay: 0.9s; }
/* Stamp Card Effects */
.stamp-border {
background-image: radial-gradient(circle, var(--bg-cream) 4px, transparent 4px);
background-size: 12px 12px;
background-position: -6px -6px;
}
/* Grain / Noise Texture */
.grain-overlay::before {
content: "";
position: fixed;
inset: 0;
z-index: 9999;
pointer-events: none;
opacity: 0.035;
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 512 512' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.75' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
background-size: 256px 256px;
}
/* Paper Texture */
.paper-texture {
position: relative;
background-color: var(--bg-cream);
background-image:
radial-gradient(ellipse at 20% 0%, rgba(212, 165, 116, 0.08) 0%, transparent 60%),
radial-gradient(ellipse at 80% 100%, rgba(199, 91, 57, 0.05) 0%, transparent 60%);
}
/* Decorative Elements */
.ornament-line {
height: 1px;
background: linear-gradient(90deg, transparent 0%, var(--gold) 20%, var(--gold) 80%, transparent 100%);
opacity: 0.3;
}
.stamp-seal {
position: relative;
display: flex;
align-items: center;
justify-content: center;
}
.stamp-seal::before {
content: "";
position: absolute;
inset: -8px;
border: 2px dashed;
border-color: rgba(212, 165, 116, 0.25);
border-radius: 50%;
animation: rotate-slow 30s linear infinite;
}
.stamp-seal::after {
content: "";
position: absolute;
inset: -20px;
border: 1px solid;
border-color: rgba(212, 165, 116, 0.1);
border-radius: 50%;
animation: rotate-slow 45s linear infinite reverse;
}
}

View File

@@ -0,0 +1,41 @@
export const API_BASE = "/api";
type ApiResponse<T> = {
success: boolean;
data?: T;
error?: { code: string; message: string };
};
let token: string | null = localStorage.getItem("stamp_token");
export function setToken(t: string) {
token = t;
localStorage.setItem("stamp_token", t);
}
export function clearToken() {
token = null;
localStorage.removeItem("stamp_token");
}
export function getToken() {
return token;
}
export async function apiFetch<T>(path: string, options: RequestInit = {}): Promise<T> {
const headers = new Headers(options.headers);
if (!headers.has("Content-Type") && !(options.body instanceof FormData)) {
headers.set("Content-Type", "application/json");
}
if (token) {
headers.set("Authorization", `Bearer ${token}`);
}
const res = await fetch(`${API_BASE}${path}`, { ...options, headers });
const json: ApiResponse<T> = await res.json();
if (!json.success) {
throw new Error(json.error?.message || "请求失败");
}
return json.data as T;
}

View File

@@ -0,0 +1,66 @@
import { createContext, useContext, useState, useEffect, useCallback, type ReactNode } from "react";
import { apiFetch, setToken, clearToken, getToken } from "./api";
type User = {
id: string;
username: string;
phone: string;
};
type AuthContextType = {
user: User | null;
isLoading: boolean;
register: (username: string, phone: string) => Promise<void>;
login: (phone: string) => Promise<void>;
logout: () => void;
};
const AuthContext = createContext<AuthContextType | null>(null);
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(null);
const [isLoading, setIsLoading] = useState(!!getToken());
useEffect(() => {
if (!getToken()) return;
apiFetch<User>("/auth/me")
.then(setUser)
.catch(() => clearToken())
.finally(() => setIsLoading(false));
}, []);
const register = useCallback(async (username: string, phone: string) => {
const data = await apiFetch<{ user: User; token: string }>("/auth/register", {
method: "POST",
body: JSON.stringify({ username, phone }),
});
setToken(data.token);
setUser(data.user);
}, []);
const login = useCallback(async (phone: string) => {
const data = await apiFetch<{ user: User; token: string }>("/auth/login", {
method: "POST",
body: JSON.stringify({ phone }),
});
setToken(data.token);
setUser(data.user);
}, []);
const logout = useCallback(() => {
clearToken();
setUser(null);
}, []);
return (
<AuthContext.Provider value={{ user, isLoading, register, login, logout }}>
{children}
</AuthContext.Provider>
);
}
export function useAuth() {
const ctx = useContext(AuthContext);
if (!ctx) throw new Error("useAuth must be used within AuthProvider");
return ctx;
}

13
packages/web/src/main.tsx Normal file
View File

@@ -0,0 +1,13 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { BrowserRouter } from "react-router-dom";
import App from "./App";
import "./index.css";
createRoot(document.getElementById("root")!).render(
<StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
</StrictMode>
);

View File

@@ -0,0 +1,164 @@
import { useState, useEffect } from "react";
import { useNavigate } from "react-router-dom";
import type { StampWithStatus, RedemptionRuleInfo, RedemptionRecord } from "@stamp/shared";
import { apiFetch } from "../lib/api";
import { useAuth } from "../lib/auth";
import StampGrid from "../components/StampGrid";
import RedeemModal from "../components/RedeemModal";
import RegisterModal from "../components/RegisterModal";
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 [showRegister, setShowRegister] = useState(false);
const collectedCount = stamps.filter((s) => s.collected).length;
const fetchData = async () => {
setLoading(true);
try {
const [stampsData, rulesData] = await Promise.all([
apiFetch<StampWithStatus[]>("/stamps"),
apiFetch<RedemptionRuleInfo[]>("/redemption/rules"),
]);
setStamps(stampsData);
setRules(rulesData);
if (user) {
const historyData = await apiFetch<RedemptionRecord[]>("/redemption/history");
setHistory(historyData);
}
} catch {
// Stamps endpoint works without auth
} finally {
setLoading(false);
}
};
useEffect(() => {
if (!authLoading) fetchData();
}, [authLoading, user]);
const handleRedeem = async (ruleId: string) => {
await apiFetch("/redemption/redeem", {
method: "POST",
body: JSON.stringify({ ruleId }),
});
await fetchData();
};
const handleRedeemClick = () => {
if (!user) {
setShowRegister(true);
return;
}
setShowRedeem(true);
};
if (loading || authLoading) {
return (
<div className="min-h-screen bg-[var(--bg-cream)] flex items-center justify-center">
<div className="w-8 h-8 border-2 border-[var(--gold)] border-t-transparent rounded-full animate-spin" />
</div>
);
}
return (
<div className="min-h-screen bg-[var(--bg-cream)] paper-texture">
{/* Header */}
<div className="sticky top-0 z-40 bg-[var(--bg-cream)]/90 backdrop-blur-sm border-b border-[var(--border-muted)]">
<div className="flex items-center justify-between px-4 py-3">
<button onClick={() => navigate("/")} className="text-[var(--text-secondary)] p-1">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M15 18l-6-6 6-6" />
</svg>
</button>
<h1 className="text-base font-semibold text-[var(--text-primary)]"></h1>
<div className="w-8" />
</div>
</div>
{/* Progress */}
<div className="px-6 pt-5 pb-4">
<div className="flex items-center justify-between mb-2">
<span className="text-sm text-[var(--text-secondary)]"></span>
<span className="text-sm font-semibold text-[var(--gold)]">
{collectedCount} / {stamps.length}
</span>
</div>
<div className="h-2 bg-[var(--border-muted)] rounded-full overflow-hidden">
<div
className="h-full bg-gradient-to-r from-[var(--gold)] to-[var(--terracotta)] rounded-full transition-all duration-500"
style={{ width: stamps.length ? `${(collectedCount / stamps.length) * 100}%` : "0%" }}
/>
</div>
</div>
{/* Stamp Grid */}
<div className="px-4 pb-6">
<StampGrid stamps={stamps} />
</div>
{/* Redeem Section */}
{rules.length > 0 && (
<div className="px-6 pb-6">
<button
onClick={handleRedeemClick}
className="w-full py-3 rounded-xl text-sm font-medium transition-colors"
style={{
backgroundColor: collectedCount > 0 ? "var(--jade)" : "var(--border-muted)",
color: collectedCount > 0 ? "white" : "var(--text-muted)",
}}
>
({rules.filter((r) => collectedCount >= r.threshold).length} )
</button>
</div>
)}
{/* Redemption History */}
{history.length > 0 && (
<div className="px-6 pb-8">
<h3 className="text-sm font-medium text-[var(--text-secondary)] mb-3"></h3>
<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>
<p className="text-xs text-[var(--text-muted)]">
{new Date(r.redeemedAt).toLocaleDateString("zh-CN")}
</p>
</div>
<span className="text-xs text-[var(--jade)]"></span>
</div>
))}
</div>
</div>
)}
{/* Modals */}
{showRedeem && (
<RedeemModal
rules={rules}
collectedCount={collectedCount}
onRedeem={handleRedeem}
onClose={() => setShowRedeem(false)}
/>
)}
{showRegister && (
<RegisterModal
onSuccess={() => {
setShowRegister(false);
fetchData();
}}
onClose={() => setShowRegister(false)}
/>
)}
</div>
);
}

View File

@@ -0,0 +1,288 @@
import { useState, useEffect, useCallback } from "react";
import { useSearchParams, useNavigate } from "react-router-dom";
import { apiFetch } from "../lib/api";
import { useAuth } from "../lib/auth";
import FloatingButton from "../components/FloatingButton";
import StampPopup from "../components/StampPopup";
import RegisterModal from "../components/RegisterModal";
const PENDING_STAMP_KEY = "stamp_pending_collect";
type StampDetail = {
id: string;
name: string;
note: string | null;
imageColor: string;
imageGrey: string;
};
type CollectState = "idle" | "loading" | "show_stamp" | "needs_register" | "collecting" | "collected" | "already_collected";
const STEPS = [
{ num: "01", title: "前往点位", desc: "跟随路线,探索散落在城市中的文化地标" },
{ num: "02", title: "扫码集章", desc: "发现点位专属二维码,扫描即刻收入囊中" },
{ num: "03", title: "兑换好礼", desc: "集满图章,兑换城市限定纪念品" },
];
export default function LandingPage() {
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const { user, isLoading: authLoading } = useAuth();
const stampId = searchParams.get("stamp");
const [stamp, setStamp] = useState<StampDetail | null>(null);
const [collectState, setCollectState] = useState<CollectState>("idle");
// Fetch stamp info when stampId is present
useEffect(() => {
if (!stampId || authLoading) return;
setCollectState("loading");
apiFetch<StampDetail>(`/stamps/${stampId}`)
.then((data) => {
setStamp(data);
setCollectState("show_stamp");
})
.catch(() => {
setCollectState("idle");
});
}, [stampId, authLoading]);
const doCollect = useCallback(async () => {
if (!stampId) return;
setCollectState("collecting");
try {
await apiFetch(`/stamps/${stampId}/collect`, { method: "POST" });
setCollectState("collected");
sessionStorage.removeItem(PENDING_STAMP_KEY);
} catch (e) {
const msg = e instanceof Error ? e.message : "";
if (msg.includes("已经收集")) {
setCollectState("already_collected");
} else {
setCollectState("idle");
}
}
}, [stampId]);
// Auto-collect if user just registered and has a pending stamp
useEffect(() => {
if (authLoading || !user || collectState !== "show_stamp" || !stampId) return;
const pending = sessionStorage.getItem(PENDING_STAMP_KEY);
if (pending === stampId) {
doCollect();
}
}, [authLoading, user, collectState, stampId, doCollect]);
const handleCollect = () => {
if (!user) {
sessionStorage.setItem(PENDING_STAMP_KEY, stampId!);
setCollectState("needs_register");
return;
}
doCollect();
};
const handleRegisterSuccess = () => {
setCollectState("show_stamp");
doCollect();
};
const handleClose = () => {
setCollectState("idle");
setStamp(null);
navigate("/", { replace: true });
};
const showStampPopup = stamp && (collectState === "show_stamp" || collectState === "collecting");
const showCollectedPopup = stamp && collectState === "collected";
const showAlreadyPopup = stamp && collectState === "already_collected";
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="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">
<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>
</div>
<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>
</div>
{i < STEPS.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>
</div>
))}
</div>
</div>
</section>
<FloatingButton />
{/* ═══════════ Collection Overlays ═══════════ */}
{showStampPopup && (
<StampPopup
name={stamp.name}
imageColor={stamp.imageColor}
note={stamp.note}
status="preview"
onCollect={handleCollect}
onClose={handleClose}
/>
)}
{showCollectedPopup && (
<StampPopup
name={stamp.name}
imageColor={stamp.imageColor}
note={stamp.note}
status="collected"
onClose={handleClose}
/>
)}
{showAlreadyPopup && (
<StampPopup
name={stamp.name}
imageColor={stamp.imageColor}
note={stamp.note}
status="already"
onClose={handleClose}
/>
)}
{showRegister && (
<RegisterModal
onSuccess={handleRegisterSuccess}
onClose={() => setCollectState("show_stamp")}
/>
)}
</div>
);
}

View File

@@ -0,0 +1,11 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src",
"jsx": "react-jsx",
"declaration": false,
"declarationMap": false
},
"include": ["src"]
}

View File

@@ -0,0 +1,13 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite";
export default defineConfig({
plugins: [react(), tailwindcss()],
server: {
proxy: {
"/api": "http://localhost:3000",
"/uploads": "http://localhost:3000",
},
},
});

2633
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

2
pnpm-workspace.yaml Normal file
View File

@@ -0,0 +1,2 @@
packages:
- "packages/*"

View File

@@ -0,0 +1,69 @@
-- CreateTable
CREATE TABLE "User" (
"id" TEXT NOT NULL PRIMARY KEY,
"username" TEXT NOT NULL,
"phone" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL
);
-- CreateTable
CREATE TABLE "Stamp" (
"id" TEXT NOT NULL PRIMARY KEY,
"name" TEXT NOT NULL,
"note" TEXT,
"imageColor" TEXT NOT NULL,
"imageGrey" TEXT NOT NULL,
"sortOrder" INTEGER NOT NULL DEFAULT 0,
"enabled" BOOLEAN NOT NULL DEFAULT true,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL
);
-- CreateTable
CREATE TABLE "Collection" (
"id" TEXT NOT NULL PRIMARY KEY,
"userId" TEXT NOT NULL,
"stampId" TEXT NOT NULL,
"collectedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Collection_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "Collection_stampId_fkey" FOREIGN KEY ("stampId") REFERENCES "Stamp" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "RedemptionRule" (
"id" TEXT NOT NULL PRIMARY KEY,
"name" TEXT NOT NULL,
"description" TEXT,
"threshold" INTEGER NOT NULL,
"enabled" BOOLEAN NOT NULL DEFAULT true,
"sortOrder" INTEGER NOT NULL DEFAULT 0,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL
);
-- CreateTable
CREATE TABLE "Redemption" (
"id" TEXT NOT NULL PRIMARY KEY,
"userId" TEXT NOT NULL,
"ruleId" TEXT NOT NULL,
"stampCount" INTEGER 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_ruleId_fkey" FOREIGN KEY ("ruleId") REFERENCES "RedemptionRule" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
);
-- CreateIndex
CREATE UNIQUE INDEX "User_username_key" ON "User"("username");
-- CreateIndex
CREATE UNIQUE INDEX "User_phone_key" ON "User"("phone");
-- CreateIndex
CREATE INDEX "Collection_userId_idx" ON "Collection"("userId");
-- CreateIndex
CREATE UNIQUE INDEX "Collection_userId_stampId_key" ON "Collection"("userId", "stampId");
-- CreateIndex
CREATE INDEX "Redemption_userId_idx" ON "Redemption"("userId");

View File

@@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (e.g., Git)
provider = "sqlite"

67
prisma/schema.prisma Normal file
View File

@@ -0,0 +1,67 @@
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "sqlite"
url = env("DATABASE_URL")
}
model User {
id String @id @default(uuid())
username String @unique
phone String @unique
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
collections Collection[]
redemptions Redemption[]
}
model Stamp {
id String @id @default(uuid())
name String
note String?
imageColor String
imageGrey String
sortOrder Int @default(0)
enabled Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
collections Collection[]
}
model Collection {
id String @id @default(uuid())
userId String
stampId String
collectedAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
stamp Stamp @relation(fields: [stampId], references: [id], onDelete: Cascade)
@@unique([userId, stampId])
@@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())
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])
@@index([userId])
}

15
tsconfig.base.json Normal file
View File

@@ -0,0 +1,15 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"esModuleInterop": true,
"strict": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true
}
}