fix: align favorites page with post adapter
Some checks failed
Deploy Staging (terry-wallet-login) / deploy (push) Failing after 28s
Some checks failed
Deploy Staging (terry-wallet-login) / deploy (push) Failing after 28s
This commit is contained in:
@@ -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=<ui-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 <token>
|
||||
```
|
||||
|
||||
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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<string, string> {
|
||||
function useCategories(lang: Lang): Category[] {
|
||||
const [categories, setCategories] = useState<Category[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
const url = `/api/categories?lang=${encodeURIComponent(langQuery(lang))}`;
|
||||
const cached = readJSONCache<Category[]>(url);
|
||||
if (cached) setCategories(itemsOrEmpty(cached));
|
||||
|
||||
let cancelled = false;
|
||||
getJSON<Category[]>(url)
|
||||
.then((items) => {
|
||||
@@ -40,99 +41,18 @@ function useCategoryNameBySlug(lang: Lang): Map<string, string> {
|
||||
.catch(() => {
|
||||
if (!cancelled && !cached) setCategories([]);
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [lang]);
|
||||
|
||||
return useMemo(() => {
|
||||
const map = new Map<string, string>();
|
||||
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<string, FavoriteLocalization>;
|
||||
};
|
||||
|
||||
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<string, string>;
|
||||
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 (
|
||||
<article
|
||||
@@ -145,7 +65,7 @@ function FavoriteResourceCard({
|
||||
{!unavailable ? (
|
||||
<Link
|
||||
to={lp(`/resource/${resource.id}`)}
|
||||
aria-label={title}
|
||||
aria-label={resource.title}
|
||||
className="absolute inset-0 z-0 rounded-2xl outline-none focus-visible:ring-2 focus-visible:ring-ark-gold/80"
|
||||
/>
|
||||
) : null}
|
||||
@@ -172,22 +92,34 @@ function FavoriteResourceCard({
|
||||
|
||||
<div className="pointer-events-none relative z-10 flex min-w-0 flex-1 flex-col gap-2 pr-11">
|
||||
<h2 className="line-clamp-2 text-base font-bold leading-snug text-white md:text-lg">
|
||||
{title}
|
||||
{resource.title}
|
||||
</h2>
|
||||
{description ? (
|
||||
{resource.description ? (
|
||||
<p className="line-clamp-2 text-sm leading-6 text-neutral-400">
|
||||
{description}
|
||||
{resource.description}
|
||||
</p>
|
||||
) : null}
|
||||
<div className="mt-auto flex flex-wrap items-center gap-2 text-xs text-neutral-400">
|
||||
<span className="rounded-full bg-[#1f2028] px-2.5 py-1 text-neutral-200">
|
||||
{cleanCategoryDisplayName(categoryLabel)}
|
||||
</span>
|
||||
<span>{typeLabel}</span>
|
||||
{date ? (
|
||||
{resource.categoryName ? (
|
||||
<span className="rounded-full bg-[#1f2028] px-2.5 py-1 text-neutral-200">
|
||||
{resource.categoryName}
|
||||
</span>
|
||||
) : null}
|
||||
{resource.type ? (
|
||||
<span>{resourceTypeLabel(t, resource.type)}</span>
|
||||
) : null}
|
||||
{resource.language ? (
|
||||
<>
|
||||
<span>·</span>
|
||||
<time dateTime={date}>{formatDateYmd(date)}</time>
|
||||
<span>{resourceLanguageLabel(t, resource.language)}</span>
|
||||
</>
|
||||
) : null}
|
||||
{resource.updatedAt ? (
|
||||
<>
|
||||
<span>·</span>
|
||||
<time dateTime={resource.updatedAt}>
|
||||
{formatDateYmd(resource.updatedAt)}
|
||||
</time>
|
||||
</>
|
||||
) : 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<FavoriteResource[]>([]);
|
||||
const categories = useCategories(lang);
|
||||
const [posts, setPosts] = useState<
|
||||
Awaited<ReturnType<typeof listFavorites>>["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 (
|
||||
<Reveal className="mx-auto grid max-w-[980px] gap-3 px-0 py-2 md:py-4">
|
||||
@@ -312,7 +251,7 @@ export default function Favorites() {
|
||||
{t("walletRetry")}
|
||||
</button>
|
||||
</div>
|
||||
) : items.length === 0 ? (
|
||||
) : resources.length === 0 ? (
|
||||
<div className="flex min-h-[280px] flex-col items-center justify-center gap-4 rounded-3xl border border-white/10 bg-[#17171d] p-8 text-center">
|
||||
<Heart className="h-10 w-10 text-ark-gold/60" strokeWidth={1.8} />
|
||||
<h2 className="text-xl font-semibold text-white">
|
||||
@@ -323,12 +262,8 @@ export default function Favorites() {
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
items.map((resource) => (
|
||||
<FavoriteResourceCard
|
||||
key={resource.id}
|
||||
categoryNameBySlug={categoryNameBySlug}
|
||||
resource={resource}
|
||||
/>
|
||||
resources.map((resource) => (
|
||||
<FavoriteResourceCard key={resource.id} resource={resource} />
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user