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

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