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
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
116 changes: 107 additions & 9 deletions src/app/(app)/home/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,22 +4,38 @@ import Image from "next/image";
import Link from "next/link";
import { useEffect, useState } from "react";
import { useSession } from "next-auth/react";
import { ArrowRight, MapPin } from "lucide-react";
import { ArrowRight, LoaderCircle, MapPin } from "lucide-react";
import { toast } from "sonner";

import HomeHeader from "@/components/layout/HomeHeader";
import PlaceDetailSheet from "@/components/sheet/PlaceDetailSheet";
import PageFade from "@/components/ui/PageFade";
import { getPopularPlaces } from "@/lib/api/places";
import { getPopularPlaces, resolvePlace, searchPlaces } from "@/lib/api/places";
import { getPopularFeed } from "@/lib/api/posts";
import { getUserProfile } from "@/lib/api/users";
import type { PlaceSummary } from "@/types/api/place";
import type { FeedPost } from "@/types/api/post";

const POPULAR_FALLBACK_IMAGES = [
"/trips-covers/cover-gwangalli.png",
"/trips-covers/cover-hillside.png",
"/trips-covers/destination-gwangalli-millak.png",
"/trips-covers/destination-gamcheon-sunset.png",
];

const POPULAR_FALLBACK_PLACES = [
{
key: "gwangalli",
imageUrl: POPULAR_FALLBACK_IMAGES[0],
subtitle: "해운대 · 광안리",
keyword: "광안리해수욕장",
},
{
key: "gamcheon",
imageUrl: POPULAR_FALLBACK_IMAGES[1],
subtitle: "감천문화마을 · 태종대",
keyword: "감천문화마을",
},
] as const;

