diff --git a/apps/web/src/App.css b/apps/web/src/App.css deleted file mode 100644 index f90339d8..00000000 --- a/apps/web/src/App.css +++ /dev/null @@ -1,184 +0,0 @@ -.counter { - font-size: 16px; - padding: 5px 10px; - border-radius: 5px; - color: var(--accent); - background: var(--accent-bg); - border: 2px solid transparent; - transition: border-color 0.3s; - margin-bottom: 24px; - - &:hover { - border-color: var(--accent-border); - } - &:focus-visible { - outline: 2px solid var(--accent); - outline-offset: 2px; - } -} - -.hero { - position: relative; - - .base, - .framework, - .vite { - inset-inline: 0; - margin: 0 auto; - } - - .base { - width: 170px; - position: relative; - z-index: 0; - } - - .framework, - .vite { - position: absolute; - } - - .framework { - z-index: 1; - top: 34px; - height: 28px; - transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg) - scale(1.4); - } - - .vite { - z-index: 0; - top: 107px; - height: 26px; - width: auto; - transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg) - scale(0.8); - } -} - -#center { - display: flex; - flex-direction: column; - gap: 25px; - place-content: center; - place-items: center; - flex-grow: 1; - - @media (max-width: 1024px) { - padding: 32px 20px 24px; - gap: 18px; - } -} - -#next-steps { - display: flex; - border-top: 1px solid var(--border); - text-align: left; - - & > div { - flex: 1 1 0; - padding: 32px; - @media (max-width: 1024px) { - padding: 24px 20px; - } - } - - .icon { - margin-bottom: 16px; - width: 22px; - height: 22px; - } - - @media (max-width: 1024px) { - flex-direction: column; - text-align: center; - } -} - -#docs { - border-right: 1px solid var(--border); - - @media (max-width: 1024px) { - border-right: none; - border-bottom: 1px solid var(--border); - } -} - -#next-steps ul { - list-style: none; - padding: 0; - display: flex; - gap: 8px; - margin: 32px 0 0; - - .logo { - height: 18px; - } - - a { - color: var(--text-h); - font-size: 16px; - border-radius: 6px; - background: var(--social-bg); - display: flex; - padding: 6px 12px; - align-items: center; - gap: 8px; - text-decoration: none; - transition: box-shadow 0.3s; - - &:hover { - box-shadow: var(--shadow); - } - .button-icon { - height: 18px; - width: 18px; - } - } - - @media (max-width: 1024px) { - margin-top: 20px; - flex-wrap: wrap; - justify-content: center; - - li { - flex: 1 1 calc(50% - 8px); - } - - a { - width: 100%; - justify-content: center; - box-sizing: border-box; - } - } -} - -#spacer { - height: 88px; - border-top: 1px solid var(--border); - @media (max-width: 1024px) { - height: 48px; - } -} - -.ticks { - position: relative; - width: 100%; - - &::before, - &::after { - content: ''; - position: absolute; - top: -4.5px; - border: 5px solid transparent; - } - - &::before { - left: 0; - border-left-color: var(--border); - } - &::after { - right: 0; - border-right-color: var(--border); - } -} diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 90ef0b97..aabf2ff6 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -1,103 +1,19 @@ -import { useState } from 'react'; +import * as Sentry from '@sentry/react'; +import { BrowserRouter, Route, Routes } from 'react-router-dom'; +import Layout from './layout'; +import Home from './pages/Home'; -import heroImg from './assets/hero.png'; -import reactLogo from './assets/react.svg'; -import viteLogo from './assets/vite.svg'; - -import './App.css'; +const SentryRoutes = Sentry.withSentryReactRouterV7Routing(Routes); function App() { - const [count, setCount] = useState(0); - return ( - <> -
-
- - React logo - Vite logo -
-
-

Get started

-

- Edit src/App.tsx and save to test HMR -

-
- -
- -
- -
-
- -

Documentation

-

Your questions, answered

- -
-
- -

Connect with us

-

Join the Vite community

- -
-
- -
-
- + + + }> + } /> + + + ); } diff --git a/apps/web/src/apis/client.ts b/apps/web/src/apis/client.ts new file mode 100644 index 00000000..d979266a --- /dev/null +++ b/apps/web/src/apis/client.ts @@ -0,0 +1,280 @@ +import { isApiErrorResponse, isServerErrorStatus, type ApiError, type ApiErrorResponse } from '@konect/utils/api-error'; + +const BASE_URL = import.meta.env.VITE_API_PATH; + +if (!BASE_URL) { + throw new Error('API 경로 환경변수가 설정되지 않았습니다.'); +} + +type QueryAtom = string | number | boolean; +type QueryParamValue = QueryAtom | QueryAtom[]; + +interface FetchOptions

