diff --git a/src/renderer/src/entities/recommendation/model/recommendation.types.ts b/src/renderer/src/entities/recommendation/model/recommendation.types.ts index d6f1595a..1087e659 100644 --- a/src/renderer/src/entities/recommendation/model/recommendation.types.ts +++ b/src/renderer/src/entities/recommendation/model/recommendation.types.ts @@ -5,6 +5,8 @@ export type RecommendationHairstyleApiData = { thumbnail_url?: string | null; /** AR 체험 방식. 스펙에 값이 정의돼 있지 않다 */ ar_mode?: string | null; + /** AR 서버 /grooms의 정확한 name. 표시명으로 추측하지 않는다. */ + ar_groom_name?: string | null; asset_version?: string | null; }; diff --git a/src/renderer/src/features/recommend/model/mapRecommendation.ts b/src/renderer/src/features/recommend/model/mapRecommendation.ts index 3c8966c9..c23f2ad1 100644 --- a/src/renderer/src/features/recommend/model/mapRecommendation.ts +++ b/src/renderer/src/features/recommend/model/mapRecommendation.ts @@ -56,6 +56,7 @@ const mapRecommendationItem = ( id: item.hairstyle?.hairstyle_id ?? `recommend-${index}`, thumbnailUrl: item.hairstyle?.thumbnail_url ?? "", styleName: item.hairstyle?.style_name ?? "", + arGroomName: item.hairstyle?.ar_mode === "THREE_D" ? item.hairstyle.ar_groom_name : null, // 추천 방식·난이도는 값이 확정되지 않아 모르면 배지를 숨긴다. reasonType: strategy ? (REASON_TYPE_BY_STRATEGY[strategy.toUpperCase()] ?? null) : null, riskLevel: difficulty ? (RISK_LEVEL_BY_API_VALUE[difficulty] ?? null) : null, diff --git a/src/renderer/src/features/recommend/model/types/Recommend.types.ts b/src/renderer/src/features/recommend/model/types/Recommend.types.ts index 65cd23e1..0fddbfa4 100644 --- a/src/renderer/src/features/recommend/model/types/Recommend.types.ts +++ b/src/renderer/src/features/recommend/model/types/Recommend.types.ts @@ -6,6 +6,7 @@ export type RecommendationItem = { id: string; thumbnailUrl: string; styleName: string; + arGroomName?: string | null; /** 서버 strategy 값을 모르면 null이라 배지를 숨긴다 */ reasonType: RecommendReasonType | null; /** 서버 관리 난이도 값을 모르면 null이라 배지를 숨긴다 */ diff --git a/src/renderer/src/features/recommend/ui/RecommendArButton.test.tsx b/src/renderer/src/features/recommend/ui/RecommendArButton.test.tsx new file mode 100644 index 00000000..ff9cb94d --- /dev/null +++ b/src/renderer/src/features/recommend/ui/RecommendArButton.test.tsx @@ -0,0 +1,24 @@ +// @vitest-environment jsdom +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, expect, it, vi } from "vitest"; +import { RecommendArButton } from "./RecommendArButton"; +const navigate = vi.hoisted(() => vi.fn()); +vi.mock("react-router-dom", () => ({ useNavigate: () => navigate })); +afterEach(() => { + cleanup(); + navigate.mockClear(); +}); +it("선택한 추천의 정확한 groom을 전달한다", () => { + render(); + fireEvent.click(screen.getByRole("button")); + expect(navigate).toHaveBeenCalledWith("/ar?groom=guile-cut"); +}); +it.each([null, "", "../secret", "not a groom"])( + "매핑이 없거나 잘못되면 다른 스타일로 이동하지 않는다: %s", + groomName => { + render(); + fireEvent.click(screen.getByRole("button")); + expect(screen.getByRole("button").hasAttribute("disabled")).toBe(true); + expect(navigate).not.toHaveBeenCalled(); + } +); diff --git a/src/renderer/src/features/recommend/ui/RecommendArButton.tsx b/src/renderer/src/features/recommend/ui/RecommendArButton.tsx index 9b4c059c..3231844b 100644 --- a/src/renderer/src/features/recommend/ui/RecommendArButton.tsx +++ b/src/renderer/src/features/recommend/ui/RecommendArButton.tsx @@ -3,22 +3,30 @@ import { font, lightTheme } from "@heddy/design-tokens"; import { RecommendChevronRightIcon } from "@/features/recommend/ui/icons/RecommendChevronRightIcon"; -/** AR 헤어스타일 체험 탭(/ar)으로 이동한다 */ -export const RecommendArButton = () => { +interface RecommendArButtonProps { + groomName?: string | null; +} + +/** 선택한 추천 스타일을 AR 화면에 전달한다. */ +export const RecommendArButton = ({ groomName }: RecommendArButtonProps) => { const navigate = useNavigate(); + const canTryOn = Boolean(groomName && /^[a-z0-9][a-z0-9-]{0,79}$/.test(groomName)); + const handleClick = () => { - navigate("/ar"); + if (!canTryOn || !groomName) return; + navigate(`/ar?${new URLSearchParams({ groom: groomName })}`); }; return ( ); diff --git a/src/renderer/src/features/recommend/ui/RecommendCard.tsx b/src/renderer/src/features/recommend/ui/RecommendCard.tsx index cf04231f..1abc7bfd 100644 --- a/src/renderer/src/features/recommend/ui/RecommendCard.tsx +++ b/src/renderer/src/features/recommend/ui/RecommendCard.tsx @@ -75,7 +75,7 @@ export const RecommendCard = ({ rank, recommendation }: RecommendCardProps) => { {/* 시안처럼 참고기록 바로 아래에 붙인다 */}
- +
diff --git a/src/renderer/src/pages/ar/model/useRequestedArGroom.test.tsx b/src/renderer/src/pages/ar/model/useRequestedArGroom.test.tsx new file mode 100644 index 00000000..77c232bc --- /dev/null +++ b/src/renderer/src/pages/ar/model/useRequestedArGroom.test.tsx @@ -0,0 +1,65 @@ +// @vitest-environment jsdom +import { act, renderHook } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { useRequestedArGroom } from "./useRequestedArGroom"; + +const state = vi.hoisted(() => ({ + location: { pathname: "/ar", search: "?groom=guile-cut", key: "first" }, + toast: vi.fn(), +})); +vi.mock("react-router-dom", () => ({ useLocation: () => state.location })); +vi.mock("@/shared", () => ({ showInfoToast: state.toast })); +const options = [ + { name: "guile-cut", label: "가일컷" }, + { name: "bob-cut", label: "보브컷" }, +]; + +beforeEach(() => { + state.location = { pathname: "/ar", search: "?groom=guile-cut", key: "first" }; + state.toast.mockClear(); +}); + +describe("추천 스타일 AR 진입", () => { + it("비동기 목록을 기다리고 선택 후 사용자의 변경을 덮어쓰지 않는다", () => { + const select = vi.fn(); + const { rerender } = renderHook( + ({ pending }) => useRequestedArGroom(pending ? [] : options, pending, select), + { + initialProps: { pending: true }, + } + ); + expect(select).not.toHaveBeenCalled(); + rerender({ pending: false }); + expect(select).toHaveBeenCalledWith("guile-cut"); + rerender({ pending: false }); + expect(select).toHaveBeenCalledTimes(1); + }); + + it("캐시된 AR 페이지에 다른 추천으로 재진입하면 새 스타일을 적용한다", () => { + const select = vi.fn(); + const { rerender } = renderHook(() => useRequestedArGroom(options, false, select)); + act(() => { + state.location = { pathname: "/ar", search: "?groom=bob-cut", key: "second" }; + }); + rerender(); + expect(select.mock.calls).toEqual([["guile-cut"], ["bob-cut"]]); + }); + + it("존재하지 않는 스타일은 원본 화면으로 돌리고 안내한다", () => { + state.location.search = "?groom=missing"; + const select = vi.fn(); + renderHook(() => useRequestedArGroom(options, false, select)); + expect(select).toHaveBeenCalledExactlyOnceWith(""); + expect(state.toast).toHaveBeenCalledTimes(1); + }); + + it("일반 AR 진입이나 다른 화면에서는 자동 선택하지 않는다", () => { + state.location.search = ""; + const select = vi.fn(); + const { rerender } = renderHook(() => useRequestedArGroom(options, false, select)); + state.location = { pathname: "/recommend", search: "?groom=guile-cut", key: "other" }; + rerender(); + expect(select).not.toHaveBeenCalled(); + }); +}); diff --git a/src/renderer/src/pages/ar/model/useRequestedArGroom.ts b/src/renderer/src/pages/ar/model/useRequestedArGroom.ts new file mode 100644 index 00000000..bd595a85 --- /dev/null +++ b/src/renderer/src/pages/ar/model/useRequestedArGroom.ts @@ -0,0 +1,30 @@ +import { useEffect, useRef } from "react"; +import { useLocation } from "react-router-dom"; + +import type { ArHairstyleOption } from "@/entities/ar-groom"; +import { showInfoToast } from "@/shared"; + +/** 목록 비동기 로딩과 캐시된 페이지 재진입 모두 지원하며, 진입당 한 번만 적용한다. */ +export const useRequestedArGroom = ( + options: ArHairstyleOption[], + isPending: boolean, + selectHairstyle: (name: string) => void +) => { + const location = useLocation(); + const appliedEntry = useRef(null); + + useEffect(() => { + if (location.pathname !== "/ar" || isPending) return; + const entry = `${location.key}:${location.search}`; + if (appliedEntry.current === entry) return; + appliedEntry.current = entry; + const requested = new URLSearchParams(location.search).get("groom"); + if (!requested) return; + if (!options.some(option => option.name === requested)) { + selectHairstyle(""); + showInfoToast("추천한 스타일을 현재 AR에서 불러올 수 없어요. 다른 스타일을 선택해 주세요."); + return; + } + selectHairstyle(requested); + }, [location.key, location.pathname, location.search, isPending, options, selectHairstyle]); +}; diff --git a/src/renderer/src/pages/ar/ui/ArHairstylePage/index.tsx b/src/renderer/src/pages/ar/ui/ArHairstylePage/index.tsx index 09aaf892..3f349a9a 100644 --- a/src/renderer/src/pages/ar/ui/ArHairstylePage/index.tsx +++ b/src/renderer/src/pages/ar/ui/ArHairstylePage/index.tsx @@ -5,6 +5,7 @@ import { useEffect, useRef, useState } from "react"; import { useGetArGrooms } from "@/entities/ar-groom"; import type { ArHairstyleOption, HairstyleOptionId } from "@/entities/ar-groom"; import { useArServerConnection } from "@/features/ar-session"; +import { useRequestedArGroom } from "../../model/useRequestedArGroom"; import { useArHairstyle } from "../../model/useArHairstyle"; import { getCircularHairstyleOption, ORIGINAL_HAIRSTYLE_OPTION } from "../../model/constants"; import { cn, showInfoToast } from "@/shared"; @@ -46,6 +47,11 @@ const ArHairstylePage = () => { fitSettings, handleFitSettingsChange, } = useArHairstyle(hairstyleOptions); + useRequestedArGroom( + serverHairstyleOptions, + isGroomsPending || Boolean(groomsError), + selectHairstyle + ); const selectedHairstyle = getCircularHairstyleOption(activeHairstylePosition, hairstyleOptions); const selectedGroom = selectedHairstyle.name; const { diff --git a/tests/ar-protocol.test.ts b/tests/ar-protocol.test.ts index 5e05d745..35655232 100644 --- a/tests/ar-protocol.test.ts +++ b/tests/ar-protocol.test.ts @@ -19,7 +19,18 @@ test("groom 이름만 목록에 사용하고 GLB를 이미지로 노출하지 parseArGrooms({ grooms: [{ name: "short-swept", url: "/grooms/style.glb", n_strands: 12855 }], }), - [{ id: "short-swept", label: "short-swept" }] + [ + { + name: "short-swept", + label: "short-swept", + hairstyleId: undefined, + gender: undefined, + type: undefined, + tags: [], + thumbUrl: undefined, + viewsUrl: undefined, + }, + ] ); assert.deepEqual(parseArGrooms({ grooms: [] }), []); });