@@ -1,145 +1,744 @@
import { useState , useEffect } from 'react' ;
import { useEffect , useLayoutEffect , useMemo , useRef , useState } from 'react' ;
import { useNavigate } from 'react-router-dom' ;
import Icon from './Icon' ;
import { getPointsBalance , type PointsBalance } from '.. /lib/api ' ;
import DivinationProcessingOverlay from './DivinationProcessingOverlay ' ;
import { getPointsBalance , getUserProfile , updateUserSettings , type PointsBalance , type DivinationResultData , type YaoType } from '../lib/api' ;
import { useUserSettings } from './AppShell' ;
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 } ;
divination : { questionTitle : string ; questionPlaceholder : string ; categoryLabel : string ; categories : string ; timeTitle : string ; timeHint : string ; guideTitle : string ; guideAuto : string ; yaoTitle : string ; coinLabel : string ; confirmBtn : string ; summaryTitle : string ; checkCategory : string ; checkMethod : string ; checkCost : string ; submitBtn : string ; shakeTitle : string ; shakeBtn : string ; hexPreview : string ; progressLabel : string } ;
}
type CoinFace = 'zi' | 'hua' ;
const TOTAL_YAO_COUNT = 6 ;
const SHAKE_DURATION_PER_YAO = 3 ; // 3 seconds per yao
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 : return 'youngYang' ;
}
}
function randomYao ( ) : YaoType {
return fromHuaCount ( Math . floor ( Math . random ( ) * 4 ) ) ;
}
// Get coin combination for a YaoType
function coinsForYaoType ( type : YaoType ) : [ CoinFace , CoinFace , CoinFace ] {
switch ( type ) {
case 'oldYin' : return [ 'zi' , 'zi' , 'zi' ] ; // 0 hua
case 'youngYang' : return [ 'hua' , 'zi' , 'zi' ] ; // 1 hua
case 'youngYin' : return [ 'hua' , 'hua' , 'zi' ] ; // 2 hua
case 'oldYang' : return [ 'hua' , 'hua' , 'hua' ] ; // 3 hua
}
}
function CoinImage ( { face , spinning } : { face : CoinFace ; spinning? : 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-md ${ spinning ? 'coin-spin' : '' } ` }
draggable = { false }
/ >
) ;
}
function YaoGlyph ( { type , confirmed } : { type ? : YaoType ; confirmed? : boolean } ) {
const color = confirmed ? '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 >
) ;
}
function YaoChangeMark ( { type } : { type ? : YaoType } ) {
if ( type === 'oldYang' ) return < span className = "text-violet-700 font-bold" > ○ < / span > ;
if ( type === 'oldYin' ) return < span className = "text-violet-700 font-bold" > × < / span > ;
return null ;
}
const copy = {
zh : {
title : '自动起卦' ,
subtitle : '点击摇卦按钮,系统自动生成六爻卦象。' ,
defaultQuestion : '我接下来三个月的事业发展需要注意什么?' ,
modify : '修改' ,
guideLines : [ '系统自动为您生成六爻卦象。' , '从初爻到上爻依次摇出。' , '每爻摇卦需要等待3秒。' ] ,
openGuide : '查看自动起卦教程' ,
guideSteps : [
[ '自动起卦' , '系统会自动为您生成六爻卦象,从初爻到上爻依次摇出。' ] ,
[ '确认时间' , '先确认起卦时间。如需调整,点击右侧「修改」。' ] ,
[ '依次摇卦' , '点击「摇一摇」按钮,系统会依次摇出六爻。每爻需要等待3秒。' ] ,
[ '开始分析' , '六爻都完成后,下方「开始解卦」按钮会激活,点击即可解卦。' ] ,
] ,
closeGuide : '结束教程' ,
nextGuide : '下一步' ,
prevGuide : '上一步' ,
lineNames : [ '初爻' , '二爻' , '三爻' , '四爻' , '五爻' , '上爻' ] ,
zi : '字' ,
hua : '花' ,
questionTypePrefix : '问题类型' ,
method : '起卦方式:自动起卦' ,
submit : '开始解卦' ,
shake : '摇一摇' ,
shaking : '摇卦中...' ,
shakingYao : '第 N 爻' ,
yaoComplete : '完成' ,
confirmTitle : '确认解卦' ,
confirmAvailable : '当前积分' ,
confirmCost : '本次消耗' ,
confirmRemaining : '解卦后剩余' ,
cancel : '取消' ,
confirm : '确认' ,
} ,
zh_Hant : {
title : '自動起卦' ,
subtitle : '點擊搖卦按鈕,系統自動生成六爻卦象。' ,
defaultQuestion : '我接下來三個月的事業發展需要注意什麼?' ,
modify : '修改' ,
guideLines : [ '系統自動為您生成六爻卦象。' , '從初爻到上爻依次搖出。' , '每爻搖卦需要等待3秒。' ] ,
openGuide : '查看自動起卦教程' ,
guideSteps : [
[ '自動起卦' , '系統會自動為您生成六爻卦象,從初爻到上爻依次搖出。' ] ,
[ '確認時間' , '先確認起卦時間。如需調整,點擊右側「修改」。' ] ,
[ '依序搖卦' , '點擊「搖一搖」按鈕,系統會依序搖出六爻。每爻需要等待3秒。' ] ,
[ '開始分析' , '六爻都完成後,下方「開始解卦」按鈕會激活,點擊即可解卦。' ] ,
] ,
closeGuide : '結束教程' ,
nextGuide : '下一步' ,
prevGuide : '上一步' ,
lineNames : [ '初爻' , '二爻' , '三爻' , '四爻' , '五爻' , '上爻' ] ,
zi : '字' ,
hua : '花' ,
questionTypePrefix : '問題類型' ,
method : '起卦方式:自動起卦' ,
submit : '開始解卦' ,
shake : '搖一搖' ,
shaking : '搖卦中...' ,
shakingYao : '第 N 爻' ,
yaoComplete : '完成' ,
confirmTitle : '確認解卦' ,
confirmAvailable : '當前積分' ,
confirmCost : '本次消耗' ,
confirmRemaining : '解卦後剩餘' ,
cancel : '取消' ,
confirm : '確認' ,
} ,
en : {
title : 'Auto Casting' ,
subtitle : 'Click the shake button to automatically generate a six-line hexagram.' ,
defaultQuestion : 'What should I pay attention to in my career development over the next three months?' ,
modify : 'Modify' ,
guideLines : [ 'The system will automatically generate a six-line hexagram for you.' , 'Lines are cast from bottom to top.' , 'Each line takes 3 seconds to cast.' ] ,
openGuide : 'View Auto Casting Guide' ,
guideSteps : [
[ 'Auto Casting' , 'The system will automatically generate a six-line hexagram, casting from the first yao to the top yao.' ] ,
[ 'Confirm Time' , 'Check the casting time first. Tap "Modify" on the right if you need to adjust it.' ] ,
[ 'Cast in Order' , 'Click "Shake" button to cast all six lines. Each line takes 3 seconds.' ] ,
[ 'Start Analysis' , 'After all six yao are complete, the "Start Interpretation" button will activate.' ] ,
] ,
closeGuide : 'Finish' ,
nextGuide : 'Next' ,
prevGuide : 'Back' ,
lineNames : [ 'First Yao' , 'Second Yao' , 'Third Yao' , 'Fourth Yao' , 'Fifth Yao' , 'Top Yao' ] ,
zi : 'Inscription' ,
hua : 'Pattern' ,
questionTypePrefix : 'Category' ,
method : 'Method: Auto Casting' ,
submit : 'Start Interpretation' ,
shake : 'Shake' ,
shaking : 'Shaking...' ,
shakingYao : 'Yao N' ,
yaoComplete : 'Done' ,
confirmTitle : 'Confirm Interpretation' ,
confirmAvailable : 'Available credits' ,
confirmCost : 'This reading cost' ,
confirmRemaining : 'Remaining after' ,
cancel : 'Cancel' ,
confirm : 'Confirm' ,
} ,
} as const ;
export default function AutoDivinationPage ( { locale , divination : d } : Props ) {
const cats = d . categories . split ( ',' ) ;
const text = copy [ locale as keyof typeof copy ] ? ? copy . zh ;
const cats = useMemo ( ( ) = > d . categories . split ( ',' ) , [ d . categories ] ) ;
const navigate = useNavigate ( ) ;
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 [ question , setQuestion ] = useState ( text . defaultQuestion ) ;
const [ selectedTime , setSelectedTime ] = useState ( ( ) = > formatDateTimeInput ( new Date ( ) ) ) ;
const [ yaoResults , setYaoResults ] = useState < YaoType [ ] > ( [ ] ) ;
const [ guideStep , setGuideStep ] = useState < number | null > ( null ) ;
const [ points , setPoints ] = useState < PointsBalance | null > ( null ) ;
const [ showProcessing , setShowProcessing ] = useState ( false ) ;
const [ showConfirm , setShowConfirm ] = useState ( false ) ;
const { userProfile , setUserProfile } = useUserSettings ( ) ;
// Shake state
const [ isShaking , setIsShaking ] = useState ( false ) ;
const [ currentShakingYao , setCurrentShakingYao ] = useState ( 0 ) ;
const [ countdown , setCountdown ] = useState ( 0 ) ;
const [ currentCoins , setCurrentCoins ] = useState < [ CoinFace , CoinFace , CoinFace ] > ( [ 'zi' , 'zi' , 'zi' ] ) ;
// Refs for guide spotlight
const coinsAreaRef = useRef < HTMLDivElement > ( null ) ;
const timePanelRef = useRef < HTMLElement > ( null ) ;
const yaoPanelRef = useRef < HTMLElement > ( null ) ;
const submitBtnRef = useRef < HTMLButtonElement > ( null ) ;
const scrollContainerRef = useRef < HTMLDivElement > ( null ) ;
const [ spotlightRect , setSpotlightRect ] = useState < { left : number ; top : number ; width : number ; height : number } | null > ( null ) ;
const [ tooltipPos , setTooltipPos ] = useState < { left : number ; top : number } > ( { left : 0 , top : 0 } ) ;
const [ tooltipSide , setTooltipSide ] = useState < 'right' | 'left' | 'bottom' | 'top' > ( 'right' ) ;
const [ isMobile , setIsMobile ] = useState ( ( ) = > typeof window !== 'undefined' && window . innerWidth < 1280 ) ;
const [ tutorialChecked , setTutorialChecked ] = useState ( false ) ;
// Auto-show tutorial on first visit
useEffect ( ( ) = > {
if ( tutorialChecked ) return ;
const tutorialSettings = userProfile ? . settings ? . divination_tutorial ;
if ( tutorialSettings && ! tutorialSettings . auto_divination_shown ) {
const timer = setTimeout ( ( ) = > {
setTutorialChecked ( true ) ;
setGuideStep ( 0 ) ;
} , 400 ) ;
return ( ) = > clearTimeout ( timer ) ;
} else if ( userProfile !== null ) {
setTutorialChecked ( true ) ;
}
} , [ userProfile , tutorialChecked ] ) ;
// Mark tutorial as shown when guide ends
const closeGuide = async ( ) = > {
setGuideStep ( null ) ;
if ( userProfile && ! userProfile . settings . divination_tutorial . auto_divination_shown ) {
const updatedSettings = {
. . . userProfile . settings ,
divination_tutorial : {
. . . userProfile . settings . divination_tutorial ,
auto_divination_shown : true ,
} ,
} ;
try {
const updated = await updateUserSettings ( { settings : updatedSettings } ) ;
setUserProfile ( updated ) ;
} catch {
// Silently fail
}
}
} ;
const prevGuideStepRef = useRef < number | null > ( null ) ;
useLayoutEffect ( ( ) = > {
if ( guideStep === null ) {
setSpotlightRect ( null ) ;
prevGuideStepRef . current = null ;
return ;
}
const targetRef = [ coinsAreaRef , timePanelRef , yaoPanelRef , submitBtnRef ] [ guideStep ] ;
if ( ! targetRef ? . current ) return ;
const tooltipWidth = 320 ;
const tooltipHeight = 180 ;
const gap = 16 ;
const scrollContainer = scrollContainerRef . current ? . parentElement ? . parentElement as HTMLElement | null ;
if ( ! scrollContainer ) return ;
const isInitialOpen = prevGuideStepRef . current === null ;
if ( isMobile ) {
const containerRect = scrollContainer . getBoundingClientRect ( ) ;
const elementRect = targetRef . current . getBoundingClientRect ( ) ;
const elementLeft = elementRect . left - containerRect . left ;
const elementTop = elementRect . top - containerRect . top + scrollContainer . scrollTop ;
const elementWidth = elementRect . width ;
const elementHeight = elementRect . height ;
if ( isInitialOpen ) {
scrollContainer . scrollTop = 0 ;
}
const scrollTopNeeded = Math . max ( 0 , elementTop - 20 ) ;
scrollContainer . scrollTo ( { top : scrollTopNeeded , behavior : 'smooth' } ) ;
requestAnimationFrame ( ( ) = > {
if ( ! targetRef . current ) return ;
const newElementRect = targetRef . current . getBoundingClientRect ( ) ;
const newContainerRect = scrollContainer . getBoundingClientRect ( ) ;
const spotlightLeft = newElementRect . left - newContainerRect . left ;
const spotlightTop = newElementRect . top - newContainerRect . top ;
const tooltipLeft = Math . max ( 16 , Math . min (
( newElementRect . left + newElementRect . right - tooltipWidth ) / 2 - newContainerRect . left ,
containerRect . width - tooltipWidth - 16
) ) ;
const tooltipTop = spotlightTop + elementHeight + gap ;
setSpotlightRect ( {
left : spotlightLeft ,
top : spotlightTop ,
width : elementWidth ,
height : elementHeight
} ) ;
setTooltipPos ( { left : tooltipLeft , top : tooltipTop } ) ;
setTooltipSide ( 'bottom' ) ;
} ) ;
prevGuideStepRef . current = guideStep ;
return ;
}
targetRef . current . scrollIntoView ( { behavior : 'smooth' , block : 'center' } ) ;
const rect = targetRef . current . getBoundingClientRect ( ) ;
let tooltipLeft : number ;
let tooltipTop : number ;
let side : 'right' | 'left' | 'bottom' | 'top' ;
if ( rect . right + gap + tooltipWidth <= window . innerWidth ) {
tooltipLeft = rect . right + gap ;
tooltipTop = rect . top ;
side = 'right' ;
} else if ( rect . left >= tooltipWidth + gap ) {
tooltipLeft = rect . left - tooltipWidth - gap ;
tooltipTop = rect . top ;
side = 'left' ;
} else {
tooltipLeft = Math . max ( 16 , Math . min ( rect . left , window . innerWidth - tooltipWidth - 16 ) ) ;
tooltipTop = rect . bottom + gap ;
side = 'bottom' ;
}
if ( tooltipTop + tooltipHeight > window . innerHeight ) {
tooltipTop = Math . max ( 16 , window . innerHeight - tooltipHeight - 16 ) ;
}
setSpotlightRect ( { left : rect.left , top : rect.top , width : rect.width , height : rect.height } ) ;
setTooltipPos ( { left : tooltipLeft , top : tooltipTop } ) ;
setTooltipSide ( side ) ;
prevGuideStepRef . current = guideStep ;
} , [ guideStep , isMobile ] ) ;
useEffect ( ( ) = > {
setCategory ( cats [ 0 ] ) ;
} , [ cats ] ) ;
useEffect ( ( ) = > {
getPointsBalance ( ) . then ( setPoints ) . catch ( ( ) = > { } ) ;
} , [ ] ) ;
useEffect ( ( ) = > {
const handleResize = ( ) = > setIsMobile ( window . innerWidth < 1280 ) ;
window . addEventListener ( 'resize' , handleResize ) ;
return ( ) = > window . removeEventListener ( 'resize' , handleResize ) ;
} , [ ] ) ;
const progress = yaoResults . length ;
const done = progress >= TOTAL_YAO_COUNT ;
const guideOpen = guideStep !== null ;
const guide = guideOpen ? text . guideSteps [ guideStep ] : null ;
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 ) ) ) ;
// Handle shake - now shakes one yao at a time
const handleShake = ( ) = > {
if ( isShaking || done ) return ;
const yaoIndex = progress ; // Current yao to shake
setCurrentShakingYao ( yaoIndex ) ;
setIsShaking ( true ) ;
setTimeout ( ( ) = > {
const newProgress = progress + 1 ;
setProgress ( newProgress ) ;
const line = Math . random ( ) > 0.5 ;
setHexLines ( prev = > [ . . . prev , line ] ) ;
setIsShaking ( false ) ;
} , 600 ) ;
setCountdown ( SHAKE_DURATION_PER_YAO ) ;
// Generate random coins for spinning animation
const spinInterval = setInterval ( ( ) = > {
const faces : CoinFace [ ] = [ 'zi' , 'hua' ] ;
setCurrentCoins ( [
faces [ Math . floor ( Math . random ( ) * 2 ) ] ,
faces [ Math . floor ( Math . random ( ) * 2 ) ] ,
faces [ Math . floor ( Math . random ( ) * 2 ) ] ,
] ) ;
} , 100 ) ;
const countdownTimer = setInterval ( ( ) = > {
setCountdown ( ( prev ) = > {
if ( prev <= 1 ) {
clearInterval ( countdownTimer ) ;
clearInterval ( spinInterval ) ;
// Generate result for this yao
const newYao = randomYao ( ) ;
setYaoResults ( ( current ) = > [ . . . current , newYao ] ) ;
setCurrentCoins ( coinsForYaoType ( newYao ) ) ;
setIsShaking ( false ) ;
setCurrentShakingYao ( 0 ) ;
return 0 ;
}
return prev - 1 ;
} ) ;
} , 1000 ) ;
} ;
const done = progress > = 6 ;
const handleSubmit = ( ) = > {
if ( ! done ) return ;
setShowConfirm ( true ) ;
} ;
const handleConfirm = ( ) = > {
setShowConfirm ( false ) ;
setShowProcessing ( true ) ;
} ;
const handleComplete = ( result : DivinationResultData | null ) = > {
setShowProcessing ( false ) ;
if ( result ) {
navigate ( ` / ${ locale } /divination/result ` , { state : { result } } ) ;
}
} ;
const handleBack = ( ) = > {
navigate ( ` / ${ locale } /dashboard ` ) ;
} ;
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 ${ points ? . availableBalance ? ? '...' } credits · This time ${ points ? . runCost ? ? 20 } credits `
: ` 可用 ${ points ? . availableBalance ? ? '...' } 积分 · 本次 ${ points ? . runCost ? ? 20 } 积分 ` }
< / div >
< div ref = { scrollContainerRef } 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 = "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 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" / >
{ locale === 'en'
? ` Available ${ points ? . availableBalance ? ? '...' } credits · This time ${ points ? . runCost ? ? 20 } credits `
: ` 可用 ${ points ? . availableBalance ? ? '...' } 积分 · 本次 ${ points ? . runCost ? ? 20 } 积分 ` }
< / 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]" >
{ /* Guide panel */ }
< section className = "flex h-[214px] flex-col gap-3 rounded-2xl border bg-white p-5 border-slate-200" >
< h2 className = "text-base font-bold text-slate-900" > { d . guideTitle } < / h2 >
{ text . guideLines . map ( ( line , i ) = > < p key = { i } 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-[17px] bg-[#F0E6FF] px-3 text-[13px] font-bold text-[#673AB7] hover:bg-[#E6D6FF] transition-colors"
>
< Icon name = "help" className = "h-[18px] w-[18px]" / >
{ text . openGuide }
< / button >
< / section >
{ /* Question panel */ }
< 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 = "auto-category" > { d . categoryLabel } < / label >
< select
id = "auto-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 >
{ /* Time panel */ }
< section ref = { timePanelRef } 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 cursor-pointer text-[13px] font-bold text-violet-700 hover:text-violet-800" > { text . modify } < / span >
< / 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 >
< / section >
< / 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 >
< section ref = { yaoPanelRef } 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 >
{ /* 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 >
) ) }
{ /* Six yao rows */ }
< div className = "flex flex-col gap-2.5" >
{ [ 5 , 4 , 3 , 2 , 1 , 0 ] . map ( ( index ) = > {
const result = yaoResults [ index ] ;
const isBeingShaken = isShaking && currentShakingYao === index ;
const isNextToShake = ! isShaking && progress === index ;
const confirmed = ! ! result ;
return (
< div
key = { index }
className = { ` flex h-[62px] items-center gap-4 rounded-[10px] px-3.5 ${
isBeingShaken ? 'border border-violet-600 bg-violet-50' :
isNextToShake ? 'border border-violet-600 bg-violet-50' :
confirmed ? 'border border-slate-200 bg-white' : 'bg-slate-50'
} ` }
>
< span className = { ` w-16 text-sm font-bold ${ isBeingShaken || isNextToShake || confirmed ? 'text-violet-700' : 'text-slate-400' } ` } >
{ text . lineNames [ index ] }
< / span >
< div className = "min-w-0 flex-1" >
< YaoGlyph type = { result } confirmed = { confirmed } / >
< / div >
< span className = "w-6 text-center" >
{ isBeingShaken ? (
< span className = "text-violet-600 text-xs font-bold" > { countdown } s < / span >
) : (
< YaoChangeMark type = { result } / >
) }
< / span >
< / div >
) ;
} ) }
< / div >
{ /* Coins area */ }
< div ref = { coinsAreaRef } 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" >
{ currentCoins . map ( ( face , index ) = > (
< div key = { index } className = "flex flex-col items-center gap-2" >
< CoinImage face = { face } spinning = { isShaking } / >
< span className = "text-[13px] font-bold text-slate-600" >
{ isShaking ? '?' : ( face === 'zi' ? text.zi : text.hua ) }
< / span >
< / div >
) ) }
< / 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 >
< button
type = "button"
onClick = { handleShake }
disabled = { done || isShaking }
className = { ` h-10 w-full rounded-full text-[13px] font-bold transition-colors flex items-center justify-center gap-2 ${
done
? 'cursor-not-allowed bg-slate-300 text-slate-400'
: isShaking
? 'bg-violet-400 text-white cursor-wait'
: 'bg-violet-700 text-white hover:bg-violet-800'
} ` }
>
{ isShaking ? (
< >
< div className = "w-4 h-4 rounded-full border-2 border-white border-t-transparent animate-spin" / >
{ text . shaking } ( { text . shakingYao . replace ( 'N' , String ( currentShakingYao + 1 ) ) } )
< / >
) : done ? (
text . yaoComplete
) : (
< >
< Icon name = "casino" className = "w-5 h-5" / >
{ text . shake }
< / >
) }
{ done && < p className = "text-violet-600 text-sm font-medium" > 六 爻 完 成 < / p > }
< / div >
< / button >
< / section >
{ /* Hexagram preview */ }
< div className = "bg-white rounded-xl p-[18px] border border -slate-2 00 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" / >
< 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-9 00" > { 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]" > { locale === 'en' ? ` Cost: ${ points ? . runCost ? ? 20 } credits ` : ` 解卦消耗: ${ points ? . runCost ? ? 20 } 积分 ` } < / p >
< div className = "flex-1" / >
< button
ref = { submitBtnRef }
type = "button"
disabled = { ! done }
onClick = { handleSubmit }
className = { ` h-[46px] w-full rounded-full text-sm font-bold transition-colors ${ done ? '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 >
{ /* Guide overlay */ }
{ guideOpen && guide && spotlightRect && (
< >
< div
className = "fixed inset-0 z-40 bg-black/70 md:block hidden"
onClick = { ( ) = > closeGuide ( ) }
/ >
< div
className = "absolute inset-0 z-40 bg-black/70 md:hidden"
style = { { top : 0 , height : '100vh' } }
onClick = { ( ) = > closeGuide ( ) }
/ >
< div
className = { ` z-50 rounded-2xl ring-4 ring-white shadow-[0_0_0_9999px_rgba(0,0,0,0.7)] transition-all duration-300 ${
isMobile ? 'absolute' : 'fixed'
} ` }
style = { {
left : spotlightRect.left ,
top : spotlightRect.top ,
width : spotlightRect.width ,
height : spotlightRect.height
} }
/ >
< div
className = { ` z-50 w-[320px] rounded-2xl bg-slate-950 p-5 text-white shadow-2xl transition-all duration-300 ${
isMobile ? 'absolute' : 'fixed'
} ` }
style = { {
left : tooltipPos.left ,
top : tooltipPos.top
} }
>
< div
className = { ` absolute h-3 w-3 rotate-45 bg-slate-950 ${
tooltipSide === 'right' ? '-left-1.5 top-6' :
tooltipSide === 'left' ? '-right-1.5 top-6' :
tooltipSide === 'top' ? '-bottom-1.5 left-6' :
'-top-1.5 left-6'
} ` }
/ >
< div className = "mb-3 flex items-center justify-between gap-4" >
< span className = "text-xs font-bold text-violet-300" > { guideStep + 1 } / { text . guideSteps . length } < / span >
< button type = "button" onClick = { ( ) = > closeGuide ( ) } className = "rounded-full p-1 text-white/50 hover:text-white" >
< Icon name = "close" className = "h-4 w-4" / >
< / button >
< / div >
< h3 className = "text-base font-bold" > { guide [ 0 ] } < / h3 >
< p className = "mt-2 text-sm leading-relaxed text-white/70" > { guide [ 1 ] } < / p >
< div className = "mt-4 flex items-center justify-between gap-3" >
< button
type = "button"
onClick = { showPreviousGuide }
disabled = { guideStep === 0 }
className = "h-9 rounded-full px-4 text-sm font-medium text-white/50 disabled:opacity-30 hover:text-white disabled:hover:text-white/50"
>
{ text . prevGuide }
< / button >
{ guideStep === text . guideSteps . length - 1 ? (
< button
type = "button"
onClick = { ( ) = > closeGuide ( ) }
className = "h-9 rounded-full bg-violet-600 px-5 text-sm font-bold text-white hover:bg-violet-500"
>
{ text . closeGuide }
< / button >
) : (
< 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 >
< button
type = "button"
onClick = { showNextGuide }
className = "h-9 rounded-full bg-violet-600 px-5 text-sm font-bold text-white hover:bg-violet-500"
>
{ text . nextGuide }
< / button >
) }
< / 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-5 00 text-[13px] " > { d . progressLabel } < / p >
< p className = "text-violet-600 text-[28px] font-bold" > { progress } / 6 < / p >
{ /* Confirmation dialog */ }
{ showConfirm && (
< div className = "fixed inset-0 z-50 flex items-center justify-center bg-black/50" >
< div className = "bg-white rounded-2 xl p-6 w-[400px] max-w-[90vw] flex flex-col gap-5 shadow-xl" >
< h3 className = "text-slate-9 00 text-lg font-bold " > { text . confirmTitle } < / h3 >
< div className = "flex flex-col gap-3" >
< div className = "flex justify-between text-sm" >
< span className = "text-slate-500" > { text . confirmAvailable } < / span >
< span className = "text-slate-900 font-semibold" > { points ? . availableBalance ? ? '...' } < / span >
< / div >
< div className = "flex justify-between text-sm" >
< span className = "text-slate-500" > { text . confirmCost } < / span >
< span className = "text-violet-600 font-semibold" > { points ? . runCost ? ? 20 } < / span >
< / div >
< div className = "border-t border-slate-200 pt-3 flex justify-between text-sm" >
< span className = "text-slate-500" > { text . confirmRemaining } < / span >
< span className = "text-slate-900 font-bold" >
{ ( points ? . availableBalance ? ? 0 ) - ( points ? . runCost ? ? 20 ) }
< / span >
< / div >
< / div >
< div className = "flex gap-3" >
< button
onClick = { ( ) = > setShowConfirm ( false ) }
className = "flex-1 h-11 rounded-full border border-slate-300 text-sm font-semibold text-slate-600 hover:bg-slate-50 transition-colors"
>
{ text . cancel }
< / button >
< button
onClick = { handleConfirm }
className = "flex-1 h-11 rounded-full bg-violet-600 text-sm font-bold text-white hover:bg-violet-700 transition-colors"
>
{ text . confirm }
< / button >
< / div >
< / 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 >
) }
{ /* Processing overlay */ }
{ showProcessing && (
< DivinationProcessingOverlay
locale = { locale }
params = { {
method : 'auto' ,
questionType : category ,
question : question ,
divinationTime : new Date ( selectedTime ) ,
} }
yaoStates = { yaoResults }
onComplete = { handleComplete }
/ >
) }
{ /* Coin spin animation */ }
< style > { `
@keyframes coin-spin {
0% { transform: rotateY(0deg); }
100% { transform: rotateY(360deg); }
}
.coin-spin {
animation: coin-spin 0.4s linear infinite;
}
` } < / style >
< / div >
) ;
}