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 00000000..d13db962 --- /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), + }); +}; diff --git a/apps/dog-walk/api/reactQuery/course/useFindCourse.ts b/apps/dog-walk/api/reactQuery/course/useFindCourse.ts index ac56e271..733ea69f 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 96aecb4b..ff22b662 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 0c1e4182..4090ba28 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 00000000..133a9af0 --- /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); +}; diff --git a/apps/dog-walk/app/(screens)/detail/[id].tsx b/apps/dog-walk/app/(screens)/detail/[id].tsx index 8249ddf8..72f53b10 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, @@ -100,7 +104,11 @@ export default function DetailScreen() { return ( - + { + 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) => ( + + )) + )} diff --git a/apps/dog-walk/app/(tabs)/search.tsx b/apps/dog-walk/app/(tabs)/search.tsx index 345944b3..eb205e75 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} /> diff --git a/apps/dog-walk/components/actionsheet/BlockCourseActionsheet.tsx b/apps/dog-walk/components/actionsheet/BlockCourseActionsheet.tsx new file mode 100644 index 00000000..18c12813 --- /dev/null +++ b/apps/dog-walk/components/actionsheet/BlockCourseActionsheet.tsx @@ -0,0 +1,105 @@ +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 () => { + try { + const id = await insertBlockedCourse({ + courseId, + userId: userInfo.id, + }); + + if (id) { + setShowActionsheet(false); + getGlobalHandleToast("차단이 완료되었습니다."); + router.back(); + } + } catch { + getGlobalHandleToast("차단에 실패했습니다."); + } + }; + + return ( + + + + + + + + + + + + + 이 코스를 차단하시겠습니까? + + + + + 차단하면 다시는 볼 수 없습니다. + + + 차단된 코스는 검색 결과와 추천 목록에서 제외되며, 이 작업은 되돌릴 + 수 없습니다. + + + + + + + + + + + ); +} diff --git a/apps/dog-walk/components/actionsheet/OptionsActionsheet.tsx b/apps/dog-walk/components/actionsheet/OptionsActionsheet.tsx new file mode 100644 index 00000000..2dc6dee5 --- /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 ( + + + + + + + + + 이 산책 코스 보지 않기 + + + + + ); +} diff --git a/apps/dog-walk/components/card/CourseCardSkeleton.tsx b/apps/dog-walk/components/card/CourseCardSkeleton.tsx new file mode 100644 index 00000000..7af2c6f7 --- /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 00000000..f171ffa0 --- /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/molecules/EmptyCourse.tsx b/apps/dog-walk/components/molecules/EmptyCourse.tsx new file mode 100644 index 00000000..6459bd3f --- /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 ( + + + + + + + + 추천 산책 코스가 없습니다 + + + 코스를 등록하고 다른 반려견 주인들과 공유해보세요! + + + + + + ); +} diff --git a/apps/dog-walk/components/organisms/DetailHeaderBar.tsx b/apps/dog-walk/components/organisms/DetailHeaderBar.tsx index 9ec19a44..069d1cf8 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 */} ); } 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 00000000..98db1767 --- /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 00000000..de47a345 --- /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 00000000..32779889 --- /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", + }, + }, +});