From c1dc7672324c85528acbdbf92f31c1ea9d07a10d Mon Sep 17 00:00:00 2001 From: jihyeonlim Date: Sun, 9 Nov 2025 20:25:20 +0900 Subject: [PATCH 01/12] =?UTF-8?q?feat:=20=E2=9C=A8=20=EC=82=B0=EC=B1=85=20?= =?UTF-8?q?=EC=BD=94=EC=8A=A4=20=EA=B2=8C=EC=8B=9C=EB=AC=BC=EC=9D=84=20?= =?UTF-8?q?=EC=B0=A8=EB=8B=A8=ED=95=98=EB=8A=94=20=ED=9B=85=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../reactQuery/block/useInsertBlockCourse.ts | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 apps/dog-walk/api/reactQuery/block/useInsertBlockCourse.ts diff --git a/apps/dog-walk/api/reactQuery/block/useInsertBlockCourse.ts b/apps/dog-walk/api/reactQuery/block/useInsertBlockCourse.ts new file mode 100644 index 0000000..d13db96 --- /dev/null +++ b/apps/dog-walk/api/reactQuery/block/useInsertBlockCourse.ts @@ -0,0 +1,29 @@ +import { useMutation } from "@tanstack/react-query"; +import { supabase } from "@/api/supabaseClient"; + +interface BlockCoursePayload { + userId: string; + courseId: number; +} + +const insertBlockCourse = async ({ userId, courseId }: BlockCoursePayload) => { + const { data, error } = await supabase + .from("blocked_courses") + .insert({ + created_at: new Date(), + user_id: userId, + course_id: courseId, + }) + .select("id") + .single(); + + if (error) throw error; + + return data?.id ?? null; +}; + +export const useInsertBlockCourse = () => { + return useMutation({ + mutationFn: (payload: BlockCoursePayload) => insertBlockCourse(payload), + }); +}; From 609b5923d5ba0fe72bc800c9cc900f1bc4e7892e Mon Sep 17 00:00:00 2001 From: jihyeonlim Date: Sun, 9 Nov 2025 20:26:50 +0900 Subject: [PATCH 02/12] =?UTF-8?q?feat:=20=E2=9C=A8=20OptionsActionsheet=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../actionsheet/OptionsActionsheet.tsx | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 apps/dog-walk/components/actionsheet/OptionsActionsheet.tsx diff --git a/apps/dog-walk/components/actionsheet/OptionsActionsheet.tsx b/apps/dog-walk/components/actionsheet/OptionsActionsheet.tsx new file mode 100644 index 0000000..2dc6dee --- /dev/null +++ b/apps/dog-walk/components/actionsheet/OptionsActionsheet.tsx @@ -0,0 +1,41 @@ +import { + Actionsheet, + ActionsheetBackdrop, + ActionsheetContent, + ActionsheetDragIndicator, + ActionsheetDragIndicatorWrapper, + ActionsheetItem, + ActionsheetItemText, +} from "../ui/actionsheet"; + +interface OptionsActionsheetProps { + showActionsheet: boolean; + setShowActionsheet: React.Dispatch>; + onPressBlock: () => void; +} + +export default function OptionsActionsheet({ + showActionsheet, + setShowActionsheet, + onPressBlock, +}: OptionsActionsheetProps) { + const handleClose = () => { + setShowActionsheet(false); + }; + + return ( + + + + + + + + + 이 산책 코스 보지 않기 + + + + + ); +} From 1286b584df828b210a6e5d79e7470744273a637f Mon Sep 17 00:00:00 2001 From: jihyeonlim Date: Sun, 9 Nov 2025 20:29:50 +0900 Subject: [PATCH 03/12] =?UTF-8?q?feat:=20=E2=9C=A8=20=EC=82=B0=EC=B1=85=20?= =?UTF-8?q?=EC=BD=94=EC=8A=A4=20=EA=B2=8C=EC=8B=9C=EA=B8=80=20=EC=B0=A8?= =?UTF-8?q?=EB=8B=A8=20=EA=B4=80=EB=A0=A8=20Actionsheet=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../actionsheet/BlockCourseActionsheet.tsx | 101 ++++++++++++++++++ 1 file changed, 101 insertions(+) create mode 100644 apps/dog-walk/components/actionsheet/BlockCourseActionsheet.tsx diff --git a/apps/dog-walk/components/actionsheet/BlockCourseActionsheet.tsx b/apps/dog-walk/components/actionsheet/BlockCourseActionsheet.tsx new file mode 100644 index 0000000..85b00cc --- /dev/null +++ b/apps/dog-walk/components/actionsheet/BlockCourseActionsheet.tsx @@ -0,0 +1,101 @@ +import { router } from "expo-router"; +import { useAtomValue } from "jotai"; +import { AlertTriangle } from "lucide-react-native"; +import { View } from "react-native"; +import { useInsertBlockCourse } from "@/api/reactQuery/block/useInsertBlockCourse"; +import { userAtom } from "@/atoms/userAtom"; +import { getGlobalHandleToast } from "../CustomToast"; +import { + Actionsheet, + ActionsheetBackdrop, + ActionsheetContent, + ActionsheetDragIndicator, + ActionsheetDragIndicatorWrapper, +} from "../ui/actionsheet"; +import { Button, ButtonText } from "../ui/button"; +import { HStack } from "../ui/hstack"; +import { Icon } from "../ui/icon"; +import { Text } from "../ui/text"; +import { VStack } from "../ui/vstack"; + +interface BlockCourseActionsheetProps { + showActionsheet: boolean; + setShowActionsheet: React.Dispatch>; + courseId: number; +} + +export default function BlockCourseActionsheet({ + showActionsheet, + setShowActionsheet, + courseId, +}: BlockCourseActionsheetProps) { + const userInfo = useAtomValue(userAtom); + + const { mutateAsync: insertBlockedCourse } = useInsertBlockCourse(); + + const handleClose = () => { + setShowActionsheet(false); + }; + + const handleBlock = async () => { + const id = await insertBlockedCourse({ + courseId, + userId: userInfo.id, + }); + + if (id) { + setShowActionsheet(false); + getGlobalHandleToast("차단이 완료되었습니다."); + router.back(); + } + }; + + return ( + + + + + + + + + + + + + 이 코스를 차단하시겠습니까? + + + + + 차단하면 다시는 볼 수 없습니다. + + + 차단된 코스는 검색 결과와 추천 목록에서 제외되며, 이 작업은 되돌릴 + 수 없습니다. + + + + + + + + + + + ); +} From f3c9a505a93da3191acd7dc1dc7999b68df78abc Mon Sep 17 00:00:00 2001 From: jihyeonlim Date: Sun, 9 Nov 2025 20:30:58 +0900 Subject: [PATCH 04/12] =?UTF-8?q?feat:=20=E2=9C=A8=20DetailHeaderBar?= =?UTF-8?q?=EC=97=90=20=EC=82=B0=EC=B1=85=20=EC=BD=94=EC=8A=A4=20=EC=B0=A8?= =?UTF-8?q?=EB=8B=A8=20Actionsheet=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/organisms/DetailHeaderBar.tsx | 53 ++++++++++++++++--- 1 file changed, 46 insertions(+), 7 deletions(-) diff --git a/apps/dog-walk/components/organisms/DetailHeaderBar.tsx b/apps/dog-walk/components/organisms/DetailHeaderBar.tsx index 9ec19a4..069d1cf 100644 --- a/apps/dog-walk/components/organisms/DetailHeaderBar.tsx +++ b/apps/dog-walk/components/organisms/DetailHeaderBar.tsx @@ -1,20 +1,34 @@ import { tva } from "@gluestack-ui/nativewind-utils/tva"; import { router } from "expo-router"; -import { ChevronLeft, Heart, Share2 } from "lucide-react-native"; +import { + ChevronLeft, + EllipsisVertical, + Heart, + Share2, +} from "lucide-react-native"; +import { useState } from "react"; +import BlockCourseActionsheet from "../actionsheet/BlockCourseActionsheet"; +import OptionsActionsheet from "../actionsheet/OptionsActionsheet"; import { getGlobalHandleToast } from "../CustomToast"; import { Button } from "../ui/button"; import { HStack } from "../ui/hstack"; import { Icon } from "../ui/icon"; interface IDetailHeaderBar { + courseId: number; isFavorite: boolean; setIsFavorite: React.Dispatch>; } export default function DetailHeaderBar({ + courseId, isFavorite, setIsFavorite, }: IDetailHeaderBar) { + const [showOptionsActionsheet, setShowOptionsActionsheet] = useState(false); + const [showBlockCourseActionsheet, setShowBlockCourseActionsheet] = + useState(false); + const HeartIconStyle = tva({ variants: { variant: { @@ -27,21 +41,21 @@ export default function DetailHeaderBar({ return ( - + + + {/* NOTE: MODAL ==> */} + { + setShowOptionsActionsheet(false); + setShowBlockCourseActionsheet(true); + }} + setShowActionsheet={setShowOptionsActionsheet} + showActionsheet={showOptionsActionsheet} + /> + + {/* NOTE: <== MODAL */} ); } From 79adb1e26ac17faeceb3275564ab6eec8483de2d Mon Sep 17 00:00:00 2001 From: jihyeonlim Date: Sun, 9 Nov 2025 20:33:57 +0900 Subject: [PATCH 05/12] =?UTF-8?q?refactor:=20=E2=99=BB=EF=B8=8F=20DetailHe?= =?UTF-8?q?aderBar=20props=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/dog-walk/app/(screens)/detail/[id].tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/apps/dog-walk/app/(screens)/detail/[id].tsx b/apps/dog-walk/app/(screens)/detail/[id].tsx index 8249ddf..faae194 100644 --- a/apps/dog-walk/app/(screens)/detail/[id].tsx +++ b/apps/dog-walk/app/(screens)/detail/[id].tsx @@ -100,7 +100,11 @@ export default function DetailScreen() { return ( - + Date: Sun, 9 Nov 2025 21:42:09 +0900 Subject: [PATCH 06/12] =?UTF-8?q?fix:=20=F0=9F=90=9B=20handleBlock=20?= =?UTF-8?q?=ED=95=A8=EC=88=98=EC=97=90=20=EC=98=88=EC=99=B8=20=EC=B2=98?= =?UTF-8?q?=EB=A6=AC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../actionsheet/BlockCourseActionsheet.tsx | 20 +++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/apps/dog-walk/components/actionsheet/BlockCourseActionsheet.tsx b/apps/dog-walk/components/actionsheet/BlockCourseActionsheet.tsx index 85b00cc..18c1281 100644 --- a/apps/dog-walk/components/actionsheet/BlockCourseActionsheet.tsx +++ b/apps/dog-walk/components/actionsheet/BlockCourseActionsheet.tsx @@ -38,15 +38,19 @@ export default function BlockCourseActionsheet({ }; const handleBlock = async () => { - const id = await insertBlockedCourse({ - courseId, - userId: userInfo.id, - }); + try { + const id = await insertBlockedCourse({ + courseId, + userId: userInfo.id, + }); - if (id) { - setShowActionsheet(false); - getGlobalHandleToast("차단이 완료되었습니다."); - router.back(); + if (id) { + setShowActionsheet(false); + getGlobalHandleToast("차단이 완료되었습니다."); + router.back(); + } + } catch { + getGlobalHandleToast("차단에 실패했습니다."); } }; From 046c2e8141c2a5a437ec4a71a7bce2c30664f815 Mon Sep 17 00:00:00 2001 From: jihyeonlim Date: Sun, 30 Nov 2025 19:49:48 +0900 Subject: [PATCH 07/12] =?UTF-8?q?feat:=20=E2=9C=A8=20=EC=8A=A4=EC=BC=88?= =?UTF-8?q?=EB=A0=88=ED=86=A4=20UI=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/card/CourseCardSkeleton.tsx | 22 +++ .../components/card/ReviewCardSkeleton.tsx | 20 +++ .../dog-walk/components/ui/skeleton/index.tsx | 136 ++++++++++++++++++ .../components/ui/skeleton/index.web.tsx | 103 +++++++++++++ .../components/ui/skeleton/styles.tsx | 35 +++++ 5 files changed, 316 insertions(+) create mode 100644 apps/dog-walk/components/card/CourseCardSkeleton.tsx create mode 100644 apps/dog-walk/components/card/ReviewCardSkeleton.tsx create mode 100644 apps/dog-walk/components/ui/skeleton/index.tsx create mode 100644 apps/dog-walk/components/ui/skeleton/index.web.tsx create mode 100644 apps/dog-walk/components/ui/skeleton/styles.tsx diff --git a/apps/dog-walk/components/card/CourseCardSkeleton.tsx b/apps/dog-walk/components/card/CourseCardSkeleton.tsx new file mode 100644 index 0000000..7af2c6f --- /dev/null +++ b/apps/dog-walk/components/card/CourseCardSkeleton.tsx @@ -0,0 +1,22 @@ +import { HStack } from "../ui/hstack"; +import { Skeleton } from "../ui/skeleton"; +import { VStack } from "../ui/vstack"; + +export default function CourseCardSkeleton() { + return ( + + + + + + + + + + + + + + + ); +} diff --git a/apps/dog-walk/components/card/ReviewCardSkeleton.tsx b/apps/dog-walk/components/card/ReviewCardSkeleton.tsx new file mode 100644 index 0000000..f171ffa --- /dev/null +++ b/apps/dog-walk/components/card/ReviewCardSkeleton.tsx @@ -0,0 +1,20 @@ +import { View } from "react-native"; +import { HStack } from "../ui/hstack"; +import { Skeleton } from "../ui/skeleton"; +import { VStack } from "../ui/vstack"; + +export default function ReviewCardSkeleton() { + return ( + + + + + + + + + + + + ); +} diff --git a/apps/dog-walk/components/ui/skeleton/index.tsx b/apps/dog-walk/components/ui/skeleton/index.tsx new file mode 100644 index 0000000..98db176 --- /dev/null +++ b/apps/dog-walk/components/ui/skeleton/index.tsx @@ -0,0 +1,136 @@ +import type { VariantProps } from "@gluestack-ui/nativewind-utils"; + +import React, { forwardRef } from "react"; +import { Animated, Easing, Platform, View } from "react-native"; +import { skeletonStyle, skeletonTextStyle } from "./styles"; + +type ISkeletonProps = React.ComponentProps & + VariantProps & { + isLoaded?: boolean; + startColor?: string; + speed?: number | string; + }; + +type ISkeletonTextProps = React.ComponentProps & + VariantProps & { + _lines?: number; + isLoaded?: boolean; + startColor?: string; + }; + +const Skeleton = forwardRef< + React.ComponentRef, + ISkeletonProps +>(function Skeleton( + { + className, + variant, + children, + startColor = "bg-background-200", + isLoaded = false, + speed = 2, + ...props + }, + ref, +) { + const pulseAnim = new Animated.Value(1); + const customTimingFunction = Easing.bezier(0.4, 0, 0.6, 1); + const fadeDuration = 0.6; + const animationDuration = (fadeDuration * 10000) / Number(speed); // Convert seconds to milliseconds + + const pulse = Animated.sequence([ + Animated.timing(pulseAnim, { + toValue: 1, // Start with opacity 1 + duration: animationDuration / 2, // Third of the animation duration + easing: customTimingFunction, + useNativeDriver: Platform.OS !== "web", + }), + Animated.timing(pulseAnim, { + toValue: 0.75, + duration: animationDuration / 2, // Third of the animation duration + easing: customTimingFunction, + useNativeDriver: Platform.OS !== "web", + }), + Animated.timing(pulseAnim, { + toValue: 1, + duration: animationDuration / 2, // Third of the animation duration + easing: customTimingFunction, + useNativeDriver: Platform.OS !== "web", + }), + ]); + + if (!isLoaded) { + Animated.loop(pulse).start(); + return ( + + ); + } else { + Animated.loop(pulse).stop(); + + return children; + } +}); + +const SkeletonText = forwardRef< + React.ComponentRef, + ISkeletonTextProps +>(function SkeletonText( + { + className, + _lines, + isLoaded = false, + startColor = "bg-background-200", + gap = 2, + children, + ...props + }, + ref, +) { + if (!isLoaded) { + if (_lines) { + return ( + + {Array.from({ length: _lines }).map((_, index) => ( + + ))} + + ); + } else { + return ( + + ); + } + } else { + return children; + } +}); + +Skeleton.displayName = "Skeleton"; +SkeletonText.displayName = "SkeletonText"; + +export { Skeleton, SkeletonText }; diff --git a/apps/dog-walk/components/ui/skeleton/index.web.tsx b/apps/dog-walk/components/ui/skeleton/index.web.tsx new file mode 100644 index 0000000..de47a34 --- /dev/null +++ b/apps/dog-walk/components/ui/skeleton/index.web.tsx @@ -0,0 +1,103 @@ +import type { VariantProps } from "@gluestack-ui/nativewind-utils"; + +import React from "react"; +import { skeletonStyle, skeletonTextStyle } from "./styles"; + +type ISkeletonProps = React.ComponentPropsWithoutRef<"div"> & + VariantProps & { + startColor?: string; + isLoaded?: boolean; + }; + +const Skeleton = React.forwardRef( + function Skeleton( + { + className, + variant = "rounded", + children, + speed = 2, + startColor = "bg-background-200", + isLoaded = false, + ...props + }, + ref, + ) { + if (!isLoaded) { + return ( +
+ ); + } else { + return children; + } + }, +); + +type ISkeletonTextProps = React.ComponentPropsWithoutRef<"div"> & + VariantProps & { + _lines?: number; + isLoaded?: boolean; + startColor?: string; + }; + +const SkeletonText = React.forwardRef( + function SkeletonText( + { + className, + _lines, + isLoaded = false, + startColor = "bg-background-200", + gap = 2, + children, + ...props + }, + ref, + ) { + if (!isLoaded) { + if (_lines) { + return ( +
+ {Array.from({ length: _lines }).map((_, index) => ( +
+ ))} +
+ ); + } else { + return ( +
+ ); + } + } else { + return children; + } + }, +); + +Skeleton.displayName = "Skeleton"; +SkeletonText.displayName = "SkeletonText"; + +export { Skeleton, SkeletonText }; diff --git a/apps/dog-walk/components/ui/skeleton/styles.tsx b/apps/dog-walk/components/ui/skeleton/styles.tsx new file mode 100644 index 0000000..3277988 --- /dev/null +++ b/apps/dog-walk/components/ui/skeleton/styles.tsx @@ -0,0 +1,35 @@ +import { tva } from "@gluestack-ui/nativewind-utils/tva"; + +export const skeletonStyle = tva({ + base: "w-full h-full", + variants: { + variant: { + sharp: "rounded-none", + circular: "rounded-full", + rounded: "rounded-md", + }, + speed: { + 1: "duration-75", + 2: "duration-100", + 3: "duration-150", + 4: "duration-200", + }, + }, +}); +export const skeletonTextStyle = tva({ + base: "rounded-sm w-full", + variants: { + speed: { + 1: "duration-75", + 2: "duration-100", + 3: "duration-150", + 4: "duration-200", + }, + gap: { + 1: "gap-1", + 2: "gap-2", + 3: "gap-3", + 4: "gap-4", + }, + }, +}); From ec88c78bf274205f7c3a8470ba4b01abb8e12c9c Mon Sep 17 00:00:00 2001 From: jihyeonlim Date: Sun, 30 Nov 2025 19:52:31 +0900 Subject: [PATCH 08/12] =?UTF-8?q?feat:=20=E2=9C=A8=20=EC=82=B0=EC=B1=85=20?= =?UTF-8?q?=EC=BD=94=EC=8A=A4=20=EB=8D=B0=EC=9D=B4=ED=84=B0=EA=B0=80=20?= =?UTF-8?q?=EC=97=86=EC=9D=84=20=EB=95=8C=EC=9D=98=20UI=20=EA=B0=9C?= =?UTF-8?q?=EB=B0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/molecules/EmptyCourse.tsx | 64 +++++++++++++++++++ 1 file changed, 64 insertions(+) create mode 100644 apps/dog-walk/components/molecules/EmptyCourse.tsx diff --git a/apps/dog-walk/components/molecules/EmptyCourse.tsx b/apps/dog-walk/components/molecules/EmptyCourse.tsx new file mode 100644 index 0000000..6459bd3 --- /dev/null +++ b/apps/dog-walk/components/molecules/EmptyCourse.tsx @@ -0,0 +1,64 @@ +import { tva } from "@gluestack-ui/nativewind-utils/tva"; +import { useRouter } from "expo-router"; +import { MapPin } from "lucide-react-native"; +import { View } from "react-native"; +import { Button, ButtonText } from "../ui/button"; +import { Icon } from "../ui/icon"; +import { Text } from "../ui/text"; +import { VStack } from "../ui/vstack"; + +interface EmptyCourseProps { + size?: "md" | "sm"; +} + +export default function EmptyCourse({ size = "sm" }: EmptyCourseProps) { + const router = useRouter(); + + const IconWrapperStyle = tva({ + variants: { + variant: { + sm: "h-10 w-10 items-center justify-center rounded-full bg-primary-300/10", + md: "h-16 w-16 items-center justify-center rounded-full bg-primary-300/10", + }, + }, + defaultVariants: { + variant: "sm", + }, + }); + + const IconStyle = tva({ + variants: { + variant: { + sm: "h-6 w-6 text-primary-300", + md: "h-10 w-10 text-primary-300", + }, + }, + defaultVariants: { + variant: "sm", + }, + }); + + return ( + + + + + + + + 추천 산책 코스가 없습니다 + + + 코스를 등록하고 다른 반려견 주인들과 공유해보세요! + + + + + + ); +} From a33f9b0c60b0b606a1ce7c9da99e6c47c03b738b Mon Sep 17 00:00:00 2001 From: jihyeonlim Date: Sun, 30 Nov 2025 19:54:52 +0900 Subject: [PATCH 09/12] =?UTF-8?q?feat:=20=E2=9C=A8=20=EC=B0=A8=EB=8B=A8?= =?UTF-8?q?=EB=90=9C=20=EC=82=B0=EC=B1=85=20=EC=BD=94=EC=8A=A4=20=ED=95=84?= =?UTF-8?q?=ED=84=B0=EB=A7=81=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../api/reactQuery/course/useFindCourse.ts | 27 ++++++++++++------- .../reactQuery/course/useFindNearbyCourses.ts | 23 +++++++++++++--- .../course/useFindPopularCourses.ts | 25 ++++++++++++----- apps/dog-walk/api/utils/applyBlockFilter.ts | 19 +++++++++++++ 4 files changed, 76 insertions(+), 18 deletions(-) create mode 100644 apps/dog-walk/api/utils/applyBlockFilter.ts diff --git a/apps/dog-walk/api/reactQuery/course/useFindCourse.ts b/apps/dog-walk/api/reactQuery/course/useFindCourse.ts index ac56e27..733ea69 100644 --- a/apps/dog-walk/api/reactQuery/course/useFindCourse.ts +++ b/apps/dog-walk/api/reactQuery/course/useFindCourse.ts @@ -1,24 +1,33 @@ import { useQuery } from "@tanstack/react-query"; import { supabase } from "@/api/supabaseClient"; +import { getBlockedCourseIds } from "@/api/utils/applyBlockFilter"; import { queryKeys } from "../queryKeys"; -const fetchCourse = async (id: number) => { +const fetchCourse = async (id: number, userId?: string) => { if (!id) throw new Error("Course ID 없음"); - const { data, error } = await supabase - .from("walking_courses") - .select("*") - .eq("id", id) - .single(); + const blockedIds = await getBlockedCourseIds(userId); + + let query = supabase.from("walking_courses").select("*").eq("id", id); + + if (blockedIds.length > 0) { + query = query.not("id", "in", `(${blockedIds.join(",")})`); + } + + const { data, error } = await query.single(); if (error) throw error; return data; }; -export const useFindCourse = (id: number) => { +export const useFindCourse = (id: number, userId?: string) => { return useQuery({ - queryKey: [queryKeys.course.findCourse, id], - queryFn: () => fetchCourse(id), + queryKey: [queryKeys.course.findCourse, id, userId], + queryFn: () => fetchCourse(id, userId), enabled: !!id, + staleTime: 0, + gcTime: 0, + refetchOnMount: "always", + refetchOnWindowFocus: true, }); }; diff --git a/apps/dog-walk/api/reactQuery/course/useFindNearbyCourses.ts b/apps/dog-walk/api/reactQuery/course/useFindNearbyCourses.ts index 96aecb4..ff22b66 100644 --- a/apps/dog-walk/api/reactQuery/course/useFindNearbyCourses.ts +++ b/apps/dog-walk/api/reactQuery/course/useFindNearbyCourses.ts @@ -2,6 +2,7 @@ import type { CourseRow } from "@/types/course"; import { useQuery } from "@tanstack/react-query"; import { supabase } from "@/api/supabaseClient"; +import { getBlockedCourseIds } from "@/api/utils/applyBlockFilter"; import { queryKeys } from "../queryKeys"; // NOTE: 지구 반지름 @@ -27,18 +28,28 @@ function haversineMeters(a: Point, b: Point) { const findNearbyCourses = async ({ latitude, longitude, + userId, }: { latitude: number; longitude: number; + userId?: string; }) => { if (!(latitude && longitude)) throw new Error("현재 위치 정보가 없음"); - const { data, error } = await supabase + const blockedIds = await getBlockedCourseIds(userId); + + let query = supabase .from("walking_courses") .select( "id,start_lat,start_lng,image_url,start_name,end_name,total_distance,total_time,average_rating", ); + if (blockedIds.length > 0) { + query = query.not("id", "in", `(${blockedIds.join(",")})`); + } + + const { data, error } = await query; + if (error) throw error; const current: Point = { lat: latitude, lng: longitude }; @@ -59,13 +70,19 @@ const findNearbyCourses = async ({ export const useFindNearbyCourses = ({ latitude, longitude, + userId, }: { latitude: number; longitude: number; + userId?: string; }) => { return useQuery({ - queryKey: [queryKeys.course.findNearbyCourses, latitude, longitude], - queryFn: () => findNearbyCourses({ latitude, longitude }), + queryKey: [queryKeys.course.findNearbyCourses, latitude, longitude, userId], + queryFn: () => findNearbyCourses({ latitude, longitude, userId }), enabled: !!latitude && !!longitude, + staleTime: 0, + gcTime: 0, + refetchOnMount: "always", + refetchOnWindowFocus: true, }); }; diff --git a/apps/dog-walk/api/reactQuery/course/useFindPopularCourses.ts b/apps/dog-walk/api/reactQuery/course/useFindPopularCourses.ts index 0c1e418..4090ba2 100644 --- a/apps/dog-walk/api/reactQuery/course/useFindPopularCourses.ts +++ b/apps/dog-walk/api/reactQuery/course/useFindPopularCourses.ts @@ -2,14 +2,23 @@ import type { CourseRow } from "@/types/course"; import { useQuery } from "@tanstack/react-query"; import { supabase } from "@/api/supabaseClient"; +import { getBlockedCourseIds } from "@/api/utils/applyBlockFilter"; import { queryKeys } from "../queryKeys"; -const findPopularCourses = async () => { - const { data, error } = await supabase +const findPopularCourses = async (userId?: string) => { + const blockedIds = await getBlockedCourseIds(userId); + + let query = supabase .from("walking_courses") .select( "id,start_lat,start_lng,image_url,start_name,end_name,total_distance,total_time,average_rating", - ) + ); + + if (blockedIds.length > 0) { + query = query.not("id", "in", `(${blockedIds.join(",")})`); + } + + const { data, error } = await query .order("average_rating", { ascending: false }) .limit(10); @@ -18,9 +27,13 @@ const findPopularCourses = async () => { return data as CourseRow[]; }; -export const useFindPopularCourses = () => { +export const useFindPopularCourses = (userId?: string) => { return useQuery({ - queryKey: [queryKeys.course.findPopularCourses], - queryFn: findPopularCourses, + queryKey: [queryKeys.course.findPopularCourses, userId], + queryFn: () => findPopularCourses(userId), + staleTime: 0, + gcTime: 0, + refetchOnMount: "always", + refetchOnWindowFocus: true, }); }; diff --git a/apps/dog-walk/api/utils/applyBlockFilter.ts b/apps/dog-walk/api/utils/applyBlockFilter.ts new file mode 100644 index 0000000..133a9af --- /dev/null +++ b/apps/dog-walk/api/utils/applyBlockFilter.ts @@ -0,0 +1,19 @@ +import { supabase } from "../supabaseClient"; + +export const getBlockedCourseIds = async ( + userId?: string, +): Promise => { + if (!userId) return []; + + const { data, error } = await supabase + .from("blocked_courses") + .select("course_id") + .eq("user_id", userId); + + if (error) { + console.error("Failed to fetch blocked courses:", error); + return []; + } + + return data.map((item) => item.course_id); +}; From 69e285207c8e633971e337a4fb0205b1e068a123 Mon Sep 17 00:00:00 2001 From: jihyeonlim Date: Sun, 30 Nov 2025 19:57:07 +0900 Subject: [PATCH 10/12] =?UTF-8?q?feat:=20=E2=9C=A8=20=EA=B2=80=EC=83=89=20?= =?UTF-8?q?=ED=99=94=EB=A9=B4=20=EC=B0=A8=EB=8B=A8=20=EB=B0=98=EC=98=81=20?= =?UTF-8?q?=EB=B0=8F=20=EB=B9=88=20=EC=83=81=ED=83=9C=20UI=20=EA=B0=9C?= =?UTF-8?q?=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/dog-walk/app/(tabs)/search.tsx | 31 ++++++++++++++++++++++------- 1 file changed, 24 insertions(+), 7 deletions(-) diff --git a/apps/dog-walk/app/(tabs)/search.tsx b/apps/dog-walk/app/(tabs)/search.tsx index 345944b..eb205e7 100644 --- a/apps/dog-walk/app/(tabs)/search.tsx +++ b/apps/dog-walk/app/(tabs)/search.tsx @@ -1,22 +1,37 @@ import BottomSheet, { BottomSheetView } from "@gorhom/bottom-sheet"; import * as Location from "expo-location"; import { useFocusEffect } from "expo-router"; +import { useAtomValue } from "jotai"; import { useCallback, useMemo, useRef, useState } from "react"; import { Alert, Linking, View } from "react-native"; import { FlatList, GestureHandlerRootView } from "react-native-gesture-handler"; import { useFindNearbyCourses } from "@/api/reactQuery/course/useFindNearbyCourses"; +import { userAtom } from "@/atoms/userAtom"; import CustomSafeAreaView from "@/components/CustomSafeAriaView"; import CourseDetailCard from "@/components/card/CourseDetailCard"; +import EmptyCourse from "@/components/molecules/EmptyCourse"; import NaverMap from "@/components/NaverMap"; import LocationLoading from "@/components/organisms/LocationLoading"; export default function SearchScreen() { + const userInfo = useAtomValue(userAtom); + const bottomSheetRef = useRef(null); const [location, setLocation] = useState({ latitude: 0, longitude: 0 }); const snapPoints = useMemo(() => [`${50}%`, `${100}%`], []); - const { data: courseList = [] } = useFindNearbyCourses(location); + const { + data: courseList = [], + refetch, + isLoading, + } = useFindNearbyCourses({ + ...location, + userId: userInfo.id, + }); + + const hasLocation = location.latitude !== 0 && location.longitude !== 0; + const showEmptyState = !isLoading && hasLocation; useFocusEffect( useCallback(() => { @@ -44,30 +59,32 @@ export default function SearchScreen() { } getCurrentLocation(); - }, []), + refetch(); + }, [refetch]), ); return ( - {location.latitude !== 0 && location.longitude !== 0 && ( + {hasLocation && ( )} - {(location.latitude === 0 || location.longitude === 0) && ( - - )} + {!hasLocation && } - + `search_${item.id}`} + ListEmptyComponent={ + showEmptyState ? : null + } renderItem={CourseDetailCard} /> From f6dc1a4ffcbcd10b0620d150f48f66ee804d5103 Mon Sep 17 00:00:00 2001 From: jihyeonlim Date: Sun, 30 Nov 2025 20:01:10 +0900 Subject: [PATCH 11/12] =?UTF-8?q?feat:=20=E2=9C=A8=20=ED=99=88=20=ED=99=94?= =?UTF-8?q?=EB=A9=B4=20=EC=B0=A8=EB=8B=A8=20=EB=B0=98=EC=98=81=20=EB=B0=8F?= =?UTF-8?q?=20=EB=A1=9C=EB=94=A9/=EB=B9=88=20=EC=83=81=ED=83=9C=20UI=20?= =?UTF-8?q?=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/dog-walk/app/(tabs)/index.tsx | 97 +++++++++++++++++++++--------- 1 file changed, 70 insertions(+), 27 deletions(-) diff --git a/apps/dog-walk/app/(tabs)/index.tsx b/apps/dog-walk/app/(tabs)/index.tsx index 17e51e5..c5092dd 100644 --- a/apps/dog-walk/app/(tabs)/index.tsx +++ b/apps/dog-walk/app/(tabs)/index.tsx @@ -1,33 +1,52 @@ import type { ReviewDataType } from "@/types/review"; import { Ionicons } from "@expo/vector-icons"; -import { useRouter } from "expo-router"; -import { - FlatList, - ScrollView, - Text, - TouchableOpacity, - View, -} from "react-native"; +import { useFocusEffect, useRouter } from "expo-router"; +import { useAtomValue } from "jotai"; +import { useCallback } from "react"; +import { FlatList, ScrollView, TouchableOpacity, View } from "react-native"; import { useFindPopularCourses } from "@/api/reactQuery/course/useFindPopularCourses"; import { useFindLatestReviews } from "@/api/reactQuery/review/useFindLatestReviews"; +import { userAtom } from "@/atoms/userAtom"; import CustomSafeAreaView from "@/components/CustomSafeAriaView"; import CourseCard from "@/components/card/CourseCard"; +import CourseCardSkeleton from "@/components/card/CourseCardSkeleton"; import ReviewCard from "@/components/card/ReviewCard"; +import ReviewCardSkeleton from "@/components/card/ReviewCardSkeleton"; +import EmptyCourse from "@/components/molecules/EmptyCourse"; +import { HStack } from "@/components/ui/hstack"; +import { Text } from "@/components/ui/text"; export default function HomeScreen() { const router = useRouter(); - const { data: recommededCourse } = useFindPopularCourses(); + const userInfo = useAtomValue(userAtom); - const { data: latestReview = [] } = useFindLatestReviews(); + const { + data: recommededCourse = [], + refetch, + isLoading, + } = useFindPopularCourses(userInfo.id); + + const { data: latestReview = [], isLoading: isReviewLoading } = + useFindLatestReviews(); + + useFocusEffect( + useCallback(() => { + refetch(); + }, [refetch]), + ); return ( - 안녕하세요 👋 - 댕댕이와 산책해요 + + 안녕하세요 👋 + + + 댕댕이와 산책해요 + - 산책 코스 검색하기 + + 산책 코스 검색하기 + - 추천 산책 코스 + + 추천 산책 코스 + router.push("/search")} > - 전체보기 + + 전체보기 + - `popular_course_${item.id}`} - nestedScrollEnabled={true} - renderItem={CourseCard} - showsHorizontalScrollIndicator={false} - /> + {isLoading ? ( + + + + + ) : ( + `popular_course_${item.id}`} + ListEmptyComponent={} + nestedScrollEnabled={true} + renderItem={CourseCard} + showsHorizontalScrollIndicator={false} + /> + )} 최근 리뷰 - {latestReview.map((item: ReviewDataType) => ( - - ))} + {isReviewLoading ? ( + <> + + + + ) : ( + latestReview.map((item: ReviewDataType) => ( + + )) + )} From 01f1ca05d6ce8bd49903f5b3492a20bd6b4daa6c Mon Sep 17 00:00:00 2001 From: jihyeonlim Date: Sun, 30 Nov 2025 20:03:19 +0900 Subject: [PATCH 12/12] =?UTF-8?q?feat:=20=E2=9C=A8=20=EC=82=B0=EC=B1=85=20?= =?UTF-8?q?=EC=BD=94=EC=8A=A4=20=EC=A1=B0=ED=9A=8C=EC=8B=9C=20userId=20?= =?UTF-8?q?=ED=8C=8C=EB=9D=BC=EB=AF=B8=ED=84=B0=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/dog-walk/app/(screens)/detail/[id].tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/apps/dog-walk/app/(screens)/detail/[id].tsx b/apps/dog-walk/app/(screens)/detail/[id].tsx index faae194..72f53b1 100644 --- a/apps/dog-walk/app/(screens)/detail/[id].tsx +++ b/apps/dog-walk/app/(screens)/detail/[id].tsx @@ -1,8 +1,10 @@ import { useLocalSearchParams } from "expo-router"; +import { useAtomValue } from "jotai"; import { memo, useMemo, useState } from "react"; import { Dimensions, FlatList, Image, ScrollView, View } from "react-native"; import { useFindCourse } from "@/api/reactQuery/course/useFindCourse"; import Images from "@/assets/images"; +import { userAtom } from "@/atoms/userAtom"; import IconText from "@/components/atoms/IconText"; import CustomSafeAreaView from "@/components/CustomSafeAriaView"; import DetailDescription from "@/components/organisms/DetailDescription"; @@ -18,6 +20,8 @@ import { IconTextType, TabKeyType } from "@/types/displayType"; import { formatDistanceKm } from "@/utils/number"; export default function DetailScreen() { + const userInfo = useAtomValue(userAtom); + const screenWidth = Dimensions.get("window").width - 32; const { id } = useLocalSearchParams(); @@ -26,7 +30,7 @@ export default function DetailScreen() { const [isFavorite, setIsFavorite] = useState(false); - const { data } = useFindCourse(Number(id)); + const { data } = useFindCourse(Number(id), userInfo.id); const { total_time = 0,