+ {/* Page Header */}
+
+
+
{g.title}
+
+
+ {/* Language Section */}
+
+
{g.languageLabel}
+
+
+
+ {/* Privacy Section */}
+
+
{g.privacyTitle}
+
+
+
+
{g.doNotSell}
+
{g.doNotSellHint}
+
+
+
+
+
+
+ {/* Notification Section */}
+
+
{g.notificationTitle}
+
+
+
notifications
+
{g.allowNotification}
+
+
+
+
+
+ {/* Language Selection Modal */}
+ {showLanguageModal && (
+
+
+
+
{locale === 'en' ? 'Select Language' : '选择语言'}
+
+
+
+ {languageOptions.map((lang) => (
+
+ ))}
+
+
+
+ )}
+
+ );
+}
diff --git a/web/src/components/LoginForm.tsx b/web/src/components/LoginForm.tsx
index c9ef679..712263d 100644
--- a/web/src/components/LoginForm.tsx
+++ b/web/src/components/LoginForm.tsx
@@ -1,5 +1,6 @@
-import { useState, useCallback } from 'react';
-import { sendOtp, loginWithEmail, ApiError } from '../lib/auth';
+import { useState, useCallback, useEffect } from 'react';
+import { sendOtp, loginWithEmail, getAuth, refreshAccessToken, ApiError, localeToBackendLanguage, backendLanguageToLocale } from '../lib/auth';
+import { getUserProfile } from '../lib/api';
interface LoginFormProps {
locale: string;
@@ -44,6 +45,29 @@ export default function LoginForm({ locale, translations: i18n, privacyUrl, term
const [countdown, setCountdown] = useState(0);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
+ const [checking, setChecking] = useState(true);
+
+ // Check existing auth on mount
+ useEffect(() => {
+ const checkAuth = async () => {
+ const auth = getAuth();
+ if (!auth) {
+ setChecking(false);
+ return;
+ }
+ try {
+ await refreshAccessToken();
+ // Token valid, get profile language and redirect
+ const profile = await getUserProfile();
+ const userLocale = backendLanguageToLocale(profile.settings?.preferences?.language || 'zh-CN');
+ window.location.href = `/${userLocale}/dashboard`;
+ } catch {
+ // Token invalid, clear auth and stay on login page
+ setChecking(false);
+ }
+ };
+ checkAuth();
+ }, [locale]);
const handleSendCode = useCallback(async () => {
if (!email || countdown > 0) return;
@@ -72,8 +96,12 @@ export default function LoginForm({ locale, translations: i18n, privacyUrl, term
setLoading(true);
try {
const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
- await loginWithEmail(email, code, locale, timezone);
- window.location.href = `/${locale}/dashboard`;
+ const language = localeToBackendLanguage(locale);
+ await loginWithEmail(email, code, language, timezone);
+ // Get profile language and redirect to correct locale
+ const profile = await getUserProfile();
+ const userLocale = backendLanguageToLocale(profile.settings?.preferences?.language || language);
+ window.location.href = `/${userLocale}/dashboard`;
} catch (err) {
setError(getErrorMessage(err, locale));
} finally {
@@ -81,6 +109,15 @@ export default function LoginForm({ locale, translations: i18n, privacyUrl, term
}
}, [email, code, agreed, locale]);
+ // Show loading while checking existing auth
+ if (checking) {
+ return (
+
diff --git a/web/src/components/NotificationPage.tsx b/web/src/components/NotificationPage.tsx
index ccff87a..67d107d 100644
--- a/web/src/components/NotificationPage.tsx
+++ b/web/src/components/NotificationPage.tsx
@@ -1,33 +1,240 @@
+import { useEffect, useState, useCallback } from 'react';
+import type { NotificationItem } from '../lib/api';
+import { getNotifications, markNotificationRead, markAllNotificationsRead } from '../lib/api';
+
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 };
- notifications: { title: string; welcomeTitle: string; welcomeBody: string; hexagramTitle: string; hexagramBody: string; creditsTitle: string; creditsBody: string };
+ notifications: { title: string; loading: string; error: string; empty: string; markAllRead: string; markAllReadDone: string };
}
-const MOCK_NOTIFS = [
- { title: 'welcomeTitle', body: 'welcomeBody', unread: true, time: '1天前', timeEn: '1 day ago' },
- { title: 'hexagramTitle', body: 'hexagramBody', unread: true, time: '2天前', timeEn: '2 days ago' },
- { title: 'creditsTitle', body: 'creditsBody', unread: false, time: '3天前', timeEn: '3 days ago' },
- { title: 'welcomeTitle', body: 'welcomeBody', unread: false, time: '5天前', timeEn: '5 days ago' },
- { title: 'creditsTitle', body: 'creditsBody', unread: false, time: '7天前', timeEn: '7 days ago' },
-];
+function formatRelativeTime(dateStr: string, locale: string): string {
+ const date = new Date(dateStr);
+ const now = new Date();
+ const diffMs = now.getTime() - date.getTime();
+ const diffMins = Math.floor(diffMs / (1000 * 60));
+ const diffHours = Math.floor(diffMs / (1000 * 60 * 60));
+ const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
+
+ if (locale === 'en') {
+ if (diffMins < 1) return 'Just now';
+ if (diffMins < 60) return `${diffMins} min ago`;
+ if (diffHours < 24) return `${diffHours}h ago`;
+ if (diffDays === 1) return 'Yesterday';
+ return `${diffDays} days ago`;
+ } else {
+ if (diffMins < 1) return '刚刚';
+ if (diffMins < 60) return `${diffMins}分钟前`;
+ if (diffHours < 24) return `${diffHours}小时前`;
+ if (diffDays === 1) return '昨天';
+ return `${diffDays}天前`;
+ }
+}
+
+function formatFullTime(dateStr: string, locale: string): string {
+ const date = new Date(dateStr);
+ if (locale === 'en') {
+ return date.toLocaleDateString('en-US', {
+ year: 'numeric',
+ month: 'long',
+ day: 'numeric',
+ hour: '2-digit',
+ minute: '2-digit',
+ });
+ }
+ return date.toLocaleDateString('zh-CN', {
+ year: 'numeric',
+ month: 'long',
+ day: 'numeric',
+ hour: '2-digit',
+ minute: '2-digit',
+ });
+}
export default function NotificationPage({ locale, notifications: n }: Props) {
+ const [items, setItems] = useState
([]);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+ const [selectedItem, setSelectedItem] = useState(null);
+ const [markingAll, setMarkingAll] = useState(false);
+ const [toast, setToast] = useState(null);
+
+ const fetchNotifications = useCallback(() => {
+ setLoading(true);
+ setError(null);
+ getNotifications(locale)
+ .then((res) => setItems(res.items))
+ .catch((err) => setError(err.message || n.error))
+ .finally(() => setLoading(false));
+ }, [locale, n.error]);
+
+ useEffect(() => {
+ fetchNotifications();
+ }, [fetchNotifications]);
+
+ useEffect(() => {
+ if (toast) {
+ const timer = setTimeout(() => setToast(null), 2000);
+ return () => clearTimeout(timer);
+ }
+ }, [toast]);
+
+ const handleItemClick = async (item: NotificationItem) => {
+ setSelectedItem(item);
+ if (!item.isRead) {
+ try {
+ const updated = await markNotificationRead(item.id, locale);
+ setItems((prev) => prev.map((i) => (i.id === item.id ? updated : i)));
+ } catch {
+ // ignore mark read error
+ }
+ }
+ };
+
+ const handleMarkAllRead = async () => {
+ const unreadItems = items.filter((i) => !i.isRead);
+ if (unreadItems.length === 0) return;
+
+ setMarkingAll(true);
+ try {
+ await markAllNotificationsRead();
+ setItems((prev) => prev.map((i) => ({ ...i, isRead: true })));
+ setToast(n.markAllReadDone);
+ } catch {
+ // ignore error
+ } finally {
+ setMarkingAll(false);
+ }
+ };
+
+ const closeModal = () => setSelectedItem(null);
+
+ const unreadCount = items.filter((i) => !i.isRead).length;
+
+ if (loading) {
+ return (
+
+
{n.title}
+
{n.loading}
+
+ );
+ }
+
+ if (error) {
+ return (
+
+ );
+ }
+
return (
+ {/* Header */}
+
{n.title}
+ {unreadCount > 0 && (
+
+ )}
+
+
+ {/* List */}
+ {items.length === 0 ? (
+
{n.empty}
+ ) : (
- {MOCK_NOTIFS.map((notif, i) => (
-
- {notif.unread &&
}
+ {items.map((notif) => (
+
+
{formatRelativeTime(notif.createdAt, locale)}
+
))}
+ )}
+
+ {/* Modal Overlay */}
+ {selectedItem && (
+
+ {/* Modal Content */}
+
e.stopPropagation()}
+ >
+ {/* Modal Header */}
+
+
+
{selectedItem.title}
+
{formatFullTime(selectedItem.createdAt, locale)}
+
+
+
+
+ {/* Modal Body */}
+
+ {/* Status Badge */}
+
+
+ {selectedItem.isRead
+ ? (locale === 'en' ? 'Read' : '已读')
+ : (locale === 'en' ? 'Unread' : '未读')}
+
+
+
+ {/* Body */}
+
+ {selectedItem.body}
+
+
+
+ {/* Modal Footer */}
+
+
+
+
+
+ )}
+
+ {/* Toast */}
+ {toast && (
+
+ {toast}
+
+ )}
);
}
diff --git a/web/src/components/ProfileDetailPage.tsx b/web/src/components/ProfileDetailPage.tsx
index beb376e..d13d181 100644
--- a/web/src/components/ProfileDetailPage.tsx
+++ b/web/src/components/ProfileDetailPage.tsx
@@ -1,4 +1,7 @@
-import { useState } from 'react';
+import { useState, useEffect, useRef } from 'react';
+import { useNavigate } from 'react-router-dom';
+import { getUserProfile, updateUserProfile, uploadAvatar, type UserProfile } from '../lib/api';
+import { getAuth } from '../lib/auth';
interface Props {
locale: string;
@@ -6,52 +9,246 @@ interface Props {
profile: { avatarTitle: string; avatarHint: string; uploadBtn: string; formTitle: string; emailLabel: string; displayNameLabel: string; displayNamePlaceholder: string; bioLabel: string; bioPlaceholder: string; saveBtn: string; cancelBtn: string };
}
-export default function ProfileDetailPage({ profile: p }: Props) {
+// Compress image before upload
+async function compressImage(file: File, maxWidth = 512, maxHeight = 512, quality = 0.8): Promise {
+ return new Promise((resolve, reject) => {
+ const img = new Image();
+ img.onload = () => {
+ // Calculate new dimensions
+ let width = img.width;
+ let height = img.height;
+ if (width > maxWidth || height > maxHeight) {
+ const ratio = Math.min(maxWidth / width, maxHeight / height);
+ width = Math.round(width * ratio);
+ height = Math.round(height * ratio);
+ }
+
+ // Create canvas and draw
+ const canvas = document.createElement('canvas');
+ canvas.width = width;
+ canvas.height = height;
+ const ctx = canvas.getContext('2d');
+ if (!ctx) {
+ reject(new Error('Canvas context not available'));
+ return;
+ }
+ ctx.drawImage(img, 0, 0, width, height);
+
+ // Convert to blob
+ canvas.toBlob(
+ (blob) => {
+ if (!blob) {
+ reject(new Error('Failed to compress image'));
+ return;
+ }
+ const compressedFile = new File([blob], file.name.replace(/\.[^.]+$/, '.jpg'), {
+ type: 'image/jpeg',
+ });
+ resolve(compressedFile);
+ },
+ 'image/jpeg',
+ quality
+ );
+ };
+ img.onerror = () => reject(new Error('Failed to load image'));
+ img.src = URL.createObjectURL(file);
+ });
+}
+
+export default function ProfileDetailPage({ locale, profile: p }: Props) {
+ const navigate = useNavigate();
+ const [profile, setProfile] = useState(null);
const [displayName, setDisplayName] = useState('');
const [bio, setBio] = useState('');
+ const [loading, setLoading] = useState(true);
+ const [saving, setSaving] = useState(false);
+ const [uploading, setUploading] = useState(false);
+ const [error, setError] = useState(null);
+ const [success, setSuccess] = useState(null);
+ const fileInputRef = useRef(null);
+
+ // Load profile on mount
+ useEffect(() => {
+ setLoading(true);
+ getUserProfile()
+ .then((data) => {
+ setProfile(data);
+ setDisplayName(data.display_name || '');
+ setBio(data.bio || '');
+ })
+ .catch((err) => setError(err.message || 'Failed to load profile'))
+ .finally(() => setLoading(false));
+ }, []);
+
+ // Clear messages after 3 seconds
+ useEffect(() => {
+ if (success) {
+ const timer = setTimeout(() => setSuccess(null), 3000);
+ return () => clearTimeout(timer);
+ }
+ }, [success]);
+
+ const handleSave = async () => {
+ setSaving(true);
+ setError(null);
+ try {
+ await updateUserProfile({
+ display_name: displayName || undefined,
+ bio: bio || undefined,
+ });
+ // Navigate back to settings on success
+ navigate(-1);
+ } catch (err) {
+ setError(err instanceof Error ? err.message : 'Failed to save');
+ } finally {
+ setSaving(false);
+ }
+ };
+
+ const handleCancel = () => {
+ navigate(-1);
+ };
+
+ const handleAvatarClick = () => {
+ fileInputRef.current?.click();
+ };
+
+ const handleFileChange = async (e: React.ChangeEvent) => {
+ const file = e.target.files?.[0];
+ if (!file) return;
+
+ // Validate file type
+ if (!['image/png', 'image/jpeg', 'image/webp'].includes(file.type)) {
+ setError(locale === 'en' ? 'Only PNG, JPG, WEBP allowed' : '仅支持 PNG、JPG、WEBP');
+ return;
+ }
+
+ setUploading(true);
+ setError(null);
+ try {
+ // Compress image before upload
+ const compressedFile = await compressImage(file, 512, 512, 0.8);
+
+ // Check compressed size (max 2MB after compression)
+ if (compressedFile.size > 2 * 1024 * 1024) {
+ throw new Error(locale === 'en' ? 'Image too large, please choose a smaller one' : '图片太大,请选择更小的图片');
+ }
+
+ const updated = await uploadAvatar(compressedFile);
+ setProfile(updated);
+ setSuccess(locale === 'en' ? 'Avatar updated' : '头像已更新');
+ } catch (err) {
+ setError(err instanceof Error ? err.message : 'Failed to upload');
+ } finally {
+ setUploading(false);
+ // Reset file input
+ if (fileInputRef.current) fileInputRef.current.value = '';
+ }
+ };
+
+ if (loading) {
+ return (
+
+
{locale === 'en' ? 'Loading...' : '加载中...'}
+
+ );
+ }
+
+ const email = getAuth()?.user?.email || 'user@example.com';
return (
{/* Avatar edit */}
-
- U
-
+ {/* Avatar preview */}
+ {profile?.avatar_url ? (
+

+ ) : (
+
+ {(displayName || 'U')[0].toUpperCase()}
+
+ )}
{p.avatarTitle}
{p.avatarHint}
-
+
+
{/* Form */}
{p.formTitle}
+ {/* Error message */}
+ {error && (
+
{error}
+ )}
+
+ {/* Success message */}
+ {success && (
+
{success}
+ )}
+
{/* Email readonly */}
email
{p.emailLabel}
-
user@example.com
+
{email}
{/* Display name */}
- setDisplayName(e.target.value)} placeholder={p.displayNamePlaceholder}
- className="w-full h-11 px-4 rounded-lg bg-slate-50 border border-slate-200 text-sm focus:outline-none focus:border-violet-400 focus:ring-1 focus:ring-violet-400" />
+ setDisplayName(e.target.value)}
+ placeholder={p.displayNamePlaceholder}
+ maxLength={30}
+ className="w-full h-11 px-4 rounded-lg bg-slate-50 border border-slate-200 text-sm focus:outline-none focus:border-violet-400 focus:ring-1 focus:ring-violet-400"
+ />
{/* Bio */}
-
-
+
+
diff --git a/web/src/components/SettingsPage.tsx b/web/src/components/SettingsPage.tsx
index b30cec9..2458bfd 100644
--- a/web/src/components/SettingsPage.tsx
+++ b/web/src/components/SettingsPage.tsx
@@ -1,4 +1,6 @@
+import { useEffect, useState } from 'react';
import { logout } from '../lib/auth';
+import { getUserProfile, getPointsBalance, type UserProfile, type PointsBalance } from '../lib/api';
interface Props {
locale: string;
@@ -7,6 +9,26 @@ interface Props {
}
export default function SettingsPage({ locale, settings: s }: Props) {
+ const [profile, setProfile] = useState(null);
+ const [points, setPoints] = useState(null);
+ const [loading, setLoading] = useState(true);
+
+ useEffect(() => {
+ setLoading(true);
+ Promise.all([
+ getUserProfile(),
+ getPointsBalance(),
+ ])
+ .then(([profileData, pointsData]) => {
+ setProfile(profileData);
+ setPoints(pointsData);
+ })
+ .catch(() => {
+ // ignore errors
+ })
+ .finally(() => setLoading(false));
+ }, []);
+
const handleLogout = () => {
if (confirm(s.logoutConfirm)) {
logout().finally(() => {
@@ -15,62 +37,183 @@ export default function SettingsPage({ locale, settings: s }: Props) {
}
};
+ const displayName = profile?.display_name || profile?.email?.split('@')[0] || 'User';
+ const email = profile?.email || 'user@example.com';
+ const bio = profile?.bio || '';
+
return (
-
{s.title}
+ {/* Page Header */}
+
+
+
{s.title}
+
+ {locale === 'en' ? 'Manage account, preferences, and policies' : '管理账号资料、偏好与协议信息'}
+
+
+
+ verified_user
+
+ {locale === 'en' ? 'Account OK' : '账号正常'}
+
+
+
-
- {/* Left column */}
-
- {/* Profile summary */}
+ {/* Main Content */}
+
+ {/* Left Column */}
+
+ {/* Profile Summary Card */}
-
{s.profileTitle}
-
-
U
-
-
User
-
user@example.com
+
+ {/* Avatar */}
+ {profile?.avatar_url ? (
+

+ ) : (
+
+ {displayName[0].toUpperCase()}
+
+ )}
+ {/* Name & Email */}
+
+
{displayName}
+
{email}
+ {/* Edit Profile Button */}
+
+ edit
+
+ {/* Bio */}
+ {bio && (
+
{bio}
+ )}
- {/* Points */}
-
-
account_balance_wallet
-
+ {/* Points Info */}
+
+
{s.pointsTitle}
+
+ {loading ? '...' : points?.balance ?? 0}
+ {s.pointsBalance}
+
+
+ {/* Arrow */}
+
chevron_right
+
- {/* Right column */}
+ {/* Right Column */}
- {/* Account settings */}
+ {/* Account Settings Panel */}
-
{s.accountTitle}
-
- {s.changeAvatar}
- chevron_right
-
-
- {s.changeName}
- chevron_right
-
-
-
{s.changeLanguage}
-
简体中文
+
{s.accountTitle}
+
- {/* Legal */}
+ {/* Legal Panel */}
- {/* Logout */}
-
- {/* Body: Packages + side panel */}
+ {/* Mobile: Side panel below rules (visible only on mobile) */}
+
+
+
+
+ {/* Body: Packages + side panel (desktop) */}
-
- {pkgs.map((pkg, i) => (
-
-
-
{pkg.name}
- {pkg.badge &&
{pkg.badge}}
+ {loading ? (
+
{s.sideDesc}
+ ) : (
+
+ {packages.map((pkg) => (
+
+
+ {pkg.name}
+ {pkg.badge && {pkg.badge}}
+
+
{pkg.price}
+
{pkg.credits}
+
{pkg.desc}
+
+ {pkg.isStarter && !pkg.starterEligible ? (s.rulesTitle.includes('已购') ? '已购买' : 'Purchased') : p.buyNow}
+
-
{pkg.price}
-
{pkg.credits}
-
{pkg.desc}
-
{p.buyNow}
-
- ))}
-
+ ))}
+
+ )}
- {/* Side panel */}
-
-
- shopping_cart
-
-
{s.sideTitle}
-
{s.sideDesc}
-
-
{s.popularLabel}
-
{s.popularText}
-
{s.stepsTitle}
-
1{s.step1}
-
2{s.step2}
-
3{s.step3}
+ {/* Desktop: Side panel (visible only on xl+) */}
+
+
diff --git a/web/src/components/navConfig.ts b/web/src/components/navConfig.ts
index f2e1176..2135f78 100644
--- a/web/src/components/navConfig.ts
+++ b/web/src/components/navConfig.ts
@@ -19,7 +19,7 @@ export function getNavConfig(locale: string, d: {
{ id: 'auto', label: d.navAuto, href: `/${locale}/divination/auto` },
]},
{ id: 'history', icon: 'history', label: d.navHistory, href: `/${locale}/history` },
- { id: 'language', icon: 'language', label: d.navLanguage, href: '' },
+ { id: 'language', icon: 'language', label: d.navLanguage, href: `/${locale}/settings/general` },
{ id: 'settings', icon: 'settings', label: d.navSettings, href: `/${locale}/settings` },
];
}
diff --git a/web/src/i18n/utils.ts b/web/src/i18n/utils.ts
index 4eac211..89a8155 100644
--- a/web/src/i18n/utils.ts
+++ b/web/src/i18n/utils.ts
@@ -29,12 +29,14 @@ export interface Translations {
about: { title: string; subtitle: string; storyTitle: string; p1: string; p2: string; p3: string; companyInfo: string; company: string; emailLabel: string; email: string; devLabel: string; developer: string; warningTitle: string; warningBody: string; legalTitle: string };
login: { welcome: string; subtitle: string; emailLabel: string; emailPlaceholder: string; codeLabel: string; codePlaceholder: string; sendCode: string; submit: string; agreePrefix: string; privacy: string; agreeAnd: string; terms: string };
dashboard: { brandName: string; navHome: string; navStore: string; navDivination: string; navManual: string; navAuto: string; navHistory: string; navLanguage: string; navSettings: string; greeting: string; greetingSub: string; heroTitle: string; heroDesc: string; heroCta: string; historyTitle: string; historyViewAll: string; logout: string };
- notifications: { title: string; welcomeTitle: string; welcomeBody: string; hexagramTitle: string; hexagramBody: string; creditsTitle: string; creditsBody: string };
+ notifications: { title: string; loading: string; error: string; empty: string; markAllRead: string; markAllReadDone: string };
store: { title: string; currentPoints: string; pointsLabel: string; rulesTitle: string; rule1: string; rule2: string; rule3: string; popularLabel: string; popularText: string; stepsTitle: string; step1: string; step2: string; step3: string; autoCredit: string; sideTitle: string; sideDesc: string };
settings: { title: string; profileTitle: string; emailLabel: string; nameLabel: string; joinedLabel: string; pointsTitle: string; pointsBalance: string; accountTitle: string; changeName: string; changeAvatar: string; changeLanguage: string; legalTitle: string; privacy: string; terms: string; logout: string; logoutConfirm: string };
profile: { avatarTitle: string; avatarHint: string; uploadBtn: string; formTitle: string; emailLabel: string; displayNameLabel: string; displayNamePlaceholder: string; bioLabel: string; bioPlaceholder: string; saveBtn: string; cancelBtn: string };
divination: { questionTitle: string; questionPlaceholder: string; categoryLabel: string; categories: string; timeTitle: string; timeHint: string; guideTitle: string; guideManual: 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 };
history: { title: string; statTotal: string; statFollow: string; statLatest: string; filters: string; filterAll: string; filterCareer: string; filterLove: string; filterWealth: string; noResults: string; resultTitle: string; conclusion: string; suggestion: string; analysis: string; focus: string; warning: string; followUpTitle: string; followUpDesc: string; followUpBtn: string; chatTitle: string; chatPlaceholder: string; sendBtn: string; relatedActions: string; newDivination: string; viewHistory: string; followUpRules: string; followUpRule1: string; followUpRule2: string; basicInfo: string; ganzhi: string; hexagramDetail: string };
+ general: { title: string; languageLabel: string; languageValue: string; privacyTitle: string; doNotSell: string; doNotSellHint: string; notificationTitle: string; allowNotification: string; saveSuccess: string; saveFailed: string };
+ feedback: { title: string; typeLabel: string; typeBug: string; typeSuggestion: string; typeOther: string; contentLabel: string; contentPlaceholder: string; imagesLabel: string; anonymousLabel: string; anonymousHint: string; submitBtn: string; submitting: string; success: string; error: string; contentRequired: string; contentTooLong: string; tooManyImages: string; imageTooLarge: string };
}
const translations: Record
= {
@@ -50,12 +52,14 @@ const translations: Record = {
about: { title: '关于觅爻签问', subtitle: '了解我们的理念与定位', storyTitle: '我们的故事', p1: '觅爻签问是一个借助于 AI 解读传统六爻卦象的平台,为用户了解中国传统易学文化提供一个窗口。六爻卦象源于《周易》深邃的哲学体系,是古人探索世界运行规律的一种独特方法。古人认为宇宙万物相互关联,在你起卦时,你的心念与时空信息会凝结成卦象的方式呈现出来。', p2: '觅爻签问基于这样的思路,帮助你跳出局限思维,从全局和演变趋势看清矛盾、机会与风险,为判断和行动提供参考。', p3: '用 AI 解锁古老智慧,让觅爻签问成为你探索趋势、明晰方向的现代助手。', companyInfo: '公司信息', company: '洵觅科技(深圳)有限公司', emailLabel: '联系邮箱', email: 'xuyunlong@xunmee.com', devLabel: '开发者', developer: 'Ann Lee', warningTitle: '特别提醒', warningBody: '卦象解读结果均由 AI 生成,仅供娱乐参考,切不可作为商业、医疗等专业领域的决策依据。理性看待卦象,自由掌握人生。', legalTitle: '法律条款' },
login: { welcome: '欢迎', subtitle: '使用邮箱验证码快速登录或注册', emailLabel: '邮箱地址', emailPlaceholder: '请输入邮箱地址', codeLabel: '验证码', codePlaceholder: '请输入验证码', sendCode: '获取验证码', submit: '登录 / 注册', agreePrefix: '我已阅读并同意', privacy: '《隐私政策》', agreeAnd: '和', terms: '《服务条款》' },
dashboard: { brandName: '觅爻签问', navHome: '首页', navStore: '商店', navDivination: '起卦', navManual: '手动起卦', navAuto: '自动起卦', navHistory: '历史解卦', navLanguage: '语言', navSettings: '设置', greeting: '下午好', greetingSub: '今天想要探寻什么方向?', heroTitle: '开始您的卦象之旅', heroDesc: '借助AI智能,探索未来的可能。心中有问,起卦便知。', heroCta: '立即起卦', historyTitle: '历史解卦', historyViewAll: '查看全部 →', logout: '退出登录' },
- notifications: { title: '通知中心', welcomeTitle: '欢迎使用觅爻签问', welcomeBody: '感谢您注册觅爻签问!您可以开始使用占卜服务进行卦象咨询了。', hexagramTitle: '卦象已生成 — 今年转岗是否合适?', hexagramBody: '您的卦象已完成,点击查看详细解读。', creditsTitle: '积分到账通知', creditsBody: '您购买的「常用加量包」210积分已到账,可在个人中心查看。' },
+ notifications: { title: '通知中心', loading: '加载中...', error: '加载失败', empty: '暂无通知', markAllRead: '全部已读', markAllReadDone: '已全部标记为已读' },
store: { title: '积分商店', currentPoints: '当前积分', pointsLabel: '积分', rulesTitle: '积分规则', rule1: '1 次起卦会消耗固定积分', rule2: '充值完成后积分实时入账', rule3: '新人专享包每个账号限购一次', popularLabel: '推荐选择', popularText: '常用加量包性价比最高,适合大多数用户日常使用。', stepsTitle: '支付流程', step1: '选择套餐并确认', step2: '完成支付', step3: '积分自动到账', autoCredit: '购买后自动到账', sideTitle: '购买后自动到账', sideDesc: '选择套餐并完成支付后,积分会同步到当前账号。' },
settings: { title: '设置', profileTitle: '个人资料', emailLabel: '邮箱', nameLabel: '昵称', joinedLabel: '注册时间', pointsTitle: '积分余额', pointsBalance: '积分', accountTitle: '账号设置', changeName: '修改昵称', changeAvatar: '修改头像', changeLanguage: '切换语言', legalTitle: '法律条款', privacy: '隐私政策', terms: '服务条款', logout: '退出登录', logoutConfirm: '确定要退出登录吗?' },
profile: { avatarTitle: '头像', avatarHint: '支持 PNG / JPG / WEBP,建议上传清晰正方形头像。', uploadBtn: '上传头像', formTitle: '基础资料', emailLabel: '邮箱', displayNameLabel: '昵称', displayNamePlaceholder: '请输入昵称', bioLabel: '个人简介', bioPlaceholder: '请输入个人简介', saveBtn: '保存', cancelBtn: '取消' },
divination: { questionTitle: '提出你的问题', questionPlaceholder: '请输入你想问的问题...', categoryLabel: '问题类型', categories: '事业,感情,财富,运势,解梦,健康,学业,寻物,其他', timeTitle: '起卦时间', timeHint: '默认使用当前时间,也可手动选择', guideTitle: '起卦指引', guideManual: '手动起卦需要您亲自抛掷三枚铜钱六次,系统会根据结果生成卦象。请按照以下步骤操作:\n\n1. 心中默念问题\n2. 点击下方铜钱选择正反面\n3. 每爻抛掷三枚铜钱\n4. 重复六次完成起卦', guideAuto: '自动起卦由系统随机生成卦象,适合快速获取结果。请按照以下步骤操作:\n\n1. 心中默念问题\n2. 点击"摇卦"按钮\n3. 系统自动生成卦象', yaoTitle: '六爻铜钱', coinLabel: '点击铜钱选择正反面', confirmBtn: '确认此爻', summaryTitle: '提交前检查', checkCategory: '问题类型:事业', checkMethod: '起卦方式:手动起卦', checkCost: '解卦消耗:20 积分', submitBtn: '确认提交', shakeTitle: '摇卦', shakeBtn: '摇一摇', hexPreview: '卦象预览', progressLabel: '完成进度' },
history: { title: '历史解卦', statTotal: '总卦数', statFollow: '可追问', statLatest: '最近一卦', filters: '快速筛选', filterAll: '全部', filterCareer: '事业', filterLove: '感情', filterWealth: '财富', noResults: '暂无解卦记录', resultTitle: '卦象解读', conclusion: '结论', suggestion: '建议', analysis: '详细分析', focus: '重点关注', warning: '以上解读由 AI 生成,仅供参考娱乐,不作为决策依据。', followUpTitle: '追问', followUpDesc: '对卦象有疑问?可以追问一次获取更深入的解读。', followUpBtn: '开始追问', chatTitle: '追问对话', chatPlaceholder: '输入你的追问...', sendBtn: '发送', relatedActions: '相关操作', newDivination: '重新起卦', viewHistory: '查看历史', followUpRules: '追问规则', followUpRule1: '每次解卦可追问一次', followUpRule2: '追问消耗 10 积分', basicInfo: '基本信息', ganzhi: '干支信息', hexagramDetail: '卦象详情' },
+ general: { title: '通用设置', languageLabel: '语言设置', languageValue: '界面语言', privacyTitle: '隐私设置', doNotSell: '个性化广告推荐', doNotSellHint: '关闭后,我们不会将您的个人信息用于广告推荐', notificationTitle: '通知设置', allowNotification: '允许接收通知', saveSuccess: '保存成功', saveFailed: '保存失败' },
+ feedback: { title: '意见反馈', typeLabel: '反馈类型', typeBug: '问题反馈', typeSuggestion: '功能建议', typeOther: '其他', contentLabel: '反馈内容', contentPlaceholder: '请详细描述您的问题或建议...', imagesLabel: '添加截图(最多3张)', anonymousLabel: '不上传我的个人信息', anonymousHint: '勾选后将不采集您的用户ID,仅采集设备信息用于问题排查', submitBtn: '提交反馈', submitting: '提交中...', success: '感谢您的反馈,我们会尽快处理', error: '提交失败,请稍后重试', contentRequired: '请输入反馈内容', contentTooLong: '反馈内容不能超过500字', tooManyImages: '最多只能上传3张图片', imageTooLarge: '图片大小不能超过5MB' },
},
zh_Hant: {
nav: { features: '功能', pricing: '定價', about: '關於', getStarted: '開始使用' },
@@ -69,12 +73,14 @@ const translations: Record = {
about: { title: '關於覓爻簽問', subtitle: '了解我們的理念與定位', storyTitle: '我們的故事', p1: '覓爻簽問是一個借助於 AI 解讀傳統六爻卦象的平台,為用戶了解中國傳統易學文化提供一個窗口。六爻卦象源於《周易》深邃的哲學體系,是古人探索世界運行規律的一種獨特方法。古人認為宇宙萬物相互關聯,在你起卦時,你的心念與時空信息會凝結成卦象的方式呈現出來。', p2: '覓爻簽問基於這樣的思路,幫助你跳出局限思維,從全局和演變趨勢看清矛盾、機會與風險,為判斷和行動提供參考。', p3: '用 AI 解鎖古老智慧,讓覓爻簽問成為你探索趨勢、明晰方向的現代助手。', companyInfo: '公司信息', company: '洵覓科技(深圳)有限公司', emailLabel: '聯繫郵箱', email: 'xuyunlong@xunmee.com', devLabel: '開發者', developer: 'Ann Lee', warningTitle: '特別提醒', warningBody: '卦象解讀結果均由 AI 生成,僅供娛樂參考,切不可作為商業、醫療等專業領域的決策依據。理性看待卦象,自由掌握人生。', legalTitle: '法律條款' },
login: { welcome: '歡迎', subtitle: '使用郵箱驗證碼快速登錄或註冊', emailLabel: '郵箱地址', emailPlaceholder: '請輸入郵箱地址', codeLabel: '驗證碼', codePlaceholder: '請輸入驗證碼', sendCode: '獲取驗證碼', submit: '登錄 / 註冊', agreePrefix: '我已閱讀並同意', privacy: '《隱私政策》', agreeAnd: '和', terms: '《服務條款》' },
dashboard: { brandName: '覓爻簽問', navHome: '首頁', navStore: '商店', navDivination: '起卦', navManual: '手動起卦', navAuto: '自動起卦', navHistory: '歷史解卦', navLanguage: '語言', navSettings: '設置', greeting: '下午好', greetingSub: '今天想要探尋什麼方向?', heroTitle: '開始您的卦象之旅', heroDesc: '借助AI智能,探索未來的可能。心中有問,起卦便知。', heroCta: '立即起卦', historyTitle: '歷史解卦', historyViewAll: '查看全部 →', logout: '退出登錄' },
- notifications: { title: '通知中心', welcomeTitle: '歡迎使用覓爻簽問', welcomeBody: '感謝您註冊覓爻簽問!您可以開始使用占卜服務進行卦象諮詢了。', hexagramTitle: '卦象已生成 — 今年轉崗是否合適?', hexagramBody: '您的卦象已完成,點擊查看詳細解讀。', creditsTitle: '積分到賬通知', creditsBody: '您購買的「常用加量包」210積分已到賬,可在個人中心查看。' },
+ notifications: { title: '通知中心', loading: '加載中...', error: '加載失敗', empty: '暫無通知', markAllRead: '全部已讀', markAllReadDone: '已全部標記為已讀' },
store: { title: '積分商店', currentPoints: '當前積分', pointsLabel: '積分', rulesTitle: '積分規則', rule1: '1 次起卦會消耗固定積分', rule2: '充值完成後積分實時入賬', rule3: '新人專享包每個賬號限購一次', popularLabel: '推薦選擇', popularText: '常用加量包性價比最高,適合大多數用戶日常使用。', stepsTitle: '支付流程', step1: '選擇套餐並確認', step2: '完成支付', step3: '積分自動到賬', autoCredit: '購買後自動到賬', sideTitle: '購買後自動到賬', sideDesc: '選擇套餐並完成支付後,積分會同步到當前賬號。' },
settings: { title: '設置', profileTitle: '個人資料', emailLabel: '郵箱', nameLabel: '暱稱', joinedLabel: '註冊時間', pointsTitle: '積分餘額', pointsBalance: '積分', accountTitle: '賬號設置', changeName: '修改暱稱', changeAvatar: '修改頭像', changeLanguage: '切換語言', legalTitle: '法律條款', privacy: '隱私政策', terms: '服務條款', logout: '退出登錄', logoutConfirm: '確定要退出登錄嗎?' },
profile: { avatarTitle: '頭像', avatarHint: '支持 PNG / JPG / WEBP,建議上傳清晰正方形頭像。', uploadBtn: '上傳頭像', formTitle: '基礎資料', emailLabel: '郵箱', displayNameLabel: '暱稱', displayNamePlaceholder: '請輸入暱稱', bioLabel: '個人簡介', bioPlaceholder: '請輸入個人簡介', saveBtn: '保存', cancelBtn: '取消' },
divination: { questionTitle: '提出你的問題', questionPlaceholder: '請輸入你想問的問題...', categoryLabel: '問題類型', categories: '事業,感情,財富,運勢,解夢,健康,學業,尋物,其他', timeTitle: '起卦時間', timeHint: '默認使用當前時間,也可手動選擇', guideTitle: '起卦指引', guideManual: '手動起卦需要您親自拋擲三枚銅錢六次,系統會根據結果生成卦象。請按照以下步驟操作:\n\n1. 心中默念問題\n2. 點擊下方銅錢選擇正反面\n3. 每爻拋擲三枚銅錢\n4. 重複六次完成起卦', guideAuto: '自動起卦由系統隨機生成卦象,適合快速獲取結果。請按照以下步驟操作:\n\n1. 心中默念問題\n2. 點擊"搖卦"按鈕\n3. 系統自動生成卦象', yaoTitle: '六爻銅錢', coinLabel: '點擊銅錢選擇正反面', confirmBtn: '確認此爻', summaryTitle: '提交前檢查', checkCategory: '問題類型:事業', checkMethod: '起卦方式:手動起卦', checkCost: '解卦消耗:20 積分', submitBtn: '確認提交', shakeTitle: '搖卦', shakeBtn: '搖一搖', hexPreview: '卦象預覽', progressLabel: '完成進度' },
history: { title: '歷史解卦', statTotal: '總卦數', statFollow: '可追問', statLatest: '最近一卦', filters: '快速篩選', filterAll: '全部', filterCareer: '事業', filterLove: '感情', filterWealth: '財富', noResults: '暫無解卦記錄', resultTitle: '卦象解讀', conclusion: '結論', suggestion: '建議', analysis: '詳細分析', focus: '重點關注', warning: '以上解讀由 AI 生成,僅供參考娛樂,不作為決策依據。', followUpTitle: '追問', followUpDesc: '對卦象有疑問?可以追問一次獲取更深入的解讀。', followUpBtn: '開始追問', chatTitle: '追問對話', chatPlaceholder: '輸入你的追問...', sendBtn: '發送', relatedActions: '相關操作', newDivination: '重新起卦', viewHistory: '查看歷史', followUpRules: '追問規則', followUpRule1: '每次解卦可追問一次', followUpRule2: '追問消耗 10 積分', basicInfo: '基本信息', ganzhi: '干支信息', hexagramDetail: '卦象詳情' },
+ general: { title: '通用設定', languageLabel: '語言設置', languageValue: '介面語言', privacyTitle: '隱私設置', doNotSell: '個人化廣告推薦', doNotSellHint: '關閉後,我們不會將您的個人資訊用於廣告推薦', notificationTitle: '通知設置', allowNotification: '允許接收通知', saveSuccess: '保存成功', saveFailed: '保存失敗' },
+ feedback: { title: '意見回饋', typeLabel: '回饋類型', typeBug: '問題回饋', typeSuggestion: '功能建議', typeOther: '其他', contentLabel: '回饋內容', contentPlaceholder: '請詳細描述您的問題或建議...', imagesLabel: '添加截圖(最多3張)', anonymousLabel: '不上傳我的個人信息', anonymousHint: '勾選後將不採集您的用戶ID,僅採集設備信息用於問題排查', submitBtn: '提交回饋', submitting: '提交中...', success: '感謝您的回饋,我們會盡快處理', error: '提交失敗,請稍後重試', contentRequired: '請輸入回饋內容', contentTooLong: '回饋內容不能超過500字', tooManyImages: '最多只能上傳3張圖片', imageTooLarge: '圖片大小不能超過5MB' },
},
en: {
nav: { features: 'Features', pricing: 'Pricing', about: 'About', getStarted: 'Get Started' },
@@ -88,12 +94,14 @@ const translations: Record = {
about: { title: 'About MeiYao Divination', subtitle: 'Our vision and philosophy', storyTitle: 'Our Story', p1: 'MeiYao Divination is an AI-assisted platform for interpreting traditional Six-Line divination and opening a window into classical Chinese wisdom. Six-Line divination originates from the deep philosophical system of the I Ching.', p2: 'MeiYao Divination helps you step outside narrow thinking, understand contradictions, opportunities, and risks from a broader trend perspective, and make calmer, more thoughtful decisions.', p3: 'Unlock ancient wisdom with AI. Let MeiYao Divination be your modern companion for exploring trends and finding direction.', companyInfo: 'Company', company: 'Xunmee Technology (Shenzhen) Co., Ltd.', emailLabel: 'Email', email: 'xuyunlong@xunmee.com', devLabel: 'Developer', developer: 'Ann Lee', warningTitle: 'Important Notice', warningBody: 'All divination interpretations are AI-generated for entertainment only. Do not use them as professional advice for business, medical, or legal decisions.', legalTitle: 'Legal' },
login: { welcome: 'Welcome', subtitle: 'Sign in or sign up with email verification code', emailLabel: 'Email', emailPlaceholder: 'Enter your email', codeLabel: 'Verification Code', codePlaceholder: 'Enter code', sendCode: 'Send Code', submit: 'Sign In / Sign Up', agreePrefix: 'I have read and agree to the ', privacy: 'Privacy Policy', agreeAnd: ' and ', terms: 'Terms of Service' },
dashboard: { brandName: 'MeiYao Divination', navHome: 'Home', navStore: 'Store', navDivination: 'Divination', navManual: 'Manual Cast', navAuto: 'Auto Cast', navHistory: 'History', navLanguage: 'Language', navSettings: 'Settings', greeting: 'Good afternoon', greetingSub: 'What would you like to explore today?', heroTitle: 'Begin Your Hexagram Journey', heroDesc: 'Explore future possibilities with AI. Ask your question, cast your hexagram.', heroCta: 'Start Divination', historyTitle: 'Recent Readings', historyViewAll: 'View All →', logout: 'Sign Out' },
- notifications: { title: 'Notifications', welcomeTitle: 'Welcome to MeiYao Divination', welcomeBody: 'Thank you for registering! You can now start using our divination services.', hexagramTitle: 'Hexagram Ready — Is a career change right?', hexagramBody: 'Your hexagram is complete. Click to view the detailed interpretation.', creditsTitle: 'Credits Received', creditsBody: 'Your Popular Pack of 210 credits has arrived. Check your profile.' },
+ notifications: { title: 'Notifications', loading: 'Loading...', error: 'Failed to load', empty: 'No notifications', markAllRead: 'Mark All Read', markAllReadDone: 'All marked as read' },
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' },
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/lib/api-routes.ts b/web/src/lib/api-routes.ts
index e66a122..ee71874 100644
--- a/web/src/lib/api-routes.ts
+++ b/web/src/lib/api-routes.ts
@@ -7,14 +7,25 @@ export const API_ROUTES = {
},
users: {
profile: '/api/v1/users/me/profile',
+ updateProfile: '/api/v1/users/me/profile',
+ updateSettings: '/api/v1/users/me/settings',
+ avatarUploadUrl: '/api/v1/users/me/avatar/upload-url',
+ uploadAvatar: '/api/v1/users/me/avatar',
},
points: {
balance: '/api/v1/points/balance',
+ packages: '/api/v1/points/packages',
},
notifications: {
+ list: '/api/v1/notifications',
unreadCount: '/api/v1/notifications/unread-count',
+ markRead: (id: string) => `/api/v1/notifications/${id}/read`,
+ markAllRead: '/api/v1/notifications/mark-all-read',
},
agent: {
history: '/api/v1/agent/history',
},
+ feedback: {
+ submit: '/api/v1/feedback',
+ },
} as const;
diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts
index ab3fffd..76a5eb7 100644
--- a/web/src/lib/api.ts
+++ b/web/src/lib/api.ts
@@ -11,22 +11,110 @@ import { API_ROUTES } from './api-routes';
export interface UserProfile {
user_id: string;
display_name: string;
- bio: string;
+ bio: string | null;
avatar_path: string | null;
avatar_url: string | null;
settings: {
+ version: number;
preferences: {
language: string;
timezone: string;
};
+ privacy: {
+ can_sell: boolean;
+ profile_visibility: string;
+ };
+ notification: {
+ allow_notifications: boolean;
+ allow_vibration: boolean;
+ };
+ divination_tutorial: {
+ divination_entry_shown: boolean;
+ auto_divination_shown: boolean;
+ manual_divination_shown: boolean;
+ };
};
updated_at: string;
}
+export interface UpdateProfileRequest {
+ display_name?: string;
+ bio?: string;
+ avatar_path?: string;
+}
+
+export interface ProfileSettings {
+ version: number;
+ preferences: {
+ language: string;
+ timezone: string;
+ };
+ privacy: {
+ can_sell: boolean;
+ profile_visibility: string;
+ };
+ notification: {
+ allow_notifications: boolean;
+ allow_vibration: boolean;
+ };
+ divination_tutorial: {
+ divination_entry_shown: boolean;
+ auto_divination_shown: boolean;
+ manual_divination_shown: boolean;
+ };
+}
+
+export interface UpdateSettingsRequest {
+ settings: ProfileSettings;
+}
+
+export interface AvatarUploadUrlRequest {
+ mime_type: string;
+ file_size: number;
+ ext: string;
+}
+
+export interface AvatarUploadUrlResponse {
+ bucket: string;
+ path: string;
+ upload_url: string;
+ expires_in: number;
+}
+
export function getUserProfile(): Promise {
return authFetch(API_ROUTES.users.profile);
}
+export function updateUserProfile(data: UpdateProfileRequest): Promise {
+ return authFetch(API_ROUTES.users.updateProfile, {
+ method: 'PATCH',
+ body: JSON.stringify(data),
+ });
+}
+
+export function getAvatarUploadUrl(data: AvatarUploadUrlRequest): Promise {
+ return authFetch(API_ROUTES.users.avatarUploadUrl, {
+ method: 'POST',
+ body: JSON.stringify(data),
+ });
+}
+
+export function uploadAvatar(file: File): Promise {
+ const formData = new FormData();
+ formData.append('file', file);
+ return authFetch(API_ROUTES.users.uploadAvatar, {
+ method: 'POST',
+ body: formData,
+ });
+}
+
+export function updateUserSettings(data: UpdateSettingsRequest): Promise {
+ return authFetch(API_ROUTES.users.updateSettings, {
+ method: 'PATCH',
+ body: JSON.stringify(data),
+ });
+}
+
// --- Points ---
export interface PointsBalance {
@@ -37,20 +125,111 @@ export interface PointsBalance {
canRun: boolean;
}
-export function getPointsBalance(): Promise {
- return authFetch(API_ROUTES.points.balance);
+export interface PackageInfo {
+ productCode: string;
+ appStoreProductId: string;
+ type: 'starter' | 'regular';
+ credits: number;
+ isStarter: boolean;
+ starterEligible: boolean;
+ sortOrder: number;
+}
+
+export interface PackagesResponse {
+ packages: PackageInfo[];
+}
+
+// Points cache with TTL
+let pointsCache: { data: PointsBalance; expiry: number } | null = null;
+const POINTS_CACHE_TTL = 60 * 1000; // 1 minute
+
+export function getPointsBalance(useCache = true): Promise {
+ const now = Date.now();
+ if (useCache && pointsCache && pointsCache.expiry > now) {
+ return Promise.resolve(pointsCache.data);
+ }
+ return authFetch(API_ROUTES.points.balance).then((data) => {
+ pointsCache = { data, expiry: now + POINTS_CACHE_TTL };
+ return data;
+ });
+}
+
+export function invalidatePointsCache(): void {
+ pointsCache = null;
+}
+
+export function getPackages(): Promise {
+ return authFetch(API_ROUTES.points.packages);
}
// --- Notifications ---
+export interface NotificationPayloadNone {
+ action: 'none';
+}
+
+export interface NotificationPayloadRoute {
+ action: 'open_route';
+ route: string;
+ entity_id?: string | null;
+ tab?: string | null;
+}
+
+export interface NotificationPayloadUrl {
+ action: 'open_url';
+ url: string;
+}
+
+export type NotificationPayload = NotificationPayloadNone | NotificationPayloadRoute | NotificationPayloadUrl;
+
+export interface NotificationItem {
+ id: string;
+ notificationId: string;
+ type: string;
+ title: string;
+ body: string;
+ payload: NotificationPayload;
+ isRead: boolean;
+ readAt: string | null;
+ createdAt: string;
+}
+
+export interface NotificationListResponse {
+ items: NotificationItem[];
+ nextCursor: string | null;
+ hasMore: boolean;
+}
+
export interface UnreadCount {
count: number;
}
+export function getNotifications(locale?: string, limit = 20, cursor?: string): Promise {
+ const params = new URLSearchParams();
+ params.set('limit', String(limit));
+ if (locale) params.set('locale', locale);
+ if (cursor) params.set('cursor', cursor);
+ const query = params.toString();
+ return authFetch(`${API_ROUTES.notifications.list}?${query}`);
+}
+
export function getUnreadNotificationCount(): Promise {
return authFetch(API_ROUTES.notifications.unreadCount);
}
+export function markNotificationRead(id: string, locale?: string): Promise {
+ const params = locale ? `?locale=${locale}` : '';
+ return authFetch(API_ROUTES.notifications.markRead(id) + params, {
+ method: 'PATCH',
+ });
+}
+
+export function markAllNotificationsRead(): Promise<{ updatedCount: number }> {
+ return authFetch<{ updatedCount: number }>(API_ROUTES.notifications.markAllRead, {
+ method: 'PATCH',
+ });
+}
+
// --- Agent History ---
export interface HistoryAgentOutput {
diff --git a/web/src/lib/auth.ts b/web/src/lib/auth.ts
index 7c215d5..d9d84d2 100644
--- a/web/src/lib/auth.ts
+++ b/web/src/lib/auth.ts
@@ -30,6 +30,34 @@ interface SessionResponse {
user: { id: string; email: string };
}
+// --- Language mapping ---
+
+/**
+ * Map frontend locale to backend BCP-47 language tag
+ */
+export function localeToBackendLanguage(locale: string): string {
+ const mapping: Record = {
+ 'zh': 'zh-CN',
+ 'zh_Hant': 'zh-TW',
+ 'en': 'en-US',
+ };
+ return mapping[locale] || 'zh-CN';
+}
+
+/**
+ * Map backend BCP-47 language tag to frontend locale
+ */
+export function backendLanguageToLocale(lang: string): string {
+ const mapping: Record = {
+ 'zh-CN': 'zh',
+ 'zh-TW': 'zh_Hant',
+ 'zh-Hant': 'zh_Hant',
+ 'en-US': 'en',
+ 'en': 'en',
+ };
+ return mapping[lang] || 'zh';
+}
+
// --- Storage ---
export function getAuth(): AuthData | null {
diff --git a/web/src/pages/en/settings/feedback.astro b/web/src/pages/en/settings/feedback.astro
new file mode 100644
index 0000000..bb4cc2a
--- /dev/null
+++ b/web/src/pages/en/settings/feedback.astro
@@ -0,0 +1,23 @@
+---
+import App from '../../../layouts/App.astro';
+import DashboardApp from '../../../components/DashboardApp';
+import { t } from '../../../i18n/utils';
+
+const locale = 'en' as const;
+const translations = {
+ dashboard: t(locale, 'dashboard'),
+ store: t(locale, 'store'),
+ pricing: t(locale, 'pricing'),
+ history: t(locale, 'history'),
+ notifications: t(locale, 'notifications'),
+ profile: t(locale, 'profile'),
+ settings: t(locale, 'settings'),
+ divination: t(locale, 'divination'),
+ general: t(locale, 'general'),
+ feedback: t(locale, 'feedback'),
+};
+---
+
+
+
+
diff --git a/web/src/pages/en/settings/general.astro b/web/src/pages/en/settings/general.astro
new file mode 100644
index 0000000..bb4cc2a
--- /dev/null
+++ b/web/src/pages/en/settings/general.astro
@@ -0,0 +1,23 @@
+---
+import App from '../../../layouts/App.astro';
+import DashboardApp from '../../../components/DashboardApp';
+import { t } from '../../../i18n/utils';
+
+const locale = 'en' as const;
+const translations = {
+ dashboard: t(locale, 'dashboard'),
+ store: t(locale, 'store'),
+ pricing: t(locale, 'pricing'),
+ history: t(locale, 'history'),
+ notifications: t(locale, 'notifications'),
+ profile: t(locale, 'profile'),
+ settings: t(locale, 'settings'),
+ divination: t(locale, 'divination'),
+ general: t(locale, 'general'),
+ feedback: t(locale, 'feedback'),
+};
+---
+
+
+
+
diff --git a/web/src/pages/zh/settings/feedback.astro b/web/src/pages/zh/settings/feedback.astro
new file mode 100644
index 0000000..c4b6400
--- /dev/null
+++ b/web/src/pages/zh/settings/feedback.astro
@@ -0,0 +1,23 @@
+---
+import App from '../../../layouts/App.astro';
+import DashboardApp from '../../../components/DashboardApp';
+import { t } from '../../../i18n/utils';
+
+const locale = 'zh' as const;
+const translations = {
+ dashboard: t(locale, 'dashboard'),
+ store: t(locale, 'store'),
+ pricing: t(locale, 'pricing'),
+ history: t(locale, 'history'),
+ notifications: t(locale, 'notifications'),
+ profile: t(locale, 'profile'),
+ settings: t(locale, 'settings'),
+ divination: t(locale, 'divination'),
+ general: t(locale, 'general'),
+ feedback: t(locale, 'feedback'),
+};
+---
+
+
+
+
diff --git a/web/src/pages/zh/settings/general.astro b/web/src/pages/zh/settings/general.astro
new file mode 100644
index 0000000..c4b6400
--- /dev/null
+++ b/web/src/pages/zh/settings/general.astro
@@ -0,0 +1,23 @@
+---
+import App from '../../../layouts/App.astro';
+import DashboardApp from '../../../components/DashboardApp';
+import { t } from '../../../i18n/utils';
+
+const locale = 'zh' as const;
+const translations = {
+ dashboard: t(locale, 'dashboard'),
+ store: t(locale, 'store'),
+ pricing: t(locale, 'pricing'),
+ history: t(locale, 'history'),
+ notifications: t(locale, 'notifications'),
+ profile: t(locale, 'profile'),
+ settings: t(locale, 'settings'),
+ divination: t(locale, 'divination'),
+ general: t(locale, 'general'),
+ feedback: t(locale, 'feedback'),
+};
+---
+
+
+
+
diff --git a/web/src/pages/zh_Hant/settings/feedback.astro b/web/src/pages/zh_Hant/settings/feedback.astro
new file mode 100644
index 0000000..a04fe0d
--- /dev/null
+++ b/web/src/pages/zh_Hant/settings/feedback.astro
@@ -0,0 +1,23 @@
+---
+import App from '../../../layouts/App.astro';
+import DashboardApp from '../../../components/DashboardApp';
+import { t } from '../../../i18n/utils';
+
+const locale = 'zh_Hant' as const;
+const translations = {
+ dashboard: t(locale, 'dashboard'),
+ store: t(locale, 'store'),
+ pricing: t(locale, 'pricing'),
+ history: t(locale, 'history'),
+ notifications: t(locale, 'notifications'),
+ profile: t(locale, 'profile'),
+ settings: t(locale, 'settings'),
+ divination: t(locale, 'divination'),
+ general: t(locale, 'general'),
+ feedback: t(locale, 'feedback'),
+};
+---
+
+
+
+
diff --git a/web/src/pages/zh_Hant/settings/general.astro b/web/src/pages/zh_Hant/settings/general.astro
new file mode 100644
index 0000000..a04fe0d
--- /dev/null
+++ b/web/src/pages/zh_Hant/settings/general.astro
@@ -0,0 +1,23 @@
+---
+import App from '../../../layouts/App.astro';
+import DashboardApp from '../../../components/DashboardApp';
+import { t } from '../../../i18n/utils';
+
+const locale = 'zh_Hant' as const;
+const translations = {
+ dashboard: t(locale, 'dashboard'),
+ store: t(locale, 'store'),
+ pricing: t(locale, 'pricing'),
+ history: t(locale, 'history'),
+ notifications: t(locale, 'notifications'),
+ profile: t(locale, 'profile'),
+ settings: t(locale, 'settings'),
+ divination: t(locale, 'divination'),
+ general: t(locale, 'general'),
+ feedback: t(locale, 'feedback'),
+};
+---
+
+
+
+