Skip to content
Closed
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
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ export type RecommendationItem = {
id: string;
thumbnailUrl: string;
styleName: string;
arGroomName?: string | null;
/** 서버 strategy 값을 모르면 null이라 배지를 숨긴다 */
reasonType: RecommendReasonType | null;
/** 서버 관리 난이도 값을 모르면 null이라 배지를 숨긴다 */
Expand Down
24 changes: 24 additions & 0 deletions src/renderer/src/features/recommend/ui/RecommendArButton.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<RecommendArButton groomName="guile-cut" />);
fireEvent.click(screen.getByRole("button"));
expect(navigate).toHaveBeenCalledWith("/ar?groom=guile-cut");
});
it.each([null, "", "../secret", "not a groom"])(
"매핑이 없거나 잘못되면 다른 스타일로 이동하지 않는다: %s",
groomName => {
render(<RecommendArButton groomName={groomName} />);
fireEvent.click(screen.getByRole("button"));
expect(screen.getByRole("button").hasAttribute("disabled")).toBe(true);
expect(navigate).not.toHaveBeenCalled();
}
);
16 changes: 12 additions & 4 deletions src/renderer/src/features/recommend/ui/RecommendArButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<button
type="button"
onClick={handleClick}
disabled={!canTryOn}
className={`flex w-full items-center justify-center gap-0.5 rounded-full py-1.5 max-[400px]:py-1 ${font.label.semiBold} text-[0.8125rem] max-[400px]:text-[0.75rem]`}
style={{ backgroundColor: lightTheme.fill.neutral, color: lightTheme.label.neutral }}
>
AR로 체험
{canTryOn ? "AR로 체험" : "AR 준비 중"}
<RecommendChevronRightIcon />
</button>
);
Expand Down
2 changes: 1 addition & 1 deletion src/renderer/src/features/recommend/ui/RecommendCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ export const RecommendCard = ({ rank, recommendation }: RecommendCardProps) => {

{/* 시안처럼 참고기록 바로 아래에 붙인다 */}
<div className="mt-0.5">
<RecommendArButton />
<RecommendArButton groomName={recommendation.arGroomName} />
</div>
</div>
</article>
Expand Down
65 changes: 65 additions & 0 deletions src/renderer/src/pages/ar/model/useRequestedArGroom.test.tsx
Original file line number Diff line number Diff line change
@@ -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();
});
});
30 changes: 30 additions & 0 deletions src/renderer/src/pages/ar/model/useRequestedArGroom.ts
Original file line number Diff line number Diff line change
@@ -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<string | null>(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]);
};
6 changes: 6 additions & 0 deletions src/renderer/src/pages/ar/ui/ArHairstylePage/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 {
Expand Down
13 changes: 12 additions & 1 deletion tests/ar-protocol.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: [] }), []);
});
Expand Down