Files
eryao/web/src/components/ManualDivinationPage.tsx
T

354 lines
17 KiB
TypeScript
Raw Normal View History

import { useEffect, useMemo, useState } from 'react';
2026-05-09 16:00:29 +08:00
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 = 'zi' | 'hua';
type YaoType = 'youngYang' | 'youngYin' | 'oldYang' | 'oldYin';
2026-05-09 16:00:29 +08:00
const TOTAL_YAO_COUNT = 6;
function formatDateTimeInput(value: Date): string {
const pad = (n: number) => String(n).padStart(2, '0');
return `${value.getFullYear()}-${pad(value.getMonth() + 1)}-${pad(value.getDate())}T${pad(value.getHours())}:${pad(value.getMinutes())}`;
}
function fromHuaCount(huaCount: number): YaoType {
switch (huaCount) {
case 0:
return 'oldYin';
case 1:
return 'youngYang';
case 2:
return 'youngYin';
case 3:
return 'oldYang';
default:
throw new RangeError('huaCount must be 0..3');
}
}
function fromCoins(coins: CoinFace[]): YaoType {
return fromHuaCount(coins.filter((coin) => coin === 'hua').length);
}
function CoinImage({ face, selected }: { face: CoinFace; selected?: boolean }) {
2026-05-09 16:00:29 +08:00
return (
<img
src={face === 'zi' ? '/images/qigua/zi.jpg' : '/images/qigua/hua.jpg'}
alt={face === 'zi' ? '字' : '花'}
className={`h-20 w-20 rounded-full object-cover shadow-sm transition-transform ${selected ? 'ring-2 ring-violet-600 ring-offset-2 ring-offset-slate-50' : ''}`}
draggable={false}
2026-05-09 16:00:29 +08:00
/>
);
}
function YaoGlyph({ type, active }: { type?: YaoType; active?: boolean }) {
const color = active || type ? 'bg-violet-700' : 'bg-slate-200';
if (!type || type === 'youngYang' || type === 'oldYang') {
return <div className={`h-2.5 w-full rounded-full ${color}`} />;
}
return (
<div className="flex h-2.5 w-full gap-4">
<div className={`h-2.5 flex-1 rounded-full ${color}`} />
<div className={`h-2.5 flex-1 rounded-full ${color}`} />
</div>
);
}
const copy = {
zh: {
title: '手动起卦',
subtitle: '准备三枚相同的钱币,从初爻到上爻依次录入六次结果。',
balance: '可用 120 积分 · 本次 20 积分',
defaultQuestion: '我接下来三个月的事业发展需要注意什么?',
modify: '修改',
guideLines: ['从初爻开始,按从下往上的顺序记录。', '每一爻由三枚钱币的字面/花面组合决定。', '六爻完成后才可开始解卦。'],
openGuide: '查看手动起卦教程',
guideSteps: [
['手动起卦', '准备三枚相同的钱币。每次记录一爻,按从下往上的顺序共记录六爻。'],
['确认时间', '先确认起卦时间。如需调整,点击右侧「修改」。'],
['依次录入六爻', '从初爻开始逐条选择,未完成前下一爻不可点。每条会弹出三枚钱币选择面板。'],
['开始分析', '六爻都填完后,「开始解卦」按钮会高亮提示,点击即可解卦。'],
],
closeGuide: '结束教程',
nextGuide: '下一步',
prevGuide: '上一步',
lineNames: ['初爻', '二爻', '三爻', '四爻', '五爻', '上爻'],
pending: '待录入',
zi: '字',
hua: '花',
yaoTypeNames: { youngYang: '少阳', youngYin: '少阴', oldYang: '老阳', oldYin: '老阴' },
questionTypePrefix: '问题类型',
method: '起卦方式:手动起卦',
submit: '开始解卦',
},
zh_Hant: {
title: '手動起卦',
subtitle: '準備三枚相同的錢幣,從初爻到上爻依序錄入六次結果。',
balance: '可用 120 積分 · 本次 20 積分',
defaultQuestion: '我接下來三個月的事業發展需要注意什麼?',
modify: '修改',
guideLines: ['從初爻開始,按從下往上的順序記錄。', '每一爻由三枚錢幣的字面/花面組合決定。', '六爻完成後才可開始解卦。'],
openGuide: '查看手動起卦教程',
guideSteps: [
['手動起卦', '準備三枚相同的錢幣。每次記錄一爻,按從下往上的順序共記錄六爻。'],
['確認時間', '先確認起卦時間。如需調整,點擊右側「修改」。'],
['依序錄入六爻', '從初爻開始逐條選擇,未完成前下一爻不可點擊。每條會彈出三枚錢幣選擇面板。'],
['開始分析', '六爻都填完後,「開始解卦」按鈕會高亮提示,點擊即可解卦。'],
],
closeGuide: '結束教程',
nextGuide: '下一步',
prevGuide: '上一步',
lineNames: ['初爻', '二爻', '三爻', '四爻', '五爻', '上爻'],
pending: '待錄入',
zi: '字',
hua: '花',
yaoTypeNames: { youngYang: '少陽', youngYin: '少陰', oldYang: '老陽', oldYin: '老陰' },
questionTypePrefix: '問題類型',
method: '起卦方式:手動起卦',
submit: '開始解卦',
},
en: {
title: 'Manual Casting',
subtitle: 'Prepare three identical coins and record six results from the first yao at the bottom to the top yao.',
balance: 'Available 120 credits · This reading 20 credits',
defaultQuestion: 'What should I pay attention to in my career development over the next three months?',
modify: 'Modify',
guideLines: ['Record from the first yao upward.', 'Each yao is determined by the text-side and flower-side combination of three coins.', 'Start interpretation after all six yao are complete.'],
openGuide: 'View Manual Casting Guide',
guideSteps: [
['Manual Casting', 'Prepare three identical coins. Record one yao at a time, from bottom to top, until all six yao are complete.'],
['Confirm Time', 'Check the casting time first. Use Modify on the right if you need to adjust it.'],
['Fill Six Yao in Order', 'Start from the first yao and complete one row at a time. The next row stays locked until the current row is confirmed.'],
['Start Interpretation', 'After all six yao are filled, Start Interpretation becomes active. Select it to continue.'],
],
closeGuide: 'Finish',
nextGuide: 'Next',
prevGuide: 'Back',
lineNames: ['First Yao', 'Second Yao', 'Third Yao', 'Fourth Yao', 'Fifth Yao', 'Top Yao'],
pending: 'Pending',
zi: 'Text',
hua: 'Flower',
yaoTypeNames: { youngYang: 'Young Yang', youngYin: 'Young Yin', oldYang: 'Old Yang', oldYin: 'Old Yin' },
questionTypePrefix: 'Category',
method: 'Method: Manual Casting',
submit: 'Start Interpretation',
},
} as const;
2026-05-09 16:00:29 +08:00
export default function ManualDivinationPage({ locale, divination: d }: Props) {
const text = copy[locale as keyof typeof copy] ?? copy.zh;
const cats = useMemo(() => d.categories.split(','), [d.categories]);
2026-05-09 16:00:29 +08:00
const [category, setCategory] = useState(cats[0]);
const [question, setQuestion] = useState(text.defaultQuestion);
const [selectedTime, setSelectedTime] = useState(() => formatDateTimeInput(new Date()));
const [coins, setCoins] = useState<[CoinFace, CoinFace, CoinFace]>(['zi', 'zi', 'zi']);
const [yaoResults, setYaoResults] = useState<YaoType[]>([]);
const [guideStep, setGuideStep] = useState<number | null>(null);
useEffect(() => {
setCategory(cats[0]);
}, [cats]);
const progress = yaoResults.length;
const currentYaoType = fromCoins(coins);
const guideOpen = guideStep !== null;
const guide = guideOpen ? text.guideSteps[guideStep] : null;
2026-05-09 16:00:29 +08:00
const flipCoin = (idx: number) => {
setCoins((current) => {
const next = [...current] as [CoinFace, CoinFace, CoinFace];
next[idx] = next[idx] === 'zi' ? 'hua' : 'zi';
return next;
});
2026-05-09 16:00:29 +08:00
};
const confirmYao = () => {
if (progress >= TOTAL_YAO_COUNT) return;
setYaoResults((current) => [...current, currentYaoType]);
setCoins(['zi', 'zi', 'zi']);
2026-05-09 16:00:29 +08:00
};
const showPreviousGuide = () => setGuideStep((step) => (step === null ? 0 : Math.max(step - 1, 0)));
const showNextGuide = () => setGuideStep((step) => (step === null ? 0 : Math.min(step + 1, text.guideSteps.length - 1)));
2026-05-09 16:00:29 +08:00
return (
<div className="relative flex min-h-full flex-col gap-[22px]">
<div className="flex items-center justify-between gap-5">
<div className="min-w-0">
<h1 className="text-[28px] font-bold leading-tight text-[#333333]">{text.title}</h1>
<p className="mt-1 text-sm text-[#666666]">{text.subtitle}</p>
2026-05-09 16:00:29 +08:00
</div>
<div className="hidden h-10 items-center gap-2 rounded-full border border-slate-200 bg-white px-3.5 text-[13px] font-semibold text-[#333333] md:flex">
<Icon name="paid" className="h-[18px] w-[18px] text-violet-700" />
{text.balance}
</div>
</div>
<div className="flex min-h-0 flex-1 flex-col gap-[22px] xl:flex-row">
<div className="flex w-full shrink-0 flex-col gap-4 xl:w-[360px]">
<section className="flex h-[300px] flex-col gap-4 rounded-2xl border border-slate-200 bg-white p-[22px]">
<h2 className="text-lg font-bold text-slate-900">{d.questionTitle}</h2>
<label className="sr-only" htmlFor="manual-category">{d.categoryLabel}</label>
<select
id="manual-category"
value={category}
onChange={(event) => setCategory(event.target.value)}
className="h-[42px] rounded-[10px] border border-slate-300 bg-slate-50 px-3 text-sm font-bold text-[#333333] outline-none focus:border-violet-500"
>
{cats.map((cat) => <option key={cat} value={cat}>{cat}</option>)}
</select>
<textarea
value={question}
onChange={(event) => setQuestion(event.target.value)}
placeholder={d.questionPlaceholder}
className="min-h-0 flex-1 resize-none rounded-[10px] border border-slate-300 bg-white px-3.5 py-3 text-sm text-[#333333] outline-none focus:border-violet-500"
/>
</section>
<section className={`flex h-[132px] flex-col gap-3 rounded-2xl border bg-white p-5 ${guideStep === 1 ? 'border-violet-500 ring-4 ring-violet-100' : 'border-slate-200'}`}>
<h2 className="text-base font-bold text-slate-900">{d.timeTitle}</h2>
<div className="flex h-[42px] items-center justify-between gap-3 rounded-[10px] bg-slate-50 px-3">
<input
type="datetime-local"
value={selectedTime}
onChange={(event) => setSelectedTime(event.target.value)}
className="w-full bg-transparent text-sm font-semibold text-[#333333] outline-none"
/>
<span className="shrink-0 text-[13px] font-bold text-violet-700">{text.modify}</span>
2026-05-09 16:00:29 +08:00
</div>
</section>
<section className={`flex h-[214px] flex-col gap-3 rounded-2xl border bg-white p-5 ${guideStep === 0 ? 'border-violet-500 ring-4 ring-violet-100' : 'border-slate-200'}`}>
<h2 className="text-base font-bold text-slate-900">{d.guideTitle}</h2>
{text.guideLines.map((line) => <p key={line} className="text-[13px] leading-relaxed text-[#666666]">{line}</p>)}
<button
type="button"
onClick={() => setGuideStep(0)}
className="mt-auto flex h-8 w-fit items-center gap-2 rounded-full bg-violet-50 px-3 text-[13px] font-bold text-violet-700"
>
<Icon name="notifications" className="h-[18px] w-[18px]" />
{text.openGuide}
</button>
</section>
2026-05-09 16:00:29 +08:00
</div>
<section className={`flex min-w-0 flex-1 flex-col gap-4 rounded-2xl border bg-white p-6 ${guideStep === 2 ? 'border-violet-500 ring-4 ring-violet-100' : 'border-slate-200'}`}>
<div className="flex items-center justify-between gap-4">
<h2 className="text-lg font-bold text-slate-900">{d.yaoTitle}</h2>
<span className="text-[13px] font-bold text-violet-700">{progress} / {TOTAL_YAO_COUNT}</span>
2026-05-09 16:00:29 +08:00
</div>
<div className="flex flex-col gap-2.5">
{[5, 4, 3, 2, 1, 0].map((index) => {
const result = yaoResults[index];
const active = index === progress && progress < TOTAL_YAO_COUNT;
2026-05-09 16:00:29 +08:00
return (
<div
key={index}
className={`flex h-[62px] items-center gap-4 rounded-[10px] px-3.5 ${active ? 'border border-violet-600 bg-violet-50' : result ? 'border border-slate-200 bg-white' : 'bg-slate-50'}`}
>
<span className={`w-16 text-sm font-bold ${active || result ? 'text-violet-700' : 'text-slate-400'}`}>{text.lineNames[index]}</span>
<div className="min-w-0 flex-1">
<YaoGlyph type={result} active={active} />
</div>
<span className={`w-20 text-right text-[13px] font-bold ${result ? 'text-violet-700' : 'text-slate-400'}`}>
{result ? text.yaoTypeNames[result] : text.pending}
</span>
2026-05-09 16:00:29 +08:00
</div>
);
})}
</div>
{progress < TOTAL_YAO_COUNT && (
<>
<div className="flex min-h-[142px] items-center justify-center rounded-xl bg-slate-50 p-4">
<div className="flex items-center justify-center gap-6">
{coins.map((face, index) => (
<button
key={index}
type="button"
onClick={() => flipCoin(index)}
className="flex w-20 flex-col items-center gap-2 text-[13px] font-bold text-slate-600"
>
<CoinImage face={face} selected={face === 'hua'} />
<span>{face === 'zi' ? text.zi : text.hua}</span>
</button>
))}
</div>
2026-05-09 16:00:29 +08:00
</div>
<div className="rounded-xl border border-violet-100 bg-violet-50 px-4 py-3 text-center text-[13px] font-semibold text-violet-800">
{text.yaoTypeNames[currentYaoType]}
</div>
<button
type="button"
onClick={confirmYao}
className="h-10 w-full rounded-full bg-violet-700 text-[13px] font-bold text-white transition-colors hover:bg-violet-800"
>
{d.confirmBtn}
</button>
</>
2026-05-09 16:00:29 +08:00
)}
</section>
2026-05-09 16:00:29 +08:00
<aside className="flex w-full shrink-0 flex-col gap-[18px] rounded-2xl border border-slate-200 bg-white p-[22px] xl:w-[300px]">
<h2 className="text-lg font-bold text-slate-900">{d.summaryTitle}</h2>
<div className="flex h-[94px] flex-col gap-2 rounded-xl bg-slate-50 p-4">
<p className="text-[13px] text-[#666666]">{d.progressLabel}</p>
<p className="text-[28px] font-bold text-violet-700">{progress} / {TOTAL_YAO_COUNT}</p>
2026-05-09 16:00:29 +08:00
</div>
<p className="text-sm text-[#666666]">{text.questionTypePrefix}{locale === 'en' ? ': ' : ''}{category}</p>
<p className="text-sm text-[#666666]">{text.method}</p>
<p className="text-sm text-[#666666]">{d.checkCost}</p>
2026-05-09 16:00:29 +08:00
<div className="flex-1" />
<button
type="button"
disabled={progress < TOTAL_YAO_COUNT}
className={`h-[46px] w-full rounded-full text-sm font-bold transition-colors ${progress >= TOTAL_YAO_COUNT ? 'bg-violet-700 text-white hover:bg-violet-800' : 'cursor-not-allowed bg-slate-300 text-slate-400'} ${guideStep === 3 ? 'ring-4 ring-violet-100' : ''}`}
>
{text.submit}
2026-05-09 16:00:29 +08:00
</button>
</aside>
</div>
{guideOpen && guide && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 p-5">
<div className="w-full max-w-md rounded-2xl border border-white/10 bg-slate-950 p-6 text-white shadow-2xl">
<div className="mb-4 flex items-center justify-between gap-4">
<span className="text-sm font-bold text-violet-200">{guideStep + 1} / {text.guideSteps.length}</span>
<button type="button" onClick={() => setGuideStep(null)} className="rounded-full p-1 text-white/70 hover:bg-white/10 hover:text-white">
<Icon name="close" className="h-5 w-5" />
</button>
</div>
<h2 className="text-xl font-bold">{guide[0]}</h2>
<p className="mt-3 text-sm leading-6 text-white/80">{guide[1]}</p>
<div className="mt-6 flex items-center justify-between gap-3">
<button type="button" onClick={showPreviousGuide} disabled={guideStep === 0} className="h-10 rounded-full px-4 text-sm font-bold text-white/70 disabled:opacity-40">
{text.prevGuide}
</button>
{guideStep === text.guideSteps.length - 1 ? (
<button type="button" onClick={() => setGuideStep(null)} className="h-10 rounded-full bg-white px-5 text-sm font-bold text-slate-950">
{text.closeGuide}
</button>
) : (
<button type="button" onClick={showNextGuide} className="h-10 rounded-full bg-white px-5 text-sm font-bold text-slate-950">
{text.nextGuide}
</button>
)}
</div>
</div>
2026-05-09 16:00:29 +08:00
</div>
)}
2026-05-09 16:00:29 +08:00
</div>
);
}