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
39 changes: 35 additions & 4 deletions src/assets/customize/combinations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,43 @@ const itemOrder = [

export function getQuestyCombinationAsset(itemIds: readonly string[]) {
const equipped = new Set(itemIds);
const name = itemOrder.filter((itemId) => equipped.has(itemId)).join("__") || "base";
const orderedItemIds = itemOrder.filter((itemId) => equipped.has(itemId));
const name = orderedItemIds.join("__") || "base";
const asset = combinationAssets[`./combinations/${name}.png`];

if (!asset) {
throw new Error(`Missing Questy combination asset: ${name}`);
if (asset) return asset;

// 서버 상태와 로컬 상태가 충돌해 존재하지 않는 조합이 만들어져도
// 화면 전체를 중단하지 않고, 가장 많은 아이템을 유지하는 조합을 찾는다.
const candidates = Array.from(
{ length: 2 ** orderedItemIds.length },
(_, mask) => mask,
).sort(
(left, right) =>
countBits(right) - countBits(left) || right - left,
);

for (const mask of candidates) {
const fallbackName =
orderedItemIds
.filter((_, index) => (mask & (1 << index)) !== 0)
.join("__") || "base";
const fallbackAsset =
combinationAssets[`./combinations/${fallbackName}.png`];

if (fallbackAsset) return fallbackAsset;
}

return combinationAssets["./combinations/base.png"];
}

function countBits(value: number) {
let count = 0;

while (value > 0) {
count += value & 1;
value >>= 1;
}

return asset;
return count;
}
42 changes: 37 additions & 5 deletions src/components/UI.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -73,12 +73,44 @@ export function WireImage({
);
}

export function SearchInput({ placeholder }: { placeholder: string }) {
type SearchInputProps = {
placeholder: string;
value: string;
onChange: (value: string) => void;
onSearch: (value: string) => void;
};

export function SearchInput({
placeholder,
value,
onChange,
onSearch,
}: SearchInputProps) {
return (
<label className="flex items-center gap-2 h-8 p-[0_12px] border-[1px_solid_#dbe8f8] rounded-2xl bg-[var(--sky-50)] h-[46px] rounded-[18px] p-[0_14px] bg-white text-[var(--muted-2)] [box-shadow:0_1px_5px_rgba(8,_37,_95,_0.08)] min-h-11 mt-[-4px] border-[1px_solid_#dce8f5] rounded-[17px] p-[0_18px] text-[#9aa8bb] [box-shadow:0_3px_9px_rgba(8,_37,_95,_0.08)]">
<Search size={16} strokeWidth={2.4} />
<input className="w-full border-0 bg-transparent text-[var(--navy)] text-[9px] outline-none text-[13px] text-[var(--ink)]" placeholder={placeholder} />
</label>
<form
className="flex items-center gap-2 h-8 p-[0_12px] border-[1px_solid_#dbe8f8] rounded-2xl bg-[var(--sky-50)] h-[46px] rounded-[18px] p-[0_14px] bg-white text-[var(--muted-2)] [box-shadow:0_1px_5px_rgba(8,_37,_95,_0.08)] min-h-11 mt-[-4px] border-[1px_solid_#dce8f5] rounded-[17px] p-[0_18px] text-[#9aa8bb] [box-shadow:0_3px_9px_rgba(8,_37,_95,_0.08)]"
onSubmit={(event) => {
event.preventDefault();
onSearch(value);
}}
role="search"
>
<button
aria-label="검색"
className="grid shrink-0 place-items-center text-[#9aa8bb]"
type="submit"
>
<Search size={16} strokeWidth={2.4} />
</button>
<input
aria-label="미션, 장소, 지역 검색"
className="w-full border-0 bg-transparent text-[var(--navy)] text-[9px] outline-none text-[13px] text-[var(--ink)]"
onChange={(event) => onChange(event.target.value)}
placeholder={placeholder}
type="search"
value={value}
/>
</form>
);
}

Expand Down
4 changes: 2 additions & 2 deletions src/components/common/Header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,10 @@ export function Header({ children }: HeaderProps) {
return (
<header className="sticky top-0 z-20 flex min-h-[66px] shrink-0 items-center justify-between border-b border-[#dcecf8] bg-white/95 px-[22px] backdrop-blur-xl">
<button
className="flex items-center gap-2"
className="flex items-center gap-1.5"
onClick={() => navigate(PATH.HOME)}
>
<img className="h-8 w-8 object-contain" src={questyProfile} alt="" />
<img className="h-11 w-11 object-contain" src={questyProfile} alt="" />
<strong className="text-[21px] font-black tracking-[-0.6px] text-[#54b4f6]">
Quespot
</strong>
Expand Down
14 changes: 8 additions & 6 deletions src/components/home/BonusBanner.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,13 @@ type BonusBannerProps = {
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 캐릭터"
/>
<span className="grid h-[76px] w-[76px] place-items-center rounded-full bg-white/85 shadow-[0_7px_18px_rgba(25,104,157,0.2)] ring-1 ring-white/70">
<img
className="h-[70px] w-[70px] object-contain drop-shadow-[0_3px_4px_rgba(25,82,120,0.18)]"
src={mascotSrc}
alt="Quespot 캐릭터"
/>
</span>

<div>
<strong className="block text-[16px] font-black leading-snug">
Expand All @@ -24,4 +26,4 @@ export default function BonusBanner({ mascotSrc }: BonusBannerProps) {
</div>
</section>
);
}
}
26 changes: 25 additions & 1 deletion src/components/home/HomeHero.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { ChevronRight } from "lucide-react";
import { ChevronRight, Cloud, Plane } from "lucide-react";

type HomeHeroProps = {
nickname: string;
Expand All @@ -15,6 +15,30 @@ export default function HomeHero({
<section className="relative flex min-h-[218px] w-full items-center overflow-hidden rounded-[28px] border border-white/70 bg-[linear-gradient(145deg,rgba(255,255,255,0.58)_0%,rgba(216,241,255,0.35)_100%)] px-[16px] shadow-[0_12px_35px_rgba(54,145,204,0.10)]">
<span className="pointer-events-none absolute -left-8 -top-10 h-24 w-24 rounded-full bg-white/45" />
<span className="pointer-events-none absolute bottom-[-36px] right-20 h-28 w-28 rounded-full bg-[#a9ddff]/25" />
<div
className="pointer-events-none absolute right-0 top-0 z-[1] h-[132px] w-[220px] overflow-hidden"
aria-hidden="true"
>
<svg
className="absolute inset-0 h-full w-full overflow-visible"
viewBox="0 0 220 132"
fill="none"
>
<path
className="animate-home-hero-route"
d="M-8 84C38 25 82 68 124 43C157 23 178 13 228 7"
stroke="rgba(91,181,248,0.42)"
strokeDasharray="5 7"
strokeLinecap="round"
strokeWidth="2"
/>
</svg>
<Cloud className="animate-questy-cloud absolute right-3 top-8 text-white/65 drop-shadow-sm" size={31} strokeWidth={1.7} />
<Cloud className="animate-questy-cloud-delayed absolute bottom-3 left-9 text-white/50" size={23} strokeWidth={1.7} />
<span className="animate-home-hero-plane absolute left-0 top-0 grid h-8 w-8 place-items-center text-[#3da5eb] drop-shadow-[0_3px_3px_rgba(35,112,164,0.22)]">
<Plane size={23} strokeWidth={2.4} fill="rgba(255,255,255,0.5)" />
</span>
</div>
<div className="z-10 min-w-0 flex-1 py-[10px]">
<span className="inline-flex h-[28px] items-center rounded-full bg-white px-[14px] text-[12px] font-black leading-none text-[#5BB5F8] shadow-[0_2px_6px_rgba(91,181,248,0.12)]">
안녕 {nickname}님!
Expand Down
16 changes: 15 additions & 1 deletion src/components/home/MissionCard.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { Heart, Loader2, MapPin } from "lucide-react";
import { categoryToneClasses } from "@/components/home/CategoryGrid";

const missionToneClasses: Record<string, string> = {
cream: "bg-[linear-gradient(145deg,#fff6cc_0%,#fffef6_100%)]",
Expand All @@ -8,6 +9,15 @@ const missionToneClasses: Record<string, string> = {
blue: "bg-[linear-gradient(145deg,#dff1ff_0%,#f7fcff_100%)]",
};

const missionCategoryToneClasses: Record<string, string> = {
역사: categoryToneClasses.violet,
문화: categoryToneClasses.pink,
자연: categoryToneClasses.green,
음식: categoryToneClasses.amber,
야경: categoryToneClasses.blue,
기타: categoryToneClasses.cyan,
};

export type RecommendedMissionCardItem = {
id: string | number;
title: string;
Expand All @@ -34,6 +44,8 @@ export default function MissionCard({
isLikePending = false,
}: MissionCardProps) {
const toneClass = missionToneClasses[mission.tone] ?? missionToneClasses.blue;
const categoryToneClass =
missionCategoryToneClasses[mission.category] ?? categoryToneClasses.blue;

return (
<article
Expand Down Expand Up @@ -79,7 +91,9 @@ export default function MissionCard({
</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]">
<span
className={`inline-flex rounded-full px-[9px] py-[5px] text-[11px] font-black ${categoryToneClass}`}
>
{mission.category}
</span>

Expand Down
6 changes: 3 additions & 3 deletions src/components/profile/ProfileChoiceSelect.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -102,17 +102,17 @@ export default function ProfileChoiceSelect({
) : null;

return (
<div className="grid gap-2">
<div className="grid min-w-0 gap-2 overflow-hidden">
<span className="text-[12px] font-bold text-[#556171]">{label} {required ? <b className="text-[#51aceb]">*</b> : <em className="font-medium not-italic text-[#9ba4af]">(선택)</em>}</span>
<button
className={`flex h-[50px] w-full items-center justify-between rounded-[15px] border bg-[#f7faff] px-[15px] text-left outline-none transition focus-visible:border-[#63b8f2] focus-visible:bg-white focus-visible:ring-4 focus-visible:ring-[#5bb5f8]/10 ${selectedOption ? "border-[#d7e8f4] text-[#222b45]" : "border-[#e4edf5] text-[#aab7c5]"}`}
className={`flex h-[50px] w-full min-w-0 items-center justify-between rounded-[15px] border bg-[#f7faff] px-3 text-left outline-none transition focus-visible:border-[#63b8f2] focus-visible:bg-white focus-visible:ring-4 focus-visible:ring-[#5bb5f8]/10 ${selectedOption ? "border-[#d7e8f4] text-[#222b45]" : "border-[#e4edf5] text-[#aab7c5]"}`}
aria-expanded={isOpen}
aria-haspopup="dialog"
aria-required={required}
onClick={() => onOpenChange(true)}
type="button"
>
<span className="type-body2 truncate">{selectedOption?.label ?? placeholder}</span>
<span className="type-body2 min-w-0 flex-1 truncate">{selectedOption?.label ?? placeholder}</span>
<ChevronDown className={`ml-2 shrink-0 text-[#8b99a8] transition-transform ${isOpen ? "rotate-180" : ""}`} aria-hidden="true" size={16} />
</button>
{sheet}
Expand Down
8 changes: 4 additions & 4 deletions src/components/profile/ProfileDatePicker.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { CalendarDays, Check, ChevronDown, ChevronLeft, ChevronRight, X } from "lucide-react";
import { CalendarDays, ChevronDown, ChevronLeft, ChevronRight, X } from "lucide-react";
import { createPortal } from "react-dom";

interface ProfileDatePickerProps {
Expand Down Expand Up @@ -106,7 +106,7 @@ export default function ProfileDatePicker({ value, onChange, isOpen, onOpenChang
const dateValue = toDateValue(visibleYear, visibleMonth, day);
const disabled = new Date(visibleYear, visibleMonth, day) > maxDate;
const selected = value === dateValue;
return <button className={`relative mx-auto grid h-10 w-10 place-items-center rounded-full text-[12px] font-bold transition disabled:opacity-25 ${selected ? "bg-[#5bb5f8] text-white shadow-[0_4px_10px_rgba(66,164,230,0.28)]" : "text-[#536171] hover:bg-[#edf7ff]"}`} disabled={disabled} key={day} onClick={() => { onChange(dateValue); onOpenChange(false); }} type="button">{day}{selected ? <Check className="absolute -right-0.5 -top-0.5 rounded-full bg-white p-0.5 text-[#46a8eb]" aria-hidden="true" size={13} strokeWidth={3} /> : null}</button>;
return <button className={`relative mx-auto grid h-10 w-10 place-items-center rounded-full text-[12px] font-bold transition disabled:opacity-25 ${selected ? "bg-[#5bb5f8] text-white shadow-[0_4px_10px_rgba(66,164,230,0.28)]" : "text-[#536171] hover:bg-[#edf7ff]"}`} disabled={disabled} key={day} onClick={() => { onChange(dateValue); onOpenChange(false); }} type="button">{day}</button>;
})}
</div>
</>
Expand All @@ -119,9 +119,9 @@ export default function ProfileDatePicker({ value, onChange, isOpen, onOpenChang
) : null;

return (
<div className="grid gap-2">
<div className="grid min-w-0 gap-2 overflow-hidden">
<span className="type-caption3 text-[#556171]">생년월일 <b className="text-[#51aceb]">*</b></span>
<button className={`flex h-[50px] w-full items-center gap-2 rounded-[15px] border bg-[#f7faff] px-[15px] text-left outline-none transition focus-visible:border-[#63b8f2] focus-visible:bg-white focus-visible:ring-4 focus-visible:ring-[#5bb5f8]/10 ${value ? "border-[#d7e8f4] text-[#222b45]" : "border-[#e4edf5] text-[#aab7c5]"}`} aria-expanded={isOpen} aria-haspopup="dialog" aria-required="true" onClick={() => onOpenChange(true)} type="button">
<button className={`flex h-[50px] w-full min-w-0 items-center gap-1.5 rounded-[15px] border bg-[#f7faff] px-3 text-left outline-none transition focus-visible:border-[#63b8f2] focus-visible:bg-white focus-visible:ring-4 focus-visible:ring-[#5bb5f8]/10 ${value ? "border-[#d7e8f4] text-[#222b45]" : "border-[#e4edf5] text-[#aab7c5]"}`} aria-expanded={isOpen} aria-haspopup="dialog" aria-required="true" onClick={() => onOpenChange(true)} type="button">
<CalendarDays className="shrink-0 text-[#68b5e8]" aria-hidden="true" size={17} />
<span className="type-body2 min-w-0 flex-1 truncate">{formattedValue}</span>
<ChevronDown className={`shrink-0 text-[#8b99a8] transition-transform ${isOpen ? "rotate-180" : ""}`} aria-hidden="true" size={16} />
Expand Down
26 changes: 21 additions & 5 deletions src/components/reward/RewardTabs.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,17 @@
import type { RewardTab } from "@/data/reward";
import { rewardTabs } from "@/data/reward";
import {
ClipboardList,
Medal,
Stamp,
type LucideIcon,
} from "lucide-react";

const rewardTabIconMap: Record<RewardTab, LucideIcon> = {
badges: Medal,
stamps: Stamp,
history: ClipboardList,
};

type RewardTabsProps = {
selectedTab: RewardTab;
Expand All @@ -14,20 +26,24 @@ export default function RewardTabs({
<section className="grid h-[54px] shrink-0 grid-cols-3 border-b border-[#EAF5FF] bg-white">
{rewardTabs.map((tab) => {
const isActive = selectedTab === tab.id;
const Icon = rewardTabIconMap[tab.id];

return (
<button
key={tab.id}
type="button"
onClick={() => onChangeTab(tab.id)}
className={[
"relative flex h-full flex-col items-center justify-center gap-[2px] text-[13px] font-black leading-none transition",
"relative flex h-full items-center justify-center gap-1.5 text-[13px] font-black leading-none transition",
isActive ? "text-[#5BB5F8]" : "text-[#A2A9B2]",
].join(" ")}
>
<span>
{tab.emoji} {tab.label}
</span>
<Icon
aria-hidden="true"
size={17}
strokeWidth={isActive ? 2.5 : 2.1}
/>
<span>{tab.label}</span>

{isActive ? (
<span className="absolute bottom-0 left-0 h-[2px] w-full bg-[#5BB5F8]" />
Expand All @@ -37,4 +53,4 @@ export default function RewardTabs({
})}
</section>
);
}
}
6 changes: 1 addition & 5 deletions src/data/reward.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ export type RewardTab = "badges" | "stamps" | "history";
export type RewardTabItem = {
id: RewardTab;
label: string;
emoji: string;
};

export type BadgeItem = {
Expand Down Expand Up @@ -45,17 +44,14 @@ export const rewardTabs: RewardTabItem[] = [
{
id: "badges",
label: "배지",
emoji: "🏅",
},
{
id: "stamps",
label: "스탬프",
emoji: "✉️",
},
{
id: "history",
label: "내역",
emoji: "📋",
},
];

Expand Down Expand Up @@ -303,4 +299,4 @@ export const rewardCoupons: CouponItem[] = [
status: "used",
usable: false,
},
];
];
Loading
Loading