diff --git a/docs/backend-wallet-favorites-production-fixes.md b/docs/backend-wallet-favorites-production-fixes.md index 990330b..55fb98b 100644 --- a/docs/backend-wallet-favorites-production-fixes.md +++ b/docs/backend-wallet-favorites-production-fixes.md @@ -187,7 +187,13 @@ If backend decides to keep the simplified `{ address }` login, please explicitly ## Priority 2 — Normalize favorites response contract -Frontend currently supports the staging response shape, but the response should be made explicit. +Frontend currently supports the staging response shape, but the response must be made explicit and self-sufficient. The frontend renders favorites as plain strings and does not perform per-resource translation, slug-to-name lookup, category fetching, or localization fallback. + +### `lang` semantics + +`?lang=` on `GET /api/favorites` is a **display resolution hint**, not a filter. It must NOT filter favorites by post language. A user who favorited Chinese and English posts must see both regardless of `lang`. `lang` only tells the backend which language to resolve display strings into. + +**Current staging behavior is wrong**: sending `?lang=en` on staging returns zero items for users whose favorites are Chinese posts, and vice versa. Because of this, the frontend currently does NOT send `lang` on `GET /api/favorites`. Once the backend treats `lang` as a resolve hint instead of a filter, the frontend will send `lang` again so resolved strings come back in the user's UI language. ### Favorites list @@ -196,36 +202,50 @@ GET /api/favorites?lang=&limit=&page=&sort=&category=&q= Authorization: Bearer ``` -Current staging response observed: +Required production response: ```json { "items": [ { "id": "...", - "postType": "image", + "title": "...", + "description": "...", + "type": "...", "categoryId": 11, "categorySlug": "official-assets", - "language": "zh", - "title": "..." + "categoryName": "...", + "language": "...", + "sourceLanguage": "...", + "coverImage": "...", + "updatedAt": "...", + "publishedAt": "...", + "favoriteCount": 0, + "availability": "available" } - ] -} -``` - -Recommended production response: - -```json -{ - "items": [], + ], "page": 1, "limit": 24, "total": 0 } ``` +Fields that must be present and pre-resolved by the backend when `lang` is supplied: + +- `title` — already in `lang`. If a translation does not exist, fall back to the post's source language. +- `description` — same rule as `title`. +- `categoryName` — localized category name for `lang`. Frontend must not look up categories by slug. +- `type` — a string the frontend can display directly. If you need both a raw type code and a label, add `typeLabel` and use that for display. +- `language` — a human-readable label for the post's source language, in `lang`. e.g. for `lang=zh-CN` a Chinese post returns `language: "中文"`. If you prefer to keep `language` as a code, add `languageLabel` and use it for display. +- `coverImage` — a usable image URL. The frontend will not fall back to attachment arrays. +- `updatedAt`, `publishedAt` — ISO timestamps. +- `favoriteCount` — optional but recommended. +- `availability` — `"available" | "unavailable"`. + `page`, `limit`, and `total` are needed for correct pagination. +The frontend must never need to: load `/api/categories`, parse `localizations` maps, walk `attachments`, or translate `type` / `language` codes for this page. + ### Favorite status by ids ```http diff --git a/src/favorites/api.ts b/src/favorites/api.ts index 6f29b00..30b1f72 100644 --- a/src/favorites/api.ts +++ b/src/favorites/api.ts @@ -1,9 +1,10 @@ -import { apiBase, itemsOrEmpty, type Resource } from "../api"; +import { apiBase, itemsOrEmpty } from "../api"; +import type { Post } from "../types/post"; export type FavoriteSort = "favorited_at" | "published_at" | "hot"; export type FavoriteListResponse = { - items: Resource[]; + items: Post[]; page?: number; limit?: number; total?: number; diff --git a/src/pages/Favorites/index.tsx b/src/pages/Favorites/index.tsx index 80f40af..9474251 100644 --- a/src/pages/Favorites/index.tsx +++ b/src/pages/Favorites/index.tsx @@ -18,20 +18,21 @@ import { useSetPageTitle } from "../../components/PageTitleContext"; import { Skeleton } from "../../components/Skeleton"; import { useWallet } from "../../wallet/WalletProvider"; import { useLocalizedPath } from "../../useLocalizedPath"; -import { cleanCategoryDisplayName } from "../../utils/categoryDisplay"; +import { postToResource } from "../../utils/postResourceAdapter"; import { formatDateYmd } from "../../utils/format"; -import { resourceTypeLabel } from "../../resourceTypeLabels"; +import { + resourceLanguageLabel, + resourceTypeLabel, +} from "../../resourceTypeLabels"; const pageSize = 50; -function useCategoryNameBySlug(lang: Lang): Map { +function useCategories(lang: Lang): Category[] { const [categories, setCategories] = useState([]); - useEffect(() => { const url = `/api/categories?lang=${encodeURIComponent(langQuery(lang))}`; const cached = readJSONCache(url); if (cached) setCategories(itemsOrEmpty(cached)); - let cancelled = false; getJSON(url) .then((items) => { @@ -40,99 +41,18 @@ function useCategoryNameBySlug(lang: Lang): Map { .catch(() => { if (!cancelled && !cached) setCategories([]); }); - return () => { cancelled = true; }; }, [lang]); - - return useMemo(() => { - const map = new Map(); - categories.forEach((category) => map.set(category.slug, category.name)); - return map; - }, [categories]); + return categories; } -type FavoriteAttachment = { - thumbnailUrl?: string; - thumbUrl?: string; - posterUrl?: string; - url?: string; -}; - -type FavoriteLocalization = { - title?: string; - text?: string; - description?: string; -}; - -type FavoriteResource = Resource & { - postType?: string; - sourceLanguage?: string; - createdAt?: string; - attachments?: FavoriteAttachment[]; - localizations?: Record; -}; - -function localizationKeys(lang: Lang): string[] { - if (lang === "zh-CN") return ["zh", "zh-CN", "zh-Hans"]; - return [lang]; -} - -function localizedResourceText( - resource: FavoriteResource, - lang: Lang, - field: "title" | "description", -): string { - for (const key of localizationKeys(lang)) { - const localized = resource.localizations?.[key]; - if (!localized) continue; - if (field === "title" && localized.title?.trim()) return localized.title; - if (field === "description") { - const text = localized.description || localized.text; - if (text?.trim()) return text; - } - } - if (field === "title") return resource.title; - return resource.description || resource.bodyText || ""; -} - -function firstAttachmentUrl(resource: FavoriteResource): string { - const attachment = resource.attachments?.[0]; - return ( - attachment?.thumbnailUrl || - attachment?.thumbUrl || - attachment?.posterUrl || - attachment?.url || - "" - ); -} - -function FavoriteResourceCard({ - categoryNameBySlug, - resource, -}: { - categoryNameBySlug: Map; - resource: FavoriteResource; -}) { - const { lang, t } = useI18n(); +function FavoriteResourceCard({ resource }: { resource: Resource }) { + const { t } = useI18n(); const lp = useLocalizedPath(); const unavailable = resource.availability === "unavailable"; - const cover = - resource.coverImage || resource.previewUrl || firstAttachmentUrl(resource); - const categoryLabel = - (resource.categorySlug && categoryNameBySlug.get(resource.categorySlug)) || - resource.categoryName || - resource.categorySlug || - "ARK"; - const typeLabel = resourceTypeLabel( - t, - resource.type || resource.postType || "resource", - ); - const date = - resource.updatedAt || resource.publishedAt || resource.createdAt || ""; - const title = localizedResourceText(resource, lang, "title"); - const description = localizedResourceText(resource, lang, "description"); + const cover = resource.coverImage || resource.previewUrl; return (
) : null} @@ -172,22 +92,34 @@ function FavoriteResourceCard({

- {title} + {resource.title}

- {description ? ( + {resource.description ? (

- {description} + {resource.description}

) : null}
- - {cleanCategoryDisplayName(categoryLabel)} - - {typeLabel} - {date ? ( + {resource.categoryName ? ( + + {resource.categoryName} + + ) : null} + {resource.type ? ( + {resourceTypeLabel(t, resource.type)} + ) : null} + {resource.language ? ( <> · - + {resourceLanguageLabel(t, resource.language)} + + ) : null} + {resource.updatedAt ? ( + <> + · + ) : null} {typeof resource.favoriteCount === "number" ? ( @@ -208,8 +140,10 @@ export default function Favorites() { const { lang, t } = useI18n(); const wallet = useWallet(); const { markFavorite } = useFavorites(); - const categoryNameBySlug = useCategoryNameBySlug(lang); - const [items, setItems] = useState([]); + const categories = useCategories(lang); + const [posts, setPosts] = useState< + Awaited>["items"] + >([]); const [loading, setLoading] = useState(false); const [loaded, setLoaded] = useState(false); const [error, setError] = useState(""); @@ -219,7 +153,7 @@ export default function Favorites() { useEffect(() => { if (!wallet.token || wallet.status !== "loggedIn") { - setItems([]); + setPosts([]); setLoading(false); setLoaded(false); setError(""); @@ -237,9 +171,9 @@ export default function Favorites() { }) .then((data) => { if (cancelled) return; - const resources = itemsOrEmpty(data.items) as FavoriteResource[]; - setItems(resources); - resources.forEach((resource) => markFavorite(resource.id, true)); + const items = itemsOrEmpty(data.items); + setPosts(items); + items.forEach((post) => markFavorite(post.id, true)); setLoaded(true); }) .catch((err) => { @@ -261,6 +195,11 @@ export default function Favorites() { }; }, [markFavorite, reloadKey, t, wallet]); + const resources = useMemo( + () => posts.map((post) => postToResource(post, lang, categories)), + [posts, lang, categories], + ); + if (wallet.status === "loading") { return ( @@ -312,7 +251,7 @@ export default function Favorites() { {t("walletRetry")}
- ) : items.length === 0 ? ( + ) : resources.length === 0 ? (

@@ -323,12 +262,8 @@ export default function Favorites() {

) : ( - items.map((resource) => ( - + resources.map((resource) => ( + )) )}
diff --git a/src/utils/postResourceAdapter.ts b/src/utils/postResourceAdapter.ts index de2466e..5321013 100644 --- a/src/utils/postResourceAdapter.ts +++ b/src/utils/postResourceAdapter.ts @@ -46,7 +46,7 @@ export function postToResource( title, description: postDisplayText(post, lang), type: inferType(post, first), - language: post.language, + language: post.sourceLanguage || post.language, categoryId: post.categoryId, categorySlug: post.categorySlug, categoryName: category?.name || post.categorySlug,