fix(web): 修复手动起卦教程、硬币动画与积分显示
- 修复硬币翻转动画:从 @keyframes 改为 CSS transition 实现双向动画 - 修复教程自动显示:将 setTutorialChecked 移入 setTimeout 回调, 避免 useEffect cleanup 提前清除 timer 导致 setGuideStep 不执行 - 添加 AppShell UserSettingsContext 共享 userProfile - 实现教程结束后调用 updateUserSettings 标记 manual_divination_shown - 添加点击已确认爻进行编辑的功能 (editingIndex 状态) - 确认爻后不再重置硬币状态 - 积分显示从硬编码改为读取 API 返回值 - 手机端教程使用 absolute 定位替代 fixed 避免滚动偏移 - 添加 isMobile 响应式状态追踪窗口大小变化
This commit is contained in:
@@ -1,9 +1,24 @@
|
|||||||
import { useState, useEffect, type ReactNode } from 'react';
|
import { useState, useEffect, createContext, useContext, type ReactNode } from 'react';
|
||||||
import { useLocation, useNavigate } from 'react-router-dom';
|
import { useLocation, useNavigate } from 'react-router-dom';
|
||||||
import Icon from './Icon';
|
import Icon from './Icon';
|
||||||
import { getAuth, refreshAccessToken, redirectToLogin, backendLanguageToLocale, type AuthUser } from '../lib/auth';
|
import { getAuth, refreshAccessToken, redirectToLogin, backendLanguageToLocale, type AuthUser } from '../lib/auth';
|
||||||
import { getUserProfile, type UserProfile } from '../lib/api';
|
import { getUserProfile, type UserProfile } from '../lib/api';
|
||||||
|
|
||||||
|
// User settings context
|
||||||
|
interface UserSettingsContextValue {
|
||||||
|
userProfile: UserProfile | null;
|
||||||
|
setUserProfile: (profile: UserProfile | null) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const UserSettingsContext = createContext<UserSettingsContextValue>({
|
||||||
|
userProfile: null,
|
||||||
|
setUserProfile: () => {},
|
||||||
|
});
|
||||||
|
|
||||||
|
export function useUserSettings() {
|
||||||
|
return useContext(UserSettingsContext);
|
||||||
|
}
|
||||||
|
|
||||||
interface NavItem {
|
interface NavItem {
|
||||||
id: string;
|
id: string;
|
||||||
icon: string;
|
icon: string;
|
||||||
@@ -114,6 +129,7 @@ export default function AppShell({ locale, brandName, navItems, userName, userEm
|
|||||||
const shellAvatarUrl = userProfile?.avatar_url;
|
const shellAvatarUrl = userProfile?.avatar_url;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<UserSettingsContext.Provider value={{ userProfile, setUserProfile }}>
|
||||||
<div className="flex h-screen bg-slate-50 overflow-hidden">
|
<div className="flex h-screen bg-slate-50 overflow-hidden">
|
||||||
{sidebarOpen && (
|
{sidebarOpen && (
|
||||||
<div className="fixed inset-0 bg-black/40 z-40 md:hidden" onClick={() => setSidebarOpen(false)} />
|
<div className="fixed inset-0 bg-black/40 z-40 md:hidden" onClick={() => setSidebarOpen(false)} />
|
||||||
@@ -213,5 +229,6 @@ export default function AppShell({ locale, brandName, navItems, userName, userEm
|
|||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
</UserSettingsContext.Provider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useState } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import Icon from './Icon';
|
import Icon from './Icon';
|
||||||
|
import { getPointsBalance, type PointsBalance } from '../lib/api';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
locale: string;
|
locale: string;
|
||||||
@@ -14,6 +15,11 @@ export default function AutoDivinationPage({ locale, divination: d }: Props) {
|
|||||||
const [progress, setProgress] = useState(0);
|
const [progress, setProgress] = useState(0);
|
||||||
const [hexLines, setHexLines] = useState<boolean[]>([]);
|
const [hexLines, setHexLines] = useState<boolean[]>([]);
|
||||||
const [isShaking, setIsShaking] = useState(false);
|
const [isShaking, setIsShaking] = useState(false);
|
||||||
|
const [points, setPoints] = useState<PointsBalance | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getPointsBalance().then(setPoints).catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleShake = () => {
|
const handleShake = () => {
|
||||||
setIsShaking(true);
|
setIsShaking(true);
|
||||||
@@ -37,7 +43,9 @@ export default function AutoDivinationPage({ locale, divination: d }: Props) {
|
|||||||
</div>
|
</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">
|
<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" />
|
<Icon name="paid" className="w-[18px] h-[18px] text-violet-700" />
|
||||||
{locale === 'en' ? 'Available 120 credits · This time 20 credits' : '可用 120 积分 · 本次 20 积分'}
|
{locale === 'en'
|
||||||
|
? `Available ${points?.availableBalance ?? '...'} credits · This time ${points?.runCost ?? 20} credits`
|
||||||
|
: `可用 ${points?.availableBalance ?? '...'} 积分 · 本次 ${points?.runCost ?? 20} 积分`}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col xl:flex-row gap-[22px] min-h-0 flex-1">
|
<div className="flex flex-col xl:flex-row gap-[22px] min-h-0 flex-1">
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||||
import Icon from './Icon';
|
import Icon from './Icon';
|
||||||
|
import { getPointsBalance, getUserProfile, updateUserSettings, type PointsBalance } from '../lib/api';
|
||||||
|
import { useUserSettings } from './AppShell';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
locale: string;
|
locale: string;
|
||||||
@@ -36,12 +38,27 @@ function fromCoins(coins: CoinFace[]): YaoType {
|
|||||||
return fromHuaCount(coins.filter((coin) => coin === 'hua').length);
|
return fromHuaCount(coins.filter((coin) => coin === 'hua').length);
|
||||||
}
|
}
|
||||||
|
|
||||||
function CoinImage({ face, selected }: { face: CoinFace; selected?: boolean }) {
|
// Get a default coin combination for a given YaoType
|
||||||
|
// (multiple combinations can map to the same YaoType, we pick one)
|
||||||
|
function coinsForYaoType(type: YaoType): [CoinFace, CoinFace, CoinFace] {
|
||||||
|
switch (type) {
|
||||||
|
case 'oldYin': // 0 hua
|
||||||
|
return ['zi', 'zi', 'zi'];
|
||||||
|
case 'youngYang': // 1 hua
|
||||||
|
return ['hua', 'zi', 'zi'];
|
||||||
|
case 'youngYin': // 2 hua
|
||||||
|
return ['hua', 'hua', 'zi'];
|
||||||
|
case 'oldYang': // 3 hua
|
||||||
|
return ['hua', 'hua', 'hua'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function CoinImage({ face }: { face: CoinFace }) {
|
||||||
return (
|
return (
|
||||||
<img
|
<img
|
||||||
src={face === 'zi' ? '/images/qigua/zi.jpg' : '/images/qigua/hua.jpg'}
|
src={face === 'zi' ? '/images/qigua/zi.jpg' : '/images/qigua/hua.jpg'}
|
||||||
alt={face === 'zi' ? '字' : '花'}
|
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' : ''}`}
|
className={`h-20 w-20 rounded-full object-cover shadow-md coin-flip ${face === 'hua' ? 'flipped' : ''}`}
|
||||||
draggable={false}
|
draggable={false}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -83,7 +100,7 @@ const copy = {
|
|||||||
['手动起卦', '准备三枚相同的钱币。每次记录一爻,按从下往上的顺序共记录六爻。'],
|
['手动起卦', '准备三枚相同的钱币。每次记录一爻,按从下往上的顺序共记录六爻。'],
|
||||||
['确认时间', '先确认起卦时间。如需调整,点击右侧「修改」。'],
|
['确认时间', '先确认起卦时间。如需调整,点击右侧「修改」。'],
|
||||||
['依次录入六爻', '从初爻开始逐条选择,未完成前下一爻不可点。每条会弹出三枚钱币选择面板。'],
|
['依次录入六爻', '从初爻开始逐条选择,未完成前下一爻不可点。每条会弹出三枚钱币选择面板。'],
|
||||||
['开始分析', '六爻都填完后,「开始解卦」按钮会高亮提示,点击即可解卦。'],
|
['开始分析', '六爻都填完后,下方「分析卦象」按钮会闪烁提示,点击即可解卦。'],
|
||||||
],
|
],
|
||||||
closeGuide: '结束教程',
|
closeGuide: '结束教程',
|
||||||
nextGuide: '下一步',
|
nextGuide: '下一步',
|
||||||
@@ -109,7 +126,7 @@ const copy = {
|
|||||||
['手動起卦', '準備三枚相同的錢幣。每次記錄一爻,按從下往上的順序共記錄六爻。'],
|
['手動起卦', '準備三枚相同的錢幣。每次記錄一爻,按從下往上的順序共記錄六爻。'],
|
||||||
['確認時間', '先確認起卦時間。如需調整,點擊右側「修改」。'],
|
['確認時間', '先確認起卦時間。如需調整,點擊右側「修改」。'],
|
||||||
['依序錄入六爻', '從初爻開始逐條選擇,未完成前下一爻不可點擊。每條會彈出三枚錢幣選擇面板。'],
|
['依序錄入六爻', '從初爻開始逐條選擇,未完成前下一爻不可點擊。每條會彈出三枚錢幣選擇面板。'],
|
||||||
['開始分析', '六爻都填完後,「開始解卦」按鈕會高亮提示,點擊即可解卦。'],
|
['開始分析', '六爻都填完後,下方「分析卦象」按鈕會閃爍提示,點擊即可解卦。'],
|
||||||
],
|
],
|
||||||
closeGuide: '結束教程',
|
closeGuide: '結束教程',
|
||||||
nextGuide: '下一步',
|
nextGuide: '下一步',
|
||||||
@@ -129,21 +146,21 @@ const copy = {
|
|||||||
balance: 'Available 120 credits · This reading 20 credits',
|
balance: 'Available 120 credits · This reading 20 credits',
|
||||||
defaultQuestion: 'What should I pay attention to in my career development over the next three months?',
|
defaultQuestion: 'What should I pay attention to in my career development over the next three months?',
|
||||||
modify: 'Modify',
|
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.'],
|
guideLines: ['Record from the first yao upward.', 'Each yao is determined by the inscription-side and pattern-side combination of three coins.', 'Start interpretation after all six yao are complete.'],
|
||||||
openGuide: 'View Manual Casting Guide',
|
openGuide: 'View Manual Casting Guide',
|
||||||
guideSteps: [
|
guideSteps: [
|
||||||
['Manual Casting', 'Prepare three identical coins. Record one yao at a time, from bottom to top, until all six yao are complete.'],
|
['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.'],
|
['Confirm Time', 'Check the casting time first. Tap "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.'],
|
['Fill Six Yao in Order', 'Start from the first yao and select one row at a time. The next row stays locked until the current row is completed.'],
|
||||||
['Start Interpretation', 'After all six yao are filled, Start Interpretation becomes active. Select it to continue.'],
|
['Start Analysis', 'After all six yao are filled, the "Analyze Hexagram" button will blink. Tap it to start interpretation.'],
|
||||||
],
|
],
|
||||||
closeGuide: 'Finish',
|
closeGuide: 'Finish',
|
||||||
nextGuide: 'Next',
|
nextGuide: 'Next',
|
||||||
prevGuide: 'Back',
|
prevGuide: 'Back',
|
||||||
lineNames: ['First Yao', 'Second Yao', 'Third Yao', 'Fourth Yao', 'Fifth Yao', 'Top Yao'],
|
lineNames: ['First Yao', 'Second Yao', 'Third Yao', 'Fourth Yao', 'Fifth Yao', 'Top Yao'],
|
||||||
pending: 'Pending',
|
pending: 'Pending',
|
||||||
zi: 'Text',
|
zi: 'Inscription',
|
||||||
hua: 'Flower',
|
hua: 'Pattern',
|
||||||
yaoTypeNames: { youngYang: 'Young Yang', youngYin: 'Young Yin', oldYang: 'Old Yang', oldYin: 'Old Yin' },
|
yaoTypeNames: { youngYang: 'Young Yang', youngYin: 'Young Yin', oldYang: 'Old Yang', oldYin: 'Old Yin' },
|
||||||
questionTypePrefix: 'Category',
|
questionTypePrefix: 'Category',
|
||||||
method: 'Method: Manual Casting',
|
method: 'Method: Manual Casting',
|
||||||
@@ -160,11 +177,191 @@ export default function ManualDivinationPage({ locale, divination: d }: Props) {
|
|||||||
const [coins, setCoins] = useState<[CoinFace, CoinFace, CoinFace]>(['zi', 'zi', 'zi']);
|
const [coins, setCoins] = useState<[CoinFace, CoinFace, CoinFace]>(['zi', 'zi', 'zi']);
|
||||||
const [yaoResults, setYaoResults] = useState<YaoType[]>([]);
|
const [yaoResults, setYaoResults] = useState<YaoType[]>([]);
|
||||||
const [guideStep, setGuideStep] = useState<number | null>(null);
|
const [guideStep, setGuideStep] = useState<number | null>(null);
|
||||||
|
const [points, setPoints] = useState<PointsBalance | null>(null);
|
||||||
|
const [editingIndex, setEditingIndex] = useState<number | null>(null);
|
||||||
|
const { userProfile, setUserProfile } = useUserSettings();
|
||||||
|
|
||||||
|
// Refs for guide spotlight positioning
|
||||||
|
// Guide steps: 0=coins area, 1=time, 2=yao panel (full), 3=submit
|
||||||
|
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);
|
||||||
|
|
||||||
|
// Track if first-visit auto-show has been attempted
|
||||||
|
const [tutorialChecked, setTutorialChecked] = useState(false);
|
||||||
|
|
||||||
|
// Auto-show tutorial on first visit
|
||||||
|
useEffect(() => {
|
||||||
|
if (tutorialChecked) return;
|
||||||
|
const tutorialSettings = userProfile?.settings?.divination_tutorial;
|
||||||
|
if (tutorialSettings && !tutorialSettings.manual_divination_shown) {
|
||||||
|
// Delay to let the page render first
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
setTutorialChecked(true);
|
||||||
|
setGuideStep(0);
|
||||||
|
}, 400);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
} else if (userProfile !== null) {
|
||||||
|
// Profile loaded but tutorial already shown
|
||||||
|
setTutorialChecked(true);
|
||||||
|
}
|
||||||
|
}, [userProfile, tutorialChecked]);
|
||||||
|
|
||||||
|
// Mark tutorial as shown when guide ends
|
||||||
|
const closeGuide = async () => {
|
||||||
|
setGuideStep(null);
|
||||||
|
if (userProfile && !userProfile.settings.divination_tutorial.manual_divination_shown) {
|
||||||
|
const updatedSettings = {
|
||||||
|
...userProfile.settings,
|
||||||
|
divination_tutorial: {
|
||||||
|
...userProfile.settings.divination_tutorial,
|
||||||
|
manual_divination_shown: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
const updated = await updateUserSettings({ settings: updatedSettings });
|
||||||
|
setUserProfile(updated);
|
||||||
|
} catch {
|
||||||
|
// Silently fail - tutorial shown state is non-critical
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Track previous guide step to detect initial open
|
||||||
|
const prevGuideStepRef = useRef<number | null>(null);
|
||||||
|
|
||||||
|
// Update spotlight position when guide step changes
|
||||||
|
// Mobile: use absolute positioning relative to scroll container for stability
|
||||||
|
// Desktop: use fixed positioning relative to viewport
|
||||||
|
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;
|
||||||
|
|
||||||
|
// Get scroll container - it's the main element inside AppShell
|
||||||
|
const scrollContainer = scrollContainerRef.current?.parentElement?.parentElement as HTMLElement | null;
|
||||||
|
if (!scrollContainer) return;
|
||||||
|
|
||||||
|
const isInitialOpen = prevGuideStepRef.current === null;
|
||||||
|
|
||||||
|
// ===== MOBILE: Absolute positioning relative to scroll container =====
|
||||||
|
if (isMobile) {
|
||||||
|
// Calculate element's offset relative to scroll container
|
||||||
|
const containerRect = scrollContainer.getBoundingClientRect();
|
||||||
|
const elementRect = targetRef.current.getBoundingClientRect();
|
||||||
|
|
||||||
|
// Element position relative to scroll container (accounts for current scroll)
|
||||||
|
const elementLeft = elementRect.left - containerRect.left;
|
||||||
|
const elementTop = elementRect.top - containerRect.top + scrollContainer.scrollTop;
|
||||||
|
const elementWidth = elementRect.width;
|
||||||
|
const elementHeight = elementRect.height;
|
||||||
|
|
||||||
|
// On initial open, scroll to top first
|
||||||
|
if (isInitialOpen) {
|
||||||
|
scrollContainer.scrollTop = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate where we need to scroll to make both spotlight and tooltip visible
|
||||||
|
// Spotlight should be at top portion, tooltip below it
|
||||||
|
const totalHeight = elementHeight + gap + tooltipHeight;
|
||||||
|
const scrollTopNeeded = Math.max(0, elementTop - 20); // 20px margin above spotlight
|
||||||
|
|
||||||
|
// Smooth scroll to position
|
||||||
|
scrollContainer.scrollTo({ top: scrollTopNeeded, behavior: 'smooth' });
|
||||||
|
|
||||||
|
// Use requestAnimationFrame to ensure scroll has started before calculating final position
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
// Recalculate element position after scroll setup
|
||||||
|
const newElementRect = targetRef.current!.getBoundingClientRect();
|
||||||
|
const newContainerRect = scrollContainer.getBoundingClientRect();
|
||||||
|
|
||||||
|
// Position relative to container (for absolute positioning)
|
||||||
|
const spotlightLeft = newElementRect.left - newContainerRect.left;
|
||||||
|
const spotlightTop = newElementRect.top - newContainerRect.top;
|
||||||
|
|
||||||
|
// Tooltip goes below the element
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== DESKTOP: Fixed positioning relative to viewport =====
|
||||||
|
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(() => {
|
useEffect(() => {
|
||||||
setCategory(cats[0]);
|
setCategory(cats[0]);
|
||||||
}, [cats]);
|
}, [cats]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getPointsBalance().then(setPoints).catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Track mobile state on resize
|
||||||
|
useEffect(() => {
|
||||||
|
const handleResize = () => setIsMobile(window.innerWidth < 1280);
|
||||||
|
window.addEventListener('resize', handleResize);
|
||||||
|
return () => window.removeEventListener('resize', handleResize);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const progress = yaoResults.length;
|
const progress = yaoResults.length;
|
||||||
const currentYaoType = fromCoins(coins);
|
const currentYaoType = fromCoins(coins);
|
||||||
const guideOpen = guideStep !== null;
|
const guideOpen = guideStep !== null;
|
||||||
@@ -179,16 +376,33 @@ export default function ManualDivinationPage({ locale, divination: d }: Props) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const confirmYao = () => {
|
const confirmYao = () => {
|
||||||
|
if (editingIndex !== null) {
|
||||||
|
// Editing an existing yao
|
||||||
|
setYaoResults((current) => {
|
||||||
|
const next = [...current];
|
||||||
|
next[editingIndex] = currentYaoType;
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
setEditingIndex(null);
|
||||||
|
} else {
|
||||||
if (progress >= TOTAL_YAO_COUNT) return;
|
if (progress >= TOTAL_YAO_COUNT) return;
|
||||||
setYaoResults((current) => [...current, currentYaoType]);
|
setYaoResults((current) => [...current, currentYaoType]);
|
||||||
setCoins(['zi', 'zi', 'zi']);
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectYaoRow = (index: number) => {
|
||||||
|
if (guideOpen) return; // Don't allow interaction during guide
|
||||||
|
if (!yaoResults[index]) return; // Not filled yet
|
||||||
|
setEditingIndex(index);
|
||||||
|
// Load the coins state for this yao
|
||||||
|
setCoins(coinsForYaoType(yaoResults[index]));
|
||||||
};
|
};
|
||||||
|
|
||||||
const showPreviousGuide = () => setGuideStep((step) => (step === null ? 0 : Math.max(step - 1, 0)));
|
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)));
|
const showNextGuide = () => setGuideStep((step) => (step === null ? 0 : Math.min(step + 1, text.guideSteps.length - 1)));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative flex min-h-full flex-col gap-[22px]">
|
<div ref={scrollContainerRef} className="relative flex min-h-full flex-col gap-[22px]">
|
||||||
<div className="flex items-center justify-between gap-5">
|
<div className="flex items-center justify-between gap-5">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<h1 className="text-[28px] font-bold leading-tight text-[#333333]">{text.title}</h1>
|
<h1 className="text-[28px] font-bold leading-tight text-[#333333]">{text.title}</h1>
|
||||||
@@ -196,12 +410,29 @@ export default function ManualDivinationPage({ locale, divination: d }: Props) {
|
|||||||
</div>
|
</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">
|
<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" />
|
<Icon name="paid" className="h-[18px] w-[18px] text-violet-700" />
|
||||||
{text.balance}
|
{locale === 'en'
|
||||||
|
? `Available ${points?.availableBalance ?? '...'} credits · This time ${points?.runCost ?? 20} credits`
|
||||||
|
: `可用 ${points?.availableBalance ?? '...'} 积分 · 本次 ${points?.runCost ?? 20} 积分`}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex min-h-0 flex-1 flex-col gap-[22px] xl:flex-row">
|
<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]">
|
<div className="flex w-full shrink-0 flex-col gap-4 xl:w-[360px]">
|
||||||
|
{/* 教程面板 - 手机端显示在最上方 */}
|
||||||
|
<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) => <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-[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>
|
||||||
|
|
||||||
|
{/* 问题面板 */}
|
||||||
<section className="flex h-[300px] flex-col gap-4 rounded-2xl border border-slate-200 bg-white p-[22px]">
|
<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>
|
<h2 className="text-lg font-bold text-slate-900">{d.questionTitle}</h2>
|
||||||
<label className="sr-only" htmlFor="manual-category">{d.categoryLabel}</label>
|
<label className="sr-only" htmlFor="manual-category">{d.categoryLabel}</label>
|
||||||
@@ -221,7 +452,8 @@ export default function ManualDivinationPage({ locale, divination: d }: Props) {
|
|||||||
/>
|
/>
|
||||||
</section>
|
</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'}`}>
|
{/* 时间面板 */}
|
||||||
|
<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>
|
<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">
|
<div className="flex h-[42px] items-center justify-between gap-3 rounded-[10px] bg-slate-50 px-3">
|
||||||
<input
|
<input
|
||||||
@@ -230,25 +462,12 @@ export default function ManualDivinationPage({ locale, divination: d }: Props) {
|
|||||||
onChange={(event) => setSelectedTime(event.target.value)}
|
onChange={(event) => setSelectedTime(event.target.value)}
|
||||||
className="w-full bg-transparent text-sm font-semibold text-[#333333] outline-none"
|
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>
|
<span className="shrink-0 cursor-pointer text-[13px] font-bold text-violet-700 hover:text-violet-800">{text.modify}</span>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</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>
|
</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'}`}>
|
<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">
|
<div className="flex items-center justify-between gap-4">
|
||||||
<h2 className="text-lg font-bold text-slate-900">{d.yaoTitle}</h2>
|
<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>
|
<span className="text-[13px] font-bold text-violet-700">{progress} / {TOTAL_YAO_COUNT}</span>
|
||||||
@@ -257,14 +476,17 @@ export default function ManualDivinationPage({ locale, divination: d }: Props) {
|
|||||||
<div className="flex flex-col gap-2.5">
|
<div className="flex flex-col gap-2.5">
|
||||||
{[5, 4, 3, 2, 1, 0].map((index) => {
|
{[5, 4, 3, 2, 1, 0].map((index) => {
|
||||||
const result = yaoResults[index];
|
const result = yaoResults[index];
|
||||||
const active = index === progress && progress < TOTAL_YAO_COUNT;
|
// Only show "active" highlight when not in editing mode
|
||||||
|
const active = editingIndex === null && index === progress && progress < TOTAL_YAO_COUNT;
|
||||||
const confirmed = !!result;
|
const confirmed = !!result;
|
||||||
|
const editing = editingIndex === index;
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={index}
|
key={index}
|
||||||
className={`flex h-[62px] items-center gap-4 rounded-[10px] px-3.5 ${active ? 'border border-violet-600 bg-violet-50' : confirmed ? 'border border-slate-200 bg-white' : 'bg-slate-50'}`}
|
onClick={() => selectYaoRow(index)}
|
||||||
|
className={`flex h-[62px] items-center gap-4 rounded-[10px] px-3.5 ${editing ? 'border border-violet-600 bg-violet-50' : active ? 'border border-violet-600 bg-violet-50' : confirmed ? 'border border-slate-200 bg-white cursor-pointer hover:border-violet-300' : 'bg-slate-50'}`}
|
||||||
>
|
>
|
||||||
<span className={`w-16 text-sm font-bold ${active || confirmed ? 'text-violet-700' : 'text-slate-400'}`}>{text.lineNames[index]}</span>
|
<span className={`w-16 text-sm font-bold ${active || confirmed || editing ? 'text-violet-700' : 'text-slate-400'}`}>{text.lineNames[index]}</span>
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<YaoGlyph type={result} confirmed={confirmed} />
|
<YaoGlyph type={result} confirmed={confirmed} />
|
||||||
</div>
|
</div>
|
||||||
@@ -276,9 +498,8 @@ export default function ManualDivinationPage({ locale, divination: d }: Props) {
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{progress < TOTAL_YAO_COUNT && (
|
{/* Coins area - always visible; shows editing state or next-yao state */}
|
||||||
<>
|
<div ref={coinsAreaRef} className="flex min-h-[142px] items-center justify-center rounded-xl bg-slate-50 p-4">
|
||||||
<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">
|
<div className="flex items-center justify-center gap-6">
|
||||||
{coins.map((face, index) => (
|
{coins.map((face, index) => (
|
||||||
<button
|
<button
|
||||||
@@ -287,7 +508,7 @@ export default function ManualDivinationPage({ locale, divination: d }: Props) {
|
|||||||
onClick={() => flipCoin(index)}
|
onClick={() => flipCoin(index)}
|
||||||
className="flex w-20 flex-col items-center gap-2 text-[13px] font-bold text-slate-600"
|
className="flex w-20 flex-col items-center gap-2 text-[13px] font-bold text-slate-600"
|
||||||
>
|
>
|
||||||
<CoinImage face={face} selected={face === 'hua'} />
|
<CoinImage face={face} />
|
||||||
<span>{face === 'zi' ? text.zi : text.hua}</span>
|
<span>{face === 'zi' ? text.zi : text.hua}</span>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
@@ -297,12 +518,17 @@ export default function ManualDivinationPage({ locale, divination: d }: Props) {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={confirmYao}
|
onClick={confirmYao}
|
||||||
className="h-10 w-full rounded-full bg-violet-700 text-[13px] font-bold text-white transition-colors hover:bg-violet-800"
|
disabled={progress >= TOTAL_YAO_COUNT && editingIndex === null}
|
||||||
|
className={`h-10 w-full rounded-full text-[13px] font-bold transition-colors ${
|
||||||
|
editingIndex !== null
|
||||||
|
? 'bg-violet-700 text-white hover:bg-violet-800'
|
||||||
|
: progress >= TOTAL_YAO_COUNT
|
||||||
|
? 'cursor-not-allowed bg-slate-300 text-slate-400'
|
||||||
|
: 'bg-violet-700 text-white hover:bg-violet-800'
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
{d.confirmBtn}
|
{editingIndex !== null ? d.confirmBtn : progress >= TOTAL_YAO_COUNT ? d.confirmBtn : d.confirmBtn}
|
||||||
</button>
|
</button>
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</section>
|
</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]">
|
<aside className="flex w-full shrink-0 flex-col gap-[18px] rounded-2xl border border-slate-200 bg-white p-[22px] xl:w-[300px]">
|
||||||
@@ -313,9 +539,10 @@ export default function ManualDivinationPage({ locale, divination: d }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-[#666666]">{text.questionTypePrefix}{locale === 'en' ? ': ' : ':'}{category}</p>
|
<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]">{text.method}</p>
|
||||||
<p className="text-sm text-[#666666]">{d.checkCost}</p>
|
<p className="text-sm text-[#666666]">{locale === 'en' ? `Cost: ${points?.runCost ?? 20} credits` : `解卦消耗:${points?.runCost ?? 20} 积分`}</p>
|
||||||
<div className="flex-1" />
|
<div className="flex-1" />
|
||||||
<button
|
<button
|
||||||
|
ref={submitBtnRef}
|
||||||
type="button"
|
type="button"
|
||||||
disabled={progress < TOTAL_YAO_COUNT}
|
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' : ''}`}
|
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' : ''}`}
|
||||||
@@ -325,33 +552,91 @@ export default function ManualDivinationPage({ locale, divination: d }: Props) {
|
|||||||
</aside>
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{guideOpen && guide && (
|
{/* Guide overlay with spotlight */}
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 p-5">
|
{guideOpen && guide && spotlightRect && (
|
||||||
<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">
|
{/* Dark overlay - fixed for desktop, covers viewport */}
|
||||||
<span className="text-sm font-bold text-violet-200">{guideStep + 1} / {text.guideSteps.length}</span>
|
<div
|
||||||
<button type="button" onClick={() => setGuideStep(null)} className="rounded-full p-1 text-white/70 hover:bg-white/10 hover:text-white">
|
className="fixed inset-0 z-40 bg-black/70 md:block hidden"
|
||||||
<Icon name="close" className="h-5 w-5" />
|
onClick={() => closeGuide()}
|
||||||
|
/>
|
||||||
|
{/* Mobile dark overlay - positioned within scroll container */}
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 z-40 bg-black/70 md:hidden"
|
||||||
|
style={{ top: 0, height: '100vh' }}
|
||||||
|
onClick={() => closeGuide()}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Spotlight on target element - fixed for desktop, absolute for mobile */}
|
||||||
|
<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
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Guide tooltip - fixed for desktop, absolute for mobile */}
|
||||||
|
<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
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Arrow pointing to spotlight */}
|
||||||
|
<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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<h2 className="text-xl font-bold">{guide[0]}</h2>
|
<h3 className="text-base font-bold">{guide[0]}</h3>
|
||||||
<p className="mt-3 text-sm leading-6 text-white/80">{guide[1]}</p>
|
<p className="mt-2 text-sm leading-relaxed text-white/70">{guide[1]}</p>
|
||||||
<div className="mt-6 flex items-center justify-between gap-3">
|
<div className="mt-4 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">
|
<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}
|
{text.prevGuide}
|
||||||
</button>
|
</button>
|
||||||
{guideStep === text.guideSteps.length - 1 ? (
|
{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">
|
<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}
|
{text.closeGuide}
|
||||||
</button>
|
</button>
|
||||||
) : (
|
) : (
|
||||||
<button type="button" onClick={showNextGuide} className="h-10 rounded-full bg-white px-5 text-sm font-bold text-slate-950">
|
<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}
|
{text.nextGuide}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -98,7 +98,7 @@ const translations: Record<Locale, Translations> = {
|
|||||||
store: { title: 'Credits Store', currentPoints: 'Current Credits', pointsLabel: 'credits', rulesTitle: 'Credits Rules', rule1: '1 divination costs a fixed number of credits', rule2: 'Credits are added instantly after purchase', rule3: 'Starter Pack is limited to one per account', popularLabel: 'Recommended', popularText: 'Popular Pack offers the best value for most users.', stepsTitle: 'Payment Steps', step1: 'Select a package', step2: 'Complete payment', step3: 'Credits added automatically', autoCredit: 'Auto-delivery after purchase', sideTitle: 'Auto-delivery after purchase', sideDesc: 'Credits are synced to your account immediately after payment.' },
|
store: { title: 'Credits Store', currentPoints: 'Current Credits', pointsLabel: 'credits', rulesTitle: 'Credits Rules', rule1: '1 divination costs a fixed number of credits', rule2: 'Credits are added instantly after purchase', rule3: 'Starter Pack is limited to one per account', popularLabel: 'Recommended', popularText: 'Popular Pack offers the best value for most users.', stepsTitle: 'Payment Steps', step1: 'Select a package', step2: 'Complete payment', step3: 'Credits added automatically', autoCredit: 'Auto-delivery after purchase', sideTitle: 'Auto-delivery after purchase', sideDesc: 'Credits are synced to your account immediately after payment.' },
|
||||||
settings: { title: 'Settings', profileTitle: 'Profile', emailLabel: 'Email', nameLabel: 'Name', joinedLabel: 'Joined', pointsTitle: 'Credits Balance', pointsBalance: 'credits', accountTitle: 'Account Settings', changeName: 'Change Name', changeAvatar: 'Change Avatar', changeLanguage: 'Change Language', legalTitle: 'Legal', privacy: 'Privacy Policy', terms: 'Terms of Service', logout: 'Sign Out', logoutConfirm: 'Are you sure you want to sign out?' },
|
settings: { title: 'Settings', profileTitle: 'Profile', emailLabel: 'Email', nameLabel: 'Name', joinedLabel: 'Joined', pointsTitle: 'Credits Balance', pointsBalance: 'credits', accountTitle: 'Account Settings', changeName: 'Change Name', changeAvatar: 'Change Avatar', changeLanguage: 'Change Language', legalTitle: 'Legal', privacy: 'Privacy Policy', terms: 'Terms of Service', logout: 'Sign Out', logoutConfirm: 'Are you sure you want to sign out?' },
|
||||||
profile: { avatarTitle: 'Avatar', avatarHint: 'Supports PNG / JPG / WEBP. Square images recommended.', uploadBtn: 'Upload Avatar', formTitle: 'Basic Info', emailLabel: 'Email', displayNameLabel: 'Display Name', displayNamePlaceholder: 'Enter display name', bioLabel: 'Bio', bioPlaceholder: 'Enter your bio', saveBtn: 'Save', cancelBtn: 'Cancel' },
|
profile: { avatarTitle: 'Avatar', avatarHint: 'Supports PNG / JPG / WEBP. Square images recommended.', uploadBtn: 'Upload Avatar', formTitle: 'Basic Info', emailLabel: 'Email', displayNameLabel: 'Display Name', displayNamePlaceholder: 'Enter display name', bioLabel: 'Bio', bioPlaceholder: 'Enter your bio', saveBtn: 'Save', cancelBtn: 'Cancel' },
|
||||||
divination: { questionTitle: 'Ask Your Question', questionPlaceholder: 'Enter your question...', categoryLabel: 'Category', categories: 'Career,Love,Wealth,Fortune,Dreams,Health,Study,Lost Items,Other', timeTitle: 'Casting Time', timeHint: 'Uses current time by default, or pick manually', guideTitle: 'Guide', guideManual: 'Manual casting requires you to toss three coins six times. Follow these steps:\n\n1. Focus on your question\n2. Click coins below to set heads/tails\n3. Toss three coins per line\n4. Repeat six times to complete', guideAuto: 'Auto casting generates a hexagram randomly. Follow these steps:\n\n1. Focus on your question\n2. Click "Shake" button\n3. System generates the hexagram', yaoTitle: 'Six Lines', coinLabel: 'Click coins to set heads/tails', confirmBtn: 'Confirm Line', summaryTitle: 'Review Before Submit', checkCategory: 'Category: Career', checkMethod: 'Method: Manual Cast', checkCost: 'Cost: 20 credits', submitBtn: 'Confirm & Submit', shakeTitle: 'Shake', shakeBtn: 'Shake', hexPreview: 'Hexagram Preview', progressLabel: 'Progress' },
|
divination: { questionTitle: 'Ask Your Question', questionPlaceholder: 'Enter your question...', categoryLabel: 'Category', categories: 'Career,Love,Wealth,Fortune,Dreams,Health,Study,Lost Items,Other', timeTitle: 'Casting Time', timeHint: 'Uses current time by default, or pick manually', guideTitle: 'Guide', guideManual: 'Manual casting requires you to toss three coins six times. Follow these steps:\n\n1. Focus on your question\n2. Click coins below to set inscription/pattern\n3. Toss three coins per line\n4. Repeat six times to complete', guideAuto: 'Auto casting generates a hexagram randomly. Follow these steps:\n\n1. Focus on your question\n2. Click "Shake" button\n3. System generates the hexagram', yaoTitle: 'Six Lines', coinLabel: 'Click coins to set inscription/pattern', confirmBtn: 'Confirm Line', summaryTitle: 'Review Before Submit', checkCategory: 'Category: Career', checkMethod: 'Method: Manual Cast', checkCost: 'Cost: 20 credits', submitBtn: 'Confirm & Submit', shakeTitle: 'Shake', shakeBtn: 'Shake', hexPreview: 'Hexagram Preview', progressLabel: 'Progress' },
|
||||||
history: { title: 'Reading History', statTotal: 'Total', statFollow: 'Follow-up', statLatest: 'Latest', filters: 'Quick Filters', filterAll: 'All', filterCareer: 'Career', filterLove: 'Love', filterWealth: 'Wealth', noResults: 'No readings yet', resultTitle: 'Reading Result', conclusion: 'Conclusion', suggestion: 'Suggestion', analysis: 'Detailed Analysis', focus: 'Key Focus', warning: 'AI-generated interpretation for entertainment only. Not professional advice.', followUpTitle: 'Follow-up', followUpDesc: 'Have questions about the reading? Ask one follow-up for deeper insights.', followUpBtn: 'Start Follow-up', chatTitle: 'Follow-up Chat', chatPlaceholder: 'Enter your follow-up question...', sendBtn: 'Send', relatedActions: 'Related Actions', newDivination: 'New Reading', viewHistory: 'View History', followUpRules: 'Follow-up Rules', followUpRule1: 'One follow-up per reading', followUpRule2: 'Follow-up costs 10 credits', basicInfo: 'Basic Info', ganzhi: 'Stem-Branch Info', hexagramDetail: 'Hexagram Details' },
|
history: { title: 'Reading History', statTotal: 'Total', statFollow: 'Follow-up', statLatest: 'Latest', filters: 'Quick Filters', filterAll: 'All', filterCareer: 'Career', filterLove: 'Love', filterWealth: 'Wealth', noResults: 'No readings yet', resultTitle: 'Reading Result', conclusion: 'Conclusion', suggestion: 'Suggestion', analysis: 'Detailed Analysis', focus: 'Key Focus', warning: 'AI-generated interpretation for entertainment only. Not professional advice.', followUpTitle: 'Follow-up', followUpDesc: 'Have questions about the reading? Ask one follow-up for deeper insights.', followUpBtn: 'Start Follow-up', chatTitle: 'Follow-up Chat', chatPlaceholder: 'Enter your follow-up question...', sendBtn: 'Send', relatedActions: 'Related Actions', newDivination: 'New Reading', viewHistory: 'View History', followUpRules: 'Follow-up Rules', followUpRule1: 'One follow-up per reading', followUpRule2: 'Follow-up costs 10 credits', basicInfo: 'Basic Info', ganzhi: 'Stem-Branch Info', hexagramDetail: 'Hexagram Details' },
|
||||||
general: { title: 'General Settings', languageLabel: 'Language', languageValue: 'Interface Language', privacyTitle: 'Privacy', doNotSell: 'Personalized Ads', doNotSellHint: 'When off, your personal info won\'t be used for ad recommendations', notificationTitle: 'Notifications', allowNotification: 'Allow notifications', saveSuccess: 'Saved successfully', saveFailed: 'Failed to save' },
|
general: { title: 'General Settings', languageLabel: 'Language', languageValue: 'Interface Language', privacyTitle: 'Privacy', doNotSell: 'Personalized Ads', doNotSellHint: 'When off, your personal info won\'t be used for ad recommendations', notificationTitle: 'Notifications', allowNotification: 'Allow notifications', saveSuccess: 'Saved successfully', saveFailed: 'Failed to save' },
|
||||||
feedback: { title: 'Feedback', typeLabel: 'Feedback Type', typeBug: 'Bug', typeSuggestion: 'Suggestion', typeOther: 'Other', contentLabel: 'Content', contentPlaceholder: 'Please describe your issue or suggestion in detail...', imagesLabel: 'Add Screenshots (max 3)', anonymousLabel: 'Do not upload my personal information', anonymousHint: 'If checked, your user ID will not be collected. Device info will still be collected for troubleshooting.', submitBtn: 'Submit Feedback', submitting: 'Submitting...', success: 'Thank you for your feedback. We will process it soon.', error: 'Failed to submit. Please try again.', contentRequired: 'Please enter feedback content', contentTooLong: 'Feedback content cannot exceed 500 characters', tooManyImages: 'Maximum 3 images allowed', imageTooLarge: 'Image size cannot exceed 5MB' },
|
feedback: { title: 'Feedback', typeLabel: 'Feedback Type', typeBug: 'Bug', typeSuggestion: 'Suggestion', typeOther: 'Other', contentLabel: 'Content', contentPlaceholder: 'Please describe your issue or suggestion in detail...', imagesLabel: 'Add Screenshots (max 3)', anonymousLabel: 'Do not upload my personal information', anonymousHint: 'If checked, your user ID will not be collected. Device info will still be collected for troubleshooting.', submitBtn: 'Submit Feedback', submitting: 'Submitting...', success: 'Thank you for your feedback. We will process it soon.', error: 'Failed to submit. Please try again.', contentRequired: 'Please enter feedback content', contentTooLong: 'Feedback content cannot exceed 500 characters', tooManyImages: 'Maximum 3 images allowed', imageTooLarge: 'Image size cannot exceed 5MB' },
|
||||||
|
|||||||
@@ -154,6 +154,16 @@
|
|||||||
background: rgba(139, 92, 246, 0.4);
|
background: rgba(139, 92, 246, 0.4);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Coin flip animation */
|
||||||
|
.coin-flip {
|
||||||
|
transition: transform 0.3s ease-out;
|
||||||
|
transform-style: preserve-3d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.coin-flip.flipped {
|
||||||
|
transform: rotateY(180deg);
|
||||||
|
}
|
||||||
|
|
||||||
/* Feature card hover */
|
/* Feature card hover */
|
||||||
.feature-card {
|
.feature-card {
|
||||||
transition: all 0.5s cubic-bezier(0.22, 1, 0.36, 1);
|
transition: all 0.5s cubic-bezier(0.22, 1, 0.36, 1);
|
||||||
|
|||||||
Reference in New Issue
Block a user