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 (
- <>
-
-
-
-
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 (
+
+ );
+}
+
+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;
Connect with us
-Join the Vite community
---
-
-
- GitHub
-
-
- -
-
-
- Discord
-
-
- -
-
-
- X.com
-
-
- -
-
-
- Bluesky
-
-
-
-