diff --git a/.vscode/vscode.code-snippets b/.vscode/vscode.code-snippets new file mode 100644 index 0000000..e5e9d1b --- /dev/null +++ b/.vscode/vscode.code-snippets @@ -0,0 +1,8 @@ +{ + "Add className={cn('')}": { + "scope": "javascript,javascriptreact,typescript,typescriptreact", + "prefix": "tw-cn", + "body": ["className={cn('${1}')}"], + "description": "className={cn('${1}')}", + }, +} diff --git a/package.json b/package.json index d320166..3b5a19f 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ "lint": "next lint" }, "dependencies": { + "@radix-ui/react-dialog": "^1.1.1", "@radix-ui/react-popover": "^1.1.1", "@react-spring/web": "^9.7.4", "@tanstack/react-query": "^5.51.21", diff --git a/src/app/(web)/[resortId]/page.tsx b/src/app/(web)/[resortId]/page.tsx new file mode 100644 index 0000000..8d0c993 --- /dev/null +++ b/src/app/(web)/[resortId]/page.tsx @@ -0,0 +1 @@ +export { default } from '@/pages/discovery-detail/ui/discovery-detail-page'; diff --git a/src/app/(web)/layout.tsx b/src/app/(web)/layout.tsx index 6eae1e6..2466710 100644 --- a/src/app/(web)/layout.tsx +++ b/src/app/(web)/layout.tsx @@ -1,9 +1,13 @@ +'use client'; + import { cn } from '@/shared/lib'; export default function Layout({ children }: { children: React.ReactNode }) { return (
-
{children}
+
+ {children} +
); } diff --git a/src/entities/discovery/model/constants.ts b/src/entities/discovery/model/constants.ts index 6e8f97f..6fa7ba8 100644 --- a/src/entities/discovery/model/constants.ts +++ b/src/entities/discovery/model/constants.ts @@ -69,6 +69,7 @@ export const WeeklyWeatherData: WeeklyWeather[] = [ export const DiscoveryData: Discovery[] = [ { + id: 1, name: '용평스키장 모나', slope: null, weather: { @@ -79,6 +80,7 @@ export const DiscoveryData: Discovery[] = [ weeklyWeather: WeeklyWeatherData, }, { + id: 2, name: '휘닉스파크', slope: 8, weather: { @@ -89,6 +91,7 @@ export const DiscoveryData: Discovery[] = [ weeklyWeather: WeeklyWeatherData, }, { + id: 3, name: '하이원스키장', slope: 10, weather: { @@ -99,6 +102,7 @@ export const DiscoveryData: Discovery[] = [ weeklyWeather: WeeklyWeatherData, }, { + id: 4, name: '비발디파크', slope: 14, weather: { @@ -109,6 +113,7 @@ export const DiscoveryData: Discovery[] = [ weeklyWeather: WeeklyWeatherData, }, { + id: 5, name: '곤지암스키장', slope: 8, weather: { diff --git a/src/entities/discovery/model/model.d.ts b/src/entities/discovery/model/model.d.ts index 1776d1e..b8ea429 100644 --- a/src/entities/discovery/model/model.d.ts +++ b/src/entities/discovery/model/model.d.ts @@ -10,6 +10,7 @@ export type WeeklyWeather = { }; export type Discovery = { + id: number; name: string; slope: number | null; weather: { diff --git a/src/pages/discovery-detail/ui/discovery-detail-page.tsx b/src/pages/discovery-detail/ui/discovery-detail-page.tsx new file mode 100644 index 0000000..86a079c --- /dev/null +++ b/src/pages/discovery-detail/ui/discovery-detail-page.tsx @@ -0,0 +1,22 @@ +'use client'; + +import DiscoveryContent from '@/widgets/discovery-detail/ui/discovery-content'; +import DiscoverySummary from '@/widgets/discovery-detail/ui/discovery-summary'; +import { Header } from '@/widgets/header/ui'; +import { DiscoveryData } from '@/entities/discovery'; +import { cn } from '@/shared/lib'; + +const DiscoveryDetailPage = ({ params }: { params: { resortId: number } }) => { + const discovery = DiscoveryData.find((discovery) => discovery.id === +params?.resortId); + if (!discovery) return null; + + return ( +
+
+ + +
+ ); +}; + +export default DiscoveryDetailPage; diff --git a/src/pages/discovery/ui/discovery-page.tsx b/src/pages/discovery/ui/discovery-page.tsx index 682eafc..98e7a15 100644 --- a/src/pages/discovery/ui/discovery-page.tsx +++ b/src/pages/discovery/ui/discovery-page.tsx @@ -20,11 +20,9 @@ const DiscoveryPageContent = () => { if (!discoveryData) return null; return ( -
-
-
- -
+
+
+
); }; diff --git a/src/shared/icons/camera.tsx b/src/shared/icons/camera.tsx index af7641d..c9771a8 100644 --- a/src/shared/icons/camera.tsx +++ b/src/shared/icons/camera.tsx @@ -21,9 +21,9 @@ const CameraIcon = () => { width="10.0996" height="9.19995" filterUnits="userSpaceOnUse" - color-interpolation-filters="sRGB" + colorInterpolationFilters="sRGB" > - + { + className?: string; +} + +const ChevronLeftIcon = ({ className, ...args }: ChevronLeftIconProps) => { + return ( + + + + + + ); +}; + +export default ChevronLeftIcon; diff --git a/src/shared/icons/share.tsx b/src/shared/icons/share.tsx new file mode 100644 index 0000000..9e09814 --- /dev/null +++ b/src/shared/icons/share.tsx @@ -0,0 +1,30 @@ +import type { SVGProps } from 'react'; +import React from 'react'; + +interface ShareIconProps extends SVGProps { + className?: string; +} + +const ShareIcon = ({ className, ...args }: ShareIconProps) => { + return ( + + + + + + ); +}; + +export default ShareIcon; diff --git a/src/shared/ui/card.tsx b/src/shared/ui/card.tsx new file mode 100644 index 0000000..b49adc0 --- /dev/null +++ b/src/shared/ui/card.tsx @@ -0,0 +1,19 @@ +import { cn } from '../lib'; + +interface CardProps extends React.HTMLAttributes { + className?: string; +} + +const Card = ({ className, children, ...props }: CardProps) => ( +
+ {children} +
+); + +export default Card; diff --git a/src/shared/ui/dialog.tsx b/src/shared/ui/dialog.tsx new file mode 100644 index 0000000..d365f65 --- /dev/null +++ b/src/shared/ui/dialog.tsx @@ -0,0 +1,87 @@ +'use client'; + +import * as DialogPrimitive from '@radix-ui/react-dialog'; +import * as React from 'react'; +import { cn } from '../lib'; + +const Dialog = DialogPrimitive.Root; + +const DialogTrigger = DialogPrimitive.Trigger; + +const DialogPortal = DialogPrimitive.Portal; + +const DialogClose = DialogPrimitive.Close; + +const DialogOverlay = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DialogOverlay.displayName = DialogPrimitive.Overlay.displayName; + +const DialogContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + {children} + + +)); +DialogContent.displayName = DialogPrimitive.Content.displayName; + +const DialogHeader = ({ className, ...props }: React.HTMLAttributes) => ( +
+); +DialogHeader.displayName = 'DialogHeader'; + +const DialogFooter = ({ className, ...props }: React.HTMLAttributes) => ( +
+); +DialogFooter.displayName = 'DialogFooter'; + +const DialogTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DialogTitle.displayName = DialogPrimitive.Title.displayName; + +const DialogDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DialogDescription.displayName = DialogPrimitive.Description.displayName; + +export { + Dialog, + DialogPortal, + DialogOverlay, + DialogClose, + DialogTrigger, + DialogContent, + DialogHeader, + DialogFooter, + DialogTitle, + DialogDescription, +}; diff --git a/src/widgets/discovery-detail/model/constants.ts b/src/widgets/discovery-detail/model/constants.ts new file mode 100644 index 0000000..9b99fa5 --- /dev/null +++ b/src/widgets/discovery-detail/model/constants.ts @@ -0,0 +1,23 @@ +export const DiscoverySummaryActionList = [ + { + name: 'bus', + title: '셔틀버스', + }, + { name: 'homepage', title: '홈페이지' }, + { name: 'vote', title: '설질 투표' }, +] as const; + +export const DiscoveryContentTabList = [ + { + name: 'webcam', + title: '웹캠 정보', + }, + { + name: 'weather', + title: '날씨', + }, + { + name: 'slop', + title: '슬로프', + }, +] as const; diff --git a/src/widgets/discovery-detail/ui/discovery-content.tsx b/src/widgets/discovery-detail/ui/discovery-content.tsx new file mode 100644 index 0000000..e490ddb --- /dev/null +++ b/src/widgets/discovery-detail/ui/discovery-content.tsx @@ -0,0 +1,50 @@ +import { useState } from 'react'; +// eslint-disable-next-line boundaries/element-types +import { WebcamMap, WebcamSlopList } from '@/widgets/webcam/ui'; +import { JISAN } from '@/entities/slop/model'; +import { cn } from '@/shared/lib'; +import { DiscoveryContentTabList } from '../model/constants'; + +const DUMMY2 = JISAN; + +const DiscoveryContent = () => { + const [selectedTab, setSelectedTab] = useState('webcam'); + const [selectedSlop, setSelectedSlop] = useState(null); + + return ( + <> +
    + {DiscoveryContentTabList.map((tab) => ( +
  • setSelectedTab(tab.name)} + > + {tab.title} +
  • + ))} +
+ {selectedTab === 'webcam' && ( + <> + + ({ + ...item, + isWebcam: !!item.webcam, + }))} + selectedSlop={selectedSlop} + setSelectedSlop={setSelectedSlop} + /> + + )} + {selectedTab === 'weather' &&
날씨
} + {selectedTab === 'slop' &&
슬로프
} + + ); +}; + +export default DiscoveryContent; diff --git a/src/widgets/discovery-detail/ui/discovery-summary-action.tsx b/src/widgets/discovery-detail/ui/discovery-summary-action.tsx new file mode 100644 index 0000000..3dda8af --- /dev/null +++ b/src/widgets/discovery-detail/ui/discovery-summary-action.tsx @@ -0,0 +1,23 @@ +import SnowIcon from '@/shared/icons/snow'; +import { cn } from '@/shared/lib'; + +interface DiscoverySummaryActionProps { + name: string; + title: string; + onClick: () => void; +} + +const DiscoverySummaryAction = ({ name, title, onClick }: DiscoverySummaryActionProps) => { + return ( +
+ +

{title}

+
+ ); +}; + +export default DiscoverySummaryAction; diff --git a/src/widgets/discovery-detail/ui/discovery-summary.tsx b/src/widgets/discovery-detail/ui/discovery-summary.tsx new file mode 100644 index 0000000..56d6eb0 --- /dev/null +++ b/src/widgets/discovery-detail/ui/discovery-summary.tsx @@ -0,0 +1,78 @@ +import { useCallback } from 'react'; +import type { Discovery } from '@/entities/discovery'; +import { cn } from '@/shared/lib'; +import Card from '@/shared/ui/card'; +// eslint-disable-next-line boundaries/element-types +import WeatherIcon from '../../discovery/ui/weather-icon'; +import { DiscoverySummaryActionList } from '../model/constants'; +import DiscoverySummaryAction from './discovery-summary-action'; +import VoteDialog from './vote-dialog'; + +const DiscoverySummary = ({ name, slope, weather }: Discovery) => { + const handleAction = useCallback( + (action: (typeof DiscoverySummaryActionList)[number]['name']) => { + switch (action) { + case 'bus': + console.log('셔틀버스'); + break; + case 'homepage': + console.log('홈페이지'); + break; + case 'vote': + break; + default: + break; + } + }, + [] + ); + + return ( +
+ +
+

{name}

+

운행중인 슬로프 {slope ?? '-'}개

+
+
+
+ +

{weather.temperature}

+
+

{weather.description}

+
+
+ + {DiscoverySummaryActionList.map((action) => { + if (action.name === 'vote') { + return ( + handleAction(action.name)} + /> + } + count={{ total: 100, voted: 50 }} + /> + ); + } else { + return ( + handleAction(action.name)} + /> + ); + } + })} + +
+ ); +}; + +export default DiscoverySummary; diff --git a/src/widgets/discovery-detail/ui/vote-dialog.tsx b/src/widgets/discovery-detail/ui/vote-dialog.tsx new file mode 100644 index 0000000..4a82f6e --- /dev/null +++ b/src/widgets/discovery-detail/ui/vote-dialog.tsx @@ -0,0 +1,82 @@ +import { useCallback, useState } from 'react'; +import CheckIcon from '@/shared/icons/check'; +import { cn } from '@/shared/lib'; +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@/shared/ui/dialog'; + +interface VoteDialogProps { + trigger: React.ReactNode; + count: { + total: number; + voted: number; + }; +} + +const VoteDialog = ({ trigger, count }: VoteDialogProps) => { + const [isGood, setIsGood] = useState(true); + + const handleVote = useCallback(() => { + console.log(isGood); + }, [isGood]); + + return ( + + {trigger} + + +

오늘의 설질

+
+ 상태가 좋아요 +

+ {count.total}명 중 {count.voted} + 명이 설질에 대해 투표했어요 +

+
+ 오늘같은 현장은 설질 괜찮을까요? +
+ +
+ + +
+ + 투표하기 + +
+
+
+ ); +}; + +export default VoteDialog; diff --git a/src/widgets/discovery/model/DUMMY.tsx b/src/widgets/discovery/model/DUMMY.tsx deleted file mode 100644 index 4c0ae6f..0000000 --- a/src/widgets/discovery/model/DUMMY.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import CloudIcon from '@/shared/icons/cloud'; -import SunIcon from '@/shared/icons/sun'; - -const WEATHER_DATA = [ - { - time: '오전 8시', - icon: , - temperature: '22°', - humidity: '20%', - }, - { - time: '오전 11시', - icon: , - temperature: '24°', - humidity: '30%', - }, - { - time: '오후 2시', - icon: , - temperature: '26°', - humidity: '40%', - }, - { - time: '오후 5시', - icon: , - temperature: '25°', - humidity: '30%', - }, -]; - -export default WEATHER_DATA; diff --git a/src/widgets/discovery/ui/discovery-card.tsx b/src/widgets/discovery/ui/discovery-card.tsx index 1787fa4..42ec41f 100644 --- a/src/widgets/discovery/ui/discovery-card.tsx +++ b/src/widgets/discovery/ui/discovery-card.tsx @@ -1,14 +1,17 @@ +import { useRouter } from 'next/navigation'; import type { Discovery } from '@/entities/discovery'; import { cn } from '@/shared/lib'; +import Card from '@/shared/ui/card'; import WeatherIcon from './weather-icon'; import WeeklyWeather from './weekly-weather'; -const DiscoveryCard = ({ name, slope, weather, weeklyWeather }: Discovery) => { +const DiscoveryCard = ({ id, name, slope, weather, weeklyWeather }: Discovery) => { + const router = useRouter(); + return ( -
router.push(`/${id}`)} >
@@ -37,7 +40,7 @@ const DiscoveryCard = ({ name, slope, weather, weeklyWeather }: Discovery) => { /> ))} -
+ ); }; export default DiscoveryCard; diff --git a/src/widgets/discovery/ui/discovery-list.tsx b/src/widgets/discovery/ui/discovery-list.tsx index 8eeb687..7533880 100644 --- a/src/widgets/discovery/ui/discovery-list.tsx +++ b/src/widgets/discovery/ui/discovery-list.tsx @@ -5,7 +5,7 @@ import DiscoveryCard from './discovery-card'; const DiscoveryList = ({ discoveryData }: { discoveryData: Discovery[] }) => (
{discoveryData.map((discovery) => ( - + ))}
); diff --git a/src/widgets/discovery/ui/summary.tsx b/src/widgets/discovery/ui/summary.tsx deleted file mode 100644 index 2925f99..0000000 --- a/src/widgets/discovery/ui/summary.tsx +++ /dev/null @@ -1,42 +0,0 @@ -import { cn } from '@/shared/lib'; - -interface SummaryProps { - name: string; - weather: number; - temperature: number; - congestion: string; -} - -const Summary = ({ name, weather, temperature, congestion }: SummaryProps) => { - return ( -
-
-

{name}

-
- - - -

{weather}

-

{temperature}°C

-
-
-
- {congestion} -
-
- ); -}; - -export default Summary; diff --git a/src/widgets/header/ui/header.tsx b/src/widgets/header/ui/header.tsx index 68afac6..275a178 100644 --- a/src/widgets/header/ui/header.tsx +++ b/src/widgets/header/ui/header.tsx @@ -1,9 +1,34 @@ +import { useRouter } from 'next/navigation'; +import ChevronLeftIcon from '@/shared/icons/chevron-left'; +import ShareIcon from '@/shared/icons/share'; import { cn } from '@/shared/lib'; -const Header = () => { +interface HeaderProps { + hasBackButton?: boolean; + hasShareButton?: boolean; +} + +const Header = ({ hasBackButton, hasShareButton }: HeaderProps) => { + const router = useRouter(); + return ( -
-

WeSki

+
+ {hasBackButton && ( + + )} +

+ WeSki +

+ {hasShareButton && ( + + )}
); }; diff --git a/src/widgets/webcam/ui/webcam-slop-list.tsx b/src/widgets/webcam/ui/webcam-slop-list.tsx index 6976421..3b4e69e 100644 --- a/src/widgets/webcam/ui/webcam-slop-list.tsx +++ b/src/widgets/webcam/ui/webcam-slop-list.tsx @@ -6,6 +6,7 @@ import CameraButton from '@/shared/ui/cam-button'; import Divider from '@/shared/ui/divider'; interface WebcamSlopListProps { + className?: string; list: { id: string; name: string; @@ -17,7 +18,12 @@ interface WebcamSlopListProps { setSelectedSlop: React.Dispatch>; } -const WebcamSlopList = ({ list, selectedSlop, setSelectedSlop }: WebcamSlopListProps) => { +const WebcamSlopList = ({ + className, + list, + selectedSlop, + setSelectedSlop, +}: WebcamSlopListProps) => { const handleSlopClick = ({ id, isOpen }: { id: string; isOpen: boolean }) => { if (!isOpen) return; if (selectedSlop === id) { @@ -27,7 +33,7 @@ const WebcamSlopList = ({ list, selectedSlop, setSelectedSlop }: WebcamSlopListP setSelectedSlop(id); }; return ( -
    +
      {list.map((item) => (