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
+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` },
];
}