Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions src/app/(home)/page.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<main>
Expand Down
29 changes: 29 additions & 0 deletions src/app/class/[id]/layout.tsx
Original file line number Diff line number Diff line change
@@ -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<Metadata> {
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;
}
70 changes: 70 additions & 0 deletions src/app/class/[id]/utils/buildClassMetadata.ts
Original file line number Diff line number Diff line change
@@ -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<GenreTypes, null>] || 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] } : {}),
},
};
}
36 changes: 36 additions & 0 deletions src/app/dancer/[id]/layout.tsx
Original file line number Diff line number Diff line change
@@ -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<Metadata> {
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;
}
80 changes: 80 additions & 0 deletions src/app/dancer/[id]/utils/buildDancerMetadata.ts
Original file line number Diff line number Diff line change
@@ -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] } : {}),
},
};
}
32 changes: 30 additions & 2 deletions src/app/layout.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<html lang="ko">
<head>
<meta charSet="UTF-8" />
<link rel="icon" href="/favicon.png" type="image/png" />
<link
rel="stylesheet"
href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard/dist/web/static/pretendard.css"
Expand Down
16 changes: 16 additions & 0 deletions src/app/robots.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import type { MetadataRoute } from 'next';

const SITE_URL = process.env.NEXT_PUBLIC_SITE_URL || 'https://www.da-sh.kr';

export default function robots(): MetadataRoute.Robots {
return {
rules: [
{
userAgent: '*',
allow: '/',
disallow: ['/my/', '/login', '/auth', '/onboarding', '/api/'],
},
],
sitemap: `${SITE_URL}/sitemap.xml`,
};
}
85 changes: 85 additions & 0 deletions src/app/search/SearchPage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
'use client';

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_TYPES>(TAB.CLASS);

const [genre, setGenre] = useState<string | null>(searchParams?.get('genre') ?? null);

const [searchValue, setSearchValue] = useState('');

const [level, setLevel] = useState<string | null>(null);
const [startDate, setStartDate] = useState('');
const [endDate, setEndDate] = useState('');
const [selectedLabel, setSelectedLabel] = useState<keyof typeof labelToSortOptionMap>(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 (
<div className={searchPageWrapperStyle}>
<SearchHeader.Root>
<SearchHeader.BackIcon />
<SearchBar searchValue={searchValue} handleSearchChange={handleSearchChange(setSearchValue)} />
</SearchHeader.Root>

<TabContainer
defaultSortTags={DEFAULT_SORT_TAGS}
genre={genre}
level={level}
startDate={startDate}
endDate={endDate}
setGenre={setGenre}
setLevel={setLevel}
setStartDate={setStartDate}
setEndDate={setEndDate}
dancerList={dancerList}
classList={classList}
error={error}
selectedLabel={selectedLabel}
setSelectedLabel={setSelectedLabel}
selectedTab={selectedTab}
setSelectedTab={setSelectedTab}
/>
</div>
);
};

export default function SearchPage() {
return (
<Suspense fallback={null}>
<Search />
</Suspense>
);
}
Loading
Loading