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

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",
},
},
});