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
36 changes: 36 additions & 0 deletions src/components/common/SectionHeader.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { ChevronRight } from "lucide-react";
import type { ReactNode } from "react";

type SectionHeaderProps = {
title: string;
icon?: ReactNode;
actionLabel?: string;
onActionClick?: () => void;
};

export default function SectionHeader({
title,
icon,
actionLabel,
onActionClick,
}: SectionHeaderProps) {
return (
<div className="flex items-center justify-between">
<h2 className="m-0 inline-flex items-center gap-[5px] text-[18px] font-black leading-tight text-[#1c1c3a]">
{icon}
{title}
</h2>

{actionLabel ? (
<button
className="inline-flex items-center gap-[3px] bg-transparent text-[13px] font-black text-[#5bb5f8]"
type="button"
onClick={onActionClick}
>
{actionLabel}
<ChevronRight size={15} strokeWidth={2.6} />
</button>
) : null}
</div>
);
}
31 changes: 31 additions & 0 deletions src/components/home/ActivitySummary.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
type ActivitySummaryItem = {
value: string;
label: string;
};

type ActivitySummaryProps = {
items: ActivitySummaryItem[];
};

export default function ActivitySummary({ items }: ActivitySummaryProps) {
return (
<section
className="grid grid-cols-4 overflow-hidden rounded-[18px] bg-white px-1.5 shadow-[0_2px_8px_rgba(8,37,95,0.08)]"
aria-label="활동 요약"
>
{items.map((item) => (
<article
className="grid min-h-[70px] justify-items-center gap-[5px] border-r border-[#e8eff8] py-4 text-center last:border-r-0"
key={item.label}
>
<strong className="text-[18px] font-black leading-none text-[#5bb5f8]">
{item.value}
</strong>
<span className="text-[10px] font-bold text-[#8b98aa]">
{item.label}
</span>
</article>
))}
</section>
);
}
27 changes: 27 additions & 0 deletions src/components/home/BonusBanner.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
type BonusBannerProps = {
mascotSrc: string;
};

export default function BonusBanner({ mascotSrc }: BonusBannerProps) {
return (
<section className="grid min-h-24 grid-cols-[82px_minmax(0,1fr)] items-center gap-3 overflow-hidden rounded-3xl bg-[radial-gradient(circle_at_right_40%,rgba(255,255,255,0.13)_0_62px,transparent_63px),linear-gradient(135deg,#55b8fb_0%,#63bded_100%)] px-5 py-[18px] text-white">
<img
className="h-[72px] w-[72px] object-contain"
src={mascotSrc}
alt="Quespot 캐릭터"
/>

<div>
<strong className="block text-[16px] font-black leading-snug">
소도시 미션에서
<br />
추가 보너스 포인트!
</strong>

<p className="mb-0 mt-1.5 text-[11px] text-white/80">
인구감소지역 미션 완료 시 +20% 보너스
</p>
</div>
</section>
);
}
79 changes: 79 additions & 0 deletions src/components/home/CategoryGrid.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import {
Building2,
Camera,
Compass,
Leaf,
Palette,
ShoppingBag,
Sunrise,
Utensils,
Waves,
type LucideIcon,
} from "lucide-react";
import SectionHeader from "@/components/common/SectionHeader";

const categoryToneClasses: Record<string, string> = {
violet: "bg-[#eee1ff] text-[#8b5cf6]",
green: "bg-[#e1faef] text-green-600",
amber: "bg-[#fff1c9] text-amber-600",
blue: "bg-[#dcecff] text-[#2577ff]",
pink: "bg-[#ffe1f0] text-pink-500",
cyan: "bg-[#cff9fb] text-cyan-600",
rose: "bg-[#ffdede] text-red-500",
purple: "bg-[#f0e3ff] text-purple-600",
};

const categoryIcons: Record<string, LucideIcon> = {
history: Building2,
nature: Leaf,
food: Utensils,
night: Sunrise,
photo: Camera,
activity: Waves,
shopping: ShoppingBag,
art: Palette,
};

type Category = {
id: string;
label: string;
tone: string;
};

type CategoryGridProps = {
categories: Category[];
};

export default function CategoryGrid({ categories }: CategoryGridProps) {
return (
<section className="grid gap-[14px]">
<SectionHeader title="카테고리" />

<div className="grid grid-cols-4 gap-2.5 max-[380px]:gap-2">
{categories.map((category) => {
const Icon = categoryIcons[category.id] ?? Compass;
const toneClass =
categoryToneClasses[category.tone] ?? categoryToneClasses.blue;

return (
<button
className="grid min-h-24 justify-items-center gap-2.5 rounded-[15px] border border-[#dce8f5] bg-white px-1.5 pb-3 pt-3.5 text-[#1c1c3a] shadow-[0_2px_6px_rgba(8,37,95,0.11)] transition active:scale-[0.98]"
key={category.id}
type="button"
>
<span
className={`grid h-[50px] w-[50px] place-items-center rounded-[17px] ${toneClass}`}
>
<Icon size={26} strokeWidth={2.3} />
</span>

<strong className="text-center text-[11px] leading-tight text-[#59677a] max-[380px]:text-[10px]">
{category.label}
</strong>
</button>
);
})}
</div>
</section>
);
}
43 changes: 43 additions & 0 deletions src/components/home/HomeHeader.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { Bell } from "lucide-react";

type HomeHeaderProps = {
mascotSrc: string;
notificationCount?: number;
onBellClick?: () => void;
};

