diff --git a/client/src/app/features/heritages/mappers/__tests__/to-world-heritage-detail-vm-test.ts b/client/src/app/features/heritages/mappers/__tests__/to-world-heritage-detail-vm.test.ts similarity index 80% rename from client/src/app/features/heritages/mappers/__tests__/to-world-heritage-detail-vm-test.ts rename to client/src/app/features/heritages/mappers/__tests__/to-world-heritage-detail-vm.test.ts index 715162c..ec973b6 100644 --- a/client/src/app/features/heritages/mappers/__tests__/to-world-heritage-detail-vm-test.ts +++ b/client/src/app/features/heritages/mappers/__tests__/to-world-heritage-detail-vm.test.ts @@ -53,7 +53,7 @@ const baseDto = ( }); describe("toWorldHeritageDetailVm", () => { - it("maps base fields and attaches sorted image VMs", () => { + it("en: maps base fields and attaches sorted image VMs", () => { const dto = baseDto({ images: [ img({ @@ -68,13 +68,15 @@ describe("toWorldHeritageDetailVm", () => { ], }); - const vm: WorldHeritageDetailVm = toWorldHeritageDetailVm(dto); + const vm: WorldHeritageDetailVm = toWorldHeritageDetailVm(dto, "en"); expect(vm.id).toBe(dto.id); - expect(vm.title).toBe(dto.official_name); + expect(vm.title).toBe(dto.name); expect(vm.country).toBe(dto.country); expect(vm.countryNameJp).toBe(dto.country_name_jp); expect(vm.region).toBe(dto.region); + expect(vm.displayDescription).toBe(dto.short_description); + expect(vm.displaySubName).toBeNull(); // detail expect(vm.primaryStatePartyCode).toBe("JPN"); @@ -97,12 +99,23 @@ describe("toWorldHeritageDetailVm", () => { expect(vm.images[1].alt).toBe("custom alt 2"); }); + it("ja: title falls back to heritage_name_jp and displaySubName carries the English name", () => { + const dto = baseDto({ images: [] }); + + const vm = toWorldHeritageDetailVm(dto, "ja"); + + expect(vm.title).toBe(dto.heritage_name_jp); + expect(vm.displaySubName).toBe(dto.name); + expect(vm.displayDescription).toBe(dto.short_description_jp); + expect(vm.country).toBe(dto.country_name_jp); + }); + it("is stable when images is empty array", () => { const dto = baseDto({ images: [] }); - const vm = toWorldHeritageDetailVm(dto); + const vm = toWorldHeritageDetailVm(dto, "en"); expect(vm.images).toEqual([]); - expect(vm.title).toBe(dto.official_name); + expect(vm.title).toBe(dto.name); }); }); diff --git a/client/src/app/features/heritages/mappers/__tests__/to-world-heritage-vm.test.ts b/client/src/app/features/heritages/mappers/__tests__/to-world-heritage-vm.test.ts index 00eeac2..51774b1 100644 --- a/client/src/app/features/heritages/mappers/__tests__/to-world-heritage-vm.test.ts +++ b/client/src/app/features/heritages/mappers/__tests__/to-world-heritage-vm.test.ts @@ -28,8 +28,8 @@ const base: ApiWorldHeritageDto = { }; describe("toWorldHeritageVm", () => { - it("maps core fields + derived values correctly (DTO shape: thumbnail string)", () => { - const vm = toWorldHeritageVm(base); + it("ja: maps core fields + derived values using Japanese labels", () => { + const vm = toWorldHeritageVm(base, "ja"); expect(vm).toMatchObject({ id: 663, @@ -54,6 +54,7 @@ describe("toWorldHeritageVm", () => { criteriaText: "ix, x", title: "白神山地", subtitle: "日本 · Asia", + displayDescription: "ダミー", areaText: "442 ha", bufferText: "320 ha", statePartyCodes: ["日本"], @@ -63,26 +64,46 @@ describe("toWorldHeritageVm", () => { }); expect(vm.criteria).toStrictEqual(["ix", "x"]); + // 英名 (Shirakami-Sanchi) は日本語タイトル (白神山地) と異なるので併記対象 + expect(vm.displaySubName).toBe("Shirakami-Sanchi"); }); - it("if official_name is empty, uses name as title", () => { - const vm = toWorldHeritageVm({ ...base, official_name: "" }); + it("en: title/country/description fall back to English fields", () => { + const vm = toWorldHeritageVm(base, "en"); + + expect(vm.title).toBe("Shirakami-Sanchi"); + expect(vm.country).toBe("Japan"); + expect(vm.subtitle).toBe("Japan · Asia"); + expect(vm.displayDescription).toBe("desc"); + expect(vm.displaySubName).toBeNull(); + }); + + it("ja: if official_name is empty, falls back to heritage_name_jp for title", () => { + const vm = toWorldHeritageVm({ ...base, official_name: "" }, "ja"); expect(vm.title).toBe("白神山地"); }); + it("ja: if Japanese description is missing, falls back to English short_description", () => { + const vm = toWorldHeritageVm({ ...base, short_description_jp: null }, "ja"); + expect(vm.displayDescription).toBe("desc"); + }); + it("when area/buffer are null, text becomes —", () => { - const vm = toWorldHeritageVm({ - ...base, - area_hectares: null, - buffer_zone_hectares: null, - }); + const vm = toWorldHeritageVm( + { + ...base, + area_hectares: null, + buffer_zone_hectares: null, + }, + "ja", + ); expect(vm.areaText).toBe("—"); expect(vm.bufferText).toBe("—"); }); it("when thumbnail is null, thumbnailUrl is null", () => { - const vm = toWorldHeritageVm({ ...base, thumbnail: null }); + const vm = toWorldHeritageVm({ ...base, thumbnail: null }, "ja"); expect(vm.thumbnailUrl).toBeNull(); }); @@ -90,21 +111,21 @@ describe("toWorldHeritageVm", () => { const dto: ApiWorldHeritageDto = { ...base, criteria: ["x", "ix", "ix"] }; const snapshot = [...dto.criteria]; - const vm = toWorldHeritageVm(dto); + const vm = toWorldHeritageVm(dto, "ja"); expect(dto.criteria).toStrictEqual(snapshot); expect(vm.criteria).toStrictEqual(["ix", "x"]); }); it("unesco_site_url can be null (mapper should not crash)", () => { - const vm = toWorldHeritageVm({ ...base, unesco_site_url: null }); + const vm = toWorldHeritageVm({ ...base, unesco_site_url: null }, "ja"); expect(vm.unescoSiteUrl).toBeNull(); }); }); describe("toWorldHeritageListVm", () => { it("maps dto array into view model array", () => { - const vms = toWorldHeritageListVm([base, { ...base, id: 661 }]); + const vms = toWorldHeritageListVm([base, { ...base, id: 661 }], "ja"); expect(vms).toHaveLength(2); expect(vms[0].id).toBe(663); expect(vms[1].id).toBe(661); diff --git a/client/src/app/features/heritages/mappers/to-world-heritage-detail-vm.ts b/client/src/app/features/heritages/mappers/to-world-heritage-detail-vm.ts index 3079aea..5783eb7 100644 --- a/client/src/app/features/heritages/mappers/to-world-heritage-detail-vm.ts +++ b/client/src/app/features/heritages/mappers/to-world-heritage-detail-vm.ts @@ -5,9 +5,13 @@ import type { WorldHeritageImageVm, WorldHeritageVm, } from "../../../../domain/types.ts"; +import type { Locale } from "../../../../domain/criteria.ts"; import { toWorldHeritageVm } from "./to-world-heritage-vm.ts"; -export function toWorldHeritageDetailVm(dto: ApiWorldHeritageDetailDto): WorldHeritageDetailVm { +export function toWorldHeritageDetailVm( + dto: ApiWorldHeritageDetailDto, + locale: Locale, +): WorldHeritageDetailVm { const listDto = { id: dto.id, official_name: dto.official_name, @@ -25,6 +29,7 @@ export function toWorldHeritageDetailVm(dto: ApiWorldHeritageDetailDto): WorldHe area_hectares: dto.area_hectares, buffer_zone_hectares: dto.buffer_zone_hectares, short_description: dto.short_description, + short_description_jp: dto.short_description_jp, unesco_site_url: dto.unesco_site_url, state_party: dto.state_party, state_party_codes: dto.state_party_codes, @@ -32,9 +37,7 @@ export function toWorldHeritageDetailVm(dto: ApiWorldHeritageDetailDto): WorldHe thumbnail: dto.thumbnail_url, } satisfies import("../../../../domain/types.ts").ApiWorldHeritageDto; - // Ensure the reshaped object satisfies ApiWorldHeritageDto at compile time - - const base: WorldHeritageVm = toWorldHeritageVm(listDto); + const base: WorldHeritageVm = toWorldHeritageVm(listDto, locale); const images: WorldHeritageImageVm[] = dto.images .slice() diff --git a/client/src/app/features/heritages/mappers/to-world-heritage-vm.ts b/client/src/app/features/heritages/mappers/to-world-heritage-vm.ts index 3cf3b80..f718631 100644 --- a/client/src/app/features/heritages/mappers/to-world-heritage-vm.ts +++ b/client/src/app/features/heritages/mappers/to-world-heritage-vm.ts @@ -3,6 +3,7 @@ import { type WorldHeritageVm, type CriteriaCode, } from "../../../../domain/types.ts"; +import type { Locale } from "../../../../domain/criteria.ts"; import { statePartyLabels } from "@features/constants/state-party-labels.ts"; import { CRITERIA } from "../../../../domain/types.ts"; @@ -19,14 +20,33 @@ const normalizeCriteria = (values: readonly (string | CriteriaCode)[]): Criteria const fmtHa = (value: number | null): string => value == null ? "—" : `${Number(value).toLocaleString("en-CA")} ha`; -const titleOf = (data: ApiWorldHeritageDto): string => - data.heritage_name_jp || data.official_name || data.name; - -const countryLabelOf = (data: ApiWorldHeritageDto): string | null => - data.country_name_jp || data.country || null; +const titleOf = (data: ApiWorldHeritageDto, locale: Locale): string => + locale === "ja" + ? data.heritage_name_jp || data.official_name || data.name + : data.name || data.official_name || data.heritage_name_jp; + +const countryLabelOf = (data: ApiWorldHeritageDto, locale: Locale): string | null => + locale === "ja" + ? data.country_name_jp || data.country || null + : data.country || data.country_name_jp || null; + +const subtitleOf = (data: ApiWorldHeritageDto, locale: Locale): string => + [countryLabelOf(data, locale), data.region].filter(Boolean).join(" · "); + +// ja の時だけ、日本語名の脇に併記する英名を返す(一致や不在なら null) +const subNameOf = (data: ApiWorldHeritageDto, locale: Locale): string | null => { + if (locale !== "ja") return null; + if (!data.heritage_name_jp || !data.name) return null; + if (data.heritage_name_jp === data.name) return null; + return data.name; +}; -const subtitleOf = (data: ApiWorldHeritageDto): string => - [countryLabelOf(data), data.region].filter(Boolean).join(" · "); +const descriptionOf = (data: ApiWorldHeritageDto, locale: Locale): string => { + if (locale === "ja") { + return data.short_description_jp || data.short_description || ""; + } + return data.short_description || ""; +}; const toStatePartyLabelsJp = (codes: readonly string[]): string[] => codes.map((code) => statePartyLabels[code]).filter((label): label is string => Boolean(label)); @@ -49,7 +69,7 @@ const normalizeStatePartiesMeta = ( ); }; -export function toWorldHeritageVm(data: ApiWorldHeritageDto): WorldHeritageVm { +export function toWorldHeritageVm(data: ApiWorldHeritageDto, locale: Locale): WorldHeritageVm { const criteriaCodes = normalizeCriteria(data.criteria); const statePartyCodesRaw = data.state_party_codes ?? []; const statePartyLabelsJp = toStatePartyLabelsJp(statePartyCodesRaw); @@ -66,7 +86,7 @@ export function toWorldHeritageVm(data: ApiWorldHeritageDto): WorldHeritageVm { name: data.name, heritageNameJp: data.heritage_name_jp, - country: data.country_name_jp, + country: countryLabelOf(data, locale) ?? "", countryNameJp: data.country_name_jp, region: data.region, stateParty, @@ -86,8 +106,10 @@ export function toWorldHeritageVm(data: ApiWorldHeritageDto): WorldHeritageVm { statePartiesMeta: normalizeStatePartiesMeta(data.state_parties_meta), primaryStatePartyCode: null, - title: titleOf(data), - subtitle: subtitleOf(data), + title: titleOf(data, locale), + subtitle: subtitleOf(data, locale), + displaySubName: subNameOf(data, locale), + displayDescription: descriptionOf(data, locale), areaText: fmtHa(data.area_hectares), bufferText: fmtHa(data.buffer_zone_hectares), criteriaText: criteriaCodes.join(", "), @@ -97,5 +119,7 @@ export function toWorldHeritageVm(data: ApiWorldHeritageDto): WorldHeritageVm { }; } -export const toWorldHeritageListVm = (list: ApiWorldHeritageDto[]): WorldHeritageVm[] => - list.map(toWorldHeritageVm); +export const toWorldHeritageListVm = ( + list: ApiWorldHeritageDto[], + locale: Locale, +): WorldHeritageVm[] => list.map((data) => toWorldHeritageVm(data, locale)); diff --git a/client/src/app/features/search/components/SearchResultsPage.tsx b/client/src/app/features/search/components/SearchResultsPage.tsx index 651235f..911e3fc 100644 --- a/client/src/app/features/search/components/SearchResultsPage.tsx +++ b/client/src/app/features/search/components/SearchResultsPage.tsx @@ -8,7 +8,7 @@ import { Pagination } from "@features/top/components/Pagination.tsx"; import { BreadcrumbList } from "@shared/components/BreadcrumbList.tsx"; import { SearchResultMapComponent } from "@features/search/components/SearchResultMapComponent.tsx"; -export type SearchResultsPageProps = { +type Props = { header?: ReactNode; items: WorldHeritageVm[]; pagination: SearchResultsPagination | null; @@ -30,7 +30,7 @@ export default function SearchResultsPage({ errorMessage, onPageChange, onBackToAllSites, -}: SearchResultsPageProps) { +}: Props) { return (
diff --git a/client/src/app/features/search/containers/__tests__/search-heritage-container.test.tsx b/client/src/app/features/search/containers/__tests__/search-heritage-container.test.tsx index bca3a83..62a1154 100644 --- a/client/src/app/features/search/containers/__tests__/search-heritage-container.test.tsx +++ b/client/src/app/features/search/containers/__tests__/search-heritage-container.test.tsx @@ -56,30 +56,58 @@ jest.mock("@features/top/components/HeritageSubHeader.tsx", () => ({ }, })); -type TopPageProps = { - header: React.ReactNode; - items: unknown[]; - onClickItem: (id: number) => void; - onReload: () => void; - currentPage: number; - perPage: number; +type TitleBarProps = { order: IdSortOption; onChangeOrder: (order: IdSortOption) => void; + onReload?: () => void; +}; + +type PaginationProps = { + currentPage: number; + perPage: number; lastPage: number; onChangePage: (page: number) => void; - paginationDisabled: boolean; - onChangePerPage: (perPage: number) => void; - perPageOptions: number[]; + onChangePerPage?: (perPage: number) => void; + perPageOptions?: readonly number[]; + disabled?: boolean; }; -let lastTopPageProps: TopPageProps | null = null; +let lastTitleBarProps: TitleBarProps | null = null; +let lastPaginationProps: PaginationProps | null = null; + +jest.mock("@features/top/components/TopPageTitleBar.tsx", () => ({ + TopPageTitleBar: (props: TitleBarProps) => { + lastTitleBarProps = props; + return null; + }, +})); + +jest.mock("@features/top/components/TopPagePagination.tsx", () => ({ + TopPagePagination: (props: PaginationProps) => { + lastPaginationProps = props; + return null; + }, +})); + +jest.mock("@features/top/components/HeritageList.tsx", () => ({ + HeritageList: () => null, +})); jest.mock("@features/top/components/TopPage.tsx", () => ({ __esModule: true, - default: (props: TopPageProps) => { - lastTopPageProps = props; - return <>{props.header}; - }, + default: (props: { + titleBar: React.ReactNode; + header?: React.ReactNode; + content: React.ReactNode; + pagination?: React.ReactNode; + }) => ( + <> + {props.titleBar} + {props.header} + {props.content} + {props.pagination} + + ), })); const parseMock = parseHeritageSearchParams as jest.MockedFunction< @@ -126,7 +154,8 @@ describe("TopPageContainer", () => { beforeEach(() => { jest.clearAllMocks(); lastSubHeaderProps = null; - lastTopPageProps = null; + lastTitleBarProps = null; + lastPaginationProps = null; currentLocation = location( "?region=Africa&search_query=Kyoto¤t_page=3&per_page=30&order=asc", @@ -183,10 +212,10 @@ describe("TopPageContainer", () => { order: "asc", }); - expect(lastTopPageProps).not.toBeNull(); - expect(lastTopPageProps!.currentPage).toBe(3); - expect(lastTopPageProps!.perPage).toBe(30); - expect(lastTopPageProps!.order).toBe("asc"); + expect(lastPaginationProps).not.toBeNull(); + expect(lastPaginationProps!.currentPage).toBe(3); + expect(lastPaginationProps!.perPage).toBe(30); + expect(lastTitleBarProps!.order).toBe("asc"); }); test("onSubmit serialises merged search params and navigates to results", async () => { @@ -280,10 +309,10 @@ describe("TopPageContainer", () => { render(); - await waitFor(() => expect(lastTopPageProps).not.toBeNull()); + await waitFor(() => expect(lastPaginationProps).not.toBeNull()); act(() => { - lastTopPageProps!.onChangePage(4); + lastPaginationProps!.onChangePage(4); }); expect(navigateMock).toHaveBeenCalledWith( @@ -306,10 +335,10 @@ describe("TopPageContainer", () => { render(); - await waitFor(() => expect(lastTopPageProps).not.toBeNull()); + await waitFor(() => expect(lastPaginationProps).not.toBeNull()); act(() => { - lastTopPageProps!.onChangePerPage(50); + lastPaginationProps!.onChangePerPage!(50); }); expect(navigateMock).toHaveBeenCalledWith( @@ -332,10 +361,10 @@ describe("TopPageContainer", () => { render(); - await waitFor(() => expect(lastTopPageProps).not.toBeNull()); + await waitFor(() => expect(lastTitleBarProps).not.toBeNull()); act(() => { - lastTopPageProps!.onChangeOrder("desc"); + lastTitleBarProps!.onChangeOrder("desc"); }); expect(navigateMock).toHaveBeenCalledWith( diff --git a/client/src/app/features/search/containers/search-heritage-result-container.tsx b/client/src/app/features/search/containers/search-heritage-result-container.tsx index 754defe..f81144f 100644 --- a/client/src/app/features/search/containers/search-heritage-result-container.tsx +++ b/client/src/app/features/search/containers/search-heritage-result-container.tsx @@ -18,6 +18,7 @@ import type { import { toWorldHeritageListVm } from "@features/heritages/mappers/to-world-heritage-vm"; import { HeritageSubHeader } from "@features/top/components/HeritageSubHeader"; import { DEFAULT_HERITAGE_SEARCH_PARAMS as SEARCH_PARAMS } from "../mapper/search-heritage.types"; +import { useLocale } from "@shared/locale/LocaleHooks"; const fmtRangeText = (pagination: Pagination, count: number): string => { if (count === 0) { @@ -98,6 +99,7 @@ function useHeritageSearchDraft(params: HeritageSearchParams) { export function SearchHeritageResultsContainer(): React.ReactElement { const location = useLocation(); const navigate = useNavigate(); + const { locale } = useLocale(); const params = React.useMemo( () => parseHeritageSearchParams(location.search), @@ -199,7 +201,7 @@ export function SearchHeritageResultsContainer(): React.ReactElement { ); } - const items = toWorldHeritageListVm(data.items); + const items = toWorldHeritageListVm(data.items, locale); const pagination = data.pagination; const rangeText = fmtRangeText(pagination, items.length); diff --git a/client/src/app/features/search/mapper/__tests__/to-world-heritage-vm.test.ts b/client/src/app/features/search/mapper/__tests__/to-world-heritage-vm.test.ts index 07f42f9..d809f7c 100644 --- a/client/src/app/features/search/mapper/__tests__/to-world-heritage-vm.test.ts +++ b/client/src/app/features/search/mapper/__tests__/to-world-heritage-vm.test.ts @@ -97,10 +97,10 @@ describe("toHeritageSearchResultVm", () => { }, }); - const viewModel = toHeritageSearchResultVm(response); + const viewModel = toHeritageSearchResultVm(response, "en"); expect(mockedToWorldHeritageListVm).toHaveBeenCalledTimes(1); - expect(mockedToWorldHeritageListVm).toHaveBeenCalledWith(response.data.items); + expect(mockedToWorldHeritageListVm).toHaveBeenCalledWith(response.data.items, "en"); expect(viewModel.items).toBe(mappedItems); expect(viewModel.pagination).toEqual(response.data.pagination); @@ -116,7 +116,7 @@ describe("toHeritageSearchResultVm", () => { }, }); - const viewModel = toHeritageSearchResultVm(response); + const viewModel = toHeritageSearchResultVm(response, "en"); expect(viewModel.isFirstPage).toBe(true); expect(viewModel.isLastPage).toBe(true); @@ -132,7 +132,7 @@ describe("toHeritageSearchResultVm", () => { }, }); - const viewModel = toHeritageSearchResultVm(response); + const viewModel = toHeritageSearchResultVm(response, "en"); expect(viewModel.isLastPage).toBe(true); expect(viewModel.isFirstPage).toBe(false); @@ -152,7 +152,7 @@ describe("toHeritageSearchResultVm", () => { }, }); - const viewModel = toHeritageSearchResultVm(response); + const viewModel = toHeritageSearchResultVm(response, "en"); expect(viewModel.rangeText).toBe("31–60 of 2,345"); }); @@ -167,7 +167,7 @@ describe("toHeritageSearchResultVm", () => { }, }); - const viewModel = toHeritageSearchResultVm(response); + const viewModel = toHeritageSearchResultVm(response, "en"); expect(viewModel.rangeText).toBe("0 of 2,345"); }); diff --git a/client/src/app/features/search/mapper/to-search-heritage-vm.ts b/client/src/app/features/search/mapper/to-search-heritage-vm.ts index 174f570..bf92045 100644 --- a/client/src/app/features/search/mapper/to-search-heritage-vm.ts +++ b/client/src/app/features/search/mapper/to-search-heritage-vm.ts @@ -1,4 +1,5 @@ import type { ApiWorldHeritageDto, Pagination, WorldHeritageVm } from "../../../../domain/types"; +import type { Locale } from "../../../../domain/criteria"; import type { HeritageSearchResponse } from "../types"; import { toWorldHeritageListVm } from "../../heritages/mappers/to-world-heritage-vm"; @@ -54,9 +55,10 @@ const formatRangeText = (pagination: Pagination, count: number): string => { export const toHeritageSearchResultVm = ( response: HeritageSearchResponse | FlatSuccess, + locale: Locale, ): HeritageSearchResultVm => { if (isPagedSuccess(response)) { - const items = toWorldHeritageListVm(response.data.items); + const items = toWorldHeritageListVm(response.data.items, locale); const pagination = response.data.pagination; return { @@ -69,7 +71,7 @@ export const toHeritageSearchResultVm = ( } if (isFlatSuccess(response)) { - const items = toWorldHeritageListVm(response.data); + const items = toWorldHeritageListVm(response.data, locale); const total = response.data.length; const pagination: Pagination = { diff --git a/client/src/app/features/top/cards/HeritageCard.tsx b/client/src/app/features/top/cards/HeritageCard.tsx index c1139a8..c6db1e1 100644 --- a/client/src/app/features/top/cards/HeritageCard.tsx +++ b/client/src/app/features/top/cards/HeritageCard.tsx @@ -1,6 +1,7 @@ import type { ReactNode, MouseEvent } from "react"; import type { WorldHeritageVm, CriteriaCode } from "../../../../domain/types.ts"; import { BaseCard } from "@shared/uis/BaseCard.tsx"; +import { useText } from "@shared/locale/ui-text.ts"; function MetaChip({ children }: { children: ReactNode }) { return ( @@ -40,6 +41,8 @@ export function HeritageCard({ item: WorldHeritageVm; onClickItem?: (id: number) => void; }) { + const text = useText(); + const goDetail = () => { if (!onClickItem) return; onClickItem(item.id); @@ -54,9 +57,9 @@ export function HeritageCard({ goDetail(); }; - const title = item.heritageNameJp || "World Heritage"; + const title = item.title; const subtitle = item.subtitle ?? ""; - const desc = (item.shortDescription ?? "").trim(); + const desc = item.displayDescription.trim(); const criteria = (item.criteria ?? []).slice(0, CRITERIA_MAX); const hasMoreCriteria = (item.criteria?.length ?? 0) > CRITERIA_MAX; @@ -74,7 +77,7 @@ export function HeritageCard({ /> ) : (
- No image + {text.noImage}
)} @@ -83,7 +86,7 @@ export function HeritageCard({ {item.isEndangered && (
- DANGER + {text.danger}
)} @@ -101,7 +104,7 @@ export function HeritageCard({ {item.category && (
- Heritage Category + {text.heritageCategory}
{item.category} @@ -112,7 +115,7 @@ export function HeritageCard({ {criteria.length > 0 && (
- Criteria + {text.criteria}
{criteria.map((c: CriteriaCode) => ( @@ -134,7 +137,7 @@ export function HeritageCard({ {desc}

) : ( -

No overview available.

+

{text.noOverview}

)}
diff --git a/client/src/app/features/top/components/HeritageList.tsx b/client/src/app/features/top/components/HeritageList.tsx new file mode 100644 index 0000000..155d2e2 --- /dev/null +++ b/client/src/app/features/top/components/HeritageList.tsx @@ -0,0 +1,28 @@ +import type { WorldHeritageVm } from "../../../../domain/types.ts"; +import { HeritageCard } from "../cards/HeritageCard"; + +export function HeritageList({ + items, + onClickItem, +}: { + items: ReadonlyArray; + onClickItem?: (id: number) => void; +}) { + if (items.length === 0) { + return ( +
+

No sites found.

+
+ ); + } + + return ( +
    + {items.map((it) => ( +
  • + +
  • + ))} +
+ ); +} diff --git a/client/src/app/features/top/components/HeritageSearchForm.tsx b/client/src/app/features/top/components/HeritageSearchForm.tsx index 18e17dd..47bc0dd 100644 --- a/client/src/app/features/top/components/HeritageSearchForm.tsx +++ b/client/src/app/features/top/components/HeritageSearchForm.tsx @@ -7,6 +7,7 @@ import { type SearchValues, type StudyRegion, } from "../../../../domain/types.ts"; +import { useText } from "@shared/locale/ui-text.ts"; type Props = { value?: SearchValues; @@ -28,24 +29,8 @@ const toCategoryOrEmpty = (value: string): Category | "" => { return isCategory(value) ? value : ""; }; -const REGION_LABELS: Record = { - "": "All", - Africa: "Africa", - Asia: "Asia", - Europe: "Europe", - "North America": "North America", - "South America": "South America", - Oceania: "Oceania", -}; - -const CATEGORY_LABELS: Record = { - "": "All", - Cultural: "Cultural", - Natural: "Natural", - Mixed: "Mixed", -}; - export function HeritageSearchForm({ value, onChange, onSubmit }: Props) { + const text = useText(); const regionOptions: readonly (StudyRegion | "")[] = ["", ...STUDY_REGIONS]; const categoryOptions: readonly (Category | "")[] = ["", ...CATEGORIES]; @@ -85,7 +70,7 @@ export function HeritageSearchForm({ value, onChange, onSubmit }: Props) { > {/* Region チップ */}
-
Region
+
{text.region}
{regionOptions.map((opt) => { const isActive = searchValues.region === opt; @@ -103,7 +88,7 @@ export function HeritageSearchForm({ value, onChange, onSubmit }: Props) { } `} > - {REGION_LABELS[opt]} + {opt === "" ? text.all : text.regionLabels[opt]} ); })} @@ -112,7 +97,7 @@ export function HeritageSearchForm({ value, onChange, onSubmit }: Props) { {/* Category チップ */}
-
Category
+
{text.category}
{categoryOptions.map((opt) => { const isActive = searchValues.category === opt; @@ -130,7 +115,7 @@ export function HeritageSearchForm({ value, onChange, onSubmit }: Props) { } `} > - {CATEGORY_LABELS[opt]} + {opt === "" ? text.all : text.categoryLabels[opt]} ); })} @@ -142,16 +127,16 @@ export function HeritageSearchForm({ value, onChange, onSubmit }: Props) { {/* Year */}
-
Year Inscribed
+
{text.yearInscribed}
set({ yearInscribedFrom: e.target.value })} - placeholder="From" + placeholder={text.yearFrom} className="w-full bg-transparent text-sm font-semibold text-zinc-900 placeholder:text-zinc-400 placeholder:font-normal focus:outline-none" - aria-label="Year inscribed from" + aria-label={`${text.yearInscribed} ${text.yearFrom}`} /> set({ yearInscribedTo: e.target.value })} - placeholder="To" + placeholder={text.yearTo} className="w-full bg-transparent text-sm font-semibold text-zinc-900 placeholder:text-zinc-400 placeholder:font-normal focus:outline-none" - aria-label="Year inscribed to" + aria-label={`${text.yearInscribed} ${text.yearTo}`} />
@@ -169,13 +154,13 @@ export function HeritageSearchForm({ value, onChange, onSubmit }: Props) { {/* Keyword + Submit */}
-
Keyword
+
{text.keyword}
set({ keyword: e.target.value })} - placeholder="Name / Country" + placeholder={text.keywordPlaceholder} className="w-full bg-transparent text-sm font-semibold text-zinc-900 placeholder:text-zinc-400 placeholder:font-normal focus:outline-none" - aria-label="Keyword" + aria-label={text.keyword} />
@@ -187,10 +172,10 @@ export function HeritageSearchForm({ value, onChange, onSubmit }: Props) { hover:bg-rose-700 transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-rose-400 " - aria-label="Search" + aria-label={text.search} > - Search + {text.search}
diff --git a/client/src/app/features/top/components/HeritageSubHeader.tsx b/client/src/app/features/top/components/HeritageSubHeader.tsx index 117f2f8..ec10c81 100644 --- a/client/src/app/features/top/components/HeritageSubHeader.tsx +++ b/client/src/app/features/top/components/HeritageSubHeader.tsx @@ -1,7 +1,7 @@ import { HeritageSearchForm } from "./HeritageSearchForm"; import { type SearchValues } from "../../../../domain/types.ts"; -export type Props = { +type Props = { value: SearchValues; onSubmit: (q: Partial) => void; onChange?: (v: SearchValues) => void; diff --git a/client/src/app/features/top/components/TopPage.tsx b/client/src/app/features/top/components/TopPage.tsx index 4b8f5ff..755f6c2 100644 --- a/client/src/app/features/top/components/TopPage.tsx +++ b/client/src/app/features/top/components/TopPage.tsx @@ -1,96 +1,20 @@ -import type { WorldHeritageVm, IdSortOption } from "../../../../domain/types.ts"; -import { HeritageCard } from "../cards/HeritageCard"; import type { ReactNode } from "react"; -import { Pagination } from "@features/top/components/Pagination.tsx"; import { Map } from "./Map.tsx"; -export type TopPageProps = { - items: ReadonlyArray; - onClickItem?: (id: number) => void; - onReload?: () => void; - header?: ReactNode; - currentPage?: number; - perPage?: number; - lastPage?: number; - order: IdSortOption; - onChangeOrder: (order: IdSortOption) => void; - onChangePage?: (page: number) => void; - onChangePerPage?: (perPage: number) => void; - perPageOptions?: readonly number[]; - paginationDisabled?: boolean; -}; - export default function TopPage({ - items, - onClickItem, - onReload, + titleBar, header, - currentPage, - perPage, - lastPage, - order, - onChangeOrder, - onChangePage, - onChangePerPage, - perPageOptions, - paginationDisabled, -}: TopPageProps) { - const options = (perPageOptions ?? [10, 30, 50, 70]) as readonly number[]; - - const showPagination = - typeof currentPage === "number" && - typeof perPage === "number" && - typeof lastPage === "number" && - typeof onChangePage === "function" && - lastPage > 1; - - const showPerPageSelect = - showPagination && typeof onChangePerPage === "function" && options.length > 0; - + content, + pagination, +}: { + titleBar: ReactNode; + header?: ReactNode; + content: ReactNode; + pagination?: ReactNode; +}) { return (
-
-
-
-

- World Heritage -

-

- Learn by searching and comparing sites. -

-
- -
- - - {onReload && ( - - )} -
-
-
+ {titleBar}
{header}
@@ -99,50 +23,8 @@ export default function TopPage({
- {items.length === 0 ? ( -
-

No sites found.

-
- ) : ( -
    - {items.map((it) => ( -
  • - -
  • - ))} -
- )} - - {showPagination && typeof perPage === "number" && ( -
- {showPerPageSelect && ( -
- - -
- )} - - -
- )} + {content} + {pagination}
); diff --git a/client/src/app/features/top/components/TopPagePagination.tsx b/client/src/app/features/top/components/TopPagePagination.tsx new file mode 100644 index 0000000..560419d --- /dev/null +++ b/client/src/app/features/top/components/TopPagePagination.tsx @@ -0,0 +1,55 @@ +import { Pagination } from "@features/top/components/Pagination.tsx"; + +export function TopPagePagination({ + currentPage, + perPage, + lastPage, + onChangePage, + onChangePerPage, + perPageOptions, + disabled, +}: { + currentPage: number; + perPage: number; + lastPage: number; + onChangePage: (page: number) => void; + onChangePerPage?: (perPage: number) => void; + perPageOptions?: readonly number[]; + disabled?: boolean; +}) { + if (lastPage <= 1) return null; + + const options = perPageOptions ?? []; + const showPerPageSelect = typeof onChangePerPage === "function" && options.length > 0; + + return ( +
+ {showPerPageSelect && ( +
+ + +
+ )} + + +
+ ); +} diff --git a/client/src/app/features/top/components/TopPageTitleBar.tsx b/client/src/app/features/top/components/TopPageTitleBar.tsx new file mode 100644 index 0000000..1461b8f --- /dev/null +++ b/client/src/app/features/top/components/TopPageTitleBar.tsx @@ -0,0 +1,59 @@ +import type { IdSortOption } from "../../../../domain/types.ts"; +import { LocaleToggle } from "@shared/locale/LocaleToggle.tsx"; +import { useText } from "@shared/locale/ui-text.ts"; + +export function TopPageTitleBar({ + order, + onChangeOrder, + onReload, +}: { + order: IdSortOption; + onChangeOrder: (order: IdSortOption) => void; + onReload?: () => void; +}) { + const text = useText(); + return ( +
+
+
+

+ {text.appTitle} +

+

{text.appTagline}

+
+ +
+ + + {onReload && ( + + )} + + +
+
+
+ ); +} diff --git a/client/src/app/features/top/components/heritage-detail/HeritageDetailLayout.tsx b/client/src/app/features/top/components/heritage-detail/HeritageDetailLayout.tsx index 8772818..f9cbd06 100644 --- a/client/src/app/features/top/components/heritage-detail/HeritageDetailLayout.tsx +++ b/client/src/app/features/top/components/heritage-detail/HeritageDetailLayout.tsx @@ -1,7 +1,6 @@ import { useState, useEffect } from "react"; import { useNavigate } from "react-router-dom"; import type { WorldHeritageDetailVm, SearchValues } from "../../../../../domain/types.ts"; -import type { Locale } from "../../../../../domain/criteria"; import { HeritageSubHeader } from "../HeritageSubHeader.tsx"; import { HeritageHero } from "./HeritageHero"; import { HeritageOverViewSection } from "./HeritageOverviewSection"; @@ -11,6 +10,8 @@ import { DetailHeritageMap } from "@features/top/components/heritage-detail/Deta import { textType } from "@shared/styles/typography"; import { useSetBreadcrumbLabel } from "@features/breadcrumbs/BreadCrumbHooks.ts"; import { BreadcrumbList } from "@shared/components/BreadcrumbList.tsx"; +import { useText } from "@shared/locale/ui-text.ts"; +import { LocaleToggle } from "@shared/locale/LocaleToggle.tsx"; const DEFAULT_SEARCH: SearchValues = { region: "", @@ -20,12 +21,6 @@ const DEFAULT_SEARCH: SearchValues = { yearInscribedTo: "", }; -const TABS: readonly { label: string; href: `#${string}` }[] = [ - { label: "Description", href: "#content" }, - { label: "Maps", href: "#geo-map" }, - { label: "Gallery", href: "#gallery" }, -] as const; - const formatCriteriaInline = (criteria: string[] | undefined) => criteria?.length ? criteria.map((c) => `(${c})`).join("") : "—"; @@ -59,30 +54,35 @@ function HeritageDetailTabs({ // Key exam info: visible without scrolling on all screen sizes. function KeyExamInfo({ item }: { item: WorldHeritageDetailVm }) { + const text = useText(); return (
- Region + {text.region} +
+
+ {text.regionLabels[item.region] ?? "—"}
-
{item.region ?? "—"}
- Category + {text.category} +
+
+ {text.categoryLabels[item.category] ?? "—"}
-
{item.category ?? "—"}
- Year Inscribed + {text.yearInscribed}
{item.yearInscribed ?? "—"}
- Criteria + {text.criteria}
{formatCriteriaInline(item.criteria)} @@ -93,18 +93,16 @@ function KeyExamInfo({ item }: { item: WorldHeritageDetailVm }) { ); } -export function HeritageDetailLayout({ - item, - locale, - toggleLocale, -}: { - item: WorldHeritageDetailVm; - locale: Locale; - toggleLocale: () => void; -}) { +export function HeritageDetailLayout({ item }: { item: WorldHeritageDetailVm }) { const [search, setSearch] = useState(DEFAULT_SEARCH); const setLabel = useSetBreadcrumbLabel(); const navigate = useNavigate(); + const text = useText(); + const tabs: readonly { label: string; href: `#${string}` }[] = [ + { label: text.description, href: "#content" }, + { label: text.maps, href: "#geo-map" }, + { label: text.gallery, href: "#gallery" }, + ]; const handleSubmit = (q: Partial) => { const next = { ...search, ...q }; @@ -131,13 +129,8 @@ export function HeritageDetailLayout({
- - + +
@@ -145,7 +138,7 @@ export function HeritageDetailLayout({
{/* Hero image */} - + {/* Key exam info: always visible */} @@ -159,7 +152,7 @@ export function HeritageDetailLayout({
{/* Left: Overview → Gallery */}
- +
diff --git a/client/src/app/features/top/components/heritage-detail/HeritageHero.tsx b/client/src/app/features/top/components/heritage-detail/HeritageHero.tsx index c1f8528..c7d0d40 100644 --- a/client/src/app/features/top/components/heritage-detail/HeritageHero.tsx +++ b/client/src/app/features/top/components/heritage-detail/HeritageHero.tsx @@ -1,7 +1,8 @@ import type { WorldHeritageDetailVm, WorldHeritageImageVm } from "../../../../../domain/types.ts"; -import type { Locale } from "../../../../../domain/criteria.ts"; +import { useText } from "@shared/locale/ui-text.ts"; -export function HeritageHero({ item, locale }: { item: WorldHeritageDetailVm; locale: Locale }) { +export function HeritageHero({ item }: { item: WorldHeritageDetailVm }) { + const text = useText(); const primaryImage: WorldHeritageImageVm | undefined = item.images.find((img) => img.isPrimary) ?? item.images[0]; @@ -9,10 +10,10 @@ export function HeritageHero({ item, locale }: { item: WorldHeritageDetailVm; lo

- {locale === "ja" && item.heritageNameJp ? item.heritageNameJp : item.name} - {locale === "ja" && item.heritageNameJp && item.name && ( + {item.title} + {item.displaySubName && ( - ({item.name}) + ({item.displaySubName}) )}

@@ -56,7 +57,7 @@ export function HeritageHero({ item, locale }: { item: WorldHeritageDetailVm; lo rel="noreferrer" className="shrink-0 font-semibold text-zinc-700 hover:underline" > - View on UNESCO + {text.viewOnUnesco} )} diff --git a/client/src/app/features/top/components/heritage-detail/HeritageMetadataList.tsx b/client/src/app/features/top/components/heritage-detail/HeritageMetadataList.tsx index 7566389..61f9e7d 100644 --- a/client/src/app/features/top/components/heritage-detail/HeritageMetadataList.tsx +++ b/client/src/app/features/top/components/heritage-detail/HeritageMetadataList.tsx @@ -1,7 +1,7 @@ import type { ReactNode } from "react"; import "./heritage-detail.css"; -export type MetadataItem = { +type MetadataItem = { label: string; value: ReactNode; hidden?: boolean; diff --git a/client/src/app/features/top/components/heritage-detail/HeritageOverviewSection.tsx b/client/src/app/features/top/components/heritage-detail/HeritageOverviewSection.tsx index bde618b..541ab04 100644 --- a/client/src/app/features/top/components/heritage-detail/HeritageOverviewSection.tsx +++ b/client/src/app/features/top/components/heritage-detail/HeritageOverviewSection.tsx @@ -1,13 +1,9 @@ import type { WorldHeritageDetailVm } from "../../../../../domain/types.ts"; import { textType } from "@shared/styles/typography.ts"; +import { useText } from "@shared/locale/ui-text.ts"; -export function HeritageOverViewSection({ - item, - locale, -}: { - item: WorldHeritageDetailVm; - locale: string; -}) { +export function HeritageOverViewSection({ item }: { item: WorldHeritageDetailVm }) { + const text = useText(); return (
-

Overview

+

{text.overview}

{item.unescoSiteUrl && ( @@ -26,16 +22,14 @@ export function HeritageOverViewSection({ className="shrink-0 rounded-full border border-sky-200 bg-sky-50 px-3 py-1.5 text-xs font-semibold text-sky-900 hover:bg-sky-100" > - View on UNESCO + {text.viewOnUnesco} )}
- {item.shortDescription ? ( + {item.displayDescription ? (

- {locale === "ja" && item.shortDescriptionJp - ? item.shortDescriptionJp - : item.shortDescription} + {item.displayDescription}

) : (

diff --git a/client/src/app/features/top/components/heritage-detail/HeritageSidebar.tsx b/client/src/app/features/top/components/heritage-detail/HeritageSidebar.tsx index 308fb04..bd38677 100644 --- a/client/src/app/features/top/components/heritage-detail/HeritageSidebar.tsx +++ b/client/src/app/features/top/components/heritage-detail/HeritageSidebar.tsx @@ -1,6 +1,7 @@ import type { WorldHeritageDetailVm } from "../../../../../domain/types.ts"; import { DetailHeritageMap } from "@features/top/components/heritage-detail/DetailHeritageMap.tsx"; import { HeritageMetadataList } from "./HeritageMetadataList.tsx"; +import { useText } from "@shared/locale/ui-text.ts"; const formatCriteriaInline = (criteria: string[] | undefined) => criteria?.length ? criteria.map((c) => `(${c})`).join("") : "—"; @@ -19,23 +20,24 @@ const formatLongitude = (lng: number): string => { }; export function HeritageSidebar({ item }: { item: WorldHeritageDetailVm }) { + const text = useText(); const hasCoord = item.latitude != null && item.longitude != null && !isZeroCoord(item.latitude, item.longitude); const metadataItems = [ - { label: "Country", value: item.country ?? "—" }, - ...(item.stateParty ? [{ label: "State Party", value: item.stateParty }] : []), - { label: "Category", value: item.category ?? "—" }, - { label: "Endangered", value: item.isEndangered ? "Yes" : "No" }, - { label: "Region", value: item.region ?? "—" }, - { label: "Year Inscribed", value: item.yearInscribed ?? "—" }, - { label: "Criteria", value: formatCriteriaInline(item.criteria) }, - { label: "Property Area", value: item.areaText ?? "—" }, - { label: "Buffer Zone", value: item.bufferText ?? "—" }, + { label: text.country, value: item.country ?? "—" }, + ...(item.stateParty ? [{ label: text.stateParty, value: item.stateParty }] : []), + { label: text.category, value: text.categoryLabels[item.category] ?? "—" }, + { label: text.endangered, value: item.isEndangered ? text.yes : text.no }, + { label: text.region, value: text.regionLabels[item.region] ?? "—" }, + { label: text.yearInscribed, value: item.yearInscribed ?? "—" }, + { label: text.criteria, value: formatCriteriaInline(item.criteria) }, + { label: text.propertyArea, value: item.areaText ?? "—" }, + { label: text.bufferZone, value: item.bufferText ?? "—" }, ...(hasCoord ? [ - { label: "Latitude", value: formatLatitude(item.latitude!) }, - { label: "Longitude", value: formatLongitude(item.longitude!) }, + { label: text.latitude, value: formatLatitude(item.latitude!) }, + { label: text.longitude, value: formatLongitude(item.longitude!) }, ] : []), ]; @@ -49,7 +51,7 @@ export function HeritageSidebar({ item }: { item: WorldHeritageDetailVm }) { {/* Heritage Data */}
- Heritage Data + {text.heritageData}
@@ -60,12 +62,12 @@ export function HeritageSidebar({ item }: { item: WorldHeritageDetailVm }) { href={item.unescoSiteUrl} target="_blank" rel="noreferrer noopener" - aria-label="View on UNESCO" + aria-label={text.viewOnUnesco} className="mt-4 inline-flex w-full items-center justify-center rounded-xl border border-sky-200 bg-sky-50 px-3 py-2 text-sm font-semibold text-sky-900 hover:bg-sky-100" > - View on UNESCO + {text.viewOnUnesco} )}
diff --git a/client/src/app/features/top/containers/__tests__/breadcrumbs.integration.test.tsx b/client/src/app/features/top/containers/__tests__/breadcrumbs.integration.test.tsx index 0a69a15..3a63a6c 100644 --- a/client/src/app/features/top/containers/__tests__/breadcrumbs.integration.test.tsx +++ b/client/src/app/features/top/containers/__tests__/breadcrumbs.integration.test.tsx @@ -14,6 +14,11 @@ jest.mock("@features/heritages/mappers/to-world-heritage-detail-vm", () => ({ toWorldHeritageDetailVm: (x: unknown) => x, })); +jest.mock("@shared/locale/LocaleHooks", () => ({ + __esModule: true, + useLocale: () => ({ locale: "en", setLocale: jest.fn(), toggleLocale: jest.fn() }), +})); + jest.mock("../../apis", () => ({ fetchWorldHeritageDetail: jest.fn(), })); diff --git a/client/src/app/features/top/containers/__tests__/top-page-container.test.tsx b/client/src/app/features/top/containers/__tests__/top-page-container.test.tsx index c60b57d..c4c5aab 100644 --- a/client/src/app/features/top/containers/__tests__/top-page-container.test.tsx +++ b/client/src/app/features/top/containers/__tests__/top-page-container.test.tsx @@ -24,30 +24,54 @@ jest.mock("../../components/HeritageSubHeader", () => ({ }, })); +jest.mock("../../components/HeritageList", () => ({ + __esModule: true, + HeritageList: function MockHeritageList(props: { + items: ReadonlyArray; + onClickItem?: (id: number) => void; + }) { + return ( +
    + {props.items.map((it) => ( +
  • + +
  • + ))} +
+ ); + }, +})); + +jest.mock("../../components/TopPageTitleBar", () => ({ + __esModule: true, + TopPageTitleBar: function MockTopPageTitleBar() { + return
; + }, +})); + +jest.mock("../../components/TopPagePagination", () => ({ + __esModule: true, + TopPagePagination: function MockTopPagePagination() { + return null; + }, +})); + jest.mock("../../components/TopPage", () => ({ __esModule: true, default: function MockTopPage(props: { - header: React.ReactNode; - items: ReadonlyArray; - onClickItem: (id: number) => void; - onReload: () => void; - currentPage: number; - lastPage: number; - onChangePage: (p: number) => void; - paginationDisabled?: boolean; + titleBar: React.ReactNode; + header?: React.ReactNode; + content: React.ReactNode; + pagination?: React.ReactNode; }) { return (
+ {props.titleBar}
{props.header}
-
    - {props.items.map((it) => ( -
  • - -
  • - ))} -
+ {props.content} + {props.pagination}
); }, diff --git a/client/src/app/features/top/containers/__tests__/world-heritage-detail-container.test.tsx b/client/src/app/features/top/containers/__tests__/world-heritage-detail-container.test.tsx index d84a9bb..31b2469 100644 --- a/client/src/app/features/top/containers/__tests__/world-heritage-detail-container.test.tsx +++ b/client/src/app/features/top/containers/__tests__/world-heritage-detail-container.test.tsx @@ -153,6 +153,8 @@ describe("WorldHeritageDetailContainer", () => { thumbnailUrl: null, title: "Kyoto", subtitle: "Japan · Asia", + displaySubName: null, + displayDescription: "dummy", areaText: "—", bufferText: "—", criteriaText: "", diff --git a/client/src/app/features/top/containers/top-page-container.tsx b/client/src/app/features/top/containers/top-page-container.tsx index 446bb07..9b4cd3b 100644 --- a/client/src/app/features/top/containers/top-page-container.tsx +++ b/client/src/app/features/top/containers/top-page-container.tsx @@ -3,12 +3,16 @@ import { useLocation, useNavigate } from "react-router-dom"; import TopPage from "../components/TopPage"; import { useTopPage } from "../hooks/use-top-page"; import { SearchHeritageFormContainer } from "@features/search/containers/search-heritage-form-container"; +import { TopPageTitleBar } from "../components/TopPageTitleBar"; +import { HeritageList } from "../components/HeritageList"; +import { TopPagePagination } from "../components/TopPagePagination"; import type { IdSortOption } from "../../../../domain/types"; import { parseHeritageSearchParams } from "@features/search/mapper/search-heritages.params"; import { DEFAULT_HERITAGE_SEARCH_PARAMS as SEARCH_PARAMS } from "@features/search/mapper/search-heritage.types"; const DEFAULT_TOP_PER_PAGE = 30; const DEFAULT_ORDER: IdSortOption = "asc"; +const PER_PAGE_OPTIONS = [10, 30, 50, 70] as const; export default function TopPageContainer(): React.ReactElement { const location = useLocation(); @@ -111,19 +115,22 @@ export default function TopPageContainer(): React.ReactElement { return ( + } header={header} - items={items} - onClickItem={handleClickItem} - onReload={reload} - currentPage={currentPage} - perPage={perPage} - order={order} - onChangeOrder={handleChangeOrder} - lastPage={pagination.last_page} - onChangePage={handleChangePage} - paginationDisabled={isLoading} - onChangePerPage={handleChangePerPage} - perPageOptions={[10, 30, 50, 70]} + content={} + pagination={ + + } /> ); } diff --git a/client/src/app/features/top/containers/world-heritage-detail-container.tsx b/client/src/app/features/top/containers/world-heritage-detail-container.tsx index 9c78c75..0e44ddc 100644 --- a/client/src/app/features/top/containers/world-heritage-detail-container.tsx +++ b/client/src/app/features/top/containers/world-heritage-detail-container.tsx @@ -1,30 +1,12 @@ -import { useEffect, useMemo, useContext } from "react"; -import { useParams, useSearchParams, useNavigate } from "react-router-dom"; +import { useEffect, useContext } from "react"; +import { useParams, useNavigate } from "react-router-dom"; import { useWorldHeritageDetail } from "../hooks/use-world-heritage-detail"; import { HeritageDetailLayout } from "../components/heritage-detail/HeritageDetailLayout"; -import { LOCALES, type Locale } from "../../../../domain/criteria"; import BreadcrumbContext from "@features/breadcrumbs/BreadCrumbProvider"; -function resolveLocale(raw: string | null): Locale { - if (!raw) return "en"; - return (LOCALES as readonly string[]).includes(raw) ? (raw as Locale) : "en"; -} - export function WorldHeritageDetailContainer() { const { id } = useParams<{ id: string }>(); const navigate = useNavigate(); - const [searchParams, setSearchParams] = useSearchParams(); - const locale = useMemo(() => resolveLocale(searchParams.get("lang")), [searchParams]); - - const toggleLanguageLocation = () => { - const theOther = locale === "ja" ? "en" : "ja"; - setSearchParams((prev) => { - const nowLocal = new URLSearchParams(prev); - nowLocal.set("lang", theOther); - - return nowLocal; - }); - }; useEffect(() => { if (!id) navigate("/heritages", { replace: true }); @@ -61,5 +43,5 @@ export function WorldHeritageDetailContainer() { ); } - return ; + return ; } diff --git a/client/src/app/features/top/hooks/__tests__/use-top-page.test.ts b/client/src/app/features/top/hooks/__tests__/use-top-page.test.ts index e906a98..e47fef9 100644 --- a/client/src/app/features/top/hooks/__tests__/use-top-page.test.ts +++ b/client/src/app/features/top/hooks/__tests__/use-top-page.test.ts @@ -16,6 +16,11 @@ jest.mock("@features/heritages/mappers/to-world-heritage-vm", () => ({ toWorldHeritageListVm: jest.fn(), })); +jest.mock("@shared/locale/LocaleHooks", () => ({ + __esModule: true, + useLocale: () => ({ locale: "en", setLocale: jest.fn(), toggleLocale: jest.fn() }), +})); + type Deferred = { promise: Promise; resolve: (v: T) => void; @@ -57,7 +62,7 @@ const fetchTopPageMock = fetchTopPage as unknown as jest.MockedFunction< (args: FetchArgs) => Promise> >; const toWorldHeritageListVmMock = toWorldHeritageListVm as unknown as jest.MockedFunction< - (dtoList: unknown[]) => unknown[] + (dtoList: unknown[], locale: "en" | "ja") => unknown[] >; describe("useTopPage", () => { @@ -117,7 +122,7 @@ describe("useTopPage", () => { expect(call.signal).toBeDefined(); expect(toWorldHeritageListVmMock).toHaveBeenCalledTimes(1); - expect(toWorldHeritageListVmMock).toHaveBeenCalledWith(rawItems); + expect(toWorldHeritageListVmMock).toHaveBeenCalledWith(rawItems, "en"); }); test("reload は in-flight リクエストを abort して再度発火する(1本目は pending のまま)", async () => { diff --git a/client/src/app/features/top/hooks/__tests__/use-world-heritage-detail.test.ts b/client/src/app/features/top/hooks/__tests__/use-world-heritage-detail.test.ts index 8b8b826..255f4a0 100644 --- a/client/src/app/features/top/hooks/__tests__/use-world-heritage-detail.test.ts +++ b/client/src/app/features/top/hooks/__tests__/use-world-heritage-detail.test.ts @@ -10,6 +10,11 @@ jest.mock("@features/heritages/mappers/to-world-heritage-detail-vm", () => ({ toWorldHeritageDetailVm: jest.fn(), })); +jest.mock("@shared/locale/LocaleHooks", () => ({ + __esModule: true, + useLocale: () => ({ locale: "en", setLocale: jest.fn(), toggleLocale: jest.fn() }), +})); + import { renderHook, act, waitFor } from "@testing-library/react"; import { describe, test, expect, beforeEach } from "@jest/globals"; import { useWorldHeritageDetail } from "../use-world-heritage-detail"; @@ -44,7 +49,7 @@ type FetchFn = (id: string, opts?: { signal?: AbortSignal }) => Promise const fetchWorldHeritageDetailMock = fetchWorldHeritageDetail as unknown as jest.MockedFunction; -type MapFn = (dto: unknown) => WorldHeritageDetailVm; +type MapFn = (dto: unknown, locale: "en" | "ja") => WorldHeritageDetailVm; const toWorldHeritageDetailVmMock = toWorldHeritageDetailVm as unknown as jest.MockedFunction; @@ -133,6 +138,8 @@ describe("useWorldHeritageDetail", () => { images: [], title: raw.official_name, subtitle: "Japan · Asia", + displaySubName: null, + displayDescription: raw.short_description, areaText: "—", bufferText: "—", criteriaText: "ii, iv", @@ -164,7 +171,7 @@ describe("useWorldHeritageDetail", () => { }), ); expect(toWorldHeritageDetailVmMock).toHaveBeenCalledTimes(1); - expect(toWorldHeritageDetailVmMock).toHaveBeenCalledWith(raw); + expect(toWorldHeritageDetailVmMock).toHaveBeenCalledWith(raw, "en"); }); test("id が null の場合: errorにせず、API は呼ばれない", async () => { @@ -237,6 +244,8 @@ describe("useWorldHeritageDetail", () => { images: [], title: "Ok", subtitle: "Japan · Asia", + displaySubName: null, + displayDescription: "dummy", areaText: "—", bufferText: "—", criteriaText: "", diff --git a/client/src/app/features/top/hooks/use-top-page.ts b/client/src/app/features/top/hooks/use-top-page.ts index 416c687..5142efc 100644 --- a/client/src/app/features/top/hooks/use-top-page.ts +++ b/client/src/app/features/top/hooks/use-top-page.ts @@ -9,6 +9,7 @@ import type { WorldHeritageVm, } from "../../../../domain/types"; import { fetchTopPage } from "@features/top/apis"; +import { useLocale } from "@shared/locale/LocaleHooks.ts"; type State = { data: WorldHeritageVm[]; @@ -36,6 +37,7 @@ const initialPagination: Pagination = { export function useTopPage(args: { currentPage: number; perPage: number; order?: IdSortOption }) { const { currentPage, perPage, order = "asc" } = args; + const { locale } = useLocale(); const [state, setState] = React.useState({ data: [], @@ -81,7 +83,7 @@ export function useTopPage(args: { currentPage: number; perPage: number; order?: if (!mountedRef.current) return; if (abortController.signal.aborted) return; - const vmList = toWorldHeritageListVm(res.items); + const vmList = toWorldHeritageListVm(res.items, locale); setState({ data: vmList, @@ -109,7 +111,7 @@ export function useTopPage(args: { currentPage: number; perPage: number; order?: })); }); }, - [], + [locale], ); React.useEffect(() => { diff --git a/client/src/app/features/top/hooks/use-world-heritage-detail.ts b/client/src/app/features/top/hooks/use-world-heritage-detail.ts index 4aa5b7e..cde600f 100644 --- a/client/src/app/features/top/hooks/use-world-heritage-detail.ts +++ b/client/src/app/features/top/hooks/use-world-heritage-detail.ts @@ -2,6 +2,7 @@ import * as React from "react"; import { toWorldHeritageDetailVm } from "@features/heritages/mappers/to-world-heritage-detail-vm"; import type { WorldHeritageDetailVm } from "../../../../domain/types.ts"; import { fetchWorldHeritageDetail } from "../apis"; +import { useLocale } from "@shared/locale/LocaleHooks.ts"; function isAbortError(err: unknown): boolean { if (typeof err !== "object" || err === null) return false; @@ -12,6 +13,7 @@ function isAbortError(err: unknown): boolean { } export function useWorldHeritageDetail(id: string | null | undefined) { + const { locale } = useLocale(); const [state, setState] = React.useState<{ data: WorldHeritageDetailVm | null; loading: boolean; @@ -45,7 +47,7 @@ export function useWorldHeritageDetail(id: string | null | undefined) { setState((state) => ({ ...state, loading: true, error: null })); fetchWorldHeritageDetail(id, { signal: controller.signal }) - .then(toWorldHeritageDetailVm) + .then((dto) => toWorldHeritageDetailVm(dto, locale)) .then((vm) => { // prepare for the next request if (reqId !== reqIdRef.current) return; @@ -56,7 +58,7 @@ export function useWorldHeritageDetail(id: string | null | undefined) { if (reqId !== reqIdRef.current) return; setState({ data: null, loading: false, error: err }); }); - }, [id]); + }, [id, locale]); React.useEffect(() => { load(); diff --git a/client/src/app/routes/AppRoutes.tsx b/client/src/app/routes/AppRoutes.tsx index 4b7401c..d0cc198 100644 --- a/client/src/app/routes/AppRoutes.tsx +++ b/client/src/app/routes/AppRoutes.tsx @@ -3,16 +3,19 @@ import TopPageContainer from "@features/top/containers/top-page-container.tsx"; import { WorldHeritageDetailContainer } from "@features/top/containers/world-heritage-detail-container.tsx"; import { SearchHeritageResultsContainer } from "@features/search/containers/search-heritage-result-container.tsx"; import { BreadcrumbProvider } from "@features/breadcrumbs/BreadCrumbProvider.tsx"; +import { LocaleProvider } from "@shared/locale/LocaleProvider.tsx"; export function AppRoutes() { return ( - - - } /> - } /> - } /> - } /> - - + + + + } /> + } /> + } /> + } /> + + + ); } diff --git a/client/src/domain/types.ts b/client/src/domain/types.ts index 6c914c2..f874aba 100644 --- a/client/src/domain/types.ts +++ b/client/src/domain/types.ts @@ -140,6 +140,8 @@ export type WorldHeritageVm = { images: WorldHeritageImageVm[]; title: string; subtitle: string; + displaySubName: string | null; + displayDescription: string; areaText: string; bufferText: string; criteriaText: string; diff --git a/client/src/locals/en/ui.json b/client/src/locals/en/ui.json new file mode 100644 index 0000000..81f8f5e --- /dev/null +++ b/client/src/locals/en/ui.json @@ -0,0 +1,51 @@ +{ + "heritageCategory": "Heritage Category", + "criteria": "Criteria", + "viewDetails": "View details", + "danger": "DANGER", + "noImage": "No image", + "noOverview": "No overview available.", + "region": "Region", + "category": "Category", + "yearInscribed": "Year Inscribed", + "description": "Description", + "maps": "Maps", + "gallery": "Gallery", + "overview": "Overview", + "viewOnUnesco": "View on UNESCO", + "heritageData": "Heritage Data", + "country": "Country", + "stateParty": "State Party", + "endangered": "Endangered", + "propertyArea": "Property Area", + "bufferZone": "Buffer Zone", + "latitude": "Latitude", + "longitude": "Longitude", + "yes": "Yes", + "no": "No", + "appTitle": "World Heritage", + "appTagline": "Learn by searching and comparing sites.", + "ascending": "Ascending", + "descending": "Descending", + "reload": "Reload", + "sortById": "Sort by ID", + "all": "All", + "keyword": "Keyword", + "keywordPlaceholder": "Name / Country", + "yearFrom": "From", + "yearTo": "To", + "search": "Search", + "categoryLabels": { + "Cultural": "Cultural", + "Natural": "Natural", + "Mixed": "Mixed" + }, + "regionLabels": { + "Africa": "Africa", + "Asia": "Asia", + "Europe": "Europe", + "North America": "North America", + "South America": "South America", + "Oceania": "Oceania" + } +} diff --git a/client/src/locals/ja/ui.json b/client/src/locals/ja/ui.json new file mode 100644 index 0000000..7ba0b60 --- /dev/null +++ b/client/src/locals/ja/ui.json @@ -0,0 +1,51 @@ +{ + "heritageCategory": "遺産カテゴリ", + "criteria": "登録基準", + "viewDetails": "詳細を見る", + "danger": "危機", + "noImage": "画像なし", + "noOverview": "概要はありません。", + "region": "地域", + "category": "分類", + "yearInscribed": "登録年", + "description": "説明", + "maps": "地図", + "gallery": "ギャラリー", + "overview": "概要", + "viewOnUnesco": "UNESCO で見る", + "heritageData": "遺産データ", + "country": "保有国", + "stateParty": "締約国", + "endangered": "危機遺産", + "propertyArea": "遺産の面積", + "bufferZone": "緩衝地帯", + "latitude": "緯度", + "longitude": "経度", + "yes": "はい", + "no": "いいえ", + "appTitle": "World Heritage", + "appTagline": "検索・比較で世界遺産を学ぶ。", + "ascending": "昇順", + "descending": "降順", + "reload": "再読み込み", + "sortById": "ID で並び替え", + "all": "すべて", + "keyword": "キーワード", + "keywordPlaceholder": "名称・国名", + "yearFrom": "から", + "yearTo": "まで", + "search": "検索", + "categoryLabels": { + "Cultural": "文化遺産", + "Natural": "自然遺産", + "Mixed": "複合遺産" + }, + "regionLabels": { + "Africa": "アフリカ", + "Asia": "アジア", + "Europe": "ヨーロッパ", + "North America": "北アメリカ", + "South America": "南アメリカ", + "Oceania": "オセアニア" + } +} diff --git a/client/src/shared/locale/LocaleHooks.ts b/client/src/shared/locale/LocaleHooks.ts new file mode 100644 index 0000000..7f0e58b --- /dev/null +++ b/client/src/shared/locale/LocaleHooks.ts @@ -0,0 +1,8 @@ +import { useContext } from "react"; +import LocaleContext from "./LocaleProvider.tsx"; + +export const useLocale = () => { + const context = useContext(LocaleContext); + if (!context) throw new Error("useLocale must be used within LocaleProvider"); + return context; +}; diff --git a/client/src/shared/locale/LocaleProvider.tsx b/client/src/shared/locale/LocaleProvider.tsx new file mode 100644 index 0000000..cda8a53 --- /dev/null +++ b/client/src/shared/locale/LocaleProvider.tsx @@ -0,0 +1,52 @@ +import React, { createContext, useCallback, useMemo } from "react"; +import type { ReactNode } from "react"; +import { useSearchParams } from "react-router-dom"; +import { LOCALES, type Locale } from "../../domain/criteria.ts"; + +const LocaleContext = createContext< + | { + locale: Locale; + setLocale: (locale: Locale) => void; + toggleLocale: () => void; + } + | undefined +>(undefined); + +const isLocale = (value: string): value is Locale => LOCALES.some((l) => l === value); + +const resolveLocale = (raw: string | null): Locale => { + if (!raw) return "en"; + return isLocale(raw) ? raw : "en"; +}; + +export const LocaleProvider: React.FC<{ children: ReactNode }> = ({ children }) => { + const [searchParams, setSearchParams] = useSearchParams(); + const locale = useMemo(() => resolveLocale(searchParams.get("lang")), [searchParams]); + + const setLocale = useCallback( + (next: Locale) => { + setSearchParams( + (prev) => { + const updated = new URLSearchParams(prev); + updated.set("lang", next); + return updated; + }, + { replace: false }, + ); + }, + [setSearchParams], + ); + + const toggleLocale = useCallback(() => { + setLocale(locale === "ja" ? "en" : "ja"); + }, [locale, setLocale]); + + const value = useMemo( + () => ({ locale, setLocale, toggleLocale }), + [locale, setLocale, toggleLocale], + ); + + return {children}; +}; + +export default LocaleContext; diff --git a/client/src/shared/locale/LocaleToggle.tsx b/client/src/shared/locale/LocaleToggle.tsx new file mode 100644 index 0000000..299f10e --- /dev/null +++ b/client/src/shared/locale/LocaleToggle.tsx @@ -0,0 +1,15 @@ +import { useLocale } from "./LocaleHooks.ts"; + +export function LocaleToggle() { + const { locale, toggleLocale } = useLocale(); + return ( + + ); +} diff --git a/client/src/shared/locale/ui-text.ts b/client/src/shared/locale/ui-text.ts new file mode 100644 index 0000000..29a9c43 --- /dev/null +++ b/client/src/shared/locale/ui-text.ts @@ -0,0 +1,13 @@ +import en from "../../locals/en/ui.json"; +import ja from "../../locals/ja/ui.json"; +import { type Locale } from "../../domain/criteria.ts"; +import { useLocale } from "./LocaleHooks.ts"; + +export type UiText = typeof en; + +const TEXTS: Record = { en, ja }; + +export const useText = (): UiText => { + const { locale } = useLocale(); + return TEXTS[locale]; +};