Skip to content
Merged
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
113 changes: 76 additions & 37 deletions src/app/api/address/search/route.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,21 @@
import { NextResponse } from "next/server";

import {
extractZipCodeFromCoordDocument,
collectAddressQueriesForMissingZip,
collectZipCodeLookups,
mapKakaoDocumentToAddressItem,
mapKakaoKeywordToAddressItem,
mergeAddressDocumentIntoZipLookups,
mergeAddressSearchResults,
mergeCoordDocumentIntoZipLookups,
resolveKeywordZipCode,
setZipLookup,
toCoordinateKey,
type KakaoAddressDocument,
type KakaoCoord2AddressDocument,
type KakaoKeywordDocument,
type KakaoSearchResponse,
type ZipCodeLookups,
} from "@/lib/kakao/addressSearch";

const KAKAO_ADDRESS_SEARCH_URL = "https://dapi.kakao.com/v2/local/search/address.json";
Expand All @@ -34,31 +40,71 @@ async function fetchKakao<T>(url: URL, apiKey: string): Promise<KakaoSearchRespo
return payload;
}

async function resolveZipCodesByCoordinates(
coordinates: string[],
async function searchAddressDocuments(
query: string,
apiKey: string,
zipCodeByCoordinates: Map<string, string>,
): Promise<Map<string, string>> {
const resolvedZipCodes = new Map(zipCodeByCoordinates);
): Promise<KakaoAddressDocument[]> {
const addressUrl = new URL(KAKAO_ADDRESS_SEARCH_URL);
addressUrl.searchParams.set("query", query);
addressUrl.searchParams.set("analyze_type", "similar");
addressUrl.searchParams.set("size", "15");

const payload = await fetchKakao<KakaoAddressDocument>(addressUrl, apiKey);
return payload.documents ?? [];
}

/** coord2address로 좌표별 우편번호 보강. 지번/도로명 룩업도 함께 채운다. */
async function enrichZipCodesByCoordinates(
coordinates: string[],
apiKey: string,
lookups: ZipCodeLookups,
): Promise<void> {
await Promise.allSettled(
coordinates.map(async (coordinateKey) => {
if (resolvedZipCodes.has(coordinateKey)) return;
if (lookups.byCoordinate.has(coordinateKey)) return;

const [x, y] = coordinateKey.split(",");
if (!x || !y) return;

const coordUrl = new URL(KAKAO_COORD2ADDRESS_URL);
coordUrl.searchParams.set("x", x);
coordUrl.searchParams.set("y", y);

const payload = await fetchKakao<KakaoCoord2AddressDocument>(coordUrl, apiKey);
const zipCode = extractZipCodeFromCoordDocument(payload.documents?.[0]);
if (zipCode) {
resolvedZipCodes.set(coordinateKey, zipCode);
}
mergeCoordDocumentIntoZipLookups(lookups, coordinateKey, payload.documents?.[0]);
}),
);
}

/**
* coord2address로도 못 채운 키워드는 지번/도로명으로 주소 검색을 다시 호출해 우편번호를 보강한다.
* (키워드 좌표가 도로명 매칭이 안 되는 지번-only 케이스 대응)
* 이미 확정된 룩업 값은 덮어쓰지 않고, query→zip 매핑은 첫 유효 문서만 사용한다.
*/
async function enrichZipCodesByAddressQueries(
queries: string[],
apiKey: string,
lookups: ZipCodeLookups,
): Promise<void> {
await Promise.allSettled(
queries.map(async (query) => {
const documents = await searchAddressDocuments(query, apiKey);
let queryZipCode = "";

for (const document of documents) {
const zipCode = mergeAddressDocumentIntoZipLookups(lookups, document);
// analyze_type=similar 상위 결과(첫 유효 문서)만 query 문자열 매핑에 사용
if (!queryZipCode && zipCode) {
queryZipCode = zipCode;
}
}

return resolvedZipCodes;
if (queryZipCode) {
setZipLookup(lookups.byJibun, query, queryZipCode);
setZipLookup(lookups.byRoad, query, queryZipCode);
}
}),
);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved

export async function GET(request: Request) {
Expand All @@ -77,24 +123,19 @@ export async function GET(request: Request) {
);
}

const addressUrl = new URL(KAKAO_ADDRESS_SEARCH_URL);
addressUrl.searchParams.set("query", query);
addressUrl.searchParams.set("analyze_type", "similar");
addressUrl.searchParams.set("size", "15");

const keywordUrl = new URL(KAKAO_KEYWORD_SEARCH_URL);
keywordUrl.searchParams.set("query", query);
keywordUrl.searchParams.set("size", "15");

const [addressResult, keywordResult] = await Promise.allSettled([
fetchKakao<KakaoAddressDocument>(addressUrl, apiKey),
searchAddressDocuments(query, apiKey),
fetchKakao<KakaoKeywordDocument>(keywordUrl, apiKey),
]);

const addressPayload = addressResult.status === "fulfilled" ? addressResult.value : null;
const addressDocuments = addressResult.status === "fulfilled" ? addressResult.value : null;
const keywordPayload = keywordResult.status === "fulfilled" ? keywordResult.value : null;

if (!addressPayload && !keywordPayload) {
if (!addressDocuments && !keywordPayload) {
const reason =
addressResult.status === "rejected"
? addressResult.reason
Expand All @@ -105,32 +146,30 @@ export async function GET(request: Request) {
return NextResponse.json({ message }, { status: 502 });
}

const zipCodeByCoordinates = new Map<string, string>();
for (const document of addressPayload?.documents ?? []) {
const zipCode = document.road_address?.zone_no || document.address?.zip_code || "";
if (!zipCode) continue;
zipCodeByCoordinates.set(toCoordinateKey(document.x, document.y), zipCode);
}

const lookups = collectZipCodeLookups(addressDocuments ?? []);
const keywordDocuments = keywordPayload?.documents ?? [];

const missingCoordinateKeys = [
...new Set(
keywordDocuments
.map((document) => toCoordinateKey(document.x, document.y))
.filter((coordinateKey) => !zipCodeByCoordinates.has(coordinateKey)),
.filter((coordinateKey) => !lookups.byCoordinate.has(coordinateKey)),
),
];

const resolvedZipCodeByCoordinates =
missingCoordinateKeys.length > 0
? await resolveZipCodesByCoordinates(missingCoordinateKeys, apiKey, zipCodeByCoordinates)
: zipCodeByCoordinates;
if (missingCoordinateKeys.length > 0) {
await enrichZipCodesByCoordinates(missingCoordinateKeys, apiKey, lookups);
}

const missingAddressQueries = collectAddressQueriesForMissingZip(keywordDocuments, lookups);
if (missingAddressQueries.length > 0) {
await enrichZipCodesByAddressQueries(missingAddressQueries, apiKey, lookups);
}

const addressResults = (addressPayload?.documents ?? []).map(mapKakaoDocumentToAddressItem);
const keywordResults = keywordDocuments.map((document, index) => {
const zipCode = resolvedZipCodeByCoordinates.get(toCoordinateKey(document.x, document.y)) || "";
return mapKakaoKeywordToAddressItem(document, index, zipCode);
});
const addressResults = (addressDocuments ?? []).map(mapKakaoDocumentToAddressItem);
const keywordResults = keywordDocuments.map((document, index) =>
mapKakaoKeywordToAddressItem(document, index, resolveKeywordZipCode(document, lookups)),
);
const results = mergeAddressSearchResults(addressResults, keywordResults);

return NextResponse.json({ results });
Expand Down
2 changes: 1 addition & 1 deletion src/app/estimate-request/page.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import EstimateRequestForm from "@/components/estimate/EstimateRequestForm";
import EstimateRequestForm from "@/components/estimate/request/EstimateRequestForm";

export default function EstimateRequestPage() {
return (
Expand Down
61 changes: 5 additions & 56 deletions src/components/common/Header/Header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,13 @@
import Image from "next/image";
import Link from "next/link";
import { usePathname, useRouter } from "next/navigation";
import { useCallback, useEffect, useId, useRef, useState, useSyncExternalStore } from "react";
import { useCallback, useEffect, useId, useRef, useState } from "react";

import NotificationPanel from "@/components/common/Header/NotificationPanel";
import NotificationTrigger from "@/components/common/Header/NotificationTrigger";
import { Text } from "@/components/common/Text";
import { useClickOutside } from "@/hooks/useClickOutside";
import { AlarmIcon } from "@/icons";
import { getLoginRedirectPath, hasAuthSession, subscribeAuthSession } from "@/lib/auth/session";
import { getLoginRedirectPath } from "@/lib/auth/session";
import { APP_ROUTES } from "@/lib/constants/appRoutes";
import { getUnreadNotificationCount, MOCK_NOTIFICATIONS } from "@/lib/mocks/notifications.mock";
import { cn } from "@/lib/utils/cn";
import { useAuthStore } from "@/stores/useAuthStore";

Expand Down Expand Up @@ -67,36 +65,13 @@ const Header = ({ isLogin: initialIsLogin, initialNickname = null }: HeaderProps
const showAuthSkeleton = (!hasHydrated || isCheckingAuth) && !initialIsLogin;
const nickname = user?.name ?? displayName ?? initialNickname ?? "닉네임";

const notificationPanelId = useId();
const [isNotificationOpen, setIsNotificationOpen] = useState(false);
const unreadCount = getUnreadNotificationCount(MOCK_NOTIFICATIONS);

const closeNotification = useCallback(() => {
setIsNotificationOpen(false);
}, []);

const notificationRef = useClickOutside<HTMLDivElement>(closeNotification);

useEffect(() => {
if (!isNotificationOpen) return;

const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
closeNotification();
}
};

document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [isNotificationOpen, closeNotification]);

const handleLogout = async () => {
setOpenMenuPath(null);

try {
await logout();
router.replace(APP_ROUTES.LOGIN);
} catch (error) {
} catch {
router.replace(APP_ROUTES.LOGIN);
}
Comment thread
youngmis marked this conversation as resolved.
Resolved
};
Expand Down Expand Up @@ -214,33 +189,7 @@ const Header = ({ isLogin: initialIsLogin, initialNickname = null }: HeaderProps
</div>
) : isLogin ? (
<div className="flex items-center gap-20">
<div ref={notificationRef} className="relative">
<button
type="button"
aria-label={unreadCount > 0 ? `알림, 읽지 않은 알림 ${unreadCount}개` : "알림"}
aria-expanded={isNotificationOpen}
aria-controls={isNotificationOpen ? notificationPanelId : undefined}
className="relative"
onClick={() => setIsNotificationOpen((prev) => !prev)}
>
<AlarmIcon className="text-icon-default size-24" aria-hidden="true" />
{unreadCount > 0 ? (
<Text
as="span"
variant="xs-semibold"
aria-hidden="true"
className="bg-status-error text-text-inverse absolute -top-4 -right-6 flex h-16 min-w-16 items-center justify-center rounded-full px-4 leading-none"
>
{unreadCount}
</Text>
) : null}
</button>
{isNotificationOpen ? (
<div id={notificationPanelId}>
<NotificationPanel onClose={closeNotification} />
</div>
) : null}
</div>
<NotificationTrigger />

<div ref={profileMenuRef} className="relative flex items-center gap-12">
<button
Expand Down
86 changes: 29 additions & 57 deletions src/components/common/Header/NotificationPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,33 @@ import { MOCK_NOTIFICATIONS, NOTIFICATION_PAGE_SIZE } from "@/lib/mocks/notifica
import type { NotificationItem } from "@/types/notification";
import { cn } from "@/lib/utils/cn";

function NotificationContent({ notification }: { notification: NotificationItem }) {
const isRead = notification.isRead;
const messageParts = buildNotificationMessageParts(notification.type, notification.content);

return (
<>
<p className={isRead ? "text-text-weak" : "text-text-secondary"}>
<Text as="span" variant="lg-medium">
{messageParts.map((part, partIndex) => (
<span
key={`${notification.id}-${partIndex}`}
className={cn(
isRead ? "text-text-weak" : part.highlight ? "text-text-brand" : undefined,
)}
>
{part.text}
</span>
))}
</Text>
</p>
<Text as="p" variant="md-medium" className={isRead ? "text-text-weak" : "text-text-muted"}>
{notification.createdAtLabel}
</Text>
</>
);
}

interface NotificationPanelProps {
notifications?: NotificationItem[];
onClose: () => void;
Expand Down Expand Up @@ -78,11 +105,6 @@ export default function NotificationPanel({
<ul className="flex w-full flex-col">
{pageItems.map((notification, index) => {
const isLast = index === pageItems.length - 1;
const isRead = notification.isRead;
const messageParts = buildNotificationMessageParts(
notification.type,
notification.content,
);

return (
<li
Expand All @@ -98,60 +120,10 @@ export default function NotificationPanel({
onClick={onClose}
className="hover:bg-background-hover focus-visible:ring-border-brand rounded-8 -mx-8 -my-4 flex flex-col gap-2 px-8 py-4 transition focus-visible:ring-1 focus-visible:outline-none"
>
<p className={isRead ? "text-text-weak" : "text-text-secondary"}>
<Text as="span" variant="lg-medium">
{messageParts.map((part, partIndex) => (
<span
key={`${notification.id}-${partIndex}`}
className={cn(
isRead
? "text-text-weak"
: part.highlight
? "text-text-brand"
: undefined,
)}
>
{part.text}
</span>
))}
</Text>
</p>
<Text
as="p"
variant="md-medium"
className={isRead ? "text-text-weak" : "text-text-muted"}
>
{notification.createdAtLabel}
</Text>
<NotificationContent notification={notification} />
</Link>
) : (
<>
<p className={isRead ? "text-text-weak" : "text-text-secondary"}>
<Text as="span" variant="lg-medium">
{messageParts.map((part, partIndex) => (
<span
key={`${notification.id}-${partIndex}`}
className={cn(
isRead
? "text-text-weak"
: part.highlight
? "text-text-brand"
: undefined,
)}
>
{part.text}
</span>
))}
</Text>
</p>
<Text
as="p"
variant="md-medium"
className={isRead ? "text-text-weak" : "text-text-muted"}
>
{notification.createdAtLabel}
</Text>
</>
<NotificationContent notification={notification} />
)}
</li>
);
Expand Down
Loading