From 1fbb07f692b62e864b27ac12ac2ecc254764c8b5 Mon Sep 17 00:00:00 2001 From: zl-q Date: Sat, 9 May 2026 18:23:21 +0800 Subject: [PATCH] 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 --- web/src/components/AppShell.tsx | 3 + web/src/components/DashboardApp.tsx | 8 + web/src/components/DashboardAppPage.astro | 2 + web/src/components/FeedbackPage.tsx | 214 +++++++++++++++ web/src/components/GeneralSettingsPage.tsx | 246 ++++++++++++++++++ web/src/components/LoginForm.tsx | 45 +++- web/src/components/NotificationPage.tsx | 237 +++++++++++++++-- web/src/components/ProfileDetailPage.tsx | 223 +++++++++++++++- web/src/components/SettingsPage.tsx | 219 +++++++++++++--- web/src/components/StorePage.tsx | 162 +++++++++--- web/src/components/navConfig.ts | 2 +- web/src/i18n/utils.ts | 16 +- web/src/lib/api-routes.ts | 11 + web/src/lib/api.ts | 185 ++++++++++++- web/src/lib/auth.ts | 28 ++ web/src/pages/en/settings/feedback.astro | 23 ++ web/src/pages/en/settings/general.astro | 23 ++ web/src/pages/zh/settings/feedback.astro | 23 ++ web/src/pages/zh/settings/general.astro | 23 ++ web/src/pages/zh_Hant/settings/feedback.astro | 23 ++ web/src/pages/zh_Hant/settings/general.astro | 23 ++ 21 files changed, 1621 insertions(+), 118 deletions(-) create mode 100644 web/src/components/FeedbackPage.tsx create mode 100644 web/src/components/GeneralSettingsPage.tsx create mode 100644 web/src/pages/en/settings/feedback.astro create mode 100644 web/src/pages/en/settings/general.astro create mode 100644 web/src/pages/zh/settings/feedback.astro create mode 100644 web/src/pages/zh/settings/general.astro create mode 100644 web/src/pages/zh_Hant/settings/feedback.astro create mode 100644 web/src/pages/zh_Hant/settings/general.astro diff --git a/web/src/components/AppShell.tsx b/web/src/components/AppShell.tsx index 4e013eb..87a5b64 100644 --- a/web/src/components/AppShell.tsx +++ b/web/src/components/AppShell.tsx @@ -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) { diff --git a/web/src/components/DashboardApp.tsx b/web/src/components/DashboardApp.tsx index 7d9fc92..8653b7d 100644 --- a/web/src/components/DashboardApp.tsx +++ b/web/src/components/DashboardApp.tsx @@ -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) { } /> } /> } /> + } /> + } /> } /> } /> } /> diff --git a/web/src/components/DashboardAppPage.astro b/web/src/components/DashboardAppPage.astro index c0500b5..4b9b6e2 100644 --- a/web/src/components/DashboardAppPage.astro +++ b/web/src/components/DashboardAppPage.astro @@ -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'), }; --- diff --git a/web/src/components/FeedbackPage.tsx b/web/src/components/FeedbackPage.tsx new file mode 100644 index 0000000..0634947 --- /dev/null +++ b/web/src/components/FeedbackPage.tsx @@ -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('bug'); + const [content, setContent] = useState(''); + const [images, setImages] = useState([]); + const [isAnonymous, setIsAnonymous] = useState(false); + const [submitting, setSubmitting] = useState(false); + const [toast, setToast] = useState<{ type: 'success' | 'error'; message: string } | null>(null); + const fileInputRef = useRef(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) => { + 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 ( +
+ {/* Page Header */} +
+ +

{f.title}

+
+ + {/* Toast */} + {toast && ( +
+ {toast.message} +
+ )} + + {/* Feedback Type */} +
+

{f.typeLabel}

+
+ {typeOptions.map((opt) => ( + + ))} +
+
+ + {/* Content */} +
+

{f.contentLabel}

+