const COMMUNITY_FALLBACK_IMAGES = [
"/trips-covers/cover-coastal-temple.png",
"/trips-covers/cover-harbor-market.png",
Expand Down Expand Up @@ -57,6 +73,9 @@ export default function HomePage() {
const [profileImageUrl, setProfileImageUrl] = useState<string | null>(null);
const [communityPosts, setCommunityPosts] = useState<FeedPost[]>([]);
const [popularPlaces, setPopularPlaces] = useState<PlaceSummary[]>([]);
const [popularLoading, setPopularLoading] = useState(true);
const [resolvingFallback, setResolvingFallback] = useState<string | null>(null);
const [fallbackPlaceIds, setFallbackPlaceIds] = useState<Record<string, number>>({});
const [detailPlaceId, setDetailPlaceId] = useState<number | null>(null);

useEffect(() => {
Expand All @@ -72,7 +91,12 @@ export default function HomePage() {
.then((response) => {
if (!cancelled) setPopularPlaces(response.items.slice(0, 2));
})
.catch(() => {});
.catch(() => {
if (!cancelled) setPopularPlaces([]);
})
.finally(() => {
if (!cancelled) setPopularLoading(false);
});

return () => {
cancelled = true;
Expand Down Expand Up @@ -101,6 +125,35 @@ export default function HomePage() {

const displayName = greetingName(userName ?? session?.user?.name);

async function openFallbackPlace(place: (typeof POPULAR_FALLBACK_PLACES)[number]) {
if (resolvingFallback) return;

const cachedPlaceId = fallbackPlaceIds[place.key];
if (cachedPlaceId != null) {
setDetailPlaceId(cachedPlaceId);
return;
}

setResolvingFallback(place.key);
try {
const response = await searchPlaces(place.keyword);
const exactMatch = response.items.find((item) => item.name.includes(place.keyword));
const match = exactMatch ?? response.items[0];
if (!match) throw new Error("추천 장소를 찾지 못했습니다.");

const resolvedPlaceId =
match.placeId !== null && match.resolved
? match.placeId
: (await resolvePlace(match)).placeId;
setFallbackPlaceIds((current) => ({ ...current, [place.key]: resolvedPlaceId }));
setDetailPlaceId(resolvedPlaceId);
} catch {
toast.error("장소 정보를 불러오지 못했어요. 잠시 후 다시 시도해주세요.");
} finally {
setResolvingFallback(null);
}
}

return (
<PageFade className="flex min-h-full shrink-0 flex-col bg-[#f8fbff] text-[#0b2146]">
<HomeHeader
Expand Down Expand Up @@ -173,7 +226,22 @@ export default function HomePage() {
<h2 className="mb-3 text-xl font-extrabold tracking-[-0.04em] text-[#0b2146]">
지금 인기 여행지
</h2>
{popularPlaces.length > 0 ? (
{popularLoading ? (
<div className="grid grid-cols-2 gap-3" aria-label="인기 여행지를 불러오는 중">
{[0, 1].map((item) => (
<div
key={item}
className="overflow-hidden rounded-[1.35rem] bg-white shadow-[0_8px_24px_rgba(38,83,133,0.08)] ring-1 ring-[#e4edf7]"
>
<div className="aspect-3/2 animate-pulse bg-[#eaf3fb]" />
<div className="space-y-2 px-3.5 py-3">
<div className="h-4 w-16 animate-pulse rounded-full bg-[#e7eef6]" />
<div className="h-3 w-24 animate-pulse rounded-full bg-[#eef3f8]" />
</div>
</div>
))}
</div>
) : popularPlaces.length > 0 ? (
<div className="grid grid-cols-2 gap-3">
{popularPlaces.map((place, index) => {
const imageUrl = place.primaryImageUrl || POPULAR_FALLBACK_IMAGES[index % POPULAR_FALLBACK_IMAGES.length];
Expand Down Expand Up @@ -201,9 +269,39 @@ export default function HomePage() {
})}
</div>
) : (
<p className="rounded-2xl bg-white py-9 text-center text-sm text-[#8996aa] ring-1 ring-[#e7eef6]">
인기 여행지 정보를 준비 중이에요
</p>
<div className="grid grid-cols-2 gap-3">
{POPULAR_FALLBACK_PLACES.map((place) => {
const resolving = resolvingFallback === place.key;
return (
<button
key={place.key}
type="button"
onClick={() => void openFallbackPlace(place)}
disabled={resolvingFallback !== null}
aria-busy={resolving}
className="group overflow-hidden rounded-[1.35rem] bg-white text-left shadow-[0_8px_24px_rgba(38,83,133,0.1)] ring-1 ring-[#e4edf7] transition-transform hover:-translate-y-0.5 disabled:cursor-wait disabled:opacity-80"
>
<span className="relative block aspect-3/2 overflow-hidden bg-[#eaf3fb]">
<CardImage src={place.imageUrl} alt={place.subtitle} />
{resolving && (
<span className="absolute inset-0 grid place-items-center bg-[#0b2146]/20" aria-hidden>
<LoaderCircle className="animate-spin text-white" size={28} strokeWidth={2.5} />
</span>
)}
</span>
<span className="block px-3.5 py-3">
<span className="flex items-center gap-2 text-base font-extrabold tracking-[-0.03em] text-[#102750]">
<MapPin size={18} strokeWidth={2.3} className="shrink-0 text-[#2E7DF2]" />
부산
</span>
<span className="mt-1 block truncate pl-[1.65rem] text-xs font-medium text-[#8996aa]">
{place.subtitle}
</span>
</span>
</button>
);
})}
</div>
)}
</section>

Expand Down
20 changes: 10 additions & 10 deletions src/app/(app)/trips/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -204,7 +204,7 @@ export default function TripsPage() {
}, [sorted, today]);

return (
<div className={`${pageFont.className} flex flex-1 flex-col bg-[#F8FBFF] text-[#14233F]`}>
<div className={`${pageFont.className} flex w-full min-w-0 flex-1 flex-col overflow-x-clip bg-[#F8FBFF] text-[#14233F]`}>
<header className="relative aspect-[2/1] min-h-[166px] shrink-0 overflow-hidden bg-white">
<Image
src="/trips-covers/header-busan.png"
Expand Down Expand Up @@ -247,11 +247,11 @@ export default function TripsPage() {
<p className="text-sm text-red-500">{error}</p>
</div>
) : (
<div className="flex flex-col gap-[clamp(16px,3.75vw,20px)] px-[clamp(19px,4.46vw,23px)] pb-8">
<div className="flex w-full min-w-0 flex-col gap-[clamp(16px,3.75vw,20px)] px-[clamp(19px,4.46vw,23px)] pb-8">
{featured && (
<section className="relative z-[1] -mt-[clamp(54px,12.7vw,64px)]">
<section className="relative z-[1] min-w-0 -mt-[clamp(54px,12.7vw,64px)]">
<h2 className="mb-[clamp(12px,2.8vw,14px)] text-[clamp(16px,3.75vw,18px)] font-extrabold tracking-tight">가장 가까운 여행</h2>
<div className="relative aspect-[1.94] min-h-[202px] overflow-hidden rounded-[23px] bg-white shadow-[0_8px_28px_rgba(44,112,191,0.09)]">
<div className="relative w-full min-w-0 aspect-[1.94] min-h-[202px] overflow-hidden rounded-[23px] bg-white shadow-[0_8px_28px_rgba(44,112,191,0.09)]">
<div className="absolute inset-y-0 right-0 w-[52%]" style={{ clipPath: "ellipse(100% 84% at 100% 50%)" }}>
<Image
src="/trips-covers/featured-lighthouse.png"
Expand Down Expand Up @@ -294,34 +294,34 @@ export default function TripsPage() {
</section>
)}

<section className="grid grid-cols-2 gap-[clamp(10px,2.35vw,12px)]" aria-label="새 일정 만들기">
<section className="grid w-full min-w-0 grid-cols-2 gap-[clamp(10px,2.35vw,12px)]" aria-label="새 일정 만들기">
<button
type="button"
onClick={() => { resetDraft(); router.push("/trips/new/date"); }}
className="flex aspect-[2.1] min-h-[91px] cursor-pointer items-center gap-[clamp(10px,2.35vw,12px)] rounded-[20px] bg-linear-to-r from-[#EAF6FF] to-[#F5FBFF] px-[clamp(10px,2.35vw,12px)] text-left transition-colors hover:from-[#E0F1FF] max-[359px]:flex-col max-[359px]:justify-center max-[359px]:gap-1"
className="flex min-w-0 aspect-[2.1] min-h-[91px] cursor-pointer items-center gap-[clamp(10px,2.35vw,12px)] overflow-hidden rounded-[20px] bg-linear-to-r from-[#EAF6FF] to-[#F5FBFF] px-[clamp(10px,2.35vw,12px)] text-left transition-colors hover:from-[#E0F1FF] max-[359px]:flex-col max-[359px]:justify-center max-[359px]:gap-1"
>
<PlanningCalendarIcon />
<span className="min-w-0 flex-1">
<span className="block whitespace-nowrap text-xs font-bold min-[390px]:text-[clamp(13px,3.05vw,15px)]">일정 계획하기</span>
<span className="mt-1 block whitespace-nowrap text-[10px] text-[#64758E] min-[390px]:text-[clamp(11px,2.58vw,13px)]">3분이면 완성</span>
</span>
<ChevronRight className="hidden size-[clamp(15px,3.52vw,18px)] shrink-0 text-[#64758E] min-[390px]:block" aria-hidden />
<ChevronRight className="hidden size-[clamp(15px,3.52vw,18px)] shrink-0 text-[#64758E] min-[430px]:block" aria-hidden />
</button>
<button
type="button"
onClick={() => router.push("/spontaneous")}
className="flex aspect-[2.1] min-h-[91px] cursor-pointer items-center gap-[clamp(10px,2.35vw,12px)] rounded-[20px] bg-linear-to-r from-[#FFF8EB] to-[#FFFDF8] px-[clamp(10px,2.35vw,12px)] text-left transition-colors hover:from-[#FFF0D9] max-[359px]:flex-col max-[359px]:justify-center max-[359px]:gap-1"
className="flex min-w-0 aspect-[2.1] min-h-[91px] cursor-pointer items-center gap-[clamp(10px,2.35vw,12px)] overflow-hidden rounded-[20px] bg-linear-to-r from-[#FFF8EB] to-[#FFFDF8] px-[clamp(10px,2.35vw,12px)] text-left transition-colors hover:from-[#FFF0D9] max-[359px]:flex-col max-[359px]:justify-center max-[359px]:gap-1"
>
<ZeroPlanSignpostIcon />
<span className="min-w-0 flex-1">
<span className="block whitespace-nowrap text-xs font-bold min-[390px]:text-[clamp(13px,3.05vw,15px)]">제로플랜</span>
<span className="mt-1 block whitespace-nowrap text-[10px] text-[#64758E] min-[390px]:text-[clamp(11px,2.58vw,13px)]">지금 바로 출발</span>
</span>
<ChevronRight className="hidden size-[clamp(15px,3.52vw,18px)] shrink-0 text-[#64758E] min-[390px]:block" aria-hidden />
<ChevronRight className="hidden size-[clamp(15px,3.52vw,18px)] shrink-0 text-[#64758E] min-[430px]:block" aria-hidden />
</button>
</section>

<section className="pt-3">
<section className="min-w-0 pt-3">
<div className="flex items-center justify-between">
<h2 className="text-[clamp(17px,4vw,20px)] font-extrabold tracking-tight">
모든 일정 <span className="text-[#2E7DF2]">{sorted.length}</span>
Expand Down
2 changes: 2 additions & 0 deletions src/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -53,5 +53,7 @@ body {
}

.scrollbar-none::-webkit-scrollbar {
width: 0;
height: 0;
display: none;
}
Loading