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` },
|
||||
];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user