Skip to content
Open
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
21 changes: 15 additions & 6 deletions src/pages/imageSetup/components/layout/FunnelLayout.tsx
Original file line number Diff line number Diff line change
@@ -1,24 +1,35 @@
import { overlay } from 'overlay-kit';
import { useNavigate } from 'react-router-dom';

import TitleNavBar from '@components/navBar/TitleNavBar';
import Popup from '@components/overlay/popup/Popup';

import TitleNavBar from '@/shared/components/v2/navBar/TitleNavBar';

import * as styles from './FunnelLayout.css';
import {
logSelectHouseInfoClickModalContinue,
logSelectHouseInfoClickModalExit,
logSelectHouseInfoViewModal,
} from '../../utils/analytics';

type FunnelStepKey = 'FloorPlanSelect' | 'InteriorStyle' | 'ActivityInfo';

// 퍼널 스텝 별 NavBar 타이틀 매핑
const NAVBAR_TITLE_BY_STEP: Record<FunnelStepKey, string> = {
FloorPlanSelect: '공간 선택하기',
InteriorStyle: '취향 선택하기',
ActivityInfo: '가구 선택하기',
};

interface FunnelLayoutProps {
children: React.ReactNode;
currentStep: 'FloorPlanSelect' | 'InteriorStyle' | 'ActivityInfo';
currentStep: FunnelStepKey;
}

