diff --git a/web/src/components/AppShell.tsx b/web/src/components/AppShell.tsx index a2c74fd..8f86306 100644 --- a/web/src/components/AppShell.tsx +++ b/web/src/components/AppShell.tsx @@ -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 Icon from './Icon'; import { getAuth, refreshAccessToken, redirectToLogin, backendLanguageToLocale, type AuthUser } from '../lib/auth'; import { getUserProfile, type UserProfile } from '../lib/api'; +// User settings context +interface UserSettingsContextValue { + userProfile: UserProfile | null; + setUserProfile: (profile: UserProfile | null) => void; +} + +const UserSettingsContext = createContext({ + userProfile: null, + setUserProfile: () => {}, +}); + +export function useUserSettings() { + return useContext(UserSettingsContext); +} + interface NavItem { id: string; icon: string; @@ -114,7 +129,8 @@ export default function AppShell({ locale, brandName, navItems, userName, userEm const shellAvatarUrl = userProfile?.avatar_url; return ( -
+ +
{sidebarOpen && (
setSidebarOpen(false)} /> )} @@ -213,5 +229,6 @@ export default function AppShell({ locale, brandName, navItems, userName, userEm
+
); } diff --git a/web/src/components/AutoDivinationPage.tsx b/web/src/components/AutoDivinationPage.tsx index 9997c52..5cecd4e 100644 --- a/web/src/components/AutoDivinationPage.tsx +++ b/web/src/components/AutoDivinationPage.tsx @@ -1,5 +1,6 @@ -import { useState } from 'react'; +import { useState, useEffect } from 'react'; import Icon from './Icon'; +import { getPointsBalance, type PointsBalance } from '../lib/api'; interface Props { locale: string; @@ -14,6 +15,11 @@ export default function AutoDivinationPage({ locale, divination: d }: Props) { const [progress, setProgress] = useState(0); const [hexLines, setHexLines] = useState([]); const [isShaking, setIsShaking] = useState(false); + const [points, setPoints] = useState(null); + + useEffect(() => { + getPointsBalance().then(setPoints).catch(() => {}); + }, []); const handleShake = () => { setIsShaking(true); @@ -37,7 +43,9 @@ export default function AutoDivinationPage({ locale, divination: d }: Props) {
- {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} 积分`}
diff --git a/web/src/components/ManualDivinationPage.tsx b/web/src/components/ManualDivinationPage.tsx index 8b0cb0f..d7c0440 100644 --- a/web/src/components/ManualDivinationPage.tsx +++ b/web/src/components/ManualDivinationPage.tsx @@ -1,5 +1,7 @@ -import { useEffect, useMemo, useState } from 'react'; +import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; import Icon from './Icon'; +import { getPointsBalance, getUserProfile, updateUserSettings, type PointsBalance } from '../lib/api'; +import { useUserSettings } from './AppShell'; interface Props { locale: string; @@ -36,12 +38,27 @@ function fromCoins(coins: CoinFace[]): YaoType { 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 ( {face ); @@ -83,7 +100,7 @@ const copy = { ['手动起卦', '准备三枚相同的钱币。每次记录一爻,按从下往上的顺序共记录六爻。'], ['确认时间', '先确认起卦时间。如需调整,点击右侧「修改」。'], ['依次录入六爻', '从初爻开始逐条选择,未完成前下一爻不可点。每条会弹出三枚钱币选择面板。'], - ['开始分析', '六爻都填完后,「开始解卦」按钮会高亮提示,点击即可解卦。'], + ['开始分析', '六爻都填完后,下方「分析卦象」按钮会闪烁提示,点击即可解卦。'], ], closeGuide: '结束教程', nextGuide: '下一步', @@ -109,7 +126,7 @@ const copy = { ['手動起卦', '準備三枚相同的錢幣。每次記錄一爻,按從下往上的順序共記錄六爻。'], ['確認時間', '先確認起卦時間。如需調整,點擊右側「修改」。'], ['依序錄入六爻', '從初爻開始逐條選擇,未完成前下一爻不可點擊。每條會彈出三枚錢幣選擇面板。'], - ['開始分析', '六爻都填完後,「開始解卦」按鈕會高亮提示,點擊即可解卦。'], + ['開始分析', '六爻都填完後,下方「分析卦象」按鈕會閃爍提示,點擊即可解卦。'], ], closeGuide: '結束教程', nextGuide: '下一步', @@ -129,21 +146,21 @@ const copy = { 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.'], + 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', 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.'], + ['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 select one row at a time. The next row stays locked until the current row is completed.'], + ['Start Analysis', 'After all six yao are filled, the "Analyze Hexagram" button will blink. Tap it to start interpretation.'], ], 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', + zi: 'Inscription', + hua: 'Pattern', yaoTypeNames: { youngYang: 'Young Yang', youngYin: 'Young Yin', oldYang: 'Old Yang', oldYin: 'Old Yin' }, questionTypePrefix: 'Category', 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 [yaoResults, setYaoResults] = useState([]); const [guideStep, setGuideStep] = useState(null); + const [points, setPoints] = useState(null); + const [editingIndex, setEditingIndex] = useState(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(null); + const timePanelRef = useRef(null); + const yaoPanelRef = useRef(null); + const submitBtnRef = useRef(null); + const scrollContainerRef = useRef(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(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(() => { setCategory(cats[0]); }, [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 currentYaoType = fromCoins(coins); const guideOpen = guideStep !== null; @@ -179,16 +376,33 @@ export default function ManualDivinationPage({ locale, divination: d }: Props) { }; const confirmYao = () => { - if (progress >= TOTAL_YAO_COUNT) return; - setYaoResults((current) => [...current, currentYaoType]); - setCoins(['zi', 'zi', 'zi']); + 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; + setYaoResults((current) => [...current, currentYaoType]); + } + }; + + 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 showNextGuide = () => setGuideStep((step) => (step === null ? 0 : Math.min(step + 1, text.guideSteps.length - 1))); return ( -
+

{text.title}

@@ -196,12 +410,29 @@ export default function ManualDivinationPage({ locale, divination: d }: Props) {
- {text.balance} + {locale === 'en' + ? `Available ${points?.availableBalance ?? '...'} credits · This time ${points?.runCost ?? 20} credits` + : `可用 ${points?.availableBalance ?? '...'} 积分 · 本次 ${points?.runCost ?? 20} 积分`}
+ {/* 教程面板 - 手机端显示在最上方 */} +
+

{d.guideTitle}

+ {text.guideLines.map((line) =>

{line}

)} + +
+ + {/* 问题面板 */}

{d.questionTitle}

@@ -221,7 +452,8 @@ export default function ManualDivinationPage({ locale, divination: d }: Props) { />
-
+ {/* 时间面板 */} +

{d.timeTitle}

setSelectedTime(event.target.value)} className="w-full bg-transparent text-sm font-semibold text-[#333333] outline-none" /> - {text.modify} + {text.modify}
- -
-

{d.guideTitle}

- {text.guideLines.map((line) =>

{line}

)} - -
-
+

{d.yaoTitle}

{progress} / {TOTAL_YAO_COUNT} @@ -257,14 +476,17 @@ export default function ManualDivinationPage({ locale, divination: d }: Props) {
{[5, 4, 3, 2, 1, 0].map((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 editing = editingIndex === index; return (
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'}`} > - {text.lineNames[index]} + {text.lineNames[index]}
@@ -276,33 +498,37 @@ export default function ManualDivinationPage({ locale, divination: d }: Props) { })}
- {progress < TOTAL_YAO_COUNT && ( - <> -
-
- {coins.map((face, index) => ( - - ))} -
-
+ {/* Coins area - always visible; shows editing state or next-yao state */} +
+
+ {coins.map((face, index) => ( + + ))} +
+
- - - )} +

{text.questionTypePrefix}{locale === 'en' ? ': ' : ':'}{category}

{text.method}

-

{d.checkCost}

+

{locale === 'en' ? `Cost: ${points?.runCost ?? 20} credits` : `解卦消耗:${points?.runCost ?? 20} 积分`}

- {guideOpen && guide && ( -
-
-
- {guideStep + 1} / {text.guideSteps.length} -
-

{guide[0]}

-

{guide[1]}

-
- {guideStep === text.guideSteps.length - 1 ? ( - ) : ( - )}
-
+ )}
); diff --git a/web/src/i18n/utils.ts b/web/src/i18n/utils.ts index 89a8155..97c23ed 100644 --- a/web/src/i18n/utils.ts +++ b/web/src/i18n/utils.ts @@ -98,7 +98,7 @@ const translations: Record = { 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?' }, 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' }, 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' }, diff --git a/web/src/styles/animations.css b/web/src/styles/animations.css index 2642c70..9cbfcfd 100644 --- a/web/src/styles/animations.css +++ b/web/src/styles/animations.css @@ -154,6 +154,16 @@ 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 { transition: all 0.5s cubic-bezier(0.22, 1, 0.36, 1);