feat(web): add authenticated app shell
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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` },
|
||||
];
|
||||
}
|
||||
Reference in New Issue
Block a user