const FunnelLayout = ({ children, currentStep }: FunnelLayoutProps) => {
const navigate = useNavigate();

// TODO: 퍼널 전체 탈출 가드 useBlocker로 바꾸기
const handleBackClick = () => {
if (currentStep === 'FloorPlanSelect') {
logSelectHouseInfoViewModal();
Expand All @@ -43,10 +54,8 @@ const FunnelLayout = ({ children, currentStep }: FunnelLayoutProps) => {
return (
<div className={styles.wrapper}>
<TitleNavBar
// TODO: 각 스텝별 헤더 타이틀 설정하기
title="스타일링 이미지 생성"
isBackIcon={true}
isLoginBtn={false}
title={NAVBAR_TITLE_BY_STEP[currentStep]}
backLabel="이전"
onBackClick={
currentStep === 'FloorPlanSelect' ? handleBackClick : undefined
Comment thread
jstar000 marked this conversation as resolved.
}
Comment thread
jstar000 marked this conversation as resolved.
Expand Down
23 changes: 14 additions & 9 deletions src/pages/imageSetup/steps/interiorStyle/InteriorStyle.css.ts
Original file line number Diff line number Diff line change
@@ -1,27 +1,32 @@
import { style } from '@vanilla-extract/css';

import { zIndex } from '@styles/tokens/zIndex';
import { unitVars } from '@styles/tokensV2/unit.css';

export const container = style({
position: 'relative',
display: 'flex',
flex: 1,
flexDirection: 'column',
alignItems: 'center',
gap: unitVars.unit.gapPadding['800'],
marginBottom: '9.6rem',
padding: unitVars.unit.gapPadding['500'],
width: '100%',
});

export const headingWrapper = style({
display: 'flex',
width: '100%',
});

// 데스크탑·모바일 모두에서 44rem 가상 프레임의 우하단에 2rem씩 띄운 floating 버튼
// - 모바일(뷰포트 ≤ 44rem): right: 2rem — 뷰포트 우측에서 2rem
// - 데스크탑(뷰포트 > 44rem): right: calc((100vw - 44rem) / 2 + 2rem) — 프레임 우측에서 2rem 안쪽
// - max()로 두 경우를 한 식에 통합
export const buttonWrapper = style({
position: 'fixed',
zIndex: zIndex.button,
right: 0,
bottom: '2rem', // CtaButton 최대 너비 설정
left: 0,
display: 'flex',
justifyContent: 'center',
margin: '0 auto',
padding: '0 2rem 0 2rem',
width: '100%',
maxWidth: '44rem',
right: 'max(2rem, calc((100vw - 44rem) / 2 + 2rem))',
bottom: '2rem',
Comment thread
jstar000 marked this conversation as resolved.
});
48 changes: 23 additions & 25 deletions src/pages/imageSetup/steps/interiorStyle/InteriorStyle.tsx
Original file line number Diff line number Diff line change
@@ -1,19 +1,15 @@
// Step 3
import { useMoodBoardQuery } from '@pages/imageSetup/apis/queries/useMoodBoardQuery';
import { FUNNELHEADER_IMAGES } from '@pages/imageSetup/constants/headerImages';
import { useInteriorStyle } from '@pages/imageSetup/hooks/useInteriorStyle';
import {
logSelectMoodboardClickBtnCTA,
logSelectMoodboardClickBtnCTAInactive,
} from '@pages/imageSetup/utils/analytics';
import { logSelectMoodboardClickBtnCTA } from '@pages/imageSetup/utils/analytics';

import CtaButton from '@components/button/ctaButton/CtaButton';
import InlineError from '@components/inlineError/InlineError';
import Loading from '@components/loading/Loading';
import ActionButton from '@components/v2/button/actionButton/ActionButton';
import TextHeading from '@components/v2/textHeading/TextHeading';

import * as styles from './InteriorStyle.css';
import MoodBoard from './MoodBoard';
import FunnelHeader from '../../components/header/FunnelHeader';

import type {
CompletedInteriorStyle,
Expand All @@ -37,26 +33,22 @@ const InteriorStyle = ({ context, onNext }: InteriorStyleProps) => {
} = useMoodBoardQuery();
const images = moodBoardData?.moodBoardResponseList || [];

// CTA 버튼 클릭 핸들러
// CTA 버튼 클릭 핸들러 (현재 native disabled로 비활성 시 클릭 자체가 차단됨)
// TODO: ActionButton에 visuallyDisabled prop이 추가되면(별도 PR)
// logSelectMoodboardClickBtnCTAInactive 로깅을 다시 복원할 것
const handleCtaButtonClick = () => {
if (isDataComplete) {
// 활성 상태 버튼 클릭
logSelectMoodboardClickBtnCTA();
handleNext();
} else {
// 비활성 상태 버튼 클릭
logSelectMoodboardClickBtnCTAInactive();
}
logSelectMoodboardClickBtnCTA();
handleNext();
Comment thread
jstar000 marked this conversation as resolved.
};

return (
<div className={styles.container}>
<FunnelHeader
title={`인테리어 취향을 알려주세요`}
detail={`인테리어 취향에 맞는 이미지를\n최대 5개까지 선택해주세요.`}
currentStep={3}
image={FUNNELHEADER_IMAGES[3]}
/>
<div className={styles.headingWrapper}>
<TextHeading
title="인테리어 취향을 알려주세요"
caption={`인테리어 취향에 맞는 이미지를\n최대 5개까지 선택해주세요.`}
/>
</div>

{isError ? (
<InlineError
Expand All @@ -73,9 +65,15 @@ const InteriorStyle = ({ context, onNext }: InteriorStyleProps) => {
onImageSelect={handleImageSelect}
/>
<div className={styles.buttonWrapper}>
<CtaButton isActive={isDataComplete} onClick={handleCtaButtonClick}>
주요 활동 선택하기
</CtaButton>
<ActionButton
variant="solid"
color="primary"
size="2XL"
disabled={!isDataComplete}
onClick={handleCtaButtonClick}
>
다음
</ActionButton>
</div>
</>
)}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@ export const container = style({
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: '2rem',
width: '100%',
height: '100%',
});
Expand Down
5 changes: 2 additions & 3 deletions src/pages/imageSetup/steps/interiorStyle/MoodBoard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,8 @@ import {
type MoodBoardImageItem,
} from '@pages/imageSetup/types/apis/interiorStyle';

import CardImage from '@components/card/cardImage/CardImage';

import * as styles from './MoodBoard.css';
import MoodboardCard from './MoodboardCard';

interface MoodBoardProps {
images: MoodBoardImageItem[];
Expand Down Expand Up @@ -48,7 +47,7 @@ const MoodBoard = ({
!isSelected;

return (
<CardImage
<MoodboardCard
key={image.id}
src={image.imageUrl}
alt={'선택 가능한 무드보드 이미지'}
Expand Down
114 changes: 114 additions & 0 deletions src/pages/imageSetup/steps/interiorStyle/MoodboardCard.css.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import { style } from '@vanilla-extract/css';
import { recipe } from '@vanilla-extract/recipes';

import { colorVars } from '@styles/tokensV2/color.css';
import { fontVars } from '@styles/tokensV2/font.css';
import { unitVars } from '@styles/tokensV2/unit.css';

// 카드 컨테이너
// - default: 1px secondary 테두리, 둥근 사각형
// - selected: 1.5px strong 테두리
// - disabled: opacity 0.5
export const card = recipe({
base: {
aspectRatio: '164 / 240',
position: 'relative',
transition: 'transform 120ms ease',
border: `1px solid ${colorVars.color.border.secondary}`,
borderRadius: unitVars.unit.radius['600'],
backgroundColor: 'transparent',
cursor: 'pointer',
padding: 0,
width: '100%',
overflow: 'hidden',
font: 'inherit',
selectors: {
'&:active': {
transform: 'scale(0.95)',
},
},
},
variants: {
state: {
default: {},
selected: {
border: `1.5px solid ${colorVars.color.border.strong}`,
},
disabled: {
opacity: 0.5,
cursor: 'not-allowed',
selectors: {
'&:active': {
transform: 'none',
},
},
},
},
},
defaultVariants: {
state: 'default',
},
});

export const image = style({
display: 'block',
objectFit: 'cover',
objectPosition: 'center',
width: '100%',
height: '100%',
userSelect: 'none',
});

// 체크박스 — 우상단에 패딩 16px 영역 안에 위치
export const checkbox = recipe({
base: {
position: 'absolute',
top: 0,
right: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
border: 'none',
background: 'transparent',
pointerEvents: 'none',
padding: unitVars.unit.gapPadding['400'],
},
variants: {
state: {
// default: 흰 stroke 빈 동그라미 20×20
default: {},
// selected: 다크 배경 24×24, 흰 숫자
selected: {},
},
},
});
Comment on lines +76 to +84
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

주석과 실제 구현 불일치 + 빈 variants 정리 필요

Line 78의 주석에 "20×20"이라고 되어 있지만, circle 레시피의 실제 구현은 2.4rem (24px)입니다. PR 목표에 명시된 "24×24로 통일" 스펙과 코드는 일치하므로, 주석을 수정해 주세요.

또한 checkbox 레시피의 state variants가 모두 빈 객체입니다. 실제 스타일 차이가 circle 레시피에서만 처리된다면, 불필요한 variants를 제거하거나 주석으로 의도를 명확히 해주세요.

💡 제안된 수정
 export const checkbox = recipe({
   base: {
     position: 'absolute',
     top: 0,
     right: 0,
     display: 'flex',
     alignItems: 'center',
     justifyContent: 'center',
     border: 'none',
     background: 'transparent',
     pointerEvents: 'none',
     padding: unitVars.unit.gapPadding['400'],
   },
   variants: {
     state: {
-      // default: 흰 stroke 빈 동그라미 20×20
+      // 스타일 차이는 circle 레시피에서 처리
       default: {},
-      // selected: 다크 배경 24×24, 흰 숫자
       selected: {},
     },
   },
 });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/pages/imageSetup/steps/interiorStyle/MoodboardCard.css.ts` around lines
76 - 84, 주석과 구현 불일치 및 불필요한 빈 variants 정리:
src/pages/imageSetup/steps/interiorStyle/MoodboardCard.css.ts에서 circle 레시피의
주석(현재 "20×20")을 PR 스펙과 실제 구현(2.4rem / 24×24)에 맞게 수정하고, checkbox 레시피의
variants.state에 빈 객체들(예: default, selected)이 남아있는 경우 실제 스타일 차이가 checkbox가 아닌
circle에서만 처리된다면 해당 빈 variants를 제거하거나 checkbox 레시피에 의도를 설명하는 주석을 추가해 불필요한 빈
variants가 남지 않도록 정리하세요 (참조 심볼: circle 레시피, checkbox 레시피, variants.state).


// 체크박스 안의 동그라미
export const circle = recipe({
base: {
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
transition:
'width 120ms ease, height 120ms ease, background-color 120ms ease',
borderRadius: unitVars.unit.radius.full,
},
variants: {
state: {
default: {
border: `1px solid ${colorVars.color.border.secondary}`,
backgroundColor: `${colorVars.color.fill.inverseSecondary}`,
width: '2.4rem',
height: '2.4rem',
},
selected: {
border: 'none',
backgroundColor: colorVars.color.fill.strong,
width: '2.4rem',
height: '2.4rem',
color: colorVars.color.text.inverse,
...fontVars.font.body_m_14,
},
},
},
});
45 changes: 45 additions & 0 deletions src/pages/imageSetup/steps/interiorStyle/MoodboardCard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import * as styles from './MoodboardCard.css';

interface MoodboardCardProps
extends Omit<React.ComponentPropsWithoutRef<'button'>, 'children' | 'type'> {
src: string;
alt: string;
selectOrder?: number;
}

const MoodboardCard = ({
src,
alt,
selectOrder = 0,
disabled = false,
className,
...rest
}: MoodboardCardProps) => {
const isSelected = selectOrder > 0;

// 카드 컨테이너 스타일 상태 (disabled가 우선)
const cardState = disabled ? 'disabled' : isSelected ? 'selected' : 'default';
// 체크박스(index 동그라미) 스타일 상태 (선택 여부만 반영)
const checkState = isSelected ? 'selected' : 'default';

return (
<button
type="button"
disabled={disabled}
aria-pressed={isSelected}
className={`${styles.card({ state: cardState })}${className ? ` ${className}` : ''}`}
{...rest}
>
<img src={src} alt={alt} className={styles.image} draggable={false} />
{!disabled && (
<span className={styles.checkbox({ state: checkState })}>
<span className={styles.circle({ state: checkState })}>
{isSelected ? selectOrder : ''}
</span>
</span>
)}
</button>
);
};

export default MoodboardCard;
Loading
Loading