feat(web): add authenticated app shell

This commit is contained in:
zl-q
2026-05-09 16:00:29 +08:00
parent c12320cb79
commit 5aa46d3311
73 changed files with 2571 additions and 250 deletions
+67 -206
View File
@@ -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<Locale, Translations>` 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.
+20
View File
@@ -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'],
},
},
});
+1 -1
View File
@@ -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
---
+2 -2
View File
@@ -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.
@@ -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
+1 -1
View File
@@ -12,7 +12,7 @@
**开发者**Ann Lee
**联系邮箱**ann@xunmee.com
**联系邮箱**feedback@xunmee.com
---
+2 -2
View File
@@ -116,7 +116,7 @@
您可以通过唯一指定联系方式提交数据请求:
- **联系邮箱**ann@xunmee.com
- **联系邮箱**feedback@xunmee.com
我将在 45 天内回复您的合法请求,并在处理前妥善验证您的身份以确保数据安全。
@@ -152,7 +152,7 @@
如果您对本隐私政策有任何疑问、建议或隐私相关投诉,请联系我:
**开发者邮箱**ann@xunmee.com
**开发者邮箱**feedback@xunmee.com
如果您是加州居民且对处理结果不满意,可咨询当地隐私监管机构。
@@ -118,4 +118,4 @@
如果您对本条款有疑问、反馈或法律咨询,请联系:
- **开发者**:独立个人开发者
- **联系邮箱**ann@xunmee.com
- **联系邮箱**feedback@xunmee.com
+1 -1
View File
@@ -12,7 +12,7 @@
**開發者**Ann Lee
**聯繫郵箱**ann@xunmee.com
**聯繫郵箱**feedback@xunmee.com
---
@@ -116,7 +116,7 @@
您可以通過唯一指定聯繫方式提交數據請求:
- **聯繫郵箱**ann@xunmee.com
- **聯繫郵箱**feedback@xunmee.com
我將在 45 天內回覆您的合法請求,並在處理前妥善驗證您的身份以確保數據安全。
@@ -152,7 +152,7 @@
如果您對本隱私政策有任何疑問、建議或隱私相關投訴,請聯繫我:
**開發者郵箱**ann@xunmee.com
**開發者郵箱**feedback@xunmee.com
如果您是加州居民且對處理結果不滿意,可諮詢當地隱私監管機構。
@@ -118,4 +118,4 @@
如果您對本條款有疑問、反饋或法律諮詢,請聯繫:
- **開發者**:獨立個人開發者
- **聯繫郵箱**ann@xunmee.com
- **聯繫郵箱**feedback@xunmee.com
+1
View File
@@ -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"
}
}
+193
View File
@@ -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<string | null>('divination');
const [authUser, setAuthUser] = useState<AuthUser | null>(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 (
<div className="min-h-screen bg-slate-50 flex items-center justify-center px-6">
<div className="h-10 w-10 rounded-full border-2 border-violet-200 border-t-violet-600 animate-spin" />
</div>
);
}
const shellUserName = userName || authUser.email.split('@')[0];
const shellUserEmail = userEmail || authUser.email;
return (
<div className="flex h-screen bg-slate-50 overflow-hidden">
{sidebarOpen && (
<div className="fixed inset-0 bg-black/40 z-40 md:hidden" onClick={() => setSidebarOpen(false)} />
)}
<aside className={`fixed md:static inset-y-0 left-0 z-50 w-[260px] bg-white border-r border-slate-200 flex flex-col gap-2 p-4 transition-[width,transform] duration-300 ${sidebarCollapsed ? 'md:w-[72px] md:px-3' : ''} ${sidebarOpen ? 'translate-x-0' : '-translate-x-full md:translate-x-0'}`}>
<div className={`flex items-center ${sidebarCollapsed ? 'md:justify-center' : 'justify-between'} px-2 py-2`}>
<a href={`/${locale}/dashboard`} onClick={(event) => { event.preventDefault(); navigate(`/${locale}/dashboard`); }} className="flex items-center gap-3 min-w-0">
<img src="/images/logo.png" alt="MeiYao" className="w-9 h-9 rounded-lg" />
<span className={`text-slate-900 text-lg font-bold whitespace-nowrap ${sidebarCollapsed ? 'md:hidden' : ''}`}>{brandName}</span>
</a>
<button onClick={() => setSidebarOpen(false)} className="md:hidden text-slate-400 hover:text-slate-600" aria-label="Close sidebar">
<Icon name="close" className="w-5 h-5" />
</button>
<button onClick={() => setSidebarCollapsed((value) => !value)} className={`hidden md:flex w-6 h-6 items-center justify-center rounded-md text-slate-400 hover:bg-slate-50 hover:text-slate-600 ${sidebarCollapsed ? 'md:absolute md:top-6 md:left-[50px] md:bg-white md:border md:border-slate-200' : ''}`} aria-label={sidebarCollapsed ? 'Expand sidebar' : 'Collapse sidebar'}>
<Icon name={sidebarCollapsed ? 'chevron_right' : 'chevron_left'} className="w-4 h-4" />
</button>
</div>
<div className="h-px bg-slate-200" />
<nav className="flex flex-col gap-1 flex-1 overflow-y-auto">
{navItems.map((item) => {
if (item.sub) {
const isExpanded = expandedNav === item.id;
const isGroupActive = item.sub.some(s => activeNav === s.id);
return (
<div key={item.id} className="flex flex-col gap-1">
<button
onClick={() => setExpandedNav(isExpanded ? null : item.id)}
className={`flex items-center gap-3 px-2.5 py-2.5 rounded-lg transition-colors w-full text-left ${sidebarCollapsed ? 'md:justify-center' : ''} ${isGroupActive ? 'text-slate-900' : 'text-slate-500 hover:bg-slate-50'}`}
title={sidebarCollapsed ? item.label : undefined}
>
<Icon name={item.icon} className={`w-[18px] h-[18px] ${isGroupActive ? 'text-slate-600' : 'text-slate-500'}`} />
<span className={`text-sm flex-1 font-medium ${sidebarCollapsed ? 'md:hidden' : ''} ${isGroupActive ? 'text-slate-900' : 'text-slate-700'}`}>{item.label}</span>
<Icon name="chevron_down" className={`w-4 h-4 text-slate-400 transition-transform ${sidebarCollapsed ? 'md:hidden' : ''} ${isExpanded ? 'rotate-180' : ''}`} />
</button>
{!sidebarCollapsed && isExpanded && item.sub.map((sub) => (
<a key={sub.id} href={sub.href}
onClick={(e) => { e.preventDefault(); navigate(sub.href); setSidebarOpen(false); }}
className={`flex items-center gap-2 pl-8 pr-2.5 py-2.5 rounded-md text-sm transition-colors border ${activeNav === sub.id ? 'bg-[#F0E6FF] border-violet-600 text-violet-700 font-bold' : 'border-transparent text-slate-500 hover:bg-slate-50'}`}>
<span className="text-xs">{activeNav === sub.id ? '●' : '○'}</span>{sub.label}
</a>
))}
<div className="h-px bg-slate-100 my-1" />
</div>
);
}
const isActive = activeNav === item.id;
if (item.href) {
return (
<a key={item.id} href={item.href}
onClick={(e) => { e.preventDefault(); navigate(item.href); setSidebarOpen(false); }}
title={sidebarCollapsed ? item.label : undefined}
className={`flex items-center gap-3 px-2.5 py-2.5 rounded-lg text-sm transition-colors ${sidebarCollapsed ? 'md:justify-center' : ''} ${isActive ? 'bg-violet-700 text-white' : 'text-slate-500 hover:bg-slate-50'}`}>
<Icon name={item.icon} className="w-[18px] h-[18px]" />
<span className={`${sidebarCollapsed ? 'md:hidden' : ''} ${isActive ? 'font-medium' : ''}`}>{item.label}</span>
</a>
);
}
return (
<button key={item.id}
title={sidebarCollapsed ? item.label : undefined}
className={`flex items-center gap-3 px-2.5 py-2.5 rounded-lg text-sm transition-colors ${sidebarCollapsed ? 'md:justify-center' : ''} ${isActive ? 'bg-violet-700 text-white' : 'text-slate-500 hover:bg-slate-50'}`}>
<Icon name={item.icon} className="w-[18px] h-[18px]" />
<span className={`${sidebarCollapsed ? 'md:hidden' : ''} ${isActive ? 'font-medium' : ''}`}>{item.label}</span>
</button>
);
})}
</nav>
<a href={`/${locale}/profile`} onClick={(event) => { event.preventDefault(); navigate(`/${locale}/profile`); }} className={`flex items-center gap-3 p-3 rounded-[10px] hover:bg-slate-50 transition-colors ${sidebarCollapsed ? 'md:justify-center' : ''}`} title={sidebarCollapsed ? shellUserName : undefined}>
<div className="w-9 h-9 rounded-full bg-violet-600 flex items-center justify-center text-white text-sm font-semibold">
{shellUserName[0].toUpperCase()}
</div>
<div className={`flex flex-col gap-1 min-w-0 ${sidebarCollapsed ? 'md:hidden' : ''}`}>
<p className="text-slate-900 text-sm font-medium truncate">{shellUserName}</p>
<p className="text-slate-400 text-xs truncate">{shellUserEmail}</p>
</div>
</a>
</aside>
<main className="flex-1 flex flex-col overflow-y-auto">
<div className="flex items-center gap-3 px-6 md:px-10 pt-6 md:pt-8">
<button onClick={() => setSidebarOpen(true)} className="md:hidden text-slate-500 hover:text-slate-700" aria-label="Open sidebar">
<Icon name="menu" className="w-6 h-6" />
</button>
</div>
<div className="flex-1 px-6 md:px-10 pb-10">
{children}
</div>
</main>
</div>
);
}
+93
View File
@@ -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<void>;
logout: () => Promise<void>;
}
const AuthContext = createContext<AuthContextValue>({
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<AuthUser | null>(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 (
<div className="min-h-screen bg-slate-50 flex items-center justify-center px-6">
<div className="h-10 w-10 rounded-full border-2 border-violet-200 border-t-violet-600 animate-spin" />
</div>
);
}
return (
<AuthContext.Provider
value={{
user,
isAuthenticated: user !== null,
loading,
login,
logout,
}}
>
{children}
</AuthContext.Provider>
);
}
+137
View File
@@ -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<boolean[]>([]);
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 (
<div className="flex flex-col gap-[22px] min-h-full">
<div className="flex items-center justify-between">
<div>
<h1 className="text-[#333333] text-[28px] font-bold leading-tight">{locale === 'en' ? 'Auto Cast' : d.checkMethod.replace(/^.*|^.*: /, '').replace('手动', '自动')}</h1>
<p className="text-[#666666] text-sm mt-1">{locale === 'en' ? 'Click the button or shake your device to generate six coin results.' : '点击按钮或摇动设备生成铜钱结果,连续 6 次形成完整卦象。'}</p>
</div>
<div className="hidden md:flex items-center gap-2 h-10 px-3.5 rounded-full bg-white border border-slate-200 text-[#333333] text-[13px] font-semibold">
<Icon name="paid" className="w-[18px] h-[18px] text-violet-700" />
{locale === 'en' ? 'Available 120 credits · This time 20 credits' : '可用 120 积分 · 本次 20 积分'}
</div>
</div>
<div className="flex flex-col xl:flex-row gap-[22px] min-h-0 flex-1">
{/* Left: Question + Time + Guide */}
<div className="w-full xl:w-[340px] flex flex-col gap-4 shrink-0">
<div className="bg-white rounded-2xl p-[22px] border border-slate-200 flex flex-col gap-4">
<h3 className="text-slate-900 text-lg font-bold">{d.questionTitle}</h3>
<div className="flex items-center justify-between h-[42px] px-3 rounded-[10px] bg-slate-50 border border-slate-300">
<span className="text-slate-600 text-sm">{category}</span>
<select value={category} onChange={e => setCategory(e.target.value)} className="bg-transparent text-sm outline-none cursor-pointer">
{cats.map(c => <option key={c} value={c}>{c}</option>)}
</select>
</div>
<textarea value={question} onChange={e => setQuestion(e.target.value)} placeholder={d.questionPlaceholder} rows={3}
className="w-full px-3.5 py-3 rounded-[10px] bg-white border border-slate-300 text-sm focus:outline-none focus:border-violet-400 resize-none" />
</div>
<div className="bg-white rounded-2xl p-5 border border-slate-200 flex flex-col gap-3">
<h3 className="text-slate-900 text-base font-bold">{d.timeTitle}</h3>
<div className="flex items-center justify-between h-[42px] px-3 rounded-[10px] bg-slate-50">
<input type="datetime-local" className="bg-transparent text-sm outline-none w-full" />
<Icon name="calendar_today" className="w-[18px] h-[18px] text-slate-400" />
</div>
</div>
<div className="bg-white rounded-2xl p-5 border border-slate-200 flex flex-col gap-3 flex-1 overflow-y-auto">
<h3 className="text-slate-900 text-base font-bold">{d.guideTitle}</h3>
<p className="text-slate-500 text-[13px] whitespace-pre-line">{d.guideAuto}</p>
</div>
</div>
{/* Center: Shake panel */}
<div className="flex-1 bg-white rounded-2xl p-6 border border-slate-200 flex flex-col gap-[18px]">
<div className="flex items-center justify-between">
<h3 className="text-slate-900 text-lg font-bold">{d.shakeTitle}</h3>
<span className="text-violet-600 text-[13px] font-bold">{progress} / 6</span>
</div>
{/* Coin stage */}
<div className="bg-slate-50 rounded-2xl p-[22px] flex items-center justify-center gap-6" style={{ minHeight: '194px' }}>
{[0, 1, 2].map(i => (
<div key={i} className="flex flex-col items-center gap-2" style={{ width: '86px' }}>
<img
src={isShaking ? '/images/qigua/hua.jpg' : '/images/qigua/zi.jpg'}
alt={locale === 'en' ? 'coin' : '铜钱'}
className={`w-16 h-16 rounded-full object-cover border border-amber-300 shadow-sm transition-all ${isShaking ? 'animate-pulse' : ''}`}
/>
<span className="text-slate-400 text-xs">{'铜钱'}</span>
</div>
))}
</div>
{/* Shake button */}
<div className="flex flex-col items-center gap-2.5" style={{ height: '82px', justifyContent: 'center' }}>
{!done && (
<button onClick={handleShake} disabled={isShaking}
className="flex items-center gap-2 px-8 py-2.5 rounded-full bg-violet-600 text-white text-sm font-bold hover:bg-violet-700 disabled:opacity-50 transition-colors">
<Icon name="casino" className="w-[18px] h-[18px]" />
{d.shakeBtn}
</button>
)}
{done && <p className="text-violet-600 text-sm font-medium"></p>}
</div>
{/* Hexagram preview */}
<div className="bg-white rounded-xl p-[18px] border border-slate-200 flex-1 flex flex-col gap-3 overflow-y-auto">
<p className="text-slate-900 text-base font-bold">{d.hexPreview}</p>
<div className="flex flex-col gap-2">
{hexLines.length > 0 ? hexLines.map((isYang, i) => isYang ? (
<div key={i} className="w-12 h-2 bg-violet-600 rounded" />
) : (
<div key={i} className="flex gap-1.5"><div className="w-4 h-2 bg-violet-400 rounded" /><div className="w-4 h-2 bg-violet-400 rounded" /></div>
)) : (
<p className="text-slate-300 text-sm"></p>
)}
</div>
</div>
</div>
{/* Right: Summary */}
<div className="w-full xl:w-[300px] bg-white rounded-2xl p-[22px] border border-slate-200 flex flex-col gap-[18px] shrink-0">
<h3 className="text-slate-900 text-lg font-bold">{d.summaryTitle}</h3>
<div className="bg-slate-50 rounded-xl p-4 flex flex-col gap-2" style={{ height: '94px' }}>
<p className="text-slate-500 text-[13px]">{d.progressLabel}</p>
<p className="text-violet-600 text-[28px] font-bold">{progress} / 6</p>
</div>
<p className="text-slate-500 text-sm">{d.checkCategory}</p>
<p className="text-slate-500 text-sm">{d.checkMethod}</p>
<p className="text-slate-500 text-sm">{d.checkCost}</p>
<div className="flex-1" />
<button disabled={!done}
className={`w-full h-[46px] rounded-full text-sm font-bold transition-colors ${done ? 'bg-violet-600 text-white hover:bg-violet-700' : 'bg-slate-300 text-slate-400 cursor-not-allowed'}`}>
{d.submitBtn}
</button>
</div>
</div>
</div>
);
}
+147
View File
@@ -0,0 +1,147 @@
import { useState, useEffect } from 'react';
import { getPointsBalance, type PointsBalance } from '../lib/api';
import { getUnreadNotificationCount, type UnreadCount } from '../lib/api';
import { getAgentHistory, mapHistoryMessagesToItems, type HistoryItem } from '../lib/api';
import Icon from './Icon';
interface DashboardProps {
locale: string;
translations: {
brandName: string; navHome: string; navStore: string; navDivination: string; navManual: string; navAuto: string; navHistory: string; navLanguage: string; navSettings: string; greeting: string; greetingSub: string; heroTitle: string; heroDesc: string; heroCta: string; historyTitle: string; historyViewAll: string; logout: string;
};
}
const CATEGORY_COLORS: Record<string, string> = {
'事业': 'bg-blue-50 text-blue-500', '感情': 'bg-pink-50 text-pink-500', '财富': 'bg-amber-50 text-amber-600', '运势': 'bg-purple-50 text-purple-500', '学业': 'bg-green-50 text-green-600',
'Career': 'bg-blue-50 text-blue-500', 'Love': 'bg-pink-50 text-pink-500', 'Wealth': 'bg-amber-50 text-amber-600', 'Study': 'bg-green-50 text-green-600',
};
const RATING_COLORS: Record<string, string> = {
'上上签': 'bg-amber-50 text-amber-500', '上签': 'bg-amber-50 text-amber-500', '中上签': 'bg-violet-50 text-violet-600', '中签': 'bg-slate-100 text-slate-500', '下签': 'bg-red-50 text-red-500',
};
export default function Dashboard({ locale, translations: i18n }: DashboardProps) {
const [points, setPoints] = useState<PointsBalance | null>(null);
const [unreadCount, setUnreadCount] = useState<UnreadCount | null>(null);
const [history, setHistory] = useState<HistoryItem[]>([]);
const [loadingData, setLoadingData] = useState(true);
const [loadError, setLoadError] = useState('');
useEffect(() => {
let alive = true;
setLoadingData(true);
setLoadError('');
Promise.all([
getPointsBalance(),
getUnreadNotificationCount(),
getAgentHistory(),
])
.then(([nextPoints, nextUnreadCount, nextHistory]) => {
if (!alive) return;
setPoints(nextPoints);
setUnreadCount(nextUnreadCount);
setHistory(mapHistoryMessagesToItems(nextHistory.messages).slice(0, 4));
})
.catch((error: unknown) => {
if (!alive) return;
setLoadError(error instanceof Error ? error.message : 'Failed to load dashboard data');
})
.finally(() => {
if (alive) setLoadingData(false);
});
return () => {
alive = false;
};
}, []);
const unreadNum = unreadCount?.count ?? 0;
const availablePoints = points?.availableBalance;
return (
<div className="flex flex-col gap-5 md:gap-6 min-h-full">
{/* Header */}
<div className="flex items-start justify-between gap-4">
<div className="min-w-0">
<h1 className="text-slate-900 text-xl md:text-2xl font-semibold">{i18n.greeting}</h1>
<p className="text-slate-500 text-sm mt-1">{i18n.greetingSub}</p>
</div>
<a
href={`/${locale}/notifications`}
className="relative w-10 h-10 rounded-xl bg-slate-100 flex items-center justify-center hover:bg-slate-200 transition-colors"
aria-label={locale === 'en' ? 'Open notifications' : '打开通知'}
>
<Icon name="notifications" className="w-5 h-5 text-slate-500" />
{unreadNum > 0 && (
<span className="absolute -top-1 -right-1 w-4 h-4 bg-red-500 rounded-full text-white text-[10px] font-bold flex items-center justify-center">{unreadNum > 9 ? '9+' : unreadNum}</span>
)}
</a>
</div>
{loadError && (
<div className="rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-600">
{loadError}
</div>
)}
{/* Hero card */}
<div className="relative rounded-2xl overflow-hidden p-5 md:p-12" style={{ background: 'linear-gradient(135deg, #673AB7, #512DA8)' }}>
<div className="flex flex-col md:flex-row md:items-center gap-6 md:gap-12">
<div className="flex-1 flex flex-col gap-4">
<h2 className="text-white text-2xl md:text-[32px] font-bold leading-tight">{i18n.heroTitle}</h2>
<p className="text-violet-200 text-sm md:text-base leading-relaxed">{i18n.heroDesc}</p>
<div className="flex flex-col sm:flex-row sm:items-center gap-3">
<a href={`/${locale}/divination/manual`}
className="w-full sm:w-fit text-center px-6 py-3 rounded-xl bg-white text-violet-600 text-base font-semibold hover:bg-violet-50 transition-colors"
style={{ boxShadow: '0 4px 16px #00000030' }}>
{i18n.heroCta}
</a>
{availablePoints !== undefined && (
<span className="text-violet-100 text-sm">
{locale === 'en' ? 'Available credits' : '可用积分'}: <strong className="text-white">{availablePoints}</strong>
</span>
)}
</div>
</div>
<div className="hidden md:flex w-[220px] h-[184px] rounded-2xl items-center justify-center" style={{ background: 'rgba(255,255,255,0.08)' }}>
<div className="text-white/40 text-2xl leading-relaxed text-center font-mono"> <br /> <br /> </div>
</div>
</div>
</div>
{/* History */}
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between">
<h3 className="text-slate-900 text-lg font-semibold">{i18n.historyTitle}</h3>
<a href={`/${locale}/history`} className="text-violet-600 text-sm hover:underline">{i18n.historyViewAll}</a>
</div>
<div className="flex flex-col gap-3">
{loadingData ? (
<div className="text-slate-400 text-sm py-8 text-center">{locale === 'en' ? 'Loading...' : '加载中...'}</div>
) : history.length === 0 ? (
<div className="text-slate-400 text-sm py-8 text-center">{locale === 'en' ? 'No readings yet' : '暂无解卦记录'}</div>
) : (
history.map((item) => (
<div key={item.id} className="flex flex-col sm:flex-row sm:items-center gap-3 sm:gap-4 bg-white rounded-xl p-4 md:p-5 border border-slate-100 hover:shadow-sm transition-shadow">
<div className="w-10 h-10 rounded-[10px] bg-blue-50 flex items-center justify-center shrink-0">
<span className="text-blue-400 text-lg"></span>
</div>
<div className="flex-1 min-w-0">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-1 sm:gap-3">
<p className="text-slate-900 text-[15px] font-medium truncate">{item.question}</p>
<span className="text-slate-400 text-xs shrink-0">{item.created_at?.slice(0, 10) || ''}</span>
</div>
<div className="flex flex-wrap items-center gap-2 mt-1.5">
{item.category && <span className={`px-2 py-0.5 rounded-md text-xs font-medium ${CATEGORY_COLORS[item.category] || 'bg-slate-100 text-slate-500'}`}>{item.category}</span>}
{item.hexagram_name && <span className="px-2 py-0.5 rounded-md bg-violet-50 text-violet-600 text-xs font-medium">{item.hexagram_name}</span>}
{item.rating && <span className={`px-2 py-0.5 rounded-md text-xs font-medium ${RATING_COLORS[item.rating] || 'bg-slate-100 text-slate-500'}`}>{item.rating}</span>}
</div>
</div>
</div>
))
)}
</div>
</div>
</div>
);
}
+104
View File
@@ -0,0 +1,104 @@
import { useEffect } from 'react';
import { BrowserRouter, Navigate, Route, Routes, useNavigate } from 'react-router-dom';
import AppShell from './AppShell';
import Dashboard from './Dashboard';
import StorePage from './StorePage';
import HistoryListPage from './HistoryListPage';
import HistoryResultPage from './HistoryResultPage';
import HistoryFollowUpPage from './HistoryFollowUpPage';
import NotificationPage from './NotificationPage';
import ProfileDetailPage from './ProfileDetailPage';
import SettingsPage from './SettingsPage';
import ManualDivinationPage from './ManualDivinationPage';
import AutoDivinationPage from './AutoDivinationPage';
import { getNavConfig } from './navConfig';
type TranslationMap = Record<string, string>;
interface DashboardAppProps {
locale: string;
translations: {
dashboard: TranslationMap;
store: TranslationMap;
pricing: TranslationMap;
history: TranslationMap;
notifications: TranslationMap;
profile: TranslationMap;
settings: TranslationMap;
divination: TranslationMap;
};
}
const APP_PATHS = [
'/dashboard',
'/store',
'/history',
'/notifications',
'/profile',
'/settings',
'/divination/manual',
'/divination/auto',
];
function AppLinkInterceptor({ locale }: { locale: string }) {
const navigate = useNavigate();
useEffect(() => {
const handleClick = (event: MouseEvent) => {
if (event.defaultPrevented || event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
const anchor = (event.target as Element | null)?.closest('a[href]');
if (!(anchor instanceof HTMLAnchorElement)) return;
if (anchor.target || anchor.hasAttribute('download')) return;
const url = new URL(anchor.href);
if (url.origin !== window.location.origin) return;
const localePrefix = `/${locale}`;
if (!url.pathname.startsWith(localePrefix)) return;
const appPath = url.pathname.slice(localePrefix.length) || '/';
if (!APP_PATHS.some((path) => appPath === path || appPath.startsWith(`${path}/`))) return;
event.preventDefault();
navigate(`${url.pathname}${url.search}${url.hash}`);
};
document.addEventListener('click', handleClick);
return () => document.removeEventListener('click', handleClick);
}, [locale, navigate]);
return null;
}
function DashboardRoutes({ locale, translations }: DashboardAppProps) {
const dashboard = translations.dashboard;
const navItems = getNavConfig(locale, dashboard);
return (
<AppShell locale={locale} brandName={dashboard.brandName} navItems={navItems}>
<AppLinkInterceptor locale={locale} />
<Routes>
<Route path={`/${locale}/dashboard`} element={<Dashboard locale={locale} translations={dashboard} />} />
<Route path={`/${locale}/store`} element={<StorePage locale={locale} dashboard={dashboard} store={translations.store} pricing={translations.pricing} />} />
<Route path={`/${locale}/history`} element={<HistoryListPage locale={locale} dashboard={dashboard} history={translations.history} />} />
<Route path={`/${locale}/history/:id`} element={<HistoryResultPage locale={locale} dashboard={dashboard} history={translations.history} />} />
<Route path={`/${locale}/history/:id/followup`} element={<HistoryFollowUpPage locale={locale} dashboard={dashboard} history={translations.history} />} />
<Route path={`/${locale}/history/result`} element={<HistoryResultPage locale={locale} dashboard={dashboard} history={translations.history} />} />
<Route path={`/${locale}/history/followup`} element={<HistoryFollowUpPage locale={locale} dashboard={dashboard} history={translations.history} />} />
<Route path={`/${locale}/notifications`} element={<NotificationPage locale={locale} dashboard={dashboard} notifications={translations.notifications} />} />
<Route path={`/${locale}/profile`} element={<ProfileDetailPage locale={locale} dashboard={dashboard} profile={translations.profile} />} />
<Route path={`/${locale}/settings`} element={<SettingsPage locale={locale} dashboard={dashboard} settings={translations.settings} />} />
<Route path={`/${locale}/divination/manual`} element={<ManualDivinationPage locale={locale} dashboard={dashboard} divination={translations.divination} />} />
<Route path={`/${locale}/divination/auto`} element={<AutoDivinationPage locale={locale} dashboard={dashboard} divination={translations.divination} />} />
<Route path="*" element={<Navigate to={`/${locale}/dashboard`} replace />} />
</Routes>
</AppShell>
);
}
export default function DashboardApp(props: DashboardAppProps) {
return (
<BrowserRouter>
<DashboardRoutes {...props} />
</BrowserRouter>
);
}
+25
View File
@@ -0,0 +1,25 @@
---
import AppLayout from '../layouts/App.astro';
import DashboardApp from './DashboardApp';
import { t, type Locale } from '../i18n/utils';
interface Props {
locale: Locale;
}
const { locale } = Astro.props;
const translations = {
dashboard: t(locale, 'dashboard'),
store: t(locale, 'store'),
pricing: t(locale, 'pricing'),
history: t(locale, 'history'),
notifications: t(locale, 'notifications'),
profile: t(locale, 'profile'),
settings: t(locale, 'settings'),
divination: t(locale, 'divination'),
};
---
<AppLayout locale={locale}>
<DashboardApp client:only="react" locale={locale} translations={translations} />
</AppLayout>
@@ -0,0 +1,87 @@
import { useState } from 'react';
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 };
history: { chatTitle: string; chatPlaceholder: string; sendBtn: string; followUpRules: string; followUpRule1: string; followUpRule2: string; relatedActions: string; newDivination: string; viewHistory: string; resultTitle: string };
}
const MOCK_MESSAGES = [
{ role: 'ai' as const, content: '您好,关于"今年转岗是否合适"的卦象解读已完成。如果您对某些方面还有疑问,可以进行一次追问。' },
{ role: 'user' as const, content: '请问什么时间转岗比较合适?' },
{ role: 'ai' as const, content: '根据天雷无妄卦的分析,结合当前时令,建议您关注秋季(农历七八月)的机会。届时天时更为有利,变动容易获得好的结果。目前阶段以积累和准备为主。' },
];
export default function HistoryFollowUpPage({ locale, history: h }: Props) {
const [message, setMessage] = useState('');
const [messages, setMessages] = useState(MOCK_MESSAGES);
const handleSend = () => {
if (!message.trim()) return;
setMessages(prev => [...prev, { role: 'user', content: message }]);
setMessage('');
};
return (
<div className="flex flex-col xl:flex-row gap-5 min-h-full">
{/* Chat panel */}
<div className="flex-1 bg-white rounded-2xl border border-slate-200 flex flex-col overflow-hidden">
{/* Header */}
<div className="flex items-center justify-between px-5 h-[72px] border-b border-slate-200 shrink-0">
<h3 className="text-slate-900 text-base font-bold">{h.chatTitle}</h3>
<span className="text-slate-400 text-sm"></span>
</div>
{/* Messages */}
<div className="flex-1 flex flex-col gap-[18px] p-6 overflow-y-auto">
{messages.map((msg, i) => (
<div key={i} className={`flex ${msg.role === 'user' ? 'justify-end' : 'justify-start'}`}>
<div className={`max-w-[80%] px-4 py-3 rounded-2xl text-sm leading-relaxed ${msg.role === 'user' ? 'bg-violet-600 text-white' : 'bg-slate-100 text-slate-700'}`}>
{msg.content}
</div>
</div>
))}
</div>
{/* Composer */}
<div className="px-[22px] py-[18px] border-t border-slate-200 flex flex-col gap-3 shrink-0">
<textarea value={message} onChange={e => setMessage(e.target.value)} placeholder={h.chatPlaceholder} rows={2}
onKeyDown={e => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleSend(); } }}
className="w-full px-4 py-3 rounded-xl bg-slate-50 border border-slate-200 text-sm focus:outline-none focus:border-violet-400 resize-none" />
<div className="flex justify-end">
<button onClick={handleSend} className="px-5 py-2 rounded-lg bg-violet-600 text-white text-sm font-semibold hover:bg-violet-700 transition-colors">{h.sendBtn}</button>
</div>
</div>
</div>
{/* Right side column */}
<div className="w-full xl:w-[340px] flex flex-col gap-3.5 shrink-0">
{/* Result summary */}
<div className="bg-white rounded-2xl p-[18px] border border-slate-200 flex flex-col gap-3">
<h4 className="text-slate-900 text-sm font-bold">{h.resultTitle}</h4>
<div className="flex justify-between text-sm"><span className="text-slate-400"></span><span className="text-slate-600"></span></div>
<div className="flex justify-between text-sm"><span className="text-slate-400"></span><span className="text-slate-600"></span></div>
<div className="flex justify-between text-sm"><span className="text-slate-400"></span><span className="text-slate-600">2025-05-08</span></div>
</div>
{/* Follow-up rules */}
<div className="bg-amber-50 rounded-2xl p-[18px] border border-amber-200 flex flex-col gap-3">
<h4 className="text-slate-900 text-sm font-bold">{h.followUpRules}</h4>
<p className="text-amber-700 text-sm">{h.followUpRule1}</p>
<p className="text-amber-700 text-sm">{h.followUpRule2}</p>
</div>
{/* Related actions */}
<div className="bg-white rounded-2xl p-[18px] border border-slate-200 flex flex-col gap-2.5">
<h4 className="text-slate-900 text-sm font-bold">{h.relatedActions}</h4>
<a href={`/${locale}/divination/manual`} className="flex items-center gap-2 text-sm text-slate-600 hover:text-violet-600 transition-colors py-1">
<span className="material-symbols-rounded text-base">casino</span>{h.newDivination}
</a>
<a href={`/${locale}/history`} className="flex items-center gap-2 text-sm text-slate-600 hover:text-violet-600 transition-colors py-1">
<span className="material-symbols-rounded text-base">history</span>{h.viewHistory}
</a>
</div>
</div>
</div>
);
}
+83
View File
@@ -0,0 +1,83 @@
import { useState } from 'react';
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 };
history: { title: string; statTotal: string; statFollow: string; statLatest: string; filters: string; filterAll: string; filterCareer: string; filterLove: string; filterWealth: string; noResults: string };
}
const MOCK_HISTORY = [
{ id: 1, question: '今年转岗是否合适?', date: '2025-05-08', category: '事业', hexagram: '天雷无妄', rating: '上上签', followUp: false },
{ id: 2, question: '最近感情是否能推进?', date: '2025-05-07', category: '感情', hexagram: '泽火革', rating: '中上签', followUp: true },
{ id: 3, question: '投资理财近期运势如何?', date: '2025-05-05', category: '财富', hexagram: '水地比', rating: '中签', followUp: false },
{ id: 4, question: '学业考试能否顺利通过?', date: '2025-05-03', category: '学业', hexagram: '山火贲', rating: '上签', followUp: true },
];
const CATEGORY_COLORS: Record<string, string> = {
'事业': 'bg-blue-50 text-blue-500', '感情': 'bg-pink-50 text-pink-500', '财富': 'bg-amber-50 text-amber-600', '学业': 'bg-green-50 text-green-600',
};
export default function HistoryListPage({ locale, history: h }: Props) {
const [selectedId, setSelectedId] = useState(1);
const [filter, setFilter] = useState('all');
const filters = [
{ id: 'all', label: h.filterAll },
{ id: 'career', label: h.filterCareer },
{ id: 'love', label: h.filterLove },
{ id: 'wealth', label: h.filterWealth },
];
return (
<div className="flex flex-col gap-5 min-h-full">
{/* Stats */}
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
{[{ label: h.statTotal, value: '12' }, { label: h.statFollow, value: '3' }, { label: h.statLatest, value: '5/8' }].map((stat, i) => (
<div key={i} className="bg-white rounded-xl p-4 border border-slate-200 flex flex-col gap-1.5">
<p className="text-slate-400 text-xs">{stat.label}</p>
<p className="text-slate-900 text-xl font-bold">{stat.value}</p>
</div>
))}
</div>
{/* Main: List + Filters */}
<div className="flex flex-col lg:flex-row gap-5 flex-1 min-h-0">
<div className="flex-1 bg-white rounded-2xl p-[18px] border border-slate-200 flex flex-col gap-3 overflow-y-auto">
<div className="flex items-center justify-between">
<h3 className="text-slate-900 text-sm font-bold">{h.title}</h3>
</div>
{MOCK_HISTORY.map(item => (
<a key={item.id} href={`/${locale}/history/${item.id}`}
onClick={(e) => { e.preventDefault(); setSelectedId(item.id); }}
className={`flex items-center gap-3.5 rounded-xl p-4 cursor-pointer transition-colors border ${selectedId === item.id ? 'bg-violet-50 border-violet-400' : 'bg-white border-slate-200 hover:bg-slate-50'}`}>
<div className="w-10 h-10 rounded-[10px] bg-blue-50 flex items-center justify-center shrink-0">
<span className="text-blue-400 text-lg"></span>
</div>
<div className="flex-1 min-w-0">
<p className="text-slate-900 text-sm font-medium truncate">{item.question}</p>
<div className="flex items-center gap-2 mt-1">
<span className={`px-2 py-0.5 rounded-md text-xs font-medium ${CATEGORY_COLORS[item.category] || 'bg-slate-100 text-slate-500'}`}>{item.category}</span>
<span className="px-2 py-0.5 rounded-md bg-violet-50 text-violet-600 text-xs font-medium">{item.hexagram}</span>
</div>
</div>
<span className="text-slate-400 text-xs shrink-0">{item.date}</span>
</a>
))}
</div>
{/* Side: Filters */}
<div className="w-full lg:w-[300px] flex flex-col gap-4 shrink-0">
<div className="bg-white rounded-2xl p-[18px] border border-slate-200 flex flex-col gap-2.5">
<h3 className="text-slate-900 text-sm font-bold">{h.filters}</h3>
{filters.map(f => (
<button key={f.id} onClick={() => setFilter(f.id)}
className={`px-3 py-2 rounded-lg text-sm text-left transition-colors ${filter === f.id ? 'bg-violet-50 text-violet-600 font-medium' : 'text-slate-500 hover:bg-slate-50'}`}>
{f.label}
</button>
))}
</div>
</div>
</div>
</div>
);
}
+86
View File
@@ -0,0 +1,86 @@
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 };
history: { resultTitle: string; conclusion: string; suggestion: string; analysis: string; focus: string; warning: string; followUpTitle: string; followUpDesc: string; followUpBtn: string; basicInfo: string; ganzhi: string; hexagramDetail: string };
}
export default function HistoryResultPage({ locale, history: h }: Props) {
return (
<div className="flex flex-col xl:flex-row gap-5 min-h-full">
{/* Left: Analysis */}
<div className="flex-1 flex flex-col gap-3.5 overflow-y-auto pr-1">
{/* Hero */}
<div className="bg-white rounded-2xl p-5 border border-slate-200 flex items-center gap-5">
<div className="w-14 h-14 rounded-xl bg-violet-50 flex items-center justify-center">
<span className="text-violet-600 text-2xl"></span>
</div>
<div>
<h3 className="text-slate-900 text-lg font-bold"></h3>
<p className="text-slate-400 text-sm">2025-05-08 · </p>
</div>
<div className="ml-auto">
<span className="px-3 py-1 rounded-full bg-amber-50 text-amber-600 text-sm font-medium"></span>
</div>
</div>
{[
{ title: h.conclusion, content: '天雷无妄卦,上干下震,象征天道运行刚健不妄。此卦提示你顺应天道,不可妄行。目前转岗时机尚未完全成熟,但大方向是正确的。' },
{ title: h.suggestion, content: '建议耐心等待更好的时机。可以先做好当前岗位的积累,同时暗中准备目标岗位所需的能力和资源。秋季可能会迎来更好的机会窗口。' },
{ title: h.analysis, content: '天雷无妄卦由乾上震下组成。乾为天、为刚;震为雷、为动。天在上而雷在下,雷动于天之下,表示万物皆随自然规律而动。对于转岗之事,此卦暗示应当顺势而为,不可强求,但也不必过于保守。保持积极心态,等待合适时机即可。' },
{ title: h.focus, content: '重点关注:人际关系的维护、技能的持续提升、以及对市场环境的观察。这三方面将为未来的转岗创造有利条件。' },
].map((card, i) => (
<div key={i} className="bg-white rounded-xl p-[18px] border border-slate-200 flex flex-col gap-2.5">
<h4 className="text-slate-900 text-sm font-bold">{card.title}</h4>
<p className="text-slate-500 text-sm leading-relaxed">{card.content}</p>
</div>
))}
{/* Warning */}
<div className="bg-amber-50 rounded-xl p-3.5 flex items-start gap-2.5">
<span className="material-symbols-rounded text-amber-500 text-lg shrink-0 mt-0.5">warning</span>
<p className="text-amber-700 text-sm">{h.warning}</p>
</div>
</div>
{/* Right side column */}
<div className="w-full xl:w-[340px] flex flex-col gap-3.5 shrink-0">
{/* Follow-up CTA */}
<div className="bg-violet-600 rounded-2xl p-[18px] flex flex-col gap-3">
<h4 className="text-white text-base font-bold">{h.followUpTitle}</h4>
<p className="text-violet-200 text-sm">{h.followUpDesc}</p>
<a href={`/${locale}/history/1/followup`} className="w-full py-2.5 rounded-lg bg-white text-violet-600 text-sm font-semibold text-center hover:bg-violet-50 transition-colors">{h.followUpBtn}</a>
</div>
{/* Basic info */}
<div className="bg-white rounded-2xl p-[18px] border border-slate-200 flex flex-col gap-3">
<h4 className="text-slate-900 text-sm font-bold">{h.basicInfo}</h4>
<div className="flex justify-between text-sm"><span className="text-slate-400"></span><span className="text-slate-600"></span></div>
<div className="flex justify-between text-sm"><span className="text-slate-400"></span><span className="text-slate-600"></span></div>
<div className="flex justify-between text-sm"><span className="text-slate-400"></span><span className="text-slate-600">2025-05-08</span></div>
<div className="flex justify-between text-sm"><span className="text-slate-400"></span><span className="text-slate-600"></span></div>
</div>
{/* Ganzhi */}
<div className="bg-white rounded-2xl p-[18px] border border-slate-200 flex flex-col gap-3">
<h4 className="text-slate-900 text-sm font-bold">{h.ganzhi}</h4>
<div className="flex justify-between text-sm"><span className="text-slate-400"></span><span className="text-slate-600"></span></div>
<div className="flex justify-between text-sm"><span className="text-slate-400"></span><span className="text-slate-600"></span></div>
<div className="flex justify-between text-sm"><span className="text-slate-400"></span><span className="text-slate-600"></span></div>
</div>
{/* Hexagram detail */}
<div className="bg-white rounded-2xl p-[18px] border border-slate-200 flex flex-col gap-3 flex-1">
<h4 className="text-slate-900 text-sm font-bold">{h.hexagramDetail}</h4>
<div className="flex flex-col gap-2 items-center">
{[true, false, false, true, true, true].map((isYang, i) => isYang ? (
<div key={i} className="w-12 h-2 bg-violet-600 rounded" />
) : (
<div key={i} className="flex gap-1.5"><div className="w-4 h-2 bg-violet-400 rounded" /><div className="w-4 h-2 bg-violet-400 rounded" /></div>
))}
</div>
<p className="text-slate-500 text-sm text-center mt-2"></p>
</div>
</div>
</div>
);
}
+62
View File
@@ -0,0 +1,62 @@
interface IconProps {
name: string;
className?: string;
}
const PATHS: Record<string, string[]> = {
home: [
'M3 10.5 12 3l9 7.5',
'M5 9.5V21h14V9.5',
'M9 21v-6h6v6',
],
shopping_bag: [
'M6 7h12l1 14H5L6 7Z',
'M9 7a3 3 0 0 1 6 0',
],
casino: [
'M7 4h10a3 3 0 0 1 3 3v10a3 3 0 0 1-3 3H7a3 3 0 0 1-3-3V7a3 3 0 0 1 3-3Z',
'M8.5 8.5h.01M15.5 8.5h.01M12 12h.01M8.5 15.5h.01M15.5 15.5h.01',
],
history: [
'M3 12a9 9 0 1 0 3-6.7',
'M3 4v5h5',
'M12 7v5l3 2',
],
language: [
'M4 5h9',
'M9 3v2',
'M5 9c1.2 3.2 3.6 5.5 7 7',
'M12 5c-.8 5-3.3 8.6-8 11',
'M14 21l4-10 4 10',
'M15.5 17h5',
],
settings: [
'M12 8a4 4 0 1 0 0 8 4 4 0 0 0 0-8Z',
'M12 2v3M12 19v3M4.93 4.93l2.12 2.12M16.95 16.95l2.12 2.12M2 12h3M19 12h3M4.93 19.07l2.12-2.12M16.95 7.05l2.12-2.12',
],
notifications: [
'M18 8a6 6 0 1 0-12 0c0 7-3 7-3 9h18c0-2-3-2-3-9',
'M10 21h4',
],
chevron_right: ['M9 18l6-6-6-6'],
chevron_left: ['M15 18l-6-6 6-6'],
chevron_down: ['M6 9l6 6 6-6'],
menu: ['M4 6h16M4 12h16M4 18h16'],
close: ['M18 6L6 18M6 6l12 12'],
calendar_today: ['M7 3v4M17 3v4M4 9h16M5 5h14a1 1 0 0 1 1 1v14a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V6a1 1 0 0 1 1-1Z'],
paid: [
'M12 22a10 10 0 1 0 0-20 10 10 0 0 0 0 20Z',
'M9.5 14.5c.6.8 1.5 1.2 2.7 1.2 1.5 0 2.4-.7 2.4-1.8 0-1.2-1-1.6-2.7-2.1-1.5-.4-2.7-.9-2.7-2.4 0-1.2 1-2.1 2.8-2.1 1.1 0 2 .3 2.6 1',
'M12 6v12',
],
};
export default function Icon({ name, className = 'w-5 h-5' }: IconProps) {
const paths = PATHS[name] ?? PATHS.home;
return (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
{paths.map((d) => <path key={d} d={d} />)}
</svg>
);
}
+184
View File
@@ -0,0 +1,184 @@
import { useState, useCallback } from 'react';
import { sendOtp, loginWithEmail, ApiError } from '../lib/auth';
interface LoginFormProps {
locale: string;
translations: {
welcome: string;
subtitle: string;
emailLabel: string;
emailPlaceholder: string;
codeLabel: string;
codePlaceholder: string;
sendCode: string;
submit: string;
agreePrefix: string;
privacy: string;
agreeAnd: string;
terms: string;
};
privacyUrl: string;
termsUrl: string;
}
const ERROR_MESSAGES: Record<string, Record<string, string>> = {
AUTH_TOO_MANY_REQUESTS: { zh: '请求过于频繁,请稍后再试', zh_Hant: '請求過於頻繁,請稍後再試', en: 'Too many requests, please try again later' },
AUTH_VERIFICATION_CODE_INVALID: { zh: '验证码错误', zh_Hant: '驗證碼錯誤', en: 'Invalid verification code' },
AUTH_USER_NOT_FOUND: { zh: '用户不存在', zh_Hant: '用戶不存在', en: 'User not found' },
AUTH_SERVICE_UNAVAILABLE: { zh: '服务暂时不可用', zh_Hant: '服務暫時不可用', en: 'Service temporarily unavailable' },
};
function getErrorMessage(err: unknown, locale: string): string {
if (err instanceof ApiError && err.code) {
const msgs = ERROR_MESSAGES[err.code];
if (msgs && msgs[locale]) return msgs[locale];
}
return locale === 'en' ? 'An error occurred, please try again' : '操作失败,请重试';
}
export default function LoginForm({ locale, translations: i18n, privacyUrl, termsUrl }: LoginFormProps) {
const [email, setEmail] = useState('');
const [code, setCode] = useState('');
const [agreed, setAgreed] = useState(false);
const [sending, setSending] = useState(false);
const [countdown, setCountdown] = useState(0);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const handleSendCode = useCallback(async () => {
if (!email || countdown > 0) return;
setError('');
setSending(true);
try {
await sendOtp(email);
setCountdown(60);
const timer = setInterval(() => {
setCountdown((c) => {
if (c <= 1) { clearInterval(timer); return 0; }
return c - 1;
});
}, 1000);
} catch (err) {
setError(getErrorMessage(err, locale));
} finally {
setSending(false);
}
}, [email, countdown, locale]);
const handleSubmit = useCallback(async (e: React.FormEvent) => {
e.preventDefault();
if (!email || !code || !agreed) return;
setError('');
setLoading(true);
try {
const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
await loginWithEmail(email, code, locale, timezone);
window.location.href = `/${locale}/dashboard`;
} catch (err) {
setError(getErrorMessage(err, locale));
} finally {
setLoading(false);
}
}, [email, code, agreed, locale]);
return (
<div className="relative min-h-screen flex items-center justify-center px-4 py-8 overflow-hidden"
style={{ background: 'linear-gradient(180deg, #F5F0FF 0%, #FFFFFF 100%)' }}>
{/* Decorative blobs */}
<div className="absolute -top-16 -left-20 w-[300px] h-[300px] rounded-full opacity-30 pointer-events-none"
style={{ background: 'linear-gradient(135deg, #E8D5FF, #D5E8FF)' }} />
<div className="absolute bottom-0 right-0 w-[200px] h-[200px] rounded-full opacity-20 pointer-events-none"
style={{ background: 'linear-gradient(45deg, #C8E6FF, #E8D5FF)' }} />
<div className="absolute top-0 left-1/2 -translate-x-1/2 w-[120px] h-[120px] rounded-full opacity-[0.06] pointer-events-none"
style={{ background: 'linear-gradient(0deg, #673AB7, #9C27B0)' }} />
<div className="relative w-full max-w-[420px] bg-white rounded-2xl shadow-lg p-5 sm:p-8 flex flex-col gap-5"
style={{ boxShadow: '0 4px 24px #0000000D' }}>
{/* Header */}
<div className="flex flex-col items-center gap-2">
<div className="w-14 h-14 rounded-[14px] overflow-hidden">
<img src="/images/logo.png" alt="MeiYao" className="w-full h-full object-contain" />
</div>
<h1 className="text-slate-900 text-2xl font-bold">{i18n.welcome}</h1>
<p className="text-slate-500 text-sm">{i18n.subtitle}</p>
</div>
{error && (
<div className="text-red-500 text-sm text-center">{error}</div>
)}
{/* Email */}
<div className="flex flex-col gap-1.5">
<label htmlFor="login-email" className="text-slate-700 text-[13px] font-medium">{i18n.emailLabel}</label>
<input
id="login-email"
name="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder={i18n.emailPlaceholder}
className="w-full h-11 px-3.5 rounded-lg bg-[#F8F8F8] border border-slate-200 text-sm text-slate-900 placeholder:text-slate-400 focus:outline-none focus:border-violet-400 focus:ring-1 focus:ring-violet-400 transition-colors"
/>
</div>
{/* Code */}
<div className="flex flex-col gap-1.5">
<label htmlFor="login-code" className="text-slate-700 text-[13px] font-medium">{i18n.codeLabel}</label>
<div className="flex gap-2">
<input
id="login-code"
name="code"
type="text"
value={code}
onChange={(e) => setCode(e.target.value)}
placeholder={i18n.codePlaceholder}
maxLength={6}
className="min-w-0 flex-1 h-11 px-3.5 rounded-lg bg-[#F8F8F8] border border-slate-200 text-sm text-slate-900 placeholder:text-slate-400 focus:outline-none focus:border-violet-400 focus:ring-1 focus:ring-violet-400 transition-colors"
/>
<button
type="button"
onClick={handleSendCode}
disabled={!email || countdown > 0 || sending}
className="h-11 w-[120px] rounded-lg bg-violet-600 text-white text-[13px] font-medium whitespace-nowrap disabled:opacity-50 disabled:cursor-not-allowed hover:bg-violet-700 transition-colors shrink-0"
>
{countdown > 0 ? `${countdown}s` : sending ? '...' : i18n.sendCode}
</button>
</div>
</div>
{/* Submit */}
<button
type="button"
onClick={handleSubmit}
disabled={!email || !code || !agreed || loading}
className="w-full h-11 rounded-lg bg-violet-600 text-white text-[15px] font-semibold disabled:opacity-50 disabled:cursor-not-allowed hover:bg-violet-700 transition-colors"
>
{loading ? '...' : i18n.submit}
</button>
{/* Agreement */}
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => setAgreed(!agreed)}
className={`w-4 h-4 rounded border-[1.5px] flex items-center justify-center shrink-0 transition-colors ${agreed ? 'bg-violet-600 border-violet-600' : 'bg-white border-violet-400'}`}
>
{agreed && (
<svg className="w-3 h-3 text-white" viewBox="0 0 12 12" fill="none">
<path d="M2 6l3 3 5-5" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
)}
</button>
<p className="text-slate-500 text-xs">
{i18n.agreePrefix}
<a href={privacyUrl} className="text-violet-600 hover:underline">{i18n.privacy}</a>
{i18n.agreeAnd}
<a href={termsUrl} className="text-violet-600 hover:underline">{i18n.terms}</a>
</p>
</div>
</div>
</div>
);
}
+172
View File
@@ -0,0 +1,172 @@
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; guideManual: string; yaoTitle: string; coinLabel: string; confirmBtn: string; summaryTitle: string; checkCategory: string; checkMethod: string; checkCost: string; submitBtn: string; progressLabel: string };
}
type CoinFace = '字' | '花';
function CoinImage({ face, size = 'w-16 h-16' }: { face: CoinFace; size?: string }) {
return (
<img
src={face === '字' ? '/images/qigua/zi.jpg' : '/images/qigua/hua.jpg'}
alt={face}
className={`${size} rounded-full object-cover border border-amber-300 shadow-sm`}
/>
);
}
export default function ManualDivinationPage({ locale, divination: d }: Props) {
const cats = d.categories.split(',');
const [category, setCategory] = useState(cats[0]);
const [question, setQuestion] = useState('');
const [yaoIndex, setYaoIndex] = useState(0);
const [coins, setCoins] = useState<[CoinFace, CoinFace, CoinFace]>(['字', '字', '字']);
const [yaoResults, setYaoResults] = useState<CoinFace[][]>([]);
const flipCoin = (idx: number) => {
const next: [CoinFace, CoinFace, CoinFace] = [...coins];
next[idx] = next[idx] === '字' ? '花' : '字';
setCoins(next);
};
const confirmYao = () => {
const newResults = [...yaoResults, [...coins]];
setYaoResults(newResults);
if (newResults.length < 6) {
setYaoIndex(newResults.length);
setCoins(['字', '字', '字']);
}
};
const progress = yaoResults.length;
const isYang = (c: CoinFace[]) => c.filter(x => x === '字').length % 2 === 1;
const yaoNames = ['初爻', '二爻', '三爻', '四爻', '五爻', '上爻'];
return (
<div className="flex flex-col gap-[22px] min-h-full">
<div className="flex items-center justify-between">
<div>
<h1 className="text-[#333333] text-[28px] font-bold leading-tight">{locale === 'en' ? 'Manual Cast' : d.checkMethod.replace(/^.*|^.*: /, '')}</h1>
<p className="text-[#666666] text-sm mt-1">{locale === 'en' ? 'Prepare three matching coins and enter six results from bottom line to top line.' : '准备三枚相同的钱币,从初爻到上爻依次录入六次结果。'}</p>
</div>
<div className="hidden md:flex items-center gap-2 h-10 px-3.5 rounded-full bg-white border border-slate-200 text-[#333333] text-[13px] font-semibold">
<Icon name="paid" className="w-[18px] h-[18px] text-violet-700" />
{locale === 'en' ? 'Available 120 credits · This time 20 credits' : '可用 120 积分 · 本次 20 积分'}
</div>
</div>
<div className="flex flex-col xl:flex-row gap-[22px] min-h-0 flex-1">
{/* Left: Question + Time + Guide */}
<div className="w-full xl:w-[360px] flex flex-col gap-4 shrink-0">
{/* Question panel */}
<div className="bg-white rounded-2xl p-[22px] border border-slate-200 flex flex-col gap-4" style={{ height: '300px' }}>
<h3 className="text-slate-900 text-lg font-bold">{d.questionTitle}</h3>
<div className="flex items-center justify-between h-[42px] px-3 rounded-[10px] bg-slate-50 border border-slate-300">
<span className="text-slate-600 text-sm">{category}</span>
<select value={category} onChange={e => setCategory(e.target.value)} className="bg-transparent text-sm outline-none cursor-pointer">
{cats.map(c => <option key={c} value={c}>{c}</option>)}
</select>
</div>
<textarea value={question} onChange={e => setQuestion(e.target.value)} placeholder={d.questionPlaceholder}
className="flex-1 w-full px-3.5 py-3 rounded-[10px] bg-white border border-slate-300 text-sm focus:outline-none focus:border-violet-400 resize-none" />
</div>
{/* Time panel */}
<div className="bg-white rounded-2xl p-5 border border-slate-200 flex flex-col gap-3" style={{ height: '132px' }}>
<h3 className="text-slate-900 text-base font-bold">{d.timeTitle}</h3>
<div className="flex items-center justify-between h-[42px] px-3 rounded-[10px] bg-slate-50">
<input type="datetime-local" className="bg-transparent text-sm outline-none w-full" />
<Icon name="calendar_today" className="w-[18px] h-[18px] text-slate-400" />
</div>
</div>
{/* Guide panel */}
<div className="bg-white rounded-2xl p-5 border border-slate-200 flex flex-col gap-3 flex-1 overflow-y-auto" style={{ height: '214px' }}>
<h3 className="text-slate-900 text-base font-bold">{d.guideTitle}</h3>
<p className="text-slate-500 text-[13px] whitespace-pre-line">{d.guideManual}</p>
</div>
</div>
{/* Center: Yao panel */}
<div className="flex-1 bg-white rounded-2xl p-6 border border-slate-200 flex flex-col gap-4 overflow-y-auto">
<div className="flex items-center justify-between">
<h3 className="text-slate-900 text-lg font-bold">{d.yaoTitle}</h3>
<span className="text-violet-600 text-[13px] font-bold">{progress} / 6</span>
</div>
{/* 6 Yao rows - from bottom to top (上爻 at top) */}
<div className="flex flex-col gap-2.5">
{[5, 4, 3, 2, 1, 0].map((i) => {
const result = yaoResults[i];
const isCurrent = i === yaoIndex && progress < 6;
const isDone = result !== undefined;
return (
<div key={i}
className={`flex items-center gap-4 h-[62px] px-3.5 rounded-[10px] ${isCurrent ? 'bg-violet-50 border border-violet-400' : isDone ? 'bg-slate-50' : 'bg-slate-50 border border-slate-200'}`}>
<span className="text-slate-400 text-xs font-medium w-8">{yaoNames[i]}</span>
{isDone ? (
<div className="flex gap-2">
{result.map((face, ci) => (
<CoinImage key={ci} face={face} size="w-8 h-8" />
))}
</div>
) : (
<span className="text-slate-300 text-xs"></span>
)}
{isDone && (
<div className="ml-auto">
{isYang(result) ? (
<div className="w-12 h-2 bg-violet-600 rounded" />
) : (
<div className="flex gap-1.5"><div className="w-4 h-2 bg-violet-400 rounded" /><div className="w-4 h-2 bg-violet-400 rounded" /></div>
)}
</div>
)}
</div>
);
})}
</div>
{/* Coin selector */}
{progress < 6 && (
<div className="bg-slate-50 rounded-xl p-4 flex flex-col items-center gap-4" style={{ minHeight: '142px' }}>
<div className="flex items-center gap-6">
{coins.map((face, ci) => (
<div key={ci} className="flex flex-col items-center gap-2" onClick={() => flipCoin(ci)} style={{ cursor: 'pointer', width: '86px' }}>
<div className={`w-16 h-16 rounded-full border-2 flex items-center justify-center text-lg font-bold transition-all cursor-pointer select-none ${face === '字' ? 'bg-amber-100 border-amber-400 text-amber-700' : 'bg-slate-200 border-slate-400 text-slate-600'}`}>
<CoinImage face={face} />
</div>
<span className="text-slate-400 text-xs">{face === '字' ? '正面' : '反面'}</span>
</div>
))}
</div>
</div>
)}
{progress < 6 && (
<button onClick={confirmYao} className="w-full h-10 rounded-xl bg-violet-600 text-white text-[13px] font-bold hover:bg-violet-700 transition-colors">{d.confirmBtn}</button>
)}
</div>
{/* Right: Summary */}
<div className="w-full xl:w-[300px] bg-white rounded-2xl p-[22px] border border-slate-200 flex flex-col gap-[18px] shrink-0">
<h3 className="text-slate-900 text-lg font-bold">{d.summaryTitle}</h3>
{/* Progress */}
<div className="bg-slate-50 rounded-xl p-4 flex flex-col gap-2">
<p className="text-slate-500 text-[13px]">{d.progressLabel}</p>
<p className="text-violet-600 text-[28px] font-bold">{progress} / 6</p>
</div>
<p className="text-slate-500 text-sm">{d.checkCategory}</p>
<p className="text-slate-500 text-sm">{d.checkMethod}</p>
<p className="text-slate-500 text-sm">{d.checkCost}</p>
<div className="flex-1" />
<button disabled={progress < 6}
className={`w-full h-[46px] rounded-full text-sm font-bold transition-colors ${progress >= 6 ? 'bg-violet-600 text-white hover:bg-violet-700' : 'bg-slate-300 text-slate-400 cursor-not-allowed'}`}>
{d.submitBtn}
</button>
</div>
</div>
</div>
);
}
+37 -27
View File
@@ -12,33 +12,43 @@ const footer = t(locale, 'footer');
const otherLocales: Locale[] = (['zh', 'zh_Hant', 'en'] as Locale[]).filter((l) => l !== locale);
---
<header class="w-full flex items-center justify-between h-16 md:h-20 px-5 md:px-20 border-b border-slate-200 bg-white sticky top-0 z-50">
<a href={localePath(locale, '/')} class="flex items-center gap-2 md:gap-3 shrink-0">
<img src="/images/logo.png" alt="MeiYao" class="w-8 h-8 md:w-9 md:h-9" />
<span class="text-slate-900 text-lg md:text-xl font-bold whitespace-nowrap">{footer.brandName}</span>
</a>
<nav class="flex items-center gap-4 md:gap-8">
<a href={localePath(locale, '/features')} class="hidden sm:block text-slate-600 text-sm hover:text-slate-900 whitespace-nowrap">{nav.features}</a>
<a href={localePath(locale, '/pricing')} class="hidden sm:block text-slate-600 text-sm hover:text-slate-900 whitespace-nowrap">{nav.pricing}</a>
<a href={localePath(locale, '/about')} class="hidden sm:block text-slate-600 text-sm hover:text-slate-900 whitespace-nowrap">{nav.about}</a>
<div class="relative group">
<button class="flex items-center gap-1 px-2 py-1.5 text-xs text-slate-600 rounded-lg border border-slate-200 bg-white hover:bg-slate-50 whitespace-nowrap">
{getLocaleLabel(locale)}
<svg class="w-3 h-3 text-slate-400" viewBox="0 0 12 12" fill="none"><path d="M3 5l3 3 3-3" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>
</button>
<div class="absolute right-0 top-full mt-1 bg-white border border-slate-200 rounded-lg shadow-lg py-1 opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-all z-50">
{otherLocales.map((l) => (
<a href={localePath(l, currentPath)} class="block px-4 py-2 text-sm text-slate-600 hover:bg-slate-50 hover:text-slate-900 whitespace-nowrap">
{getLocaleLabel(l)}
</a>
))}
</div>
</div>
<a href={localePath(locale, '/login')} class="bg-violet-600 text-white text-xs md:text-sm font-semibold px-4 md:px-5 py-2 md:py-2.5 rounded-lg hover:bg-violet-700 transition-colors whitespace-nowrap">
{nav.getStarted}
<header class="w-full border-b border-slate-200 bg-white sticky top-0 z-50">
<div class="flex h-16 md:h-20 items-center justify-between gap-3 px-5 md:px-20">
<a href={localePath(locale, '/')} class="flex min-w-0 items-center gap-2 md:gap-3 shrink">
<img src="/images/logo.png" alt="MeiYao" class="w-8 h-8 md:w-9 md:h-9 shrink-0" />
<span class="truncate text-slate-900 text-lg md:text-xl font-bold whitespace-nowrap">{footer.brandName}</span>
</a>
<nav class="hidden md:flex items-center gap-8">
<a href={localePath(locale, '/features')} class="text-slate-600 text-sm hover:text-slate-900 whitespace-nowrap">{nav.features}</a>
<a href={localePath(locale, '/pricing')} class="text-slate-600 text-sm hover:text-slate-900 whitespace-nowrap">{nav.pricing}</a>
<a href={localePath(locale, '/about')} class="text-slate-600 text-sm hover:text-slate-900 whitespace-nowrap">{nav.about}</a>
</nav>
<div class="flex items-center gap-3 md:gap-4 shrink-0">
<details class="relative">
<summary class="flex list-none items-center gap-1 px-2 py-1.5 text-xs text-slate-600 rounded-lg border border-slate-200 bg-white hover:bg-slate-50 whitespace-nowrap cursor-pointer">
{getLocaleLabel(locale)}
<svg class="w-3 h-3 text-slate-400" viewBox="0 0 12 12" fill="none"><path d="M3 5l3 3 3-3" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>
</summary>
<div class="absolute right-0 top-full mt-1 min-w-full bg-white border border-slate-200 rounded-lg shadow-lg py-1 z-50">
{otherLocales.map((l) => (
<a href={localePath(l, currentPath)} class="block px-4 py-2 text-sm text-slate-600 hover:bg-slate-50 hover:text-slate-900 whitespace-nowrap">
{getLocaleLabel(l)}
</a>
))}
</div>
</details>
<a href={localePath(locale, '/login')} class="bg-violet-600 text-white text-xs md:text-sm font-semibold px-4 md:px-5 py-2 md:py-2.5 rounded-lg hover:bg-violet-700 transition-colors whitespace-nowrap">
{nav.getStarted}
</a>
</div>
</div>
<nav class="grid grid-cols-3 border-t border-slate-100 px-5 py-2 text-center md:hidden">
<a href={localePath(locale, '/features')} class="text-slate-600 text-sm font-medium hover:text-slate-900">{nav.features}</a>
<a href={localePath(locale, '/pricing')} class="text-slate-600 text-sm font-medium hover:text-slate-900">{nav.pricing}</a>
<a href={localePath(locale, '/about')} class="text-slate-600 text-sm font-medium hover:text-slate-900">{nav.about}</a>
</nav>
</header>
+33
View File
@@ -0,0 +1,33 @@
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 };
notifications: { title: string; welcomeTitle: string; welcomeBody: string; hexagramTitle: string; hexagramBody: string; creditsTitle: string; creditsBody: string };
}
const MOCK_NOTIFS = [
{ title: 'welcomeTitle', body: 'welcomeBody', unread: true, time: '1天前', timeEn: '1 day ago' },
{ title: 'hexagramTitle', body: 'hexagramBody', unread: true, time: '2天前', timeEn: '2 days ago' },
{ title: 'creditsTitle', body: 'creditsBody', unread: false, time: '3天前', timeEn: '3 days ago' },
{ title: 'welcomeTitle', body: 'welcomeBody', unread: false, time: '5天前', timeEn: '5 days ago' },
{ title: 'creditsTitle', body: 'creditsBody', unread: false, time: '7天前', timeEn: '7 days ago' },
];
export default function NotificationPage({ locale, notifications: n }: Props) {
return (
<div className="flex flex-col gap-6 min-h-full">
<h1 className="text-slate-900 text-2xl font-bold">{n.title}</h1>
<div className="flex flex-col gap-3">
{MOCK_NOTIFS.map((notif, i) => (
<div key={i} className="relative bg-white rounded-xl px-5 py-4 flex items-start gap-4 hover:shadow-sm transition-shadow">
{notif.unread && <div className="absolute left-4 top-5 w-2 h-2 rounded-full bg-red-500" />}
<div className="flex-1 min-w-0 ml-2">
<p className={`text-[15px] ${notif.unread ? 'text-slate-900 font-semibold' : 'text-slate-600'}`}>{(n as any)[notif.title]}</p>
<p className="text-slate-400 text-[13px] mt-1">{(n as any)[notif.body]}</p>
</div>
<span className="text-slate-400 text-xs shrink-0 mt-0.5">{locale === 'en' ? notif.timeEn : notif.time}</span>
</div>
))}
</div>
</div>
);
}
+59
View File
@@ -0,0 +1,59 @@
import { useState } from 'react';
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 };
profile: { avatarTitle: string; avatarHint: string; uploadBtn: string; formTitle: string; emailLabel: string; displayNameLabel: string; displayNamePlaceholder: string; bioLabel: string; bioPlaceholder: string; saveBtn: string; cancelBtn: string };
}
export default function ProfileDetailPage({ profile: p }: Props) {
const [displayName, setDisplayName] = useState('');
const [bio, setBio] = useState('');
return (
<div className="flex flex-col lg:flex-row gap-6 min-h-full">
{/* Avatar edit */}
<div className="w-full lg:w-[360px] bg-white rounded-2xl p-7 border border-slate-200 flex flex-col items-center gap-5 shrink-0 self-start">
<div className="w-32 h-32 rounded-full bg-violet-50 border-2 border-violet-200 flex items-center justify-center">
<span className="text-violet-600 text-4xl font-bold">U</span>
</div>
<h3 className="text-slate-900 text-lg font-bold">{p.avatarTitle}</h3>
<p className="text-slate-500 text-[13px] text-center">{p.avatarHint}</p>
<button className="w-full h-[42px] rounded-full bg-violet-600 text-white text-sm font-semibold hover:bg-violet-700 transition-colors">{p.uploadBtn}</button>
</div>
{/* Form */}
<div className="flex-1 bg-white rounded-2xl p-7 border border-slate-200 flex flex-col gap-6">
<h3 className="text-slate-900 text-xl font-bold">{p.formTitle}</h3>
{/* Email readonly */}
<div className="bg-slate-50 rounded-xl px-4 py-4 flex items-center gap-4">
<span className="material-symbols-rounded text-slate-400 text-lg">email</span>
<div>
<p className="text-slate-400 text-xs">{p.emailLabel}</p>
<p className="text-slate-600 text-sm">user@example.com</p>
</div>
</div>
{/* Display name */}
<div className="flex flex-col gap-2">
<label className="text-slate-700 text-sm font-medium">{p.displayNameLabel}</label>
<input value={displayName} onChange={e => setDisplayName(e.target.value)} placeholder={p.displayNamePlaceholder}
className="w-full h-11 px-4 rounded-lg bg-slate-50 border border-slate-200 text-sm focus:outline-none focus:border-violet-400 focus:ring-1 focus:ring-violet-400" />
</div>
{/* Bio */}
<div className="flex flex-col gap-2">
<label className="text-slate-700 text-sm font-medium">{p.bioLabel}</label>
<textarea value={bio} onChange={e => setBio(e.target.value)} placeholder={p.bioPlaceholder} rows={4}
className="w-full px-4 py-3 rounded-lg bg-slate-50 border border-slate-200 text-sm focus:outline-none focus:border-violet-400 focus:ring-1 focus:ring-violet-400 resize-none" />
</div>
<div className="flex gap-3 justify-end mt-auto">
<button className="px-5 py-2.5 rounded-lg text-sm text-slate-500 hover:bg-slate-50 transition-colors">{p.cancelBtn}</button>
<button className="px-5 py-2.5 rounded-lg bg-violet-600 text-white text-sm font-semibold hover:bg-violet-700 transition-colors">{p.saveBtn}</button>
</div>
</div>
</div>
);
}
+81
View File
@@ -0,0 +1,81 @@
import { logout } from '../lib/auth';
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 };
settings: { title: string; profileTitle: string; emailLabel: string; nameLabel: string; joinedLabel: string; pointsTitle: string; pointsBalance: string; accountTitle: string; changeName: string; changeAvatar: string; changeLanguage: string; legalTitle: string; privacy: string; terms: string; logout: string; logoutConfirm: string };
}
export default function SettingsPage({ locale, settings: s }: Props) {
const handleLogout = () => {
if (confirm(s.logoutConfirm)) {
logout().finally(() => {
window.location.href = `/${locale}/login`;
});
}
};
return (
<div className="flex flex-col gap-6 min-h-full">
<h1 className="text-slate-900 text-xl font-bold">{s.title}</h1>
<div className="flex flex-col lg:flex-row gap-6 flex-1 min-h-0">
{/* Left column */}
<div className="w-full lg:w-[360px] flex flex-col gap-4 shrink-0">
{/* Profile summary */}
<div className="bg-white rounded-2xl p-[18px] border border-slate-200 flex flex-col gap-3">
<h3 className="text-slate-900 text-sm font-bold">{s.profileTitle}</h3>
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-full bg-violet-600 flex items-center justify-center text-white text-sm font-bold">U</div>
<div>
<p className="text-slate-900 text-sm font-medium">User</p>
<p className="text-slate-400 text-xs">user@example.com</p>
</div>
</div>
</div>
{/* Points */}
<div className="bg-white rounded-2xl p-5 border border-slate-200 flex items-center gap-4">
<span className="material-symbols-rounded text-violet-600 text-2xl">account_balance_wallet</span>
<div>
<p className="text-slate-400 text-xs">{s.pointsTitle}</p>
<p className="text-slate-900 text-lg font-bold">210 <span className="text-sm font-normal text-slate-400">{s.pointsBalance}</span></p>
</div>
</div>
</div>
{/* Right column */}
<div className="flex-1 flex flex-col gap-4">
{/* Account settings */}
<div className="bg-white rounded-2xl p-6 border border-slate-200 flex flex-col gap-4">
<h3 className="text-slate-900 text-base font-bold">{s.accountTitle}</h3>
<a href={`/${locale}/profile`} className="flex items-center justify-between py-2 hover:bg-slate-50 rounded-lg px-1 transition-colors">
<span className="text-slate-600 text-sm">{s.changeAvatar}</span>
<span className="material-symbols-rounded text-slate-400 text-lg">chevron_right</span>
</a>
<a href={`/${locale}/profile`} className="flex items-center justify-between py-2 hover:bg-slate-50 rounded-lg px-1 transition-colors">
<span className="text-slate-600 text-sm">{s.changeName}</span>
<span className="material-symbols-rounded text-slate-400 text-lg">chevron_right</span>
</a>
<div className="flex items-center justify-between py-2">
<span className="text-slate-600 text-sm">{s.changeLanguage}</span>
<span className="text-slate-400 text-sm"></span>
</div>
</div>
{/* Legal */}
<div className="bg-white rounded-2xl p-6 border border-slate-200 flex flex-col gap-4">
<h3 className="text-slate-900 text-base font-bold">{s.legalTitle}</h3>
<a href={`/${locale}/privacy`} className="text-slate-600 text-sm hover:text-violet-600">{s.privacy}</a>
<a href={`/${locale}/terms`} className="text-slate-600 text-sm hover:text-violet-600">{s.terms}</a>
</div>
{/* Logout */}
<button onClick={handleLogout} className="bg-white rounded-2xl px-5 py-3.5 border border-red-200 flex items-center justify-between hover:bg-red-50 transition-colors">
<span className="text-red-500 text-sm font-medium">{s.logout}</span>
<span className="material-symbols-rounded text-red-400 text-lg">logout</span>
</button>
</div>
</div>
</div>
);
}
+84
View File
@@ -0,0 +1,84 @@
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 };
store: { title: string; currentPoints: string; pointsLabel: string; rulesTitle: string; rule1: string; rule2: string; rule3: string; popularLabel: string; popularText: string; stepsTitle: string; step1: string; step2: string; step3: string; sideTitle: string; sideDesc: string };
pricing: { p1Name: string; p1Badge: string; p1Price: string; p1Credits: string; p1Desc: string; p2Name: string; p2Price: string; p2Credits: string; p2Desc: string; p3Name: string; p3Badge: string; p3Price: string; p3Credits: string; p3Desc: string; p4Name: string; p4Price: string; p4Credits: string; p4Desc: string; buyNow: string };
}
const PACKAGES = ['p1', 'p2', 'p3', 'p4'] as const;
export default function StorePage({ store: s, pricing: p }: Props) {
const pkgs = PACKAGES.map(k => ({
name: p[`${k}Name`], badge: p[`${k}Badge` as keyof typeof p] || '', price: p[`${k}Price`], credits: p[`${k}Credits`], desc: p[`${k}Desc`],
featured: k === 'p3',
}));
return (
<div className="flex flex-col gap-5 min-h-full">
<div className="flex items-center justify-between">
<h1 className="text-slate-900 text-xl font-bold">{s.title}</h1>
<div className="w-10 h-10 rounded-xl border border-slate-200 flex items-center justify-center">
<span className="material-symbols-rounded text-slate-500 text-xl">notifications</span>
</div>
</div>
{/* Top: Points hero + rules */}
<div className="flex flex-col lg:flex-row gap-5">
<div className="flex-1 rounded-2xl p-7 flex items-center gap-6" style={{ background: 'linear-gradient(135deg, #673AB7, #9C27B0)' }}>
<div className="w-[68px] h-[68px] rounded-full flex items-center justify-center" style={{ background: 'rgba(255,255,255,0.14)' }}>
<span className="material-symbols-rounded text-white text-3xl">account_balance_wallet</span>
</div>
<div>
<p className="text-violet-200 text-sm">{s.currentPoints}</p>
<p className="text-white text-3xl font-bold">210 <span className="text-base font-normal text-violet-200">{s.pointsLabel}</span></p>
</div>
</div>
<div className="w-full lg:w-[320px] bg-white rounded-2xl p-5 border border-slate-200 flex flex-col gap-3.5 shrink-0">
<div className="flex items-center gap-2.5">
<span className="material-symbols-rounded text-violet-600 text-lg">info</span>
<span className="text-slate-900 text-sm font-bold">{s.rulesTitle}</span>
</div>
<p className="text-slate-500 text-sm">{s.rule1}</p>
<p className="text-slate-500 text-sm">{s.rule2}</p>
<p className="text-slate-500 text-sm">{s.rule3}</p>
</div>
</div>
{/* Body: Packages + side panel */}
<div className="flex flex-col xl:flex-row gap-5 flex-1 min-h-0">
<div className="flex-1 flex flex-col gap-4 overflow-y-auto">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{pkgs.map((pkg, i) => (
<div key={i} className={`bg-white rounded-2xl p-6 flex flex-col gap-3 border ${pkg.featured ? 'border-violet-400 ring-1 ring-violet-100' : 'border-slate-200'}`}>
<div className="flex items-center justify-between">
<span className="text-slate-900 font-bold text-base">{pkg.name}</span>
{pkg.badge && <span className={`text-xs px-2.5 py-0.5 rounded-full ${pkg.featured ? 'bg-violet-600 text-white' : 'bg-amber-100 text-amber-700'}`}>{pkg.badge}</span>}
</div>
<p className="text-slate-900 text-2xl font-extrabold">{pkg.price}</p>
<p className="text-violet-600 text-sm font-medium">{pkg.credits}</p>
<p className="text-slate-500 text-sm">{pkg.desc}</p>
<button className={`w-full py-2.5 rounded-lg font-semibold text-sm mt-auto ${pkg.featured ? 'bg-violet-600 text-white hover:bg-violet-700' : 'bg-white text-violet-600 border border-violet-200 hover:bg-violet-50'} transition-colors`}>{p.buyNow}</button>
</div>
))}
</div>
</div>
{/* Side panel */}
<div className="w-full xl:w-[320px] bg-white rounded-2xl p-5 border border-slate-200 flex flex-col gap-4 shrink-0 overflow-y-auto">
<div className="w-12 h-12 rounded-xl bg-violet-50 flex items-center justify-center">
<span className="material-symbols-rounded text-violet-600 text-2xl">shopping_cart</span>
</div>
<p className="text-slate-900 text-lg font-bold">{s.sideTitle}</p>
<p className="text-slate-500 text-sm">{s.sideDesc}</p>
<div className="h-px bg-slate-100" />
<p className="text-slate-400 text-xs font-semibold">{s.popularLabel}</p>
<div className="bg-amber-50 rounded-xl p-3.5 text-amber-700 text-sm">{s.popularText}</div>
<p className="text-slate-400 text-xs font-semibold mt-2">{s.stepsTitle}</p>
<div className="flex items-center gap-2.5 text-sm text-slate-600"><span className="w-5 h-5 rounded-full bg-violet-100 text-violet-600 text-xs font-bold flex items-center justify-center">1</span>{s.step1}</div>
<div className="flex items-center gap-2.5 text-sm text-slate-600"><span className="w-5 h-5 rounded-full bg-violet-100 text-violet-600 text-xs font-bold flex items-center justify-center">2</span>{s.step2}</div>
<div className="flex items-center gap-2.5 text-sm text-slate-600"><span className="w-5 h-5 rounded-full bg-violet-100 text-violet-600 text-xs font-bold flex items-center justify-center">3</span>{s.step3}</div>
</div>
</div>
</div>
);
}
+25
View File
@@ -0,0 +1,25 @@
import type { ReactNode } from 'react';
export interface NavItem {
id: string;
icon: string;
label: string;
href: string;
sub?: { id: string; label: string; href: string }[];
}
export function getNavConfig(locale: string, d: {
navHome: string; navStore: string; navDivination: string; navManual: string; navAuto: string; navHistory: string; navLanguage: string; navSettings: string;
}): NavItem[] {
return [
{ id: 'home', icon: 'home', label: d.navHome, href: `/${locale}/dashboard` },
{ id: 'store', icon: 'shopping_bag', label: d.navStore, href: `/${locale}/store` },
{ id: 'divination', icon: 'casino', label: d.navDivination, href: '', sub: [
{ id: 'manual', label: d.navManual, href: `/${locale}/divination/manual` },
{ id: 'auto', label: d.navAuto, href: `/${locale}/divination/auto` },
]},
{ id: 'history', icon: 'history', label: d.navHistory, href: `/${locale}/history` },
{ id: 'language', icon: 'language', label: d.navLanguage, href: '' },
{ id: 'settings', icon: 'settings', label: d.navSettings, href: `/${locale}/settings` },
];
}
+36 -4
View File
@@ -26,7 +26,15 @@ export interface Translations {
footer: { brandName: string; desc: string; col1Title: string; col1Link1: string; col1Link2: string; col2Title: string; col2Link1: string; col2Link2: string; col3Title: string; col3Link1: string; col3Link2: string };
features: { title: string; subtitle: string; tagline: string; c1Title: string; c1Desc: string; c2Title: string; c2Desc: string; c3Title: string; c3Desc: string; c4Title: string; c4Desc: string; c5Title: string; c5Desc: string; c6Title: string; c6Desc: string };
pricing: { title: string; subtitle: string; p1Name: string; p1Badge: string; p1Price: string; p1Credits: string; p1Desc: string; p2Name: string; p2Price: string; p2Credits: string; p2Desc: string; p2Detail: string; p3Name: string; p3Badge: string; p3Price: string; p3Credits: string; p3Desc: string; p4Name: string; p4Price: string; p4Credits: string; p4Desc: string; p4Detail: string; buyNow: string };
about: { title: string; subtitle: string; storyTitle: string; p1: string; p2: string; p3: string; companyInfo: string; company: string; emailLabel: string; email: string; devLabel: string; developer: string; icpLabel: string; icp: string; warningTitle: string; warningBody: string; legalTitle: string };
about: { title: string; subtitle: string; storyTitle: string; p1: string; p2: string; p3: string; companyInfo: string; company: string; emailLabel: string; email: string; devLabel: string; developer: string; warningTitle: string; warningBody: string; legalTitle: string };
login: { welcome: string; subtitle: string; emailLabel: string; emailPlaceholder: string; codeLabel: string; codePlaceholder: string; sendCode: string; submit: string; agreePrefix: string; privacy: string; agreeAnd: string; terms: string };
dashboard: { brandName: string; navHome: string; navStore: string; navDivination: string; navManual: string; navAuto: string; navHistory: string; navLanguage: string; navSettings: string; greeting: string; greetingSub: string; heroTitle: string; heroDesc: string; heroCta: string; historyTitle: string; historyViewAll: string; logout: string };
notifications: { title: string; welcomeTitle: string; welcomeBody: string; hexagramTitle: string; hexagramBody: string; creditsTitle: string; creditsBody: string };
store: { title: string; currentPoints: string; pointsLabel: string; rulesTitle: string; rule1: string; rule2: string; rule3: string; popularLabel: string; popularText: string; stepsTitle: string; step1: string; step2: string; step3: string; autoCredit: string; sideTitle: string; sideDesc: string };
settings: { title: string; profileTitle: string; emailLabel: string; nameLabel: string; joinedLabel: string; pointsTitle: string; pointsBalance: string; accountTitle: string; changeName: string; changeAvatar: string; changeLanguage: string; legalTitle: string; privacy: string; terms: string; logout: string; logoutConfirm: string };
profile: { avatarTitle: string; avatarHint: string; uploadBtn: string; formTitle: string; emailLabel: string; displayNameLabel: string; displayNamePlaceholder: string; bioLabel: string; bioPlaceholder: string; saveBtn: string; cancelBtn: string };
divination: { questionTitle: string; questionPlaceholder: string; categoryLabel: string; categories: string; timeTitle: string; timeHint: string; guideTitle: string; guideManual: string; guideAuto: string; yaoTitle: string; coinLabel: string; confirmBtn: string; summaryTitle: string; checkCategory: string; checkMethod: string; checkCost: string; submitBtn: string; shakeTitle: string; shakeBtn: string; hexPreview: string; progressLabel: string };
history: { title: string; statTotal: string; statFollow: string; statLatest: string; filters: string; filterAll: string; filterCareer: string; filterLove: string; filterWealth: string; noResults: string; resultTitle: string; conclusion: string; suggestion: string; analysis: string; focus: string; warning: string; followUpTitle: string; followUpDesc: string; followUpBtn: string; chatTitle: string; chatPlaceholder: string; sendBtn: string; relatedActions: string; newDivination: string; viewHistory: string; followUpRules: string; followUpRule1: string; followUpRule2: string; basicInfo: string; ganzhi: string; hexagramDetail: string };
}
const translations: Record<Locale, Translations> = {
@@ -39,7 +47,15 @@ const translations: Record<Locale, Translations> = {
footer: { brandName: '觅爻签问', desc: '以古老智慧,解读今时困惑。让每一次签问,都成为与自己对话的机会。', col1Title: '产品', col1Link1: '功能介绍', col1Link2: '定价', col2Title: '支持', col2Link1: '帮助中心', col2Link2: '联系我们', col3Title: '法律', col3Link1: '隐私政策', col3Link2: '服务条款' },
features: { title: '功能特性', subtitle: '以古老智慧解读今时困惑,觅爻签问提供完整的易学体验', tagline: '从起卦到解读,每一步都精心设计', c1Title: '两种起卦方式', c1Desc: '手动起卦与自动起卦,灵活选择最适合你的方式。推荐使用手动起卦,卦象解读更准确。', c2Title: 'AI 解卦分析', c2Desc: '基于传统六爻卦象与周易哲学体系,结合AI智能分析,提供深度卦象解读与建议。', c3Title: '九类问题覆盖', c3Desc: '事业、情感、财富、运势、解梦、健康、学业、寻物等九大领域,全面覆盖日常生活所问。', c4Title: '追问互动', c4Desc: '每次解卦后可深入追问一次,针对卦象细节获取更多洞见,让解读更加全面深入。', c5Title: '历史记录', c5Desc: '自动保存所有解卦记录,包括卦象详情与AI解读。随时回顾历史,追踪问题变化趋势。', c6Title: '点数系统', c6Desc: '提供灵活积分套餐:新人专享、入门补充、常用加量、高频进阶。按需购买,自由使用。' },
pricing: { title: '选择适合你的套餐', subtitle: '灵活积分套餐,按需选择,随时可用', p1Name: '新人专享包', p1Badge: '限购一次', p1Price: '$0.99', p1Credits: '60 积分', p1Desc: '最适合初次体验', p2Name: '入门补充包', p2Price: '$4.99', p2Credits: '100 积分', p2Desc: '日常解卦补充', p2Detail: '适量点数补充,经济实惠之选', p3Name: '常用加量包', p3Badge: '推荐', p3Price: '$7.99', p3Credits: '210 积分', p3Desc: '最适合日常使用', p4Name: '高频进阶包', p4Price: '$12.99', p4Credits: '415 积分', p4Desc: '重度使用优选', p4Detail: '大量点数储备,超值单价', buyNow: '立即购买' },
about: { title: '关于觅爻签问', subtitle: '了解我们的理念与定位', storyTitle: '我们的故事', p1: '觅爻签问是一个借助于 AI 解读传统六爻卦象的平台,为用户了解中国传统易学文化提供一个窗口。六爻卦象源于《周易》深邃的哲学体系,是古人探索世界运行规律的一种独特方法。古人认为宇宙万物相互关联,在你起卦时,你的心念与时空信息会凝结成卦象的方式呈现出来。', p2: '觅爻签问基于这样的思路,帮助你跳出局限思维,从全局和演变趋势看清矛盾、机会与风险,为判断和行动提供参考。', p3: '用 AI 解锁古老智慧,让觅爻签问成为你探索趋势、明晰方向的现代助手。', companyInfo: '公司信息', company: '洵觅科技(深圳)有限公司', emailLabel: '联系邮箱', email: 'xuyunlong@xunmee.com', devLabel: '开发者', developer: 'Ann Lee', icpLabel: '备案号', icp: '粤ICP备2025428416号-1A', warningTitle: '特别提醒', warningBody: '卦象解读结果均由 AI 生成,仅供娱乐参考,切不可作为商业、医疗等专业领域的决策依据。理性看待卦象,自由掌握人生。', legalTitle: '法律条款' },
about: { title: '关于觅爻签问', subtitle: '了解我们的理念与定位', storyTitle: '我们的故事', p1: '觅爻签问是一个借助于 AI 解读传统六爻卦象的平台,为用户了解中国传统易学文化提供一个窗口。六爻卦象源于《周易》深邃的哲学体系,是古人探索世界运行规律的一种独特方法。古人认为宇宙万物相互关联,在你起卦时,你的心念与时空信息会凝结成卦象的方式呈现出来。', p2: '觅爻签问基于这样的思路,帮助你跳出局限思维,从全局和演变趋势看清矛盾、机会与风险,为判断和行动提供参考。', p3: '用 AI 解锁古老智慧,让觅爻签问成为你探索趋势、明晰方向的现代助手。', companyInfo: '公司信息', company: '洵觅科技(深圳)有限公司', emailLabel: '联系邮箱', email: 'xuyunlong@xunmee.com', devLabel: '开发者', developer: 'Ann Lee', warningTitle: '特别提醒', warningBody: '卦象解读结果均由 AI 生成,仅供娱乐参考,切不可作为商业、医疗等专业领域的决策依据。理性看待卦象,自由掌握人生。', legalTitle: '法律条款' },
login: { welcome: '欢迎', subtitle: '使用邮箱验证码快速登录或注册', emailLabel: '邮箱地址', emailPlaceholder: '请输入邮箱地址', codeLabel: '验证码', codePlaceholder: '请输入验证码', sendCode: '获取验证码', submit: '登录 / 注册', agreePrefix: '我已阅读并同意', privacy: '《隐私政策》', agreeAnd: '和', terms: '《服务条款》' },
dashboard: { brandName: '觅爻签问', navHome: '首页', navStore: '商店', navDivination: '起卦', navManual: '手动起卦', navAuto: '自动起卦', navHistory: '历史解卦', navLanguage: '语言', navSettings: '设置', greeting: '下午好', greetingSub: '今天想要探寻什么方向?', heroTitle: '开始您的卦象之旅', heroDesc: '借助AI智能,探索未来的可能。心中有问,起卦便知。', heroCta: '立即起卦', historyTitle: '历史解卦', historyViewAll: '查看全部 →', logout: '退出登录' },
notifications: { title: '通知中心', welcomeTitle: '欢迎使用觅爻签问', welcomeBody: '感谢您注册觅爻签问!您可以开始使用占卜服务进行卦象咨询了。', hexagramTitle: '卦象已生成 — 今年转岗是否合适?', hexagramBody: '您的卦象已完成,点击查看详细解读。', creditsTitle: '积分到账通知', creditsBody: '您购买的「常用加量包」210积分已到账,可在个人中心查看。' },
store: { title: '积分商店', currentPoints: '当前积分', pointsLabel: '积分', rulesTitle: '积分规则', rule1: '1 次起卦会消耗固定积分', rule2: '充值完成后积分实时入账', rule3: '新人专享包每个账号限购一次', popularLabel: '推荐选择', popularText: '常用加量包性价比最高,适合大多数用户日常使用。', stepsTitle: '支付流程', step1: '选择套餐并确认', step2: '完成支付', step3: '积分自动到账', autoCredit: '购买后自动到账', sideTitle: '购买后自动到账', sideDesc: '选择套餐并完成支付后,积分会同步到当前账号。' },
settings: { title: '设置', profileTitle: '个人资料', emailLabel: '邮箱', nameLabel: '昵称', joinedLabel: '注册时间', pointsTitle: '积分余额', pointsBalance: '积分', accountTitle: '账号设置', changeName: '修改昵称', changeAvatar: '修改头像', changeLanguage: '切换语言', legalTitle: '法律条款', privacy: '隐私政策', terms: '服务条款', logout: '退出登录', logoutConfirm: '确定要退出登录吗?' },
profile: { avatarTitle: '头像', avatarHint: '支持 PNG / JPG / WEBP,建议上传清晰正方形头像。', uploadBtn: '上传头像', formTitle: '基础资料', emailLabel: '邮箱', displayNameLabel: '昵称', displayNamePlaceholder: '请输入昵称', bioLabel: '个人简介', bioPlaceholder: '请输入个人简介', saveBtn: '保存', cancelBtn: '取消' },
divination: { questionTitle: '提出你的问题', questionPlaceholder: '请输入你想问的问题...', categoryLabel: '问题类型', categories: '事业,感情,财富,运势,解梦,健康,学业,寻物,其他', timeTitle: '起卦时间', timeHint: '默认使用当前时间,也可手动选择', guideTitle: '起卦指引', guideManual: '手动起卦需要您亲自抛掷三枚铜钱六次,系统会根据结果生成卦象。请按照以下步骤操作:\n\n1. 心中默念问题\n2. 点击下方铜钱选择正反面\n3. 每爻抛掷三枚铜钱\n4. 重复六次完成起卦', guideAuto: '自动起卦由系统随机生成卦象,适合快速获取结果。请按照以下步骤操作:\n\n1. 心中默念问题\n2. 点击"摇卦"按钮\n3. 系统自动生成卦象', yaoTitle: '六爻铜钱', coinLabel: '点击铜钱选择正反面', confirmBtn: '确认此爻', summaryTitle: '提交前检查', checkCategory: '问题类型:事业', checkMethod: '起卦方式:手动起卦', checkCost: '解卦消耗:20 积分', submitBtn: '确认提交', shakeTitle: '摇卦', shakeBtn: '摇一摇', hexPreview: '卦象预览', progressLabel: '完成进度' },
history: { title: '历史解卦', statTotal: '总卦数', statFollow: '可追问', statLatest: '最近一卦', filters: '快速筛选', filterAll: '全部', filterCareer: '事业', filterLove: '感情', filterWealth: '财富', noResults: '暂无解卦记录', resultTitle: '卦象解读', conclusion: '结论', suggestion: '建议', analysis: '详细分析', focus: '重点关注', warning: '以上解读由 AI 生成,仅供参考娱乐,不作为决策依据。', followUpTitle: '追问', followUpDesc: '对卦象有疑问?可以追问一次获取更深入的解读。', followUpBtn: '开始追问', chatTitle: '追问对话', chatPlaceholder: '输入你的追问...', sendBtn: '发送', relatedActions: '相关操作', newDivination: '重新起卦', viewHistory: '查看历史', followUpRules: '追问规则', followUpRule1: '每次解卦可追问一次', followUpRule2: '追问消耗 10 积分', basicInfo: '基本信息', ganzhi: '干支信息', hexagramDetail: '卦象详情' },
},
zh_Hant: {
nav: { features: '功能', pricing: '定價', about: '關於', getStarted: '開始使用' },
@@ -50,7 +66,15 @@ const translations: Record<Locale, Translations> = {
footer: { brandName: '覓爻簽問', desc: '以古老智慧,解讀今時困惑。讓每一次簽問,都成為與自己對話的機會。', col1Title: '產品', col1Link1: '功能介紹', col1Link2: '定價', col2Title: '支持', col2Link1: '幫助中心', col2Link2: '聯繫我們', col3Title: '法律', col3Link1: '隱私政策', col3Link2: '服務條款' },
features: { title: '功能特性', subtitle: '以古老智慧解讀今時困惑,覓爻簽問提供完整的易學體驗', tagline: '從起卦到解讀,每一步都精心設計', c1Title: '兩種起卦方式', c1Desc: '手動起卦與自動起卦,靈活選擇最適合你的方式。推薦使用手動起卦,卦象解讀更準確。', c2Title: 'AI 解卦分析', c2Desc: '基於傳統六爻卦象與周易哲學體系,結合AI智能分析,提供深度卦象解讀與建議。', c3Title: '九類問題覆蓋', c3Desc: '事業、情感、財富、運勢、解夢、健康、學業、尋物等九大領域,全面覆蓋日常生活所問。', c4Title: '追問互動', c4Desc: '每次解卦後可深入追問一次,針對卦象細節獲取更多洞見,讓解讀更加全面深入。', c5Title: '歷史記錄', c5Desc: '自動保存所有解卦記錄,包括卦象詳情與AI解讀。隨時回顧歷史,追蹤問題變化趨勢。', c6Title: '點數系統', c6Desc: '提供靈活積分套餐:新人專享、入門補充、常用加量、高頻進階。按需購買,自由使用。' },
pricing: { title: '選擇適合你的套餐', subtitle: '靈活積分套餐,按需選擇,隨時可用', p1Name: '新人專享包', p1Badge: '限購一次', p1Price: '$0.99', p1Credits: '60 積分', p1Desc: '最適合初次體驗', p2Name: '入門補充包', p2Price: '$4.99', p2Credits: '100 積分', p2Desc: '日常解卦補充', p2Detail: '適量點數補充,經濟實惠之選', p3Name: '常用加量包', p3Badge: '推薦', p3Price: '$7.99', p3Credits: '210 積分', p3Desc: '最適合日常使用', p4Name: '高頻進階包', p4Price: '$12.99', p4Credits: '415 積分', p4Desc: '重度使用優選', p4Detail: '大量點數儲備,超值單價', buyNow: '立即購買' },
about: { title: '關於覓爻簽問', subtitle: '了解我們的理念與定位', storyTitle: '我們的故事', p1: '覓爻簽問是一個借助於 AI 解讀傳統六爻卦象的平台,為用戶了解中國傳統易學文化提供一個窗口。六爻卦象源於《周易》深邃的哲學體系,是古人探索世界運行規律的一種獨特方法。古人認為宇宙萬物相互關聯,在你起卦時,你的心念與時空信息會凝結成卦象的方式呈現出來。', p2: '覓爻簽問基於這樣的思路,幫助你跳出局限思維,從全局和演變趨勢看清矛盾、機會與風險,為判斷和行動提供參考。', p3: '用 AI 解鎖古老智慧,讓覓爻簽問成為你探索趨勢、明晰方向的現代助手。', companyInfo: '公司信息', company: '洵覓科技(深圳)有限公司', emailLabel: '聯繫郵箱', email: 'xuyunlong@xunmee.com', devLabel: '開發者', developer: 'Ann Lee', icpLabel: '備案號', icp: '粵ICP備2025428416號-1A', warningTitle: '特別提醒', warningBody: '卦象解讀結果均由 AI 生成,僅供娛樂參考,切不可作為商業、醫療等專業領域的決策依據。理性看待卦象,自由掌握人生。', legalTitle: '法律條款' },
about: { title: '關於覓爻簽問', subtitle: '了解我們的理念與定位', storyTitle: '我們的故事', p1: '覓爻簽問是一個借助於 AI 解讀傳統六爻卦象的平台,為用戶了解中國傳統易學文化提供一個窗口。六爻卦象源於《周易》深邃的哲學體系,是古人探索世界運行規律的一種獨特方法。古人認為宇宙萬物相互關聯,在你起卦時,你的心念與時空信息會凝結成卦象的方式呈現出來。', p2: '覓爻簽問基於這樣的思路,幫助你跳出局限思維,從全局和演變趨勢看清矛盾、機會與風險,為判斷和行動提供參考。', p3: '用 AI 解鎖古老智慧,讓覓爻簽問成為你探索趨勢、明晰方向的現代助手。', companyInfo: '公司信息', company: '洵覓科技(深圳)有限公司', emailLabel: '聯繫郵箱', email: 'xuyunlong@xunmee.com', devLabel: '開發者', developer: 'Ann Lee', warningTitle: '特別提醒', warningBody: '卦象解讀結果均由 AI 生成,僅供娛樂參考,切不可作為商業、醫療等專業領域的決策依據。理性看待卦象,自由掌握人生。', legalTitle: '法律條款' },
login: { welcome: '歡迎', subtitle: '使用郵箱驗證碼快速登錄或註冊', emailLabel: '郵箱地址', emailPlaceholder: '請輸入郵箱地址', codeLabel: '驗證碼', codePlaceholder: '請輸入驗證碼', sendCode: '獲取驗證碼', submit: '登錄 / 註冊', agreePrefix: '我已閱讀並同意', privacy: '《隱私政策》', agreeAnd: '和', terms: '《服務條款》' },
dashboard: { brandName: '覓爻簽問', navHome: '首頁', navStore: '商店', navDivination: '起卦', navManual: '手動起卦', navAuto: '自動起卦', navHistory: '歷史解卦', navLanguage: '語言', navSettings: '設置', greeting: '下午好', greetingSub: '今天想要探尋什麼方向?', heroTitle: '開始您的卦象之旅', heroDesc: '借助AI智能,探索未來的可能。心中有問,起卦便知。', heroCta: '立即起卦', historyTitle: '歷史解卦', historyViewAll: '查看全部 →', logout: '退出登錄' },
notifications: { title: '通知中心', welcomeTitle: '歡迎使用覓爻簽問', welcomeBody: '感謝您註冊覓爻簽問!您可以開始使用占卜服務進行卦象諮詢了。', hexagramTitle: '卦象已生成 — 今年轉崗是否合適?', hexagramBody: '您的卦象已完成,點擊查看詳細解讀。', creditsTitle: '積分到賬通知', creditsBody: '您購買的「常用加量包」210積分已到賬,可在個人中心查看。' },
store: { title: '積分商店', currentPoints: '當前積分', pointsLabel: '積分', rulesTitle: '積分規則', rule1: '1 次起卦會消耗固定積分', rule2: '充值完成後積分實時入賬', rule3: '新人專享包每個賬號限購一次', popularLabel: '推薦選擇', popularText: '常用加量包性價比最高,適合大多數用戶日常使用。', stepsTitle: '支付流程', step1: '選擇套餐並確認', step2: '完成支付', step3: '積分自動到賬', autoCredit: '購買後自動到賬', sideTitle: '購買後自動到賬', sideDesc: '選擇套餐並完成支付後,積分會同步到當前賬號。' },
settings: { title: '設置', profileTitle: '個人資料', emailLabel: '郵箱', nameLabel: '暱稱', joinedLabel: '註冊時間', pointsTitle: '積分餘額', pointsBalance: '積分', accountTitle: '賬號設置', changeName: '修改暱稱', changeAvatar: '修改頭像', changeLanguage: '切換語言', legalTitle: '法律條款', privacy: '隱私政策', terms: '服務條款', logout: '退出登錄', logoutConfirm: '確定要退出登錄嗎?' },
profile: { avatarTitle: '頭像', avatarHint: '支持 PNG / JPG / WEBP,建議上傳清晰正方形頭像。', uploadBtn: '上傳頭像', formTitle: '基礎資料', emailLabel: '郵箱', displayNameLabel: '暱稱', displayNamePlaceholder: '請輸入暱稱', bioLabel: '個人簡介', bioPlaceholder: '請輸入個人簡介', saveBtn: '保存', cancelBtn: '取消' },
divination: { questionTitle: '提出你的問題', questionPlaceholder: '請輸入你想問的問題...', categoryLabel: '問題類型', categories: '事業,感情,財富,運勢,解夢,健康,學業,尋物,其他', timeTitle: '起卦時間', timeHint: '默認使用當前時間,也可手動選擇', guideTitle: '起卦指引', guideManual: '手動起卦需要您親自拋擲三枚銅錢六次,系統會根據結果生成卦象。請按照以下步驟操作:\n\n1. 心中默念問題\n2. 點擊下方銅錢選擇正反面\n3. 每爻拋擲三枚銅錢\n4. 重複六次完成起卦', guideAuto: '自動起卦由系統隨機生成卦象,適合快速獲取結果。請按照以下步驟操作:\n\n1. 心中默念問題\n2. 點擊"搖卦"按鈕\n3. 系統自動生成卦象', yaoTitle: '六爻銅錢', coinLabel: '點擊銅錢選擇正反面', confirmBtn: '確認此爻', summaryTitle: '提交前檢查', checkCategory: '問題類型:事業', checkMethod: '起卦方式:手動起卦', checkCost: '解卦消耗:20 積分', submitBtn: '確認提交', shakeTitle: '搖卦', shakeBtn: '搖一搖', hexPreview: '卦象預覽', progressLabel: '完成進度' },
history: { title: '歷史解卦', statTotal: '總卦數', statFollow: '可追問', statLatest: '最近一卦', filters: '快速篩選', filterAll: '全部', filterCareer: '事業', filterLove: '感情', filterWealth: '財富', noResults: '暫無解卦記錄', resultTitle: '卦象解讀', conclusion: '結論', suggestion: '建議', analysis: '詳細分析', focus: '重點關注', warning: '以上解讀由 AI 生成,僅供參考娛樂,不作為決策依據。', followUpTitle: '追問', followUpDesc: '對卦象有疑問?可以追問一次獲取更深入的解讀。', followUpBtn: '開始追問', chatTitle: '追問對話', chatPlaceholder: '輸入你的追問...', sendBtn: '發送', relatedActions: '相關操作', newDivination: '重新起卦', viewHistory: '查看歷史', followUpRules: '追問規則', followUpRule1: '每次解卦可追問一次', followUpRule2: '追問消耗 10 積分', basicInfo: '基本信息', ganzhi: '干支信息', hexagramDetail: '卦象詳情' },
},
en: {
nav: { features: 'Features', pricing: 'Pricing', about: 'About', getStarted: 'Get Started' },
@@ -61,7 +85,15 @@ const translations: Record<Locale, Translations> = {
footer: { brandName: 'MeiYao Divination', desc: 'Using ancient wisdom to interpret modern confusion. Let every divination become a chance to dialogue with yourself.', col1Title: 'Product', col1Link1: 'Features', col1Link2: 'Pricing', col2Title: 'Support', col2Link1: 'Help Center', col2Link2: 'Contact Us', col3Title: 'Legal', col3Link1: 'Privacy Policy', col3Link2: 'Terms of Service' },
features: { title: 'Features', subtitle: 'Ancient wisdom meets modern困惑, MeiYao provides a complete I Ching experience', tagline: 'From casting to interpretation, every step is carefully designed', c1Title: 'Two Casting Methods', c1Desc: 'Manual and auto casting — choose what suits you best. Manual casting is recommended for more accurate readings.', c2Title: 'AI Analysis', c2Desc: 'Combining traditional Six-Line hexagrams with AI intelligence for in-depth interpretation and suggestions.', c3Title: '9 Question Categories', c3Desc: 'Career, love, wealth, fortune, dreams, health, study, lost items, and more — covering all aspects of daily life.', c4Title: 'Follow-up Questions', c4Desc: 'Ask one follow-up question after each reading for deeper insights into specific hexagram details.', c5Title: 'History', c5Desc: 'All readings are automatically saved with full hexagram details and AI interpretations. Review anytime.', c6Title: 'Credits System', c6Desc: 'Flexible credit packages: starter, basic, popular, and premium. Purchase as needed, use freely.' },
pricing: { title: 'Choose Your Plan', subtitle: 'Flexible credit packages, pay as you go', p1Name: 'Starter Pack', p1Badge: 'Once Only', p1Price: '$0.99', p1Credits: '60 credits', p1Desc: 'Best for first-timers', p2Name: 'Basic Pack', p2Price: '$4.99', p2Credits: '100 credits', p2Desc: 'Daily supplement', p2Detail: 'Affordable credit refill', p3Name: 'Popular Pack', p3Badge: 'Popular', p3Price: '$7.99', p3Credits: '210 credits', p3Desc: 'Best for daily use', p4Name: 'Premium Pack', p4Price: '$12.99', p4Credits: '415 credits', p4Desc: 'Best value per credit', p4Detail: 'Bulk credits at best unit price', buyNow: 'Buy Now' },
about: { title: 'About MeiYao Divination', subtitle: 'Our vision and philosophy', storyTitle: 'Our Story', p1: 'MeiYao Divination is an AI-assisted platform for interpreting traditional Six-Line divination and opening a window into classical Chinese wisdom. Six-Line divination originates from the deep philosophical system of the I Ching.', p2: 'MeiYao Divination helps you step outside narrow thinking, understand contradictions, opportunities, and risks from a broader trend perspective, and make calmer, more thoughtful decisions.', p3: 'Unlock ancient wisdom with AI. Let MeiYao Divination be your modern companion for exploring trends and finding direction.', companyInfo: 'Company', company: 'Xunmee Technology (Shenzhen) Co., Ltd.', emailLabel: 'Email', email: 'xuyunlong@xunmee.com', devLabel: 'Developer', developer: 'Ann Lee', icpLabel: 'ICP License', icp: 'Yue ICP 2025428416-1A', warningTitle: 'Important Notice', warningBody: 'All divination interpretations are AI-generated for entertainment only. Do not use them as professional advice for business, medical, or legal decisions.', legalTitle: 'Legal' },
about: { title: 'About MeiYao Divination', subtitle: 'Our vision and philosophy', storyTitle: 'Our Story', p1: 'MeiYao Divination is an AI-assisted platform for interpreting traditional Six-Line divination and opening a window into classical Chinese wisdom. Six-Line divination originates from the deep philosophical system of the I Ching.', p2: 'MeiYao Divination helps you step outside narrow thinking, understand contradictions, opportunities, and risks from a broader trend perspective, and make calmer, more thoughtful decisions.', p3: 'Unlock ancient wisdom with AI. Let MeiYao Divination be your modern companion for exploring trends and finding direction.', companyInfo: 'Company', company: 'Xunmee Technology (Shenzhen) Co., Ltd.', emailLabel: 'Email', email: 'xuyunlong@xunmee.com', devLabel: 'Developer', developer: 'Ann Lee', warningTitle: 'Important Notice', warningBody: 'All divination interpretations are AI-generated for entertainment only. Do not use them as professional advice for business, medical, or legal decisions.', legalTitle: 'Legal' },
login: { welcome: 'Welcome', subtitle: 'Sign in or sign up with email verification code', emailLabel: 'Email', emailPlaceholder: 'Enter your email', codeLabel: 'Verification Code', codePlaceholder: 'Enter code', sendCode: 'Send Code', submit: 'Sign In / Sign Up', agreePrefix: 'I have read and agree to the ', privacy: 'Privacy Policy', agreeAnd: ' and ', terms: 'Terms of Service' },
dashboard: { brandName: 'MeiYao Divination', navHome: 'Home', navStore: 'Store', navDivination: 'Divination', navManual: 'Manual Cast', navAuto: 'Auto Cast', navHistory: 'History', navLanguage: 'Language', navSettings: 'Settings', greeting: 'Good afternoon', greetingSub: 'What would you like to explore today?', heroTitle: 'Begin Your Hexagram Journey', heroDesc: 'Explore future possibilities with AI. Ask your question, cast your hexagram.', heroCta: 'Start Divination', historyTitle: 'Recent Readings', historyViewAll: 'View All →', logout: 'Sign Out' },
notifications: { title: 'Notifications', welcomeTitle: 'Welcome to MeiYao Divination', welcomeBody: 'Thank you for registering! You can now start using our divination services.', hexagramTitle: 'Hexagram Ready — Is a career change right?', hexagramBody: 'Your hexagram is complete. Click to view the detailed interpretation.', creditsTitle: 'Credits Received', creditsBody: 'Your Popular Pack of 210 credits has arrived. Check your profile.' },
store: { title: 'Credits Store', currentPoints: 'Current Credits', pointsLabel: 'credits', rulesTitle: 'Credits Rules', rule1: '1 divination costs a fixed number of credits', rule2: 'Credits are added instantly after purchase', rule3: 'Starter Pack is limited to one per account', popularLabel: 'Recommended', popularText: 'Popular Pack offers the best value for most users.', stepsTitle: 'Payment Steps', step1: 'Select a package', step2: 'Complete payment', step3: 'Credits added automatically', autoCredit: 'Auto-delivery after purchase', sideTitle: 'Auto-delivery after purchase', sideDesc: 'Credits are synced to your account immediately after payment.' },
settings: { title: 'Settings', profileTitle: 'Profile', emailLabel: 'Email', nameLabel: 'Name', joinedLabel: 'Joined', pointsTitle: 'Credits Balance', pointsBalance: 'credits', accountTitle: 'Account Settings', changeName: 'Change Name', changeAvatar: 'Change Avatar', changeLanguage: 'Change Language', legalTitle: 'Legal', privacy: 'Privacy Policy', terms: 'Terms of Service', logout: 'Sign Out', logoutConfirm: 'Are you sure you want to sign out?' },
profile: { avatarTitle: 'Avatar', avatarHint: 'Supports PNG / JPG / WEBP. Square images recommended.', uploadBtn: 'Upload Avatar', formTitle: 'Basic Info', emailLabel: 'Email', displayNameLabel: 'Display Name', displayNamePlaceholder: 'Enter display name', bioLabel: 'Bio', bioPlaceholder: 'Enter your bio', saveBtn: 'Save', cancelBtn: 'Cancel' },
divination: { questionTitle: 'Ask Your Question', questionPlaceholder: 'Enter your question...', categoryLabel: 'Category', categories: 'Career,Love,Wealth,Fortune,Dreams,Health,Study,Lost Items,Other', timeTitle: 'Casting Time', timeHint: 'Uses current time by default, or pick manually', guideTitle: 'Guide', guideManual: 'Manual casting requires you to toss three coins six times. Follow these steps:\n\n1. Focus on your question\n2. Click coins below to set heads/tails\n3. Toss three coins per line\n4. Repeat six times to complete', guideAuto: 'Auto casting generates a hexagram randomly. Follow these steps:\n\n1. Focus on your question\n2. Click "Shake" button\n3. System generates the hexagram', yaoTitle: 'Six Lines', coinLabel: 'Click coins to set heads/tails', confirmBtn: 'Confirm Line', summaryTitle: 'Review Before Submit', checkCategory: 'Category: Career', checkMethod: 'Method: Manual Cast', checkCost: 'Cost: 20 credits', submitBtn: 'Confirm & Submit', shakeTitle: 'Shake', shakeBtn: 'Shake', hexPreview: 'Hexagram Preview', progressLabel: 'Progress' },
history: { title: 'Reading History', statTotal: 'Total', statFollow: 'Follow-up', statLatest: 'Latest', filters: 'Quick Filters', filterAll: 'All', filterCareer: 'Career', filterLove: 'Love', filterWealth: 'Wealth', noResults: 'No readings yet', resultTitle: 'Reading Result', conclusion: 'Conclusion', suggestion: 'Suggestion', analysis: 'Detailed Analysis', focus: 'Key Focus', warning: 'AI-generated interpretation for entertainment only. Not professional advice.', followUpTitle: 'Follow-up', followUpDesc: 'Have questions about the reading? Ask one follow-up for deeper insights.', followUpBtn: 'Start Follow-up', chatTitle: 'Follow-up Chat', chatPlaceholder: 'Enter your follow-up question...', sendBtn: 'Send', relatedActions: 'Related Actions', newDivination: 'New Reading', viewHistory: 'View History', followUpRules: 'Follow-up Rules', followUpRule1: 'One follow-up per reading', followUpRule2: 'Follow-up costs 10 credits', basicInfo: 'Basic Info', ganzhi: 'Stem-Branch Info', hexagramDetail: 'Hexagram Details' },
},
};
+28
View File
@@ -0,0 +1,28 @@
---
import '../styles/global.css';
import '../styles/animations.css';
import { AuthProvider } from '../components/AuthProvider';
interface Props {
locale: import('../i18n/utils').Locale;
}
const { locale } = Astro.props;
---
<!doctype html>
<html lang={locale}>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Material+Symbols+Rounded:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200" />
</head>
<body class="bg-slate-50 text-slate-900 antialiased">
<main>
<AuthProvider client:load>
<slot />
</AuthProvider>
</main>
</body>
</html>
+24
View File
@@ -0,0 +1,24 @@
---
import '../styles/global.css';
import '../styles/animations.css';
interface Props {
locale: import('../i18n/utils').Locale;
}
const { locale } = Astro.props;
---
<!doctype html>
<html lang={locale}>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
</head>
<body class="bg-white text-slate-900 antialiased">
<main>
<slot />
</main>
</body>
</html>
+1
View File
@@ -17,6 +17,7 @@ const { locale } = Astro.props;
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Material+Symbols+Rounded:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200" />
</head>
<body class="bg-white text-slate-900 antialiased">
<Navbar locale={locale} />
+51
View File
@@ -0,0 +1,51 @@
const apiBase = (): string => import.meta.env.PUBLIC_API_URL || '';
export function apiUrl(path: string): string {
return path.startsWith('http') ? path : `${apiBase()}${path}`;
}
export class ApiError extends Error {
status: number;
code?: string;
detail?: string;
constructor(status: number, title: string, code?: string, detail?: string) {
super(title);
this.name = 'ApiError';
this.status = status;
this.code = code;
this.detail = detail;
}
}
export async function toApiError(res: Response): Promise<ApiError> {
try {
const body = await res.json();
return new ApiError(
res.status,
body.title || body.detail || `Request failed (${res.status})`,
body.code,
body.detail,
);
} catch {
return new ApiError(res.status, `Request failed (${res.status})`);
}
}
export function jsonHeaders(options?: RequestInit): Headers {
const headers = new Headers(options?.headers);
if (!headers.has('Content-Type') && !(options?.body instanceof FormData)) {
headers.set('Content-Type', 'application/json');
}
return headers;
}
export async function apiRequest<T>(path: string, options?: RequestInit): Promise<T> {
const res = await fetch(apiUrl(path), {
...options,
headers: jsonHeaders(options),
});
if (!res.ok) throw await toApiError(res);
if (res.status === 204) return undefined as T;
return res.json() as Promise<T>;
}
+20
View File
@@ -0,0 +1,20 @@
export const API_ROUTES = {
auth: {
sendOtp: '/api/v1/auth/otp/send',
emailSession: '/api/v1/auth/email-session',
refreshSession: '/api/v1/auth/sessions/refresh',
deleteSession: '/api/v1/auth/sessions',
},
users: {
profile: '/api/v1/users/me/profile',
},
points: {
balance: '/api/v1/points/balance',
},
notifications: {
unreadCount: '/api/v1/notifications/unread-count',
},
agent: {
history: '/api/v1/agent/history',
},
} as const;
+120
View File
@@ -0,0 +1,120 @@
/**
* Typed API client for backend endpoints.
* Wraps authFetch for authenticated requests.
*/
import { authFetch } from './auth';
import { API_ROUTES } from './api-routes';
// --- User Profile ---
export interface UserProfile {
user_id: string;
display_name: string;
bio: string;
avatar_path: string | null;
avatar_url: string | null;
settings: {
preferences: {
language: string;
timezone: string;
};
};
updated_at: string;
}
export function getUserProfile(): Promise<UserProfile> {
return authFetch<UserProfile>(API_ROUTES.users.profile);
}
// --- Points ---
export interface PointsBalance {
balance: number;
frozenBalance: number;
availableBalance: number;
runCost: number;
canRun: boolean;
}
export function getPointsBalance(): Promise<PointsBalance> {
return authFetch<PointsBalance>(API_ROUTES.points.balance);
}
// --- Notifications ---
export interface UnreadCount {
count: number;
}
export function getUnreadNotificationCount(): Promise<UnreadCount> {
return authFetch<UnreadCount>(API_ROUTES.notifications.unreadCount);
}
// --- Agent History ---
export interface HistoryAgentOutput {
status?: string | null;
sign_level?: string | null;
conclusion?: string[];
focus_points?: string[];
advice?: string[];
keywords?: string[];
answer?: string | null;
divination_derived?: {
guaName?: string;
gua_name?: string;
binaryCode?: string;
changedBinaryCode?: string;
} | null;
}
export interface HistoryMessage {
id: string;
threadId: string;
seq: number;
role: 'user' | 'assistant';
content: string;
timestamp: string;
agent_output?: HistoryAgentOutput | null;
}
export interface HistoryItem {
id: string;
threadId: string;
question: string;
category: string;
hexagram_name: string;
rating: string;
created_at: string;
can_follow_up: boolean;
}
export interface HistorySnapshot {
scope: string;
threadId: string | null;
day: string | null;
hasMore: boolean;
messages: HistoryMessage[];
}
export async function getAgentHistory(): Promise<HistorySnapshot> {
return authFetch<HistorySnapshot>(API_ROUTES.agent.history);
}
export function mapHistoryMessagesToItems(messages: HistoryMessage[]): HistoryItem[] {
return messages.map((message) => {
const output = message.agent_output;
const derived = output?.divination_derived;
return {
id: message.id,
threadId: message.threadId,
question: output?.answer || message.content,
category: output?.keywords?.[0] || '',
hexagram_name: derived?.guaName || derived?.gua_name || '',
rating: output?.sign_level || '',
created_at: message.timestamp,
can_follow_up: output?.status === 'success',
};
});
}
+211
View File
@@ -0,0 +1,211 @@
/**
* Auth storage + API calls + authFetch wrapper.
* Mirrors Flutter's SessionStore + AuthApi + AuthRepositoryImpl.
*/
import { apiRequest, apiUrl, jsonHeaders, toApiError, ApiError } from './api-client';
import { API_ROUTES } from './api-routes';
const STORAGE_KEY = 'meeyao_auth';
export { ApiError };
export interface AuthUser {
id: string;
email: string;
}
export interface AuthData {
access_token: string;
refresh_token: string;
expires_at: number; // Unix ms
user: AuthUser;
}
interface SessionResponse {
access_token: string;
refresh_token: string;
expires_in: number;
token_type: string;
user: { id: string; email: string };
}
// --- Storage ---
export function getAuth(): AuthData | null {
if (typeof window === 'undefined') return null;
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return null;
try {
return JSON.parse(raw) as AuthData;
} catch {
clearAuth();
return null;
}
}
export function setAuth(data: AuthData): void {
localStorage.setItem(STORAGE_KEY, JSON.stringify(data));
}
export function clearAuth(): void {
localStorage.removeItem(STORAGE_KEY);
}
// --- Token status ---
export function isTokenExpired(): boolean {
const auth = getAuth();
if (!auth) return true;
// Refresh 60 seconds before actual expiry
return auth.expires_at - 60_000 < Date.now();
}
// --- Helpers ---
function toAuthData(response: SessionResponse): AuthData {
return {
access_token: response.access_token,
refresh_token: response.refresh_token,
expires_at: Date.now() + response.expires_in * 1000,
user: { id: response.user.id, email: response.user.email },
};
}
function getLocaleFromPath(): string {
if (typeof window === 'undefined') return 'zh';
const match = window.location.pathname.match(/^\/(zh|zh_Hant|en)(?:\/|$)/);
return match ? match[1] : 'zh';
}
export function loginPath(): string {
const locale = getLocaleFromPath();
return `/${locale}/login`;
}
export function redirectToLogin(): void {
if (typeof window === 'undefined') return;
window.location.replace(loginPath());
}
// --- API calls ---
export async function sendOtp(email: string): Promise<void> {
await apiRequest<void>(API_ROUTES.auth.sendOtp, {
method: 'POST',
body: JSON.stringify({ email }),
});
}
export async function loginWithEmail(
email: string,
token: string,
language?: string,
timezone?: string,
): Promise<AuthData> {
const body: Record<string, string> = { email, token };
if (language) body.language = language;
if (timezone) body.timezone = timezone;
const json = await apiRequest<SessionResponse>(API_ROUTES.auth.emailSession, {
method: 'POST',
body: JSON.stringify(body),
});
const data = toAuthData(json);
setAuth(data);
return data;
}
export async function refreshAccessToken(): Promise<AuthData> {
const auth = getAuth();
if (!auth?.refresh_token) {
clearAuth();
throw new Error('No refresh token');
}
const res = await fetch(apiUrl(API_ROUTES.auth.refreshSession), {
method: 'POST',
headers: jsonHeaders(),
body: JSON.stringify({ refresh_token: auth.refresh_token }),
});
if (!res.ok) {
clearAuth();
throw await toApiError(res);
}
const json: SessionResponse = await res.json();
const data = toAuthData(json);
setAuth(data);
return data;
}
export async function logout(): Promise<void> {
const auth = getAuth();
try {
if (auth?.refresh_token) {
await fetch(apiUrl(API_ROUTES.auth.deleteSession), {
method: 'DELETE',
headers: jsonHeaders(),
body: JSON.stringify({ refresh_token: auth.refresh_token }),
});
}
} finally {
clearAuth();
}
}
// --- authFetch ---
export async function authFetch<T>(path: string, options?: RequestInit): Promise<T> {
// 1. Ensure token is fresh
if (isTokenExpired()) {
try {
await refreshAccessToken();
} catch {
// refresh failed, redirect to login
clearAuth();
redirectToLogin();
throw new Error('Session expired');
}
}
const auth = getAuth();
if (!auth) {
redirectToLogin();
throw new Error('Not authenticated');
}
const headers = jsonHeaders(options);
headers.set('Authorization', `Bearer ${auth.access_token}`);
// 2. Make request
const url = apiUrl(path);
let res = await fetch(url, { ...options, headers });
// 3. On 401, refresh once and retry
if (res.status === 401) {
try {
await refreshAccessToken();
} catch {
clearAuth();
redirectToLogin();
throw new Error('Session expired');
}
const refreshed = getAuth();
if (!refreshed) {
redirectToLogin();
throw new Error('Not authenticated');
}
const retryHeaders = jsonHeaders(options);
retryHeaders.set('Authorization', `Bearer ${refreshed.access_token}`);
res = await fetch(url, { ...options, headers: retryHeaders });
}
if (res.status === 401) {
clearAuth();
redirectToLogin();
throw new Error('Not authenticated');
}
if (!res.ok) throw await toApiError(res);
if (res.status === 204) return undefined as T;
return res.json() as Promise<T>;
}
+7
View File
@@ -0,0 +1,7 @@
---
import DashboardAppPage from '../../components/DashboardAppPage.astro';
const locale = 'en' as const;
---
<DashboardAppPage locale={locale} />
+7
View File
@@ -0,0 +1,7 @@
---
import DashboardAppPage from '../../../components/DashboardAppPage.astro';
const locale = 'en' as const;
---
<DashboardAppPage locale={locale} />
+7
View File
@@ -0,0 +1,7 @@
---
import DashboardAppPage from '../../../components/DashboardAppPage.astro';
const locale = 'en' as const;
---
<DashboardAppPage locale={locale} />
+7
View File
@@ -0,0 +1,7 @@
---
import DashboardAppPage from '../../../components/DashboardAppPage.astro';
const locale = 'en' as const;
---
<DashboardAppPage locale={locale} />
+7
View File
@@ -0,0 +1,7 @@
---
import DashboardAppPage from '../../../components/DashboardAppPage.astro';
const locale = 'en' as const;
---
<DashboardAppPage locale={locale} />
+7
View File
@@ -0,0 +1,7 @@
---
import DashboardAppPage from '../../../components/DashboardAppPage.astro';
const locale = 'en' as const;
---
<DashboardAppPage locale={locale} />
+18
View File
@@ -0,0 +1,18 @@
---
import AuthLayout from '../../layouts/Auth.astro';
import LoginForm from '../../components/LoginForm';
import { t, localePath } from '../../i18n/utils';
const locale = 'en' as const;
const login = t(locale, 'login');
---
<AuthLayout locale={locale}>
<LoginForm
client:load
locale={locale}
translations={login}
privacyUrl={localePath(locale, '/privacy')}
termsUrl={localePath(locale, '/terms')}
/>
</AuthLayout>
@@ -0,0 +1,7 @@
---
import DashboardAppPage from '../../../components/DashboardAppPage.astro';
const locale = 'en' as const;
---
<DashboardAppPage locale={locale} />
+7
View File
@@ -0,0 +1,7 @@
---
import DashboardAppPage from '../../../components/DashboardAppPage.astro';
const locale = 'en' as const;
---
<DashboardAppPage locale={locale} />
+7
View File
@@ -0,0 +1,7 @@
---
import DashboardAppPage from '../../../components/DashboardAppPage.astro';
const locale = 'en' as const;
---
<DashboardAppPage locale={locale} />
+7
View File
@@ -0,0 +1,7 @@
---
import DashboardAppPage from '../../../components/DashboardAppPage.astro';
const locale = 'en' as const;
---
<DashboardAppPage locale={locale} />
+7
View File
@@ -0,0 +1,7 @@
---
import DashboardAppPage from '../../components/DashboardAppPage.astro';
const locale = 'zh' as const;
---
<DashboardAppPage locale={locale} />
+7
View File
@@ -0,0 +1,7 @@
---
import DashboardAppPage from '../../../components/DashboardAppPage.astro';
const locale = 'zh' as const;
---
<DashboardAppPage locale={locale} />
+7
View File
@@ -0,0 +1,7 @@
---
import DashboardAppPage from '../../../components/DashboardAppPage.astro';
const locale = 'zh' as const;
---
<DashboardAppPage locale={locale} />
+7
View File
@@ -0,0 +1,7 @@
---
import DashboardAppPage from '../../../components/DashboardAppPage.astro';
const locale = 'zh' as const;
---
<DashboardAppPage locale={locale} />
+7
View File
@@ -0,0 +1,7 @@
---
import DashboardAppPage from '../../../components/DashboardAppPage.astro';
const locale = 'zh' as const;
---
<DashboardAppPage locale={locale} />
+7
View File
@@ -0,0 +1,7 @@
---
import DashboardAppPage from '../../../components/DashboardAppPage.astro';
const locale = 'zh' as const;
---
<DashboardAppPage locale={locale} />
+18
View File
@@ -0,0 +1,18 @@
---
import AuthLayout from '../../layouts/Auth.astro';
import LoginForm from '../../components/LoginForm';
import { t, localePath } from '../../i18n/utils';
const locale = 'zh' as const;
const login = t(locale, 'login');
---
<AuthLayout locale={locale}>
<LoginForm
client:load
locale={locale}
translations={login}
privacyUrl={localePath(locale, '/privacy')}
termsUrl={localePath(locale, '/terms')}
/>
</AuthLayout>
@@ -0,0 +1,7 @@
---
import DashboardAppPage from '../../../components/DashboardAppPage.astro';
const locale = 'zh' as const;
---
<DashboardAppPage locale={locale} />
+7
View File
@@ -0,0 +1,7 @@
---
import DashboardAppPage from '../../../components/DashboardAppPage.astro';
const locale = 'zh' as const;
---
<DashboardAppPage locale={locale} />
+7
View File
@@ -0,0 +1,7 @@
---
import DashboardAppPage from '../../../components/DashboardAppPage.astro';
const locale = 'zh' as const;
---
<DashboardAppPage locale={locale} />
+7
View File
@@ -0,0 +1,7 @@
---
import DashboardAppPage from '../../../components/DashboardAppPage.astro';
const locale = 'zh' as const;
---
<DashboardAppPage locale={locale} />
+7
View File
@@ -0,0 +1,7 @@
---
import DashboardAppPage from '../../components/DashboardAppPage.astro';
const locale = 'zh_Hant' as const;
---
<DashboardAppPage locale={locale} />
@@ -0,0 +1,7 @@
---
import DashboardAppPage from '../../../components/DashboardAppPage.astro';
const locale = 'zh_Hant' as const;
---
<DashboardAppPage locale={locale} />
@@ -0,0 +1,7 @@
---
import DashboardAppPage from '../../../components/DashboardAppPage.astro';
const locale = 'zh_Hant' as const;
---
<DashboardAppPage locale={locale} />
@@ -0,0 +1,7 @@
---
import DashboardAppPage from '../../../components/DashboardAppPage.astro';
const locale = 'zh_Hant' as const;
---
<DashboardAppPage locale={locale} />
@@ -0,0 +1,7 @@
---
import DashboardAppPage from '../../../components/DashboardAppPage.astro';
const locale = 'zh_Hant' as const;
---
<DashboardAppPage locale={locale} />
@@ -0,0 +1,7 @@
---
import DashboardAppPage from '../../../components/DashboardAppPage.astro';
const locale = 'zh_Hant' as const;
---
<DashboardAppPage locale={locale} />
+18
View File
@@ -0,0 +1,18 @@
---
import AuthLayout from '../../layouts/Auth.astro';
import LoginForm from '../../components/LoginForm';
import { t, localePath } from '../../i18n/utils';
const locale = 'zh_Hant' as const;
const login = t(locale, 'login');
---
<AuthLayout locale={locale}>
<LoginForm
client:load
locale={locale}
translations={login}
privacyUrl={localePath(locale, '/privacy')}
termsUrl={localePath(locale, '/terms')}
/>
</AuthLayout>
@@ -0,0 +1,7 @@
---
import DashboardAppPage from '../../../components/DashboardAppPage.astro';
const locale = 'zh_Hant' as const;
---
<DashboardAppPage locale={locale} />
@@ -0,0 +1,7 @@
---
import DashboardAppPage from '../../../components/DashboardAppPage.astro';
const locale = 'zh_Hant' as const;
---
<DashboardAppPage locale={locale} />
@@ -0,0 +1,7 @@
---
import DashboardAppPage from '../../../components/DashboardAppPage.astro';
const locale = 'zh_Hant' as const;
---
<DashboardAppPage locale={locale} />
+7
View File
@@ -0,0 +1,7 @@
---
import DashboardAppPage from '../../../components/DashboardAppPage.astro';
const locale = 'zh_Hant' as const;
---
<DashboardAppPage locale={locale} />
+8
View File
@@ -63,6 +63,14 @@
.reveal.visible.stagger-5 { animation-delay: 0.5s; }
.reveal.visible.stagger-6 { animation-delay: 0.6s; }
@media (max-width: 767px) {
.reveal, .reveal-left, .reveal-right, .reveal-scale {
opacity: 1;
transform: none;
animation: none;
}
}
/* Cyber gradient */
.cyber-gradient {
background: linear-gradient(135deg, #8B5CF6 0%, #A78BFA 50%, #C084FC 100%);
+16 -1
View File
@@ -1 +1,16 @@
@import "tailwindcss";
@import "tailwindcss";
.material-symbols-rounded {
font-family: 'Material Symbols Rounded';
font-weight: normal;
font-style: normal;
font-size: 24px;
line-height: 1;
letter-spacing: normal;
text-transform: none;
display: inline-block;
white-space: nowrap;
word-wrap: normal;
direction: ltr;
-webkit-font-smoothing: antialiased;
}