diff --git a/src/app/(home)/page.tsx b/src/app/(home)/page.tsx index 6e62c3077..cd466278a 100644 --- a/src/app/(home)/page.tsx +++ b/src/app/(home)/page.tsx @@ -1,9 +1,16 @@ +import type { Metadata } from 'next'; import Footer from '@/app/(home)/components/Footer/Footer'; import HomeCarousel from '@/app/(home)/components/HomeCarousel/HomeCarousel'; import LatestLessons from '@/app/(home)/components/LatestLessons/LatestLessons'; import PopularGenre from '@/app/(home)/components/PopularGenre/PopularGenre'; import UpcomingLessons from '@/app/(home)/components/UpcomingLessons/UpcomingLessons'; +export const metadata: Metadata = { + title: 'Dash 대쉬 | 꿈꾸던 댄스 클래스를 만나다', + description: '원데이 클래스부터 프라이빗 스쿨까지, 대쉬에서 댄서와 클래스를 검색하고 지금 바로 신청하세요!', + alternates: { canonical: '/' }, +}; + export default function Page() { return (
diff --git a/src/app/class/[id]/layout.tsx b/src/app/class/[id]/layout.tsx new file mode 100644 index 000000000..343cad2e3 --- /dev/null +++ b/src/app/class/[id]/layout.tsx @@ -0,0 +1,29 @@ +import type { Metadata } from 'next'; +import { getLessonDetail } from '@/app/class/[id]/apis/ky'; +import { getFallbackClassMetadata, lessonDetailToMetadata } from '@/app/class/[id]/utils/buildClassMetadata'; + +function parseLessonId(id: string): number | null { + const n = Number(id); + if (!Number.isInteger(n) || n <= 0) return null; + return n; +} + +// eslint-disable-next-line react-refresh/only-export-components -- Next.js generateMetadata +export async function generateMetadata({ params }: { params: Promise<{ id: string }> }): Promise { + const { id } = await params; + const lessonId = parseLessonId(id); + if (lessonId === null) { + return getFallbackClassMetadata(); + } + + try { + const data = await getLessonDetail(lessonId); + return lessonDetailToMetadata(data); + } catch { + return getFallbackClassMetadata(); + } +} + +export default function Layout({ children }: { children: React.ReactNode }) { + return children; +} diff --git a/src/app/class/[id]/utils/buildClassMetadata.ts b/src/app/class/[id]/utils/buildClassMetadata.ts new file mode 100644 index 000000000..317ffc39d --- /dev/null +++ b/src/app/class/[id]/utils/buildClassMetadata.ts @@ -0,0 +1,70 @@ +import type { Metadata } from 'next'; +import type { LessonDetailResponseTypes } from '@/app/class/[id]/types/api'; +import type { GenreTypes } from '@/app/onboarding/types/genreTypes'; +import { genreMapping } from '@/shared/constants/index'; +import { formatDateToKR } from '@/shared/utils/date'; + +export const CLASS_METADATA_TITLE_SUFFIX = 'Dash 대쉬'; + +export function getFallbackClassMetadata(): Metadata { + const absolute = `클래스 | ${CLASS_METADATA_TITLE_SUFFIX}`; + return { + title: { absolute }, + description: '댄스 클래스 정보를 확인하세요.', + }; +} + +function translateGenre(genre: LessonDetailResponseTypes['genre']): string { + if (genre === null) return ''; + return genreMapping[genre as Exclude] || String(genre); +} + +export function getLessonDateRangeLabel(lessonRounds: { startDateTime: string; endDateTime: string }[]): string { + if (!lessonRounds.length) return ''; + let minStart = lessonRounds[0].startDateTime; + let maxEnd = lessonRounds[0].endDateTime; + for (const r of lessonRounds) { + if (new Date(r.startDateTime) < new Date(minStart)) minStart = r.startDateTime; + if (new Date(r.endDateTime) > new Date(maxEnd)) maxEnd = r.endDateTime; + } + return `${formatDateToKR(minStart)} ~ ${formatDateToKR(maxEnd)}`; +} + +export function sliceDetail40(detail: string): string { + if (!detail) return ''; + return detail.length <= 40 ? detail : detail.slice(0, 40); +} + +export function buildDescriptionFromLesson(data: LessonDetailResponseTypes): string { + const dateRange = getLessonDateRangeLabel(data.lessonRound.lessonRounds); + const detail40 = sliceDetail40(data.detail); + const recommendation = data.recommendation?.trim() ?? ''; + return [dateRange, detail40, recommendation].filter(Boolean).join(' · '); +} + +function buildAbsoluteTitle(data: LessonDetailResponseTypes): string { + const parts = [data.name, data.teacherNickname, translateGenre(data.genre)].filter(Boolean); + const core = parts.join(' · '); + return core ? `${core} | ${CLASS_METADATA_TITLE_SUFFIX}` : `클래스 | ${CLASS_METADATA_TITLE_SUFFIX}`; +} + +export function lessonDetailToMetadata(data: LessonDetailResponseTypes): Metadata { + const absolute = buildAbsoluteTitle(data); + const description = buildDescriptionFromLesson(data); + const ogDescription = description || '댄스 클래스 정보를 확인하세요.'; + + return { + title: { absolute }, + description: ogDescription, + openGraph: { + title: absolute, + description: ogDescription, + ...(data.imageUrl ? { images: [{ url: data.imageUrl }] } : {}), + }, + twitter: { + title: absolute, + description: ogDescription, + ...(data.imageUrl ? { images: [data.imageUrl] } : {}), + }, + }; +} diff --git a/src/app/dancer/[id]/layout.tsx b/src/app/dancer/[id]/layout.tsx new file mode 100644 index 000000000..ed83860c5 --- /dev/null +++ b/src/app/dancer/[id]/layout.tsx @@ -0,0 +1,36 @@ +import type { Metadata } from 'next'; +import { getDancerDetail } from '@/app/dancer/[id]/apis/ky'; +import { + dancerDetailToMetadata, + getFallbackDancerMetadata, + isWithdrawnDancer, +} from '@/app/dancer/[id]/utils/buildDancerMetadata'; + +function parseDancerId(id: string): number | null { + const n = Number(id); + if (!Number.isInteger(n) || n <= 0) return null; + return n; +} + +// eslint-disable-next-line react-refresh/only-export-components -- Next.js generateMetadata +export async function generateMetadata({ params }: { params: Promise<{ id: string }> }): Promise { + const { id } = await params; + const dancerId = parseDancerId(id); + if (dancerId === null) { + return getFallbackDancerMetadata(); + } + + try { + const data = await getDancerDetail(dancerId); + if (isWithdrawnDancer(data)) { + return getFallbackDancerMetadata(); + } + return dancerDetailToMetadata(data); + } catch { + return getFallbackDancerMetadata(); + } +} + +export default function Layout({ children }: { children: React.ReactNode }) { + return children; +} diff --git a/src/app/dancer/[id]/utils/buildDancerMetadata.ts b/src/app/dancer/[id]/utils/buildDancerMetadata.ts new file mode 100644 index 000000000..a4329d83e --- /dev/null +++ b/src/app/dancer/[id]/utils/buildDancerMetadata.ts @@ -0,0 +1,80 @@ +import type { Metadata } from 'next'; +import type { DancerDetailResponseTypes } from '@/app/dancer/[id]/types/api'; + +const TITLE_SUFFIX = 'Dash 대쉬'; + +const WITHDRAWN_DETAIL_MESSAGE = '탈퇴한 회원입니다.'; + +export function getFallbackDancerMetadata(): Metadata { + const absolute = `댄서 프로필 | ${TITLE_SUFFIX}`; + return { + title: { absolute }, + description: '댄서 프로필과 진행 중인 클래스를 확인하세요.', + }; +} + +export function isWithdrawnDancer(data: DancerDetailResponseTypes): boolean { + return data.detail === WITHDRAWN_DETAIL_MESSAGE; +} + +function sliceIntro40(detail: string): string { + if (!detail || detail === WITHDRAWN_DETAIL_MESSAGE) return ''; + return detail.length <= 40 ? detail : detail.slice(0, 40); +} + +function buildSnsPart(data: DancerDetailResponseTypes): string { + const parts = [data.instagram?.trim(), data.youtube?.trim()].filter(Boolean) as string[]; + return parts.join(' · '); +} + +/** 학력 → 경력 → 수상 순, 각 배열 내부는 · 로 이어 붙임 */ +function buildHistoryPart(data: DancerDetailResponseTypes): string { + const edu = (data.educations ?? []) + .map((s) => s.trim()) + .filter(Boolean) + .join(' · '); + const exp = (data.experiences ?? []) + .map((s) => s.trim()) + .filter(Boolean) + .join(' · '); + const prize = (data.prizes ?? []) + .map((s) => s.trim()) + .filter(Boolean) + .join(' · '); + return [edu, exp, prize].filter(Boolean).join(' · '); +} + +export function buildDescriptionFromDancer(data: DancerDetailResponseTypes): string { + const intro40 = sliceIntro40(data.detail); + const sns = buildSnsPart(data); + const history = buildHistoryPart(data); + return [intro40, sns, history].filter(Boolean).join(' · '); +} + +function buildAbsoluteTitle(nickname: string): string { + const name = nickname.trim(); + const core = name ? `${name} 댄서 프로필 & 진행 클래스` : '댄서 프로필 & 진행 클래스'; + return `${core} | ${TITLE_SUFFIX}`; +} + +export function dancerDetailToMetadata(data: DancerDetailResponseTypes): Metadata { + const absolute = buildAbsoluteTitle(data.nickname); + const description = buildDescriptionFromDancer(data); + const ogDescription = description || '댄서 프로필과 진행 중인 클래스를 확인하세요.'; + const imageUrl = data.imageUrls?.[0]; + + return { + title: { absolute }, + description: ogDescription, + openGraph: { + title: absolute, + description: ogDescription, + ...(imageUrl ? { images: [{ url: imageUrl }] } : {}), + }, + twitter: { + title: absolute, + description: ogDescription, + ...(imageUrl ? { images: [imageUrl] } : {}), + }, + }; +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index a82cfdeee..9e564fb58 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,14 +1,42 @@ +import type { Metadata } from 'next'; import Script from 'next/script'; import Providers from '@/app/Providers'; import Header from '@/common/components/Header/Header'; import '@/shared/styles/index.css'; +const SITE_URL = process.env.NEXT_PUBLIC_SITE_URL || 'https://www.da-sh.kr'; + +export const metadata: Metadata = { + metadataBase: new URL(SITE_URL), + title: { + default: 'DASH - 댄스 클래스 예약 플랫폼', + template: '%s | DASH', + }, + description: + '원하는 댄스 클래스를 찾고, 예약하고, 춤추세요. 힙합, 팝핑, 왁킹, K-POP 등 다양한 장르의 댄스 클래스를 한눈에.', + keywords: ['댄스 클래스', '댄스 수업', '댄스 예약', '힙합', '팝핑', '왁킹', 'K-POP', '브레이킹', 'DASH'], + icons: { icon: '/favicon.png' }, + openGraph: { + type: 'website', + locale: 'ko_KR', + siteName: 'DASH', + title: 'DASH - 댄스 클래스 예약 플랫폼', + description: '원하는 댄스 클래스를 찾고, 예약하고, 춤추세요.', + images: [{ url: '/dash-Thumbnail.png', width: 1200, height: 630, alt: 'DASH 댄스 클래스 예약 플랫폼' }], + }, + twitter: { + card: 'summary_large_image', + title: 'DASH - 댄스 클래스 예약 플랫폼', + description: '원하는 댄스 클래스를 찾고, 예약하고, 춤추세요.', + images: ['/dash-Thumbnail.png'], + }, + robots: { index: true, follow: true }, +}; + export default function RootLayout({ children }: { children: React.ReactNode }) { return ( - - { + const searchParams = useSearchParams(); + const { selectedTab, setSelectedTab } = useTabNavigation(TAB.CLASS); + + const [genre, setGenre] = useState(searchParams?.get('genre') ?? null); + + const [searchValue, setSearchValue] = useState(''); + + const [level, setLevel] = useState(null); + const [startDate, setStartDate] = useState(''); + const [endDate, setEndDate] = useState(''); + const [selectedLabel, setSelectedLabel] = useState(SORT_LABELS.LATEST); + + const debouncedSearchValue = useDebounce({ value: searchValue, delay: 300 }); + + const sortOption = labelToSortOptionMap[selectedLabel]; + + const { data: dancerList, error } = useGetDancerList({ + keyword: debouncedSearchValue, + selectedTab: selectedTab as TAB_TYPES, + }); + + const { data: classList } = useGetClassList({ + keyword: debouncedSearchValue, + genre: genre ? genreEngMapping[genre] : undefined, + level: level ? levelEngMapping[level] : undefined, + startDate: formatDateStartTime(startDate), + endDate: formatDateEndTime(endDate), + sortOption, + selectedTab: selectedTab as TAB_TYPES, + }); + + return ( +
+ + + + + + +
+ ); +}; + +export default function SearchPage() { + return ( + + + + ); +} diff --git a/src/app/search/page.tsx b/src/app/search/page.tsx index 57e7b148a..9dcde9a02 100644 --- a/src/app/search/page.tsx +++ b/src/app/search/page.tsx @@ -1,85 +1,12 @@ -'use client'; +import type { Metadata } from 'next'; +import SearchPage from '@/app/search/SearchPage'; -import { useSearchParams } from 'next/navigation'; -import { Suspense, useState } from 'react'; -import { useGetClassList, useGetDancerList } from '@/app/search/apis/queries'; -import SearchBar from '@/app/search/components/SearchBar/SearchBar'; -import SearchHeader from '@/app/search/components/SearchHeader/SearchHeader'; -import TabContainer from '@/app/search/components/TabContainer/TabContainer'; -import type { TAB_TYPES } from '@/app/search/constants/index'; -import { DEFAULT_SORT_TAGS, SORT_LABELS, TAB } from '@/app/search/constants/index'; -import { searchPageWrapperStyle } from '@/app/search/index.css'; -import { formatDateEndTime, formatDateStartTime } from '@/app/search/utils/formatDate'; -import { handleSearchChange } from '@/app/search/utils/searchHandlers'; -import useDebounce from '@/common/hooks/useDebounce'; -import { genreEngMapping, labelToSortOptionMap, levelEngMapping } from '@/shared/constants'; -import { useTabNavigation } from '@/shared/hooks/useTabNavigation'; - -const Search = () => { - const searchParams = useSearchParams(); - const { selectedTab, setSelectedTab } = useTabNavigation(TAB.CLASS); - - const [genre, setGenre] = useState(searchParams?.get('genre') ?? null); - - const [searchValue, setSearchValue] = useState(''); - - const [level, setLevel] = useState(null); - const [startDate, setStartDate] = useState(''); - const [endDate, setEndDate] = useState(''); - const [selectedLabel, setSelectedLabel] = useState(SORT_LABELS.LATEST); - - const debouncedSearchValue = useDebounce({ value: searchValue, delay: 300 }); - - const sortOption = labelToSortOptionMap[selectedLabel]; - - const { data: dancerList, error } = useGetDancerList({ - keyword: debouncedSearchValue, - selectedTab: selectedTab as TAB_TYPES, - }); - - const { data: classList } = useGetClassList({ - keyword: debouncedSearchValue, - genre: genre ? genreEngMapping[genre] : undefined, - level: level ? levelEngMapping[level] : undefined, - startDate: formatDateStartTime(startDate), - endDate: formatDateEndTime(endDate), - sortOption, - selectedTab: selectedTab as TAB_TYPES, - }); - - return ( -
- - - - - - -
- ); +export const metadata: Metadata = { + title: '클래스 검색', + description: '장르, 난이도, 일정으로 나에게 맞는 댄스 클래스를 찾아보세요.', + alternates: { canonical: '/search' }, }; export default function Page() { - return ( - - - - ); + return ; } diff --git a/src/app/sitemap.ts b/src/app/sitemap.ts new file mode 100644 index 000000000..413c3efd5 --- /dev/null +++ b/src/app/sitemap.ts @@ -0,0 +1,20 @@ +import type { MetadataRoute } from 'next'; + +const SITE_URL = process.env.NEXT_PUBLIC_SITE_URL || 'https://www.da-sh.kr'; + +export default function sitemap(): MetadataRoute.Sitemap { + return [ + { + url: SITE_URL, + lastModified: new Date(), + changeFrequency: 'daily', + priority: 1.0, + }, + { + url: `${SITE_URL}/search`, + lastModified: new Date(), + changeFrequency: 'daily', + priority: 0.8, + }, + ]; +}