export default function HomeHeader({
mascotSrc,
notificationCount = 0,
onBellClick,
}: HomeHeaderProps) {
return (
<header className="-mx-[18px] flex min-h-[62px] items-center justify-between bg-white px-[22px]">
<div className="flex items-center gap-2">
<img
className="h-[34px] w-[34px] object-contain"
src={mascotSrc}
alt="Quespot 캐릭터"
/>
<strong className="block text-[24px] font-black leading-none text-[#5bb5f8]">
Quespot
</strong>
</div>

<button
className="relative grid h-9 w-9 place-items-center rounded-full bg-transparent text-[#b7c2d1]"
type="button"
aria-label="알림"
onClick={onBellClick}
>
<Bell size={19} strokeWidth={2.4} />

{notificationCount > 0 ? (
<span className="absolute right-0 top-[2px] grid h-[18px] w-[18px] place-items-center rounded-full border-2 border-white bg-red-500 text-[10px] font-black text-white">
{notificationCount}
</span>
) : null}
</button>
</header>
);
}
50 changes: 50 additions & 0 deletions src/components/home/HomeHero.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { ChevronRight } from "lucide-react";
import { Button } from "@/components/UI";

type HomeHeroProps = {
nickname: string;
mascotSrc: string;
onExploreClick: () => void;
};

export default function HomeHero({
nickname,
mascotSrc,
onExploreClick,
}: HomeHeroProps) {
return (
<section className="-mx-[18px] grid grid-cols-[minmax(0,1fr)_152px] items-center gap-2 bg-[linear-gradient(180deg,#cdeeff_0%,#e9f7ff_100%)] px-[22px] pb-[26px] pt-[20px] max-[380px]:grid-cols-[1fr_126px]">
<div>
<span className="inline-flex items-center rounded-full bg-white/90 px-[14px] py-[7px] text-[12px] font-black text-[#5bb5f8] before:mr-2 before:h-[7px] before:w-[7px] before:rounded-full before:bg-[#5bb5f8] before:content-['']">
{nickname}님 안녕하세요!
</span>

<h1 className="m-0 mt-[18px] text-[26px] font-black leading-[1.22] tracking-[-0.3px] text-[#1c1c3a]">
미션으로 떠나는
<br />
<span className="text-[#5bb5f8]">특별한 여행</span>
</h1>

<p className="mb-[18px] mt-[12px] text-[14px] leading-[1.55] text-[#718198]">
Questy와 함께 일상을
<br />
여행으로 바꿔보세요
</p>

<Button
className="min-h-10 w-fit rounded-[18px] bg-[#5bb5f8] px-[18px] text-[13px] font-black shadow-[0_8px_16px_rgba(91,181,248,0.28)]"
icon={<ChevronRight size={16} strokeWidth={2.6} />}
onClick={onExploreClick}
>
미션 탐색
</Button>
</div>

<img
className="h-[158px] w-[158px] justify-self-end object-contain drop-shadow-[0_18px_18px_rgba(43,143,219,0.18)] max-[380px]:h-[126px] max-[380px]:w-[126px]"
src={mascotSrc}
alt="Quespot 캐릭터"
/>
</section>
);
}
69 changes: 69 additions & 0 deletions src/components/home/MissionCard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { Heart, MapPin } from "lucide-react";

const missionToneClasses: Record<string, string> = {
cream: "bg-[linear-gradient(145deg,#fff6cc_0%,#fffef6_100%)]",
lavender: "bg-[linear-gradient(145deg,#f1e8ff_0%,#fffaff_100%)]",
mint: "bg-[linear-gradient(145deg,#ddfaee_0%,#fafffd_100%)]",
peach: "bg-[linear-gradient(145deg,#ffe6d7_0%,#fff9f5_100%)]",
blue: "bg-[linear-gradient(145deg,#dff1ff_0%,#f7fcff_100%)]",
};

type Mission = {
id: string | number;
title: string;
category: string;
distance: string;
points: number;
visual: string;
tone: string;
};

type MissionCardProps = {
mission: Mission;
onClick: () => void;
};

export default function MissionCard({ mission, onClick }: MissionCardProps) {
const toneClass = missionToneClasses[mission.tone] ?? missionToneClasses.blue;

return (
<article
className="cursor-pointer overflow-hidden rounded-2xl border border-[#dce8f5] bg-white shadow-[0_2px_8px_rgba(8,37,95,0.09)] transition active:scale-[0.98]"
onClick={onClick}
>
<div className={`relative grid min-h-[122px] place-items-center ${toneClass}`}>
<button
className="absolute right-[10px] top-[10px] grid h-7 w-7 place-items-center rounded-full bg-white/85 text-[#b8c3d2]"
onClick={(event) => event.stopPropagation()}
type="button"
aria-label={`${mission.title} 찜하기`}
>
<Heart size={15} strokeWidth={2.3} />
</button>

<span className="text-[44px] drop-shadow-[0_10px_12px_rgba(8,37,95,0.12)]">
{mission.visual}
</span>
</div>

<div className="px-3 pb-[14px] pt-3">
<span className="inline-flex rounded-full bg-[#fff0c9] px-[9px] py-[5px] text-[11px] font-black text-[#f59e0b]">
{mission.category}
</span>

<strong className="mt-[9px] block text-[14px] font-black leading-[1.35] text-[#1c1c3a]">
{mission.title}
</strong>

<footer className="mt-3 flex items-center justify-between text-[12px] text-[#99a6b8]">
<span className="inline-flex items-center gap-1">
<MapPin size={12} strokeWidth={2.4} />
{mission.distance}
</span>

<b className="text-[#5bb5f8]">+{mission.points}P</b>
</footer>
</div>
</article>
);
}
Loading
Loading