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:
@@ -7,14 +7,25 @@ export const API_ROUTES = {
|
||||
},
|
||||
users: {
|
||||
profile: '/api/v1/users/me/profile',
|
||||
updateProfile: '/api/v1/users/me/profile',
|
||||
updateSettings: '/api/v1/users/me/settings',
|
||||
avatarUploadUrl: '/api/v1/users/me/avatar/upload-url',
|
||||
uploadAvatar: '/api/v1/users/me/avatar',
|
||||
},
|
||||
points: {
|
||||
balance: '/api/v1/points/balance',
|
||||
packages: '/api/v1/points/packages',
|
||||
},
|
||||
notifications: {
|
||||
list: '/api/v1/notifications',
|
||||
unreadCount: '/api/v1/notifications/unread-count',
|
||||
markRead: (id: string) => `/api/v1/notifications/${id}/read`,
|
||||
markAllRead: '/api/v1/notifications/mark-all-read',
|
||||
},
|
||||
agent: {
|
||||
history: '/api/v1/agent/history',
|
||||
},
|
||||
feedback: {
|
||||
submit: '/api/v1/feedback',
|
||||
},
|
||||
} as const;
|
||||
|
||||
+182
-3
@@ -11,22 +11,110 @@ import { API_ROUTES } from './api-routes';
|
||||
export interface UserProfile {
|
||||
user_id: string;
|
||||
display_name: string;
|
||||
bio: string;
|
||||
bio: string | null;
|
||||
avatar_path: string | null;
|
||||
avatar_url: string | null;
|
||||
settings: {
|
||||
version: number;
|
||||
preferences: {
|
||||
language: string;
|
||||
timezone: string;
|
||||
};
|
||||
privacy: {
|
||||
can_sell: boolean;
|
||||
profile_visibility: string;
|
||||
};
|
||||
notification: {
|
||||
allow_notifications: boolean;
|
||||
allow_vibration: boolean;
|
||||
};
|
||||
divination_tutorial: {
|
||||
divination_entry_shown: boolean;
|
||||
auto_divination_shown: boolean;
|
||||
manual_divination_shown: boolean;
|
||||
};
|
||||
};
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface UpdateProfileRequest {
|
||||
display_name?: string;
|
||||
bio?: string;
|
||||
avatar_path?: string;
|
||||
}
|
||||
|
||||
export interface ProfileSettings {
|
||||
version: number;
|
||||
preferences: {
|
||||
language: string;
|
||||
timezone: string;
|
||||
};
|
||||
privacy: {
|
||||
can_sell: boolean;
|
||||
profile_visibility: string;
|
||||
};
|
||||
notification: {
|
||||
allow_notifications: boolean;
|
||||
allow_vibration: boolean;
|
||||
};
|
||||
divination_tutorial: {
|
||||
divination_entry_shown: boolean;
|
||||
auto_divination_shown: boolean;
|
||||
manual_divination_shown: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export interface UpdateSettingsRequest {
|
||||
settings: ProfileSettings;
|
||||
}
|
||||
|
||||
export interface AvatarUploadUrlRequest {
|
||||
mime_type: string;
|
||||
file_size: number;
|
||||
ext: string;
|
||||
}
|
||||
|
||||
export interface AvatarUploadUrlResponse {
|
||||
bucket: string;
|
||||
path: string;
|
||||
upload_url: string;
|
||||
expires_in: number;
|
||||
}
|
||||
|
||||
export function getUserProfile(): Promise<UserProfile> {
|
||||
return authFetch<UserProfile>(API_ROUTES.users.profile);
|
||||
}
|
||||
|
||||
export function updateUserProfile(data: UpdateProfileRequest): Promise<UserProfile> {
|
||||
return authFetch<UserProfile>(API_ROUTES.users.updateProfile, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
export function getAvatarUploadUrl(data: AvatarUploadUrlRequest): Promise<AvatarUploadUrlResponse> {
|
||||
return authFetch<AvatarUploadUrlResponse>(API_ROUTES.users.avatarUploadUrl, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
export function uploadAvatar(file: File): Promise<UserProfile> {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
return authFetch<UserProfile>(API_ROUTES.users.uploadAvatar, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
}
|
||||
|
||||
export function updateUserSettings(data: UpdateSettingsRequest): Promise<UserProfile> {
|
||||
return authFetch<UserProfile>(API_ROUTES.users.updateSettings, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
// --- Points ---
|
||||
|
||||
export interface PointsBalance {
|
||||
@@ -37,20 +125,111 @@ export interface PointsBalance {
|
||||
canRun: boolean;
|
||||
}
|
||||
|
||||
export function getPointsBalance(): Promise<PointsBalance> {
|
||||
return authFetch<PointsBalance>(API_ROUTES.points.balance);
|
||||
export interface PackageInfo {
|
||||
productCode: string;
|
||||
appStoreProductId: string;
|
||||
type: 'starter' | 'regular';
|
||||
credits: number;
|
||||
isStarter: boolean;
|
||||
starterEligible: boolean;
|
||||
sortOrder: number;
|
||||
}
|
||||
|
||||
export interface PackagesResponse {
|
||||
packages: PackageInfo[];
|
||||
}
|
||||
|
||||
// Points cache with TTL
|
||||
let pointsCache: { data: PointsBalance; expiry: number } | null = null;
|
||||
const POINTS_CACHE_TTL = 60 * 1000; // 1 minute
|
||||
|
||||
export function getPointsBalance(useCache = true): Promise<PointsBalance> {
|
||||
const now = Date.now();
|
||||
if (useCache && pointsCache && pointsCache.expiry > now) {
|
||||
return Promise.resolve(pointsCache.data);
|
||||
}
|
||||
return authFetch<PointsBalance>(API_ROUTES.points.balance).then((data) => {
|
||||
pointsCache = { data, expiry: now + POINTS_CACHE_TTL };
|
||||
return data;
|
||||
});
|
||||
}
|
||||
|
||||
export function invalidatePointsCache(): void {
|
||||
pointsCache = null;
|
||||
}
|
||||
|
||||
export function getPackages(): Promise<PackagesResponse> {
|
||||
return authFetch<PackagesResponse>(API_ROUTES.points.packages);
|
||||
}
|
||||
|
||||
// --- Notifications ---
|
||||
|
||||
export interface NotificationPayloadNone {
|
||||
action: 'none';
|
||||
}
|
||||
|
||||
export interface NotificationPayloadRoute {
|
||||
action: 'open_route';
|
||||
route: string;
|
||||
entity_id?: string | null;
|
||||
tab?: string | null;
|
||||
}
|
||||
|
||||
export interface NotificationPayloadUrl {
|
||||
action: 'open_url';
|
||||
url: string;
|
||||
}
|
||||
|
||||
export type NotificationPayload = NotificationPayloadNone | NotificationPayloadRoute | NotificationPayloadUrl;
|
||||
|
||||
export interface NotificationItem {
|
||||
id: string;
|
||||
notificationId: string;
|
||||
type: string;
|
||||
title: string;
|
||||
body: string;
|
||||
payload: NotificationPayload;
|
||||
isRead: boolean;
|
||||
readAt: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface NotificationListResponse {
|
||||
items: NotificationItem[];
|
||||
nextCursor: string | null;
|
||||
hasMore: boolean;
|
||||
}
|
||||
|
||||
export interface UnreadCount {
|
||||
count: number;
|
||||
}
|
||||
|
||||
export function getNotifications(locale?: string, limit = 20, cursor?: string): Promise<NotificationListResponse> {
|
||||
const params = new URLSearchParams();
|
||||
params.set('limit', String(limit));
|
||||
if (locale) params.set('locale', locale);
|
||||
if (cursor) params.set('cursor', cursor);
|
||||
const query = params.toString();
|
||||
return authFetch<NotificationListResponse>(`${API_ROUTES.notifications.list}?${query}`);
|
||||
}
|
||||
|
||||
export function getUnreadNotificationCount(): Promise<UnreadCount> {
|
||||
return authFetch<UnreadCount>(API_ROUTES.notifications.unreadCount);
|
||||
}
|
||||
|
||||
export function markNotificationRead(id: string, locale?: string): Promise<NotificationItem> {
|
||||
const params = locale ? `?locale=${locale}` : '';
|
||||
return authFetch<NotificationItem>(API_ROUTES.notifications.markRead(id) + params, {
|
||||
method: 'PATCH',
|
||||
});
|
||||
}
|
||||
|
||||
export function markAllNotificationsRead(): Promise<{ updatedCount: number }> {
|
||||
return authFetch<{ updatedCount: number }>(API_ROUTES.notifications.markAllRead, {
|
||||
method: 'PATCH',
|
||||
});
|
||||
}
|
||||
|
||||
// --- Agent History ---
|
||||
|
||||
export interface HistoryAgentOutput {
|
||||
|
||||
@@ -30,6 +30,34 @@ interface SessionResponse {
|
||||
user: { id: string; email: string };
|
||||
}
|
||||
|
||||
// --- Language mapping ---
|
||||
|
||||
/**
|
||||
* Map frontend locale to backend BCP-47 language tag
|
||||
*/
|
||||
export function localeToBackendLanguage(locale: string): string {
|
||||
const mapping: Record<string, string> = {
|
||||
'zh': 'zh-CN',
|
||||
'zh_Hant': 'zh-TW',
|
||||
'en': 'en-US',
|
||||
};
|
||||
return mapping[locale] || 'zh-CN';
|
||||
}
|
||||
|
||||
/**
|
||||
* Map backend BCP-47 language tag to frontend locale
|
||||
*/
|
||||
export function backendLanguageToLocale(lang: string): string {
|
||||
const mapping: Record<string, string> = {
|
||||
'zh-CN': 'zh',
|
||||
'zh-TW': 'zh_Hant',
|
||||
'zh-Hant': 'zh_Hant',
|
||||
'en-US': 'en',
|
||||
'en': 'en',
|
||||
};
|
||||
return mapping[lang] || 'zh';
|
||||
}
|
||||
|
||||
// --- Storage ---
|
||||
|
||||
export function getAuth(): AuthData | null {
|
||||
|
||||
Reference in New Issue
Block a user