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: [] }), []);
});