> extends Omit { + headers?: Record; + body?: unknown; + params?: P; +} + +export const apiClient = { + get: >( + endPoint: string, + options: FetchOptions

= {} + ) => sendRequest(endPoint, { ...options, method: 'GET' }), + post: >( + endPoint: string, + options: FetchOptions

= {} + ) => sendRequest(endPoint, { ...options, method: 'POST' }), + put: >( + endPoint: string, + options: FetchOptions

= {} + ) => sendRequest(endPoint, { ...options, method: 'PUT' }), + delete: >( + endPoint: string, + options: FetchOptions

= {} + ) => sendRequest(endPoint, { ...options, method: 'DELETE' }), + patch: >( + endPoint: string, + options: FetchOptions

= {} + ) => sendRequest(endPoint, { ...options, method: 'PATCH' }), +}; + +function isFetchNetworkError(error: unknown): error is TypeError { + if (!(error instanceof TypeError)) return false; + + const message = error.message.toLowerCase(); + return ( + message.includes('failed to fetch') || + message.includes('load failed') || + message.includes('networkerror') || + message.includes('network request failed') + ); +} + +async function throwApiError(response: Response): Promise { + const errorData = await parseErrorResponse(response); + const message = isServerErrorStatus(response.status) + ? '서버 오류가 발생했습니다.' + : (errorData?.message ?? 'API 요청 실패'); + + const error = new Error(message) as ApiError; + error.status = response.status; + error.statusText = response.statusText; + error.url = response.url; + error.apiError = errorData ?? undefined; + + throw error; +} + +function rethrowFetchError(error: unknown, url: string, isTimeout = false): never { + if (error instanceof Error && error.name === 'AbortError') { + if (isTimeout) { + const timeoutError = new Error('요청 시간이 초과되었습니다.') as ApiError; + timeoutError.name = 'TimeoutError'; + timeoutError.status = 0; + timeoutError.statusText = 'TIMEOUT'; + timeoutError.url = url; + throw timeoutError; + } + + const cancelError = new Error('요청이 취소되었습니다.') as ApiError; + cancelError.name = 'Canceled'; + cancelError.status = 0; + cancelError.statusText = 'CANCELED'; + cancelError.url = url; + throw cancelError; + } + + if (isFetchNetworkError(error)) { + throw createNetworkApiError(url); + } + + throw error as Error; +} + +function createNetworkApiError(requestUrl: string): ApiError { + const error = new Error('네트워크 연결에 실패했습니다. 잠시 후 다시 시도해 주세요.') as ApiError; + error.name = 'NetworkError'; + error.status = 0; + error.statusText = 'NETWORK_ERROR'; + error.url = requestUrl; + return error; +} + +function joinUrl(baseUrl: string, path: string) { + const base = baseUrl.replace(/\/+$/, ''); + const p = path.replace(/^\/+/, ''); + return `${base}/${p}`; +} + +function buildQuery(params: Record) { + const usp = new URLSearchParams(); + + for (const [key, value] of Object.entries(params)) { + if (value == null) continue; + + if (Array.isArray(value)) { + if (value.length === 0) continue; + + for (const v of value) { + if (v == null) continue; + usp.append(key, String(v)); + } + } else { + usp.append(key, String(value)); + } + } + + return usp.toString(); +} + +function buildUrl(endPoint: string, params?: Record): string { + let url = joinUrl(BASE_URL, endPoint); + + if (params && Object.keys(params).length > 0) { + const query = buildQuery(params); + if (query) url += `?${query}`; + } + + return url; +} + +function buildFetchOptions

( + options: FetchOptions

& { method: string }, + abortSignal: AbortSignal +): RequestInit { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { headers, body, method, params, ...restOptions } = options; + + const isPlainObjectOrArray = + body !== undefined && + body !== null && + typeof body === 'object' && + (Array.isArray(body) || body.constructor === Object); + + const h: Record = { + ...(isPlainObjectOrArray ? { 'Content-Type': 'application/json' } : {}), + ...headers, + }; + + const fetchOpts: RequestInit = { + headers: h, + method, + signal: abortSignal, + credentials: 'include', + ...restOptions, + }; + + if (body !== undefined && body !== null && !['GET', 'HEAD'].includes(method)) { + fetchOpts.body = isPlainObjectOrArray ? JSON.stringify(body) : (body as BodyInit); + } + + return fetchOpts; +} + +async function executeFetch

