diff --git a/src/assets/customize/combinations.ts b/src/assets/customize/combinations.ts index 8d198f3..ad8ec12 100644 --- a/src/assets/customize/combinations.ts +++ b/src/assets/customize/combinations.ts @@ -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; } diff --git a/src/components/UI.tsx b/src/components/UI.tsx index 67c0bc4..1609e10 100644 --- a/src/components/UI.tsx +++ b/src/components/UI.tsx @@ -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 ( - +
{ + event.preventDefault(); + onSearch(value); + }} + role="search" + > + + onChange(event.target.value)} + placeholder={placeholder} + type="search" + value={value} + /> +
); } diff --git a/src/components/common/Header.tsx b/src/components/common/Header.tsx index eeb2820..055a300 100644 --- a/src/components/common/Header.tsx +++ b/src/components/common/Header.tsx @@ -15,10 +15,10 @@ export function Header({ children }: HeaderProps) { return (
{sheet} diff --git a/src/components/profile/ProfileDatePicker.tsx b/src/components/profile/ProfileDatePicker.tsx index 372b67e..1f96312 100644 --- a/src/components/profile/ProfileDatePicker.tsx +++ b/src/components/profile/ProfileDatePicker.tsx @@ -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 { @@ -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 ; + return ; })} @@ -119,9 +119,9 @@ export default function ProfileDatePicker({ value, onChange, isOpen, onOpenChang ) : null; return ( -
+
생년월일 * - + ) : ( + + )} +
+
+ + {isNotificationSettingsLoading + ? "알림 설정을 확인하고 있어요." + : isNotificationSettingsError + ? "알림 설정을 불러오지 못했어요." + : isNotificationUpdating + ? "알림 설정을 변경하고 있어요." + : notificationSettings?.pushEnabled + ? "현재 알림을 받고 있어요." + : "현재 알림이 꺼져 있어요."} + + {!isNotificationSettingsLoading && + !isNotificationSettingsError && + !isNotificationUpdating ? ( + + {notificationSettings?.pushEnabled ? "켜짐" : "꺼짐"} + + ) : null} +
+ +
diff --git a/src/pages/map/MapPage.tsx b/src/pages/map/MapPage.tsx index 37b85ca..b2ad046 100644 --- a/src/pages/map/MapPage.tsx +++ b/src/pages/map/MapPage.tsx @@ -1,4 +1,11 @@ -import { useEffect, useMemo, useState } from "react"; +import { + useEffect, + useMemo, + useRef, + useState, + type PointerEvent as ReactPointerEvent, + type WheelEvent as ReactWheelEvent, +} from "react"; import { useLocation, useNavigate } from "react-router-dom"; import { MapPin, @@ -47,6 +54,13 @@ export default function MapPage() { const [selectedSpotId, setSelectedSpotId] = useState(""); const [isPlaceListOpen, setIsPlaceListOpen] = useState(false); const [isMissionSheetOpen, setIsMissionSheetOpen] = useState(false); + const spotScrollDragRef = useRef<{ + pointerId: number; + startX: number; + scrollLeft: number; + moved: boolean; + } | null>(null); + const suppressSpotClickRef = useRef(false); const { currentLocation, locationStatus } = useCurrentLocation(); @@ -117,6 +131,45 @@ export default function MapPage() { }); }; + const handleSpotScrollPointerDown = ( + event: ReactPointerEvent, + ) => { + spotScrollDragRef.current = { + pointerId: event.pointerId, + startX: event.clientX, + scrollLeft: event.currentTarget.scrollLeft, + moved: false, + }; + event.currentTarget.setPointerCapture(event.pointerId); + }; + + const handleSpotScrollPointerMove = ( + event: ReactPointerEvent, + ) => { + const drag = spotScrollDragRef.current; + if (!drag || drag.pointerId !== event.pointerId) return; + + const distance = event.clientX - drag.startX; + if (Math.abs(distance) > 4) drag.moved = true; + event.currentTarget.scrollLeft = drag.scrollLeft - distance; + }; + + const handleSpotScrollPointerEnd = ( + event: ReactPointerEvent, + ) => { + const drag = spotScrollDragRef.current; + if (!drag || drag.pointerId !== event.pointerId) return; + + suppressSpotClickRef.current = drag.moved; + spotScrollDragRef.current = null; + if (event.currentTarget.hasPointerCapture(event.pointerId)) { + event.currentTarget.releasePointerCapture(event.pointerId); + } + window.setTimeout(() => { + suppressSpotClickRef.current = false; + }, 0); + }; + console.log(data); return ( @@ -237,7 +290,17 @@ export default function MapPage() {
-
+
) => { + event.currentTarget.scrollLeft += event.deltaX || event.deltaY; + }} + > {isNearLoading && (
데이터를 불러오는 중 입니다... @@ -253,7 +316,10 @@ export default function MapPage() { key={spot.districtCode} spot={spot} selected={selectedSpot?.districtCode === spot.districtCode} - onClick={() => handleSelectSpotCard(spot.districtCode)} + onClick={() => { + if (suppressSpotClickRef.current) return; + handleSelectSpotCard(spot.districtCode); + }} /> ))}
@@ -546,7 +612,7 @@ function SpotSummaryCard({ spot, selected, onClick }: SpotSummaryCardProps) { type="button" onClick={onClick} className={[ - "flex min-w-[82px] flex-col items-center rounded-[16px] border-2 px-[12px] py-[12px] transition active:scale-[0.98]", + "flex min-w-[82px] shrink-0 flex-col items-center rounded-[16px] border-2 px-[12px] py-[12px] transition active:scale-[0.98]", selected ? "border-[#5BB5F8] bg-primary text-white" : isCompleted diff --git a/src/pages/mission/MissionPage.tsx b/src/pages/mission/MissionPage.tsx index a478a94..a90ca14 100644 --- a/src/pages/mission/MissionPage.tsx +++ b/src/pages/mission/MissionPage.tsx @@ -115,8 +115,13 @@ export default function MissionPage() { const [searchParams, setSearchParams] = useSearchParams(); const selectedCategory = getMissionCategory(searchParams.get("category")); + const searchKeyword = searchParams.get("keyword") ?? ""; - const [keyword, setKeyword] = useState(""); + const [keyword, setKeyword] = useState(searchKeyword); + + useEffect(() => { + setKeyword(searchKeyword); + }, [searchKeyword]); const { currentLocation, locationStatus } = useCurrentLocation();