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:
@@ -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) {
|
||||
|
||||
@@ -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'),
|
||||
};
|
||||
---
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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%)' }}>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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' },
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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' 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>
|
||||
Reference in New Issue
Block a user