From be496a15ae80a625684dfbe97ba6008692e196f5 Mon Sep 17 00:00:00 2001 From: kimzuni Date: Fri, 12 Jun 2026 21:07:34 +0900 Subject: [PATCH 1/6] chore: update file-delivery --- .gitignore | 1 + root/etc/s6-overlay/s6-rc.d/file-delivery/run | 5 +++-- 2 files changed, 4 insertions(+), 2 deletions(-) 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 From 2cc3aa7519dd69cb0870d3a6fc5edf10c26bcd13 Mon Sep 17 00:00:00 2001 From: kimzuni Date: Fri, 12 Jun 2026 21:07:34 +0900 Subject: [PATCH 2/6] =?UTF-8?q?feat:=20=EB=A9=94=EC=9D=B8=20Home=20?= =?UTF-8?q?=ED=8E=98=EC=9D=B4=EC=A7=80=EB=A5=BC=20=EC=B6=94=EC=B2=9C=20?= =?UTF-8?q?=EB=AA=A9=EB=A1=9D=20=ED=8E=98=EC=9D=B4=EC=A7=80=EB=A1=9C=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/router.tsx | 7 +---- src/components/header.tsx | 19 +++++++----- src/pages/home.tsx | 49 ------------------------------ src/pages/user-recommendations.tsx | 47 ++++++++++++++++------------ src/shared/routes.ts | 1 - 5 files changed, 40 insertions(+), 83 deletions(-) delete mode 100644 src/pages/home.tsx 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/pages/user-recommendations.tsx b/src/pages/user-recommendations.tsx index 80ce739..012c616 100644 --- a/src/pages/user-recommendations.tsx +++ b/src/pages/user-recommendations.tsx @@ -1,6 +1,6 @@ import { useEffect, useState } from "react"; -import { useNavigate } from "react-router"; -import { LoaderIcon, SearchAlertIcon, ClockIcon, Sparkles, MapPin } from "lucide-react"; +import { useNavigate, NavLink } from "react-router"; +import { LoaderIcon, User2Icon, SearchAlertIcon, ClockIcon, Sparkles, MapPin } from "lucide-react"; import type * as schema from "@/shared/schema"; import { getRecommendations } from "@/lib/api"; @@ -140,7 +140,12 @@ export function UserRecommendationsPage() { getRecommendations() .then((res) => { - if (!cancelled) setData(res); + if (cancelled) return; + if (res.total === 0) { + navigate(ROUTES.ONBOARDING, { replace: true }); + return; + } + setData(res); }) .catch((e) => { console.error(e); @@ -151,7 +156,7 @@ export function UserRecommendationsPage() { }); return () => { cancelled = true; }; - }, []); + }, [navigate]); const handleItemClick = (item: schema.UserRecommendationItem) => { navigate( @@ -162,7 +167,23 @@ export function UserRecommendationsPage() { return ( <> -
+
+
+ + + + +
+
{loading ? (
@@ -179,19 +200,7 @@ export function UserRecommendationsPage() { children="다시 시도" />
- ) : !data || data.total === 0 ? ( -
- -
-

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

-

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

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

총 {data.total}건의 추천 요청 @@ -204,7 +213,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", From 77059a3c5ec836364bf93627f317fd5d1e3aff3d Mon Sep 17 00:00:00 2001 From: kimzuni Date: Fri, 12 Jun 2026 21:07:34 +0900 Subject: [PATCH 3/6] =?UTF-8?q?chore:=20=EC=97=85=EB=8D=B0=EC=9D=B4?= =?UTF-8?q?=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/school-district-box.tsx | 36 +++ src/lib/price-unit.ts | 13 +- src/pages/onboarding.tsx | 125 +++++---- src/pages/recommendation.tsx | 334 +++++++++++++++++++------ src/pages/user-recommendations.tsx | 19 +- src/shared/schema.ts | 4 +- 6 files changed, 375 insertions(+), 156 deletions(-) create mode 100644 src/components/school-district-box.tsx diff --git a/src/components/school-district-box.tsx b/src/components/school-district-box.tsx new file mode 100644 index 0000000..de791de --- /dev/null +++ b/src/components/school-district-box.tsx @@ -0,0 +1,36 @@ +import { cn } from "@/lib/utils"; +import type * as schema from "@/shared/schema"; + + + +export interface SchoolDistrictBoxProps extends React.ComponentProps<"button"> { + item: schema.SchoolDistrictTypeItem; + isSelected?: boolean; +} + + +export function SchoolDistrictBox({ + item, + className, + isSelected, + ...props +}: SchoolDistrictBoxProps) { + return ( + + ); +} 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/onboarding.tsx b/src/pages/onboarding.tsx index 15f9613..ca83205 100644 --- a/src/pages/onboarding.tsx +++ b/src/pages/onboarding.tsx @@ -61,6 +61,7 @@ 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"; @@ -145,7 +146,10 @@ const SECTION_OPTIONS: SectionOptions[] = [ .filter(Boolean) as string[]; return ( -
+

인프라 우선순위

    @@ -261,22 +265,12 @@ const SECTION_OPTIONS: SectionOptions[] = [ {ctx.schoolDistrictTypeItems.map((district) => { const isSelected = ctx.selectedSchoolDistricts.includes(district.type); return ( - + isSelected={isSelected} + /> ); })}
@@ -333,52 +327,56 @@ const SECTION_OPTIONS: SectionOptions[] = [
-
-
- - { - 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]); + }} + /> +
@@ -1225,11 +1223,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..8995413 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 { 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"; @@ -16,6 +16,7 @@ import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area"; import { Tooltip } from "@/components/tooltip"; import { Header } from "@/components/header"; import { InfraTypeBadge } from "@/components/infra-type-badge"; +import { SchoolDistrictBox } from "@/components/school-district-box"; import { Drawer, DrawerTrigger, @@ -41,7 +42,9 @@ import { DialogFooter, DialogHeader, DialogTitle, + DialogTrigger, } from "@/components/ui/dialog"; +import { useSchoolDistrictTypesStore } from "@/stores/items"; @@ -53,6 +56,28 @@ const defaultPoint: schema.MapCoordinate = { lat: 33.450701, lng: 126.570667 }; +function NaverMapLink({ + latitude, + longitude, + children, + ...props +}: ( + & Omit, "href"> + & schema.Coordinate +)) { + return ( + + ); +} + + + /** * 추천 결과 요청 상태에 따른 메시지와 아이콘을 보여주는 컴포넌트 */ @@ -183,6 +208,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 +274,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 +290,9 @@ function PropertyDetailContent({
- {/* 상세 내용 */} - + {children} ); } @@ -422,9 +454,13 @@ function RecommendationMap({ clickable={true} > onActiveChange(null)} - /> + > + + + )} @@ -448,7 +484,7 @@ function RecommendationMap({ function Trophy({ rank }: { rank: number }) { const imoji = ["🥇", "🥈", "🥉"][rank - 1] || ""; return ( -

+ + {Math.round(score)}점 + + ); +} + function PriceBox({ label, price, @@ -521,7 +574,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 +612,7 @@ function PropertySummaryBox(p: ( children="상세보기" onClick={(e) => { e.stopPropagation(); - p.onDetailClick(p.id); + p.onDetailClick(p.id, p.rank); }} /> @@ -582,7 +635,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 +733,117 @@ function RecommendationSheet({ +function RecommendationRequestDataDialog({ + open, + onOpenChange, + trigger, + requestData, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + trigger: React.ReactNode; + requestData?: schema.RequestData; +}) { + const selectedInfraTypes = new Set(requestData?.infrastructureTypes.map(x => x.type) ?? []); + const selectedSchoolDistricts = new Set(requestData?.schoolDistricts?.map(x => x.type) ?? []); + const schoolDistrictTypesStore = useSchoolDistrictTypesStore(); + + useEffect(() => { + schoolDistrictTypesStore.fetch(); + }, [schoolDistrictTypesStore]); + + return ( +

+ + + + + + + +
+
+

동네

+

+

+ +
+

인프라 우선순위

+
    + {requestData?.infrastructureTypes.map((x, idx) => ( +
  1. + +
  2. + ))} +
+
+ + {(selectedInfraTypes.has("ELEMENTARY_SCHOOL") || selectedInfraTypes.has("MIDDLE_SCHOOL") || selectedInfraTypes.has("HIGH_SCHOOL")) && ( +
+

학군 유형

+
+ {schoolDistrictTypesStore.items.map(district => ( + + ))} +
+
+ )} + + {selectedInfraTypes.has("HIGH_SCHOOL") && ( +
+

선택한 고등학교

+
    + {requestData?.highSchools?.map(x => ( +
  • + )) ??
  • -
  • } +
+
+ )} + +
+

가격

+
+

매매: {!requestData?.salePrice?.min && !requestData?.salePrice?.max ? "-" : (<> + {requestData.salePrice.min && formatPriceUnit(requestData.salePrice.min).join(" ")} + {" ~ "} + {requestData.salePrice.max && formatPriceUnit(requestData.salePrice.max).join(" ")} + )}

+

전세: {!requestData?.jeonsePrice?.min && !requestData?.jeonsePrice?.max ? "-" : (<> + {requestData.jeonsePrice.min && formatPriceUnit(requestData.jeonsePrice.min).join(" ")} + {" ~ "} + {requestData.jeonsePrice.max && formatPriceUnit(requestData.jeonsePrice.max).join(" ")} + )}

+
+
+
+
+
+ ); +} + + + const snapPoints = ["550px", 1]; interface LocationState { /** * - 추천 요청 후 페이지 이동 시 입력한 name 값 넘기기 - * - 추천 목록 페이지에서 클릭 시 화묜에 표시된 name 넘기기 + * - 추천 목록 페이지에서 클릭 시 화면에 표시된 name 넘기기 */ name?: string; } @@ -742,11 +900,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 +918,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 +944,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 +986,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 +1024,19 @@ export function RecommendationPage() { }} onDetailClick={handleViewDetail} > -
+
+ } + requestData={recommendation?.requestData} + /> +
{infraInfos.map(infra => ( - + ))}
)}
{isMobile && <> -
+
+ } + requestData={recommendation?.requestData} + /> +
{infraInfos.map((infra) => ( @@ -965,25 +1144,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 +1169,22 @@ export function RecommendationPage() {

{detailError}

- ) : detailData ? ( + ) : p ? (
- x.type))}/> + x.type))}/>
) : null} - {detailData ? ( - - - + {p ? ( + ) : (