Compare commits
4 Commits
3f0942329d
...
20abe0be0c
| Author | SHA1 | Date | |
|---|---|---|---|
| 20abe0be0c | |||
| 1e4871e337 | |||
| a9739cddce | |||
| 627454971c |
@@ -9,6 +9,7 @@ Read this file before changing the Astro site, React app islands, authenticated
|
||||
## Current Stack
|
||||
|
||||
- Astro 6 for static public pages and route files.
|
||||
- Web production build uses Astro server output with the `@astrojs/node` adapter so client-owned dynamic shell routes such as `/{locale}/history/:id` can be refreshed directly.
|
||||
- React 19 for interactive client UI.
|
||||
- React Router DOM for the authenticated business app shell.
|
||||
- Tailwind CSS 4 through `@tailwindcss/vite`.
|
||||
@@ -26,7 +27,7 @@ Public pages are Astro pages under `web/src/pages/{locale}/` and use `Marketing.
|
||||
Authenticated pages are Astro route shells that all render `DashboardAppPage.astro`. The actual logged-in application is a single React Router app:
|
||||
|
||||
- `DashboardApp.tsx` owns React Router routes for dashboard, store, history, notifications, profile, settings, and divination pages.
|
||||
- `AppShell.tsx` owns the persistent sidebar, mobile drawer, route guard, and authenticated layout.
|
||||
- `AppShell.tsx` owns the persistent sidebar, mobile drawer, route guard, authenticated session recovery, and authenticated layout.
|
||||
- Business page components render only their page body. They must not wrap themselves in `AppShell`.
|
||||
- Sidebar navigation must use React Router navigation so the shell remains mounted and only the right-side content changes.
|
||||
- Direct browser refresh on each existing business route must still render the app shell through Astro.
|
||||
@@ -39,6 +40,7 @@ Login and public marketing/legal pages are not part of the authenticated app she
|
||||
- Test credentials for local verification: `test@example.com` with code `123456`.
|
||||
- Auth state is stored by `web/src/lib/auth.ts` under one local storage key.
|
||||
- Every authenticated route must recover or refresh the session before showing business content.
|
||||
- `AppShell.tsx` is the single owner of authenticated app session recovery. Do not add another client wrapper that also refreshes the session around every authenticated route.
|
||||
- Missing, expired, invalid, or refresh-failed tokens must clear local auth and redirect to `/{locale}/login`.
|
||||
- Do not add silent success paths for auth failures.
|
||||
|
||||
@@ -48,10 +50,61 @@ Login and public marketing/legal pages are not part of the authenticated app she
|
||||
- Shared request behavior lives in `web/src/lib/api-client.ts`.
|
||||
- Auth/session behavior lives in `web/src/lib/auth.ts`.
|
||||
- Business API functions live in `web/src/lib/api.ts`.
|
||||
- Shared authenticated read caching lives in `web/src/lib/data-client.ts` and `web/src/lib/resources.ts`.
|
||||
- Components must call typed API helper functions, not inline `fetch('/api/...')`.
|
||||
- Components that need profile, points, packages, history, notifications, or unread-count data should use the resource hooks/functions from `web/src/lib/resources.ts` instead of starting their own duplicate GET lifecycle.
|
||||
- Dashboard-visible user, points, notification, and history data must come from the backend. Do not hardcode those values.
|
||||
- Production API host is `https://api.meeyao.com`; local dev should use same-origin `/api` and the Vite proxy.
|
||||
|
||||
### Authenticated Data Resource Pattern
|
||||
|
||||
Use this pattern for backend reads that are reused across authenticated pages:
|
||||
|
||||
```typescript
|
||||
// lib/api.ts: transport-only business API
|
||||
export function getPointsBalance(): Promise<PointsBalance> {
|
||||
return authFetch<PointsBalance>(API_ROUTES.points.balance);
|
||||
}
|
||||
|
||||
// lib/resources.ts: cache policy + hook/function surface
|
||||
export function usePoints() {
|
||||
return useResource({
|
||||
key: pointsBalanceKey,
|
||||
ttlMs: 60_000,
|
||||
fetcher: getPointsBalance,
|
||||
staleWhileRevalidate: true,
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
Resource contracts:
|
||||
|
||||
- `lib/api.ts` remains transport-only: no per-endpoint ad hoc memory cache there.
|
||||
- `lib/resources.ts` owns resource keys, TTLs, in-flight dedupe, stale-while-revalidate behavior, prefetch, and mutation invalidation.
|
||||
- `clearAuth()` must clear the shared data cache so authenticated data cannot leak across users.
|
||||
- Resource hooks must support disabled/optional keys for pages where an id may be absent; do not create a fetcher that intentionally rejects during normal render.
|
||||
- Active hooks must refetch after invalidation when they still need the resource.
|
||||
|
||||
Invalidation matrix:
|
||||
|
||||
- Profile, avatar, or settings write -> set the profile resource with the returned backend profile.
|
||||
- Divination run or follow-up completion -> invalidate points and the relevant history list/thread resources.
|
||||
- Notification mark-read -> patch the notification list and decrement unread count when the item changes from unread to read.
|
||||
- Mark-all-notifications-read -> patch the notification list and set unread count to zero.
|
||||
- Logout, expired refresh, or invalid auth -> clear auth and clear all resource data.
|
||||
|
||||
Wrong vs correct:
|
||||
|
||||
```typescript
|
||||
// Wrong: every page starts an independent duplicate GET.
|
||||
useEffect(() => {
|
||||
getUserProfile().then(setProfile);
|
||||
}, []);
|
||||
|
||||
// Correct: subscribe to the shared profile resource.
|
||||
const profileState = useProfile();
|
||||
```
|
||||
|
||||
## Layout Rules
|
||||
|
||||
- Build mobile-first, then add `sm:`, `md:`, `lg:`, and `xl:` refinements.
|
||||
@@ -61,6 +114,13 @@ Login and public marketing/legal pages are not part of the authenticated app she
|
||||
- Mobile sidebar must be reachable through the menu button and must not hide the page content permanently.
|
||||
- Public header mobile navigation must expose feature, pricing, about, login, and language switching.
|
||||
|
||||
### Mobile Guided Overlays
|
||||
|
||||
- Keep one dimming strategy per viewport. Do not combine a full-screen dark overlay with a spotlight element that also uses an oversized outer shadow on the same mobile viewport.
|
||||
- Mobile spotlight targets should fit inside the phone viewport. If a desktop tutorial highlights a tall panel, use a smaller mobile-only target such as the rows or controls that the step actually explains.
|
||||
- Tooltip placement and arrow direction must match: a tooltip above the target uses a bottom arrow pointing down; a tooltip below the target uses a top arrow pointing up.
|
||||
- When the app shell owns scrolling, compute mobile overlay coordinates relative to the page component host and visible scroll container, not the document body.
|
||||
|
||||
## i18n Rules
|
||||
|
||||
- Supported locales: `zh`, `zh_Hant`, `en`.
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
{"file": ".trellis/spec/web/index.md", "reason": "Verify request behavior, authenticated routing, and responsive browser checks."}
|
||||
@@ -0,0 +1 @@
|
||||
{"file": ".trellis/spec/web/index.md", "reason": "Web authenticated app architecture, API, auth, and performance-sensitive layout rules."}
|
||||
@@ -0,0 +1,127 @@
|
||||
# Audit and optimize web performance
|
||||
|
||||
## Goal
|
||||
|
||||
Comprehensively audit and refactor the web frontend data interaction layer while preserving the existing UI. The refactor should reduce repeated HTTP requests, hide US-backend latency where safe, centralize cache/invalidation behavior, and make authenticated page transitions feel faster in both perceived and actual request-count terms.
|
||||
|
||||
## What I already know
|
||||
|
||||
* Backend is hosted in the US, so users far away experience noticeable request latency.
|
||||
* User has observed repeated HTTP requests and avoidable data-loading waits.
|
||||
* Web stack is Astro 6 + React 19 + React Router DOM under `web/`.
|
||||
* Auth state lives in `web/src/lib/auth.ts`; business APIs live in `web/src/lib/api.ts`.
|
||||
* Existing `getPointsBalance()` has a 1-minute in-memory TTL cache, but many other stable reads do not.
|
||||
* Early code scan found repeated `getUserProfile()` calls across `AppShell`, `LoginForm`, `SettingsPage`, `GeneralSettingsPage`, and `ProfileDetailPage`.
|
||||
* `AppShell` already loads profile globally and exposes `UserSettingsContext`; some pages still refetch profile instead of using that context.
|
||||
* Auth refresh now has single-flight behavior from the previous fix, which helps avoid concurrent refresh waste.
|
||||
|
||||
## Assumptions
|
||||
|
||||
* Performance improvements should preserve backend source of truth and auth safety.
|
||||
* Cached authenticated data must be invalidated after writes that change it.
|
||||
* We should prefer a local, typed data-client/resource layer over adding a large state/query dependency unless audit shows the custom approach is too risky.
|
||||
* Optimizations should be observable through request count reduction and improved perceived loading behavior.
|
||||
|
||||
## Requirements
|
||||
|
||||
* Audit all authenticated web pages for duplicate or avoidable requests.
|
||||
* Define and implement a frontend data layer with caching, in-flight dedupe, stale-while-revalidate, prefetch, and explicit invalidation.
|
||||
* Reuse AppShell-loaded profile/settings where possible instead of refetching.
|
||||
* Add cache invalidation for profile/settings/points changes.
|
||||
* Keep UI presentation unchanged at rest; only data source and loading/refresh behavior should change.
|
||||
* Avoid stale or unsafe auth behavior: 401 and refresh failures must still clear auth and redirect.
|
||||
* Improve perceived performance with cached data, route-level reuse, and fewer full-page loading waits.
|
||||
* Document verification with before/after request counts for key flows.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
* [x] Request audit lists endpoints, call sites, duplicate patterns, and priority.
|
||||
* [x] Refactor plan defines target data architecture, resource cache policies, invalidation rules, and phased rollout.
|
||||
* [x] Data client/resource layer is implemented without changing the visible UI design.
|
||||
* [x] Dashboard → settings/profile/general/divination navigation avoids duplicate profile requests where safe.
|
||||
* [x] Points balance requests are deduped in-flight and cached/invalidated intentionally.
|
||||
* [x] Stable package/config-style reads are cached or intentionally left uncached with rationale.
|
||||
* [x] Authenticated pages keep correct behavior after writes and after 401 responses.
|
||||
* [ ] Browser verification captures representative flows under mobile and desktop viewports.
|
||||
* [x] `git diff --check` passes; build/typecheck status documented.
|
||||
|
||||
## Definition of Done
|
||||
|
||||
* PRD updated with final implementation notes.
|
||||
* Targeted code changes committed.
|
||||
* Performance audit and refactor plan recorded in the task directory.
|
||||
* Any reusable conventions captured in `.trellis/spec/web/index.md` or relevant spec.
|
||||
|
||||
## Out of Scope
|
||||
|
||||
* Backend endpoint redesign.
|
||||
* CDN/edge deployment changes.
|
||||
* Offline mode.
|
||||
* Large dependency adoption unless explicitly approved after audit.
|
||||
* Visual redesign of existing pages.
|
||||
|
||||
## Technical Notes
|
||||
|
||||
* Follow `.trellis/spec/web/index.md`.
|
||||
* All API paths remain in `web/src/lib/api-routes.ts`.
|
||||
* Components should call typed API helpers from `web/src/lib/api.ts`, not inline `fetch('/api/...')`.
|
||||
* Target refactor plan: `.trellis/tasks/05-10-audit-and-optimize-web-performance/refactor-plan.md`.
|
||||
* Request audit: `.trellis/tasks/05-10-audit-and-optimize-web-performance/request-audit.md`.
|
||||
* Current build is blocked by existing Astro adapter configuration (`NoAdapterInstalled`) and should be documented unless fixed in a separate task.
|
||||
|
||||
## Implementation Notes - 2026-05-10
|
||||
|
||||
* Added `web/src/lib/data-client.ts`, a typed in-memory query cache with TTL, in-flight request dedupe, `peek`, `set`, prefix `invalidate`, `prefetch`, `subscribe`, and `clearAll`.
|
||||
* Added `web/src/lib/resources.ts` for profile, points, packages, history list/thread/summary, notifications list, and unread-count cache policy plus React resource hooks.
|
||||
* Moved points TTL behavior out of `api.ts`; `getPointsBalance()` is now transport-only and points caching lives in the resource layer.
|
||||
* AppShell now loads profile through the profile resource, subscribes to profile cache changes, clears data cache via `clearAuth()`, prefetches cheap dashboard data after auth, and prefetches route resources on nav hover/focus.
|
||||
* Settings, general settings, profile detail, dashboard, store, history list, notifications, manual/auto divination, divination processing, result, and follow-up pages now reuse resource caches instead of owning duplicate GET lifecycles.
|
||||
* Mutations patch or invalidate shared cache: profile/settings/avatar set profile; notification read/all-read patch list and unread count; divination/follow-up completion invalidates points and history.
|
||||
* Quality check fixed two post-implementation issues: invalidated active resources now refetch instead of staying empty/stale, and `useHistoryThread(undefined)` no longer emits a doomed request when result pages rely on router/session state.
|
||||
* Verification: `npm exec astro sync` passed; `git diff --check` passed; `npm run build` remains blocked by existing Astro `NoAdapterInstalled`; temporary `tsc --noEmit` check reports existing `DashboardApp.tsx` translation-map typing errors after refactor-specific type issues were fixed.
|
||||
* Browser verification note: the in-app browser was switched to a visible 390x844 mobile viewport with the dev server on `127.0.0.1:4322`, but the automation surface only exposed the in-app browser toolbar during this run, so request-count capture still needs a follow-up pass with a usable page automation surface.
|
||||
|
||||
## Implementation Notes - 2026-05-10 Second Pass
|
||||
|
||||
* Fixed the Astro build blocker without breaking existing direct-refresh history URLs by adding the `@astrojs/node` adapter and switching the web build to server output. The six `prerender = false` dynamic history shell pages remain in place for `/{locale}/history/:id` and `/{locale}/history/:id/followup`.
|
||||
* New in-app links still use the static shell URLs with `threadId` query parameters: `/{locale}/history/result?threadId=...` and `/{locale}/history/followup?threadId=...`, while the legacy direct URLs continue to resolve through Astro and React Router.
|
||||
* Split authenticated page bodies in `DashboardApp.tsx` with `React.lazy` and `Suspense`, keeping `AppShell`, nav config, auth guard, and link interception eager. The initial `DashboardApp` chunk dropped from the prior ~142 KB shape to ~54 KB in the built output, with heavy pages emitted as separate chunks.
|
||||
* Optimized static image assets in place under `web/design/assets/images` while preserving filenames and references. Largest qigua result images are now about 125-130 KB each, coin images about 26-27 KB, logo about 42 KB, and tutorial images reduced from roughly 800 KB / 2.5 MB / 3.0 MB to about 372 KB / 1.0 MB / 1.2 MB.
|
||||
* Added `primeHistoryThreadFromSnapshot()` so dashboard/history-list clicks seed the thread resource from an already-loaded history snapshot before navigating to the result page. This avoids an immediate duplicate thread GET when list data already contains the target thread.
|
||||
* Added real web typecheck dependencies (`@astrojs/check`, `typescript`, `@types/node`) and fixed the surfaced type gaps: `DashboardApp` now uses typed i18n sections, settings route shells reuse `DashboardAppPage.astro`, and the about-page ICP fields are present in all locales.
|
||||
|
||||
## Verification - 2026-05-10 Second Pass
|
||||
|
||||
* `npm run build` in `web/`: passed. Astro built server output with `@astrojs/node`, preserving on-demand dynamic history shells.
|
||||
* `npm exec astro sync` in `web/`: passed.
|
||||
* `git diff --check`: passed.
|
||||
* `npm exec tsc -- --noEmit`: passed.
|
||||
* `npm exec astro check`: passed with 0 errors; remaining diagnostics are hints for pre-existing cleanup candidates.
|
||||
* Server preview smoke via `astro preview` on `127.0.0.1:4322`: `GET /zh/dashboard`, `/zh/history/smoke-thread`, `/zh/history/smoke-thread/followup`, `/zh/history/result?threadId=smoke-thread`, `/zh/history/followup?threadId=smoke-thread`, `/en/history/smoke-thread`, and `/zh_Hant/history/smoke-thread/followup` all returned `200 text/html`.
|
||||
* Built client chunks: `DashboardApp` is ~54 KB, with lazy page chunks emitted separately (`ManualDivinationPage` ~19 KB, `AutoDivinationPage` ~19 KB, `DivinationResultPage` ~14 KB, `HistoryFollowUpPage` ~9 KB, `HistoryListPage` ~7 KB). Built asset folders: `dist/client/_astro` ~520 KB and `dist/client/images` ~4.2 MB.
|
||||
|
||||
## Implementation Notes - 2026-05-10 Third Pass
|
||||
|
||||
* Removed the authenticated layout-level `AuthProvider` wrapper and deleted the unused provider/context file. `AppShell` remains the only authenticated session recovery and route-guard owner for dashboard routes.
|
||||
* Login and existing-session checks now use the shared profile resource instead of calling `getUserProfile()` directly, so login-to-dashboard can reuse the profile cache that was already fetched for language redirect decisions.
|
||||
* Successful email login clears the shared data cache before storing the new session to avoid carrying authenticated resource data across account changes.
|
||||
|
||||
## Verification - 2026-05-10 Third Pass
|
||||
|
||||
* `npm exec astro sync` in `web/`: passed.
|
||||
* `npm exec tsc -- --noEmit` in `web/`: passed.
|
||||
* `npm exec astro check` in `web/`: passed with 0 errors; remaining diagnostics are hints.
|
||||
* `npm run build` in `web/`: passed. Vite reported the existing `auth.ts` mixed static/dynamic import chunking warning.
|
||||
* `git diff --check`: passed.
|
||||
|
||||
## Implementation Notes - 2026-05-10 Fourth Pass
|
||||
|
||||
* Fresh email-code login submit now redirects using the locale implied by the submitted backend language (`localeToBackendLanguage(locale)` -> `backendLanguageToLocale(language)`) instead of fetching profile before navigation. This removes the avoidable profile GET whose in-memory cache was discarded by the full-page dashboard load.
|
||||
* Existing-auth login-page recovery still fetches profile after token refresh because the stored profile language preference is the authoritative source for redirecting an already-authenticated user from the login page. The current resource layer is intentionally in-memory only, so it cannot provide a reload-safe handoff for `window.location.href`; optimizing the fresh-login side avoids the waste while preserving stored-preference redirects for recovered sessions.
|
||||
|
||||
## Verification - 2026-05-10 Fourth Pass
|
||||
|
||||
* `npm exec tsc -- --noEmit` in `web/`: passed.
|
||||
* `npm exec astro check` in `web/`: passed with 0 errors; remaining diagnostics are hints.
|
||||
* `npm run build` in `web/`: passed. Vite reported the existing `auth.ts` mixed static/dynamic import chunking warning.
|
||||
* `git diff --check`: passed.
|
||||
@@ -0,0 +1,383 @@
|
||||
# Web data interaction refactor plan
|
||||
|
||||
## Executive summary
|
||||
|
||||
The current web app has solid typed API helpers, but it does not yet have an application data layer. Most pages call backend endpoints directly inside local `useEffect` blocks, so route changes repeatedly reload the same user/profile/points/history data. This is especially visible when the backend is far away because every avoidable request becomes a user-visible wait.
|
||||
|
||||
The recommended refactor is to keep the UI unchanged and introduce a lightweight, project-owned data layer that centralizes:
|
||||
|
||||
* request in-flight dedupe
|
||||
* TTL cache and stale-while-revalidate behavior
|
||||
* explicit invalidation after writes
|
||||
* route-level prefetch
|
||||
* shared resource state for pages
|
||||
* optimistic local updates where safe
|
||||
|
||||
Do not start by sprinkling `localStorage` or ad hoc memoization into components. That would reduce some requests but preserve the deeper problem: every component still owns its own data lifecycle.
|
||||
|
||||
## Current architecture diagnosis
|
||||
|
||||
### 1. API helpers are typed, but not stateful
|
||||
|
||||
`web/src/lib/api.ts` provides typed functions such as `getUserProfile`, `getPointsBalance`, `getAgentHistory`, and `getNotifications`. Except for points, those functions always hit the backend.
|
||||
|
||||
This is clean as a transport layer, but incomplete as a frontend data layer.
|
||||
|
||||
### 2. Components own network lifecycle
|
||||
|
||||
Examples:
|
||||
|
||||
* `AppShell` fetches profile globally, but `SettingsPage`, `GeneralSettingsPage`, and `ProfileDetailPage` refetch profile.
|
||||
* `Dashboard` fetches points/history/unread count on mount.
|
||||
* `HistoryListPage` fetches history again even if dashboard just fetched it.
|
||||
* `StorePage` fetches packages on mount even though packages are stable config-style data.
|
||||
* `NotificationPage` owns its own list and mark-read updates without updating dashboard unread-count cache.
|
||||
|
||||
Each page is locally correct, but globally wasteful.
|
||||
|
||||
### 3. Loading states are page-local
|
||||
|
||||
Because data state is local to each page, the UI often transitions from a populated page to a blank/loading page even if relevant data was just fetched elsewhere. This makes high-latency backend access feel worse than it needs to.
|
||||
|
||||
### 4. Mutations do not have a shared invalidation model
|
||||
|
||||
Examples:
|
||||
|
||||
* `updateUserSettings` returns updated profile, but only the calling component updates local state.
|
||||
* `updateUserProfile` / `uploadAvatar` can leave `AppShell` profile stale unless context is manually updated.
|
||||
* `markNotificationRead` updates the notification page list, but dashboard unread count is not centrally invalidated.
|
||||
* `enqueueDivinationRun` should invalidate points and history after success, but that is not represented in a central policy.
|
||||
|
||||
## Target architecture
|
||||
|
||||
### Layer 1: transport stays simple
|
||||
|
||||
Keep `authFetch`, `authFetchRaw`, `apiUrl`, `api-routes`, and RFC7807 parsing as the low-level transport boundary.
|
||||
|
||||
Responsibilities:
|
||||
|
||||
* auth refresh and 401 handling
|
||||
* JSON request/response parsing
|
||||
* SSE raw response support
|
||||
* no UI cache knowledge
|
||||
|
||||
### Layer 2: API functions remain typed commands
|
||||
|
||||
Keep `web/src/lib/api.ts` as the typed backend API command layer.
|
||||
|
||||
Responsibilities:
|
||||
|
||||
* endpoint-specific payload construction
|
||||
* response types
|
||||
* backend data mapping helpers
|
||||
* no React hooks
|
||||
|
||||
Change:
|
||||
|
||||
* Remove ad hoc points-only cache from this file.
|
||||
* Move caching into a dedicated data/cache layer.
|
||||
|
||||
### Layer 3: new app data client
|
||||
|
||||
Add `web/src/lib/data-client.ts` or `web/src/lib/query-client.ts`.
|
||||
|
||||
Core capabilities:
|
||||
|
||||
```ts
|
||||
type CacheKey = readonly string[];
|
||||
|
||||
interface CacheEntry<T> {
|
||||
data?: T;
|
||||
error?: unknown;
|
||||
updatedAt: number;
|
||||
expiresAt: number;
|
||||
promise?: Promise<T>;
|
||||
}
|
||||
|
||||
interface QueryOptions<T> {
|
||||
key: CacheKey;
|
||||
ttlMs: number;
|
||||
fetcher: () => Promise<T>;
|
||||
staleWhileRevalidate?: boolean;
|
||||
}
|
||||
```
|
||||
|
||||
Required operations:
|
||||
|
||||
* `query<T>(options): Promise<T>`: returns fresh data, dedupes in-flight request.
|
||||
* `peek<T>(key): T | undefined`: synchronous read for instant UI seeding.
|
||||
* `set<T>(key, data, ttlMs)`: update cache after mutations.
|
||||
* `invalidate(keyPrefix)`: remove or mark stale.
|
||||
* `prefetch(options)`: warm cache without forcing UI loading.
|
||||
* `clearAll()`: call on logout or terminal auth failure.
|
||||
|
||||
Why local helper first:
|
||||
|
||||
* Project currently has no query library.
|
||||
* The data model is small enough to solve with a typed helper.
|
||||
* Avoid adding dependency and architecture churn before measuring gains.
|
||||
|
||||
Future option:
|
||||
|
||||
* If data flows grow, migrate the same key model to TanStack Query later. The refactor should name cache keys and invalidation policies in a way that would map cleanly.
|
||||
|
||||
### Layer 4: resource-specific services
|
||||
|
||||
Add resource wrappers, for example:
|
||||
|
||||
* `web/src/lib/resources/profile.ts`
|
||||
* `web/src/lib/resources/points.ts`
|
||||
* `web/src/lib/resources/store.ts`
|
||||
* `web/src/lib/resources/history.ts`
|
||||
* `web/src/lib/resources/notifications.ts`
|
||||
|
||||
Each resource owns:
|
||||
|
||||
* cache key
|
||||
* TTL
|
||||
* fetch function
|
||||
* invalidation after writes
|
||||
* local patch helpers
|
||||
|
||||
Example:
|
||||
|
||||
```ts
|
||||
export const profileKey = ['profile'] as const;
|
||||
|
||||
export function getProfileResource() {
|
||||
return query({
|
||||
key: profileKey,
|
||||
ttlMs: 5 * 60_000,
|
||||
fetcher: getUserProfile,
|
||||
staleWhileRevalidate: true,
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateProfileResource(input: UpdateProfileRequest) {
|
||||
const updated = await updateUserProfile(input);
|
||||
set(profileKey, updated, PROFILE_TTL);
|
||||
return updated;
|
||||
}
|
||||
```
|
||||
|
||||
### Layer 5: React provider and hooks
|
||||
|
||||
Add `AppDataProvider` under the authenticated app shell.
|
||||
|
||||
Hooks:
|
||||
|
||||
* `useResource(key, loader, options)`
|
||||
* `useProfile()`
|
||||
* `usePoints()`
|
||||
* `useHistorySummary()`
|
||||
* `useHistoryThread(threadId)`
|
||||
* `useNotifications(locale)`
|
||||
* `useUnreadCount()`
|
||||
|
||||
Hook behavior:
|
||||
|
||||
* seed UI from `peek()`
|
||||
* return cached data immediately when available
|
||||
* refresh in background when stale
|
||||
* expose `{ data, loading, refreshing, error, reload }`
|
||||
* never bypass auth 401 behavior
|
||||
|
||||
This keeps UI unchanged: pages still render the same cards/lists/forms, but data source shifts from local `useEffect` to shared hooks.
|
||||
|
||||
## Cache policy by data domain
|
||||
|
||||
| Domain | Key | TTL | Stale UI allowed | Invalidate on |
|
||||
| --- | --- | ---: | --- | --- |
|
||||
| Auth session | localStorage `meeyao_auth` | token expiry | no | logout, 401 refresh failure |
|
||||
| Profile/settings | `['profile']` | 5 min | yes | profile update, settings update, avatar upload, logout |
|
||||
| Points balance | `['points', 'balance']` | 30-60 sec | yes | divination run accepted/success, purchase, manual reload, logout |
|
||||
| Packages | `['points', 'packages']` | 30 min | yes | app reload, explicit admin/package changes if added later |
|
||||
| Dashboard history summary | `['history', 'summary']` | 60 sec | yes | divination run success, follow-up success, session delete |
|
||||
| History full list | `['history', 'list']` | 60 sec | yes | divination run success, follow-up success, session delete |
|
||||
| History thread | `['history', 'thread', threadId]` | 2-5 min | yes | follow-up success for same thread, session delete |
|
||||
| Notifications list | `['notifications', locale]` | 60 sec | yes | mark read/all read, new notification polling if added |
|
||||
| Unread count | `['notifications', 'unread-count']` | 30 sec | yes | mark read/all read, notification list refresh |
|
||||
|
||||
## Display-state model
|
||||
|
||||
Every page should distinguish:
|
||||
|
||||
* `loading`: no cached data yet and initial request is pending
|
||||
* `refreshing`: cached data is visible while background request updates it
|
||||
* `error`: no usable data, or background refresh failed
|
||||
* `stale`: data is visible but may be older than TTL
|
||||
|
||||
UI stays visually the same, but behavior improves:
|
||||
|
||||
* If cached data exists, do not show full-page loading.
|
||||
* Use existing skeleton/spinner only for first load.
|
||||
* On refresh failure, keep visible stale data and show a small non-blocking error only where useful.
|
||||
|
||||
## Route prefetch strategy
|
||||
|
||||
In `AppShell`, prefetch cheap likely-next data:
|
||||
|
||||
* After auth/profile load:
|
||||
* points balance
|
||||
* unread notification count
|
||||
* dashboard history summary
|
||||
* On sidebar hover/focus/click intent:
|
||||
* Store: packages + points
|
||||
* History: history list
|
||||
* Settings/Profile/General: profile
|
||||
* Divination manual/auto: points
|
||||
* Notifications: notification list + unread count
|
||||
|
||||
Prefetch must be bounded:
|
||||
|
||||
* only authenticated routes
|
||||
* no SSE prefetch
|
||||
* no mutation prefetch
|
||||
* no repeated prefetch while in-flight or fresh
|
||||
|
||||
## Mutation and invalidation policy
|
||||
|
||||
### Profile/settings
|
||||
|
||||
* `updateUserProfile` and `uploadAvatar`
|
||||
* set `['profile']` to returned profile
|
||||
* update `AppShell` user context immediately
|
||||
* `updateUserSettings`
|
||||
* set `['profile']` to returned profile
|
||||
* redirect language only after cache/context update
|
||||
|
||||
### Points/store
|
||||
|
||||
* Purchase flow or future payment success:
|
||||
* invalidate `['points', 'balance']`
|
||||
* Divination run:
|
||||
* optimistically mark points stale when run is accepted
|
||||
* invalidate points and history after run finishes
|
||||
|
||||
### History
|
||||
|
||||
* Divination run success:
|
||||
* set result in sessionStorage/router state as today
|
||||
* invalidate history summary/list
|
||||
* optionally seed `['history', 'thread', threadId]` if enough data exists
|
||||
* Follow-up success:
|
||||
* invalidate `['history', 'thread', threadId]`
|
||||
* invalidate summary/list
|
||||
|
||||
### Notifications
|
||||
|
||||
* Mark one read:
|
||||
* patch `['notifications', locale]`
|
||||
* decrement `['notifications', 'unread-count']` locally
|
||||
* Mark all read:
|
||||
* patch list to all read
|
||||
* set unread count to 0
|
||||
|
||||
## Implementation phases
|
||||
|
||||
### Phase 1: measurement and guardrails
|
||||
|
||||
Deliverables:
|
||||
|
||||
* Add a dev-only request logger around `authFetch`/`apiRequest`.
|
||||
* Capture before counts for:
|
||||
* login -> dashboard
|
||||
* dashboard -> settings -> profile -> general
|
||||
* dashboard -> manual/auto divination
|
||||
* dashboard -> history
|
||||
* dashboard -> store
|
||||
* Keep UI unchanged.
|
||||
|
||||
Acceptance:
|
||||
|
||||
* We can prove before/after request count improvements.
|
||||
|
||||
### Phase 2: data client foundation
|
||||
|
||||
Deliverables:
|
||||
|
||||
* Add typed cache/data client.
|
||||
* Move existing points TTL cache into the data client.
|
||||
* Add clear-on-logout/auth-failure hook.
|
||||
* Add unit-light self checks where possible, or targeted browser checks if test infra is absent.
|
||||
|
||||
Acceptance:
|
||||
|
||||
* In-flight duplicate calls collapse into one request.
|
||||
* Cache invalidation is explicit and inspectable.
|
||||
|
||||
### Phase 3: profile/settings context refactor
|
||||
|
||||
Deliverables:
|
||||
|
||||
* AppShell uses profile resource.
|
||||
* SettingsPage/ProfileDetailPage/GeneralSettingsPage use profile resource/context instead of unconditional fetch.
|
||||
* Mutations update profile cache and shell state.
|
||||
|
||||
Acceptance:
|
||||
|
||||
* Dashboard -> settings -> profile -> general should not issue repeated `GET /users/me/profile` while profile is fresh.
|
||||
|
||||
### Phase 4: points, packages, dashboard
|
||||
|
||||
Deliverables:
|
||||
|
||||
* Points uses data client TTL + in-flight dedupe.
|
||||
* Packages cached with long TTL.
|
||||
* Dashboard data uses shared resources.
|
||||
* Store/divination/settings reuse points cache.
|
||||
|
||||
Acceptance:
|
||||
|
||||
* Dashboard -> store -> divination does not repeatedly fetch points/packages while fresh.
|
||||
|
||||
### Phase 5: history and notifications
|
||||
|
||||
Deliverables:
|
||||
|
||||
* History list/summary/thread resources.
|
||||
* Dashboard and HistoryListPage share cached history.
|
||||
* Notification list/unread resources with local patch after mark-read.
|
||||
|
||||
Acceptance:
|
||||
|
||||
* Dashboard -> history reuses fresh history data.
|
||||
* Mark-read updates unread count without waiting for dashboard reload.
|
||||
|
||||
### Phase 6: perceived-performance polish
|
||||
|
||||
Deliverables:
|
||||
|
||||
* Replace full-page loading on cached routes with stale data + subtle refreshing state.
|
||||
* Add bounded route prefetch in AppShell.
|
||||
* Add regression browser checks for mobile/desktop.
|
||||
|
||||
Acceptance:
|
||||
|
||||
* UI remains visually unchanged at rest.
|
||||
* Route transitions feel instant when data is cached.
|
||||
|
||||
## Risks and mitigations
|
||||
|
||||
| Risk | Mitigation |
|
||||
| --- | --- |
|
||||
| Stale user-visible data | Short TTL for volatile data; explicit invalidation after writes. |
|
||||
| Auth cache leaking between users | Clear all data cache on logout, missing auth, refresh failure, and user id change. |
|
||||
| Hidden background refresh errors | Keep stale data visible but expose reload/error state for first-load failures. |
|
||||
| Over-prefetch increasing traffic | Prefetch only small GET endpoints and rely on in-flight/fresh checks. |
|
||||
| Refactor touching too many pages at once | Ship by phases with request-count verification after each phase. |
|
||||
| UI drift | Do not alter markup/layout except replacing data source/loading conditions. |
|
||||
|
||||
## Recommended final architecture
|
||||
|
||||
```
|
||||
React Page
|
||||
-> useProfile/usePoints/useHistory/useNotifications
|
||||
-> Resource wrapper (keys, TTL, invalidation)
|
||||
-> DataClient (cache, in-flight dedupe, stale-while-revalidate)
|
||||
-> typed API functions in api.ts
|
||||
-> authFetch/apiRequest transport
|
||||
-> backend
|
||||
```
|
||||
|
||||
The core principle: components describe what data they need; resource wrappers decide when the backend actually needs to be called.
|
||||
@@ -0,0 +1,60 @@
|
||||
# Web request audit
|
||||
|
||||
## Initial request topology
|
||||
|
||||
### App shell and auth
|
||||
|
||||
| Area | Request(s) | Current behavior | Waste / risk |
|
||||
| --- | --- | --- | --- |
|
||||
| `AppShell` | `refreshAccessToken()`, `getUserProfile()` | Runs on authenticated shell mount. Profile is stored in `UserSettingsContext`. | This should be the primary profile/settings source for child pages. |
|
||||
| `LoginForm` existing auth check | `refreshAccessToken()`, `getUserProfile()` | Valid existing session fetches profile to choose locale before redirect. | Acceptable, but could reuse stored auth language if persisted in future. |
|
||||
| `LoginForm` submit | `loginWithEmail()`, `getUserProfile()` | Login response lacks settings, so profile is fetched to choose locale. | Acceptable until backend returns language/settings in auth response. |
|
||||
|
||||
### Duplicate profile/settings reads
|
||||
|
||||
| Page | Current request | Better behavior |
|
||||
| --- | --- | --- |
|
||||
| `SettingsPage` | `getUserProfile()` + `getPointsBalance()` | Reuse `UserSettingsContext.userProfile`; fetch only points. |
|
||||
| `GeneralSettingsPage` | `getUserProfile()` | Seed from `UserSettingsContext.userProfile`; fetch only if context missing. Update context after `updateUserSettings()`. |
|
||||
| `ProfileDetailPage` | `getUserProfile()` | Seed from `UserSettingsContext.userProfile`; fetch only if context missing. Update context after `updateUserProfile()` / avatar upload. |
|
||||
| `ManualDivinationPage` / `AutoDivinationPage` | import `getUserProfile` but rely on context for profile; `getUserProfile` import appears unused | Remove unused import and keep using context. |
|
||||
|
||||
### Points and store reads
|
||||
|
||||
| Page | Current request | Better behavior |
|
||||
| --- | --- | --- |
|
||||
| `Dashboard` | `getPointsBalance()`, `getUnreadNotificationCount()`, `getAgentHistory()` | Points has TTL cache, but should dedupe in-flight. History can be shared with history list via short TTL cache. |
|
||||
| `SettingsPage` | `getPointsBalance()` | Keep cache, add in-flight dedupe and stale-while-revalidate option. |
|
||||
| `StorePage` | `getPointsBalance()`, `getPackages()` | Cache packages for a longer TTL because packages are stable configuration. Keep explicit invalidation after purchase/payment flows. |
|
||||
| `ManualDivinationPage` / `AutoDivinationPage` | `getPointsBalance()` | Reuse points cache/in-flight dedupe; consider prefetch when entering divination nav group. |
|
||||
|
||||
### History and notifications
|
||||
|
||||
| Area | Current request | Better behavior |
|
||||
| --- | --- | --- |
|
||||
| `Dashboard` | `getAgentHistory()` for latest four | Add short TTL/in-flight cache for history summary; history list can reuse if still fresh. |
|
||||
| `HistoryListPage` | `getAgentHistory()` full list | Reuse cache populated by dashboard, then refresh in background. |
|
||||
| `NotificationPage` | `getNotifications(locale)` | Cache list briefly per locale. Invalidate/update locally after mark-read actions. |
|
||||
| Dashboard unread badge | `getUnreadNotificationCount()` | Cache briefly; invalidate/update after mark-read or mark-all-read. |
|
||||
|
||||
## Priority plan
|
||||
|
||||
1. Add a small typed cache helper in `web/src/lib/api.ts` or adjacent `web/src/lib/api-cache.ts` for:
|
||||
- TTL cache
|
||||
- in-flight promise dedupe
|
||||
- explicit invalidation
|
||||
- optional stale return with background refresh where UI can support it
|
||||
2. Apply first to profile/settings, points, packages, history summary/list, notification list/count.
|
||||
3. Refactor pages to use `UserSettingsContext` for profile reads.
|
||||
4. Add prefetch hooks at AppShell/nav boundaries where it is cheap and safe.
|
||||
5. Browser-verify request count reductions on:
|
||||
- login -> dashboard
|
||||
- dashboard -> settings -> profile -> general
|
||||
- dashboard -> manual/auto divination
|
||||
- dashboard -> history
|
||||
- dashboard -> store
|
||||
|
||||
## Open implementation questions
|
||||
|
||||
* Whether to implement a minimal local cache helper or introduce a query library. Current repo has no query dependency, so default recommendation is a minimal helper first.
|
||||
* Whether auth login response should eventually include profile language/settings to avoid immediate post-login profile fetch. This would be a backend contract change and is out of scope for the first frontend-only optimization pass.
|
||||
@@ -0,0 +1,376 @@
|
||||
# Research: Remaining web performance bottlenecks after 1e4871e
|
||||
|
||||
- **Query**: Deeply audit the web frontend for remaining performance bottlenecks after commit 1e4871e. Focus on repeated authenticated requests still possible, first-load route waterfall, oversized static assets, bundle/code-splitting opportunities, build-layer blocker (`NoAdapterInstalled`), dev/prod config, and measurable request/cache improvements.
|
||||
- **Scope**: mixed
|
||||
- **Date**: 2026-05-10
|
||||
|
||||
## Findings
|
||||
|
||||
### Files Found
|
||||
|
||||
| File Path | Description |
|
||||
|---|---|
|
||||
| `web/src/lib/data-client.ts:77` | In-memory query cache with in-flight dedupe, TTL, stale-while-revalidate, invalidation, subscriptions. |
|
||||
| `web/src/lib/resources.ts:46` | Resource keys, TTLs, hooks, prefetch policies, and mutation invalidation wrappers. |
|
||||
| `web/src/components/AppShell.tsx:74` | Authenticated shell boot sequence: refresh token, load profile resource, then prefetch dashboard basics. |
|
||||
| `web/src/components/AuthProvider.tsx:37` | Separate auth recovery wrapper used by `AppLayout`, causing another refresh lifecycle before/alongside `AppShell`. |
|
||||
| `web/src/components/DashboardApp.tsx:1` | Eagerly imports every authenticated route component into one React island. |
|
||||
| `web/src/components/DashboardAppPage.astro:26` | Mounts the full dashboard app with `client:only="react"` for every authenticated route page. |
|
||||
| `web/src/pages/*/history/[id].astro:2` | Dynamic history result routes opt out of prerendering with `export const prerender = false`. |
|
||||
| `web/src/pages/*/history/[id]/followup.astro:2` | Dynamic follow-up routes opt out of prerendering with `export const prerender = false`. |
|
||||
| `web/astro.config.mjs:6` | No Astro adapter configured; Vite dev proxy points `/api` at production API. |
|
||||
| `web/src/layouts/App.astro:19` | Loads full Material Symbols variable font CSS from Google on authenticated app pages. |
|
||||
| `web/src/layouts/Marketing.astro:20` | Loads same full Material Symbols font CSS on marketing pages. |
|
||||
| `web/design/assets/images/**` | Source static images include multiple 0.6 MB to 3.0 MB PNG/JPG files. |
|
||||
| `web/public/` | Only contains favicon files; current code references `/images/logo.png` and `/images/qigua/*.jpg`. |
|
||||
| `web/dist/**` | Existing build output from an earlier run contains copied large image assets and one large dashboard JS chunk; current build cannot regenerate it. |
|
||||
|
||||
### Code Patterns
|
||||
|
||||
#### Priority 1: build remains blocked, which prevents reliable production measurement
|
||||
|
||||
`pnpm run build` fails:
|
||||
|
||||
```text
|
||||
[NoAdapterInstalled] Cannot use server-rendered pages without an adapter.
|
||||
```
|
||||
|
||||
The blocker is directly explained by six dynamic Astro route files that export `prerender = false`:
|
||||
|
||||
```astro
|
||||
// web/src/pages/en/history/[id].astro:2
|
||||
export const prerender = false;
|
||||
```
|
||||
|
||||
The same pattern exists in:
|
||||
|
||||
- `web/src/pages/en/history/[id].astro:2`
|
||||
- `web/src/pages/zh/history/[id].astro:2`
|
||||
- `web/src/pages/zh_Hant/history/[id].astro:2`
|
||||
- `web/src/pages/en/history/[id]/followup.astro:2`
|
||||
- `web/src/pages/zh/history/[id]/followup.astro:2`
|
||||
- `web/src/pages/zh_Hant/history/[id]/followup.astro:2`
|
||||
|
||||
These routes do not use server-only data. They only read `Astro.params.id` and render the same client-side `DashboardAppPage`:
|
||||
|
||||
```astro
|
||||
// web/src/pages/en/history/[id].astro:4-10
|
||||
import DashboardAppPage from '../../../components/DashboardAppPage.astro';
|
||||
|
||||
const locale = 'en' as const;
|
||||
const { id } = Astro.params;
|
||||
---
|
||||
|
||||
<DashboardAppPage locale={locale} />
|
||||
```
|
||||
|
||||
Implementation steps:
|
||||
|
||||
1. If deployment target is static hosting, remove `export const prerender = false` and add a static fallback strategy for client-owned history routes, e.g. Astro static dynamic route support with `getStaticPaths()` only if finite paths exist, or replace file-based `[id]` pages with a static catch-all/client app entry supported by the host rewrite rules.
|
||||
2. If deployment target requires on-demand rendering, add the correct Astro server adapter in `web/astro.config.mjs` and the matching package in `web/package.json`.
|
||||
3. Make build success the gate before bundle-size, image, and request-count acceptance data are considered final.
|
||||
|
||||
#### Priority 2: first authenticated load can still spend requests on duplicate auth/session work
|
||||
|
||||
`AppLayout` wraps every authenticated route with `AuthProvider client:load`:
|
||||
|
||||
```astro
|
||||
// web/src/layouts/App.astro:23-25
|
||||
<AuthProvider client:load>
|
||||
<slot />
|
||||
</AuthProvider>
|
||||
```
|
||||
|
||||
`AuthProvider` refreshes on mount regardless of whether the access token is still fresh:
|
||||
|
||||
```tsx
|
||||
// web/src/components/AuthProvider.tsx:37-45
|
||||
const auth = getAuth();
|
||||
if (!auth?.refresh_token) {
|
||||
clearAuth();
|
||||
redirectToLogin();
|
||||
return;
|
||||
}
|
||||
refreshAccessToken()
|
||||
```
|
||||
|
||||
`AppShell` then has its own auth boot sequence and also calls `refreshAccessToken()` before loading profile:
|
||||
|
||||
```tsx
|
||||
// web/src/components/AppShell.tsx:82-86
|
||||
refreshAccessToken()
|
||||
.then((data) => {
|
||||
if (alive) setAuthUser(data.user);
|
||||
return getProfileResource();
|
||||
})
|
||||
```
|
||||
|
||||
The single-flight refresh promise in `web/src/lib/auth.ts:172` dedupes concurrent refreshes, but this is still a redundant lifecycle. Depending on hydration timing, it can be one shared refresh or two sequential refresh calls. It also delays the authenticated shell behind two components that both show full-screen loading spinners.
|
||||
|
||||
Implementation steps:
|
||||
|
||||
1. Collapse authenticated-route auth boot into one owner. Prefer `AppShell` because it already owns profile, locale redirect, nav prefetch, and user context.
|
||||
2. Remove `AuthProvider` from `AppLayout` if no routed child consumes `useAuth()`, or turn it into a passive context seeded from `getAuth()` without forcing refresh.
|
||||
3. In the remaining boot path, call `refreshAccessToken()` only when `isTokenExpired()` is true; otherwise seed `authUser` from `getAuth().user`.
|
||||
4. Measure initial `/zh/dashboard` load request count before and after. Target: avoid a refresh request when the token is fresh, and keep one refresh maximum when expired.
|
||||
|
||||
#### Priority 3: login to dashboard still repeats profile/session requests
|
||||
|
||||
`LoginForm` checks existing sessions and submit success by calling `getUserProfile()` directly:
|
||||
|
||||
```tsx
|
||||
// web/src/components/LoginForm.tsx:59-63
|
||||
await refreshAccessToken();
|
||||
const profile = await getUserProfile();
|
||||
const userLocale = backendLanguageToLocale(profile.settings?.preferences?.language || 'zh-CN');
|
||||
window.location.href = `/${userLocale}/dashboard`;
|
||||
```
|
||||
|
||||
```tsx
|
||||
// web/src/components/LoginForm.tsx:100-104
|
||||
await loginWithEmail(email, code, language, timezone);
|
||||
const profile = await getUserProfile();
|
||||
const userLocale = backendLanguageToLocale(profile.settings?.preferences?.language || language);
|
||||
window.location.href = `/${userLocale}/dashboard`;
|
||||
```
|
||||
|
||||
Because the redirect reloads the app, the in-memory profile resource is empty on the dashboard. `AppShell` then fetches profile again through `getProfileResource()` at `web/src/components/AppShell.tsx:85`.
|
||||
|
||||
Implementation steps:
|
||||
|
||||
1. Short term: after login, redirect to `/${backendLanguageToLocale(language)}/dashboard` without the extra profile read when the user selected/submitted the language and backend stores it.
|
||||
2. If backend user preference must remain authoritative, persist only the minimal post-login locale/profile seed into `sessionStorage` and hydrate `profileKey` before `AppShell` calls `getProfileResource()`.
|
||||
3. Long term: have the auth/session response include profile language/settings, which eliminates the post-login profile GET entirely.
|
||||
4. Target measurable improvement: login success path should drop from `login + profile + dashboard profile` to `login + dashboard profile`, or to `login` if profile seed is safe.
|
||||
|
||||
#### Priority 4: invalidation can actively create duplicate refetches
|
||||
|
||||
The resource layer intentionally uses one key for history summary and list:
|
||||
|
||||
```ts
|
||||
// web/src/lib/resources.ts:49-50
|
||||
export const historyListKey = ['history', 'list'] as const;
|
||||
export const historySummaryKey = historyListKey;
|
||||
```
|
||||
|
||||
But invalidation calls both keys:
|
||||
|
||||
```ts
|
||||
// web/src/lib/resources.ts:290-293
|
||||
export function invalidateHistory(threadId?: string): void {
|
||||
invalidate(historySummaryKey);
|
||||
invalidate(historyListKey);
|
||||
if (threadId) invalidate(historyThreadKey(threadId));
|
||||
}
|
||||
```
|
||||
|
||||
Since the two keys are the same object/value, the first `invalidate()` deletes the entry and notifies subscribers, which can immediately start a fetch and store an in-flight promise. The second `invalidate()` can delete that new in-flight entry and notify again, allowing a second network request for the same history list.
|
||||
|
||||
Points have a similar repeated invalidation pattern during run flows:
|
||||
|
||||
```tsx
|
||||
// web/src/components/DivinationProcessingOverlay.tsx:157-159
|
||||
const { threadId, runId } = await enqueueDivinationRun(params, yaoStates);
|
||||
invalidatePoints();
|
||||
```
|
||||
|
||||
```tsx
|
||||
// web/src/components/DivinationProcessingOverlay.tsx:257-259
|
||||
setStep('done');
|
||||
invalidatePoints();
|
||||
invalidateHistory(threadId);
|
||||
```
|
||||
|
||||
Follow-up does the same around SSE completion:
|
||||
|
||||
```tsx
|
||||
// web/src/components/HistoryFollowUpPage.tsx:166-190
|
||||
const { runId } = await enqueueFollowUpRun(threadId, text, resultData);
|
||||
invalidatePoints();
|
||||
...
|
||||
invalidateHistory(threadId);
|
||||
invalidatePoints();
|
||||
const snapshot = await getHistoryThreadResource(threadId, true);
|
||||
```
|
||||
|
||||
Implementation steps:
|
||||
|
||||
1. Make `invalidateHistory()` dedupe equal prefixes before invalidating, or stop aliasing `historySummaryKey` and `historyListKey`.
|
||||
2. Add an option to mark entries stale without deleting active in-flight promises, e.g. `invalidate(prefix, { refetchActive: true, preserveInFlight: true })`.
|
||||
3. In divination/follow-up flows, prefer one points invalidation at the moment the backend has committed the charge, or use a stale mark after enqueue and one forced reload at finish.
|
||||
4. Add instrumentation in `data-client.ts` around `startFetch()` in dev builds to count key-level fetch starts. Target: one `['points','balance']` refresh and one history refresh per completed run.
|
||||
|
||||
#### Priority 5: first-load route waterfall and bundle shape are still dominated by one eager app island
|
||||
|
||||
Every authenticated route page renders the same `DashboardAppPage`, and that mounts one client-only React app:
|
||||
|
||||
```astro
|
||||
// web/src/components/DashboardAppPage.astro:26-27
|
||||
<AppLayout locale={locale}>
|
||||
<DashboardApp client:only="react" locale={locale} translations={translations} />
|
||||
</AppLayout>
|
||||
```
|
||||
|
||||
`DashboardApp.tsx` eagerly imports all route screens:
|
||||
|
||||
```tsx
|
||||
// web/src/components/DashboardApp.tsx:3-15
|
||||
import AppShell from './AppShell';
|
||||
import Dashboard from './Dashboard';
|
||||
import StorePage from './StorePage';
|
||||
import HistoryListPage from './HistoryListPage';
|
||||
import DivinationResultPage from './DivinationResultPage';
|
||||
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';
|
||||
```
|
||||
|
||||
This means `/dashboard` pays parse/compile/download cost for store, notifications, profile edit, manual casting, auto casting, result rendering, follow-up chat, feedback, and settings code before any route transition.
|
||||
|
||||
Existing stale `web/dist` output from 2026-05-10 01:07 showed:
|
||||
|
||||
| Asset | Raw | Gzip |
|
||||
|---|---:|---:|
|
||||
| `web/dist/_astro/client.Bncfyed9.js` | 185,936 bytes | 58,384 bytes |
|
||||
| `web/dist/_astro/DashboardApp.Df7kJHO-.js` | 142,394 bytes | 39,455 bytes |
|
||||
| `web/dist/_astro/animations.BXuKIGyB.css` | ~89 KiB | 16,947 bytes |
|
||||
|
||||
These numbers are not current-build authoritative because `pnpm run build` now fails, but they confirm the dashboard app chunk is large enough to justify code splitting once the build blocker is fixed.
|
||||
|
||||
Implementation steps:
|
||||
|
||||
1. Convert route components in `DashboardApp.tsx` to `React.lazy(() => import('./RoutePage'))` and wrap `<Routes>` in a small Suspense fallback that preserves shell layout.
|
||||
2. Keep `AppShell`, `Dashboard`, and core nav in the initial chunk; lazy-load lower-probability paths such as store, result, follow-up, profile edit, feedback, notifications, manual/auto casting.
|
||||
3. Split heavy local copy objects in `ManualDivinationPage.tsx`, `AutoDivinationPage.tsx`, and `DivinationProcessingOverlay.tsx` with the route component rather than the dashboard shell.
|
||||
4. Add bundle budget reporting after the build blocker is fixed.
|
||||
|
||||
#### Priority 6: static image assets are missing from `public/` but oversized in source/stale dist
|
||||
|
||||
Current source references these public URLs:
|
||||
|
||||
```tsx
|
||||
// web/src/components/AppShell.tsx:156
|
||||
<img src="/images/logo.png" alt="MeiYao" className="w-9 h-9 rounded-lg" />
|
||||
```
|
||||
|
||||
```tsx
|
||||
// web/src/components/ManualDivinationPage.tsx:61-63
|
||||
<img
|
||||
src={face === 'zi' ? '/images/qigua/zi.jpg' : '/images/qigua/hua.jpg'}
|
||||
```
|
||||
|
||||
```tsx
|
||||
// web/src/components/DivinationResultPage.tsx:24-27
|
||||
if (normalized.includes('上上')) return '/images/qigua/shangshang.jpg';
|
||||
if (normalized.includes('中上')) return '/images/qigua/zhongshang.jpg';
|
||||
if (normalized.includes('下下')) return '/images/qigua/xiaxia.jpg';
|
||||
return '/images/qigua/zhongxia.jpg';
|
||||
```
|
||||
|
||||
But `web/public/` currently contains only:
|
||||
|
||||
- `web/public/favicon.ico` (655 B)
|
||||
- `web/public/favicon.svg` (749 B)
|
||||
|
||||
The same assets exist under `web/design/assets/images/**` and stale `web/dist/images/**`. Largest source assets:
|
||||
|
||||
| File | Size |
|
||||
|---|---:|
|
||||
| `web/design/assets/images/tutorial/tutorial_3.png` | 3.0 MB |
|
||||
| `web/design/assets/images/tutorial/tutorial_2.png` | 2.5 MB |
|
||||
| `web/design/assets/images/qigua/xiaxia.jpg` | 1.4 MB |
|
||||
| `web/design/assets/images/tutorial/tutorial_1.png` | 799 KB |
|
||||
| `web/design/assets/images/qigua/shangshang.jpg` | 768 KB |
|
||||
| `web/design/assets/images/qigua/zhongxia.jpg` | 601 KB |
|
||||
| `web/design/assets/images/qigua/zhongshang.jpg` | 596 KB |
|
||||
| `web/design/assets/images/logo.png` | 142 KB |
|
||||
|
||||
Implementation steps:
|
||||
|
||||
1. Decide whether `web/public/images/**` should be source-controlled. If yes, copy optimized web assets there rather than relying on stale `dist/`.
|
||||
2. Resize/compress qigua images to their displayed dimensions and convert to WebP/AVIF with JPG fallback only if needed.
|
||||
3. Replace `logo.png` with optimized SVG or small WebP/PNG; 142 KB is too high for a small nav/login logo.
|
||||
4. Do not ship tutorial PNGs unless a web tutorial page references them. If they are needed later, lazy-load and optimize them.
|
||||
5. Add an asset budget check after build works, e.g. fail if any single public image exceeds 200 KB without explicit rationale.
|
||||
|
||||
#### Priority 7: Material Symbols font load is broad and render-blocking
|
||||
|
||||
Both app and marketing layouts include the full Material Symbols variable CSS:
|
||||
|
||||
```astro
|
||||
// web/src/layouts/App.astro:19
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Material+Symbols+Rounded:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200" />
|
||||
```
|
||||
|
||||
```astro
|
||||
// web/src/layouts/Marketing.astro:20
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Material+Symbols+Rounded:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200" />
|
||||
```
|
||||
|
||||
The app also has a local `Icon.tsx` component for many icons, so the font is mostly used by settings/store/feedback subpages. The font CSS is loaded before route-specific need and from a third-party origin.
|
||||
|
||||
Implementation steps:
|
||||
|
||||
1. Replace remaining `material-symbols-rounded` spans with `Icon.tsx` where icons already exist.
|
||||
2. If the font remains necessary, self-host a subset or load it only in authenticated route chunks that use it.
|
||||
3. Add `preconnect` only if the remote font remains.
|
||||
|
||||
#### Priority 8: dev/prod API config is not explicit enough for repeatable performance verification
|
||||
|
||||
`apiBase()` depends on `PUBLIC_API_URL`:
|
||||
|
||||
```ts
|
||||
// web/src/lib/api-client.ts:1-4
|
||||
const apiBase = (): string => import.meta.env.PUBLIC_API_URL || '';
|
||||
export function apiUrl(path: string): string {
|
||||
return path.startsWith('http') ? path : `${apiBase()}${path}`;
|
||||
}
|
||||
```
|
||||
|
||||
Dev server proxy is hardcoded to production:
|
||||
|
||||
```mjs
|
||||
// web/astro.config.mjs:17-24
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'https://api.meeyao.com',
|
||||
changeOrigin: true,
|
||||
secure: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
```
|
||||
|
||||
This makes local request-count testing hit the production-latency path by default, and production behavior depends on `PUBLIC_API_URL` being set correctly in the hosting environment.
|
||||
|
||||
Implementation steps:
|
||||
|
||||
1. Add `.env.example` documenting `PUBLIC_API_URL` for production and local verification.
|
||||
2. Make dev proxy target configurable, e.g. `DEV_API_PROXY_TARGET ?? 'https://api.meeyao.com'`.
|
||||
3. During perf verification, record whether requests go same-origin proxy or direct API origin, because browser connection reuse and CORS/preflight behavior differ.
|
||||
|
||||
### External References
|
||||
|
||||
- [Astro routing reference](https://docs.astro.build/ja/reference/routing-reference/) — Astro routes prerender by default in static mode; exporting `prerender = false` opts a page into on-demand server rendering.
|
||||
- [Astro NoAdapterInstalled error reference](https://docs.astro.build/zh-tw/reference/errors/no-adapter-installed/) — server-rendered pages require an appropriate deployment adapter.
|
||||
|
||||
### Related Specs
|
||||
|
||||
- `.trellis/spec/web/index.md` — Web-specific guidance referenced by the task PRD.
|
||||
- `.trellis/tasks/05-10-audit-and-optimize-web-performance/prd.md` — Active task goals, acceptance criteria, prior implementation notes.
|
||||
- `.trellis/tasks/05-10-audit-and-optimize-web-performance/request-audit.md` — Original request topology and duplicate request plan.
|
||||
- `.trellis/tasks/05-10-audit-and-optimize-web-performance/refactor-plan.md` — Target data-client/resource architecture that commit `1e4871e` implemented.
|
||||
|
||||
## Caveats / Not Found
|
||||
|
||||
- `pnpm run build` currently fails with `NoAdapterInstalled`, so current production bundle sizes could not be regenerated.
|
||||
- `web/dist/**` exists but is stale relative to the current failing build. Sizes from `web/dist` are useful directional evidence only.
|
||||
- `pnpm exec astro sync` passed.
|
||||
- `pnpm exec tsc --noEmit` could not run because `typescript`/`tsc` is not installed in `web/node_modules`.
|
||||
- Browser request-count capture was not run in this research pass. The task PRD already notes a previous automation blocker; request-count verification should happen after the build/route blocker and auth boot duplication are addressed.
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"id": "audit-and-optimize-web-performance",
|
||||
"name": "audit-and-optimize-web-performance",
|
||||
"title": "Audit and optimize web performance",
|
||||
"description": "",
|
||||
"status": "in_progress",
|
||||
"dev_type": null,
|
||||
"scope": null,
|
||||
"package": null,
|
||||
"priority": "P2",
|
||||
"creator": "zl-q",
|
||||
"assignee": "zl-q",
|
||||
"createdAt": "2026-05-10",
|
||||
"completedAt": null,
|
||||
"branch": null,
|
||||
"base_branch": "dev",
|
||||
"worktree_path": null,
|
||||
"commit": null,
|
||||
"pr_url": null,
|
||||
"subtasks": [],
|
||||
"children": [],
|
||||
"parent": null,
|
||||
"relatedFiles": [],
|
||||
"notes": "",
|
||||
"meta": {}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{"file": ".trellis/spec/web/index.md", "reason": "Verify mobile overlay behavior and web layout constraints."}
|
||||
@@ -0,0 +1 @@
|
||||
{"file": ".trellis/spec/web/index.md", "reason": "Web authenticated app layout, responsive behavior, and mobile guided overlay rules."}
|
||||
@@ -0,0 +1,53 @@
|
||||
# Fix mobile divination tutorial overlay
|
||||
|
||||
## Goal
|
||||
|
||||
Fix the mobile web tutorial overlay on manual and auto divination pages so spotlight targets, tooltip placement, arrow direction, and overlay shadow match the intended mobile UX.
|
||||
|
||||
## What I already know
|
||||
|
||||
* User reports mobile tutorial has an overly large dark/shadowed area.
|
||||
* Step 3 should highlight the six yao area, not only the coin area. If the full coin + yao area is too large, highlight only the six yao rows and exclude the coins.
|
||||
* Step 4 tooltip arrow should point down toward the spotlighted "start divination" button.
|
||||
* Step 4 tooltip currently overlaps the spotlight region.
|
||||
* Affected files are `web/src/components/ManualDivinationPage.tsx` and `web/src/components/AutoDivinationPage.tsx`.
|
||||
|
||||
## Assumptions
|
||||
|
||||
* Keep desktop behavior visually equivalent unless the existing code already uses a different desktop target.
|
||||
* Mobile behavior is the priority for this task.
|
||||
* No backend or protocol changes are needed.
|
||||
|
||||
## Requirements
|
||||
|
||||
* Mobile step 3 highlights the six yao rows/panel area and excludes the coin selector.
|
||||
* Mobile step 4 places the tooltip above the submit/start button with enough gap to avoid overlap.
|
||||
* Mobile step 4 arrow points downward toward the spotlighted button.
|
||||
* Remove the excessive/double-shadow appearance caused by overlapping dimming strategies.
|
||||
* Apply the same behavior to manual and auto divination tutorial overlays.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
* [x] In mobile viewport, manual step 3 spotlight excludes the coin selector.
|
||||
* [x] In mobile viewport, auto step 3 spotlight excludes the coin selector.
|
||||
* [x] In mobile viewport, manual step 4 tooltip does not overlap the highlighted submit button and arrow points down.
|
||||
* [x] In mobile viewport, auto step 4 tooltip does not overlap the highlighted submit button and arrow points down.
|
||||
* [x] Overlay dimming is visually consistent without large unintended dark blocks.
|
||||
|
||||
## Definition of Done
|
||||
|
||||
* Local browser verification at a phone-sized viewport.
|
||||
* `git diff --check` passes.
|
||||
* Build/typecheck status documented if blocked by existing project configuration.
|
||||
|
||||
## Out of Scope
|
||||
|
||||
* Redesigning the tutorial copy.
|
||||
* Changing tutorial persistence/profile settings behavior.
|
||||
* Backend auth/session changes.
|
||||
|
||||
## Technical Notes
|
||||
|
||||
* Web spec applies: `.trellis/spec/web/index.md`.
|
||||
* Existing mobile threshold uses `window.innerWidth < 1280`.
|
||||
* App shell scroll container is the main vertical scrolling region.
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"id": "fix-mobile-divination-tutorial-overlay",
|
||||
"name": "fix-mobile-divination-tutorial-overlay",
|
||||
"title": "Fix mobile divination tutorial overlay",
|
||||
"description": "",
|
||||
"status": "completed",
|
||||
"dev_type": null,
|
||||
"scope": null,
|
||||
"package": null,
|
||||
"priority": "P2",
|
||||
"creator": "zl-q",
|
||||
"assignee": "zl-q",
|
||||
"createdAt": "2026-05-10",
|
||||
"completedAt": "2026-05-10",
|
||||
"branch": null,
|
||||
"base_branch": "dev",
|
||||
"worktree_path": null,
|
||||
"commit": null,
|
||||
"pr_url": null,
|
||||
"subtasks": [],
|
||||
"children": [],
|
||||
"parent": null,
|
||||
"relatedFiles": [],
|
||||
"notes": "",
|
||||
"meta": {}
|
||||
}
|
||||
@@ -1,9 +1,14 @@
|
||||
// @ts-check
|
||||
import { defineConfig } from 'astro/config';
|
||||
import node from '@astrojs/node';
|
||||
import react from '@astrojs/react';
|
||||
import tailwindcss from '@tailwindcss/vite';
|
||||
|
||||
export default defineConfig({
|
||||
output: 'server',
|
||||
adapter: node({
|
||||
mode: 'standalone',
|
||||
}),
|
||||
integrations: [react()],
|
||||
i18n: {
|
||||
locales: ['zh', 'zh_Hant', 'en'],
|
||||
|
||||
|
Before Width: | Height: | Size: 142 KiB After Width: | Height: | Size: 42 KiB |
|
Before Width: | Height: | Size: 96 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 768 KiB After Width: | Height: | Size: 129 KiB |
|
Before Width: | Height: | Size: 1.4 MiB After Width: | Height: | Size: 124 KiB |
|
Before Width: | Height: | Size: 596 KiB After Width: | Height: | Size: 130 KiB |
|
Before Width: | Height: | Size: 601 KiB After Width: | Height: | Size: 130 KiB |
|
Before Width: | Height: | Size: 102 KiB After Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 799 KiB After Width: | Height: | Size: 372 KiB |
|
Before Width: | Height: | Size: 2.5 MiB After Width: | Height: | Size: 1022 KiB |
|
Before Width: | Height: | Size: 3.0 MiB After Width: | Height: | Size: 1.2 MiB |
@@ -12,6 +12,7 @@
|
||||
"astro": "astro"
|
||||
},
|
||||
"dependencies": {
|
||||
"@astrojs/node": "^10.1.0",
|
||||
"@astrojs/react": "^5.0.4",
|
||||
"@tailwindcss/vite": "^4.3.0",
|
||||
"@types/react": "^19.2.14",
|
||||
@@ -22,5 +23,10 @@
|
||||
"react-dom": "^19.2.6",
|
||||
"react-router-dom": "^7.15.0",
|
||||
"tailwindcss": "^4.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@astrojs/check": "^0.9.9",
|
||||
"@types/node": "^25.6.2",
|
||||
"typescript": "^6.0.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,9 @@ import { useState, useEffect, createContext, useContext, type ReactNode } from '
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import Icon from './Icon';
|
||||
import { getAuth, refreshAccessToken, redirectToLogin, backendLanguageToLocale, type AuthUser } from '../lib/auth';
|
||||
import { getUserProfile, type UserProfile } from '../lib/api';
|
||||
import type { UserProfile } from '../lib/api';
|
||||
import { getCachedProfile, getProfileResource, prefetchAppBasics, prefetchForPath, profileKey } from '../lib/resources';
|
||||
import { subscribe } from '../lib/data-client';
|
||||
|
||||
// User settings context
|
||||
interface UserSettingsContextValue {
|
||||
@@ -80,11 +82,12 @@ export default function AppShell({ locale, brandName, navItems, userName, userEm
|
||||
refreshAccessToken()
|
||||
.then((data) => {
|
||||
if (alive) setAuthUser(data.user);
|
||||
return getUserProfile();
|
||||
return getProfileResource();
|
||||
})
|
||||
.then((profile) => {
|
||||
if (!alive) return;
|
||||
setUserProfile(profile);
|
||||
prefetchAppBasics();
|
||||
|
||||
// Check if URL locale matches user's language preference
|
||||
const userLocale = backendLanguageToLocale(profile.settings?.preferences?.language || 'zh-CN');
|
||||
@@ -107,6 +110,14 @@ export default function AppShell({ locale, brandName, navItems, userName, userEm
|
||||
};
|
||||
}, [locale]);
|
||||
|
||||
useEffect(() => {
|
||||
const cached = getCachedProfile();
|
||||
if (cached) setUserProfile(cached);
|
||||
return subscribe(profileKey, () => {
|
||||
setUserProfile(getCachedProfile() ?? null);
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeNav === 'manual' || activeNav === 'auto') setExpandedNav('divination');
|
||||
}, [activeNav]);
|
||||
@@ -116,6 +127,10 @@ export default function AppShell({ locale, brandName, navItems, userName, userEm
|
||||
routerNavigate(href);
|
||||
};
|
||||
|
||||
const prefetchNav = (href: string) => {
|
||||
prefetchForPath(href, locale);
|
||||
};
|
||||
|
||||
if (checkingAuth || authUser === null) {
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-50 flex items-center justify-center px-6">
|
||||
@@ -125,7 +140,7 @@ export default function AppShell({ locale, brandName, navItems, userName, userEm
|
||||
}
|
||||
|
||||
const shellUserName = userName || userProfile?.display_name || authUser?.email?.split('@')[0] || '';
|
||||
const shellUserEmail = userEmail || userProfile?.email || authUser?.email || '';
|
||||
const shellUserEmail = userEmail || authUser?.email || '';
|
||||
const shellAvatarUrl = userProfile?.avatar_url;
|
||||
|
||||
return (
|
||||
@@ -169,6 +184,8 @@ export default function AppShell({ locale, brandName, navItems, userName, userEm
|
||||
</button>
|
||||
{!sidebarCollapsed && isExpanded && item.sub.map((sub) => (
|
||||
<a key={sub.id} href={sub.href}
|
||||
onMouseEnter={() => prefetchNav(sub.href)}
|
||||
onFocus={() => prefetchNav(sub.href)}
|
||||
onClick={(e) => { e.preventDefault(); navigate(sub.href); setSidebarOpen(false); }}
|
||||
className={`flex items-center gap-2 pl-8 pr-2.5 py-2.5 rounded-md text-sm transition-colors border ${activeNav === sub.id ? 'bg-[#F0E6FF] border-violet-600 text-violet-700 font-bold' : 'border-transparent text-slate-500 hover:bg-slate-50'}`}>
|
||||
<span className="text-xs">{activeNav === sub.id ? '●' : '○'}</span>{sub.label}
|
||||
@@ -183,6 +200,8 @@ export default function AppShell({ locale, brandName, navItems, userName, userEm
|
||||
if (item.href) {
|
||||
return (
|
||||
<a key={item.id} href={item.href}
|
||||
onMouseEnter={() => prefetchNav(item.href)}
|
||||
onFocus={() => prefetchNav(item.href)}
|
||||
onClick={(e) => { e.preventDefault(); navigate(item.href); setSidebarOpen(false); }}
|
||||
title={sidebarCollapsed ? item.label : undefined}
|
||||
className={`flex items-center gap-3 px-2.5 py-2.5 rounded-lg text-sm transition-colors ${sidebarCollapsed ? 'md:justify-center' : ''} ${isActive ? 'bg-violet-700 text-white' : 'text-slate-500 hover:bg-slate-50'}`}>
|
||||
|
||||
@@ -1,93 +0,0 @@
|
||||
import { createContext, useContext, useState, useEffect, useCallback, type ReactNode } from 'react';
|
||||
import {
|
||||
getAuth,
|
||||
loginWithEmail as doLogin,
|
||||
refreshAccessToken,
|
||||
logout as doLogout,
|
||||
clearAuth,
|
||||
redirectToLogin,
|
||||
type AuthUser,
|
||||
} from '../lib/auth';
|
||||
|
||||
interface AuthContextValue {
|
||||
user: AuthUser | null;
|
||||
isAuthenticated: boolean;
|
||||
loading: boolean;
|
||||
login: (email: string, token: string, language?: string, timezone?: string) => Promise<void>;
|
||||
logout: () => Promise<void>;
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextValue>({
|
||||
user: null,
|
||||
isAuthenticated: false,
|
||||
loading: true,
|
||||
login: async () => {},
|
||||
logout: async () => {},
|
||||
});
|
||||
|
||||
export function useAuth(): AuthContextValue {
|
||||
return useContext(AuthContext);
|
||||
}
|
||||
|
||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const [user, setUser] = useState<AuthUser | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
// recoverSession on mount
|
||||
useEffect(() => {
|
||||
const auth = getAuth();
|
||||
if (!auth?.refresh_token) {
|
||||
clearAuth();
|
||||
redirectToLogin();
|
||||
return;
|
||||
}
|
||||
refreshAccessToken()
|
||||
.then((data) => {
|
||||
setUser(data.user);
|
||||
})
|
||||
.catch(() => {
|
||||
clearAuth();
|
||||
setUser(null);
|
||||
redirectToLogin();
|
||||
})
|
||||
.finally(() => {
|
||||
setLoading(false);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const login = useCallback(
|
||||
async (email: string, token: string, language?: string, timezone?: string) => {
|
||||
const data = await doLogin(email, token, language, timezone);
|
||||
setUser(data.user);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const logout = useCallback(async () => {
|
||||
setUser(null);
|
||||
await doLogout();
|
||||
redirectToLogin();
|
||||
}, []);
|
||||
|
||||
if (loading || user === null) {
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-50 flex items-center justify-center px-6">
|
||||
<div className="h-10 w-10 rounded-full border-2 border-violet-200 border-t-violet-600 animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthContext.Provider
|
||||
value={{
|
||||
user,
|
||||
isAuthenticated: user !== null,
|
||||
loading,
|
||||
login,
|
||||
logout,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
}
|
||||
@@ -2,7 +2,8 @@ import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import Icon from './Icon';
|
||||
import DivinationProcessingOverlay from './DivinationProcessingOverlay';
|
||||
import { getPointsBalance, getUserProfile, updateUserSettings, type PointsBalance, type DivinationResultData, type YaoType } from '../lib/api';
|
||||
import { type DivinationResultData, type YaoType } from '../lib/api';
|
||||
import { updateSettingsResource, usePoints } from '../lib/resources';
|
||||
import { useUserSettings } from './AppShell';
|
||||
|
||||
interface Props {
|
||||
@@ -180,12 +181,13 @@ export default function AutoDivinationPage({ locale, divination: d }: Props) {
|
||||
const text = copy[locale as keyof typeof copy] ?? copy.zh;
|
||||
const cats = useMemo(() => d.categories.split(','), [d.categories]);
|
||||
const navigate = useNavigate();
|
||||
const [category, setCategory] = useState(cats[0]);
|
||||
const [question, setQuestion] = useState(text.defaultQuestion);
|
||||
const [category, setCategory] = useState<string>(cats[0]);
|
||||
const [question, setQuestion] = useState<string>(text.defaultQuestion);
|
||||
const [selectedTime, setSelectedTime] = useState(() => formatDateTimeInput(new Date()));
|
||||
const [yaoResults, setYaoResults] = useState<YaoType[]>([]);
|
||||
const [guideStep, setGuideStep] = useState<number | null>(null);
|
||||
const [points, setPoints] = useState<PointsBalance | null>(null);
|
||||
const pointsState = usePoints();
|
||||
const points = pointsState.data ?? null;
|
||||
const [showProcessing, setShowProcessing] = useState(false);
|
||||
const [showConfirm, setShowConfirm] = useState(false);
|
||||
const { userProfile, setUserProfile } = useUserSettings();
|
||||
@@ -200,6 +202,7 @@ export default function AutoDivinationPage({ locale, divination: d }: Props) {
|
||||
const coinsAreaRef = useRef<HTMLDivElement>(null);
|
||||
const timePanelRef = useRef<HTMLElement>(null);
|
||||
const yaoPanelRef = useRef<HTMLElement>(null);
|
||||
const yaoRowsRef = useRef<HTMLDivElement>(null);
|
||||
const submitBtnRef = useRef<HTMLButtonElement>(null);
|
||||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||
const [spotlightRect, setSpotlightRect] = useState<{ left: number; top: number; width: number; height: number } | null>(null);
|
||||
@@ -236,7 +239,7 @@ export default function AutoDivinationPage({ locale, divination: d }: Props) {
|
||||
},
|
||||
};
|
||||
try {
|
||||
const updated = await updateUserSettings({ settings: updatedSettings });
|
||||
const updated = await updateSettingsResource(updatedSettings);
|
||||
setUserProfile(updated);
|
||||
} catch {
|
||||
// Silently fail
|
||||
@@ -253,12 +256,17 @@ export default function AutoDivinationPage({ locale, divination: d }: Props) {
|
||||
return;
|
||||
}
|
||||
|
||||
const targetRef = [coinsAreaRef, timePanelRef, yaoPanelRef, submitBtnRef][guideStep];
|
||||
const mobileGuideTargets = [coinsAreaRef, timePanelRef, yaoRowsRef, submitBtnRef];
|
||||
const desktopGuideTargets = [coinsAreaRef, timePanelRef, yaoPanelRef, submitBtnRef];
|
||||
const targetRef = (isMobile ? mobileGuideTargets : desktopGuideTargets)[guideStep];
|
||||
if (!targetRef?.current) return;
|
||||
|
||||
const tooltipWidth = 320;
|
||||
const tooltipHeight = 180;
|
||||
const gap = 16;
|
||||
const tooltipHeight = isMobile ? 220 : 180;
|
||||
const gap = isMobile ? 24 : 16;
|
||||
|
||||
const overlayHost = scrollContainerRef.current;
|
||||
if (!overlayHost) return;
|
||||
|
||||
const scrollContainer = scrollContainerRef.current?.parentElement?.parentElement as HTMLElement | null;
|
||||
if (!scrollContainer) return;
|
||||
@@ -266,10 +274,9 @@ export default function AutoDivinationPage({ locale, divination: d }: Props) {
|
||||
const isInitialOpen = prevGuideStepRef.current === null;
|
||||
|
||||
if (isMobile) {
|
||||
const containerRect = scrollContainer.getBoundingClientRect();
|
||||
const elementRect = targetRef.current.getBoundingClientRect();
|
||||
|
||||
const elementLeft = elementRect.left - containerRect.left;
|
||||
const containerRect = scrollContainer.getBoundingClientRect();
|
||||
const elementTop = elementRect.top - containerRect.top + scrollContainer.scrollTop;
|
||||
const elementWidth = elementRect.width;
|
||||
const elementHeight = elementRect.height;
|
||||
@@ -278,22 +285,37 @@ export default function AutoDivinationPage({ locale, divination: d }: Props) {
|
||||
scrollContainer.scrollTop = 0;
|
||||
}
|
||||
|
||||
const scrollTopNeeded = Math.max(0, elementTop - 20);
|
||||
scrollContainer.scrollTo({ top: scrollTopNeeded, behavior: 'smooth' });
|
||||
const scrollTopNeeded = Math.max(
|
||||
0,
|
||||
guideStep === 3 ? elementTop - tooltipHeight - gap - 32 : elementTop - 20,
|
||||
);
|
||||
scrollContainer.scrollTo({ top: scrollTopNeeded, behavior: 'auto' });
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
if (!targetRef.current) return;
|
||||
const newElementRect = targetRef.current.getBoundingClientRect();
|
||||
const newContainerRect = scrollContainer.getBoundingClientRect();
|
||||
const hostRect = overlayHost.getBoundingClientRect();
|
||||
const visibleContainerRect = scrollContainer.getBoundingClientRect();
|
||||
const visibleTop = visibleContainerRect.top - hostRect.top;
|
||||
const visibleBottom = visibleTop + visibleContainerRect.height;
|
||||
|
||||
const spotlightLeft = newElementRect.left - newContainerRect.left;
|
||||
const spotlightTop = newElementRect.top - newContainerRect.top;
|
||||
const spotlightLeft = newElementRect.left - hostRect.left;
|
||||
const spotlightTop = newElementRect.top - hostRect.top;
|
||||
|
||||
const tooltipLeft = Math.max(16, Math.min(
|
||||
(newElementRect.left + newElementRect.right - tooltipWidth) / 2 - newContainerRect.left,
|
||||
containerRect.width - tooltipWidth - 16
|
||||
(newElementRect.left + newElementRect.right - tooltipWidth) / 2 - hostRect.left,
|
||||
hostRect.width - tooltipWidth - 16
|
||||
));
|
||||
const tooltipTop = spotlightTop + elementHeight + gap;
|
||||
let tooltipTop = spotlightTop + elementHeight + gap;
|
||||
let side: 'bottom' | 'top' = 'bottom';
|
||||
if (guideStep === 3) {
|
||||
tooltipTop = Math.max(visibleTop + 16, spotlightTop - tooltipHeight - gap);
|
||||
side = 'top';
|
||||
}
|
||||
if (tooltipTop + tooltipHeight > visibleBottom) {
|
||||
tooltipTop = Math.max(visibleTop + 16, spotlightTop - tooltipHeight - gap);
|
||||
side = 'top';
|
||||
}
|
||||
|
||||
setSpotlightRect({
|
||||
left: spotlightLeft,
|
||||
@@ -302,7 +324,7 @@ export default function AutoDivinationPage({ locale, divination: d }: Props) {
|
||||
height: elementHeight
|
||||
});
|
||||
setTooltipPos({ left: tooltipLeft, top: tooltipTop });
|
||||
setTooltipSide('bottom');
|
||||
setTooltipSide(side);
|
||||
});
|
||||
|
||||
prevGuideStepRef.current = guideStep;
|
||||
@@ -344,10 +366,6 @@ export default function AutoDivinationPage({ locale, divination: d }: Props) {
|
||||
setCategory(cats[0]);
|
||||
}, [cats]);
|
||||
|
||||
useEffect(() => {
|
||||
getPointsBalance().then(setPoints).catch(() => {});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const handleResize = () => setIsMobile(window.innerWidth < 1280);
|
||||
window.addEventListener('resize', handleResize);
|
||||
@@ -418,10 +436,6 @@ export default function AutoDivinationPage({ locale, divination: d }: Props) {
|
||||
}
|
||||
};
|
||||
|
||||
const handleBack = () => {
|
||||
navigate(`/${locale}/dashboard`);
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={scrollContainerRef} className="relative flex min-h-full flex-col gap-[22px]">
|
||||
<div className="flex items-center justify-between gap-5">
|
||||
@@ -495,7 +509,7 @@ export default function AutoDivinationPage({ locale, divination: d }: Props) {
|
||||
</div>
|
||||
|
||||
{/* Six yao rows */}
|
||||
<div className="flex flex-col gap-2.5">
|
||||
<div ref={yaoRowsRef} className="flex flex-col gap-2.5">
|
||||
{[5, 4, 3, 2, 1, 0].map((index) => {
|
||||
const result = yaoResults[index];
|
||||
const isBeingShaken = isShaking && currentShakingYao === index;
|
||||
@@ -597,11 +611,11 @@ export default function AutoDivinationPage({ locale, divination: d }: Props) {
|
||||
{guideOpen && guide && spotlightRect && (
|
||||
<>
|
||||
<div
|
||||
className="fixed inset-0 z-40 bg-black/70 md:block hidden"
|
||||
className="fixed inset-0 z-40 hidden bg-black/70 xl:block"
|
||||
onClick={() => closeGuide()}
|
||||
/>
|
||||
<div
|
||||
className="absolute inset-0 z-40 bg-black/70 md:hidden"
|
||||
className="absolute inset-0 z-40 xl:hidden"
|
||||
style={{ top: 0, height: '100vh' }}
|
||||
onClick={() => closeGuide()}
|
||||
/>
|
||||
@@ -631,7 +645,7 @@ export default function AutoDivinationPage({ locale, divination: d }: Props) {
|
||||
className={`absolute h-3 w-3 rotate-45 bg-slate-950 ${
|
||||
tooltipSide === 'right' ? '-left-1.5 top-6' :
|
||||
tooltipSide === 'left' ? '-right-1.5 top-6' :
|
||||
tooltipSide === 'top' ? '-bottom-1.5 left-6' :
|
||||
tooltipSide === 'top' ? '-bottom-1.5 left-1/2 -translate-x-1/2' :
|
||||
'-top-1.5 left-6'
|
||||
}`}
|
||||
/>
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { getPointsBalance, type PointsBalance } from '../lib/api';
|
||||
import { getUnreadNotificationCount, type UnreadCount } from '../lib/api';
|
||||
import { getAgentHistory, mapHistoryMessagesToItems, type HistoryItem } from '../lib/api';
|
||||
import { mapHistoryMessagesToItems } from '../lib/api';
|
||||
import { primeHistoryThreadFromSnapshot, useHistorySummary, usePoints, useUnreadCount } from '../lib/resources';
|
||||
import Icon from './Icon';
|
||||
|
||||
interface DashboardProps {
|
||||
@@ -53,40 +51,15 @@ const RATING_COLORS: Record<string, string> = {
|
||||
};
|
||||
|
||||
export default function Dashboard({ locale, translations: i18n }: DashboardProps) {
|
||||
const [points, setPoints] = useState<PointsBalance | null>(null);
|
||||
const [unreadCount, setUnreadCount] = useState<UnreadCount | null>(null);
|
||||
const [history, setHistory] = useState<HistoryItem[]>([]);
|
||||
const [loadingData, setLoadingData] = useState(true);
|
||||
const [loadError, setLoadError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
setLoadingData(true);
|
||||
setLoadError('');
|
||||
|
||||
Promise.all([
|
||||
getPointsBalance(),
|
||||
getUnreadNotificationCount(),
|
||||
getAgentHistory(),
|
||||
])
|
||||
.then(([nextPoints, nextUnreadCount, nextHistory]) => {
|
||||
if (!alive) return;
|
||||
setPoints(nextPoints);
|
||||
setUnreadCount(nextUnreadCount);
|
||||
setHistory(mapHistoryMessagesToItems(nextHistory.messages).slice(0, 4));
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (!alive) return;
|
||||
setLoadError(error instanceof Error ? error.message : 'Failed to load dashboard data');
|
||||
})
|
||||
.finally(() => {
|
||||
if (alive) setLoadingData(false);
|
||||
});
|
||||
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, []);
|
||||
const pointsState = usePoints();
|
||||
const unreadState = useUnreadCount();
|
||||
const historyState = useHistorySummary();
|
||||
const points = pointsState.data;
|
||||
const unreadCount = unreadState.data;
|
||||
const history = historyState.data ? mapHistoryMessagesToItems(historyState.data.messages).slice(0, 4) : [];
|
||||
const loadingData = historyState.loading;
|
||||
const loadErrorSource = pointsState.error || unreadState.error || historyState.error;
|
||||
const loadError = loadErrorSource instanceof Error ? loadErrorSource.message : loadErrorSource ? 'Failed to load dashboard data' : '';
|
||||
|
||||
const unreadNum = unreadCount?.count ?? 0;
|
||||
const availablePoints = points?.availableBalance;
|
||||
@@ -157,7 +130,8 @@ export default function Dashboard({ locale, translations: i18n }: DashboardProps
|
||||
history.map((item) => (
|
||||
<a
|
||||
key={item.id}
|
||||
href={`/${locale}/history/${item.threadId}`}
|
||||
href={`/${locale}/history/result?threadId=${encodeURIComponent(item.threadId)}`}
|
||||
onClick={() => historyState.data && primeHistoryThreadFromSnapshot(item.threadId, historyState.data)}
|
||||
className="flex flex-col sm:flex-row sm:items-center gap-3 sm:gap-4 bg-white rounded-xl p-4 md:p-5 border border-slate-100 hover:shadow-sm hover:border-violet-200 transition-all cursor-pointer"
|
||||
>
|
||||
<div className="w-10 h-10 rounded-[10px] bg-blue-50 flex items-center justify-center shrink-0">
|
||||
|
||||
@@ -1,39 +1,27 @@
|
||||
import { useEffect } from 'react';
|
||||
import { lazy, Suspense, useEffect } from 'react';
|
||||
import { BrowserRouter, Navigate, Route, Routes, useNavigate } from 'react-router-dom';
|
||||
import type { Locale, Translations } from '../i18n/utils';
|
||||
import AppShell from './AppShell';
|
||||
import Dashboard from './Dashboard';
|
||||
import StorePage from './StorePage';
|
||||
import HistoryListPage from './HistoryListPage';
|
||||
import DivinationResultPage from './DivinationResultPage';
|
||||
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';
|
||||
|
||||
type TranslationMap = Record<string, string>;
|
||||
|
||||
interface DashboardAppProps {
|
||||
locale: string;
|
||||
translations: {
|
||||
dashboard: TranslationMap;
|
||||
store: TranslationMap;
|
||||
pricing: TranslationMap;
|
||||
history: TranslationMap;
|
||||
notifications: TranslationMap;
|
||||
profile: TranslationMap;
|
||||
settings: TranslationMap;
|
||||
divination: TranslationMap;
|
||||
general: TranslationMap;
|
||||
feedback: TranslationMap;
|
||||
result: TranslationMap;
|
||||
};
|
||||
locale: Locale;
|
||||
translations: Pick<Translations, 'dashboard' | 'store' | 'pricing' | 'history' | 'notifications' | 'profile' | 'settings' | 'divination' | 'general' | 'feedback' | 'result'>;
|
||||
}
|
||||
|
||||
const Dashboard = lazy(() => import('./Dashboard'));
|
||||
const StorePage = lazy(() => import('./StorePage'));
|
||||
const HistoryListPage = lazy(() => import('./HistoryListPage'));
|
||||
const DivinationResultPage = lazy(() => import('./DivinationResultPage'));
|
||||
const HistoryFollowUpPage = lazy(() => import('./HistoryFollowUpPage'));
|
||||
const NotificationPage = lazy(() => import('./NotificationPage'));
|
||||
const ProfileDetailPage = lazy(() => import('./ProfileDetailPage'));
|
||||
const SettingsPage = lazy(() => import('./SettingsPage'));
|
||||
const GeneralSettingsPage = lazy(() => import('./GeneralSettingsPage'));
|
||||
const FeedbackPage = lazy(() => import('./FeedbackPage'));
|
||||
const ManualDivinationPage = lazy(() => import('./ManualDivinationPage'));
|
||||
const AutoDivinationPage = lazy(() => import('./AutoDivinationPage'));
|
||||
|
||||
const APP_PATHS = [
|
||||
'/dashboard',
|
||||
'/store',
|
||||
@@ -77,6 +65,14 @@ function AppLinkInterceptor({ locale }: { locale: string }) {
|
||||
return null;
|
||||
}
|
||||
|
||||
function RouteFallback() {
|
||||
return (
|
||||
<div className="flex min-h-[320px] items-center justify-center">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-b-2 border-violet-600" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DashboardRoutes({ locale, translations }: DashboardAppProps) {
|
||||
const dashboard = translations.dashboard;
|
||||
const navItems = getNavConfig(locale, dashboard);
|
||||
@@ -84,24 +80,26 @@ function DashboardRoutes({ locale, translations }: DashboardAppProps) {
|
||||
return (
|
||||
<AppShell locale={locale} brandName={dashboard.brandName} navItems={navItems}>
|
||||
<AppLinkInterceptor locale={locale} />
|
||||
<Routes>
|
||||
<Route path={`/${locale}/dashboard`} element={<Dashboard locale={locale} translations={dashboard} />} />
|
||||
<Route path={`/${locale}/store`} element={<StorePage locale={locale} dashboard={dashboard} store={translations.store} pricing={translations.pricing} />} />
|
||||
<Route path={`/${locale}/history`} element={<HistoryListPage locale={locale} dashboard={dashboard} history={translations.history} />} />
|
||||
<Route path={`/${locale}/history/:id`} element={<DivinationResultPage locale={locale} translations={translations.result} />} />
|
||||
<Route path={`/${locale}/history/:id/followup`} element={<HistoryFollowUpPage locale={locale} dashboard={dashboard} history={translations.history} />} />
|
||||
<Route path={`/${locale}/history/result`} element={<DivinationResultPage locale={locale} translations={translations.result} />} />
|
||||
<Route path={`/${locale}/history/followup`} element={<HistoryFollowUpPage locale={locale} dashboard={dashboard} history={translations.history} />} />
|
||||
<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={`/${locale}/divination/result`} element={<DivinationResultPage locale={locale} translations={translations.result} />} />
|
||||
<Route path="*" element={<Navigate to={`/${locale}/dashboard`} replace />} />
|
||||
</Routes>
|
||||
<Suspense fallback={<RouteFallback />}>
|
||||
<Routes>
|
||||
<Route path={`/${locale}/dashboard`} element={<Dashboard locale={locale} translations={dashboard} />} />
|
||||
<Route path={`/${locale}/store`} element={<StorePage locale={locale} dashboard={dashboard} store={translations.store} pricing={translations.pricing} />} />
|
||||
<Route path={`/${locale}/history`} element={<HistoryListPage locale={locale} dashboard={dashboard} history={translations.history} />} />
|
||||
<Route path={`/${locale}/history/:id`} element={<DivinationResultPage locale={locale} translations={translations.result} />} />
|
||||
<Route path={`/${locale}/history/:id/followup`} element={<HistoryFollowUpPage locale={locale} dashboard={dashboard} history={translations.history} />} />
|
||||
<Route path={`/${locale}/history/result`} element={<DivinationResultPage locale={locale} translations={translations.result} />} />
|
||||
<Route path={`/${locale}/history/followup`} element={<HistoryFollowUpPage locale={locale} dashboard={dashboard} history={translations.history} />} />
|
||||
<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={`/${locale}/divination/result`} element={<DivinationResultPage locale={locale} translations={translations.result} />} />
|
||||
<Route path="*" element={<Navigate to={`/${locale}/dashboard`} replace />} />
|
||||
</Routes>
|
||||
</Suspense>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
type YaoType,
|
||||
type DivinationResultData,
|
||||
} from '../lib/api';
|
||||
import { invalidateHistory, invalidatePoints } from '../lib/resources';
|
||||
|
||||
// 八卦卡片数据 - 使用 Flutter 的文本
|
||||
const I_CHING_CARDS = {
|
||||
@@ -154,6 +155,7 @@ export default function DivinationProcessingOverlay({
|
||||
try {
|
||||
// 1. 提交起卦请求
|
||||
const { threadId, runId } = await enqueueDivinationRun(params, yaoStates);
|
||||
invalidatePoints();
|
||||
|
||||
if (aborted) return;
|
||||
|
||||
@@ -173,7 +175,7 @@ export default function DivinationProcessingOverlay({
|
||||
let conclusion = '';
|
||||
let focusPoints: string[] = [];
|
||||
let advice: string[] = [];
|
||||
let keywords: string[] = [];
|
||||
let keywords = '';
|
||||
let answer = '';
|
||||
let status: 'success' | 'failed' | 'refused' = 'success';
|
||||
|
||||
@@ -253,6 +255,8 @@ export default function DivinationProcessingOverlay({
|
||||
setResult(result);
|
||||
}
|
||||
setStep('done');
|
||||
invalidatePoints();
|
||||
invalidateHistory(threadId);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useLocation, useNavigate, useParams } from 'react-router-dom';
|
||||
import { getAgentHistoryByThread, historyMessageToResultData, type DivinationResultData, type YaoType } from '../lib/api';
|
||||
import { useLocation, useNavigate, useParams, useSearchParams } from 'react-router-dom';
|
||||
import { historyMessageToResultData, type DivinationResultData, type YaoType } from '../lib/api';
|
||||
import { useHistoryThread } from '../lib/resources';
|
||||
import Icon from './Icon';
|
||||
|
||||
interface Props {
|
||||
@@ -338,14 +339,15 @@ const RESULT_STORAGE_KEY = 'divination_result_data';
|
||||
export default function DivinationResultPage({ locale, translations: t }: Props) {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const { id: threadId } = useParams<{ id: string }>();
|
||||
const { id: routeThreadId } = useParams<{ id: string }>();
|
||||
const [searchParams] = useSearchParams();
|
||||
const threadId = routeThreadId ?? searchParams.get('threadId') ?? undefined;
|
||||
const threadState = useHistoryThread(threadId);
|
||||
const [data, setData] = useState<DivinationResultData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [canFollowUp, setCanFollowUp] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
|
||||
// 1. Try router state (from divination flow)
|
||||
const state = location.state as { result?: DivinationResultData } | null;
|
||||
if (state?.result) {
|
||||
@@ -369,24 +371,22 @@ export default function DivinationResultPage({ locale, translations: t }: Props)
|
||||
// 3. Fetch by threadId (from history flow)
|
||||
if (threadId) {
|
||||
setLoading(true);
|
||||
getAgentHistoryByThread(threadId)
|
||||
.then((snapshot) => {
|
||||
if (!alive) return;
|
||||
const assistantMsg = snapshot.messages.find((m) => m.role === 'assistant');
|
||||
if (!assistantMsg) return;
|
||||
if (threadState.data) {
|
||||
const assistantMsg = threadState.data.messages.find((m) => m.role === 'assistant');
|
||||
if (assistantMsg) {
|
||||
const resultData = historyMessageToResultData(assistantMsg);
|
||||
if (resultData) setData(resultData);
|
||||
const userCount = snapshot.messages.filter((m) => m.role === 'user').length;
|
||||
setCanFollowUp(userCount < 2);
|
||||
})
|
||||
.catch(() => { /* ignore */ })
|
||||
.finally(() => { if (alive) setLoading(false); });
|
||||
}
|
||||
const userCount = threadState.data.messages.filter((m) => m.role === 'user').length;
|
||||
setCanFollowUp(userCount < 2);
|
||||
setLoading(false);
|
||||
} else if (!threadState.loading) {
|
||||
setLoading(false);
|
||||
}
|
||||
} else {
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
return () => { alive = false; };
|
||||
}, [location.state, threadId]);
|
||||
}, [location.state, threadId, threadState.data, threadState.loading]);
|
||||
|
||||
// Redirect if no data and not loading
|
||||
useEffect(() => {
|
||||
@@ -405,7 +405,7 @@ export default function DivinationResultPage({ locale, translations: t }: Props)
|
||||
const handleFollowUp = () => {
|
||||
const effectiveThreadId = data?.threadId || threadId;
|
||||
if (effectiveThreadId) {
|
||||
navigate(`/${locale}/history/${effectiveThreadId}/followup`, { state: { result: data } });
|
||||
navigate(`/${locale}/history/followup?threadId=${encodeURIComponent(effectiveThreadId)}`, { state: { result: data } });
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ 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';
|
||||
import { apiUrl } from '../lib/api-client';
|
||||
|
||||
interface Props {
|
||||
locale: string;
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
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';
|
||||
import type { ProfileSettings } from '../lib/api';
|
||||
import { backendLanguageToLocale } from '../lib/auth';
|
||||
import { updateSettingsResource, useProfile } from '../lib/resources';
|
||||
|
||||
interface Props {
|
||||
locale: string;
|
||||
@@ -40,9 +41,8 @@ function getDefaultSettings(): ProfileSettings {
|
||||
|
||||
export default function GeneralSettingsPage({ locale, general: g }: Props) {
|
||||
const navigate = useNavigate();
|
||||
const [profile, setProfile] = useState<UserProfile | null>(null);
|
||||
const profileState = useProfile();
|
||||
const [settings, setSettings] = useState<ProfileSettings>(getDefaultSettings());
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [showLanguageModal, setShowLanguageModal] = useState(false);
|
||||
const [toast, setToast] = useState<{ type: 'error'; message: string } | null>(null);
|
||||
@@ -52,26 +52,14 @@ export default function GeneralSettingsPage({ locale, general: g }: Props) {
|
||||
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));
|
||||
}, []);
|
||||
if (profileState.data?.settings) setSettings(profileState.data.settings);
|
||||
}, [profileState.data]);
|
||||
|
||||
const saveSettings = async (newSettings: ProfileSettings): Promise<boolean> => {
|
||||
setSaving(true);
|
||||
try {
|
||||
const updated = await updateUserSettings({ settings: newSettings });
|
||||
const updated = await updateSettingsResource(newSettings);
|
||||
setSettings(updated.settings);
|
||||
setProfile(updated);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
@@ -135,7 +123,7 @@ export default function GeneralSettingsPage({ locale, general: g }: Props) {
|
||||
|
||||
const currentLangLabel = languageOptions.find(l => l.tag === selectedLanguage)?.label || selectedLanguage;
|
||||
|
||||
if (loading) {
|
||||
if (profileState.loading) {
|
||||
return (
|
||||
<div className="flex flex-col gap-6 min-h-full">
|
||||
<div className="text-slate-500">{locale === 'en' ? 'Loading...' : '加载中...'}</div>
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { useLocation, useNavigate, useParams } from 'react-router-dom';
|
||||
import { useLocation, useNavigate, useParams, useSearchParams } from 'react-router-dom';
|
||||
import {
|
||||
getAgentHistoryByThread,
|
||||
historyMessageToResultData,
|
||||
enqueueFollowUpRun,
|
||||
streamDivinationEvents,
|
||||
type DivinationResultData,
|
||||
} from '../lib/api';
|
||||
import { getHistoryThreadResource, invalidateHistory, invalidatePoints } from '../lib/resources';
|
||||
import Icon from './Icon';
|
||||
|
||||
interface Props {
|
||||
@@ -63,7 +63,9 @@ const CATEGORY_COLORS: Record<string, string> = {
|
||||
export default function HistoryFollowUpPage({ locale, history: h }: Props) {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const { id: threadId } = useParams<{ id: string }>();
|
||||
const { id: routeThreadId } = useParams<{ id: string }>();
|
||||
const [searchParams] = useSearchParams();
|
||||
const threadId = routeThreadId ?? searchParams.get('threadId') ?? undefined;
|
||||
|
||||
const [resultData, setResultData] = useState<DivinationResultData | null>(null);
|
||||
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
||||
@@ -91,7 +93,7 @@ export default function HistoryFollowUpPage({ locale, history: h }: Props) {
|
||||
}
|
||||
|
||||
try {
|
||||
const snapshot = await getAgentHistoryByThread(threadId);
|
||||
const snapshot = await getHistoryThreadResource(threadId);
|
||||
if (!alive) return;
|
||||
|
||||
// Extract result data from first assistant message
|
||||
@@ -162,6 +164,7 @@ export default function HistoryFollowUpPage({ locale, history: h }: Props) {
|
||||
|
||||
try {
|
||||
const { runId } = await enqueueFollowUpRun(threadId, text, resultData);
|
||||
invalidatePoints();
|
||||
|
||||
let answer = '';
|
||||
for await (const event of streamDivinationEvents(threadId, runId)) {
|
||||
@@ -182,7 +185,9 @@ export default function HistoryFollowUpPage({ locale, history: h }: Props) {
|
||||
);
|
||||
|
||||
// Reload history to get server-side message IDs
|
||||
const snapshot = await getAgentHistoryByThread(threadId);
|
||||
invalidateHistory(threadId);
|
||||
invalidatePoints();
|
||||
const snapshot = await getHistoryThreadResource(threadId, true);
|
||||
const chatMessages: ChatMessage[] = snapshot.messages
|
||||
.filter((m) => m.role === 'user' || m.role === 'assistant')
|
||||
.map((m) => ({
|
||||
@@ -195,7 +200,7 @@ export default function HistoryFollowUpPage({ locale, history: h }: Props) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to send follow-up');
|
||||
// Reload history to restore correct state
|
||||
try {
|
||||
const snapshot = await getAgentHistoryByThread(threadId);
|
||||
const snapshot = await getHistoryThreadResource(threadId, true);
|
||||
const chatMessages: ChatMessage[] = snapshot.messages
|
||||
.filter((m) => m.role === 'user' || m.role === 'assistant')
|
||||
.map((m) => ({
|
||||
@@ -370,7 +375,7 @@ export default function HistoryFollowUpPage({ locale, history: h }: Props) {
|
||||
<div className="bg-white rounded-2xl p-4 border border-slate-200 flex flex-col gap-2.5">
|
||||
<h4 className="text-slate-900 text-sm font-bold">{h.relatedActions}</h4>
|
||||
<button
|
||||
onClick={() => navigate(`/${locale}/history/${threadId}`)}
|
||||
onClick={() => threadId && navigate(`/${locale}/history/result?threadId=${encodeURIComponent(threadId)}`)}
|
||||
className="flex items-center gap-2 text-sm text-slate-600 hover:text-violet-600 transition-colors py-1"
|
||||
>
|
||||
<Icon name="auto_awesome" className="w-4 h-4" />
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { useState, useMemo } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { getAgentHistory, mapHistoryMessagesToItems, type HistoryItem } from '../lib/api';
|
||||
import { mapHistoryMessagesToItems, type HistoryItem } from '../lib/api';
|
||||
import { primeHistoryThreadFromSnapshot, useHistoryList } from '../lib/resources';
|
||||
import Icon from './Icon';
|
||||
|
||||
interface Props {
|
||||
@@ -58,36 +59,13 @@ const RATING_COLORS: Record<string, string> = {
|
||||
|
||||
export default function HistoryListPage({ locale, history: i18n }: Props) {
|
||||
const navigate = useNavigate();
|
||||
const [allItems, setAllItems] = useState<HistoryItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const historyState = useHistoryList();
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
const [activeFilter, setActiveFilter] = useState<CategoryKey | 'all'>('all');
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
|
||||
// 获取历史数据
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
setLoading(true);
|
||||
setError('');
|
||||
|
||||
getAgentHistory()
|
||||
.then((data) => {
|
||||
if (!alive) return;
|
||||
setAllItems(mapHistoryMessagesToItems(data.messages));
|
||||
})
|
||||
.catch((err) => {
|
||||
if (!alive) return;
|
||||
setError(err instanceof Error ? err.message : 'Failed to load history');
|
||||
})
|
||||
.finally(() => {
|
||||
if (alive) setLoading(false);
|
||||
});
|
||||
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, []);
|
||||
const allItems = useMemo(() => historyState.data ? mapHistoryMessagesToItems(historyState.data.messages) : [], [historyState.data]);
|
||||
const loading = historyState.loading;
|
||||
const error = historyState.error instanceof Error ? historyState.error.message : historyState.error ? 'Failed to load history' : '';
|
||||
|
||||
const stats = useMemo(() => {
|
||||
const total = allItems.length;
|
||||
@@ -148,7 +126,8 @@ export default function HistoryListPage({ locale, history: i18n }: Props) {
|
||||
// 点击卡片跳转
|
||||
const handleItemClick = (item: HistoryItem) => {
|
||||
setSelectedId(item.id);
|
||||
navigate(`/${locale}/history/${item.threadId}`);
|
||||
if (historyState.data) primeHistoryThreadFromSnapshot(item.threadId, historyState.data);
|
||||
navigate(`/${locale}/history/result?threadId=${encodeURIComponent(item.threadId)}`);
|
||||
};
|
||||
|
||||
// 返回首页
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
import { sendOtp, loginWithEmail, getAuth, refreshAccessToken, ApiError, localeToBackendLanguage, backendLanguageToLocale } from '../lib/auth';
|
||||
import { getUserProfile } from '../lib/api';
|
||||
import { getProfileResource } from '../lib/resources';
|
||||
|
||||
interface LoginFormProps {
|
||||
locale: string;
|
||||
@@ -58,7 +58,7 @@ export default function LoginForm({ locale, translations: i18n, privacyUrl, term
|
||||
try {
|
||||
await refreshAccessToken();
|
||||
// Token valid, get profile language and redirect
|
||||
const profile = await getUserProfile();
|
||||
const profile = await getProfileResource();
|
||||
const userLocale = backendLanguageToLocale(profile.settings?.preferences?.language || 'zh-CN');
|
||||
window.location.href = `/${userLocale}/dashboard`;
|
||||
} catch {
|
||||
@@ -89,8 +89,7 @@ export default function LoginForm({ locale, translations: i18n, privacyUrl, term
|
||||
}
|
||||
}, [email, countdown, locale]);
|
||||
|
||||
const handleSubmit = useCallback(async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const handleSubmit = useCallback(async () => {
|
||||
if (!email || !code || !agreed) return;
|
||||
setError('');
|
||||
setLoading(true);
|
||||
@@ -98,9 +97,9 @@ export default function LoginForm({ locale, translations: i18n, privacyUrl, term
|
||||
const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
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);
|
||||
// Fresh login just sent this language to the backend. Avoid fetching profile
|
||||
// before a full-page navigation that would lose the in-memory resource cache.
|
||||
const userLocale = backendLanguageToLocale(language);
|
||||
window.location.href = `/${userLocale}/dashboard`;
|
||||
} catch (err) {
|
||||
setError(getErrorMessage(err, locale));
|
||||
|
||||
@@ -2,7 +2,8 @@ import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import Icon from './Icon';
|
||||
import DivinationProcessingOverlay from './DivinationProcessingOverlay';
|
||||
import { getPointsBalance, getUserProfile, updateUserSettings, type PointsBalance, type DivinationResultData } from '../lib/api';
|
||||
import type { DivinationResultData } from '../lib/api';
|
||||
import { updateSettingsResource, usePoints } from '../lib/resources';
|
||||
import { useUserSettings } from './AppShell';
|
||||
|
||||
interface Props {
|
||||
@@ -192,13 +193,14 @@ export default function ManualDivinationPage({ locale, divination: d }: Props) {
|
||||
const text = copy[locale as keyof typeof copy] ?? copy.zh;
|
||||
const cats = useMemo(() => d.categories.split(','), [d.categories]);
|
||||
const navigate = useNavigate();
|
||||
const [category, setCategory] = useState(cats[0]);
|
||||
const [question, setQuestion] = useState(text.defaultQuestion);
|
||||
const [category, setCategory] = useState<string>(cats[0]);
|
||||
const [question, setQuestion] = useState<string>(text.defaultQuestion);
|
||||
const [selectedTime, setSelectedTime] = useState(() => formatDateTimeInput(new Date()));
|
||||
const [coins, setCoins] = useState<[CoinFace, CoinFace, CoinFace]>(['zi', 'zi', 'zi']);
|
||||
const [yaoResults, setYaoResults] = useState<YaoType[]>([]);
|
||||
const [guideStep, setGuideStep] = useState<number | null>(null);
|
||||
const [points, setPoints] = useState<PointsBalance | null>(null);
|
||||
const pointsState = usePoints();
|
||||
const points = pointsState.data ?? null;
|
||||
const [editingIndex, setEditingIndex] = useState<number | null>(null);
|
||||
const [showProcessing, setShowProcessing] = useState(false);
|
||||
const [showConfirm, setShowConfirm] = useState(false);
|
||||
@@ -209,6 +211,7 @@ export default function ManualDivinationPage({ locale, divination: d }: Props) {
|
||||
const coinsAreaRef = useRef<HTMLDivElement>(null);
|
||||
const timePanelRef = useRef<HTMLElement>(null);
|
||||
const yaoPanelRef = useRef<HTMLElement>(null);
|
||||
const yaoRowsRef = useRef<HTMLDivElement>(null);
|
||||
const submitBtnRef = useRef<HTMLButtonElement>(null);
|
||||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||
const [spotlightRect, setSpotlightRect] = useState<{ left: number; top: number; width: number; height: number } | null>(null);
|
||||
@@ -248,7 +251,7 @@ export default function ManualDivinationPage({ locale, divination: d }: Props) {
|
||||
},
|
||||
};
|
||||
try {
|
||||
const updated = await updateUserSettings({ settings: updatedSettings });
|
||||
const updated = await updateSettingsResource(updatedSettings);
|
||||
setUserProfile(updated);
|
||||
} catch {
|
||||
// Silently fail - tutorial shown state is non-critical
|
||||
@@ -269,12 +272,17 @@ export default function ManualDivinationPage({ locale, divination: d }: Props) {
|
||||
return;
|
||||
}
|
||||
|
||||
const targetRef = [coinsAreaRef, timePanelRef, yaoPanelRef, submitBtnRef][guideStep];
|
||||
const mobileGuideTargets = [coinsAreaRef, timePanelRef, yaoRowsRef, submitBtnRef];
|
||||
const desktopGuideTargets = [coinsAreaRef, timePanelRef, yaoPanelRef, submitBtnRef];
|
||||
const targetRef = (isMobile ? mobileGuideTargets : desktopGuideTargets)[guideStep];
|
||||
if (!targetRef?.current) return;
|
||||
|
||||
const tooltipWidth = 320;
|
||||
const tooltipHeight = 180;
|
||||
const gap = 16;
|
||||
const tooltipHeight = isMobile ? 220 : 180;
|
||||
const gap = isMobile ? 24 : 16;
|
||||
|
||||
const overlayHost = scrollContainerRef.current;
|
||||
if (!overlayHost) return;
|
||||
|
||||
// Get scroll container - it's the main element inside AppShell
|
||||
const scrollContainer = scrollContainerRef.current?.parentElement?.parentElement as HTMLElement | null;
|
||||
@@ -284,12 +292,10 @@ export default function ManualDivinationPage({ locale, divination: d }: Props) {
|
||||
|
||||
// ===== MOBILE: Absolute positioning relative to scroll container =====
|
||||
if (isMobile) {
|
||||
// Calculate element's offset relative to scroll container
|
||||
const containerRect = scrollContainer.getBoundingClientRect();
|
||||
const elementRect = targetRef.current.getBoundingClientRect();
|
||||
|
||||
// Element position relative to scroll container (accounts for current scroll)
|
||||
const elementLeft = elementRect.left - containerRect.left;
|
||||
const containerRect = scrollContainer.getBoundingClientRect();
|
||||
const elementTop = elementRect.top - containerRect.top + scrollContainer.scrollTop;
|
||||
const elementWidth = elementRect.width;
|
||||
const elementHeight = elementRect.height;
|
||||
@@ -299,30 +305,42 @@ export default function ManualDivinationPage({ locale, divination: d }: Props) {
|
||||
scrollContainer.scrollTop = 0;
|
||||
}
|
||||
|
||||
// Calculate where we need to scroll to make both spotlight and tooltip visible
|
||||
// Spotlight should be at top portion, tooltip below it
|
||||
const totalHeight = elementHeight + gap + tooltipHeight;
|
||||
const scrollTopNeeded = Math.max(0, elementTop - 20); // 20px margin above spotlight
|
||||
// Calculate where we need to scroll to make the spotlight visible.
|
||||
const scrollTopNeeded = Math.max(
|
||||
0,
|
||||
guideStep === 3 ? elementTop - tooltipHeight - gap - 32 : elementTop - 20,
|
||||
);
|
||||
|
||||
// Smooth scroll to position
|
||||
scrollContainer.scrollTo({ top: scrollTopNeeded, behavior: 'smooth' });
|
||||
scrollContainer.scrollTo({ top: scrollTopNeeded, behavior: 'auto' });
|
||||
|
||||
// Use requestAnimationFrame to ensure scroll has started before calculating final position
|
||||
// Use requestAnimationFrame to calculate after the scroll position has updated.
|
||||
requestAnimationFrame(() => {
|
||||
if (!targetRef.current) return;
|
||||
// Recalculate element position after scroll setup
|
||||
const newElementRect = targetRef.current!.getBoundingClientRect();
|
||||
const newContainerRect = scrollContainer.getBoundingClientRect();
|
||||
const newElementRect = targetRef.current.getBoundingClientRect();
|
||||
const hostRect = overlayHost.getBoundingClientRect();
|
||||
const visibleContainerRect = scrollContainer.getBoundingClientRect();
|
||||
const visibleTop = visibleContainerRect.top - hostRect.top;
|
||||
const visibleBottom = visibleTop + visibleContainerRect.height;
|
||||
|
||||
// Position relative to container (for absolute positioning)
|
||||
const spotlightLeft = newElementRect.left - newContainerRect.left;
|
||||
const spotlightTop = newElementRect.top - newContainerRect.top;
|
||||
// Position relative to this component because the mobile overlay is absolute.
|
||||
const spotlightLeft = newElementRect.left - hostRect.left;
|
||||
const spotlightTop = newElementRect.top - hostRect.top;
|
||||
|
||||
// Tooltip goes below the element
|
||||
const tooltipLeft = Math.max(16, Math.min(
|
||||
(newElementRect.left + newElementRect.right - tooltipWidth) / 2 - newContainerRect.left,
|
||||
containerRect.width - tooltipWidth - 16
|
||||
(newElementRect.left + newElementRect.right - tooltipWidth) / 2 - hostRect.left,
|
||||
hostRect.width - tooltipWidth - 16
|
||||
));
|
||||
const tooltipTop = spotlightTop + elementHeight + gap;
|
||||
let tooltipTop = spotlightTop + elementHeight + gap;
|
||||
let side: 'bottom' | 'top' = 'bottom';
|
||||
if (guideStep === 3) {
|
||||
tooltipTop = Math.max(visibleTop + 16, spotlightTop - tooltipHeight - gap);
|
||||
side = 'top';
|
||||
}
|
||||
if (tooltipTop + tooltipHeight > visibleBottom) {
|
||||
tooltipTop = Math.max(visibleTop + 16, spotlightTop - tooltipHeight - gap);
|
||||
side = 'top';
|
||||
}
|
||||
|
||||
setSpotlightRect({
|
||||
left: spotlightLeft,
|
||||
@@ -331,7 +349,7 @@ export default function ManualDivinationPage({ locale, divination: d }: Props) {
|
||||
height: elementHeight
|
||||
});
|
||||
setTooltipPos({ left: tooltipLeft, top: tooltipTop });
|
||||
setTooltipSide('bottom');
|
||||
setTooltipSide(side);
|
||||
});
|
||||
|
||||
prevGuideStepRef.current = guideStep;
|
||||
@@ -374,10 +392,6 @@ export default function ManualDivinationPage({ locale, divination: d }: Props) {
|
||||
setCategory(cats[0]);
|
||||
}, [cats]);
|
||||
|
||||
useEffect(() => {
|
||||
getPointsBalance().then(setPoints).catch(() => {});
|
||||
}, []);
|
||||
|
||||
// Track mobile state on resize
|
||||
useEffect(() => {
|
||||
const handleResize = () => setIsMobile(window.innerWidth < 1280);
|
||||
@@ -513,7 +527,7 @@ export default function ManualDivinationPage({ locale, divination: d }: Props) {
|
||||
<span className="text-[13px] font-bold text-violet-700">{progress} / {TOTAL_YAO_COUNT}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2.5">
|
||||
<div ref={yaoRowsRef} className="flex flex-col gap-2.5">
|
||||
{[5, 4, 3, 2, 1, 0].map((index) => {
|
||||
const result = yaoResults[index];
|
||||
// Only show "active" highlight when not in editing mode
|
||||
@@ -598,12 +612,12 @@ export default function ManualDivinationPage({ locale, divination: d }: Props) {
|
||||
<>
|
||||
{/* Dark overlay - fixed for desktop, covers viewport */}
|
||||
<div
|
||||
className="fixed inset-0 z-40 bg-black/70 md:block hidden"
|
||||
className="fixed inset-0 z-40 hidden bg-black/70 xl:block"
|
||||
onClick={() => closeGuide()}
|
||||
/>
|
||||
{/* Mobile dark overlay - positioned within scroll container */}
|
||||
<div
|
||||
className="absolute inset-0 z-40 bg-black/70 md:hidden"
|
||||
className="absolute inset-0 z-40 xl:hidden"
|
||||
style={{ top: 0, height: '100vh' }}
|
||||
onClick={() => closeGuide()}
|
||||
/>
|
||||
@@ -636,7 +650,7 @@ export default function ManualDivinationPage({ locale, divination: d }: Props) {
|
||||
className={`absolute h-3 w-3 rotate-45 bg-slate-950 ${
|
||||
tooltipSide === 'right' ? '-left-1.5 top-6' :
|
||||
tooltipSide === 'left' ? '-right-1.5 top-6' :
|
||||
tooltipSide === 'top' ? '-bottom-1.5 left-6' :
|
||||
tooltipSide === 'top' ? '-bottom-1.5 left-1/2 -translate-x-1/2' :
|
||||
'-top-1.5 left-6'
|
||||
}`}
|
||||
/>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import type { NotificationItem } from '../lib/api';
|
||||
import { getNotifications, markNotificationRead, markAllNotificationsRead } from '../lib/api';
|
||||
import { markAllNotificationsReadResource, markNotificationReadResource, useNotifications } from '../lib/resources';
|
||||
|
||||
interface Props {
|
||||
locale: string;
|
||||
@@ -52,25 +52,13 @@ function formatFullTime(dateStr: string, locale: string): string {
|
||||
}
|
||||
|
||||
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 notificationsState = useNotifications(locale);
|
||||
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]);
|
||||
const items = useMemo(() => notificationsState.data?.items ?? [], [notificationsState.data]);
|
||||
const loading = notificationsState.loading;
|
||||
const error = notificationsState.error instanceof Error ? notificationsState.error.message : notificationsState.error ? n.error : null;
|
||||
|
||||
useEffect(() => {
|
||||
if (toast) {
|
||||
@@ -83,8 +71,7 @@ export default function NotificationPage({ locale, notifications: n }: Props) {
|
||||
setSelectedItem(item);
|
||||
if (!item.isRead) {
|
||||
try {
|
||||
const updated = await markNotificationRead(item.id, locale);
|
||||
setItems((prev) => prev.map((i) => (i.id === item.id ? updated : i)));
|
||||
await markNotificationReadResource(item.id, locale);
|
||||
} catch {
|
||||
// ignore mark read error
|
||||
}
|
||||
@@ -97,8 +84,7 @@ export default function NotificationPage({ locale, notifications: n }: Props) {
|
||||
|
||||
setMarkingAll(true);
|
||||
try {
|
||||
await markAllNotificationsRead();
|
||||
setItems((prev) => prev.map((i) => ({ ...i, isRead: true })));
|
||||
await markAllNotificationsReadResource(locale);
|
||||
setToast(n.markAllReadDone);
|
||||
} catch {
|
||||
// ignore error
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
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';
|
||||
import { updateProfileResource, uploadAvatarResource, useProfile } from '../lib/resources';
|
||||
|
||||
interface Props {
|
||||
locale: string;
|
||||
@@ -57,28 +57,25 @@ async function compressImage(file: File, maxWidth = 512, maxHeight = 512, qualit
|
||||
|
||||
export default function ProfileDetailPage({ locale, profile: p }: Props) {
|
||||
const navigate = useNavigate();
|
||||
const [profile, setProfile] = useState<UserProfile | null>(null);
|
||||
const profileState = useProfile();
|
||||
const profile = profileState.data ?? 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));
|
||||
}, []);
|
||||
if (!profileState.data) return;
|
||||
setDisplayName(profileState.data.display_name || '');
|
||||
setBio(profileState.data.bio || '');
|
||||
}, [profileState.data]);
|
||||
|
||||
useEffect(() => {
|
||||
if (profileState.error instanceof Error) setError(profileState.error.message || 'Failed to load profile');
|
||||
}, [profileState.error]);
|
||||
|
||||
// Clear messages after 3 seconds
|
||||
useEffect(() => {
|
||||
@@ -92,7 +89,7 @@ export default function ProfileDetailPage({ locale, profile: p }: Props) {
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
await updateUserProfile({
|
||||
await updateProfileResource({
|
||||
display_name: displayName || undefined,
|
||||
bio: bio || undefined,
|
||||
});
|
||||
@@ -134,8 +131,7 @@ export default function ProfileDetailPage({ locale, profile: p }: Props) {
|
||||
throw new Error(locale === 'en' ? 'Image too large, please choose a smaller one' : '图片太大,请选择更小的图片');
|
||||
}
|
||||
|
||||
const updated = await uploadAvatar(compressedFile);
|
||||
setProfile(updated);
|
||||
await uploadAvatarResource(compressedFile);
|
||||
setSuccess(locale === 'en' ? 'Avatar updated' : '头像已更新');
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to upload');
|
||||
@@ -146,7 +142,7 @@ export default function ProfileDetailPage({ locale, profile: p }: Props) {
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
if (profileState.loading) {
|
||||
return (
|
||||
<div className="flex flex-col gap-6 min-h-full">
|
||||
<div className="text-slate-500">{locale === 'en' ? 'Loading...' : '加载中...'}</div>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { logout, getAuth } from '../lib/auth';
|
||||
import { getUserProfile, getPointsBalance, type UserProfile, type PointsBalance } from '../lib/api';
|
||||
import { usePoints, useProfile } from '../lib/resources';
|
||||
|
||||
interface Props {
|
||||
locale: string;
|
||||
@@ -9,25 +8,11 @@ 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 profileState = useProfile();
|
||||
const pointsState = usePoints();
|
||||
const profile = profileState.data ?? null;
|
||||
const points = pointsState.data ?? null;
|
||||
const loading = profileState.loading || pointsState.loading;
|
||||
|
||||
const handleLogout = () => {
|
||||
if (confirm(s.logoutConfirm)) {
|
||||
@@ -38,8 +23,8 @@ export default function SettingsPage({ locale, settings: s }: Props) {
|
||||
};
|
||||
|
||||
const authEmail = getAuth()?.user?.email;
|
||||
const displayName = loading ? '' : (profile?.display_name || profile?.email?.split('@')[0] || authEmail?.split('@')[0] || '');
|
||||
const email = loading ? '' : (profile?.email || authEmail || '');
|
||||
const displayName = loading ? '' : (profile?.display_name || authEmail?.split('@')[0] || '');
|
||||
const email = loading ? '' : (authEmail || '');
|
||||
const bio = profile?.bio || '';
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import type { PointsBalance, PackageInfo } from '../lib/api';
|
||||
import { getPointsBalance, getPackages, invalidatePointsCache } from '../lib/api';
|
||||
import { useMemo } from 'react';
|
||||
import { usePackages, usePoints } from '../lib/resources';
|
||||
|
||||
interface Props {
|
||||
locale: string;
|
||||
@@ -50,49 +49,35 @@ function SidePanel({ s }: { s: Props['store'] }) {
|
||||
}
|
||||
|
||||
export default function StorePage({ store: s, pricing: p }: Props) {
|
||||
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]);
|
||||
const pointsState = usePoints();
|
||||
const packagesState = usePackages();
|
||||
const points = pointsState.data ?? null;
|
||||
const packages = useMemo<PackageDisplay[]>(() => {
|
||||
const packagesData = packagesState.data;
|
||||
if (!packagesData) return [];
|
||||
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,
|
||||
};
|
||||
});
|
||||
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);
|
||||
});
|
||||
return displayPkgs;
|
||||
}, [packagesState.data, p, s.pointsLabel]);
|
||||
const loading = pointsState.loading || packagesState.loading;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-5 min-h-full">
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
export interface NavItem {
|
||||
id: string;
|
||||
icon: string;
|
||||
|
||||
@@ -26,7 +26,7 @@ export interface Translations {
|
||||
footer: { brandName: string; desc: string; col1Title: string; col1Link1: string; col1Link2: string; col2Title: string; col2Link1: string; col2Link2: string; col3Title: string; col3Link1: string; col3Link2: string };
|
||||
features: { title: string; subtitle: string; tagline: string; c1Title: string; c1Desc: string; c2Title: string; c2Desc: string; c3Title: string; c3Desc: string; c4Title: string; c4Desc: string; c5Title: string; c5Desc: string; c6Title: string; c6Desc: string };
|
||||
pricing: { title: string; subtitle: string; p1Name: string; p1Badge: string; p1Price: string; p1Credits: string; p1Desc: string; p2Name: string; p2Price: string; p2Credits: string; p2Desc: string; p2Detail: string; p3Name: string; p3Badge: string; p3Price: string; p3Credits: string; p3Desc: string; p4Name: string; p4Price: string; p4Credits: string; p4Desc: string; p4Detail: string; buyNow: string };
|
||||
about: { title: string; subtitle: string; storyTitle: string; p1: string; p2: string; p3: string; companyInfo: string; company: string; emailLabel: string; email: string; devLabel: string; developer: string; warningTitle: string; warningBody: string; legalTitle: string };
|
||||
about: { title: string; subtitle: string; storyTitle: string; p1: string; p2: string; p3: string; companyInfo: string; company: string; emailLabel: string; email: string; devLabel: string; developer: string; icpLabel: string; icp: string; warningTitle: string; warningBody: string; legalTitle: string };
|
||||
login: { welcome: string; subtitle: string; emailLabel: string; emailPlaceholder: string; codeLabel: string; codePlaceholder: string; sendCode: string; submit: string; agreePrefix: string; privacy: string; agreeAnd: string; terms: string };
|
||||
dashboard: { brandName: string; navHome: string; navStore: string; navDivination: string; navManual: string; navAuto: string; navHistory: string; navLanguage: string; navSettings: string; greeting: string; greetingSub: string; heroTitle: string; heroDesc: string; heroCta: string; historyTitle: string; historyViewAll: string; logout: string };
|
||||
notifications: { title: string; loading: string; error: string; empty: string; markAllRead: string; markAllReadDone: string };
|
||||
@@ -50,7 +50,7 @@ const translations: Record<Locale, Translations> = {
|
||||
footer: { brandName: '觅爻签问', desc: '以古老智慧,解读今时困惑。让每一次签问,都成为与自己对话的机会。', col1Title: '产品', col1Link1: '功能介绍', col1Link2: '定价', col2Title: '支持', col2Link1: '帮助中心', col2Link2: '联系我们', col3Title: '法律', col3Link1: '隐私政策', col3Link2: '服务条款' },
|
||||
features: { title: '功能特性', subtitle: '以古老智慧解读今时困惑,觅爻签问提供完整的易学体验', tagline: '从起卦到解读,每一步都精心设计', c1Title: '两种起卦方式', c1Desc: '手动起卦与自动起卦,灵活选择最适合你的方式。推荐使用手动起卦,卦象解读更准确。', c2Title: 'AI 解卦分析', c2Desc: '基于传统六爻卦象与周易哲学体系,结合AI智能分析,提供深度卦象解读与建议。', c3Title: '九类问题覆盖', c3Desc: '事业、情感、财富、运势、解梦、健康、学业、寻物等九大领域,全面覆盖日常生活所问。', c4Title: '追问互动', c4Desc: '每次解卦后可深入追问一次,针对卦象细节获取更多洞见,让解读更加全面深入。', c5Title: '历史记录', c5Desc: '自动保存所有解卦记录,包括卦象详情与AI解读。随时回顾历史,追踪问题变化趋势。', c6Title: '点数系统', c6Desc: '提供灵活积分套餐:新人专享、入门补充、常用加量、高频进阶。按需购买,自由使用。' },
|
||||
pricing: { title: '选择适合你的套餐', subtitle: '灵活积分套餐,按需选择,随时可用', p1Name: '新人专享包', p1Badge: '限购一次', p1Price: '$0.99', p1Credits: '60 积分', p1Desc: '最适合初次体验', p2Name: '入门补充包', p2Price: '$4.99', p2Credits: '100 积分', p2Desc: '日常解卦补充', p2Detail: '适量点数补充,经济实惠之选', p3Name: '常用加量包', p3Badge: '推荐', p3Price: '$7.99', p3Credits: '210 积分', p3Desc: '最适合日常使用', p4Name: '高频进阶包', p4Price: '$12.99', p4Credits: '415 积分', p4Desc: '重度使用优选', p4Detail: '大量点数储备,超值单价', buyNow: '立即购买' },
|
||||
about: { title: '关于觅爻签问', subtitle: '了解我们的理念与定位', storyTitle: '我们的故事', p1: '觅爻签问是一个借助于 AI 解读传统六爻卦象的平台,为用户了解中国传统易学文化提供一个窗口。六爻卦象源于《周易》深邃的哲学体系,是古人探索世界运行规律的一种独特方法。古人认为宇宙万物相互关联,在你起卦时,你的心念与时空信息会凝结成卦象的方式呈现出来。', p2: '觅爻签问基于这样的思路,帮助你跳出局限思维,从全局和演变趋势看清矛盾、机会与风险,为判断和行动提供参考。', p3: '用 AI 解锁古老智慧,让觅爻签问成为你探索趋势、明晰方向的现代助手。', companyInfo: '公司信息', company: '洵觅科技(深圳)有限公司', emailLabel: '联系邮箱', email: 'xuyunlong@xunmee.com', devLabel: '开发者', developer: 'Ann Lee', warningTitle: '特别提醒', warningBody: '卦象解读结果均由 AI 生成,仅供娱乐参考,切不可作为商业、医疗等专业领域的决策依据。理性看待卦象,自由掌握人生。', legalTitle: '法律条款' },
|
||||
about: { title: '关于觅爻签问', subtitle: '了解我们的理念与定位', storyTitle: '我们的故事', p1: '觅爻签问是一个借助于 AI 解读传统六爻卦象的平台,为用户了解中国传统易学文化提供一个窗口。六爻卦象源于《周易》深邃的哲学体系,是古人探索世界运行规律的一种独特方法。古人认为宇宙万物相互关联,在你起卦时,你的心念与时空信息会凝结成卦象的方式呈现出来。', p2: '觅爻签问基于这样的思路,帮助你跳出局限思维,从全局和演变趋势看清矛盾、机会与风险,为判断和行动提供参考。', p3: '用 AI 解锁古老智慧,让觅爻签问成为你探索趋势、明晰方向的现代助手。', companyInfo: '公司信息', company: '洵觅科技(深圳)有限公司', emailLabel: '联系邮箱', email: 'xuyunlong@xunmee.com', devLabel: '开发者', developer: 'Ann Lee', icpLabel: '备案号', icp: '粤ICP备2025428416号-1A', warningTitle: '特别提醒', warningBody: '卦象解读结果均由 AI 生成,仅供娱乐参考,切不可作为商业、医疗等专业领域的决策依据。理性看待卦象,自由掌握人生。', legalTitle: '法律条款' },
|
||||
login: { welcome: '欢迎', subtitle: '使用邮箱验证码快速登录或注册', emailLabel: '邮箱地址', emailPlaceholder: '请输入邮箱地址', codeLabel: '验证码', codePlaceholder: '请输入验证码', sendCode: '获取验证码', submit: '登录 / 注册', agreePrefix: '我已阅读并同意', privacy: '《隐私政策》', agreeAnd: '和', terms: '《服务条款》' },
|
||||
dashboard: { brandName: '觅爻签问', navHome: '首页', navStore: '商店', navDivination: '起卦', navManual: '手动起卦', navAuto: '自动起卦', navHistory: '历史解卦', navLanguage: '语言', navSettings: '设置', greeting: '下午好', greetingSub: '今天想要探寻什么方向?', heroTitle: '开始您的卦象之旅', heroDesc: '借助AI智能,探索未来的可能。心中有问,起卦便知。', heroCta: '立即起卦', historyTitle: '历史解卦', historyViewAll: '查看全部 →', logout: '退出登录' },
|
||||
notifications: { title: '通知中心', loading: '加载中...', error: '加载失败', empty: '暂无通知', markAllRead: '全部已读', markAllReadDone: '已全部标记为已读' },
|
||||
@@ -72,7 +72,7 @@ const translations: Record<Locale, Translations> = {
|
||||
footer: { brandName: '覓爻簽問', desc: '以古老智慧,解讀今時困惑。讓每一次簽問,都成為與自己對話的機會。', col1Title: '產品', col1Link1: '功能介紹', col1Link2: '定價', col2Title: '支持', col2Link1: '幫助中心', col2Link2: '聯繫我們', col3Title: '法律', col3Link1: '隱私政策', col3Link2: '服務條款' },
|
||||
features: { title: '功能特性', subtitle: '以古老智慧解讀今時困惑,覓爻簽問提供完整的易學體驗', tagline: '從起卦到解讀,每一步都精心設計', c1Title: '兩種起卦方式', c1Desc: '手動起卦與自動起卦,靈活選擇最適合你的方式。推薦使用手動起卦,卦象解讀更準確。', c2Title: 'AI 解卦分析', c2Desc: '基於傳統六爻卦象與周易哲學體系,結合AI智能分析,提供深度卦象解讀與建議。', c3Title: '九類問題覆蓋', c3Desc: '事業、情感、財富、運勢、解夢、健康、學業、尋物等九大領域,全面覆蓋日常生活所問。', c4Title: '追問互動', c4Desc: '每次解卦後可深入追問一次,針對卦象細節獲取更多洞見,讓解讀更加全面深入。', c5Title: '歷史記錄', c5Desc: '自動保存所有解卦記錄,包括卦象詳情與AI解讀。隨時回顧歷史,追蹤問題變化趨勢。', c6Title: '點數系統', c6Desc: '提供靈活積分套餐:新人專享、入門補充、常用加量、高頻進階。按需購買,自由使用。' },
|
||||
pricing: { title: '選擇適合你的套餐', subtitle: '靈活積分套餐,按需選擇,隨時可用', p1Name: '新人專享包', p1Badge: '限購一次', p1Price: '$0.99', p1Credits: '60 積分', p1Desc: '最適合初次體驗', p2Name: '入門補充包', p2Price: '$4.99', p2Credits: '100 積分', p2Desc: '日常解卦補充', p2Detail: '適量點數補充,經濟實惠之選', p3Name: '常用加量包', p3Badge: '推薦', p3Price: '$7.99', p3Credits: '210 積分', p3Desc: '最適合日常使用', p4Name: '高頻進階包', p4Price: '$12.99', p4Credits: '415 積分', p4Desc: '重度使用優選', p4Detail: '大量點數儲備,超值單價', buyNow: '立即購買' },
|
||||
about: { title: '關於覓爻簽問', subtitle: '了解我們的理念與定位', storyTitle: '我們的故事', p1: '覓爻簽問是一個借助於 AI 解讀傳統六爻卦象的平台,為用戶了解中國傳統易學文化提供一個窗口。六爻卦象源於《周易》深邃的哲學體系,是古人探索世界運行規律的一種獨特方法。古人認為宇宙萬物相互關聯,在你起卦時,你的心念與時空信息會凝結成卦象的方式呈現出來。', p2: '覓爻簽問基於這樣的思路,幫助你跳出局限思維,從全局和演變趨勢看清矛盾、機會與風險,為判斷和行動提供參考。', p3: '用 AI 解鎖古老智慧,讓覓爻簽問成為你探索趨勢、明晰方向的現代助手。', companyInfo: '公司信息', company: '洵覓科技(深圳)有限公司', emailLabel: '聯繫郵箱', email: 'xuyunlong@xunmee.com', devLabel: '開發者', developer: 'Ann Lee', warningTitle: '特別提醒', warningBody: '卦象解讀結果均由 AI 生成,僅供娛樂參考,切不可作為商業、醫療等專業領域的決策依據。理性看待卦象,自由掌握人生。', legalTitle: '法律條款' },
|
||||
about: { title: '關於覓爻簽問', subtitle: '了解我們的理念與定位', storyTitle: '我們的故事', p1: '覓爻簽問是一個借助於 AI 解讀傳統六爻卦象的平台,為用戶了解中國傳統易學文化提供一個窗口。六爻卦象源於《周易》深邃的哲學體系,是古人探索世界運行規律的一種獨特方法。古人認為宇宙萬物相互關聯,在你起卦時,你的心念與時空信息會凝結成卦象的方式呈現出來。', p2: '覓爻簽問基於這樣的思路,幫助你跳出局限思維,從全局和演變趨勢看清矛盾、機會與風險,為判斷和行動提供參考。', p3: '用 AI 解鎖古老智慧,讓覓爻簽問成為你探索趨勢、明晰方向的現代助手。', companyInfo: '公司信息', company: '洵覓科技(深圳)有限公司', emailLabel: '聯繫郵箱', email: 'xuyunlong@xunmee.com', devLabel: '開發者', developer: 'Ann Lee', icpLabel: '備案號', icp: '粵ICP備2025428416號-1A', warningTitle: '特別提醒', warningBody: '卦象解讀結果均由 AI 生成,僅供娛樂參考,切不可作為商業、醫療等專業領域的決策依據。理性看待卦象,自由掌握人生。', legalTitle: '法律條款' },
|
||||
login: { welcome: '歡迎', subtitle: '使用郵箱驗證碼快速登錄或註冊', emailLabel: '郵箱地址', emailPlaceholder: '請輸入郵箱地址', codeLabel: '驗證碼', codePlaceholder: '請輸入驗證碼', sendCode: '獲取驗證碼', submit: '登錄 / 註冊', agreePrefix: '我已閱讀並同意', privacy: '《隱私政策》', agreeAnd: '和', terms: '《服務條款》' },
|
||||
dashboard: { brandName: '覓爻簽問', navHome: '首頁', navStore: '商店', navDivination: '起卦', navManual: '手動起卦', navAuto: '自動起卦', navHistory: '歷史解卦', navLanguage: '語言', navSettings: '設置', greeting: '下午好', greetingSub: '今天想要探尋什麼方向?', heroTitle: '開始您的卦象之旅', heroDesc: '借助AI智能,探索未來的可能。心中有問,起卦便知。', heroCta: '立即起卦', historyTitle: '歷史解卦', historyViewAll: '查看全部 →', logout: '退出登錄' },
|
||||
notifications: { title: '通知中心', loading: '加載中...', error: '加載失敗', empty: '暫無通知', markAllRead: '全部已讀', markAllReadDone: '已全部標記為已讀' },
|
||||
@@ -94,7 +94,7 @@ const translations: Record<Locale, Translations> = {
|
||||
footer: { brandName: 'MeiYao Divination', desc: 'Using ancient wisdom to interpret modern confusion. Let every divination become a chance to dialogue with yourself.', col1Title: 'Product', col1Link1: 'Features', col1Link2: 'Pricing', col2Title: 'Support', col2Link1: 'Help Center', col2Link2: 'Contact Us', col3Title: 'Legal', col3Link1: 'Privacy Policy', col3Link2: 'Terms of Service' },
|
||||
features: { title: 'Features', subtitle: 'Ancient wisdom meets modern困惑, MeiYao provides a complete I Ching experience', tagline: 'From casting to interpretation, every step is carefully designed', c1Title: 'Two Casting Methods', c1Desc: 'Manual and auto casting — choose what suits you best. Manual casting is recommended for more accurate readings.', c2Title: 'AI Analysis', c2Desc: 'Combining traditional Six-Line hexagrams with AI intelligence for in-depth interpretation and suggestions.', c3Title: '9 Question Categories', c3Desc: 'Career, love, wealth, fortune, dreams, health, study, lost items, and more — covering all aspects of daily life.', c4Title: 'Follow-up Questions', c4Desc: 'Ask one follow-up question after each reading for deeper insights into specific hexagram details.', c5Title: 'History', c5Desc: 'All readings are automatically saved with full hexagram details and AI interpretations. Review anytime.', c6Title: 'Credits System', c6Desc: 'Flexible credit packages: starter, basic, popular, and premium. Purchase as needed, use freely.' },
|
||||
pricing: { title: 'Choose Your Plan', subtitle: 'Flexible credit packages, pay as you go', p1Name: 'Starter Pack', p1Badge: 'Once Only', p1Price: '$0.99', p1Credits: '60 credits', p1Desc: 'Best for first-timers', p2Name: 'Basic Pack', p2Price: '$4.99', p2Credits: '100 credits', p2Desc: 'Daily supplement', p2Detail: 'Affordable credit refill', p3Name: 'Popular Pack', p3Badge: 'Popular', p3Price: '$7.99', p3Credits: '210 credits', p3Desc: 'Best for daily use', p4Name: 'Premium Pack', p4Price: '$12.99', p4Credits: '415 credits', p4Desc: 'Best value per credit', p4Detail: 'Bulk credits at best unit price', buyNow: 'Buy Now' },
|
||||
about: { title: 'About MeiYao Divination', subtitle: 'Our vision and philosophy', storyTitle: 'Our Story', p1: 'MeiYao Divination is an AI-assisted platform for interpreting traditional Six-Line divination and opening a window into classical Chinese wisdom. Six-Line divination originates from the deep philosophical system of the I Ching.', p2: 'MeiYao Divination helps you step outside narrow thinking, understand contradictions, opportunities, and risks from a broader trend perspective, and make calmer, more thoughtful decisions.', p3: 'Unlock ancient wisdom with AI. Let MeiYao Divination be your modern companion for exploring trends and finding direction.', companyInfo: 'Company', company: 'Xunmee Technology (Shenzhen) Co., Ltd.', emailLabel: 'Email', email: 'xuyunlong@xunmee.com', devLabel: 'Developer', developer: 'Ann Lee', warningTitle: 'Important Notice', warningBody: 'All divination interpretations are AI-generated for entertainment only. Do not use them as professional advice for business, medical, or legal decisions.', legalTitle: 'Legal' },
|
||||
about: { title: 'About MeiYao Divination', subtitle: 'Our vision and philosophy', storyTitle: 'Our Story', p1: 'MeiYao Divination is an AI-assisted platform for interpreting traditional Six-Line divination and opening a window into classical Chinese wisdom. Six-Line divination originates from the deep philosophical system of the I Ching.', p2: 'MeiYao Divination helps you step outside narrow thinking, understand contradictions, opportunities, and risks from a broader trend perspective, and make calmer, more thoughtful decisions.', p3: 'Unlock ancient wisdom with AI. Let MeiYao Divination be your modern companion for exploring trends and finding direction.', companyInfo: 'Company', company: 'Xunmee Technology (Shenzhen) Co., Ltd.', emailLabel: 'Email', email: 'xuyunlong@xunmee.com', devLabel: 'Developer', developer: 'Ann Lee', icpLabel: 'ICP filing', icp: '粤ICP备2025428416号-1A', warningTitle: 'Important Notice', warningBody: 'All divination interpretations are AI-generated for entertainment only. Do not use them as professional advice for business, medical, or legal decisions.', legalTitle: 'Legal' },
|
||||
login: { welcome: 'Welcome', subtitle: 'Sign in or sign up with email verification code', emailLabel: 'Email', emailPlaceholder: 'Enter your email', codeLabel: 'Verification Code', codePlaceholder: 'Enter code', sendCode: 'Send Code', submit: 'Sign In / Sign Up', agreePrefix: 'I have read and agree to the ', privacy: 'Privacy Policy', agreeAnd: ' and ', terms: 'Terms of Service' },
|
||||
dashboard: { brandName: 'MeiYao Divination', navHome: 'Home', navStore: 'Store', navDivination: 'Divination', navManual: 'Manual Cast', navAuto: 'Auto Cast', navHistory: 'History', navLanguage: 'Language', navSettings: 'Settings', greeting: 'Good afternoon', greetingSub: 'What would you like to explore today?', heroTitle: 'Begin Your Hexagram Journey', heroDesc: 'Explore future possibilities with AI. Ask your question, cast your hexagram.', heroCta: 'Start Divination', historyTitle: 'Recent Readings', historyViewAll: 'View All →', logout: 'Sign Out' },
|
||||
notifications: { title: 'Notifications', loading: 'Loading...', error: 'Failed to load', empty: 'No notifications', markAllRead: 'Mark All Read', markAllReadDone: 'All marked as read' },
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
---
|
||||
import '../styles/global.css';
|
||||
import '../styles/animations.css';
|
||||
import { AuthProvider } from '../components/AuthProvider';
|
||||
|
||||
interface Props {
|
||||
locale: import('../i18n/utils').Locale;
|
||||
@@ -20,9 +19,7 @@ const { locale } = Astro.props;
|
||||
</head>
|
||||
<body class="bg-slate-50 text-slate-900 antialiased">
|
||||
<main>
|
||||
<AuthProvider client:load>
|
||||
<slot />
|
||||
</AuthProvider>
|
||||
<slot />
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -139,23 +139,12 @@ 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 getPointsBalance(): Promise<PointsBalance> {
|
||||
return authFetch<PointsBalance>(API_ROUTES.points.balance);
|
||||
}
|
||||
|
||||
export function invalidatePointsCache(): void {
|
||||
pointsCache = null;
|
||||
// Points caching lives in resources.ts. Kept for older imports during rollout.
|
||||
}
|
||||
|
||||
export function getPackages(): Promise<PackagesResponse> {
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
|
||||
import { apiRequest, apiUrl, jsonHeaders, toApiError, ApiError } from './api-client';
|
||||
import { API_ROUTES } from './api-routes';
|
||||
import { clearAll as clearDataCache } from './data-client';
|
||||
|
||||
const STORAGE_KEY = 'meeyao_auth';
|
||||
|
||||
@@ -78,6 +79,7 @@ export function setAuth(data: AuthData): void {
|
||||
|
||||
export function clearAuth(): void {
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
clearDataCache();
|
||||
}
|
||||
|
||||
// --- Token status ---
|
||||
@@ -89,6 +91,8 @@ export function isTokenExpired(): boolean {
|
||||
return auth.expires_at - 60_000 < Date.now();
|
||||
}
|
||||
|
||||
let refreshPromise: Promise<AuthData> | null = null;
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
function toAuthData(response: SessionResponse): AuthData {
|
||||
@@ -140,11 +144,12 @@ export async function loginWithEmail(
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const data = toAuthData(json);
|
||||
clearDataCache();
|
||||
setAuth(data);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function refreshAccessToken(): Promise<AuthData> {
|
||||
async function doRefreshAccessToken(): Promise<AuthData> {
|
||||
const auth = getAuth();
|
||||
if (!auth?.refresh_token) {
|
||||
clearAuth();
|
||||
@@ -165,6 +170,17 @@ export async function refreshAccessToken(): Promise<AuthData> {
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function refreshAccessToken(): Promise<AuthData> {
|
||||
if (refreshPromise) return refreshPromise;
|
||||
|
||||
refreshPromise = doRefreshAccessToken();
|
||||
try {
|
||||
return await refreshPromise;
|
||||
} finally {
|
||||
refreshPromise = null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function logout(): Promise<void> {
|
||||
const auth = getAuth();
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
export type CacheKey = readonly string[];
|
||||
|
||||
interface CacheEntry<T> {
|
||||
data?: T;
|
||||
error?: unknown;
|
||||
updatedAt: number;
|
||||
expiresAt: number;
|
||||
promise?: Promise<T>;
|
||||
}
|
||||
|
||||
export interface QueryOptions<T> {
|
||||
key: CacheKey;
|
||||
ttlMs: number;
|
||||
fetcher: () => Promise<T>;
|
||||
staleWhileRevalidate?: boolean;
|
||||
force?: boolean;
|
||||
}
|
||||
|
||||
type Listener = () => void;
|
||||
|
||||
const cache = new Map<string, CacheEntry<unknown>>();
|
||||
const listeners = new Map<string, Set<Listener>>();
|
||||
|
||||
function keyToString(key: CacheKey): string {
|
||||
return JSON.stringify(key);
|
||||
}
|
||||
|
||||
function isPrefix(key: CacheKey, prefix: CacheKey): boolean {
|
||||
return prefix.every((part, index) => key[index] === part);
|
||||
}
|
||||
|
||||
function notify(serializedKey: string): void {
|
||||
listeners.get(serializedKey)?.forEach((listener) => listener());
|
||||
}
|
||||
|
||||
function notifyPrefix(prefix: CacheKey): void {
|
||||
for (const serializedKey of listeners.keys()) {
|
||||
const parsedKey = JSON.parse(serializedKey) as string[];
|
||||
if (isPrefix(parsedKey, prefix)) notify(serializedKey);
|
||||
}
|
||||
}
|
||||
|
||||
function startFetch<T>(serializedKey: string, ttlMs: number, fetcher: () => Promise<T>): Promise<T> {
|
||||
const now = Date.now();
|
||||
const existing = cache.get(serializedKey) as CacheEntry<T> | undefined;
|
||||
const promise = fetcher()
|
||||
.then((data) => {
|
||||
cache.set(serializedKey, {
|
||||
data,
|
||||
updatedAt: Date.now(),
|
||||
expiresAt: Date.now() + ttlMs,
|
||||
});
|
||||
notify(serializedKey);
|
||||
return data;
|
||||
})
|
||||
.catch((error) => {
|
||||
cache.set(serializedKey, {
|
||||
data: existing?.data,
|
||||
error,
|
||||
updatedAt: existing?.updatedAt ?? now,
|
||||
expiresAt: existing?.data === undefined ? now : existing.expiresAt,
|
||||
});
|
||||
notify(serializedKey);
|
||||
throw error;
|
||||
});
|
||||
|
||||
cache.set(serializedKey, {
|
||||
...existing,
|
||||
updatedAt: existing?.updatedAt ?? now,
|
||||
expiresAt: existing?.expiresAt ?? now,
|
||||
promise,
|
||||
});
|
||||
notify(serializedKey);
|
||||
return promise;
|
||||
}
|
||||
|
||||
export function query<T>({
|
||||
key,
|
||||
ttlMs,
|
||||
fetcher,
|
||||
staleWhileRevalidate = true,
|
||||
force = false,
|
||||
}: QueryOptions<T>): Promise<T> {
|
||||
const serializedKey = keyToString(key);
|
||||
const entry = cache.get(serializedKey) as CacheEntry<T> | undefined;
|
||||
const now = Date.now();
|
||||
|
||||
if (!force && entry?.promise) return entry.promise;
|
||||
if (!force && entry?.data !== undefined && entry.expiresAt > now) return Promise.resolve(entry.data);
|
||||
|
||||
if (!force && staleWhileRevalidate && entry?.data !== undefined) {
|
||||
void startFetch(serializedKey, ttlMs, fetcher).catch((error) => {
|
||||
console.debug('[data-client] Background refresh failed', error);
|
||||
});
|
||||
return Promise.resolve(entry.data);
|
||||
}
|
||||
|
||||
return startFetch(serializedKey, ttlMs, fetcher);
|
||||
}
|
||||
|
||||
export function prefetch<T>(options: QueryOptions<T>): void {
|
||||
void query(options).catch((error) => {
|
||||
console.debug('[data-client] Prefetch failed', error);
|
||||
});
|
||||
}
|
||||
|
||||
export function peek<T>(key: CacheKey): T | undefined {
|
||||
return (cache.get(keyToString(key)) as CacheEntry<T> | undefined)?.data;
|
||||
}
|
||||
|
||||
export function getEntry<T>(key: CacheKey): CacheEntry<T> | undefined {
|
||||
return cache.get(keyToString(key)) as CacheEntry<T> | undefined;
|
||||
}
|
||||
|
||||
export function set<T>(key: CacheKey, data: T, ttlMs: number): void {
|
||||
cache.set(keyToString(key), {
|
||||
data,
|
||||
updatedAt: Date.now(),
|
||||
expiresAt: Date.now() + ttlMs,
|
||||
});
|
||||
notify(keyToString(key));
|
||||
}
|
||||
|
||||
export function invalidate(prefix: CacheKey): void {
|
||||
for (const serializedKey of Array.from(cache.keys())) {
|
||||
const parsedKey = JSON.parse(serializedKey) as string[];
|
||||
if (isPrefix(parsedKey, prefix)) {
|
||||
cache.delete(serializedKey);
|
||||
notify(serializedKey);
|
||||
}
|
||||
}
|
||||
notifyPrefix(prefix);
|
||||
}
|
||||
|
||||
export function clearAll(): void {
|
||||
cache.clear();
|
||||
for (const serializedKey of listeners.keys()) notify(serializedKey);
|
||||
}
|
||||
|
||||
export function subscribe(key: CacheKey, listener: Listener): () => void {
|
||||
const serializedKey = keyToString(key);
|
||||
const keyListeners = listeners.get(serializedKey) ?? new Set<Listener>();
|
||||
keyListeners.add(listener);
|
||||
listeners.set(serializedKey, keyListeners);
|
||||
return () => {
|
||||
keyListeners.delete(listener);
|
||||
if (keyListeners.size === 0) listeners.delete(serializedKey);
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,408 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
getAgentHistory,
|
||||
getAgentHistoryByThread,
|
||||
getNotifications,
|
||||
getPackages,
|
||||
getPointsBalance,
|
||||
getUnreadNotificationCount,
|
||||
getUserProfile,
|
||||
markAllNotificationsRead,
|
||||
markNotificationRead,
|
||||
updateUserProfile,
|
||||
updateUserSettings,
|
||||
uploadAvatar,
|
||||
type HistorySnapshot,
|
||||
type NotificationItem,
|
||||
type NotificationListResponse,
|
||||
type PackageInfo,
|
||||
type PackagesResponse,
|
||||
type PointsBalance,
|
||||
type ProfileSettings,
|
||||
type UnreadCount,
|
||||
type UpdateProfileRequest,
|
||||
type UserProfile,
|
||||
} from './api';
|
||||
import {
|
||||
getEntry,
|
||||
invalidate,
|
||||
peek,
|
||||
prefetch,
|
||||
query,
|
||||
set,
|
||||
subscribe,
|
||||
type QueryOptions,
|
||||
} from './data-client';
|
||||
|
||||
const PROFILE_TTL = 5 * 60_000;
|
||||
const POINTS_TTL = 60_000;
|
||||
const PACKAGES_TTL = 30 * 60_000;
|
||||
const HISTORY_TTL = 60_000;
|
||||
const HISTORY_THREAD_TTL = 5 * 60_000;
|
||||
const NOTIFICATIONS_TTL = 60_000;
|
||||
const UNREAD_TTL = 30_000;
|
||||
|
||||
export const profileKey = ['profile'] as const;
|
||||
export const pointsBalanceKey = ['points', 'balance'] as const;
|
||||
export const packagesKey = ['points', 'packages'] as const;
|
||||
export const historyListKey = ['history', 'list'] as const;
|
||||
export const historySummaryKey = historyListKey;
|
||||
export const historyThreadKey = (threadId: string) => ['history', 'thread', threadId] as const;
|
||||
export const notificationsKey = (locale: string) => ['notifications', 'list', locale] as const;
|
||||
export const unreadCountKey = ['notifications', 'unread-count'] as const;
|
||||
|
||||
interface ResourceState<T> {
|
||||
data: T | undefined;
|
||||
loading: boolean;
|
||||
refreshing: boolean;
|
||||
error: unknown;
|
||||
reload: () => Promise<T>;
|
||||
}
|
||||
|
||||
type ResourceOptions<T> = QueryOptions<T> & {
|
||||
enabled?: boolean;
|
||||
};
|
||||
|
||||
export function useResource<T>(options: ResourceOptions<T>): ResourceState<T> {
|
||||
const optionsRef = useRef(options);
|
||||
optionsRef.current = options;
|
||||
const keyId = useMemo(() => JSON.stringify(options.key), [options.key]);
|
||||
const enabled = options.enabled ?? true;
|
||||
const [data, setDataState] = useState<T | undefined>(() => enabled ? peek<T>(options.key) : undefined);
|
||||
const [loading, setLoading] = useState(() => enabled && peek<T>(options.key) === undefined);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [error, setError] = useState<unknown>(() => enabled ? getEntry<T>(options.key)?.error : undefined);
|
||||
|
||||
const load = useCallback((force = false) => {
|
||||
const currentOptions = optionsRef.current;
|
||||
if (currentOptions.enabled === false) {
|
||||
return Promise.reject(new Error('Resource is disabled'));
|
||||
}
|
||||
const hasCachedData = peek<T>(currentOptions.key) !== undefined;
|
||||
setLoading(!hasCachedData);
|
||||
setRefreshing(hasCachedData);
|
||||
return query({ ...currentOptions, force })
|
||||
.then((next) => {
|
||||
setDataState(next);
|
||||
setError(undefined);
|
||||
return next;
|
||||
})
|
||||
.catch((err) => {
|
||||
setError(err);
|
||||
throw err;
|
||||
})
|
||||
.finally(() => {
|
||||
setLoading(false);
|
||||
setRefreshing(false);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const syncFromCache = useCallback(() => {
|
||||
const entry = getEntry<T>(optionsRef.current.key);
|
||||
if (!entry) {
|
||||
setDataState(undefined);
|
||||
setError(undefined);
|
||||
setRefreshing(false);
|
||||
void load(false).catch(() => undefined);
|
||||
return;
|
||||
}
|
||||
setDataState(entry.data);
|
||||
setError(entry.error);
|
||||
setRefreshing(Boolean(entry.promise && entry.data !== undefined));
|
||||
}, [load]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return undefined;
|
||||
return subscribe(optionsRef.current.key, syncFromCache);
|
||||
}, [enabled, keyId, syncFromCache]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) {
|
||||
setDataState(undefined);
|
||||
setLoading(false);
|
||||
setRefreshing(false);
|
||||
setError(undefined);
|
||||
return;
|
||||
}
|
||||
setDataState(peek<T>(optionsRef.current.key));
|
||||
setError(getEntry<T>(optionsRef.current.key)?.error);
|
||||
void load(false).catch(() => undefined);
|
||||
}, [enabled, keyId, load]);
|
||||
|
||||
return {
|
||||
data,
|
||||
loading,
|
||||
refreshing,
|
||||
error,
|
||||
reload: () => load(true),
|
||||
};
|
||||
}
|
||||
|
||||
function fetchProfileResource(force = false): Promise<UserProfile> {
|
||||
return query({
|
||||
key: profileKey,
|
||||
ttlMs: PROFILE_TTL,
|
||||
fetcher: getUserProfile,
|
||||
staleWhileRevalidate: true,
|
||||
force,
|
||||
});
|
||||
}
|
||||
|
||||
export function getProfileResource(): Promise<UserProfile> {
|
||||
return fetchProfileResource(false);
|
||||
}
|
||||
|
||||
export function useProfile(): ResourceState<UserProfile> {
|
||||
return useResource({
|
||||
key: profileKey,
|
||||
ttlMs: PROFILE_TTL,
|
||||
fetcher: getUserProfile,
|
||||
staleWhileRevalidate: true,
|
||||
});
|
||||
}
|
||||
|
||||
export function setProfileResource(profile: UserProfile): void {
|
||||
set(profileKey, profile, PROFILE_TTL);
|
||||
}
|
||||
|
||||
export async function updateProfileResource(input: UpdateProfileRequest): Promise<UserProfile> {
|
||||
const updated = await updateUserProfile(input);
|
||||
setProfileResource(updated);
|
||||
return updated;
|
||||
}
|
||||
|
||||
export async function uploadAvatarResource(file: File): Promise<UserProfile> {
|
||||
const updated = await uploadAvatar(file);
|
||||
setProfileResource(updated);
|
||||
return updated;
|
||||
}
|
||||
|
||||
export async function updateSettingsResource(settings: ProfileSettings): Promise<UserProfile> {
|
||||
const updated = await updateUserSettings({ settings });
|
||||
setProfileResource(updated);
|
||||
return updated;
|
||||
}
|
||||
|
||||
export function getPointsResource(force = false): Promise<PointsBalance> {
|
||||
return query({
|
||||
key: pointsBalanceKey,
|
||||
ttlMs: POINTS_TTL,
|
||||
fetcher: getPointsBalance,
|
||||
staleWhileRevalidate: true,
|
||||
force,
|
||||
});
|
||||
}
|
||||
|
||||
export function usePoints(): ResourceState<PointsBalance> {
|
||||
return useResource({
|
||||
key: pointsBalanceKey,
|
||||
ttlMs: POINTS_TTL,
|
||||
fetcher: getPointsBalance,
|
||||
staleWhileRevalidate: true,
|
||||
});
|
||||
}
|
||||
|
||||
export function invalidatePoints(): void {
|
||||
invalidate(pointsBalanceKey);
|
||||
}
|
||||
|
||||
export function getPackagesResource(force = false): Promise<PackagesResponse> {
|
||||
return query({
|
||||
key: packagesKey,
|
||||
ttlMs: PACKAGES_TTL,
|
||||
fetcher: getPackages,
|
||||
staleWhileRevalidate: true,
|
||||
force,
|
||||
});
|
||||
}
|
||||
|
||||
export function usePackages(): ResourceState<PackagesResponse> {
|
||||
return useResource({
|
||||
key: packagesKey,
|
||||
ttlMs: PACKAGES_TTL,
|
||||
fetcher: getPackages,
|
||||
staleWhileRevalidate: true,
|
||||
});
|
||||
}
|
||||
|
||||
export function getHistoryListResource(force = false): Promise<HistorySnapshot> {
|
||||
return query({
|
||||
key: historyListKey,
|
||||
ttlMs: HISTORY_TTL,
|
||||
fetcher: getAgentHistory,
|
||||
staleWhileRevalidate: true,
|
||||
force,
|
||||
});
|
||||
}
|
||||
|
||||
export function useHistoryList(): ResourceState<HistorySnapshot> {
|
||||
return useResource({
|
||||
key: historyListKey,
|
||||
ttlMs: HISTORY_TTL,
|
||||
fetcher: getAgentHistory,
|
||||
staleWhileRevalidate: true,
|
||||
});
|
||||
}
|
||||
|
||||
export function getHistorySummaryResource(force = false): Promise<HistorySnapshot> {
|
||||
return query({
|
||||
key: historySummaryKey,
|
||||
ttlMs: HISTORY_TTL,
|
||||
fetcher: getAgentHistory,
|
||||
staleWhileRevalidate: true,
|
||||
force,
|
||||
});
|
||||
}
|
||||
|
||||
export function useHistorySummary(): ResourceState<HistorySnapshot> {
|
||||
return useResource({
|
||||
key: historySummaryKey,
|
||||
ttlMs: HISTORY_TTL,
|
||||
fetcher: getAgentHistory,
|
||||
staleWhileRevalidate: true,
|
||||
});
|
||||
}
|
||||
|
||||
export function getHistoryThreadResource(threadId: string, force = false): Promise<HistorySnapshot> {
|
||||
return query({
|
||||
key: historyThreadKey(threadId),
|
||||
ttlMs: HISTORY_THREAD_TTL,
|
||||
fetcher: () => getAgentHistoryByThread(threadId),
|
||||
staleWhileRevalidate: true,
|
||||
force,
|
||||
});
|
||||
}
|
||||
|
||||
export function primeHistoryThreadFromSnapshot(threadId: string, snapshot: HistorySnapshot): void {
|
||||
const messages = snapshot.messages.filter((message) => message.threadId === threadId);
|
||||
if (messages.length === 0) return;
|
||||
set(historyThreadKey(threadId), {
|
||||
scope: 'thread',
|
||||
threadId,
|
||||
day: snapshot.day,
|
||||
hasMore: false,
|
||||
messages,
|
||||
}, HISTORY_THREAD_TTL);
|
||||
}
|
||||
|
||||
export function useHistoryThread(threadId?: string): ResourceState<HistorySnapshot> {
|
||||
return useResource({
|
||||
key: threadId ? historyThreadKey(threadId) : ['history', 'thread', 'missing'],
|
||||
ttlMs: HISTORY_THREAD_TTL,
|
||||
fetcher: () => {
|
||||
if (!threadId) return Promise.reject(new Error('Missing history thread id'));
|
||||
return getAgentHistoryByThread(threadId);
|
||||
},
|
||||
staleWhileRevalidate: true,
|
||||
enabled: Boolean(threadId),
|
||||
});
|
||||
}
|
||||
|
||||
export function invalidateHistory(threadId?: string): void {
|
||||
invalidate(historySummaryKey);
|
||||
invalidate(historyListKey);
|
||||
if (threadId) invalidate(historyThreadKey(threadId));
|
||||
}
|
||||
|
||||
export function getNotificationsResource(locale: string, force = false): Promise<NotificationListResponse> {
|
||||
return query({
|
||||
key: notificationsKey(locale),
|
||||
ttlMs: NOTIFICATIONS_TTL,
|
||||
fetcher: () => getNotifications(locale),
|
||||
staleWhileRevalidate: true,
|
||||
force,
|
||||
});
|
||||
}
|
||||
|
||||
export function useNotifications(locale: string): ResourceState<NotificationListResponse> {
|
||||
return useResource({
|
||||
key: notificationsKey(locale),
|
||||
ttlMs: NOTIFICATIONS_TTL,
|
||||
fetcher: () => getNotifications(locale),
|
||||
staleWhileRevalidate: true,
|
||||
});
|
||||
}
|
||||
|
||||
export function getUnreadCountResource(force = false): Promise<UnreadCount> {
|
||||
return query({
|
||||
key: unreadCountKey,
|
||||
ttlMs: UNREAD_TTL,
|
||||
fetcher: getUnreadNotificationCount,
|
||||
staleWhileRevalidate: true,
|
||||
force,
|
||||
});
|
||||
}
|
||||
|
||||
export function useUnreadCount(): ResourceState<UnreadCount> {
|
||||
return useResource({
|
||||
key: unreadCountKey,
|
||||
ttlMs: UNREAD_TTL,
|
||||
fetcher: getUnreadNotificationCount,
|
||||
staleWhileRevalidate: true,
|
||||
});
|
||||
}
|
||||
|
||||
export async function markNotificationReadResource(id: string, locale: string): Promise<NotificationItem> {
|
||||
const updated = await markNotificationRead(id, locale);
|
||||
const listKey = notificationsKey(locale);
|
||||
const list = peek<NotificationListResponse>(listKey);
|
||||
if (list) {
|
||||
const previous = list.items.find((item) => item.id === id);
|
||||
set(listKey, {
|
||||
...list,
|
||||
items: list.items.map((item) => (item.id === id ? updated : item)),
|
||||
}, NOTIFICATIONS_TTL);
|
||||
if (previous && !previous.isRead && updated.isRead) {
|
||||
const unread = peek<UnreadCount>(unreadCountKey);
|
||||
if (unread) set(unreadCountKey, { count: Math.max(0, unread.count - 1) }, UNREAD_TTL);
|
||||
}
|
||||
} else {
|
||||
invalidate(unreadCountKey);
|
||||
}
|
||||
return updated;
|
||||
}
|
||||
|
||||
export async function markAllNotificationsReadResource(locale: string): Promise<{ updatedCount: number }> {
|
||||
const result = await markAllNotificationsRead();
|
||||
const listKey = notificationsKey(locale);
|
||||
const list = peek<NotificationListResponse>(listKey);
|
||||
if (list) {
|
||||
const readAt = new Date().toISOString();
|
||||
set(listKey, {
|
||||
...list,
|
||||
items: list.items.map((item) => ({ ...item, isRead: true, readAt: item.readAt ?? readAt })),
|
||||
}, NOTIFICATIONS_TTL);
|
||||
}
|
||||
set(unreadCountKey, { count: 0 }, UNREAD_TTL);
|
||||
return result;
|
||||
}
|
||||
|
||||
export function prefetchAppBasics(): void {
|
||||
prefetch({ key: pointsBalanceKey, ttlMs: POINTS_TTL, fetcher: getPointsBalance, staleWhileRevalidate: true });
|
||||
prefetch({ key: unreadCountKey, ttlMs: UNREAD_TTL, fetcher: getUnreadNotificationCount, staleWhileRevalidate: true });
|
||||
prefetch({ key: historySummaryKey, ttlMs: HISTORY_TTL, fetcher: getAgentHistory, staleWhileRevalidate: true });
|
||||
}
|
||||
|
||||
export function prefetchForPath(pathname: string, locale: string): void {
|
||||
if (pathname.includes('/store')) {
|
||||
prefetch({ key: packagesKey, ttlMs: PACKAGES_TTL, fetcher: getPackages, staleWhileRevalidate: true });
|
||||
prefetch({ key: pointsBalanceKey, ttlMs: POINTS_TTL, fetcher: getPointsBalance, staleWhileRevalidate: true });
|
||||
} else if (pathname.includes('/history')) {
|
||||
prefetch({ key: historyListKey, ttlMs: HISTORY_TTL, fetcher: getAgentHistory, staleWhileRevalidate: true });
|
||||
} else if (pathname.includes('/notifications')) {
|
||||
prefetch({ key: notificationsKey(locale), ttlMs: NOTIFICATIONS_TTL, fetcher: () => getNotifications(locale), staleWhileRevalidate: true });
|
||||
prefetch({ key: unreadCountKey, ttlMs: UNREAD_TTL, fetcher: getUnreadNotificationCount, staleWhileRevalidate: true });
|
||||
} else if (pathname.includes('/settings') || pathname.includes('/profile')) {
|
||||
prefetch({ key: profileKey, ttlMs: PROFILE_TTL, fetcher: getUserProfile, staleWhileRevalidate: true });
|
||||
} else if (pathname.includes('/divination')) {
|
||||
prefetch({ key: pointsBalanceKey, ttlMs: POINTS_TTL, fetcher: getPointsBalance, staleWhileRevalidate: true });
|
||||
}
|
||||
}
|
||||
|
||||
export function getCachedProfile(): UserProfile | undefined {
|
||||
return peek<UserProfile>(profileKey);
|
||||
}
|
||||
|
||||
export function getCachedPackages(): PackageInfo[] | undefined {
|
||||
return peek<PackagesResponse>(packagesKey)?.packages;
|
||||
}
|
||||
@@ -4,7 +4,6 @@ export const prerender = false;
|
||||
import DashboardAppPage from '../../../components/DashboardAppPage.astro';
|
||||
|
||||
const locale = 'en' as const;
|
||||
const { id } = Astro.params;
|
||||
---
|
||||
|
||||
<DashboardAppPage locale={locale} />
|
||||
|
||||
@@ -4,7 +4,6 @@ export const prerender = false;
|
||||
import DashboardAppPage from '../../../../components/DashboardAppPage.astro';
|
||||
|
||||
const locale = 'en' as const;
|
||||
const { id } = Astro.params;
|
||||
---
|
||||
|
||||
<DashboardAppPage locale={locale} />
|
||||
|
||||
@@ -1,23 +1,7 @@
|
||||
---
|
||||
import App from '../../../layouts/App.astro';
|
||||
import DashboardApp from '../../../components/DashboardApp';
|
||||
import { t } from '../../../i18n/utils';
|
||||
import DashboardAppPage from '../../../components/DashboardAppPage.astro';
|
||||
|
||||
const locale = 'en' as const;
|
||||
const translations = {
|
||||
dashboard: t(locale, 'dashboard'),
|
||||
store: t(locale, 'store'),
|
||||
pricing: t(locale, 'pricing'),
|
||||
history: t(locale, 'history'),
|
||||
notifications: t(locale, 'notifications'),
|
||||
profile: t(locale, 'profile'),
|
||||
settings: t(locale, 'settings'),
|
||||
divination: t(locale, 'divination'),
|
||||
general: t(locale, 'general'),
|
||||
feedback: t(locale, 'feedback'),
|
||||
};
|
||||
---
|
||||
|
||||
<App locale={locale}>
|
||||
<DashboardApp client:only="react" locale={locale} translations={translations} />
|
||||
</App>
|
||||
<DashboardAppPage locale={locale} />
|
||||
|
||||
@@ -1,23 +1,7 @@
|
||||
---
|
||||
import App from '../../../layouts/App.astro';
|
||||
import DashboardApp from '../../../components/DashboardApp';
|
||||
import { t } from '../../../i18n/utils';
|
||||
import DashboardAppPage from '../../../components/DashboardAppPage.astro';
|
||||
|
||||
const locale = 'en' as const;
|
||||
const translations = {
|
||||
dashboard: t(locale, 'dashboard'),
|
||||
store: t(locale, 'store'),
|
||||
pricing: t(locale, 'pricing'),
|
||||
history: t(locale, 'history'),
|
||||
notifications: t(locale, 'notifications'),
|
||||
profile: t(locale, 'profile'),
|
||||
settings: t(locale, 'settings'),
|
||||
divination: t(locale, 'divination'),
|
||||
general: t(locale, 'general'),
|
||||
feedback: t(locale, 'feedback'),
|
||||
};
|
||||
---
|
||||
|
||||
<App locale={locale}>
|
||||
<DashboardApp client:only="react" locale={locale} translations={translations} />
|
||||
</App>
|
||||
<DashboardAppPage locale={locale} />
|
||||
|
||||
@@ -4,7 +4,6 @@ export const prerender = false;
|
||||
import DashboardAppPage from '../../../components/DashboardAppPage.astro';
|
||||
|
||||
const locale = 'zh' as const;
|
||||
const { id } = Astro.params;
|
||||
---
|
||||
|
||||
<DashboardAppPage locale={locale} />
|
||||
|
||||
@@ -4,7 +4,6 @@ export const prerender = false;
|
||||
import DashboardAppPage from '../../../../components/DashboardAppPage.astro';
|
||||
|
||||
const locale = 'zh' as const;
|
||||
const { id } = Astro.params;
|
||||
---
|
||||
|
||||
<DashboardAppPage locale={locale} />
|
||||
|
||||
@@ -1,23 +1,7 @@
|
||||
---
|
||||
import App from '../../../layouts/App.astro';
|
||||
import DashboardApp from '../../../components/DashboardApp';
|
||||
import { t } from '../../../i18n/utils';
|
||||
import DashboardAppPage from '../../../components/DashboardAppPage.astro';
|
||||
|
||||
const locale = 'zh' as const;
|
||||
const translations = {
|
||||
dashboard: t(locale, 'dashboard'),
|
||||
store: t(locale, 'store'),
|
||||
pricing: t(locale, 'pricing'),
|
||||
history: t(locale, 'history'),
|
||||
notifications: t(locale, 'notifications'),
|
||||
profile: t(locale, 'profile'),
|
||||
settings: t(locale, 'settings'),
|
||||
divination: t(locale, 'divination'),
|
||||
general: t(locale, 'general'),
|
||||
feedback: t(locale, 'feedback'),
|
||||
};
|
||||
---
|
||||
|
||||
<App locale={locale}>
|
||||
<DashboardApp client:only="react" locale={locale} translations={translations} />
|
||||
</App>
|
||||
<DashboardAppPage locale={locale} />
|
||||
|
||||
@@ -1,23 +1,7 @@
|
||||
---
|
||||
import App from '../../../layouts/App.astro';
|
||||
import DashboardApp from '../../../components/DashboardApp';
|
||||
import { t } from '../../../i18n/utils';
|
||||
import DashboardAppPage from '../../../components/DashboardAppPage.astro';
|
||||
|
||||
const locale = 'zh' as const;
|
||||
const translations = {
|
||||
dashboard: t(locale, 'dashboard'),
|
||||
store: t(locale, 'store'),
|
||||
pricing: t(locale, 'pricing'),
|
||||
history: t(locale, 'history'),
|
||||
notifications: t(locale, 'notifications'),
|
||||
profile: t(locale, 'profile'),
|
||||
settings: t(locale, 'settings'),
|
||||
divination: t(locale, 'divination'),
|
||||
general: t(locale, 'general'),
|
||||
feedback: t(locale, 'feedback'),
|
||||
};
|
||||
---
|
||||
|
||||
<App locale={locale}>
|
||||
<DashboardApp client:only="react" locale={locale} translations={translations} />
|
||||
</App>
|
||||
<DashboardAppPage locale={locale} />
|
||||
|
||||
@@ -4,7 +4,6 @@ export const prerender = false;
|
||||
import DashboardAppPage from '../../../components/DashboardAppPage.astro';
|
||||
|
||||
const locale = 'zh_Hant' as const;
|
||||
const { id } = Astro.params;
|
||||
---
|
||||
|
||||
<DashboardAppPage locale={locale} />
|
||||
|
||||
@@ -4,7 +4,6 @@ export const prerender = false;
|
||||
import DashboardAppPage from '../../../../components/DashboardAppPage.astro';
|
||||
|
||||
const locale = 'zh_Hant' as const;
|
||||
const { id } = Astro.params;
|
||||
---
|
||||
|
||||
<DashboardAppPage locale={locale} />
|
||||
|
||||
@@ -1,23 +1,7 @@
|
||||
---
|
||||
import App from '../../../layouts/App.astro';
|
||||
import DashboardApp from '../../../components/DashboardApp';
|
||||
import { t } from '../../../i18n/utils';
|
||||
import DashboardAppPage from '../../../components/DashboardAppPage.astro';
|
||||
|
||||
const locale = 'zh_Hant' as const;
|
||||
const translations = {
|
||||
dashboard: t(locale, 'dashboard'),
|
||||
store: t(locale, 'store'),
|
||||
pricing: t(locale, 'pricing'),
|
||||
history: t(locale, 'history'),
|
||||
notifications: t(locale, 'notifications'),
|
||||
profile: t(locale, 'profile'),
|
||||
settings: t(locale, 'settings'),
|
||||
divination: t(locale, 'divination'),
|
||||
general: t(locale, 'general'),
|
||||
feedback: t(locale, 'feedback'),
|
||||
};
|
||||
---
|
||||
|
||||
<App locale={locale}>
|
||||
<DashboardApp client:only="react" locale={locale} translations={translations} />
|
||||
</App>
|
||||
<DashboardAppPage locale={locale} />
|
||||
|
||||
@@ -1,23 +1,7 @@
|
||||
---
|
||||
import App from '../../../layouts/App.astro';
|
||||
import DashboardApp from '../../../components/DashboardApp';
|
||||
import { t } from '../../../i18n/utils';
|
||||
import DashboardAppPage from '../../../components/DashboardAppPage.astro';
|
||||
|
||||
const locale = 'zh_Hant' as const;
|
||||
const translations = {
|
||||
dashboard: t(locale, 'dashboard'),
|
||||
store: t(locale, 'store'),
|
||||
pricing: t(locale, 'pricing'),
|
||||
history: t(locale, 'history'),
|
||||
notifications: t(locale, 'notifications'),
|
||||
profile: t(locale, 'profile'),
|
||||
settings: t(locale, 'settings'),
|
||||
divination: t(locale, 'divination'),
|
||||
general: t(locale, 'general'),
|
||||
feedback: t(locale, 'feedback'),
|
||||
};
|
||||
---
|
||||
|
||||
<App locale={locale}>
|
||||
<DashboardApp client:only="react" locale={locale} translations={translations} />
|
||||
</App>
|
||||
<DashboardAppPage locale={locale} />
|
||||
|
||||