terry-wallet-login #15
@@ -187,7 +187,13 @@ If backend decides to keep the simplified `{ address }` login, please explicitly
|
|||||||
|
|
||||||
## Priority 2 — Normalize favorites response contract
|
## 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
|
### Favorites list
|
||||||
|
|
||||||
@@ -196,36 +202,50 @@ GET /api/favorites?lang=&limit=&page=&sort=&category=&q=
|
|||||||
Authorization: Bearer <token>
|
Authorization: Bearer <token>
|
||||||
```
|
```
|
||||||
|
|
||||||
Current staging response observed:
|
Required production response:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"items": [
|
"items": [
|
||||||
{
|
{
|
||||||
"id": "...",
|
"id": "...",
|
||||||
"postType": "image",
|
"title": "...",
|
||||||
|
"description": "...",
|
||||||
|
"type": "...",
|
||||||
"categoryId": 11,
|
"categoryId": 11,
|
||||||
"categorySlug": "official-assets",
|
"categorySlug": "official-assets",
|
||||||
"language": "zh",
|
"categoryName": "...",
|
||||||
"title": "..."
|
"language": "...",
|
||||||
|
"sourceLanguage": "...",
|
||||||
|
"coverImage": "...",
|
||||||
|
"updatedAt": "...",
|
||||||
|
"publishedAt": "...",
|
||||||
|
"favoriteCount": 0,
|
||||||
|
"availability": "available"
|
||||||
}
|
}
|
||||||
]
|
],
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Recommended production response:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"items": [],
|
|
||||||
"page": 1,
|
"page": 1,
|
||||||
"limit": 24,
|
"limit": 24,
|
||||||
"total": 0
|
"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.
|
`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
|
### Favorite status by ids
|
||||||
|
|
||||||
```http
|
```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 FavoriteSort = "favorited_at" | "published_at" | "hot";
|
||||||
|
|
||||||
export type FavoriteListResponse = {
|
export type FavoriteListResponse = {
|
||||||
items: Resource[];
|
items: Post[];
|
||||||
page?: number;
|
page?: number;
|
||||||
limit?: number;
|
limit?: number;
|
||||||
total?: number;
|
total?: number;
|
||||||
|
|||||||
@@ -18,20 +18,21 @@ import { useSetPageTitle } from "../../components/PageTitleContext";
|
|||||||
import { Skeleton } from "../../components/Skeleton";
|
import { Skeleton } from "../../components/Skeleton";
|
||||||
import { useWallet } from "../../wallet/WalletProvider";
|
import { useWallet } from "../../wallet/WalletProvider";
|
||||||
import { useLocalizedPath } from "../../useLocalizedPath";
|
import { useLocalizedPath } from "../../useLocalizedPath";
|
||||||
import { cleanCategoryDisplayName } from "../../utils/categoryDisplay";
|
import { postToResource } from "../../utils/postResourceAdapter";
|
||||||
import { formatDateYmd } from "../../utils/format";
|
import { formatDateYmd } from "../../utils/format";
|
||||||
import { resourceTypeLabel } from "../../resourceTypeLabels";
|
import {
|
||||||
|
resourceLanguageLabel,
|
||||||
|
resourceTypeLabel,
|
||||||
|
} from "../../resourceTypeLabels";
|
||||||
|
|
||||||
const pageSize = 50;
|
const pageSize = 50;
|
||||||
|
|
||||||
function useCategoryNameBySlug(lang: Lang): Map<string, string> {
|
function useCategories(lang: Lang): Category[] {
|
||||||
const [categories, setCategories] = useState<Category[]>([]);
|
const [categories, setCategories] = useState<Category[]>([]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const url = `/api/categories?lang=${encodeURIComponent(langQuery(lang))}`;
|
const url = `/api/categories?lang=${encodeURIComponent(langQuery(lang))}`;
|
||||||
const cached = readJSONCache<Category[]>(url);
|
const cached = readJSONCache<Category[]>(url);
|
||||||
if (cached) setCategories(itemsOrEmpty(cached));
|
if (cached) setCategories(itemsOrEmpty(cached));
|
||||||
|
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
getJSON<Category[]>(url)
|
getJSON<Category[]>(url)
|
||||||
.then((items) => {
|
.then((items) => {
|
||||||
@@ -40,99 +41,18 @@ function useCategoryNameBySlug(lang: Lang): Map<string, string> {
|
|||||||
.catch(() => {
|
.catch(() => {
|
||||||
if (!cancelled && !cached) setCategories([]);
|
if (!cancelled && !cached) setCategories([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [lang]);
|
}, [lang]);
|
||||||
|
return categories;
|
||||||
return useMemo(() => {
|
|
||||||
const map = new Map<string, string>();
|
|
||||||
categories.forEach((category) => map.set(category.slug, category.name));
|
|
||||||
return map;
|
|
||||||
}, [categories]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type FavoriteAttachment = {
|
function FavoriteResourceCard({ resource }: { resource: Resource }) {
|
||||||
thumbnailUrl?: string;
|
const { t } = useI18n();
|
||||||
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();
|
|
||||||
const lp = useLocalizedPath();
|
const lp = useLocalizedPath();
|
||||||
const unavailable = resource.availability === "unavailable";
|
const unavailable = resource.availability === "unavailable";
|
||||||
const cover =
|
const cover = resource.coverImage || resource.previewUrl;
|
||||||
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");
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<article
|
<article
|
||||||
@@ -145,7 +65,7 @@ function FavoriteResourceCard({
|
|||||||
{!unavailable ? (
|
{!unavailable ? (
|
||||||
<Link
|
<Link
|
||||||
to={lp(`/resource/${resource.id}`)}
|
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"
|
className="absolute inset-0 z-0 rounded-2xl outline-none focus-visible:ring-2 focus-visible:ring-ark-gold/80"
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : 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">
|
<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">
|
<h2 className="line-clamp-2 text-base font-bold leading-snug text-white md:text-lg">
|
||||||
{title}
|
{resource.title}
|
||||||
</h2>
|
</h2>
|
||||||
{description ? (
|
{resource.description ? (
|
||||||
<p className="line-clamp-2 text-sm leading-6 text-neutral-400">
|
<p className="line-clamp-2 text-sm leading-6 text-neutral-400">
|
||||||
{description}
|
{resource.description}
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
<div className="mt-auto flex flex-wrap items-center gap-2 text-xs text-neutral-400">
|
<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">
|
{resource.categoryName ? (
|
||||||
{cleanCategoryDisplayName(categoryLabel)}
|
<span className="rounded-full bg-[#1f2028] px-2.5 py-1 text-neutral-200">
|
||||||
</span>
|
{resource.categoryName}
|
||||||
<span>{typeLabel}</span>
|
</span>
|
||||||
{date ? (
|
) : null}
|
||||||
|
{resource.type ? (
|
||||||
|
<span>{resourceTypeLabel(t, resource.type)}</span>
|
||||||
|
) : null}
|
||||||
|
{resource.language ? (
|
||||||
<>
|
<>
|
||||||
<span>·</span>
|
<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}
|
) : null}
|
||||||
{typeof resource.favoriteCount === "number" ? (
|
{typeof resource.favoriteCount === "number" ? (
|
||||||
@@ -208,8 +140,10 @@ export default function Favorites() {
|
|||||||
const { lang, t } = useI18n();
|
const { lang, t } = useI18n();
|
||||||
const wallet = useWallet();
|
const wallet = useWallet();
|
||||||
const { markFavorite } = useFavorites();
|
const { markFavorite } = useFavorites();
|
||||||
const categoryNameBySlug = useCategoryNameBySlug(lang);
|
const categories = useCategories(lang);
|
||||||
const [items, setItems] = useState<FavoriteResource[]>([]);
|
const [posts, setPosts] = useState<
|
||||||
|
Awaited<ReturnType<typeof listFavorites>>["items"]
|
||||||
|
>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [loaded, setLoaded] = useState(false);
|
const [loaded, setLoaded] = useState(false);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
@@ -219,7 +153,7 @@ export default function Favorites() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!wallet.token || wallet.status !== "loggedIn") {
|
if (!wallet.token || wallet.status !== "loggedIn") {
|
||||||
setItems([]);
|
setPosts([]);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
setLoaded(false);
|
setLoaded(false);
|
||||||
setError("");
|
setError("");
|
||||||
@@ -237,9 +171,9 @@ export default function Favorites() {
|
|||||||
})
|
})
|
||||||
.then((data) => {
|
.then((data) => {
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
const resources = itemsOrEmpty(data.items) as FavoriteResource[];
|
const items = itemsOrEmpty(data.items);
|
||||||
setItems(resources);
|
setPosts(items);
|
||||||
resources.forEach((resource) => markFavorite(resource.id, true));
|
items.forEach((post) => markFavorite(post.id, true));
|
||||||
setLoaded(true);
|
setLoaded(true);
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
@@ -261,6 +195,11 @@ export default function Favorites() {
|
|||||||
};
|
};
|
||||||
}, [markFavorite, reloadKey, t, wallet]);
|
}, [markFavorite, reloadKey, t, wallet]);
|
||||||
|
|
||||||
|
const resources = useMemo(
|
||||||
|
() => posts.map((post) => postToResource(post, lang, categories)),
|
||||||
|
[posts, lang, categories],
|
||||||
|
);
|
||||||
|
|
||||||
if (wallet.status === "loading") {
|
if (wallet.status === "loading") {
|
||||||
return (
|
return (
|
||||||
<Reveal className="mx-auto grid max-w-[980px] gap-3 px-0 py-2 md:py-4">
|
<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")}
|
{t("walletRetry")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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">
|
<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} />
|
<Heart className="h-10 w-10 text-ark-gold/60" strokeWidth={1.8} />
|
||||||
<h2 className="text-xl font-semibold text-white">
|
<h2 className="text-xl font-semibold text-white">
|
||||||
@@ -323,12 +262,8 @@ export default function Favorites() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
items.map((resource) => (
|
resources.map((resource) => (
|
||||||
<FavoriteResourceCard
|
<FavoriteResourceCard key={resource.id} resource={resource} />
|
||||||
key={resource.id}
|
|
||||||
categoryNameBySlug={categoryNameBySlug}
|
|
||||||
resource={resource}
|
|
||||||
/>
|
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ export function postToResource(
|
|||||||
title,
|
title,
|
||||||
description: postDisplayText(post, lang),
|
description: postDisplayText(post, lang),
|
||||||
type: inferType(post, first),
|
type: inferType(post, first),
|
||||||
language: post.language,
|
language: post.sourceLanguage || post.language,
|
||||||
categoryId: post.categoryId,
|
categoryId: post.categoryId,
|
||||||
categorySlug: post.categorySlug,
|
categorySlug: post.categorySlug,
|
||||||
categoryName: category?.name || post.categorySlug,
|
categoryName: category?.name || post.categorySlug,
|
||||||
|
|||||||
Reference in New Issue
Block a user