diff --git a/.trellis/spec/web/index.md b/.trellis/spec/web/index.md index fb0824b..ee5f78d 100644 --- a/.trellis/spec/web/index.md +++ b/.trellis/spec/web/index.md @@ -1,232 +1,93 @@ -# Web Development Guidelines +# Web Spec -> Astro 6 + React 19 + Tailwind CSS 4 + shadcn/ui +## Scope ---- +This spec applies to `web/**`. -## Tech Stack +Read this file before changing the Astro site, React app islands, authenticated app routes, API clients, i18n, or responsive layout. -| Layer | Technology | Version | -|-------|-----------|---------| -| Framework | Astro | 6.x | -| Interactive UI | React | 19.x (client-only, no RSC) | -| Styling | Tailwind CSS | 4.x (via `@tailwindcss/vite`) | -| Component Library | shadcn/ui | latest | -| Language | TypeScript | 5.x (strict) | -| i18n | Built-in (type-safe object in `src/i18n/utils.ts`) | - | -| Markdown Rendering | `marked` | latest | -| Auth | Supabase Auth JS SDK | - | +## Current Stack -### Architecture Decision +- Astro 6 for static public pages and route files. +- React 19 for interactive client UI. +- React Router DOM for the authenticated business app shell. +- Tailwind CSS 4 through `@tailwindcss/vite`. +- TypeScript strict mode. +- Local i18n from `web/src/i18n/utils.ts`. +- Backend API base for production: `https://api.meeyao.com`. +- Local development API access uses the Vite `/api` proxy in `web/astro.config.mjs`. -- **Astro SSG** for marketing/public pages (SEO-critical) -- **React client islands** for interactive parts (Login, Dashboard, Notifications) -- **No React Server Components** — CVE-2025-55182 risk eliminated -- **No Next.js** — multiple critical CVEs (CVE-2025-55182, CVE-2025-29927, CVE-2025-66478) +Do not introduce a second frontend framework, a second router, or scattered API URL construction for web code. -### Actual Dependencies +## Route Architecture -``` -astro, @astrojs/react, react, react-dom, @tailwindcss/vite, tailwindcss, marked -``` +Public pages are Astro pages under `web/src/pages/{locale}/` and use `Marketing.astro`. ---- +Authenticated pages are Astro route shells that all render `DashboardAppPage.astro`. The actual logged-in application is a single React Router app: -## Pre-Development Checklist +- `DashboardApp.tsx` owns React Router routes for dashboard, store, history, notifications, profile, settings, and divination pages. +- `AppShell.tsx` owns the persistent sidebar, mobile drawer, route guard, and authenticated layout. +- Business page components render only their page body. They must not wrap themselves in `AppShell`. +- Sidebar navigation must use React Router navigation so the shell remains mounted and only the right-side content changes. +- Direct browser refresh on each existing business route must still render the app shell through Astro. -Before writing web code, read: +Login and public marketing/legal pages are not part of the authenticated app shell. -- [ ] This index -- [ ] Design file: `web/design/eryao.pen` -- [ ] Backend API contracts: `docs/protocols/` -- [ ] Error code mapping: `docs/protocols/common/http-error-codes.md` -- [ ] Mobile i18n files (for translation sync): `apps/lib/l10n/app_*.arb` +## Auth Rules ---- +- Login and registration are the same email-code flow. The backend auto-registers new email accounts. +- Test credentials for local verification: `test@example.com` with code `123456`. +- Auth state is stored by `web/src/lib/auth.ts` under one local storage key. +- Every authenticated route must recover or refresh the session before showing business content. +- Missing, expired, invalid, or refresh-failed tokens must clear local auth and redirect to `/{locale}/login`. +- Do not add silent success paths for auth failures. -## Project Structure +## API Rules -``` -web/ -├── design/ # Pencil design files (.pen) -│ ├── eryao.pen -│ └── assets/ # Shared assets (images, legal) -│ ├── images/ -│ └── legal/{zh,zh_Hant,en}/ -├── public/ -│ ├── images -> ../design/assets/images # symlink, no duplication -│ ├── legal -> ../design/assets/legal # symlink, no duplication -│ ├── favicon.ico -│ └── favicon.svg -├── src/ -│ ├── components/ -│ │ ├── Navbar.astro # Marketing nav (responsive, lang switcher) -│ │ ├── Footer.astro # Marketing footer (responsive) -│ │ ├── Hero.astro # Landing hero section -│ │ ├── Showcase.astro # Landing showcase section -│ │ ├── Testimonials.astro # Landing testimonials section -│ │ ├── CtaSection.astro # Landing CTA section -│ │ ├── FeaturesPage.astro # Features grid page -│ │ ├── PricingPage.astro # Pricing cards page -│ │ ├── AboutPage.astro # About + company info page -│ │ └── LegalPage.astro # Markdown legal page (generic) -│ ├── layouts/ -│ │ └── Marketing.astro # Nav + content + footer + scroll animations -│ ├── pages/ -│ │ ├── index.astro # Root redirect -> /zh/ -│ │ ├── {zh,zh_Hant,en}/ -│ │ │ ├── index.astro # Landing -│ │ │ ├── features.astro -│ │ │ ├── pricing.astro -│ │ │ ├── about.astro -│ │ │ ├── privacy.astro -│ │ │ └── terms.astro -│ ├── i18n/ -│ │ └── utils.ts # Type-safe translations (all inline) -│ └── styles/ -│ ├── global.css # Tailwind entry -│ └── animations.css # Scroll reveal animations -├── astro.config.mjs -├── tsconfig.json -└── package.json -``` +- All API paths live in `web/src/lib/api-routes.ts`. +- Shared request behavior lives in `web/src/lib/api-client.ts`. +- Auth/session behavior lives in `web/src/lib/auth.ts`. +- Business API functions live in `web/src/lib/api.ts`. +- Components must call typed API helper functions, not inline `fetch('/api/...')`. +- Dashboard-visible user, points, notification, and history data must come from the backend. Do not hardcode those values. +- Production API host is `https://api.meeyao.com`; local dev should use same-origin `/api` and the Vite proxy. ---- +## Layout Rules -## Responsive Layout +- Build mobile-first, then add `sm:`, `md:`, `lg:`, and `xl:` refinements. +- Business pages must not require horizontal scrolling at common phone widths such as `390x844`. +- Use responsive stacks for fixed-width desktop columns: `flex-col lg:flex-row`, `w-full lg:w-[...]`. +- Keep the authenticated shell as `h-screen` with the main content scrollable. +- Mobile sidebar must be reachable through the menu button and must not hide the page content permanently. +- Public header mobile navigation must expose feature, pricing, about, login, and language switching. -**All pages must be fully responsive: full-width sections, no fixed pixel widths.** +## i18n Rules -### Layout Principles +- Supported locales: `zh`, `zh_Hant`, `en`. +- Routes are prefixed by locale, including the default locale. +- User-visible text should come from `web/src/i18n/utils.ts` or locale-specific content assets. +- Do not add user-facing strings in only one locale. -1. **Full-width sections** — Each section spans `w-full`, content is centered with `max-w-7xl mx-auto` -2. **No fixed pixel widths** — Use `w-full`, `max-w-*`, and `flex/grid` for all layouts -3. **Mobile-first** — Base styles target mobile, `md:` breakpoint for desktop -4. **Viewport-filling sections** — Hero and CTA use `min-h-screen` or generous padding to fill viewport -5. **Smooth scroll transitions** — Each section flows into the next with gradients or color changes +## Design Source -### Breakpoints +- Pencil design files under `web/design/` are the visual source for login and public page design. +- If UI implementation diverges from Pencil, inspect the design first and keep the code aligned unless the user explicitly asks to change the design. +- Assets in `web/public/images` and `web/public/legal` are symlinks to `web/design/assets`; do not duplicate them. -| Breakpoint | Target | -|-----------|--------| -| Base (< 768px) | Mobile phones | -| `md:` (>= 768px) | Tablets and desktop | +## Verification -### Section Patterns +Before finishing meaningful web changes: -``` -Hero: w-full, min-h-screen, bg-gradient, centered content -Showcase: w-full, flex-col md:flex-row, gap, centered max-w -Testimonials: w-full, bg-slate-900, grid cols-1 md:cols-3 -CTA: w-full, bg-violet-600, centered -Footer: w-full, bg-slate-950, responsive flex -``` +- Run `npm run build` in `web/`. +- Use Chrome DevTools on `http://localhost:4322/` for at least one desktop and one phone viewport when layout or routing changed. +- For authenticated changes, verify the test account can log in and lands on `/{locale}/dashboard`. +- Verify direct unauthenticated access to a business route redirects to login. +- Verify sidebar navigation changes content without a full document reload. ---- +## Forbidden -## Design Tokens - -All colors from design map directly to Tailwind defaults: - -| Token | Tailwind | Hex | -|-------|----------|-----| -| Primary | `violet-600` | #7C3AED | -| Primary light | `violet-100` | — | -| Background | `white` | #FFFFFF | -| Surface | `slate-50` | #F8FAFC | -| Dark bg | `slate-900` | #0F172A | -| Footer bg | `slate-950` | #020617 | -| Border | `slate-200` | #E2E8F0 | -| Warning bg | `amber-50` | #FFFBEB | -| Gradient top | `white` -> `violet-50` | — | - ---- - -## Scroll Animations - -Defined in `src/styles/animations.css`, triggered by `IntersectionObserver` in `Marketing.astro`. - -| Class | Effect | Use Case | -|-------|--------|----------| -| `.reveal` | Fade up (translateY 24px) | Default for most elements | -| `.reveal-left` | Slide from left | Showcase left column | -| `.reveal-right` | Slide from right | Showcase right column | -| `.reveal-scale` | Scale in (0.95 -> 1) | CTA section | -| `.stagger-1` to `.stagger-4` | Delay 0.1s-0.4s | Grouped elements (cards, grid items) | - -- All animations: `0.6-0.7s`, `cubic-bezier(0.22, 1, 0.36, 1)` (ease-out-quint) -- `prefers-reduced-motion: reduce` disables all animations -- Observer `rootMargin: 0px 0px -60px 0px` for pre-trigger -- Elements are `opacity: 0` until `.visible` class is added, then `animation-fill-mode: forwards` - ---- - -## i18n (CRITICAL) - -**Every user-visible text must use i18n keys. No hardcoded strings.** - -### Supported Locales -| Code | Language | Brand Name | -|------|----------|------------| -| `zh` | 简体中文 | 觅爻签问 | -| `zh_Hant` | 繁體中文 | 覓爻簽問 | -| `en` | English | MeiYao Divination | - -### Implementation - -- All translations are in `src/i18n/utils.ts` as a typed `Record` object -- Access via `t(locale, 'section')` which returns the section object (e.g., `t(locale, 'nav').features`) -- URL strategy: `/{locale}/path` prefix (e.g., `/zh/`, `/en/`) -- Default locale: `zh`, root `/` redirects to `/zh/` -- Config in `astro.config.mjs` with `prefixDefaultLocale: true` - -### Rules -1. **All text must come from `t()`** — never inline strings -2. **Brand name** is always from `footer.brandName` (consistent across Navbar/Footer) -3. **Check Flutter i18n first** (`apps/lib/l10n/app_*.arb`) before inventing translations -4. **Legal content** loaded from `public/legal/{locale}/` markdown files - ---- - -## Assets - -- Shared assets live in `web/design/assets/` (used by Pencil) -- `web/public/images` and `web/public/legal` are **symlinks** to `../design/assets/*` -- **Never duplicate** assets — always use symlinks or references - ---- - -## Cross-Layer Contracts - -### API Integration -- Backend: FastAPI REST endpoints (see `docs/protocols/`) -- Auth: Supabase Auth JS SDK (client-side) -- Error format: RFC 7807 `ApiProblem` (same as mobile) - -### Brand Consistency (Flutter app) -- App title: "觅爻签问" / "MeiYao Divination" (NOT "MiYao") -- Company: 洵觅科技(深圳)有限公司 / Xunmee Technology (Shenzhen) Co., Ltd. -- Contact: xuyunlong@xunmee.com -- ICP: 粤ICP备2025428416号-1A - ---- - -## Quality Rules - -### Forbidden -- Do not use React Server Components (RSC) -- Do not import server-only modules in client islands -- Do not hardcode colors outside Tailwind tokens -- Do not use fixed pixel widths for layout (use responsive utilities) -- Do not use `any` in TypeScript -- Do not use third-party shadcn registries (official only) -- Do not duplicate assets — use symlinks - -### Required -- All pages must be fully responsive (mobile + desktop) -- All sections must be full-width with centered content -- All pages must pass Lighthouse SEO >= 95 (marketing pages) -- All interactive components must be accessible (ARIA) -- `npm audit` must be clean before merge -- TypeScript strict mode enabled -- `prefers-reduced-motion` must disable animations +- Do not hardcode API URLs or endpoint paths in components. +- Do not add `.env` as a required file for normal local development. +- Do not reintroduce page-refresh sidebar navigation inside the authenticated app. +- Do not wrap business page components in nested cards or duplicate `AppShell`. +- Do not add fallback/mock success behavior for failed API calls. diff --git a/web/astro.config.mjs b/web/astro.config.mjs index aca5505..02d849e 100644 --- a/web/astro.config.mjs +++ b/web/astro.config.mjs @@ -14,5 +14,25 @@ export default defineConfig({ }, vite: { plugins: [tailwindcss()], + server: { + proxy: { + '/api': { + target: 'https://api.meeyao.com', + changeOrigin: true, + secure: true, + }, + }, + }, + optimizeDeps: { + include: ['react', 'react-dom', 'react/jsx-dev-runtime'], + esbuildOptions: { + define: { + 'process.env.NODE_ENV': '"development"', + }, + }, + }, + resolve: { + dedupe: ['react', 'react-dom'], + }, }, }); diff --git a/web/design/assets/legal/en/about_us.md b/web/design/assets/legal/en/about_us.md index af65574..7ecd245 100644 --- a/web/design/assets/legal/en/about_us.md +++ b/web/design/assets/legal/en/about_us.md @@ -12,7 +12,7 @@ MeeYao Divination is designed based on traditional oriental culture. Our core go **Developer:** Ann Lee -**Contact Email:** ann@xunmee.com +**Contact Email:** feedback@xunmee.com --- diff --git a/web/design/assets/legal/en/privacy_policy.md b/web/design/assets/legal/en/privacy_policy.md index 7d0a817..e0fd11e 100644 --- a/web/design/assets/legal/en/privacy_policy.md +++ b/web/design/assets/legal/en/privacy_policy.md @@ -116,7 +116,7 @@ In accordance with CCPA/CPRA and U.S. local privacy laws, you enjoy the followin You can submit data requests through the only dedicated contact method: -- **Contact Email**: ann@xunmee.com +- **Contact Email**: feedback@xunmee.com I will respond to your legitimate request within 45 days, and properly verify your identity to ensure data security before processing. @@ -152,7 +152,7 @@ This Privacy Policy may be updated irregularly to adapt to platform rules and le If you have any questions, suggestions or privacy-related complaints about this Privacy Policy, please contact me: -**Developer Email**: ann@xunmee.com +**Developer Email**: feedback@xunmee.com If you are a California resident and dissatisfied with the processing result, you can consult the local privacy regulatory authority. diff --git a/web/design/assets/legal/en/terms_of_service.md b/web/design/assets/legal/en/terms_of_service.md index 634c97b..213713f 100644 --- a/web/design/assets/legal/en/terms_of_service.md +++ b/web/design/assets/legal/en/terms_of_service.md @@ -118,4 +118,4 @@ I reserve the right to revise and update these Terms of Service at any time. Mat If you have questions, feedback or legal inquiries about these Terms, please contact: - **Developer**: Individual Independent Developer -- **Contact Email**: ann@xunmee.com +- **Contact Email**: feedback@xunmee.com diff --git a/web/design/assets/legal/zh/about_us.md b/web/design/assets/legal/zh/about_us.md index 01b3acf..85d2226 100644 --- a/web/design/assets/legal/zh/about_us.md +++ b/web/design/assets/legal/zh/about_us.md @@ -12,7 +12,7 @@ **开发者**:Ann Lee -**联系邮箱**:ann@xunmee.com +**联系邮箱**:feedback@xunmee.com --- diff --git a/web/design/assets/legal/zh/privacy_policy.md b/web/design/assets/legal/zh/privacy_policy.md index 9094c48..6bebd7b 100644 --- a/web/design/assets/legal/zh/privacy_policy.md +++ b/web/design/assets/legal/zh/privacy_policy.md @@ -116,7 +116,7 @@ 您可以通过唯一指定联系方式提交数据请求: -- **联系邮箱**:ann@xunmee.com +- **联系邮箱**:feedback@xunmee.com 我将在 45 天内回复您的合法请求,并在处理前妥善验证您的身份以确保数据安全。 @@ -152,7 +152,7 @@ 如果您对本隐私政策有任何疑问、建议或隐私相关投诉,请联系我: -**开发者邮箱**:ann@xunmee.com +**开发者邮箱**:feedback@xunmee.com 如果您是加州居民且对处理结果不满意,可咨询当地隐私监管机构。 diff --git a/web/design/assets/legal/zh/terms_of_service.md b/web/design/assets/legal/zh/terms_of_service.md index 696bd11..68e3aa6 100644 --- a/web/design/assets/legal/zh/terms_of_service.md +++ b/web/design/assets/legal/zh/terms_of_service.md @@ -118,4 +118,4 @@ 如果您对本条款有疑问、反馈或法律咨询,请联系: - **开发者**:独立个人开发者 -- **联系邮箱**:ann@xunmee.com +- **联系邮箱**:feedback@xunmee.com diff --git a/web/design/assets/legal/zh_Hant/about_us.md b/web/design/assets/legal/zh_Hant/about_us.md index 5b36c43..218fddd 100644 --- a/web/design/assets/legal/zh_Hant/about_us.md +++ b/web/design/assets/legal/zh_Hant/about_us.md @@ -12,7 +12,7 @@ **開發者**:Ann Lee -**聯繫郵箱**:ann@xunmee.com +**聯繫郵箱**:feedback@xunmee.com --- diff --git a/web/design/assets/legal/zh_Hant/privacy_policy.md b/web/design/assets/legal/zh_Hant/privacy_policy.md index 92e15fa..681ff4d 100644 --- a/web/design/assets/legal/zh_Hant/privacy_policy.md +++ b/web/design/assets/legal/zh_Hant/privacy_policy.md @@ -116,7 +116,7 @@ 您可以通過唯一指定聯繫方式提交數據請求: -- **聯繫郵箱**:ann@xunmee.com +- **聯繫郵箱**:feedback@xunmee.com 我將在 45 天內回覆您的合法請求,並在處理前妥善驗證您的身份以確保數據安全。 @@ -152,7 +152,7 @@ 如果您對本隱私政策有任何疑問、建議或隱私相關投訴,請聯繫我: -**開發者郵箱**:ann@xunmee.com +**開發者郵箱**:feedback@xunmee.com 如果您是加州居民且對處理結果不滿意,可諮詢當地隱私監管機構。 diff --git a/web/design/assets/legal/zh_Hant/terms_of_service.md b/web/design/assets/legal/zh_Hant/terms_of_service.md index 3cd3393..7e33a7f 100644 --- a/web/design/assets/legal/zh_Hant/terms_of_service.md +++ b/web/design/assets/legal/zh_Hant/terms_of_service.md @@ -118,4 +118,4 @@ 如果您對本條款有疑問、反饋或法律諮詢,請聯繫: - **開發者**:獨立個人開發者 -- **聯繫郵箱**:ann@xunmee.com +- **聯繫郵箱**:feedback@xunmee.com diff --git a/web/package.json b/web/package.json index 40a0d4d..7b52d6c 100644 --- a/web/package.json +++ b/web/package.json @@ -20,6 +20,7 @@ "marked": "^18.0.3", "react": "^19.2.6", "react-dom": "^19.2.6", + "react-router-dom": "^7.15.0", "tailwindcss": "^4.3.0" } } diff --git a/web/src/components/AppShell.tsx b/web/src/components/AppShell.tsx new file mode 100644 index 0000000..4e013eb --- /dev/null +++ b/web/src/components/AppShell.tsx @@ -0,0 +1,193 @@ +import { useState, useEffect, type ReactNode } from 'react'; +import { useLocation, useNavigate } from 'react-router-dom'; +import Icon from './Icon'; +import { getAuth, refreshAccessToken, redirectToLogin, type AuthUser } from '../lib/auth'; + +interface NavItem { + id: string; + icon: string; + label: string; + href: string; + sub?: { id: string; label: string; href: string }[]; +} + +interface AppShellProps { + locale: string; + brandName: string; + navItems: NavItem[]; + userName?: string; + userEmail?: string; + children: ReactNode; +} + +function cleanPath(path: string): string { + return path.replace(/\/+$/, '') || '/'; +} + +function getActiveNav(items: NavItem[], locale: string, pathname?: string): string { + const path = cleanPath(pathname ?? (typeof window === 'undefined' ? '' : window.location.pathname)); + for (const item of items) { + if (item.sub) { + for (const sub of item.sub) { + if (sub.href && path === cleanPath(sub.href)) return sub.id; + } + } + if (item.href && path === cleanPath(item.href)) return item.id; + if (item.href && path.startsWith(cleanPath(item.href) + '/')) return item.id; + } + if (path === `/${locale}/dashboard` || path === `/${locale}`) return 'home'; + return 'home'; +} + +export default function AppShell({ locale, brandName, navItems, userName, userEmail, children }: AppShellProps) { + const location = useLocation(); + const routerNavigate = useNavigate(); + const [sidebarOpen, setSidebarOpen] = useState(false); + const [sidebarCollapsed, setSidebarCollapsed] = useState(false); + const [expandedNav, setExpandedNav] = useState('divination'); + const [authUser, setAuthUser] = useState(null); + const [checkingAuth, setCheckingAuth] = useState(true); + const activeNav = getActiveNav(navItems, locale, location.pathname); + + useEffect(() => { + let alive = true; + const auth = getAuth(); + if (!auth?.refresh_token) { + redirectToLogin(); + return; + } + + refreshAccessToken() + .then((data) => { + if (alive) setAuthUser(data.user); + }) + .catch(() => { + redirectToLogin(); + }) + .finally(() => { + if (alive) setCheckingAuth(false); + }); + + return () => { + alive = false; + }; + }, []); + + useEffect(() => { + if (activeNav === 'manual' || activeNav === 'auto') setExpandedNav('divination'); + }, [activeNav]); + + const navigate = (href: string) => { + if (!href) return; + routerNavigate(href); + }; + + if (checkingAuth || authUser === null) { + return ( +
+
+
+ ); + } + + const shellUserName = userName || authUser.email.split('@')[0]; + const shellUserEmail = userEmail || authUser.email; + + return ( +
+ {sidebarOpen && ( +
setSidebarOpen(false)} /> + )} + + + +
+
+ +
+
+ {children} +
+
+
+ ); +} diff --git a/web/src/components/AuthProvider.tsx b/web/src/components/AuthProvider.tsx new file mode 100644 index 0000000..ab329c7 --- /dev/null +++ b/web/src/components/AuthProvider.tsx @@ -0,0 +1,93 @@ +import { createContext, useContext, useState, useEffect, useCallback, type ReactNode } from 'react'; +import { + getAuth, + loginWithEmail as doLogin, + refreshAccessToken, + logout as doLogout, + clearAuth, + redirectToLogin, + type AuthUser, +} from '../lib/auth'; + +interface AuthContextValue { + user: AuthUser | null; + isAuthenticated: boolean; + loading: boolean; + login: (email: string, token: string, language?: string, timezone?: string) => Promise; + logout: () => Promise; +} + +const AuthContext = createContext({ + user: null, + isAuthenticated: false, + loading: true, + login: async () => {}, + logout: async () => {}, +}); + +export function useAuth(): AuthContextValue { + return useContext(AuthContext); +} + +export function AuthProvider({ children }: { children: ReactNode }) { + const [user, setUser] = useState(null); + const [loading, setLoading] = useState(true); + + // recoverSession on mount + useEffect(() => { + const auth = getAuth(); + if (!auth?.refresh_token) { + clearAuth(); + redirectToLogin(); + return; + } + refreshAccessToken() + .then((data) => { + setUser(data.user); + }) + .catch(() => { + clearAuth(); + setUser(null); + redirectToLogin(); + }) + .finally(() => { + setLoading(false); + }); + }, []); + + const login = useCallback( + async (email: string, token: string, language?: string, timezone?: string) => { + const data = await doLogin(email, token, language, timezone); + setUser(data.user); + }, + [], + ); + + const logout = useCallback(async () => { + setUser(null); + await doLogout(); + redirectToLogin(); + }, []); + + if (loading || user === null) { + return ( +
+
+
+ ); + } + + return ( + + {children} + + ); +} diff --git a/web/src/components/AutoDivinationPage.tsx b/web/src/components/AutoDivinationPage.tsx new file mode 100644 index 0000000..9997c52 --- /dev/null +++ b/web/src/components/AutoDivinationPage.tsx @@ -0,0 +1,137 @@ +import { useState } from 'react'; +import Icon from './Icon'; + +interface Props { + locale: string; + dashboard: { brandName: string; navHome: string; navStore: string; navDivination: string; navManual: string; navAuto: string; navHistory: string; navLanguage: string; navSettings: string; logout: string }; + divination: { questionTitle: string; questionPlaceholder: string; categoryLabel: string; categories: string; timeTitle: string; timeHint: string; guideTitle: string; guideAuto: string; shakeTitle: string; shakeBtn: string; hexPreview: string; summaryTitle: string; checkCategory: string; checkMethod: string; checkCost: string; submitBtn: string; progressLabel: string }; +} + +export default function AutoDivinationPage({ locale, divination: d }: Props) { + const cats = d.categories.split(','); + const [category, setCategory] = useState(cats[0]); + const [question, setQuestion] = useState(''); + const [progress, setProgress] = useState(0); + const [hexLines, setHexLines] = useState([]); + const [isShaking, setIsShaking] = useState(false); + + const handleShake = () => { + setIsShaking(true); + setTimeout(() => { + const newProgress = progress + 1; + setProgress(newProgress); + const line = Math.random() > 0.5; + setHexLines(prev => [...prev, line]); + setIsShaking(false); + }, 600); + }; + + const done = progress >= 6; + + return ( +
+
+
+

{locale === 'en' ? 'Auto Cast' : d.checkMethod.replace(/^.*:|^.*: /, '').replace('手动', '自动')}

+

{locale === 'en' ? 'Click the button or shake your device to generate six coin results.' : '点击按钮或摇动设备生成铜钱结果,连续 6 次形成完整卦象。'}

+
+
+ + {locale === 'en' ? 'Available 120 credits · This time 20 credits' : '可用 120 积分 · 本次 20 积分'} +
+
+
+ {/* Left: Question + Time + Guide */} +
+
+

{d.questionTitle}

+
+ {category} + +
+