feat(web): add settings sub-pages and connect to backend APIs

- Add GeneralSettingsPage for language, privacy, and notification settings
- Add FeedbackPage for user feedback submission with image upload
- Connect settings to backend PATCH /users/me/settings API
- Implement language preference sync between frontend and backend
- Update login flow to pass language preference and redirect based on user settings
- Add Astro entry pages for /settings/general and /settings/feedback routes
- Update sidebar navigation: language button links to general settings
- Fix account data card to link to profile page
- Remove "deletion" text from account data description
This commit is contained in:
zl-q
2026-05-09 18:23:21 +08:00
parent 5aa46d3311
commit 1fbb07f692
21 changed files with 1621 additions and 118 deletions
+3
View File
@@ -26,6 +26,9 @@ function cleanPath(path: string): string {
function getActiveNav(items: NavItem[], locale: string, pathname?: string): string {
const path = cleanPath(pathname ?? (typeof window === 'undefined' ? '' : window.location.pathname));
// Profile page and settings sub-pages are part of settings
if (path === `/${locale}/profile`) return 'settings';
if (path.startsWith(`/${locale}/settings`)) return 'settings';
for (const item of items) {
if (item.sub) {
for (const sub of item.sub) {
+8
View File
@@ -9,6 +9,8 @@ import HistoryFollowUpPage from './HistoryFollowUpPage';
import NotificationPage from './NotificationPage';
import ProfileDetailPage from './ProfileDetailPage';
import SettingsPage from './SettingsPage';
import GeneralSettingsPage from './GeneralSettingsPage';
import FeedbackPage from './FeedbackPage';
import ManualDivinationPage from './ManualDivinationPage';
import AutoDivinationPage from './AutoDivinationPage';
import { getNavConfig } from './navConfig';
@@ -26,6 +28,8 @@ interface DashboardAppProps {
profile: TranslationMap;
settings: TranslationMap;
divination: TranslationMap;
general: TranslationMap;
feedback: TranslationMap;
};
}
@@ -36,6 +40,8 @@ const APP_PATHS = [
'/notifications',
'/profile',
'/settings',
'/settings/general',
'/settings/feedback',
'/divination/manual',
'/divination/auto',
];
@@ -87,6 +93,8 @@ function DashboardRoutes({ locale, translations }: DashboardAppProps) {
<Route path={`/${locale}/notifications`} element={<NotificationPage locale={locale} dashboard={dashboard} notifications={translations.notifications} />} />
<Route path={`/${locale}/profile`} element={<ProfileDetailPage locale={locale} dashboard={dashboard} profile={translations.profile} />} />
<Route path={`/${locale}/settings`} element={<SettingsPage locale={locale} dashboard={dashboard} settings={translations.settings} />} />
<Route path={`/${locale}/settings/general`} element={<GeneralSettingsPage locale={locale} general={translations.general} />} />
<Route path={`/${locale}/settings/feedback`} element={<FeedbackPage locale={locale} feedback={translations.feedback} />} />
<Route path={`/${locale}/divination/manual`} element={<ManualDivinationPage locale={locale} dashboard={dashboard} divination={translations.divination} />} />
<Route path={`/${locale}/divination/auto`} element={<AutoDivinationPage locale={locale} dashboard={dashboard} divination={translations.divination} />} />
<Route path="*" element={<Navigate to={`/${locale}/dashboard`} replace />} />
@@ -17,6 +17,8 @@ const translations = {
profile: t(locale, 'profile'),
settings: t(locale, 'settings'),
divination: t(locale, 'divination'),
general: t(locale, 'general'),
feedback: t(locale, 'feedback'),
};
---
+214
View File
@@ -0,0 +1,214 @@
import { useState, useRef } from 'react';
import { useNavigate } from 'react-router-dom';
import { authFetch } from '../lib/auth';
import { API_ROUTES } from '../lib/api-routes';
import { apiUrl, jsonHeaders } from '../lib/api-client';
interface Props {
locale: 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 };
}
type FeedbackType = 'bug' | 'suggestion' | 'other';
const MAX_IMAGES = 3;
const MAX_CONTENT_SIZE = 500;
const MAX_IMAGE_SIZE = 5 * 1024 * 1024;
export default function FeedbackPage({ locale, feedback: f }: Props) {
const navigate = useNavigate();
const [selectedType, setSelectedType] = useState<FeedbackType>('bug');
const [content, setContent] = useState('');
const [images, setImages] = useState<File[]>([]);
const [isAnonymous, setIsAnonymous] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [toast, setToast] = useState<{ type: 'success' | 'error'; message: string } | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const typeOptions: { value: FeedbackType; label: string }[] = [
{ value: 'bug', label: f.typeBug },
{ value: 'suggestion', label: f.typeSuggestion },
{ value: 'other', label: f.typeOther },
];
const handleImagePick = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
if (images.length >= MAX_IMAGES) {
setToast({ type: 'error', message: f.tooManyImages });
return;
}
if (file.size > MAX_IMAGE_SIZE) {
setToast({ type: 'error', message: f.imageTooLarge });
return;
}
setImages([...images, file]);
if (fileInputRef.current) fileInputRef.current.value = '';
};
const handleRemoveImage = (index: number) => {
setImages(images.filter((_, i) => i !== index));
};
const handleSubmit = async () => {
const trimmedContent = content.trim();
if (!trimmedContent) {
setToast({ type: 'error', message: f.contentRequired });
return;
}
if (trimmedContent.length > MAX_CONTENT_SIZE) {
setToast({ type: 'error', message: f.contentTooLong });
return;
}
setSubmitting(true);
setToast(null);
try {
const formData = new FormData();
formData.append('feedback_type', selectedType);
formData.append('content', trimmedContent);
formData.append('device_info', JSON.stringify({ platform: 'web', model: navigator.userAgent }));
formData.append('app_version', '1.0.0');
formData.append('os_version', navigator.platform);
for (const image of images) {
formData.append('images', image);
}
if (isAnonymous) {
// Anonymous submission - no auth header
const res = await fetch(apiUrl(API_ROUTES.feedback.submit), {
method: 'POST',
body: formData,
});
if (!res.ok) {
throw new Error('Submit failed');
}
} else {
// Authenticated submission
await authFetch(API_ROUTES.feedback.submit, {
method: 'POST',
body: formData,
});
}
setToast({ type: 'success', message: f.success });
setTimeout(() => navigate(-1), 1500);
} catch {
setToast({ type: 'error', message: f.error });
} finally {
setSubmitting(false);
}
};
return (
<div className="flex flex-col gap-6 min-h-full">
{/* Page Header */}
<div className="flex items-center gap-3">
<button
onClick={() => navigate(-1)}
className="w-8 h-8 rounded-lg bg-white border border-slate-200 flex items-center justify-center hover:bg-slate-50 transition-colors"
>
<span className="material-symbols-rounded text-slate-500 text-lg">arrow_back</span>
</button>
<h1 className="text-slate-900 text-xl font-bold">{f.title}</h1>
</div>
{/* Toast */}
{toast && (
<div className={`px-4 py-3 rounded-lg text-sm ${toast.type === 'success' ? 'bg-green-50 text-green-600' : 'bg-red-50 text-red-500'}`}>
{toast.message}
</div>
)}
{/* Feedback Type */}
<div className="bg-white rounded-2xl p-6 border border-slate-200 flex flex-col gap-4">
<h3 className="text-slate-900 text-lg font-bold">{f.typeLabel}</h3>
<div className="flex gap-2">
{typeOptions.map((opt) => (
<button
key={opt.value}
onClick={() => setSelectedType(opt.value)}
className={`flex-1 px-4 py-2.5 rounded-lg text-sm font-medium transition-colors ${selectedType === opt.value ? 'bg-violet-600 text-white' : 'bg-slate-100 text-slate-600 hover:bg-slate-200'}`}
>
{opt.label}
</button>
))}
</div>
</div>
{/* Content */}
<div className="bg-white rounded-2xl p-6 border border-slate-200 flex flex-col gap-4">
<h3 className="text-slate-900 text-lg font-bold">{f.contentLabel}</h3>
<textarea
value={content}
onChange={(e) => setContent(e.target.value)}
placeholder={f.contentPlaceholder}
rows={6}
maxLength={MAX_CONTENT_SIZE}
className="w-full px-4 py-3 rounded-xl bg-slate-50 border border-slate-200 text-sm focus:outline-none focus:border-violet-400 focus:ring-1 focus:ring-violet-400 resize-none"
/>
<p className="text-slate-400 text-xs text-right">{content.length}/{MAX_CONTENT_SIZE}</p>
</div>
{/* Images */}
<div className="bg-white rounded-2xl p-6 border border-slate-200 flex flex-col gap-4">
<h3 className="text-slate-900 text-lg font-bold">{f.imagesLabel}</h3>
<div className="flex flex-wrap gap-3">
{images.map((img, idx) => (
<div key={idx} className="relative w-20 h-20 rounded-xl border border-slate-200 overflow-hidden bg-slate-50">
<img src={URL.createObjectURL(img)} alt="Preview" className="w-full h-full object-cover" />
<button
onClick={() => handleRemoveImage(idx)}
className="absolute top-1 right-1 w-5 h-5 rounded-full bg-red-500 text-white flex items-center justify-center text-xs hover:bg-red-600"
>
<span className="material-symbols-rounded text-sm">close</span>
</button>
</div>
))}
{images.length < MAX_IMAGES && (
<label className="w-20 h-20 rounded-xl border border-dashed border-slate-300 bg-slate-50 flex items-center justify-center cursor-pointer hover:bg-slate-100 transition-colors">
<input
ref={fileInputRef}
type="file"
accept="image/png,image/jpeg,image/webp"
onChange={handleImagePick}
className="hidden"
/>
<span className="material-symbols-rounded text-slate-400 text-2xl">add_photo_alternate</span>
</label>
)}
</div>
</div>
{/* Anonymous Option */}
<div className="bg-white rounded-2xl p-6 border border-slate-200">
<label className="flex items-start gap-3 cursor-pointer">
<input
type="checkbox"
checked={isAnonymous}
onChange={(e) => setIsAnonymous(e.target.checked)}
className="mt-1 w-4 h-4 rounded border-slate-300 text-violet-600 focus:ring-violet-500"
/>
<div>
<p className="text-slate-900 text-sm font-medium">{f.anonymousLabel}</p>
<p className="text-slate-400 text-xs mt-0.5">{f.anonymousHint}</p>
</div>
</label>
</div>
{/* Submit Button */}
<button
onClick={handleSubmit}
disabled={submitting}
className="w-full py-3.5 rounded-xl bg-violet-600 text-white text-sm font-semibold hover:bg-violet-700 disabled:opacity-50 transition-colors"
>
{submitting ? f.submitting : f.submitBtn}
</button>
</div>
);
}
+246
View File
@@ -0,0 +1,246 @@
import { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { getUserProfile, updateUserSettings, type UserProfile, type ProfileSettings } from '../lib/api';
import { localeToBackendLanguage, backendLanguageToLocale } from '../lib/auth';
interface Props {
locale: string;
general: { title: string; languageLabel: string; languageValue: string; privacyTitle: string; doNotSell: string; doNotSellHint: string; notificationTitle: string; allowNotification: string; saveSuccess: string; saveFailed: string };
}
const languageOptions = [
{ tag: 'zh-CN', label: '简体中文' },
{ tag: 'zh-TW', label: '繁體中文' },
{ tag: 'en-US', label: 'English' },
];
// Default settings structure
function getDefaultSettings(): ProfileSettings {
return {
version: 1,
preferences: {
language: 'zh-CN',
timezone: 'Asia/Shanghai',
},
privacy: {
can_sell: false,
profile_visibility: 'public',
},
notification: {
allow_notifications: true,
allow_vibration: true,
},
divination_tutorial: {
divination_entry_shown: false,
auto_divination_shown: false,
manual_divination_shown: false,
},
};
}
export default function GeneralSettingsPage({ locale, general: g }: Props) {
const navigate = useNavigate();
const [profile, setProfile] = useState<UserProfile | null>(null);
const [settings, setSettings] = useState<ProfileSettings>(getDefaultSettings());
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [showLanguageModal, setShowLanguageModal] = useState(false);
const selectedLanguage = settings.preferences.language;
const canSell = settings.privacy.can_sell;
const allowNotifications = settings.notification.allow_notifications;
useEffect(() => {
setLoading(true);
getUserProfile()
.then((data) => {
setProfile(data);
if (data.settings) {
setSettings(data.settings);
}
})
.catch(() => {
// Use defaults
})
.finally(() => setLoading(false));
}, []);
const saveSettings = async (newSettings: ProfileSettings): Promise<boolean> => {
setSaving(true);
try {
const updated = await updateUserSettings({ settings: newSettings });
setSettings(updated.settings);
setProfile(updated);
return true;
} catch {
return false;
} finally {
setSaving(false);
}
};
const handleLanguageSelect = async (lang: string) => {
setShowLanguageModal(false);
if (lang === selectedLanguage) return;
const newSettings: ProfileSettings = {
...settings,
preferences: {
...settings.preferences,
language: lang,
},
};
const success = await saveSettings(newSettings);
if (success) {
const newLocale = backendLanguageToLocale(lang);
const currentPath = window.location.pathname;
const newPath = currentPath.replace(`/${locale}`, `/${newLocale}`);
window.location.href = newPath;
}
};
const handleToggleChange = async (field: 'canSell' | 'allowNotifications', value: boolean) => {
let newSettings: ProfileSettings;
if (field === 'canSell') {
newSettings = {
...settings,
privacy: {
...settings.privacy,
can_sell: value,
},
};
} else {
newSettings = {
...settings,
notification: {
...settings.notification,
allow_notifications: value,
},
};
}
// Optimistically update UI
setSettings(newSettings);
// Save to backend (silent)
await saveSettings(newSettings);
};
const currentLangLabel = languageOptions.find(l => l.tag === selectedLanguage)?.label || selectedLanguage;
if (loading) {
return (
<div className="flex flex-col gap-6 min-h-full">
<div className="text-slate-500">{locale === 'en' ? 'Loading...' : '加载中...'}</div>
</div>
);
}
return (
<div className="flex flex-col gap-6 min-h-full">
{/* Page Header */}
<div className="flex items-center gap-3">
<button
onClick={() => navigate(-1)}
className="w-8 h-8 rounded-lg bg-white border border-slate-200 flex items-center justify-center hover:bg-slate-50 transition-colors"
>
<span className="material-symbols-rounded text-slate-500 text-lg">arrow_back</span>
</button>
<h1 className="text-slate-900 text-xl font-bold">{g.title}</h1>
</div>
{/* Language Section */}
<div className="bg-white rounded-2xl p-6 border border-slate-200 flex flex-col gap-4">
<h3 className="text-slate-900 text-lg font-bold">{g.languageLabel}</h3>
<button
onClick={() => setShowLanguageModal(true)}
disabled={saving}
className="flex items-center justify-between px-4 py-3.5 rounded-xl bg-slate-50 hover:bg-slate-100 transition-colors disabled:opacity-50"
>
<div className="flex items-center gap-3">
<span className="material-symbols-rounded text-violet-600 text-xl">language</span>
<div className="text-left">
<p className="text-slate-900 text-sm font-medium">{currentLangLabel}</p>
<p className="text-slate-400 text-xs">{selectedLanguage}</p>
</div>
</div>
<span className="material-symbols-rounded text-slate-400 text-lg">chevron_right</span>
</button>
</div>
{/* Privacy Section */}
<div className="bg-white rounded-2xl p-6 border border-slate-200 flex flex-col gap-4">
<h3 className="text-slate-900 text-lg font-bold">{g.privacyTitle}</h3>
<div className="flex flex-col gap-1">
<div className="flex items-center justify-between px-4 py-3.5 rounded-xl bg-slate-50">
<div className="flex-1">
<p className="text-slate-900 text-sm font-medium">{g.doNotSell}</p>
<p className="text-slate-400 text-xs mt-0.5">{g.doNotSellHint}</p>
</div>
<button
onClick={() => handleToggleChange('canSell', !canSell)}
disabled={saving}
className={`w-11 h-6 rounded-full transition-colors relative shrink-0 ml-3 disabled:opacity-50 ${canSell ? 'bg-violet-600' : 'bg-slate-200'}`}
>
<div className={`w-5 h-5 rounded-full bg-white shadow-sm absolute top-0.5 transition-transform ${canSell ? 'left-5.5' : 'left-0.5'}`} />
</button>
</div>
</div>
</div>
{/* Notification Section */}
<div className="bg-white rounded-2xl p-6 border border-slate-200 flex flex-col gap-4">
<h3 className="text-slate-900 text-lg font-bold">{g.notificationTitle}</h3>
<div className="flex items-center justify-between px-4 py-3.5 rounded-xl bg-slate-50">
<div className="flex items-center gap-3">
<span className="material-symbols-rounded text-violet-600 text-xl">notifications</span>
<p className="text-slate-900 text-sm font-medium">{g.allowNotification}</p>
</div>
<button
onClick={() => handleToggleChange('allowNotifications', !allowNotifications)}
disabled={saving}
className={`w-11 h-6 rounded-full transition-colors relative disabled:opacity-50 ${allowNotifications ? 'bg-violet-600' : 'bg-slate-200'}`}
>
<div className={`w-5 h-5 rounded-full bg-white shadow-sm absolute top-0.5 transition-transform ${allowNotifications ? 'left-5.5' : 'left-0.5'}`} />
</button>
</div>
</div>
{/* Language Selection Modal */}
{showLanguageModal && (
<div className="fixed inset-0 bg-black/40 z-50 flex items-center justify-center p-6">
<div className="bg-white rounded-2xl w-full max-w-md p-6 flex flex-col gap-4">
<div className="flex items-center justify-between">
<h3 className="text-slate-900 text-lg font-bold">{locale === 'en' ? 'Select Language' : '选择语言'}</h3>
<button onClick={() => setShowLanguageModal(false)} className="text-slate-400 hover:text-slate-600">
<span className="material-symbols-rounded text-xl">close</span>
</button>
</div>
<div className="flex flex-col gap-2">
{languageOptions.map((lang) => (
<button
key={lang.tag}
onClick={() => handleLanguageSelect(lang.tag)}
disabled={saving}
className={`flex items-center justify-between px-4 py-3 rounded-xl transition-colors disabled:opacity-50 ${selectedLanguage === lang.tag ? 'bg-violet-50 border border-violet-200' : 'hover:bg-slate-50'}`}
>
<div className="flex items-center gap-3">
<span className="material-symbols-rounded text-violet-600 text-lg">language</span>
<div className="text-left">
<p className="text-slate-900 text-sm font-medium">{lang.label}</p>
<p className="text-slate-400 text-xs">{lang.tag}</p>
</div>
</div>
{selectedLanguage === lang.tag && (
<span className="material-symbols-rounded text-violet-600 text-lg">check</span>
)}
</button>
))}
</div>
</div>
</div>
)}
</div>
);
}
+41 -4
View File
@@ -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 (
<div className="min-h-screen flex items-center justify-center" style={{ background: 'linear-gradient(180deg, #F5F0FF 0%, #FFFFFF 100%)' }}>
<div className="h-10 w-10 rounded-full border-2 border-violet-200 border-t-violet-600 animate-spin" />
</div>
);
}
return (
<div className="relative min-h-screen flex items-center justify-center px-4 py-8 overflow-hidden"
style={{ background: 'linear-gradient(180deg, #F5F0FF 0%, #FFFFFF 100%)' }}>
+222 -15
View File
@@ -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<NotificationItem[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [selectedItem, setSelectedItem] = useState<NotificationItem | null>(null);
const [markingAll, setMarkingAll] = useState(false);
const [toast, setToast] = useState<string | null>(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 (
<div className="flex flex-col gap-6 min-h-full">
<h1 className="text-slate-900 text-2xl font-bold">{n.title}</h1>
<div className="text-slate-500">{n.loading}</div>
</div>
);
}
if (error) {
return (
<div className="flex flex-col gap-6 min-h-full">
<h1 className="text-slate-900 text-2xl font-bold">{n.title}</h1>
<div className="text-red-500">{error}</div>
</div>
);
}
return (
<div className="flex flex-col gap-6 min-h-full">
{/* Header */}
<div className="flex items-center justify-between">
<h1 className="text-slate-900 text-2xl font-bold">{n.title}</h1>
{unreadCount > 0 && (
<button
onClick={handleMarkAllRead}
disabled={markingAll}
className="text-sm text-amber-600 hover:text-amber-700 disabled:text-slate-400"
>
{markingAll ? '...' : n.markAllRead}
</button>
)}
</div>
{/* List */}
{items.length === 0 ? (
<div className="text-slate-500 py-8 text-center">{n.empty}</div>
) : (
<div className="flex flex-col gap-3">
{MOCK_NOTIFS.map((notif, i) => (
<div key={i} className="relative bg-white rounded-xl px-5 py-4 flex items-start gap-4 hover:shadow-sm transition-shadow">
{notif.unread && <div className="absolute left-4 top-5 w-2 h-2 rounded-full bg-red-500" />}
{items.map((notif) => (
<button
key={notif.id}
onClick={() => handleItemClick(notif)}
className="relative bg-white rounded-xl px-5 py-4 flex items-start gap-4 hover:shadow-sm transition-shadow text-left w-full"
>
{!notif.isRead && <div className="absolute left-4 top-5 w-2 h-2 rounded-full bg-red-500" />}
<div className="flex-1 min-w-0 ml-2">
<p className={`text-[15px] ${notif.unread ? 'text-slate-900 font-semibold' : 'text-slate-600'}`}>{(n as any)[notif.title]}</p>
<p className="text-slate-400 text-[13px] mt-1">{(n as any)[notif.body]}</p>
<p className={`text-[15px] ${!notif.isRead ? 'text-slate-900 font-semibold' : 'text-slate-600'}`}>
{notif.title}
</p>
<p className="text-slate-400 text-[13px] mt-1">{notif.body}</p>
</div>
<span className="text-slate-400 text-xs shrink-0 mt-0.5">{locale === 'en' ? notif.timeEn : notif.time}</span>
</div>
<span className="text-slate-400 text-xs shrink-0 mt-0.5">{formatRelativeTime(notif.createdAt, locale)}</span>
</button>
))}
</div>
)}
{/* Modal Overlay */}
{selectedItem && (
<div
className="fixed inset-0 bg-black/50 z-40 flex items-center justify-center p-4"
onClick={closeModal}
>
{/* Modal Content */}
<div
className="bg-white rounded-2xl shadow-xl w-full max-w-md max-h-[80vh] overflow-hidden flex flex-col"
onClick={(e) => e.stopPropagation()}
>
{/* Modal Header */}
<div className="flex items-start justify-between gap-3 p-5 border-b border-slate-100">
<div className="flex-1 min-w-0">
<h2 className="text-lg font-semibold text-slate-900">{selectedItem.title}</h2>
<p className="text-slate-400 text-sm mt-1">{formatFullTime(selectedItem.createdAt, locale)}</p>
</div>
<button
onClick={closeModal}
className="text-slate-400 hover:text-slate-600 p-1 shrink-0 -mt-1 -mr-1"
aria-label="Close"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{/* Modal Body */}
<div className="p-5 overflow-y-auto">
{/* Status Badge */}
<div className="mb-4">
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
selectedItem.isRead ? 'bg-slate-100 text-slate-600' : 'bg-amber-100 text-amber-700'
}`}>
{selectedItem.isRead
? (locale === 'en' ? 'Read' : '已读')
: (locale === 'en' ? 'Unread' : '未读')}
</span>
</div>
{/* Body */}
<div className="text-slate-700 text-[15px] leading-relaxed whitespace-pre-wrap">
{selectedItem.body}
</div>
</div>
{/* Modal Footer */}
<div className="p-5 border-t border-slate-100">
<button
onClick={closeModal}
className="w-full py-2.5 bg-violet-600 text-white rounded-lg font-medium hover:bg-violet-700 transition-colors"
>
{locale === 'en' ? 'Close' : '关闭'}
</button>
</div>
</div>
</div>
)}
{/* Toast */}
{toast && (
<div className="fixed bottom-8 left-1/2 -translate-x-1/2 bg-slate-800 text-white px-4 py-2 rounded-lg text-sm shadow-lg z-50">
{toast}
</div>
)}
</div>
);
}
+210 -13
View File
@@ -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<File> {
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<UserProfile | null>(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<string | null>(null);
const [success, setSuccess] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement>(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<HTMLInputElement>) => {
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 (
<div className="flex flex-col gap-6 min-h-full">
<div className="text-slate-500">{locale === 'en' ? 'Loading...' : '加载中...'}</div>
</div>
);
}
const email = getAuth()?.user?.email || 'user@example.com';
return (
<div className="flex flex-col lg:flex-row gap-6 min-h-full">
{/* Avatar edit */}
<div className="w-full lg:w-[360px] bg-white rounded-2xl p-7 border border-slate-200 flex flex-col items-center gap-5 shrink-0 self-start">
<div className="w-32 h-32 rounded-full bg-violet-50 border-2 border-violet-200 flex items-center justify-center">
<span className="text-violet-600 text-4xl font-bold">U</span>
</div>
{/* Avatar preview */}
{profile?.avatar_url ? (
<img src={profile.avatar_url} alt={displayName} className="w-32 h-32 rounded-full object-cover border-2 border-violet-200" />
) : (
<div className="w-32 h-32 rounded-full bg-violet-50 border-2 border-violet-200 flex items-center justify-center">
<span className="text-violet-600 text-4xl font-bold">{(displayName || 'U')[0].toUpperCase()}</span>
</div>
)}
<h3 className="text-slate-900 text-lg font-bold">{p.avatarTitle}</h3>
<p className="text-slate-500 text-[13px] text-center">{p.avatarHint}</p>
<button className="w-full h-[42px] rounded-full bg-violet-600 text-white text-sm font-semibold hover:bg-violet-700 transition-colors">{p.uploadBtn}</button>
<input
ref={fileInputRef}
type="file"
accept="image/png,image/jpeg,image/webp"
onChange={handleFileChange}
className="hidden"
/>
<button
onClick={handleAvatarClick}
disabled={uploading}
className="w-full h-[42px] rounded-full bg-violet-600 text-white text-sm font-semibold hover:bg-violet-700 disabled:opacity-50 transition-colors"
>
{uploading ? (locale === 'en' ? 'Uploading...' : '上传中...') : p.uploadBtn}
</button>
</div>
{/* Form */}
<div className="flex-1 bg-white rounded-2xl p-7 border border-slate-200 flex flex-col gap-6">
<h3 className="text-slate-900 text-xl font-bold">{p.formTitle}</h3>
{/* Error message */}
{error && (
<div className="text-red-500 text-sm bg-red-50 px-4 py-2 rounded-lg">{error}</div>
)}
{/* Success message */}
{success && (
<div className="text-green-600 text-sm bg-green-50 px-4 py-2 rounded-lg">{success}</div>
)}
{/* Email readonly */}
<div className="bg-slate-50 rounded-xl px-4 py-4 flex items-center gap-4">
<span className="material-symbols-rounded text-slate-400 text-lg">email</span>
<div>
<p className="text-slate-400 text-xs">{p.emailLabel}</p>
<p className="text-slate-600 text-sm">user@example.com</p>
<p className="text-slate-600 text-sm">{email}</p>
</div>
</div>
{/* Display name */}
<div className="flex flex-col gap-2">
<label className="text-slate-700 text-sm font-medium">{p.displayNameLabel}</label>
<input value={displayName} onChange={e => 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" />
<input
value={displayName}
onChange={(e) => 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"
/>
</div>
{/* Bio */}
<div className="flex flex-col gap-2">
<label className="text-slate-700 text-sm font-medium">{p.bioLabel}</label>
<textarea value={bio} onChange={e => setBio(e.target.value)} placeholder={p.bioPlaceholder} rows={4}
className="w-full px-4 py-3 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 resize-none" />
<textarea
value={bio}
onChange={(e) => setBio(e.target.value)}
placeholder={p.bioPlaceholder}
rows={4}
maxLength={200}
className="w-full px-4 py-3 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 resize-none"
/>
<p className="text-slate-400 text-xs text-right">{bio.length}/200</p>
</div>
<div className="flex gap-3 justify-end mt-auto">
<button className="px-5 py-2.5 rounded-lg text-sm text-slate-500 hover:bg-slate-50 transition-colors">{p.cancelBtn}</button>
<button className="px-5 py-2.5 rounded-lg bg-violet-600 text-white text-sm font-semibold hover:bg-violet-700 transition-colors">{p.saveBtn}</button>
<button
onClick={handleCancel}
className="px-5 py-2.5 rounded-lg text-sm text-slate-500 hover:bg-slate-50 transition-colors"
>
{p.cancelBtn}
</button>
<button
onClick={handleSave}
disabled={saving}
className="px-5 py-2.5 rounded-lg bg-violet-600 text-white text-sm font-semibold hover:bg-violet-700 disabled:opacity-50 transition-colors"
>
{saving ? (locale === 'en' ? 'Saving...' : '保存中...') : p.saveBtn}
</button>
</div>
</div>
</div>
+181 -38
View File
@@ -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<UserProfile | null>(null);
const [points, setPoints] = useState<PointsBalance | null>(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 (
<div className="flex flex-col gap-6 min-h-full">
<h1 className="text-slate-900 text-xl font-bold">{s.title}</h1>
{/* Page Header */}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div className="flex flex-col gap-1.5">
<h1 className="text-slate-900 text-2xl font-bold">{s.title}</h1>
<p className="text-slate-500 text-sm">
{locale === 'en' ? 'Manage account, preferences, and policies' : '管理账号资料、偏好与协议信息'}
</p>
</div>
<div className="flex items-center gap-2 px-3.5 py-2.5 bg-white rounded-full border border-slate-200">
<span className="material-symbols-rounded text-violet-600 text-lg">verified_user</span>
<span className="text-slate-700 text-sm font-medium">
{locale === 'en' ? 'Account OK' : '账号正常'}
</span>
</div>
</div>
<div className="flex flex-col lg:flex-row gap-6 flex-1 min-h-0">
{/* Left column */}
<div className="w-full lg:w-[360px] flex flex-col gap-4 shrink-0">
{/* Profile summary */}
{/* Main Content */}
<div className="flex flex-col xl:flex-row gap-6 flex-1 min-h-0">
{/* Left Column */}
<div className="w-full xl:w-[360px] flex flex-col gap-4 shrink-0">
{/* Profile Summary Card */}
<div className="bg-white rounded-2xl p-[18px] border border-slate-200 flex flex-col gap-3">
<h3 className="text-slate-900 text-sm font-bold">{s.profileTitle}</h3>
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-full bg-violet-600 flex items-center justify-center text-white text-sm font-bold">U</div>
<div>
<p className="text-slate-900 text-sm font-medium">User</p>
<p className="text-slate-400 text-xs">user@example.com</p>
<div className="flex items-start gap-3">
{/* Avatar */}
{profile?.avatar_url ? (
<img src={profile.avatar_url} alt={displayName} className="w-14 h-14 rounded-[28px] object-cover" />
) : (
<div className="w-14 h-14 rounded-[28px] bg-violet-50 flex items-center justify-center">
<span className="text-violet-600 text-xl font-bold">{displayName[0].toUpperCase()}</span>
</div>
)}
{/* Name & Email */}
<div className="flex-1 min-w-0">
<p className="text-slate-900 text-lg font-bold truncate">{displayName}</p>
<p className="text-slate-500 text-xs truncate">{email}</p>
</div>
{/* Edit Profile Button */}
<a
href={`/${locale}/profile`}
className="w-8 h-8 rounded-2xl bg-slate-50 border border-slate-200 flex items-center justify-center hover:bg-slate-100 transition-colors shrink-0"
title={s.changeName}
>
<span className="material-symbols-rounded text-violet-600 text-[17px]">edit</span>
</a>
</div>
{/* Bio */}
{bio && (
<p className="text-slate-500 text-[13px] leading-relaxed">{bio}</p>
)}
</div>
{/* Points */}
<div className="bg-white rounded-2xl p-5 border border-slate-200 flex items-center gap-4">
<span className="material-symbols-rounded text-violet-600 text-2xl">account_balance_wallet</span>
<div>
<p className="text-slate-400 text-xs">{s.pointsTitle}</p>
<p className="text-slate-900 text-lg font-bold">210 <span className="text-sm font-normal text-slate-400">{s.pointsBalance}</span></p>
{/* Points Card */}
<a
href={`/${locale}/store`}
className="bg-white rounded-2xl p-5 border border-slate-200 flex items-center gap-3.5 hover:shadow-sm transition-shadow"
>
{/* Points Icon */}
<div className="w-11 h-11 rounded-xl bg-violet-50 flex items-center justify-center shrink-0">
<span className="material-symbols-rounded text-violet-600 text-[26px]">paid</span>
</div>
</div>
{/* Points Info */}
<div className="flex-1 min-w-0">
<p className="text-slate-400 text-[13px]">{s.pointsTitle}</p>
<p className="text-slate-900 text-xl font-bold">
{loading ? '...' : points?.balance ?? 0}
<span className="text-sm font-normal text-slate-400 ml-1">{s.pointsBalance}</span>
</p>
</div>
{/* Arrow */}
<span className="material-symbols-rounded text-violet-600 text-[22px] shrink-0">chevron_right</span>
</a>
</div>
{/* Right column */}
{/* Right Column */}
<div className="flex-1 flex flex-col gap-4">
{/* Account settings */}
{/* Account Settings Panel */}
<div className="bg-white rounded-2xl p-6 border border-slate-200 flex flex-col gap-4">
<h3 className="text-slate-900 text-base font-bold">{s.accountTitle}</h3>
<a href={`/${locale}/profile`} className="flex items-center justify-between py-2 hover:bg-slate-50 rounded-lg px-1 transition-colors">
<span className="text-slate-600 text-sm">{s.changeAvatar}</span>
<span className="material-symbols-rounded text-slate-400 text-lg">chevron_right</span>
</a>
<a href={`/${locale}/profile`} className="flex items-center justify-between py-2 hover:bg-slate-50 rounded-lg px-1 transition-colors">
<span className="text-slate-600 text-sm">{s.changeName}</span>
<span className="material-symbols-rounded text-slate-400 text-lg">chevron_right</span>
</a>
<div className="flex items-center justify-between py-2">
<span className="text-slate-600 text-sm">{s.changeLanguage}</span>
<span className="text-slate-400 text-sm"></span>
<h3 className="text-slate-900 text-lg font-bold">{s.accountTitle}</h3>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3.5">
{/* General Settings */}
<a
href={`/${locale}/settings/general`}
className="bg-slate-50 rounded-xl p-4 flex flex-col gap-2.5 hover:bg-slate-100 transition-colors cursor-pointer"
>
<span className="material-symbols-rounded text-violet-600 text-xl">tune</span>
<div>
<p className="text-slate-900 text-[15px] font-bold">
{locale === 'en' ? 'General' : '通用设置'}
</p>
<p className="text-slate-400 text-xs">
{locale === 'en' ? 'Language, notifications' : '语言、通知'}
</p>
</div>
</a>
{/* Feedback */}
<a
href={`/${locale}/settings/feedback`}
className="bg-slate-50 rounded-xl p-4 flex flex-col gap-2.5 hover:bg-slate-100 transition-colors cursor-pointer"
>
<span className="material-symbols-rounded text-violet-600 text-xl">feedback</span>
<div>
<p className="text-slate-900 text-[15px] font-bold">
{locale === 'en' ? 'Feedback' : '意见反馈'}
</p>
<p className="text-slate-400 text-xs">
{locale === 'en' ? 'Submit suggestions' : '提交问题与建议'}
</p>
</div>
</a>
{/* Account Data */}
<a
href={`/${locale}/profile`}
className="bg-slate-50 rounded-xl p-4 flex flex-col gap-2.5 hover:bg-slate-100 transition-colors cursor-pointer"
>
<span className="material-symbols-rounded text-violet-600 text-xl">person</span>
<div>
<p className="text-slate-900 text-[15px] font-bold">
{locale === 'en' ? 'Account Data' : '账号数据'}
</p>
<p className="text-slate-400 text-xs">
{locale === 'en' ? 'Profile information' : '账号信息'}
</p>
</div>
</a>
</div>
</div>
{/* Legal */}
{/* Legal Panel */}
<div className="bg-white rounded-2xl p-6 border border-slate-200 flex flex-col gap-4">
<h3 className="text-slate-900 text-base font-bold">{s.legalTitle}</h3>
<a href={`/${locale}/privacy`} className="text-slate-600 text-sm hover:text-violet-600">{s.privacy}</a>
<a href={`/${locale}/terms`} className="text-slate-600 text-sm hover:text-violet-600">{s.terms}</a>
<h3 className="text-slate-900 text-lg font-bold">{s.legalTitle}</h3>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3.5">
{/* About */}
<a href={`/${locale}/about`} className="bg-slate-50 rounded-xl p-4 flex flex-col gap-2.5 hover:bg-slate-100 transition-colors">
<span className="material-symbols-rounded text-violet-600 text-xl">info</span>
<div>
<p className="text-slate-900 text-[15px] font-bold">
{locale === 'en' ? 'About' : '关于我们'}
</p>
<p className="text-slate-400 text-xs">
{locale === 'en' ? 'Product vision' : '产品理念与定位'}
</p>
</div>
</a>
{/* Privacy */}
<a href={`/${locale}/privacy`} className="bg-slate-50 rounded-xl p-4 flex flex-col gap-2.5 hover:bg-slate-100 transition-colors">
<span className="material-symbols-rounded text-violet-600 text-xl">security</span>
<div>
<p className="text-slate-900 text-[15px] font-bold">{s.privacy}</p>
<p className="text-slate-400 text-xs">
{locale === 'en' ? 'Privacy policy' : '隐私保护说明'}
</p>
</div>
</a>
{/* Terms */}
<a href={`/${locale}/terms`} className="bg-slate-50 rounded-xl p-4 flex flex-col gap-2.5 hover:bg-slate-100 transition-colors">
<span className="material-symbols-rounded text-violet-600 text-xl">description</span>
<div>
<p className="text-slate-900 text-[15px] font-bold">{s.terms}</p>
<p className="text-slate-400 text-xs">
{locale === 'en' ? 'User agreement' : '用户服务协议'}
</p>
</div>
</a>
</div>
</div>
{/* Logout */}
<button onClick={handleLogout} className="bg-white rounded-2xl px-5 py-3.5 border border-red-200 flex items-center justify-between hover:bg-red-50 transition-colors">
{/* Logout Button */}
<button
onClick={handleLogout}
className="bg-white rounded-2xl px-5 py-3.5 border border-red-200 flex items-center justify-between hover:bg-red-50 transition-colors"
>
<span className="text-red-500 text-sm font-medium">{s.logout}</span>
<span className="material-symbols-rounded text-red-400 text-lg">logout</span>
</button>
+122 -40
View File
@@ -1,3 +1,7 @@
import { useEffect, useState } from 'react';
import type { PointsBalance, PackageInfo } from '../lib/api';
import { getPointsBalance, getPackages, invalidatePointsCache } 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 };
@@ -5,22 +9,94 @@ interface Props {
pricing: { p1Name: string; p1Badge: string; p1Price: string; p1Credits: string; p1Desc: string; p2Name: string; p2Price: string; p2Credits: string; p2Desc: string; p3Name: string; p3Badge: string; p3Price: string; p3Credits: string; p3Desc: string; p4Name: string; p4Price: string; p4Credits: string; p4Desc: string; buyNow: string };
}
const PACKAGES = ['p1', 'p2', 'p3', 'p4'] as const;
interface PackageDisplay {
name: string;
badge: string;
price: string;
credits: string;
desc: string;
featured: boolean;
productCode: string;
appStoreProductId: string;
starterEligible: boolean;
isStarter: boolean;
}
// Map product codes to display names from pricing translations
const PRODUCT_CODE_MAP: Record<string, string> = {
'new_user_pack': 'p1', // 新人专享包 60积分
'starter_pack': 'p2', // 入门补充包 100积分
'popular_pack': 'p3', // 常用加量包 210积分
'premium_pack': 'p4', // 高频进阶包 415积分
};
function SidePanel({ s }: { s: Props['store'] }) {
return (
<div className="w-full xl:w-[320px] bg-white rounded-2xl p-5 border border-slate-200 flex flex-col gap-4 shrink-0 overflow-y-auto">
<div className="w-12 h-12 rounded-xl bg-violet-50 flex items-center justify-center">
<span className="material-symbols-rounded text-violet-600 text-2xl">shopping_cart</span>
</div>
<p className="text-slate-900 text-lg font-bold">{s.sideTitle}</p>
<p className="text-slate-500 text-sm">{s.sideDesc}</p>
<div className="h-px bg-slate-100" />
<p className="text-slate-400 text-xs font-semibold">{s.popularLabel}</p>
<div className="bg-amber-50 rounded-xl p-3.5 text-amber-700 text-sm">{s.popularText}</div>
<p className="text-slate-400 text-xs font-semibold mt-2">{s.stepsTitle}</p>
<div className="flex items-center gap-2.5 text-sm text-slate-600"><span className="w-5 h-5 rounded-full bg-violet-100 text-violet-600 text-xs font-bold flex items-center justify-center">1</span>{s.step1}</div>
<div className="flex items-center gap-2.5 text-sm text-slate-600"><span className="w-5 h-5 rounded-full bg-violet-100 text-violet-600 text-xs font-bold flex items-center justify-center">2</span>{s.step2}</div>
<div className="flex items-center gap-2.5 text-sm text-slate-600"><span className="w-5 h-5 rounded-full bg-violet-100 text-violet-600 text-xs font-bold flex items-center justify-center">3</span>{s.step3}</div>
</div>
);
}
export default function StorePage({ store: s, pricing: p }: Props) {
const pkgs = PACKAGES.map(k => ({
name: p[`${k}Name`], badge: p[`${k}Badge` as keyof typeof p] || '', price: p[`${k}Price`], credits: p[`${k}Credits`], desc: p[`${k}Desc`],
featured: k === 'p3',
}));
const [points, setPoints] = useState<PointsBalance | null>(null);
const [packages, setPackages] = useState<PackageDisplay[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
setLoading(true);
Promise.all([
getPointsBalance(),
getPackages(),
])
.then(([pointsData, packagesData]) => {
setPoints(pointsData);
// Map backend packages to display format
const displayPkgs: PackageDisplay[] = packagesData.packages.map((pkg) => {
const key = PRODUCT_CODE_MAP[pkg.productCode] || 'p1';
return {
name: p[`${key}Name` as keyof typeof p] || pkg.productCode,
badge: pkg.isStarter ? (pkg.starterEligible ? p.p1Badge : '') : '',
price: p[`${key}Price` as keyof typeof p] || '',
credits: `${pkg.credits} ${s.pointsLabel}`,
desc: p[`${key}Desc` as keyof typeof p] || '',
featured: pkg.productCode === 'popular_pack', // 只有常用加量包是推荐
productCode: pkg.productCode,
appStoreProductId: pkg.appStoreProductId,
starterEligible: pkg.starterEligible,
isStarter: pkg.isStarter,
};
});
// Sort by sortOrder
displayPkgs.sort((a, b) => {
const pkgA = packagesData.packages.find(pkg => pkg.productCode === a.productCode);
const pkgB = packagesData.packages.find(pkg => pkg.productCode === b.productCode);
return (pkgA?.sortOrder || 0) - (pkgB?.sortOrder || 0);
});
setPackages(displayPkgs);
})
.catch(() => {
// Fallback to static data if API fails
setPoints({ balance: 0, frozenBalance: 0, availableBalance: 0, runCost: 20, canRun: false });
setPackages([]);
})
.finally(() => setLoading(false));
}, [p, s.pointsLabel]);
return (
<div className="flex flex-col gap-5 min-h-full">
<div className="flex items-center justify-between">
<h1 className="text-slate-900 text-xl font-bold">{s.title}</h1>
<div className="w-10 h-10 rounded-xl border border-slate-200 flex items-center justify-center">
<span className="material-symbols-rounded text-slate-500 text-xl">notifications</span>
</div>
</div>
<h1 className="text-slate-900 text-xl font-bold">{s.title}</h1>
{/* Top: Points hero + rules */}
<div className="flex flex-col lg:flex-row gap-5">
@@ -30,7 +106,10 @@ export default function StorePage({ store: s, pricing: p }: Props) {
</div>
<div>
<p className="text-violet-200 text-sm">{s.currentPoints}</p>
<p className="text-white text-3xl font-bold">210 <span className="text-base font-normal text-violet-200">{s.pointsLabel}</span></p>
<p className="text-white text-3xl font-bold">
{loading ? '...' : points?.balance ?? 0}
<span className="text-base font-normal text-violet-200"> {s.pointsLabel}</span>
</p>
</div>
</div>
<div className="w-full lg:w-[320px] bg-white rounded-2xl p-5 border border-slate-200 flex flex-col gap-3.5 shrink-0">
@@ -44,39 +123,42 @@ export default function StorePage({ store: s, pricing: p }: Props) {
</div>
</div>
{/* Body: Packages + side panel */}
{/* Mobile: Side panel below rules (visible only on mobile) */}
<div className="xl:hidden">
<SidePanel s={s} />
</div>
{/* Body: Packages + side panel (desktop) */}
<div className="flex flex-col xl:flex-row gap-5 flex-1 min-h-0">
<div className="flex-1 flex flex-col gap-4 overflow-y-auto">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{pkgs.map((pkg, i) => (
<div key={i} className={`bg-white rounded-2xl p-6 flex flex-col gap-3 border ${pkg.featured ? 'border-violet-400 ring-1 ring-violet-100' : 'border-slate-200'}`}>
<div className="flex items-center justify-between">
<span className="text-slate-900 font-bold text-base">{pkg.name}</span>
{pkg.badge && <span className={`text-xs px-2.5 py-0.5 rounded-full ${pkg.featured ? 'bg-violet-600 text-white' : 'bg-amber-100 text-amber-700'}`}>{pkg.badge}</span>}
{loading ? (
<div className="text-slate-500 text-center py-8">{s.sideDesc}</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{packages.map((pkg) => (
<div key={pkg.productCode} className={`bg-white rounded-2xl p-6 flex flex-col gap-3 border ${pkg.featured ? 'border-violet-400 ring-1 ring-violet-100' : 'border-slate-200'}`}>
<div className="flex items-center justify-between">
<span className="text-slate-900 font-bold text-base">{pkg.name}</span>
{pkg.badge && <span className={`text-xs px-2.5 py-0.5 rounded-full ${pkg.featured ? 'bg-violet-600 text-white' : 'bg-amber-100 text-amber-700'}`}>{pkg.badge}</span>}
</div>
<p className="text-slate-900 text-2xl font-extrabold">{pkg.price}</p>
<p className="text-violet-600 text-sm font-medium">{pkg.credits}</p>
<p className="text-slate-500 text-sm">{pkg.desc}</p>
<button
className={`w-full py-2.5 rounded-lg font-semibold text-sm mt-auto ${pkg.featured ? 'bg-violet-600 text-white hover:bg-violet-700' : 'bg-white text-violet-600 border border-violet-200 hover:bg-violet-50'} transition-colors`}
disabled={pkg.isStarter && !pkg.starterEligible}
>
{pkg.isStarter && !pkg.starterEligible ? (s.rulesTitle.includes('已购') ? '已购买' : 'Purchased') : p.buyNow}
</button>
</div>
<p className="text-slate-900 text-2xl font-extrabold">{pkg.price}</p>
<p className="text-violet-600 text-sm font-medium">{pkg.credits}</p>
<p className="text-slate-500 text-sm">{pkg.desc}</p>
<button className={`w-full py-2.5 rounded-lg font-semibold text-sm mt-auto ${pkg.featured ? 'bg-violet-600 text-white hover:bg-violet-700' : 'bg-white text-violet-600 border border-violet-200 hover:bg-violet-50'} transition-colors`}>{p.buyNow}</button>
</div>
))}
</div>
))}
</div>
)}
</div>
{/* Side panel */}
<div className="w-full xl:w-[320px] bg-white rounded-2xl p-5 border border-slate-200 flex flex-col gap-4 shrink-0 overflow-y-auto">
<div className="w-12 h-12 rounded-xl bg-violet-50 flex items-center justify-center">
<span className="material-symbols-rounded text-violet-600 text-2xl">shopping_cart</span>
</div>
<p className="text-slate-900 text-lg font-bold">{s.sideTitle}</p>
<p className="text-slate-500 text-sm">{s.sideDesc}</p>
<div className="h-px bg-slate-100" />
<p className="text-slate-400 text-xs font-semibold">{s.popularLabel}</p>
<div className="bg-amber-50 rounded-xl p-3.5 text-amber-700 text-sm">{s.popularText}</div>
<p className="text-slate-400 text-xs font-semibold mt-2">{s.stepsTitle}</p>
<div className="flex items-center gap-2.5 text-sm text-slate-600"><span className="w-5 h-5 rounded-full bg-violet-100 text-violet-600 text-xs font-bold flex items-center justify-center">1</span>{s.step1}</div>
<div className="flex items-center gap-2.5 text-sm text-slate-600"><span className="w-5 h-5 rounded-full bg-violet-100 text-violet-600 text-xs font-bold flex items-center justify-center">2</span>{s.step2}</div>
<div className="flex items-center gap-2.5 text-sm text-slate-600"><span className="w-5 h-5 rounded-full bg-violet-100 text-violet-600 text-xs font-bold flex items-center justify-center">3</span>{s.step3}</div>
{/* Desktop: Side panel (visible only on xl+) */}
<div className="hidden xl:block">
<SidePanel s={s} />
</div>
</div>
</div>
+1 -1
View File
@@ -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` },
];
}
+12 -4
View File
@@ -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<Locale, Translations> = {
@@ -50,12 +52,14 @@ const translations: Record<Locale, Translations> = {
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<Locale, Translations> = {
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<Locale, Translations> = {
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' },
},
};
+11
View File
@@ -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;
+182 -3
View File
@@ -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<UserProfile> {
return authFetch<UserProfile>(API_ROUTES.users.profile);
}
export function updateUserProfile(data: UpdateProfileRequest): Promise<UserProfile> {
return authFetch<UserProfile>(API_ROUTES.users.updateProfile, {
method: 'PATCH',
body: JSON.stringify(data),
});
}
export function getAvatarUploadUrl(data: AvatarUploadUrlRequest): Promise<AvatarUploadUrlResponse> {
return authFetch<AvatarUploadUrlResponse>(API_ROUTES.users.avatarUploadUrl, {
method: 'POST',
body: JSON.stringify(data),
});
}
export function uploadAvatar(file: File): Promise<UserProfile> {
const formData = new FormData();
formData.append('file', file);
return authFetch<UserProfile>(API_ROUTES.users.uploadAvatar, {
method: 'POST',
body: formData,
});
}
export function updateUserSettings(data: UpdateSettingsRequest): Promise<UserProfile> {
return authFetch<UserProfile>(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<PointsBalance> {
return authFetch<PointsBalance>(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<PointsBalance> {
const now = Date.now();
if (useCache && pointsCache && pointsCache.expiry > now) {
return Promise.resolve(pointsCache.data);
}
return authFetch<PointsBalance>(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<PackagesResponse> {
return authFetch<PackagesResponse>(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<NotificationListResponse> {
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<NotificationListResponse>(`${API_ROUTES.notifications.list}?${query}`);
}
export function getUnreadNotificationCount(): Promise<UnreadCount> {
return authFetch<UnreadCount>(API_ROUTES.notifications.unreadCount);
}
export function markNotificationRead(id: string, locale?: string): Promise<NotificationItem> {
const params = locale ? `?locale=${locale}` : '';
return authFetch<NotificationItem>(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 {
+28
View File
@@ -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<string, string> = {
'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<string, string> = {
'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 {
+23
View File
@@ -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'),
};
---
<App locale={locale}>
<DashboardApp client:only="react" locale={locale} translations={translations} />
</App>
+23
View File
@@ -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'),
};
---
<App locale={locale}>
<DashboardApp client:only="react" locale={locale} translations={translations} />
</App>
+23
View File
@@ -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'),
};
---
<App locale={locale}>
<DashboardApp client:only="react" locale={locale} translations={translations} />
</App>
+23
View File
@@ -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'),
};
---
<App locale={locale}>
<DashboardApp client:only="react" locale={locale} translations={translations} />
</App>
@@ -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'),
};
---
<App locale={locale}>
<DashboardApp client:only="react" locale={locale} translations={translations} />
</App>
@@ -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'),
};
---
<App locale={locale}>
<DashboardApp client:only="react" locale={locale} translations={translations} />
</App>