354 lines
17 KiB
TypeScript
354 lines
17 KiB
TypeScript
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>
|
||
);
|
||
}
|