diff --git a/.gitignore b/.gitignore index b3293c5..e62b3c2 100644 --- a/.gitignore +++ b/.gitignore @@ -32,3 +32,4 @@ dist-ssr # docker .pnpm-store +.initialized diff --git a/root/etc/s6-overlay/s6-rc.d/file-delivery/run b/root/etc/s6-overlay/s6-rc.d/file-delivery/run index bb773c4..a21f3f2 100755 --- a/root/etc/s6-overlay/s6-rc.d/file-delivery/run +++ b/root/etc/s6-overlay/s6-rc.d/file-delivery/run @@ -2,8 +2,9 @@ cd /app -if [ -f "package.json" ]; then +if [ -f ".initialized" ]; then exit 0 fi -mv /defaults/* . +cp -a /defaults/. . +touch .initialized diff --git a/src/app/router.tsx b/src/app/router.tsx index 6a358ce..5fa6880 100644 --- a/src/app/router.tsx +++ b/src/app/router.tsx @@ -6,7 +6,6 @@ import { RootLayout } from "@/layouts/RootLayout"; import { LocalAuthLayout } from "@/layouts/LocalAuthLayout"; import { ServerAuthLayout } from "@/layouts/ServerAuthLayout"; -import { HomePage } from "@/pages/home"; import { RecommendationPage } from "@/pages/recommendation"; import { UserRecommendationsPage } from "@/pages/user-recommendations"; import { OnboardingPage } from "@/pages/onboarding"; @@ -26,10 +25,6 @@ export const router = createBrowserRouter([ path: "", element: , children: [ - { - path: ROUTES.HOME, - element: - }, { path: ROUTES.SIGN_IN, element: @@ -55,7 +50,7 @@ export const router = createBrowserRouter([ element: }, { - path: ROUTES.USER_RECOMMENDATIONS, + path: ROUTES.HOME, element: }, { diff --git a/src/components/header.tsx b/src/components/header.tsx index 74a9b63..56648f3 100644 --- a/src/components/header.tsx +++ b/src/components/header.tsx @@ -1,4 +1,4 @@ -import { useNavigate } from "react-router"; +import { useNavigate, useLocation } from "react-router"; import { ArrowLeftIcon } from "lucide-react"; import { cn } from "@/lib/utils"; @@ -23,6 +23,7 @@ export function Header({ ...props }: HeaderProps) { const navigate = useNavigate(); + const { pathname } = useLocation(); const handleBack: React.MouseEventHandler = () => { const historyIndex = window.history.state?.idx; @@ -41,13 +42,15 @@ export function Header({ {...props} >
- + ); +} diff --git a/src/lib/api.ts b/src/lib/api.ts index 1fc9780..d0fa03e 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -211,6 +211,20 @@ export async function createRecommendation(data: schema.RecommendationCreateInpu }); } +/** + * 추천 정보 수정 요청 + */ +export async function updateRecommendation(taskId: string, data: schema.RecommendationUpdateInput) { + return request({ + guard: { + request: schema.recommendationUpdateInput, + }, + method: "PATCH", + url: `/recommendations/${taskId}`, + data, + }); +} + /** * 추천 생성 결과 조회 */ diff --git a/src/lib/price-unit.ts b/src/lib/price-unit.ts index 4398376..1f0aba4 100644 --- a/src/lib/price-unit.ts +++ b/src/lib/price-unit.ts @@ -43,9 +43,18 @@ export function convertToNumber(value: string) { * @returns 숫자 가격과 단위 문자열의 튜플 (예: [20, "억 원"], [500, "만 원"]) */ export function formatPriceUnit(value: number): [number, PriceUnit] { + let number: number; + let unit: PriceUnit; + if (value >= 10_000 * 10_000) { - return [value / (10_000 * 10_000), "억 원"]; + number = value / (10_000 * 10_000); + unit = "억 원"; } else { - return [value / 10_000, "만 원"]; + number = value / 10_000; + unit = "만 원"; } + + number = Math.round(number * 100) / 100; // 소수점 둘째 자리까지 반올림 + + return [number, unit]; } diff --git a/src/pages/home.tsx b/src/pages/home.tsx deleted file mode 100644 index 421b913..0000000 --- a/src/pages/home.tsx +++ /dev/null @@ -1,49 +0,0 @@ -import { useCallback } from "react"; -import { NavLink } from "react-router"; - -import { useLogoutMutation } from "@/hooks/auth"; -import { ROUTES } from "@/shared/routes"; -import { Button } from "@/components/ui/button"; - - - -/** - * 메인 페이지 컴포넌트 - */ -export function HomePage() { - const logoutMutation = useLogoutMutation(); - - const handleCacheClear = useCallback(() => { - localStorage.clear(); - sessionStorage.clear(); - logoutMutation.mutate(); - }, [logoutMutation]); - - return ( -
- 페이지 목록:
- - 로그인 페이지
- - 회원가입 페이지
- - 온보딩 페이지
- - 추천 결과 조회 페이지
- - 사용자 추천 요청 목록 페이지
- - 설정 페이지
-
-
- ); -} diff --git a/src/pages/onboarding.tsx b/src/pages/onboarding.tsx index 15f9613..84095a9 100644 --- a/src/pages/onboarding.tsx +++ b/src/pages/onboarding.tsx @@ -1,5 +1,6 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useLocation, useNavigate, useSearchParams } from "react-router"; +import { TagIcon } from "lucide-react"; import { cn } from "@/lib/utils"; import { convertToNumber } from "@/lib/price-unit"; @@ -61,19 +62,10 @@ import { Footer } from "@/components/footer"; import { Header } from "@/components/header"; import * as form from "@/components/form"; import { InfraTypeBadge } from "@/components/infra-type-badge"; +import { SchoolDistrictBox } from "@/components/school-district-box"; -/** - * 가격 슬라이드 옵션 기본값 - */ -const DEFAULT_PRICE_SLIDER_OPTIONS: PriceSliderOptions = { - min: 0, - max: 9999, - step: 0.1, - unit: "억 원", -}; - /** * 가격 정보 */ @@ -82,13 +74,23 @@ const PRICE_ITEMS: readonly PriceItem[] = [ key: "sale", icon: "🏠", label: "매매", - slider: DEFAULT_PRICE_SLIDER_OPTIONS, + slider: { + min: 0, + max: Infinity, + step: 0.1, + unit: "억 원", + }, }, { key: "jeonse", icon: "💸", label: "전세", - slider: DEFAULT_PRICE_SLIDER_OPTIONS, + slider: { + min: 0, + max: Infinity, + step: 1, + unit: "만 원", + }, }, ]; @@ -97,7 +99,7 @@ const PRICE_ITEMS: readonly PriceItem[] = [ */ const SECTION_OPTIONS: SectionOptions[] = [ { - heading: "선호 동네", + heading: "선호 동네 (선택 사항)", description: "추천 받고 싶은 동네를 선택할 수 있어요!", renderOverview: ctx => ( +

인프라 우선순위

    @@ -261,22 +266,12 @@ const SECTION_OPTIONS: SectionOptions[] = [ {ctx.schoolDistrictTypeItems.map((district) => { const isSelected = ctx.selectedSchoolDistricts.includes(district.type); return ( - + isSelected={isSelected} + /> ); })}
@@ -327,58 +322,62 @@ const SECTION_OPTIONS: SectionOptions[] = [ renderChildren: ctx => (
{ctx.priceItems.map(item => ( -
+
-
-
- - { - const parsed = Number(event.target.value); - if (Number.isNaN(parsed)) return; - - const nextMin = clampNumber(parsed, item.slider.min, item.slider.max); - const nextMax = Math.max(nextMin, item.range[1]); - item.setRange([nextMin, nextMax]); - }} - /> - - { - const parsed = Number(event.target.value); - if (Number.isNaN(parsed)) return; - - const nextMax = clampNumber(parsed, item.slider.min, item.slider.max); - const nextMin = Math.min(item.range[0], nextMax); - item.setRange([nextMin, nextMax]); - }} - /> +
+
+
+ + { + const parsed = Number(event.target.value); + if (Number.isNaN(parsed)) return; + + const nextMin = clampNumber(parsed, item.slider.min, item.slider.max); + const nextMax = Math.max(nextMin, item.range[1]); + item.setRange([nextMin, nextMax]); + }} + /> +
+
+ + { + const parsed = Number(event.target.value); + if (Number.isNaN(parsed)) return; + + const nextMax = clampNumber(parsed, item.slider.min, item.slider.max); + const nextMin = Math.min(item.range[0], nextMax); + item.setRange([nextMin, nextMax]); + }} + /> +
@@ -402,20 +401,22 @@ const SECTION_OPTIONS: SectionOptions[] = [
- { - if (value.length !== 2) return; - item.setRange(value as PriceRange); - }} - /> + {item.slider.max !== Infinity && ( + { + if (value.length !== 2) return; + item.setRange(value as PriceRange); + }} + /> + )}
))} @@ -530,13 +531,13 @@ interface SectionOptions { const createInitialOnboardingPriceState = (): OnboardingPriceState => ({ sale: { enabled: false, - range: [DEFAULT_PRICE_SLIDER_OPTIONS.min, DEFAULT_PRICE_SLIDER_OPTIONS.max], - unit: DEFAULT_PRICE_SLIDER_OPTIONS.unit, + range: [PRICE_ITEMS[0].slider.min, PRICE_ITEMS[0].slider.max], + unit: PRICE_ITEMS[0].slider.unit, }, jeonse: { enabled: false, - range: [DEFAULT_PRICE_SLIDER_OPTIONS.min, DEFAULT_PRICE_SLIDER_OPTIONS.max], - unit: DEFAULT_PRICE_SLIDER_OPTIONS.unit, + range: [PRICE_ITEMS[1].slider.min, PRICE_ITEMS[1].slider.max], + unit: PRICE_ITEMS[1].slider.unit, }, }); @@ -716,6 +717,7 @@ export function OnboardingPage() { const [overviewErrorMessages, setOverviewErrorMessages] = useState([]); const [editorErrorMessages, setEditorErrorMessages] = useState([]); const [requestErrorMessage, setRequestErrorMessage] = useState(""); + const [name, setName] = useState(""); const [committedState, setCommittedState] = useState(createOnboardingFormState( storedRegion, @@ -1172,7 +1174,7 @@ export function OnboardingPage() { try { const response = await createRecommendation({ - // name: undefined, / 입력란 추가? + name: name.trim() || undefined, regionId: committedState.region.id || null, infrastructureTypes: committedState.infraTypes.map(infra => infra.type), highSchoolIds: committedState.highSchools.map(Number), @@ -1187,11 +1189,12 @@ export function OnboardingPage() { search: `?task_id=${encodeURIComponent(response.taskId)}`, }, { replace: true, + state: { name: name.trim() || undefined }, }); } catch (error) { setRequestErrorMessage(getRequestErrorMessage(error)); } - }, [committedState, navigate, resetOnboarding, sectionContext]); + }, [committedState, name, navigate, resetOnboarding, sectionContext]); return (
@@ -1213,6 +1216,21 @@ export function OnboardingPage() { : "absolute inset-0 h-0 -translate-x-12 opacity-0 pointer-events-none", )} > +
+

추천 이름

+

이 추천에 대한 나만의 이름을 지정할 수 있어요! (선택 사항)

+ setName(e.target.value)} + leftIcon={} + /> +
+

검색 조건을 설정해 주세요

원하는 조건에 맞는 집을 찾아드려요!

@@ -1225,11 +1243,12 @@ export function OnboardingPage() { key={opts.heading} className="relative group" data-invalid={!!overviewErrorMessages[idx]} + data-required={opts.required ? true : undefined} > {opts.required && ( handleEditClick(idx)} children="수정" diff --git a/src/pages/recommendation.tsx b/src/pages/recommendation.tsx index 2145333..1958296 100644 --- a/src/pages/recommendation.tsx +++ b/src/pages/recommendation.tsx @@ -1,6 +1,6 @@ import { useRef, useMemo, useState, useEffect, useCallback } from "react"; -import { useLocation, type Location } from "react-router"; -import { LoaderIcon, SearchAlertIcon, X, MapPin, Sparkles } from "lucide-react"; +import { useNavigate, useLocation, type Location } from "react-router"; +import { TagIcon, CheckCheckIcon, SettingsIcon, LoaderIcon, SearchAlertIcon, X, MapPin, Sparkles } from "lucide-react"; import { useSearchParams } from "react-router"; import { useKakaoLoader, useMap, Map as KakaoMap, MapMarker, CustomOverlayMap } from "react-kakao-maps-sdk"; @@ -8,14 +8,16 @@ import * as env from "@/shared/env"; import type * as schema from "@/shared/schema"; import { RETRY_DELAY_MS, sleep } from "@/shared/common"; import { cn } from "@/lib/utils"; -import { getRecommendation, getRecommendationProperty } from "@/lib/api"; +import { getRecommendation, getRecommendationProperty, updateRecommendation } from "@/lib/api"; import { formatPriceUnit } from "@/lib/price-unit"; import { useIsMobile } from "@/hooks/use-mobile"; import { Button } from "@/components/ui/button"; import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area"; import { Tooltip } from "@/components/tooltip"; import { Header } from "@/components/header"; +import { FloatingLabelInput } from "@/components/input"; import { InfraTypeBadge } from "@/components/infra-type-badge"; +import { SchoolDistrictBox } from "@/components/school-district-box"; import { Drawer, DrawerTrigger, @@ -41,7 +43,9 @@ import { DialogFooter, DialogHeader, DialogTitle, + DialogTrigger, } from "@/components/ui/dialog"; +import { useSchoolDistrictTypesStore } from "@/stores/items"; @@ -53,6 +57,28 @@ const defaultPoint: schema.MapCoordinate = { lat: 33.450701, lng: 126.570667 }; +function NaverMapLink({ + latitude, + longitude, + children, + ...props +}: ( + & Omit, "href"> + & schema.Coordinate +)) { + return ( + + ); +} + + + /** * 추천 결과 요청 상태에 따른 메시지와 아이콘을 보여주는 컴포넌트 */ @@ -183,6 +209,27 @@ interface PropertyDetailViewProps { onClose?: () => void; } +function PropertyAddress({ + name, + region, + address, +}: schema.RecommendationPropertySummary) { + return ( + // 건물명 및 주소 +
+
+

+ {name} +

+
+ + {address.landLot || address.roadName || region?.name} +
+
+
+ ); +} + function PropertyDetailContent({ property, infrastructureTypes, @@ -228,17 +275,6 @@ function PropertyDetailContent({ )}> {property.infrastructure.map((infra, idx) => { if (isDetailed && 'name' in infra && 'score' in infra) { - const searches = [ - ...(property.region?.name.split(" ").slice(0, 2) ?? []), - ( - infra.label.includes("학교") ? "학교" : - infra.label.includes("병원") ? "병원" : - infra.label.includes("공원") ? "공원" : - infra.label - ), - infra.name, - ] - const isActive = infrastructureTypes?.has(infra.type); return ( @@ -255,10 +291,9 @@ function PropertyDetailContent({
- {/* 상세 내용 */} - + {children}
); } @@ -422,9 +455,13 @@ function RecommendationMap({ clickable={true} > onActiveChange(null)} - /> + > + + + )}
@@ -448,7 +485,7 @@ function RecommendationMap({ function Trophy({ rank }: { rank: number }) { const imoji = ["🥇", "🥈", "🥉"][rank - 1] || ""; return ( -

+ + {Math.round(score)}점 + + ); +} + function PriceBox({ label, price, @@ -521,7 +575,7 @@ function PropertySummaryBox(p: ( hasSalePrice: boolean; hasJeonsePrice: boolean; onActiveChange: (id: number) => void; - onDetailClick: (id: number) => void; + onDetailClick: (id: number, rank: number) => void; } )) { return ( @@ -559,7 +613,7 @@ function PropertySummaryBox(p: ( children="상세보기" onClick={(e) => { e.stopPropagation(); - p.onDetailClick(p.id); + p.onDetailClick(p.id, p.rank); }} /> @@ -582,7 +636,7 @@ function RecommendationItems({ items: PropertySummaryWithRank[]; activeItem?: ActivePropertyItem | null; onActiveChange: (id: number) => void; - onDetailClick: (id: number) => void; + onDetailClick: (id: number, rank: number) => void; hasSalePrice: boolean; hasJeonsePrice: boolean; className?: string; @@ -680,12 +734,165 @@ function RecommendationSheet({ +function RecommendationRequestDataDialog({ + taskId, + open, + onOpenChange, + onRecommendationNameSave, + recommendationName, + trigger, + requestData, +}: { + taskId: string; + open: boolean; + onOpenChange: (open: boolean) => void; + onRecommendationNameSave: (name: string) => void; + recommendationName: string; + trigger: React.ReactNode; + requestData?: schema.RequestData; +}) { + const navigation = useNavigate(); + const location = useLocation(); + const [submit, setSUbmit] = useState(false); + const selectedInfraTypes = new Set(requestData?.infrastructureTypes.map(x => x.type) ?? []); + const selectedSchoolDistricts = new Set(requestData?.schoolDistricts?.map(x => x.type) ?? []); + const schoolDistrictTypesStore = useSchoolDistrictTypesStore(); + + const [recName, setRecName] = useState(recommendationName); + + useEffect(() => { + schoolDistrictTypesStore.fetch(); + }, [schoolDistrictTypesStore]); + + const onSubmit = useCallback>(e => { + e.preventDefault(); + updateRecommendation(taskId, { name: recName }) + .then(() => { + onRecommendationNameSave(recName); + setSUbmit(true); + setTimeout(() => { + setSUbmit(false); + }, 2000); + }) + .then(() => { + navigation(location, { + replace: true, + state: { + ...location.state, + name: recName, + }, + }); + }); + }, [location, navigation, taskId, recName, onRecommendationNameSave]); + + return ( +

+ + + + + +
+ setRecName(e.currentTarget.value)} + leftIcon={} + label="추천 이름" + placeholder="추천 이름" + className="flex-1" + /> +
+ ); +} + + + const snapPoints = ["550px", 1]; interface LocationState { /** * - 추천 요청 후 페이지 이동 시 입력한 name 값 넘기기 - * - 추천 목록 페이지에서 클릭 시 화묜에 표시된 name 넘기기 + * - 추천 목록 페이지에서 클릭 시 화면에 표시된 name 넘기기 */ name?: string; } @@ -742,11 +949,13 @@ export function RecommendationPage() { const [recommendation, setRecommendation] = useState(null); const [detailOpen, setDetailOpen] = useState(false); const [detailLoading, setDetailLoading] = useState(false); - const [detailData, setDetailData] = useState(null); + const [details, setDetails] = useState>({}); const [detailError, setDetailError] = useState(null); - const [selectedProperty, setSelectedProperty] = useState(null); + const [selectedProperty, setSelectedProperty] = useState<{ rank: number; item?: schema.RecommendationPropertySummary }>({ rank: 0 }); + + const [isRequestDataDialogOpen, setIsRequestDataDialogOpen] = useState(false); + const [recName, setRecName] = useState(location.state?.name?.trim() ?? ""); - const recName = recommendation?.requestData.name || location.state?.name || taskId; const hasSalePrice = !!recommendation?.requestData.salePrice; const hasJeonsePrice = !!recommendation?.requestData.jeonsePrice; @@ -758,17 +967,18 @@ export function RecommendationPage() { } }; - const handleViewDetail = async (propertyId: number) => { + const handleViewDetail = async (propertyId: number, rank: number) => { if (!recommendation) return; - const prop = recommendation.properties?.find(p => p.id === propertyId); - setSelectedProperty(prop ?? null); + const item = recommendation.properties?.find(p => p.id === propertyId); + setSelectedProperty({ rank, item }); setDetailOpen(true); + handlePropertyClick(propertyId, "detail"); + if (details[propertyId]) return; + setDetailLoading(true); setDetailError(null); - setDetailData(null); - handlePropertyClick(propertyId, "detail"); try { const reqData = recommendation.requestData; @@ -783,7 +993,10 @@ export function RecommendationPage() { }; const detail = await getRecommendationProperty(taskId, propertyId, apiInput); - setDetailData(detail); + setDetails(prev => ({ + ...prev, + [propertyId]: detail, + })); } catch (e) { console.error(e); setDetailError("상세 정보를 불러오는 데 실패했습니다."); @@ -822,6 +1035,7 @@ export function RecommendationPage() { const rec = await getRecommendation(taskId); if (cancelled) return; + setRecName(p => rec.requestData.name?.trim() ?? p); setRecState(rec.status); setRecommendation(rec); @@ -859,12 +1073,22 @@ export function RecommendationPage() { }} onDetailClick={handleViewDetail} > -
+
+ } + requestData={recommendation?.requestData} + /> +
{infraInfos.map(infra => ( - + ))}
)}
{isMobile && <> -
+
+ } + requestData={recommendation?.requestData} + /> +
{infraInfos.map((infra) => ( @@ -965,25 +1199,19 @@ export function RecommendationPage() { {(() => { - const p = detailData || selectedProperty; + const tank = selectedProperty.rank; + const propertyId = selectedProperty.item?.id; + const cachedDetail = propertyId !== undefined ? details[propertyId] : undefined; + const p = cachedDetail ?? selectedProperty.item; + return ( <> -
- - {p?.name} - -
- - {p?.score === undefined ? "???" : Math.round(p.score)}점 -
+
+ +
- - - - {p?.address.roadName || p?.address.landLot || p?.region?.name} - - + {p && } {detailLoading ? ( @@ -996,19 +1224,22 @@ export function RecommendationPage() {

{detailError}

- ) : detailData ? ( + ) : p ? (
- x.type))}/> + x.type))}/>
) : null} - {detailData ? ( - - - + {p ? ( + ) : ( + + + +
+
{loading ? (
@@ -179,19 +199,7 @@ export function UserRecommendationsPage() { children="다시 시도" />
- ) : !data || data.total === 0 ? ( -
- -
-

아직 추천 요청 내역이 없어요

-

온보딩에서 조건을 입력하고 첫 추천을 받아보세요!

-
-
- ) : ( + ) : data ? (

총 {data.total}건의 추천 요청 @@ -204,7 +212,7 @@ export function UserRecommendationsPage() { /> ))}

- )} + ) : null} ); } diff --git a/src/shared/routes.ts b/src/shared/routes.ts index 1cfba08..89c6665 100644 --- a/src/shared/routes.ts +++ b/src/shared/routes.ts @@ -5,7 +5,6 @@ export const ROUTES = { HOME: "/", ONBOARDING: "/onboarding", RECOMMENDATION: "/recommendation", - USER_RECOMMENDATIONS: "/user-recommendation", SETTINGS: "/settings", SIGN_IN: "/login", SIGN_UP: "/signup", diff --git a/src/shared/schema.ts b/src/shared/schema.ts index 12d3f17..c5a58ad 100644 --- a/src/shared/schema.ts +++ b/src/shared/schema.ts @@ -30,6 +30,7 @@ export type RecommendationPropertySummary = z.infer; export type UserRecommendationsOutput = z.infer; export type RecommendationCreateInput = z.infer; +export type RecommendationUpdateInput = z.infer; export type RecommendationCreateOutput = z.infer; export type RecommendationSummaryOutput = z.infer; export type RecommendationPropertyDetailOutput = z.infer; @@ -257,11 +258,11 @@ export const recommendationPropertySummary = z.object({ id: nonNegativeInt, name: nonEmptyString, score: nonNegativeFloat, - region: regionItem.nullable(), + region: regionItem, address, salePrice: priceRange.nullable(), jeonsePrice: priceRange.nullable(), - infrastructure: infraSummary.array().max(2), + infrastructure: infraSummary.array(), }); diff --git a/src/stores/items.ts b/src/stores/items.ts index 9f552ca..c6ccf70 100644 --- a/src/stores/items.ts +++ b/src/stores/items.ts @@ -51,6 +51,7 @@ function createItemStore( set({ isError: false, isLoading: true }); try { const { items } = await apiFn(); + if (!items.length) throw new Error; set({ items, updatedAt: now,