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

354 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useMemo, 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 = 'zi' | 'hua';
type YaoType = 'youngYang' | 'youngYin' | 'oldYang' | 'oldYin';
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 }) {
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}
/>
);
}
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;
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]);
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;
const flipCoin = (idx: number) => {
setCoins((current) => {
const next = [...current] as [CoinFace, CoinFace, CoinFace];
next[idx] = next[idx] === 'zi' ? 'hua' : 'zi';
return next;
});
};
const confirmYao = () => {
if (progress >= TOTAL_YAO_COUNT) return;
setYaoResults((current) => [...current, currentYaoType]);
setCoins(['zi', 'zi', 'zi']);
};
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)));
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>
</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>
</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>
</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>
</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;
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>
</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>
</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>
</>
)}
</section>
<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>
</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>
<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}
</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>
</div>
)}
</div>
);
}