( + endPoint: string, + options: FetchOptions

& { method: string }, + timeout: number +): Promise<{ response: Response; timeoutId: ReturnType }> { + const url = buildUrl(endPoint, options.params as Record | undefined); + + const abortController = new AbortController(); + let didTimeout = false; + const timeoutId = setTimeout(() => { + didTimeout = true; + abortController.abort(); + }, timeout); + + try { + const fetchOpts = buildFetchOptions(options, abortController.signal); + const response = await fetch(url, fetchOpts); + return { response, timeoutId }; + } catch (error) { + clearTimeout(timeoutId); + rethrowFetchError(error, url, didTimeout); + } +} + +async function sendRequest>( + endPoint: string, + options: FetchOptions

= {}, + timeout: number = 10000 +): Promise { + const { method } = options; + + if (!method) { + throw new Error('HTTP method가 설정되지 않았습니다.'); + } + + const { response, timeoutId } = await executeFetch

( + endPoint, + options as FetchOptions

& { method: string }, + timeout + ); + + const url = response.url; + + try { + if (!response.ok) { + return await throwApiError(response); + } + + return await parseResponse(response); + } catch (error) { + if (error instanceof Error && error.name === 'AbortError') { + rethrowFetchError(error, url, true); + } + + throw error; + } finally { + clearTimeout(timeoutId); + } +} + +async function parseErrorResponse(response: Response): Promise { + const contentType = response.headers.get('Content-Type') || ''; + + if (contentType.includes('application/json')) { + try { + const data: unknown = await response.json(); + return isApiErrorResponse(data) ? data : null; + } catch { + return null; + } + } + + return null; +} + +async function parseResponse(response: Response): Promise { + if (response.status === 204 || response.headers.get('Content-Length') === '0') { + return null as unknown as T; + } + + const contentType = response.headers.get('Content-Type') || ''; + + if (contentType.includes('application/json')) { + const responseText = await response.text(); + + if (responseText.trim() === '') { + return null as unknown as T; + } + + try { + return JSON.parse(responseText) as T; + } catch { + const error = new Error('응답 JSON 파싱에 실패했습니다.') as ApiError; + error.name = 'ParseError'; + error.status = response.status; + error.statusText = response.statusText; + error.url = response.url; + throw error; + } + } + + if (contentType.includes('text')) { + return (await response.text()) as unknown as T; + } + + return null as unknown as T; +} diff --git a/apps/web/src/apis/home/entity.ts b/apps/web/src/apis/home/entity.ts new file mode 100644 index 00000000..8c311e27 --- /dev/null +++ b/apps/web/src/apis/home/entity.ts @@ -0,0 +1,21 @@ +export type Region = 'SEOUL' | 'GYEONGGI' | 'CHUNGCHEONG' | 'JEOLLA' | 'GYEONGSANG' | 'GANGWON' | 'JEJU' | 'UNKNOWN'; + +export interface HomeRequestParams { + query?: string; + region?: Region; +} + +export interface University { + id: number; + name: string; + campusName: string; + region: Region; + regionName: string; + imageUrl: string; + clubCount: number; +} + +export interface HomeResponse { + totalUniversityCount: number; + universities: University[]; +} diff --git a/apps/web/src/apis/home/index.ts b/apps/web/src/apis/home/index.ts new file mode 100644 index 00000000..d7dcdd27 --- /dev/null +++ b/apps/web/src/apis/home/index.ts @@ -0,0 +1,7 @@ +import { apiClient } from '../client'; +import type { HomeRequestParams, HomeResponse } from './entity'; + +export const getHome = async (params?: HomeRequestParams) => { + const response = await apiClient.get('konect/home', { params }); + return response; +}; diff --git a/apps/web/src/apis/home/queries.ts b/apps/web/src/apis/home/queries.ts new file mode 100644 index 00000000..45e37371 --- /dev/null +++ b/apps/web/src/apis/home/queries.ts @@ -0,0 +1,17 @@ +import { queryOptions } from '@tanstack/react-query'; + +import type { HomeRequestParams } from './entity'; +import { getHome } from '.'; + +export const homeQueryKeys = { + all: ['home'] as const, + detail: (params: HomeRequestParams) => [...homeQueryKeys.all, params.query ?? '', params.region ?? ''] as const, +}; + +export const homeQueries = { + detail: (params: HomeRequestParams) => + queryOptions({ + queryKey: homeQueryKeys.detail(params), + queryFn: () => getHome(params), + }), +}; diff --git a/apps/web/src/assets/club-badge-blue.png b/apps/web/src/assets/club-badge-blue.png new file mode 100644 index 00000000..1236eaba Binary files /dev/null and b/apps/web/src/assets/club-badge-blue.png differ diff --git a/apps/web/src/assets/club-badge-red.png b/apps/web/src/assets/club-badge-red.png new file mode 100644 index 00000000..2b6f66a8 Binary files /dev/null and b/apps/web/src/assets/club-badge-red.png differ diff --git a/apps/web/src/assets/fonts/CalSans-Regular.woff2 b/apps/web/src/assets/fonts/CalSans-Regular.woff2 new file mode 100644 index 00000000..6c83a2df Binary files /dev/null and b/apps/web/src/assets/fonts/CalSans-Regular.woff2 differ diff --git a/apps/web/src/assets/hero-cat-book.png b/apps/web/src/assets/hero-cat-book.png new file mode 100644 index 00000000..1485702e Binary files /dev/null and b/apps/web/src/assets/hero-cat-book.png differ diff --git a/apps/web/src/assets/image/Logo.png b/apps/web/src/assets/image/Logo.png new file mode 100644 index 00000000..2e8f35b4 Binary files /dev/null and b/apps/web/src/assets/image/Logo.png differ diff --git a/apps/web/src/assets/svg/search-icon.svg b/apps/web/src/assets/svg/search-icon.svg new file mode 100644 index 00000000..4fec163e --- /dev/null +++ b/apps/web/src/assets/svg/search-icon.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/apps/web/src/assets/university-koreatech.png b/apps/web/src/assets/university-koreatech.png new file mode 100644 index 00000000..469d0958 Binary files /dev/null and b/apps/web/src/assets/university-koreatech.png differ diff --git a/apps/web/src/assets/university-seoul.png b/apps/web/src/assets/university-seoul.png new file mode 100644 index 00000000..e556e099 Binary files /dev/null and b/apps/web/src/assets/university-seoul.png differ diff --git a/apps/web/src/global.d.ts b/apps/web/src/global.d.ts new file mode 100644 index 00000000..1c7697bd --- /dev/null +++ b/apps/web/src/global.d.ts @@ -0,0 +1,15 @@ +interface ImportMetaEnv { + readonly VITE_API_PATH: string; + readonly VITE_SENTRY_DSN?: string; + readonly VITE_SENTRY_ENABLED?: 'true' | 'false'; + readonly VITE_SENTRY_ENVIRONMENT?: string; + readonly VITE_SENTRY_RELEASE?: string; + readonly VITE_SENTRY_TRACES_SAMPLE_RATE?: string; + readonly VITE_SENTRY_REPLAY_SESSION_SAMPLE_RATE?: string; + readonly VITE_SENTRY_REPLAY_ON_ERROR_SAMPLE_RATE?: string; + readonly VITE_SENTRY_DEBUG_TRANSACTIONS?: 'true' | 'false'; +} + +interface ImportMeta { + readonly env: ImportMetaEnv; +} diff --git a/apps/web/src/index.css b/apps/web/src/index.css index f4350d37..668d0d6c 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -4,114 +4,66 @@ @import '@konect/design-tokens/typography.css'; @import '@konect/design-tokens/theme.css'; -:root { - --text: #6b6375; - --text-h: #08060d; - --bg: #fff; - --border: #e5e4e7; - --code-bg: #f4f3ec; - --accent: #aa3bff; - --accent-bg: rgba(170, 59, 255, 0.1); - --accent-border: rgba(170, 59, 255, 0.5); - --social-bg: rgba(244, 243, 236, 0.5); - --shadow: - rgba(0, 0, 0, 0.1) 0 10px 15px -3px, rgba(0, 0, 0, 0.05) 0 4px 6px -2px; - - --sans: system-ui, 'Segoe UI', Roboto, sans-serif; - --heading: system-ui, 'Segoe UI', Roboto, sans-serif; - --mono: ui-monospace, Consolas, monospace; - - font: 18px/145% var(--sans); - letter-spacing: 0.18px; - color-scheme: light dark; - color: var(--text); - background: var(--bg); - font-synthesis: none; - text-rendering: optimizeLegibility; - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; - - @media (max-width: 1024px) { - font-size: 16px; - } +@font-face { + font-family: 'Cal Sans'; + src: url('./assets/fonts/CalSans-Regular.woff2') format('woff2'); + font-weight: 400; + font-style: normal; + font-display: swap; } -@media (prefers-color-scheme: dark) { +@layer base { :root { - --text: #9ca3af; - --text-h: #f3f4f6; - --bg: #16171d; - --border: #2e303a; - --code-bg: #1f2028; - --accent: #c084fc; - --accent-bg: rgba(192, 132, 252, 0.15); - --accent-border: rgba(192, 132, 252, 0.5); - --social-bg: rgba(47, 48, 58, 0.5); - --shadow: - rgba(0, 0, 0, 0.4) 0 10px 15px -3px, rgba(0, 0, 0, 0.25) 0 4px 6px -2px; - } + --font-cal-sans: 'Cal Sans', SUIT, Pretendard, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; - #social .button-icon { - filter: invert(1) brightness(2); + font-family: + Pretendard, + SUIT, + -apple-system, + BlinkMacSystemFont, + 'Segoe UI', + sans-serif; + color: var(--color-black); + background: var(--color-web-background); + font-synthesis: none; + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; } -} -#root { - width: 1126px; - max-width: 100%; - margin: 0 auto; - text-align: center; - border-inline: 1px solid var(--border); - min-height: 100svh; - display: flex; - flex-direction: column; - box-sizing: border-box; -} + * { + box-sizing: border-box; + } -body { - margin: 0; -} + body { + min-width: 320px; + min-height: 100vh; + margin: 0; + overflow-x: hidden; + background: var(--color-indigo-5); + } -h1, -h2 { - font-family: var(--heading); - font-weight: 500; - color: var(--text-h); -} + button, + input { + font: inherit; + } -h1 { - font-size: 56px; - letter-spacing: -1.68px; - margin: 32px 0; - @media (max-width: 1024px) { - font-size: 36px; - margin: 20px 0; + button { + cursor: pointer; } -} -h2 { - font-size: 24px; - line-height: 118%; - letter-spacing: -0.24px; - margin: 0 0 8px; - @media (max-width: 1024px) { - font-size: 20px; + + button:disabled { + cursor: default; } -} -p { - margin: 0; -} -code, -.counter { - font-family: var(--mono); - display: inline-flex; - border-radius: 4px; - color: var(--text-h); -} + p, + h1, + h2 { + margin: 0; + } -code { - font-size: 15px; - line-height: 135%; - padding: 4px 8px; - background: var(--code-bg); + a { + color: inherit; + text-decoration: none; + } } diff --git a/apps/web/src/layout/Header/index.tsx b/apps/web/src/layout/Header/index.tsx new file mode 100644 index 00000000..85cb2791 --- /dev/null +++ b/apps/web/src/layout/Header/index.tsx @@ -0,0 +1,24 @@ +import Logo from '@/assets/image/Logo.png'; + +function Header() { + return ( +

+
+ + 로고 + + Konect + + + +
+
+ ); +} + +export default Header; diff --git a/apps/web/src/layout/index.tsx b/apps/web/src/layout/index.tsx new file mode 100644 index 00000000..77a63797 --- /dev/null +++ b/apps/web/src/layout/index.tsx @@ -0,0 +1,13 @@ +import { Outlet } from 'react-router-dom'; +import Header from './Header'; + +function Layout() { + return ( + <> +
+ + + ); +} + +export default Layout; diff --git a/apps/web/src/pages/Home/index.tsx b/apps/web/src/pages/Home/index.tsx new file mode 100644 index 00000000..e8a62578 --- /dev/null +++ b/apps/web/src/pages/Home/index.tsx @@ -0,0 +1,242 @@ +import { useState, type ChangeEvent } from 'react'; +import { useDebouncedCallback } from '@konect/utils/use-debounced-callback'; +import { useSuspenseQuery } from '@tanstack/react-query'; +import type { Region, HomeRequestParams, University } from '@/apis/home/entity'; +import { homeQueries } from '@/apis/home/queries'; +import clubBadgeBlue from '@/assets/club-badge-blue.png'; +import clubBadgeRed from '@/assets/club-badge-red.png'; +import heroCatBook from '@/assets/hero-cat-book.png'; +import SearchIcon from '@/assets/svg/search-icon.svg'; + +const REGION_OPTIONS: { label: string; value?: Region }[] = [ + { label: '전체' }, + { label: '서울', value: 'SEOUL' }, + { label: '경기도', value: 'GYEONGGI' }, + { label: '충청도', value: 'CHUNGCHEONG' }, + { label: '전라도', value: 'JEOLLA' }, + { label: '경상도', value: 'GYEONGSANG' }, + { label: '강원도', value: 'GANGWON' }, + { label: '제주도', value: 'JEJU' }, +]; + +type RecentClub = { + id: number; + name: string; + category: string; + keyword: string; + logo: string; +}; + +const recentClubs: RecentClub[] = [ + { + id: 1, + name: '경영전략연구회', + category: '학술', + keyword: '경영', + logo: clubBadgeBlue, + }, + { + id: 2, + name: '경영전략연구회', + category: '학술', + keyword: '경영', + logo: clubBadgeRed, + }, + { + id: 3, + name: '경영전략연구회', + category: '학술', + keyword: '경영', + logo: clubBadgeBlue, + }, + { + id: 4, + name: '경영전략연구회', + category: '학술', + keyword: '경영', + logo: clubBadgeBlue, + }, +]; + +function Home() { + const [selectedRegion, setSelectedRegion] = useState(); + const [searchKeyword, setSearchKeyword] = useState(''); + const [searchQuery, setSearchQuery] = useState(''); + + const updateSearchQuery = useDebouncedCallback((value: string) => { + setSearchQuery(value.trim()); + }); + + const homeParams = { + ...(searchQuery ? { query: searchQuery } : {}), + ...(selectedRegion ? { region: selectedRegion } : {}), + } satisfies HomeRequestParams; + + const { data: homeData } = useSuspenseQuery(homeQueries.detail(homeParams)); + const universities = homeData.universities ?? []; + const totalUniversityCount = homeData.totalUniversityCount; + + const handleSearchKeywordChange = (event: ChangeEvent) => { + const value = event.target.value; + setSearchKeyword(value); + updateSearchQuery(value); + }; + + return ( +
+
+
+
+ 전국 대학 동아리를 한 곳에서 +
+
+

+ 입학 전에도, 재학 중에도 +
+ + 동아리 정보는 + + Konect + + 에서 + +

+
+ +

+ 대학 이름을 검색하거나 목록에서 선택하면
+ 해당 학교에 등록된 동아리 정보를 확인할 수 있어요. +

+ + +
+ +
+ +
+ {recentClubs.map((club) => ( + + ))} +
+
+ +
+ +
+
+ {REGION_OPTIONS.map((region) => { + const isSelected = region.value === selectedRegion; + + return ( + + ); + })} +
+

+ 총{totalUniversityCount}개 대학 +

+
+ +
+ {universities.length > 0 ? ( + universities.map((university) => ) + ) : ( + + )} +
+
+
+
+ ); +} + +function SectionTitle({ title, description }: { title: string; description: string }) { + return ( +
+

{title}

+

{description}

+
+ ); +} + +function RecentClubCard({ club }: { club: RecentClub }) { + return ( + + ); +} + +// function UniversityCardSkeletonList() { +// return Array.from({ length: 8 }, (_, index) => ( +//
+// +// +// +//
+// )); +// } + +function UniversityListMessage({ message }: { message: string }) { + return ( +
+ {message} +
+ ); +} + +function UniversityCard({ university }: { university: University }) { + const universityLabel = university.campusName ? `${university.name} ${university.campusName}` : university.name; + + return ( + + ); +} + +export default Home; diff --git a/apps/web/src/svg.d.ts b/apps/web/src/svg.d.ts new file mode 100644 index 00000000..23e8b74f --- /dev/null +++ b/apps/web/src/svg.d.ts @@ -0,0 +1,4 @@ +declare module '*.svg' { + const content: React.FC>; + export default content; +} diff --git a/eslint.config.js b/eslint.config.js index c9a1dc50..f4a0e134 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -34,7 +34,13 @@ export default defineConfig([ settings: { 'import/resolver': { typescript: { - project: './tsconfig.json', + project: [ + './tsconfig.json', + './apps/*/tsconfig.app.json', + './apps/*/tsconfig.node.json', + './packages/*/tsconfig.json', + ], + noWarnOnMultipleProjects: true, alwaysTryTypes: true, }, }, diff --git a/packages/design-tokens/src/colors.css b/packages/design-tokens/src/colors.css index 9fd1f3ea..470ea07d 100644 --- a/packages/design-tokens/src/colors.css +++ b/packages/design-tokens/src/colors.css @@ -6,6 +6,7 @@ /* Base */ --color-black: #323532; --color-white: #ffffff; + --color-web-background: #f8fafc; --color-background: #f4f6f9; --color-primary: #323532;