Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 21 additions & 21 deletions packages/web/app/[locale]/(shell)/explore/ExploreClient.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"use client";

import { useState, useEffect, useMemo, useRef, useCallback } from "react";
import { useTranslations } from "next-intl";
import { Search, X, ChevronDown } from "lucide-react";
import { useDebounce, useRecentSearchesStore } from "@decoded/shared";
import { useExploreData } from "@/lib/hooks/useExploreData";
Expand Down Expand Up @@ -58,6 +59,7 @@ export function ExploreClient({
hasMagazine,
initialQuery = "",
}: Props) {
const t = useTranslations("Explore");
const debouncedQuery = useSearchStore((state) => state.debouncedQuery);
const query = useSearchStore((state) => state.query);
const setQuery = useSearchStore((state) => state.setQuery);
Expand Down Expand Up @@ -263,9 +265,9 @@ export function ExploreClient({
<div className="mx-auto max-w-[1400px] px-4 pb-12 md:px-6">
{/* Page header */}
<header className="pt-6">
<h1 className="text-2xl font-bold md:text-3xl">Explore</h1>
<h1 className="text-2xl font-bold md:text-3xl">{t("header.title")}</h1>
<p className="mt-1 text-sm text-muted-foreground">
Discover looks, moods and items from the community.
{t("header.subtitle")}
</p>
</header>

Expand All @@ -281,7 +283,7 @@ export function ExploreClient({
onFocus={() => query.length > 0 && setShowSuggestions(true)}
onKeyDown={handleKeyDown}
data-testid="explore-search-input"
placeholder="Search people, shows, items..."
placeholder={t("search.placeholder")}
role="combobox"
aria-label="Search"
aria-autocomplete="list"
Expand All @@ -294,7 +296,7 @@ export function ExploreClient({
onClick={handleClear}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
type="button"
aria-label="Clear search"
aria-label={t("search.clearAria")}
>
<X className="h-4 w-4" />
</button>
Expand Down Expand Up @@ -363,7 +365,7 @@ export function ExploreClient({
: "border-border text-muted-foreground hover:bg-accent"
)}
>
<option value="">Context</option>
<option value="">{t("search.contextLabel")}</option>
{contextOptions.map(([value, count]) => (
<option key={value} value={value}>
{value} ({count})
Expand Down Expand Up @@ -393,7 +395,7 @@ export function ExploreClient({
className="ml-1 flex-shrink-0 text-[10px] text-muted-foreground transition-colors hover:text-foreground"
type="button"
>
Clear all
{t("search.clearAll")}
</button>
)}
</div>
Expand All @@ -418,19 +420,17 @@ export function ExploreClient({
<div className="flex flex-col items-center justify-center px-4 py-16 text-center">
<div className="mb-4 text-4xl">⚠️</div>
<h2 className="mb-2 text-xl font-semibold text-foreground">
Failed to load posts
{t("loadError.title")}
</h2>
<p className="mb-6 max-w-md text-sm text-muted-foreground">
{error instanceof Error
? error.message
: "Something went wrong while loading posts."}
{error instanceof Error ? error.message : t("loadError.body")}
</p>
<button
onClick={() => refetch()}
className="rounded-full border border-border bg-card px-4 py-2 text-sm font-medium text-foreground transition-colors hover:bg-accent"
type="button"
>
Retry
{t("loadError.retry")}
</button>
</div>
)}
Expand All @@ -440,14 +440,14 @@ export function ExploreClient({
<div className="mx-auto flex max-w-md flex-col items-center justify-center px-4 py-16 text-center">
<div className="mb-4 text-4xl">🛠️</div>
<h2 className="mb-2 text-xl font-semibold text-foreground">
Search is temporarily unavailable
{t("searchError.title")}
</h2>
<p className="mb-6 text-sm text-muted-foreground">
{(() => {
const msg =
error instanceof Error
? error.message
: "Please try again in a moment.";
: t("searchError.body");
return msg.length > 140 ? `${msg.slice(0, 140)}…` : msg;
})()}
</p>
Expand All @@ -457,14 +457,14 @@ export function ExploreClient({
className="rounded-full border border-border bg-card px-4 py-2 text-sm font-medium text-foreground transition-colors hover:bg-accent"
type="button"
>
Retry
{t("searchError.retry")}
</button>
<button
type="button"
onClick={handleClear}
className="rounded-full px-4 py-2 text-sm text-muted-foreground transition-colors hover:text-foreground"
>
Clear search
{t("searchError.clear")}
</button>
</div>
</div>
Expand All @@ -478,13 +478,13 @@ export function ExploreClient({
</div>
<h2 className="mb-2 text-xl font-semibold text-foreground">
{debouncedQuery.trim().length > 0
? `No results for '${debouncedQuery}'`
: "No posts found yet."}
? t("empty.noResults", { query: debouncedQuery })
: t("empty.noPosts")}
</h2>
<p className="mb-6 text-sm text-muted-foreground">
{debouncedQuery.trim().length > 0
? "Try different keywords or the suggestions below."
: "Check back later."}
? t("empty.noResultsHint")
: t("empty.noPostsHint")}
</p>
{debouncedQuery.trim().length > 0 && (
<>
Expand All @@ -505,7 +505,7 @@ export function ExploreClient({
onClick={handleClear}
className="text-xs text-muted-foreground underline underline-offset-2 transition-colors hover:text-foreground"
>
Clear search
{t("empty.clear")}
</button>
</>
)}
Expand Down Expand Up @@ -533,7 +533,7 @@ export function ExploreClient({
<div ref={sentinelRef} aria-hidden="true" className="h-px" />
{isFetchingNextPage && (
<div className="flex justify-center py-6">
<LoadingSpinner text="Loading more..." />
<LoadingSpinner text={t("loadingMore")} />
</div>
)}
</>
Expand Down
55 changes: 55 additions & 0 deletions packages/web/i18n/__tests__/explore-messages-parity.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { describe, it, expect } from "vitest";
import { createTranslator } from "next-intl";
import ko from "@/messages/ko.json";
import en from "@/messages/en.json";

/**
* The Explore namespace (#845) copies the Home i18n baseline. ko/en must expose
* the identical key shape — a missing key on either side is a hard failure
* (untranslated string leaks the wrong language at runtime).
*/
function leafKeyPaths(obj: unknown, prefix = ""): string[] {
if (obj === null || typeof obj !== "object") return [prefix];
return Object.entries(obj as Record<string, unknown>).flatMap(([k, v]) =>
leafKeyPaths(v, prefix ? `${prefix}.${k}` : k)
);
}

describe("Explore messages ko/en parity", () => {
it("both locales define an Explore namespace", () => {
expect(en).toHaveProperty("Explore");
expect(ko).toHaveProperty("Explore");
});

it("ko and en expose the identical Explore key set", () => {
const enKeys = leafKeyPaths(
(en as Record<string, unknown>).Explore ?? {}
).sort();
const koKeys = leafKeyPaths(
(ko as Record<string, unknown>).Explore ?? {}
).sort();
expect(koKeys).toEqual(enKeys);
// Guard against an empty-vs-empty false pass.
expect(enKeys.length).toBeGreaterThan(0);
});

// ICU MessageFormat treats a straight quote before `{` as a literal escape,
// so `'{query}'` would render the placeholder verbatim. This guards that the
// dynamic empty-state title actually interpolates the query on both locales.
it.each([
["en", en],
["ko", ko],
] as const)(
"empty.noResults interpolates {query} under %s",
(locale, messages) => {
const t = createTranslator({
locale,
messages,
namespace: "Explore",
});
const out = t("empty.noResults", { query: "vintage denim" });
expect(out).toContain("vintage denim");
expect(out).not.toContain("{query}");
}
);
});
39 changes: 11 additions & 28 deletions packages/web/lib/components/explore/ComingSoonState.tsx
Original file line number Diff line number Diff line change
@@ -1,32 +1,12 @@
import { Sparkles, Shirt, Tag, Users, Palette } from "lucide-react";
import { useTranslations } from "next-intl";
import type { ExploreEntity } from "./EntityTabBar";

const COPY: Record<
Exclude<ExploreEntity, "looks">,
{ icon: React.ReactNode; title: string; description: string }
> = {
moods: {
icon: <Palette className="size-8" />,
title: "Moods are coming soon",
description:
"Curated mood collections — Minimal, Street, Y2K and more — are on the way.",
},
items: {
icon: <Shirt className="size-8" />,
title: "Items are coming soon",
description:
"Browse and search individual garments matched from decoded looks.",
},
brands: {
icon: <Tag className="size-8" />,
title: "Brands are coming soon",
description: "Follow brands and see every look they appear in.",
},
people: {
icon: <Users className="size-8" />,
title: "People are coming soon",
description: "Style icons and creators you can follow.",
},
const ICON: Record<Exclude<ExploreEntity, "looks">, React.ReactNode> = {
moods: <Palette className="size-8" />,
items: <Shirt className="size-8" />,
brands: <Tag className="size-8" />,
people: <Users className="size-8" />,
};

/**
Expand All @@ -37,7 +17,10 @@ export function ComingSoonState({
}: {
entity: Exclude<ExploreEntity, "looks">;
}) {
const { icon, title, description } = COPY[entity];
const t = useTranslations("Explore");
const icon = ICON[entity];
const title = t(`comingSoon.${entity}.title`);
const description = t(`comingSoon.${entity}.description`);
return (
<div className="flex min-h-[40vh] flex-col items-center justify-center gap-3 px-6 py-16 text-center">
<span className="flex size-16 items-center justify-center rounded-2xl bg-primary/15 text-foreground">
Expand All @@ -47,7 +30,7 @@ export function ComingSoonState({
<p className="max-w-sm text-sm text-muted-foreground">{description}</p>
<span className="mt-1 inline-flex items-center gap-1.5 rounded-full bg-muted px-3 py-1 text-xs font-medium text-muted-foreground">
<Sparkles className="size-3.5" />
Coming soon
{t("comingSoon.badge")}
</span>
</div>
);
Expand Down
21 changes: 12 additions & 9 deletions packages/web/lib/components/explore/EntityTabBar.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,17 @@
"use client";

import { useTranslations } from "next-intl";
import { cn } from "@/lib/utils";

export type ExploreEntity = "looks" | "moods" | "items" | "brands" | "people";

export const EXPLORE_ENTITIES: { id: ExploreEntity; label: string }[] = [
{ id: "looks", label: "Looks" },
{ id: "moods", label: "Moods" },
{ id: "items", label: "Items" },
{ id: "brands", label: "Brands" },
{ id: "people", label: "People" },
// Order only — display labels come from the `Explore.entities` message namespace.
export const EXPLORE_ENTITIES: ExploreEntity[] = [
"looks",
"moods",
"items",
"brands",
"people",
];

export interface EntityTabBarProps {
Expand All @@ -28,16 +30,17 @@ export function EntityTabBar({
onChange,
className,
}: EntityTabBarProps) {
const t = useTranslations("Explore");
return (
<div
role="tablist"
aria-label="Explore entities"
aria-label={t("entities.aria")}
className={cn(
"flex gap-1 overflow-x-auto border-b border-border scrollbar-hide",
className
)}
>
{EXPLORE_ENTITIES.map(({ id, label }) => {
{EXPLORE_ENTITIES.map((id) => {
const isActive = id === active;
return (
<button
Expand All @@ -53,7 +56,7 @@ export function EntityTabBar({
: "border-transparent text-muted-foreground hover:text-foreground"
)}
>
{label}
{t(`entities.${id}`)}
</button>
);
})}
Expand Down
6 changes: 4 additions & 2 deletions packages/web/lib/components/explore/ExploreCardCell.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"use client";

import { memo, useState } from "react";
import { useTranslations } from "next-intl";
import { Link } from "@/i18n/navigation";
import { gsap } from "gsap";
import { Flip } from "gsap/Flip";
Expand All @@ -26,6 +27,7 @@ export const ExploreCardCell = memo(function ExploreCardCell({
gridIndex,
item,
}: ExploreCardCellProps) {
const t = useTranslations("Explore");
const [imageError, setImageError] = useState(false);
const setTransition = useTransitionStore((state) => state.setTransition);
const track = useTrackEvent();
Expand Down Expand Up @@ -88,7 +90,7 @@ export const ExploreCardCell = memo(function ExploreCardCell({
>
<PostImage
src={imageUrl}
alt={`Image ${imageId}`}
alt={t("card.imageAlt")}
maxHeight="60vh"
className="h-full"
flagKey="ExploreCardCell"
Expand All @@ -102,7 +104,7 @@ export const ExploreCardCell = memo(function ExploreCardCell({
{typeof item?.spotCount === "number" && item.spotCount > 0 && (
<div
role="status"
aria-label={`${item.spotCount} spots`}
aria-label={t("card.spotsAria", { count: item.spotCount })}
className="absolute top-2 right-2 rounded-full bg-black/60 px-2 py-0.5 text-[11px] font-medium text-white"
>
{item.spotCount}
Expand Down
4 changes: 3 additions & 1 deletion packages/web/lib/components/explore/SortDropdown.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"use client";

import { ChevronDown } from "lucide-react";
import { useTranslations } from "next-intl";
import { cn } from "@/lib/utils";
import {
SEARCH_SORT_OPTIONS,
Expand Down Expand Up @@ -29,6 +30,7 @@ export function SortDropdown({
options = SEARCH_SORT_OPTIONS,
className,
}: SortDropdownProps) {
const t = useTranslations("Explore");
return (
<div className={cn("relative flex-shrink-0", className)}>
<select
Expand All @@ -44,7 +46,7 @@ export function SortDropdown({
>
{options.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
{t(`sort.${opt.value}`)}
</option>
))}
</select>
Expand Down
Loading
Loading