diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9d3e2612..648b661f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,6 +13,7 @@ jobs: runs-on: ubuntu-latest outputs: web: ${{ steps.filter.outputs.web }} + university-web: ${{ steps.filter.outputs.university-web }} admin: ${{ steps.filter.outputs.admin }} root: ${{ steps.filter.outputs.root }} steps: @@ -26,12 +27,15 @@ jobs: filters: | web: - 'apps/web/**' + university-web: + - 'apps/university-web/**' admin: - 'apps/admin/**' root: - 'package.json' - 'pnpm-lock.yaml' - 'pnpm-workspace.yaml' + - 'scripts/**' - 'turbo.json' - '.github/workflows/**' @@ -62,6 +66,33 @@ jobs: - name: Run checks (lint & typecheck) run: pnpm --filter @solid-connect/web run ci:check + # University Web 앱 품질 체크 + university-web-quality-check: + name: University Web - Quality Check + runs-on: ubuntu-latest + needs: detect-changes + if: needs.detect-changes.outputs.university-web == 'true' || needs.detect-changes.outputs.root == 'true' + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Install pnpm + uses: pnpm/action-setup@v3 + with: + version: 9 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: "22.x" + cache: "pnpm" + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Run checks (lint & typecheck) + run: pnpm --filter @solid-connect/university-web run ci:check + # Admin 앱 품질 체크 admin-quality-check: name: Admin - Quality Check @@ -120,6 +151,39 @@ jobs: run: pnpm --filter @solid-connect/web run build env: NODE_ENV: production + UNIVERSITY_WEB_DOMAIN: https://university-web.ci.local + + # University Web 앱 빌드 + university-web-build: + name: University Web - Build + runs-on: ubuntu-latest + needs: [detect-changes, university-web-quality-check] + if: | + always() && + (needs.detect-changes.outputs.university-web == 'true' || needs.detect-changes.outputs.root == 'true') && + needs.university-web-quality-check.result == 'success' + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Install pnpm + uses: pnpm/action-setup@v3 + with: + version: 9 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: "22.x" + cache: "pnpm" + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Build university web application + run: pnpm --filter @solid-connect/university-web run build + env: + NODE_ENV: production # Admin 앱 빌드 admin-build: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 32447364..c69d6ea2 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -7,14 +7,16 @@ on: workflow_dispatch: inputs: target: - description: "Promote target" + description: "Promote target (both keeps the legacy web+admin selection)" required: true default: "both" type: choice options: + - all - both - web - admin + - university force_redeploy: description: "When up to date, skip divergence and print manual redeploy guidance" required: true @@ -71,6 +73,12 @@ jobs: admin) RELEASE_BRANCHES="release-admin" ;; + university) + RELEASE_BRANCHES="release-university" + ;; + all) + RELEASE_BRANCHES="release-web release-admin release-university" + ;; both) RELEASE_BRANCHES="release-web release-admin" ;; diff --git a/.husky/pre-commit b/.husky/pre-commit index 68038af8..c39c416a 100644 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -2,6 +2,7 @@ echo "🔍 Running CI parity checks before commit..." CHANGED_FILES=$(git diff --cached --name-only) RUN_WEB=0 +RUN_UNIVERSITY_WEB=0 RUN_ADMIN=0 for FILE in $CHANGED_FILES; do @@ -9,11 +10,15 @@ for FILE in $CHANGED_FILES; do apps/web/*) RUN_WEB=1 ;; + apps/university-web/*) + RUN_UNIVERSITY_WEB=1 + ;; apps/admin/*) RUN_ADMIN=1 ;; - package.json|pnpm-lock.yaml|pnpm-workspace.yaml|turbo.json|.github/workflows/*) + package.json|pnpm-lock.yaml|pnpm-workspace.yaml|scripts/*|turbo.json|.github/workflows/*) RUN_WEB=1 + RUN_UNIVERSITY_WEB=1 RUN_ADMIN=1 ;; esac @@ -23,11 +28,15 @@ if [ "$RUN_WEB" -eq 1 ]; then pnpm --filter @solid-connect/web run ci:check fi +if [ "$RUN_UNIVERSITY_WEB" -eq 1 ]; then + pnpm --filter @solid-connect/university-web run ci:check +fi + if [ "$RUN_ADMIN" -eq 1 ]; then pnpm --filter @solid-connect/admin run ci:check fi -if [ "$RUN_WEB" -eq 0 ] && [ "$RUN_ADMIN" -eq 0 ]; then +if [ "$RUN_WEB" -eq 0 ] && [ "$RUN_UNIVERSITY_WEB" -eq 0 ] && [ "$RUN_ADMIN" -eq 0 ]; then echo "ℹ️ No CI-targeted changes detected; skipping parity checks." fi diff --git a/.husky/pre-push b/.husky/pre-push index f49f8c48..b6052a2e 100755 --- a/.husky/pre-push +++ b/.husky/pre-push @@ -15,6 +15,7 @@ else fi RUN_WEB=0 +RUN_UNIVERSITY_WEB=0 RUN_ADMIN=0 for FILE in $CHANGED_FILES; do @@ -22,11 +23,15 @@ for FILE in $CHANGED_FILES; do apps/web/*) RUN_WEB=1 ;; + apps/university-web/*) + RUN_UNIVERSITY_WEB=1 + ;; apps/admin/*) RUN_ADMIN=1 ;; - package.json|pnpm-lock.yaml|pnpm-workspace.yaml|turbo.json|.github/workflows/*) + package.json|pnpm-lock.yaml|pnpm-workspace.yaml|scripts/*|turbo.json|.github/workflows/*) RUN_WEB=1 + RUN_UNIVERSITY_WEB=1 RUN_ADMIN=1 ;; esac @@ -34,7 +39,12 @@ done if [ "$RUN_WEB" -eq 1 ]; then pnpm --filter @solid-connect/web run ci:check - NODE_ENV=production pnpm --filter @solid-connect/web run build + NODE_ENV=production UNIVERSITY_WEB_DOMAIN=https://university-web.ci.local pnpm --filter @solid-connect/web run build +fi + +if [ "$RUN_UNIVERSITY_WEB" -eq 1 ]; then + pnpm --filter @solid-connect/university-web run ci:check + NODE_ENV=production pnpm --filter @solid-connect/university-web run build fi if [ "$RUN_ADMIN" -eq 1 ]; then @@ -42,7 +52,7 @@ if [ "$RUN_ADMIN" -eq 1 ]; then NODE_ENV=production pnpm --filter @solid-connect/admin run build fi -if [ "$RUN_WEB" -eq 0 ] && [ "$RUN_ADMIN" -eq 0 ]; then +if [ "$RUN_WEB" -eq 0 ] && [ "$RUN_UNIVERSITY_WEB" -eq 0 ] && [ "$RUN_ADMIN" -eq 0 ]; then echo "ℹ️ No CI-targeted changes detected; skipping parity builds." fi diff --git a/apps/university-web/.env b/apps/university-web/.env new file mode 100644 index 00000000..75c471fe --- /dev/null +++ b/apps/university-web/.env @@ -0,0 +1,40 @@ +# Shared configuration across all environments +# Environment-specific values are in .env.development, .env.preview, .env.production + +NEXT_PUBLIC_IMAGE_URL=https://cdn.default.solid-connection.com +NEXT_PUBLIC_UPLOADED_IMAGE_URL=https://cdn.upload.solid-connection.com + +# google maps +NEXT_PUBLIC_GOOGLE_MAP_API_KEY= + +# google analytics +NEXT_PUBLIC_GOOGLE_ANALYTICS_ID=G-V1KLYZC1DS + +# apple +NEXT_PUBLIC_APPLE_CLIENT_ID=com.basakcrispy.solidconnection.auth +NEXT_PUBLIC_APPLE_SCOPE="email" + +NEXT_PUBLIC_CONTACT_LINK=https://open.kakao.com/o/grTrKWdg + +# AI Inspector (server-only) +# Set these values in .env.local or deployment environment variables. +# AI_INSPECTOR_FIREBASE_PROJECT_ID= +# AI_INSPECTOR_FIREBASE_CLIENT_EMAIL= +# AI_INSPECTOR_FIREBASE_PRIVATE_KEY= +# AI_INSPECTOR_FIRESTORE_COLLECTION=aiInspectorTasks + +# BELOW IS EXAMPLE FOR .env.local + +# firebase private +# FIREBASE_PROJECT_ID= +# FIREBASE_PRIVATE_KEY_ID= +# FIREBASE_PRIVATE_KEY= +# FIREBASE_CLIENT_EMAIL= +# FIREBASE_CLIENT_ID= +# FIREBASE_CLIENT_X509_CERT_URL= + +# sentry +# SENTRY_PROJECT= +# SENTRY_ORG= +# SENTRY_DSN= +# SENTRY_ENVIRONMENT= diff --git a/apps/university-web/.env.development b/apps/university-web/.env.development new file mode 100644 index 00000000..bbf8a2de --- /dev/null +++ b/apps/university-web/.env.development @@ -0,0 +1,10 @@ +SENTRY_ENVIRONMENT=development + +# web page +NEXT_PUBLIC_WEB_URL=http://localhost:3000 + +# api server +NEXT_PUBLIC_API_SERVER_URL=https://api.stage.solid-connection.com + +# kakao +NEXT_PUBLIC_KAKAO_JS_KEY= diff --git a/apps/university-web/.env.production b/apps/university-web/.env.production new file mode 100644 index 00000000..388df289 --- /dev/null +++ b/apps/university-web/.env.production @@ -0,0 +1,10 @@ +SENTRY_ENVIRONMENT=production + +# web page +NEXT_PUBLIC_WEB_URL=https://www.solid-connection.com + +# api server +NEXT_PUBLIC_API_SERVER_URL=https://api.solid-connection.com + +# kakao +NEXT_PUBLIC_KAKAO_JS_KEY= diff --git a/apps/university-web/AUTHENTICATION.md b/apps/university-web/AUTHENTICATION.md new file mode 100644 index 00000000..9ee799d5 --- /dev/null +++ b/apps/university-web/AUTHENTICATION.md @@ -0,0 +1,60 @@ +# Authentication & Authorization + +## Overview + +웹 인증은 **클라이언트 재발급/인터셉터 중심**으로 동작합니다. + +- 서버 진입 시 middleware에서 보호 경로를 `/login`으로 선리다이렉트하지 않습니다. +- 인증 실패 시점은 페이지 렌더/데이터 요청 단계에서 결정됩니다. + +## Current Flow + +### 1. App Initialization + +- `ReissueProvider`에서 앱 최초 진입 시 `/auth/reissue`를 시도합니다. +- 성공 시 access token이 스토어에 반영되고, 실패 시 비로그인 상태를 유지합니다. + +### 2. Request Interceptor + +- `axiosInstance` 요청 인터셉터가 access token 만료 여부를 검사합니다. +- 토큰이 없거나 만료된 경우 재발급을 시도하고, 실패하면 로그인 이동을 유도합니다. + +### 3. Response Interceptor + +- API 응답이 401이면 재발급 1회 후 원요청을 재시도합니다. +- 재시도 실패 시 로그인 페이지로 이동합니다. + +### 4. Page-level Guards + +- 인증이 필요한 UI(예: 멘토 페이지)는 클라이언트에서 재발급/토큰 상태를 확인합니다. +- 필요한 경우 페이지 내부 로직에서 `/login`으로 이동합니다. + +## Middleware Responsibility + +`apps/web/src/middleware.ts`는 현재 아래만 담당합니다. + +- stage 환경 `robots.txt` 제어 +- 스캐너/프로브 경로 차단 (`.php`, `/.git`, `/wp-admin` 등) +- 정적 리소스 경로 제외 matcher 유지 + +즉, middleware는 더 이상 인증 선검증(보호 경로 강제 로그인 리다이렉트)을 수행하지 않습니다. + +## Tokens + +### Refresh Token + +- HTTP-only 쿠키로 관리 +- 재발급 API 호출 시 서버가 검증 + +### Access Token + +- Zustand store 기반으로 관리 +- API 인증 헤더에 사용 +- 만료 시 재발급을 통해 갱신 + +## Related Files + +- `apps/web/src/lib/zustand/useAuthStore.ts` +- `apps/web/src/utils/axiosInstance.ts` +- `apps/web/src/components/layout/ReissueProvider/index.tsx` +- `apps/web/src/middleware.ts` diff --git a/apps/university-web/COMPONENTS.md b/apps/university-web/COMPONENTS.md new file mode 100644 index 00000000..b4f5e982 --- /dev/null +++ b/apps/university-web/COMPONENTS.md @@ -0,0 +1,399 @@ +# Component Design System + +## Overview + +This document defines the component standardization patterns for Solid Connection. All components should follow these guidelines to maintain UI consistency and AI-friendly development. + +## Component Categories + +### 1. UI Components (`/components/ui/`) + +Generic, reusable UI components that are domain-agnostic. + +**Examples:** +- Button +- Input +- Modal +- Toast +- Dropdown +- Checkbox +- Progress +- Tab + +**Guidelines:** +- Must accept `className` prop for custom styling +- Use `clsx` for conditional class names +- Follow Tailwind CSS utility-first approach +- Should be composable and reusable + +### 2. Layout Components (`/components/layout/`) + +Components that handle page structure and navigation. + +**Examples:** +- GlobalLayout +- TopLogoBar +- BottomNavigation +- PathBasedNavigation + +**Guidelines:** +- Handle routing and navigation logic +- Manage authentication state +- Provide consistent layout structure + +### 3. Feature Components + +Domain-specific components organized by feature area. + +**Examples:** +- `/components/mentor/*` +- `/components/search/*` +- `/components/score/*` +- `/components/community/*` + +**Guidelines:** +- Keep components focused on single responsibility +- Use hooks for business logic +- Separate presentation from logic + +## Common Patterns + +### Button Pattern + +```tsx +import clsx from "clsx"; + +interface ButtonProps { + children: React.ReactNode; + variant?: "primary" | "secondary" | "outline"; + size?: "sm" | "md" | "lg"; + disabled?: boolean; + onClick?: () => void; + className?: string; +} + +export const Button = ({ + children, + variant = "primary", + size = "md", + disabled = false, + onClick, + className, +}: ButtonProps) => { + return ( + + ); +}; +``` + +### Input Pattern + +```tsx +import clsx from "clsx"; +import { forwardRef } from "react"; + +interface InputProps extends React.InputHTMLAttributes { + label?: string; + error?: string; + helperText?: string; +} + +export const Input = forwardRef( + ({ label, error, helperText, className, ...props }, ref) => { + return ( +
+ {label && } + + {error &&

{error}

} + {helperText && !error &&

{helperText}

} +
+ ); + } +); + +Input.displayName = "Input"; +``` + +### Modal Pattern + +```tsx +"use client"; + +interface ModalProps { + isOpen: boolean; + onClose: () => void; + children: React.ReactNode; + title?: string; +} + +export const Modal = ({ isOpen, onClose, children, title }: ModalProps) => { + if (!isOpen) return null; + + return ( +
+
+
+ {title &&

{title}

} + {children} +
+
+ ); +}; +``` + +## Styling Guidelines + +### Typography Classes + +Use predefined typography classes from Tailwind config: + +- `typo-bold-1` - Bold, large text +- `typo-sb-3` - Semi-bold, medium text +- `typo-regular-4` - Regular, small text +- `typo-medium-5` - Medium, extra small text + +### Color Palette + +- Primary: `text-primary`, `bg-primary` +- Secondary: `text-secondary`, `bg-secondary` +- K-series (grayscale): `text-k-900` to `text-k-50`, `bg-k-900` to `bg-k-50` +- Accent: `text-accent-custom-indigo`, `bg-accent-custom-yellow` + +### Spacing + +Follow Tailwind's spacing scale: +- `gap-2`, `gap-3`, `gap-5` for consistent spacing +- `px-5`, `py-3` for padding +- `mt-5`, `mb-10` for margins + +## Component Props Best Practices + +### 1. Always Accept `className` + +Allow consumers to customize styling: + +```tsx +interface Props { + className?: string; +} + +export const Component = ({ className }: Props) => ( +
Content
+); +``` + +### 2. Use Discriminated Unions for Variants + +```tsx +type ButtonProps = + | { variant: "primary"; onClick: () => void } + | { variant: "link"; href: string }; +``` + +### 3. Forward Refs for Form Elements + +```tsx +export const Input = forwardRef((props, ref) => { + return ; +}); +``` + +## Accessibility Guidelines + +### 1. Semantic HTML + +Use appropriate HTML elements: +- ` +``` + +### 3. Keyboard Navigation + +Support keyboard interactions: +- Tab navigation +- Enter/Space for buttons +- Escape to close modals + +## File Organization + +``` +components/ +├── ui/ # Generic UI components +│ ├── Button/ +│ │ ├── index.tsx +│ │ └── Button.test.tsx +│ ├── Input/ +│ └── Modal/ +├── layout/ # Layout components +│ ├── GlobalLayout/ +│ └── BottomNavigation/ +└── [feature]/ # Feature-specific components + └── [ComponentName]/ + ├── index.tsx + └── hooks/ + └── useComponentLogic.ts +``` + +## Testing Guidelines + +### Unit Tests + +Test component behavior: + +```tsx +import { render, screen } from "@testing-library/react"; +import { Button } from "./Button"; + +describe("Button", () => { + it("renders children correctly", () => { + render(); + expect(screen.getByText("Click me")).toBeInTheDocument(); + }); + + it("calls onClick when clicked", () => { + const onClick = jest.fn(); + render(); + screen.getByText("Click me").click(); + expect(onClick).toHaveBeenCalledTimes(1); + }); +}); +``` + +## Migration Checklist + +When refactoring existing components: + +- [ ] Extract inline styles to Tailwind classes +- [ ] Add `className` prop for customization +- [ ] Use `clsx` for conditional classes +- [ ] Separate logic into custom hooks +- [ ] Add TypeScript types +- [ ] Add proper error handling +- [ ] Test component behavior +- [ ] Update documentation + +## AI-Friendly Development + +### Component Context File (llm.txt) + +See `/llm.txt` for component usage examples and patterns that AI assistants should follow when generating or modifying components. + +### Naming Conventions + +- PascalCase for components: `Button`, `UserProfile` +- camelCase for props: `onClick`, `isLoading` +- kebab-case for CSS classes: `bg-primary`, `text-k-900` + +### Import Order + +```tsx +// 1. React imports +import { useState, useEffect } from "react"; + +// 2. Third-party imports +import clsx from "clsx"; + +// 3. Internal imports (absolute) +import { Button } from "@/components/ui/Button"; +import { useAuth } from "@/lib/hooks/useAuth"; + +// 4. Relative imports +import { ComponentChild } from "./ComponentChild"; + +// 5. Type imports +import type { User } from "@/types/user"; +``` + +## Examples + +### Before Refactoring + +```tsx +const MyButton = ({ text, handleClick }) => ( +
+ {text} +
+); +``` + +### After Refactoring + +```tsx +interface MyButtonProps { + children: React.ReactNode; + onClick: () => void; + variant?: "primary" | "secondary"; + className?: string; +} + +export const MyButton = ({ + children, + onClick, + variant = "primary", + className, +}: MyButtonProps) => ( + +); +``` + +## Resources + +- [Tailwind CSS Documentation](https://tailwindcss.com/docs) +- [React TypeScript Cheatsheet](https://react-typescript-cheatsheet.netlify.app/) +- [Figma Design System](link-to-figma) diff --git a/apps/university-web/REACT_COMPILER.md b/apps/university-web/REACT_COMPILER.md new file mode 100644 index 00000000..945160a7 --- /dev/null +++ b/apps/university-web/REACT_COMPILER.md @@ -0,0 +1,24 @@ +# React Compiler rollout + +## Current setup + +- React Compiler is wired through top-level `reactCompiler` in `next.config.mjs`. +- The web app runs on React 19 and Next.js 16, so compiled output can use React's built-in compiler runtime. +- `react-compiler-runtime` is not installed because it is only needed when targeting React 17 or 18. +- The compiler runs in `compilationMode: "annotation"` so only components or hooks with `"use memo"` are compiled. +- `ChannelBadge` is the first annotated component. It is intentionally small and presentational to keep the first rollout low risk. + +## Rollout notes + +- A custom Babel config is intentionally not used because it disables SWC and conflicts with `next/font` in this Next.js app. +- Keep `babel-plugin-react-compiler` aligned with the React Compiler rollout notes when upgrading Next or React. +- Build time should be watched before widening compiler coverage. +- Do not switch to full compilation until the app has a React Compiler lint/audit path. The current repo uses Biome instead of ESLint, so `eslint-plugin-react-hooks` `recommended-latest` is a follow-up decision rather than part of this first slice. +- For any component that behaves incorrectly after annotation, remove `"use memo"` or add `"use no memo"` while investigating. +- Re-check the required `babel-plugin-react-compiler` version whenever Next or React is upgraded. +- When verifying compiled output on React 19, look for `react/compiler-runtime` rather than `react-compiler-runtime`. + +## Verification + +- Run `pnpm --filter @solid-connect/web build` and confirm the compiled server/client output references `react/compiler-runtime`. +- Run `pnpm --filter @solid-connect/web ci:check` before merging. diff --git a/apps/university-web/biome.json b/apps/university-web/biome.json new file mode 100644 index 00000000..b776eb42 --- /dev/null +++ b/apps/university-web/biome.json @@ -0,0 +1,42 @@ +{ + "$schema": "https://biomejs.dev/schemas/2.3.12/schema.json", + "extends": ["../../packages/config/biome/base.json"], + "files": { + "includes": ["**", "!node_modules", "!.next", "!build", "!dist", "!*.tsbuildinfo"] + }, + "vcs": { + "useIgnoreFile": false + }, + "linter": { + "rules": { + "a11y": { + "noStaticElementInteractions": "off", + "useKeyWithClickEvents": "off", + "useButtonType": "off", + "useFocusableInteractive": "off", + "useHtmlLang": "off", + "useSemanticElements": "off", + "noSvgWithoutTitle": "off" + }, + "security": { + "noDangerouslySetInnerHtml": "off" + }, + "performance": { + "noImgElement": "off" + }, + "correctness": { + "noUnusedVariables": "off", + "noUnusedFunctionParameters": "off" + }, + "style": { + "noNonNullAssertion": "off" + }, + "suspicious": { + "noConsole": "error", + "noExplicitAny": "error", + "noUnknownAtRules": "off", + "useIterableCallbackReturn": "off" + } + } + } +} diff --git a/apps/university-web/components.json b/apps/university-web/components.json new file mode 100644 index 00000000..1d82d929 --- /dev/null +++ b/apps/university-web/components.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "new-york", + "rsc": true, + "tsx": true, + "tailwind": { + "config": "tailwind.config.ts", + "css": "src/styles/globals.css", + "baseColor": "slate", + "cssVariables": true, + "prefix": "" + }, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + }, + "iconLibrary": "lucide" +} diff --git a/apps/university-web/headver.json b/apps/university-web/headver.json new file mode 100644 index 00000000..2a27d7de --- /dev/null +++ b/apps/university-web/headver.json @@ -0,0 +1,3 @@ +{ + "head": 34 +} diff --git a/apps/university-web/images.d.ts b/apps/university-web/images.d.ts new file mode 100644 index 00000000..25565b95 --- /dev/null +++ b/apps/university-web/images.d.ts @@ -0,0 +1,41 @@ +declare module "*.png" { + const content: StaticImageData; + export default content; +} + +declare module "*.jpg" { + const content: StaticImageData; + export default content; +} + +declare module "*.jpeg" { + const content: StaticImageData; + export default content; +} + +declare module "*.gif" { + const content: StaticImageData; + export default content; +} + +declare module "*.webp" { + const content: StaticImageData; + export default content; +} + +declare module "*.ico" { + const content: StaticImageData; + export default content; +} + +declare module "*.bmp" { + const content: StaticImageData; + export default content; +} + +interface StaticImageData { + src: string; + height: number; + width: number; + blurDataURL?: string; +} diff --git a/apps/university-web/instrumentation.ts b/apps/university-web/instrumentation.ts new file mode 100644 index 00000000..f8a929ba --- /dev/null +++ b/apps/university-web/instrumentation.ts @@ -0,0 +1,9 @@ +export async function register() { + if (process.env.NEXT_RUNTIME === "nodejs") { + await import("./sentry.server.config"); + } + + if (process.env.NEXT_RUNTIME === "edge") { + await import("./sentry.edge.config"); + } +} diff --git a/apps/university-web/next.config.mjs b/apps/university-web/next.config.mjs new file mode 100644 index 00000000..062a5609 --- /dev/null +++ b/apps/university-web/next.config.mjs @@ -0,0 +1,136 @@ +// Injected content via Sentry wizard below + +import bundleAnalyzer from "@next/bundle-analyzer"; +import { withSentryConfig } from "@sentry/nextjs"; + +const shouldRunBundleAnalyzer = process.env.ANALYZE === "true"; +const svgComponentLoaders = ["@svgr/webpack"]; + +const withBundleAnalyzer = bundleAnalyzer({ + enabled: shouldRunBundleAnalyzer, +}); + +const imageRemotePatterns = [ + "k.kakaocdn.net", + "cdn.default.solid-connection.com", + "cdn.upload.solid-connection.com", +].map((hostname) => ({ + protocol: "https", + hostname, +})); + +/** @type {import('next').NextConfig} */ +const nextConfig = { + assetPrefix: "/university-static", + transpilePackages: ["@solid-connect/ai-inspector"], + reactCompiler: { + compilationMode: "annotation", + }, + turbopack: { + rules: { + "*.svg": { + loaders: svgComponentLoaders, + as: "*.js", + }, + }, + }, + images: { + unoptimized: true, + remotePatterns: imageRemotePatterns, + formats: ["image/avif", "image/webp"], + deviceSizes: [360, 640, 768, 1024, 1280], + }, + // 압축 활성화 + compress: true, + // 정적 리소스 최적화 + experimental: { + optimizeCss: true, + gzipSize: true, + optimizePackageImports: [ + "lucide-react", + "@radix-ui/react-select", + "@radix-ui/react-checkbox", + "@radix-ui/react-label", + "@radix-ui/react-progress", + "@tanstack/react-query", + "class-variance-authority", + "tailwind-merge", + "zod", + "react-hook-form", + "@hookform/resolvers", + ], + }, + typescript: { + ignoreBuildErrors: true, + }, + ...(shouldRunBundleAnalyzer + ? { + webpack: (config) => { + // Bundle analyzer still runs through webpack because it is webpack-plugin based. + if (!config.optimization) { + config.optimization = {}; + } + if (!config.optimization.splitChunks) { + config.optimization.splitChunks = {}; + } + if (!config.optimization.splitChunks.cacheGroups) { + config.optimization.splitChunks.cacheGroups = {}; + } + + config.optimization.splitChunks.cacheGroups.styles = { + name: "styles", + test: /\.(css|scss)$/, + chunks: "all", + enforce: true, + }; + + config.module.rules.push({ + test: /\.svg$/, + use: svgComponentLoaders, + }); + return config; + }, + } + : {}), +}; + +export default withSentryConfig( + withBundleAnalyzer(nextConfig), + { + // For all available options, see: + // https://github.com/getsentry/sentry-webpack-plugin#options + + // Suppresses source map uploading logs during build + silent: true, + org: process.env.SENTRY_ORG, + project: process.env.SENTRY_PROJECT, + authToken: process.env.SENTRY_AUTH_TOKEN, + }, + { + // For all available options, see: + // https://docs.sentry.io/platforms/javascript/guides/nextjs/manual-setup/ + + // Upload a larger set of source maps for prettier stack traces (increases build time) + widenClientFileUpload: true, + + // IE11 지원 불필요 - 번들 사이즈 최적화를 위해 비활성화 + transpileClientSDK: false, + + // Routes browser requests to Sentry through a Next.js rewrite to circumvent ad-blockers. (increases server load) + // Note: Check that the configured route will not match with your Next.js middleware, otherwise reporting of client- + // side errors will fail. + tunnelRoute: "/university/monitoring", + + // Hides source maps from generated client bundles + hideSourceMaps: true, + + // Automatically tree-shake Sentry logger statements to reduce bundle size + disableLogger: true, + + // Enables automatic instrumentation of Vercel Cron Monitors. + // See the following for more information: + // https://docs.sentry.io/product/crons/ + // https://vercel.com/docs/cron-jobs + automaticVercelMonitors: true, + }, +); diff --git a/apps/university-web/package.json b/apps/university-web/package.json new file mode 100644 index 00000000..de8b9412 --- /dev/null +++ b/apps/university-web/package.json @@ -0,0 +1,65 @@ +{ + "name": "@solid-connect/university-web", + "version": "2.2445", + "private": true, + "scripts": { + "dev": "next dev --port 3001", + "build": "next build", + "start": "next start", + "lint": "biome check --write .", + "lint:check": "biome check .", + "format": "biome format --write .", + "format:check": "biome format .", + "typecheck": "tsc --noEmit", + "typecheck:ci": "tsc --noEmit -p tsconfig.ci.json", + "ci:check": "pnpm run lint:check && pnpm run typecheck:ci", + "analyze": "ANALYZE=true next build --webpack" + }, + "dependencies": { + "@hookform/resolvers": "^5.2.2", + "@next/third-parties": "^16.2.6", + "@radix-ui/react-checkbox": "^1.1.4", + "@radix-ui/react-label": "^2.1.2", + "@radix-ui/react-progress": "^1.1.2", + "@radix-ui/react-select": "^2.1.6", + "@react-google-maps/api": "^2.19.2", + "@sentry/nextjs": "^10.22.0", + "@solid-connect/ai-inspector": "workspace:^", + "@stomp/stompjs": "^7.1.1", + "@tanstack/react-query": "^5.84.1", + "@tanstack/react-query-devtools": "^5.84.1", + "@tanstack/react-virtual": "^3.13.12", + "@vercel/speed-insights": "^1.3.1", + "axios": "^1.6.7", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "firebase-admin": "^13.7.0", + "linkify-react": "^4.3.2", + "linkifyjs": "^4.3.2", + "lucide-react": "^0.479.0", + "next": "^16.2.6", + "next-render-analyzer": "^0.1.2", + "react": "^19.2.6", + "react-dom": "^19.2.6", + "react-hook-form": "^7.60.0", + "react-hot-toast": "^2.6.0", + "sockjs-client": "^1.6.1", + "tailwind-merge": "^3.0.2", + "tailwindcss-animate": "^1.0.7", + "zod": "^4.0.0", + "zustand": "^5.0.7" + }, + "devDependencies": { + "@next/bundle-analyzer": "^16.2.6", + "@svgr/webpack": "^8.1.0", + "@types/node": "^20.11.19", + "@types/react": "19.2.15", + "@types/react-dom": "19.2.3", + "autoprefixer": "^10.4.20", + "babel-plugin-react-compiler": "1.0.0", + "critters": "^0.0.23", + "postcss": "^8.4.45", + "tailwindcss": "^3.4.10", + "typescript": "^5.3.3" + } +} diff --git a/apps/university-web/postcss.config.js b/apps/university-web/postcss.config.js new file mode 100644 index 00000000..fcac40ac --- /dev/null +++ b/apps/university-web/postcss.config.js @@ -0,0 +1,8 @@ +module.exports = { + plugins: { + tailwindcss: { + config: "./tailwind.config.ts", + }, + autoprefixer: {}, + }, +}; diff --git a/apps/university-web/public/fonts/PretendardVariable.woff2 b/apps/university-web/public/fonts/PretendardVariable.woff2 new file mode 100644 index 00000000..49c54b51 Binary files /dev/null and b/apps/university-web/public/fonts/PretendardVariable.woff2 differ diff --git a/apps/university-web/public/icons/favicon_256.ico b/apps/university-web/public/icons/favicon_256.ico new file mode 100644 index 00000000..ec7d51d1 Binary files /dev/null and b/apps/university-web/public/icons/favicon_256.ico differ diff --git a/apps/university-web/public/icons/favicon_32.ico b/apps/university-web/public/icons/favicon_32.ico new file mode 100644 index 00000000..ec7d51d1 Binary files /dev/null and b/apps/university-web/public/icons/favicon_32.ico differ diff --git a/apps/university-web/public/icons/favicon_48.ico b/apps/university-web/public/icons/favicon_48.ico new file mode 100644 index 00000000..ec7d51d1 Binary files /dev/null and b/apps/university-web/public/icons/favicon_48.ico differ diff --git a/apps/university-web/public/images/article-thumb.png b/apps/university-web/public/images/article-thumb.png new file mode 100644 index 00000000..c48e5bf0 Binary files /dev/null and b/apps/university-web/public/images/article-thumb.png differ diff --git a/apps/university-web/public/images/check-grade.jpeg b/apps/university-web/public/images/check-grade.jpeg new file mode 100644 index 00000000..bd143abb Binary files /dev/null and b/apps/university-web/public/images/check-grade.jpeg differ diff --git a/apps/university-web/public/images/gpa-cert-example-1.png b/apps/university-web/public/images/gpa-cert-example-1.png new file mode 100644 index 00000000..25b80206 Binary files /dev/null and b/apps/university-web/public/images/gpa-cert-example-1.png differ diff --git a/apps/university-web/public/images/gpa-cert-example-2.png b/apps/university-web/public/images/gpa-cert-example-2.png new file mode 100644 index 00000000..56eb1345 Binary files /dev/null and b/apps/university-web/public/images/gpa-cert-example-2.png differ diff --git a/apps/university-web/public/images/gpa-cert-example-3.png b/apps/university-web/public/images/gpa-cert-example-3.png new file mode 100644 index 00000000..b3de591d Binary files /dev/null and b/apps/university-web/public/images/gpa-cert-example-3.png differ diff --git a/apps/university-web/public/images/lang-cert-example-1.png b/apps/university-web/public/images/lang-cert-example-1.png new file mode 100644 index 00000000..0cbbe0e5 Binary files /dev/null and b/apps/university-web/public/images/lang-cert-example-1.png differ diff --git a/apps/university-web/public/images/language/default.png b/apps/university-web/public/images/language/default.png new file mode 100644 index 00000000..1a17bca5 Binary files /dev/null and b/apps/university-web/public/images/language/default.png differ diff --git a/apps/university-web/public/images/language/ielts.png b/apps/university-web/public/images/language/ielts.png new file mode 100644 index 00000000..6218faaf Binary files /dev/null and b/apps/university-web/public/images/language/ielts.png differ diff --git a/apps/university-web/public/images/language/toefl_ibt.png b/apps/university-web/public/images/language/toefl_ibt.png new file mode 100644 index 00000000..e8968e74 Binary files /dev/null and b/apps/university-web/public/images/language/toefl_ibt.png differ diff --git a/apps/university-web/public/images/language/toefl_itp.png b/apps/university-web/public/images/language/toefl_itp.png new file mode 100644 index 00000000..a27b3750 Binary files /dev/null and b/apps/university-web/public/images/language/toefl_itp.png differ diff --git a/apps/university-web/public/images/language/toeic.png b/apps/university-web/public/images/language/toeic.png new file mode 100644 index 00000000..1a17bca5 Binary files /dev/null and b/apps/university-web/public/images/language/toeic.png differ diff --git a/apps/university-web/public/images/onboarding-1.jpeg b/apps/university-web/public/images/onboarding-1.jpeg new file mode 100644 index 00000000..1452cae8 Binary files /dev/null and b/apps/university-web/public/images/onboarding-1.jpeg differ diff --git a/apps/university-web/public/images/onboarding-2.jpeg b/apps/university-web/public/images/onboarding-2.jpeg new file mode 100644 index 00000000..a908f52d Binary files /dev/null and b/apps/university-web/public/images/onboarding-2.jpeg differ diff --git a/apps/university-web/public/images/onboarding-3.jpeg b/apps/university-web/public/images/onboarding-3.jpeg new file mode 100644 index 00000000..89ed5cff Binary files /dev/null and b/apps/university-web/public/images/onboarding-3.jpeg differ diff --git a/apps/university-web/public/images/onboarding-4.jpeg b/apps/university-web/public/images/onboarding-4.jpeg new file mode 100644 index 00000000..d40e091b Binary files /dev/null and b/apps/university-web/public/images/onboarding-4.jpeg differ diff --git a/apps/university-web/public/images/placeholder/profile112.png b/apps/university-web/public/images/placeholder/profile112.png new file mode 100644 index 00000000..41b6ca43 Binary files /dev/null and b/apps/university-web/public/images/placeholder/profile112.png differ diff --git a/apps/university-web/public/images/placeholder/profile64.svg b/apps/university-web/public/images/placeholder/profile64.svg new file mode 100644 index 00000000..b8a94408 --- /dev/null +++ b/apps/university-web/public/images/placeholder/profile64.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/apps/university-web/public/images/rabbit.png b/apps/university-web/public/images/rabbit.png new file mode 100644 index 00000000..b8ef5d25 Binary files /dev/null and b/apps/university-web/public/images/rabbit.png differ diff --git a/apps/university-web/public/images/region_america.jpeg b/apps/university-web/public/images/region_america.jpeg new file mode 100644 index 00000000..45557b96 Binary files /dev/null and b/apps/university-web/public/images/region_america.jpeg differ diff --git a/apps/university-web/public/images/region_asia.jpeg b/apps/university-web/public/images/region_asia.jpeg new file mode 100644 index 00000000..244bee84 Binary files /dev/null and b/apps/university-web/public/images/region_asia.jpeg differ diff --git a/apps/university-web/public/images/region_china.jpeg b/apps/university-web/public/images/region_china.jpeg new file mode 100644 index 00000000..b7b7ae4f Binary files /dev/null and b/apps/university-web/public/images/region_china.jpeg differ diff --git a/apps/university-web/public/images/region_europe.jpeg b/apps/university-web/public/images/region_europe.jpeg new file mode 100644 index 00000000..8722b9a9 Binary files /dev/null and b/apps/university-web/public/images/region_europe.jpeg differ diff --git a/apps/university-web/public/images/site-thumbnail.png b/apps/university-web/public/images/site-thumbnail.png new file mode 100644 index 00000000..874f611a Binary files /dev/null and b/apps/university-web/public/images/site-thumbnail.png differ diff --git a/apps/university-web/public/images/survey-complete-icon.png b/apps/university-web/public/images/survey-complete-icon.png new file mode 100644 index 00000000..bdd44379 Binary files /dev/null and b/apps/university-web/public/images/survey-complete-icon.png differ diff --git a/apps/university-web/public/images/survey-modal/arrow-right.svg b/apps/university-web/public/images/survey-modal/arrow-right.svg new file mode 100644 index 00000000..035a61cf --- /dev/null +++ b/apps/university-web/public/images/survey-modal/arrow-right.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/apps/university-web/public/images/survey-modal/bg-vector.svg b/apps/university-web/public/images/survey-modal/bg-vector.svg new file mode 100644 index 00000000..99e2b6e2 --- /dev/null +++ b/apps/university-web/public/images/survey-modal/bg-vector.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/apps/university-web/public/images/survey-modal/checkbox-circle.svg b/apps/university-web/public/images/survey-modal/checkbox-circle.svg new file mode 100644 index 00000000..3cce0f57 --- /dev/null +++ b/apps/university-web/public/images/survey-modal/checkbox-circle.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/university-web/public/images/survey-modal/top-logo.svg b/apps/university-web/public/images/survey-modal/top-logo.svg new file mode 100644 index 00000000..d1a12e3f --- /dev/null +++ b/apps/university-web/public/images/survey-modal/top-logo.svg @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/university-web/public/images/univs/incheon.png b/apps/university-web/public/images/univs/incheon.png new file mode 100644 index 00000000..b849061a Binary files /dev/null and b/apps/university-web/public/images/univs/incheon.png differ diff --git a/apps/university-web/public/images/univs/inha.png b/apps/university-web/public/images/univs/inha.png new file mode 100644 index 00000000..dbb99bba Binary files /dev/null and b/apps/university-web/public/images/univs/inha.png differ diff --git a/apps/university-web/public/images/univs/sungshin.jpg b/apps/university-web/public/images/univs/sungshin.jpg new file mode 100644 index 00000000..a95d01b3 Binary files /dev/null and b/apps/university-web/public/images/univs/sungshin.jpg differ diff --git a/apps/university-web/public/images/univs/sungsin.jpg b/apps/university-web/public/images/univs/sungsin.jpg new file mode 100644 index 00000000..a95d01b3 Binary files /dev/null and b/apps/university-web/public/images/univs/sungsin.jpg differ diff --git a/apps/university-web/public/images/unvis/incheon.png b/apps/university-web/public/images/unvis/incheon.png new file mode 100644 index 00000000..b849061a Binary files /dev/null and b/apps/university-web/public/images/unvis/incheon.png differ diff --git a/apps/university-web/public/images/unvis/inha.png b/apps/university-web/public/images/unvis/inha.png new file mode 100644 index 00000000..dbb99bba Binary files /dev/null and b/apps/university-web/public/images/unvis/inha.png differ diff --git a/apps/university-web/public/images/unvis/sungsin.jpg b/apps/university-web/public/images/unvis/sungsin.jpg new file mode 100644 index 00000000..a95d01b3 Binary files /dev/null and b/apps/university-web/public/images/unvis/sungsin.jpg differ diff --git a/apps/university-web/public/naver79eaa7633f3e5bc9aede38c409737c21.html b/apps/university-web/public/naver79eaa7633f3e5bc9aede38c409737c21.html new file mode 100644 index 00000000..883dd4bd --- /dev/null +++ b/apps/university-web/public/naver79eaa7633f3e5bc9aede38c409737c21.html @@ -0,0 +1 @@ +naver-site-verification: naver79eaa7633f3e5bc9aede38c409737c21.html \ No newline at end of file diff --git a/apps/university-web/public/svgs/applicant-banner.svg b/apps/university-web/public/svgs/applicant-banner.svg new file mode 100644 index 00000000..a578af05 --- /dev/null +++ b/apps/university-web/public/svgs/applicant-banner.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/apps/university-web/public/svgs/arrow-back-filled.svg b/apps/university-web/public/svgs/arrow-back-filled.svg new file mode 100644 index 00000000..12341a3b --- /dev/null +++ b/apps/university-web/public/svgs/arrow-back-filled.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/apps/university-web/public/svgs/auth/apple-logo.svg b/apps/university-web/public/svgs/auth/apple-logo.svg new file mode 100644 index 00000000..f5281529 --- /dev/null +++ b/apps/university-web/public/svgs/auth/apple-logo.svg @@ -0,0 +1,6 @@ + + + diff --git a/apps/university-web/public/svgs/auth/email-icon.svg b/apps/university-web/public/svgs/auth/email-icon.svg new file mode 100644 index 00000000..7701654c --- /dev/null +++ b/apps/university-web/public/svgs/auth/email-icon.svg @@ -0,0 +1,6 @@ + + + diff --git a/apps/university-web/public/svgs/auth/index.ts b/apps/university-web/public/svgs/auth/index.ts new file mode 100644 index 00000000..ea097f61 --- /dev/null +++ b/apps/university-web/public/svgs/auth/index.ts @@ -0,0 +1,19 @@ +import IconAppleLogo from "./apple-logo.svg"; +import IconEmailIcon from "./email-icon.svg"; +import IconKakaoLogo from "./kakao-logo.svg"; +import IconPrepare1 from "./prepare-1.svg"; +import IconPrepare2 from "./prepare-2.svg"; +import IconPrepare3 from "./prepare-3.svg"; +import IconSignupProfileImage from "./signup-profile-image.svg"; + +export { + IconPrepare1, + IconPrepare2, + IconPrepare3, + IconSignupProfileImage, + IconKakaoLogo, + IconAppleLogo, + IconEmailIcon, +}; + +// Login page icons diff --git a/apps/university-web/public/svgs/auth/kakao-logo.svg b/apps/university-web/public/svgs/auth/kakao-logo.svg new file mode 100644 index 00000000..4faefd24 --- /dev/null +++ b/apps/university-web/public/svgs/auth/kakao-logo.svg @@ -0,0 +1,15 @@ + + kakao 로고 + + diff --git a/apps/university-web/public/svgs/auth/prepare-1.svg b/apps/university-web/public/svgs/auth/prepare-1.svg new file mode 100644 index 00000000..7d6bd916 --- /dev/null +++ b/apps/university-web/public/svgs/auth/prepare-1.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/apps/university-web/public/svgs/auth/prepare-2.svg b/apps/university-web/public/svgs/auth/prepare-2.svg new file mode 100644 index 00000000..302355c4 --- /dev/null +++ b/apps/university-web/public/svgs/auth/prepare-2.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/apps/university-web/public/svgs/auth/prepare-3.svg b/apps/university-web/public/svgs/auth/prepare-3.svg new file mode 100644 index 00000000..5cf654e2 --- /dev/null +++ b/apps/university-web/public/svgs/auth/prepare-3.svg @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/apps/university-web/public/svgs/auth/signup-profile-image.svg b/apps/university-web/public/svgs/auth/signup-profile-image.svg new file mode 100644 index 00000000..644eacb7 --- /dev/null +++ b/apps/university-web/public/svgs/auth/signup-profile-image.svg @@ -0,0 +1,4 @@ + + + + diff --git a/apps/university-web/public/svgs/bookmark-filled.svg b/apps/university-web/public/svgs/bookmark-filled.svg new file mode 100644 index 00000000..42604c1d --- /dev/null +++ b/apps/university-web/public/svgs/bookmark-filled.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/apps/university-web/public/svgs/bookmark-outlined.svg b/apps/university-web/public/svgs/bookmark-outlined.svg new file mode 100644 index 00000000..68525ce2 --- /dev/null +++ b/apps/university-web/public/svgs/bookmark-outlined.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/apps/university-web/public/svgs/clock.svg b/apps/university-web/public/svgs/clock.svg new file mode 100644 index 00000000..6c974c00 --- /dev/null +++ b/apps/university-web/public/svgs/clock.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/apps/university-web/public/svgs/close-filled.svg b/apps/university-web/public/svgs/close-filled.svg new file mode 100644 index 00000000..85278305 --- /dev/null +++ b/apps/university-web/public/svgs/close-filled.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/apps/university-web/public/svgs/community/communication.svg b/apps/university-web/public/svgs/community/communication.svg new file mode 100644 index 00000000..0c4260ad --- /dev/null +++ b/apps/university-web/public/svgs/community/communication.svg @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/apps/university-web/public/svgs/community/expand-more-filled.svg b/apps/university-web/public/svgs/community/expand-more-filled.svg new file mode 100644 index 00000000..a2fd3ddc --- /dev/null +++ b/apps/university-web/public/svgs/community/expand-more-filled.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/apps/university-web/public/svgs/community/index.ts b/apps/university-web/public/svgs/community/index.ts new file mode 100644 index 00000000..d1d7aec9 --- /dev/null +++ b/apps/university-web/public/svgs/community/index.ts @@ -0,0 +1,4 @@ +import IconCommunication from "./communication.svg"; +import IconExpandMoreFilled from "./expand-more-filled.svg"; + +export { IconCommunication, IconExpandMoreFilled }; diff --git a/apps/university-web/public/svgs/flight.svg b/apps/university-web/public/svgs/flight.svg new file mode 100644 index 00000000..dd55d6d9 --- /dev/null +++ b/apps/university-web/public/svgs/flight.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/apps/university-web/public/svgs/home/cloud.svg b/apps/university-web/public/svgs/home/cloud.svg new file mode 100644 index 00000000..9156bb2f --- /dev/null +++ b/apps/university-web/public/svgs/home/cloud.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/apps/university-web/public/svgs/home/graduation-cap.svg b/apps/university-web/public/svgs/home/graduation-cap.svg new file mode 100644 index 00000000..af41a3c2 --- /dev/null +++ b/apps/university-web/public/svgs/home/graduation-cap.svg @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/apps/university-web/public/svgs/home/id-card.svg b/apps/university-web/public/svgs/home/id-card.svg new file mode 100644 index 00000000..a5a49639 --- /dev/null +++ b/apps/university-web/public/svgs/home/id-card.svg @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/apps/university-web/public/svgs/home/index.ts b/apps/university-web/public/svgs/home/index.ts new file mode 100644 index 00000000..83d59db2 --- /dev/null +++ b/apps/university-web/public/svgs/home/index.ts @@ -0,0 +1,19 @@ +import IconCloud from "./cloud.svg"; +import IconGraduationCap from "./graduation-cap.svg"; +import IconIdCard from "./id-card.svg"; +import IconLoveLetter from "./love-letter.svg"; +import IconMagnifyingGlass from "./magnifying-glass.svg"; +import IconMuseum from "./museum.svg"; +import IconPaper from "./paper.svg"; +import IconRightArrow from "./right-arrow.svg"; + +export { + IconCloud, + IconGraduationCap, + IconIdCard, + IconMagnifyingGlass, + IconMuseum, + IconPaper, + IconRightArrow, + IconLoveLetter, +}; diff --git a/apps/university-web/public/svgs/home/love-letter.svg b/apps/university-web/public/svgs/home/love-letter.svg new file mode 100644 index 00000000..3fd9ef3b --- /dev/null +++ b/apps/university-web/public/svgs/home/love-letter.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/apps/university-web/public/svgs/home/magnifying-glass.svg b/apps/university-web/public/svgs/home/magnifying-glass.svg new file mode 100644 index 00000000..43cf0db7 --- /dev/null +++ b/apps/university-web/public/svgs/home/magnifying-glass.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/apps/university-web/public/svgs/home/museum.svg b/apps/university-web/public/svgs/home/museum.svg new file mode 100644 index 00000000..37b1a030 --- /dev/null +++ b/apps/university-web/public/svgs/home/museum.svg @@ -0,0 +1,10 @@ + + + + + + + + + + \ No newline at end of file diff --git a/apps/university-web/public/svgs/home/paper.svg b/apps/university-web/public/svgs/home/paper.svg new file mode 100644 index 00000000..0594a8ec --- /dev/null +++ b/apps/university-web/public/svgs/home/paper.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/apps/university-web/public/svgs/home/right-arrow.svg b/apps/university-web/public/svgs/home/right-arrow.svg new file mode 100644 index 00000000..bde0cbec --- /dev/null +++ b/apps/university-web/public/svgs/home/right-arrow.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/apps/university-web/public/svgs/image.svg b/apps/university-web/public/svgs/image.svg new file mode 100644 index 00000000..38dae38b --- /dev/null +++ b/apps/university-web/public/svgs/image.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/apps/university-web/public/svgs/index.ts b/apps/university-web/public/svgs/index.ts new file mode 100644 index 00000000..6dad0563 --- /dev/null +++ b/apps/university-web/public/svgs/index.ts @@ -0,0 +1,57 @@ +import IconApplicantBanner from "./applicant-banner.svg"; +import IconArrowBackFilled from "./arrow-back-filled.svg"; +import IconBookmarkFilled from "./bookmark-filled.svg"; +import IconBookmarkOutlined from "./bookmark-outlined.svg"; +import IconClock from "./clock.svg"; +import IconCloseFilled from "./close-filled.svg"; +import IconFlight from "./flight.svg"; +import IconImage from "./image.svg"; +import IconMoreVertFilled from "./more-vert-filled.svg"; +import IconObjectsAndTools from "./objects-and-tools.svg"; +import IconPostCheckboxFilled from "./post-checkbox-filled.svg"; +import IconPostCheckboxOutlined from "./post-checkbox-outlined.svg"; +import IconPostLikeFilled from "./post-like-filled.svg"; +import IconPostLikeOutline from "./post-like-outline.svg"; +import IconScoreBanner from "./score-banner.svg"; +import IconSearchBanner from "./search-banner.svg"; +import IconSearchFilled from "./search-filled.svg"; +import IconShare from "./shareIcon.svg"; +import IconShareFilled from "./shareIconFilled.svg"; +import IconSignupRegionAmerica from "./signup-region-america.svg"; +import IconSignupRegionAsia from "./signup-region-asia.svg"; +import IconSignupRegionEurope from "./signup-region-europe.svg"; +import IconSignupRegionWorld from "./signup-region-world.svg"; +import IconSolidConnectionFullBlackLogo from "./solid-connection-full-black-logo.svg"; +import IconSpeaker from "./speaker.svg"; +import IconSubComment from "./sub-comment.svg"; +import IconTablerSearch from "./tabler-search.svg"; + +export { + IconApplicantBanner, + IconArrowBackFilled, + IconBookmarkFilled, + IconBookmarkOutlined, + IconClock, + IconCloseFilled, + IconFlight, + IconImage, + IconMoreVertFilled, + IconObjectsAndTools, + IconPostCheckboxFilled, + IconPostCheckboxOutlined, + IconPostLikeFilled, + IconPostLikeOutline, + IconScoreBanner, + IconSearchBanner, + IconSearchFilled, + IconShare, + IconShareFilled, + IconSignupRegionAmerica, + IconSignupRegionAsia, + IconSignupRegionEurope, + IconSignupRegionWorld, + IconSolidConnectionFullBlackLogo, + IconSpeaker, + IconSubComment, + IconTablerSearch, +}; diff --git a/apps/university-web/public/svgs/loading/cloud-spinner-1.svg b/apps/university-web/public/svgs/loading/cloud-spinner-1.svg new file mode 100644 index 00000000..219b7652 --- /dev/null +++ b/apps/university-web/public/svgs/loading/cloud-spinner-1.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/apps/university-web/public/svgs/loading/cloud-spinner-2.svg b/apps/university-web/public/svgs/loading/cloud-spinner-2.svg new file mode 100644 index 00000000..9ef12c5a --- /dev/null +++ b/apps/university-web/public/svgs/loading/cloud-spinner-2.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/apps/university-web/public/svgs/loading/cloud-spinner-3.svg b/apps/university-web/public/svgs/loading/cloud-spinner-3.svg new file mode 100644 index 00000000..08a033c7 --- /dev/null +++ b/apps/university-web/public/svgs/loading/cloud-spinner-3.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/apps/university-web/public/svgs/loading/cloud-spinner-4.svg b/apps/university-web/public/svgs/loading/cloud-spinner-4.svg new file mode 100644 index 00000000..c060f067 --- /dev/null +++ b/apps/university-web/public/svgs/loading/cloud-spinner-4.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/apps/university-web/public/svgs/loading/cloud-spinner-5.svg b/apps/university-web/public/svgs/loading/cloud-spinner-5.svg new file mode 100644 index 00000000..71a19544 --- /dev/null +++ b/apps/university-web/public/svgs/loading/cloud-spinner-5.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/apps/university-web/public/svgs/loading/cloud-spinner-6.svg b/apps/university-web/public/svgs/loading/cloud-spinner-6.svg new file mode 100644 index 00000000..1a276708 --- /dev/null +++ b/apps/university-web/public/svgs/loading/cloud-spinner-6.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/apps/university-web/public/svgs/loading/cloud-spinner-7.svg b/apps/university-web/public/svgs/loading/cloud-spinner-7.svg new file mode 100644 index 00000000..20385022 --- /dev/null +++ b/apps/university-web/public/svgs/loading/cloud-spinner-7.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/apps/university-web/public/svgs/loading/index.ts b/apps/university-web/public/svgs/loading/index.ts new file mode 100644 index 00000000..053b3254 --- /dev/null +++ b/apps/university-web/public/svgs/loading/index.ts @@ -0,0 +1,19 @@ +import IconCloudSpinner1 from "./cloud-spinner-1.svg"; +import IconCloudSpinner2 from "./cloud-spinner-2.svg"; +import IconCloudSpinner3 from "./cloud-spinner-3.svg"; +import IconCloudSpinner4 from "./cloud-spinner-4.svg"; +import IconCloudSpinner5 from "./cloud-spinner-5.svg"; +import IconCloudSpinner6 from "./cloud-spinner-6.svg"; +import IconCloudSpinner7 from "./cloud-spinner-7.svg"; +import IconNotFound from "./not-found.svg"; + +export { + IconCloudSpinner1, + IconCloudSpinner2, + IconCloudSpinner3, + IconCloudSpinner4, + IconCloudSpinner5, + IconCloudSpinner6, + IconCloudSpinner7, + IconNotFound, +}; diff --git a/apps/university-web/public/svgs/loading/not-found.svg b/apps/university-web/public/svgs/loading/not-found.svg new file mode 100644 index 00000000..d984f751 --- /dev/null +++ b/apps/university-web/public/svgs/loading/not-found.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/apps/university-web/public/svgs/mentor/album.svg b/apps/university-web/public/svgs/mentor/album.svg new file mode 100644 index 00000000..60d4f884 --- /dev/null +++ b/apps/university-web/public/svgs/mentor/album.svg @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/apps/university-web/public/svgs/mentor/alert-sub-c.svg b/apps/university-web/public/svgs/mentor/alert-sub-c.svg new file mode 100644 index 00000000..9e879684 --- /dev/null +++ b/apps/university-web/public/svgs/mentor/alert-sub-c.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/apps/university-web/public/svgs/mentor/alert.svg b/apps/university-web/public/svgs/mentor/alert.svg new file mode 100644 index 00000000..de3ae2d1 --- /dev/null +++ b/apps/university-web/public/svgs/mentor/alert.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/apps/university-web/public/svgs/mentor/camera.svg b/apps/university-web/public/svgs/mentor/camera.svg new file mode 100644 index 00000000..f7ac994e --- /dev/null +++ b/apps/university-web/public/svgs/mentor/camera.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/apps/university-web/public/svgs/mentor/check.svg b/apps/university-web/public/svgs/mentor/check.svg new file mode 100644 index 00000000..6caf9f7d --- /dev/null +++ b/apps/university-web/public/svgs/mentor/check.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/apps/university-web/public/svgs/mentor/default-profile.svg b/apps/university-web/public/svgs/mentor/default-profile.svg new file mode 100644 index 00000000..476333b8 --- /dev/null +++ b/apps/university-web/public/svgs/mentor/default-profile.svg @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/apps/university-web/public/svgs/mentor/direct-message.svg b/apps/university-web/public/svgs/mentor/direct-message.svg new file mode 100644 index 00000000..29f5a519 --- /dev/null +++ b/apps/university-web/public/svgs/mentor/direct-message.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/apps/university-web/public/svgs/mentor/direction-down.svg b/apps/university-web/public/svgs/mentor/direction-down.svg new file mode 100644 index 00000000..d4a70fbd --- /dev/null +++ b/apps/university-web/public/svgs/mentor/direction-down.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/apps/university-web/public/svgs/mentor/direction-right.svg b/apps/university-web/public/svgs/mentor/direction-right.svg new file mode 100644 index 00000000..e79fecb6 --- /dev/null +++ b/apps/university-web/public/svgs/mentor/direction-right.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/apps/university-web/public/svgs/mentor/direction-up.svg b/apps/university-web/public/svgs/mentor/direction-up.svg new file mode 100644 index 00000000..9b6bb095 --- /dev/null +++ b/apps/university-web/public/svgs/mentor/direction-up.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/apps/university-web/public/svgs/mentor/file.svg b/apps/university-web/public/svgs/mentor/file.svg new file mode 100644 index 00000000..0a8dfa82 --- /dev/null +++ b/apps/university-web/public/svgs/mentor/file.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/apps/university-web/public/svgs/mentor/graduation.svg b/apps/university-web/public/svgs/mentor/graduation.svg new file mode 100644 index 00000000..eef5d7ea --- /dev/null +++ b/apps/university-web/public/svgs/mentor/graduation.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/apps/university-web/public/svgs/mentor/index.ts b/apps/university-web/public/svgs/mentor/index.ts new file mode 100644 index 00000000..9974caab --- /dev/null +++ b/apps/university-web/public/svgs/mentor/index.ts @@ -0,0 +1,59 @@ +import IconAlbum from "./album.svg"; +import IconAlert from "./alert.svg"; +import IconAlertSubC from "./alert-sub-c.svg"; +import IconCamera from "./camera.svg"; +import IconCheck from "./check.svg"; +import IconDefaultProfile from "./default-profile.svg"; +import IconDirectMessage from "./direct-message.svg"; +import IconDirectionDown from "./direction-down.svg"; +import IconDirectionRight from "./direction-right.svg"; +import IconDirectionUp from "./direction-up.svg"; +import IconFile from "./file.svg"; +import IconGraduation from "./graduation.svg"; +import IconLikeFill from "./like-fill.svg"; +import IconLikeNotFill from "./like-not-fill.svg"; +import IconModify from "./modify.svg"; +import IconPencil from "./pencil.svg"; +import IconPlus from "./plus.svg"; +import IconPlusK200 from "./plus-k-200.svg"; +import IconPoligon from "./poligon.svg"; +import IconReport from "./report.svg"; +import IconSearchBlue from "./search-blue.svg"; +import IconSetting from "./setting.svg"; +import IconSmile from "./smile.svg"; +import IconSolidConnentionLogo from "./solid-connection-logo.svg"; +import IconTime from "./time.svg"; +import IconUnSmile from "./un-smile.svg"; +import IconUserPrimaryColor from "./user-primary-color.svg"; +import IconXWhite from "./x-white.svg"; + +export { + IconAlert, + IconAlertSubC, + IconDirectionDown, + IconDefaultProfile, + IconDirectionUp, + IconDirectionRight, + IconGraduation, + IconPoligon, + IconUserPrimaryColor, + IconPencil, + IconPlus, + IconSolidConnentionLogo, + IconSearchBlue, + IconSetting, + IconCamera, + IconDirectMessage, + IconLikeNotFill, + IconLikeFill, + IconUnSmile, + IconSmile, + IconTime, + IconCheck, + IconModify, + IconPlusK200, + IconXWhite, + IconFile, + IconAlbum, + IconReport, +}; diff --git a/apps/university-web/public/svgs/mentor/like-fill.svg b/apps/university-web/public/svgs/mentor/like-fill.svg new file mode 100644 index 00000000..9f7b4763 --- /dev/null +++ b/apps/university-web/public/svgs/mentor/like-fill.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/apps/university-web/public/svgs/mentor/like-not-fill.svg b/apps/university-web/public/svgs/mentor/like-not-fill.svg new file mode 100644 index 00000000..2f1f5d02 --- /dev/null +++ b/apps/university-web/public/svgs/mentor/like-not-fill.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/apps/university-web/public/svgs/mentor/modify.svg b/apps/university-web/public/svgs/mentor/modify.svg new file mode 100644 index 00000000..0cb4e592 --- /dev/null +++ b/apps/university-web/public/svgs/mentor/modify.svg @@ -0,0 +1,4 @@ + + + + diff --git a/apps/university-web/public/svgs/mentor/pencil.svg b/apps/university-web/public/svgs/mentor/pencil.svg new file mode 100644 index 00000000..ece515eb --- /dev/null +++ b/apps/university-web/public/svgs/mentor/pencil.svg @@ -0,0 +1,6 @@ + + + \ No newline at end of file diff --git a/apps/university-web/public/svgs/mentor/plus-k-200.svg b/apps/university-web/public/svgs/mentor/plus-k-200.svg new file mode 100644 index 00000000..2adbe3d1 --- /dev/null +++ b/apps/university-web/public/svgs/mentor/plus-k-200.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/apps/university-web/public/svgs/mentor/plus.svg b/apps/university-web/public/svgs/mentor/plus.svg new file mode 100644 index 00000000..1658e625 --- /dev/null +++ b/apps/university-web/public/svgs/mentor/plus.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/apps/university-web/public/svgs/mentor/poligon.svg b/apps/university-web/public/svgs/mentor/poligon.svg new file mode 100644 index 00000000..7149e54f --- /dev/null +++ b/apps/university-web/public/svgs/mentor/poligon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/university-web/public/svgs/mentor/report.svg b/apps/university-web/public/svgs/mentor/report.svg new file mode 100644 index 00000000..7b194ca0 --- /dev/null +++ b/apps/university-web/public/svgs/mentor/report.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/apps/university-web/public/svgs/mentor/search-blue.svg b/apps/university-web/public/svgs/mentor/search-blue.svg new file mode 100644 index 00000000..2cb796f6 --- /dev/null +++ b/apps/university-web/public/svgs/mentor/search-blue.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/apps/university-web/public/svgs/mentor/setting.svg b/apps/university-web/public/svgs/mentor/setting.svg new file mode 100644 index 00000000..916c3a82 --- /dev/null +++ b/apps/university-web/public/svgs/mentor/setting.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/apps/university-web/public/svgs/mentor/smile.svg b/apps/university-web/public/svgs/mentor/smile.svg new file mode 100644 index 00000000..540d6a34 --- /dev/null +++ b/apps/university-web/public/svgs/mentor/smile.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/apps/university-web/public/svgs/mentor/solid-connection-logo.svg b/apps/university-web/public/svgs/mentor/solid-connection-logo.svg new file mode 100644 index 00000000..ebd956e4 --- /dev/null +++ b/apps/university-web/public/svgs/mentor/solid-connection-logo.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/apps/university-web/public/svgs/mentor/time.svg b/apps/university-web/public/svgs/mentor/time.svg new file mode 100644 index 00000000..8f39cd04 --- /dev/null +++ b/apps/university-web/public/svgs/mentor/time.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/apps/university-web/public/svgs/mentor/un-smile.svg b/apps/university-web/public/svgs/mentor/un-smile.svg new file mode 100644 index 00000000..87953035 --- /dev/null +++ b/apps/university-web/public/svgs/mentor/un-smile.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/apps/university-web/public/svgs/mentor/user-primary-color.svg b/apps/university-web/public/svgs/mentor/user-primary-color.svg new file mode 100644 index 00000000..9c2e452a --- /dev/null +++ b/apps/university-web/public/svgs/mentor/user-primary-color.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/apps/university-web/public/svgs/mentor/x-white.svg b/apps/university-web/public/svgs/mentor/x-white.svg new file mode 100644 index 00000000..147dc4f8 --- /dev/null +++ b/apps/university-web/public/svgs/mentor/x-white.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/apps/university-web/public/svgs/more-vert-filled.svg b/apps/university-web/public/svgs/more-vert-filled.svg new file mode 100644 index 00000000..d1261200 --- /dev/null +++ b/apps/university-web/public/svgs/more-vert-filled.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/apps/university-web/public/svgs/my/album-white.svg b/apps/university-web/public/svgs/my/album-white.svg new file mode 100644 index 00000000..f7ea6712 --- /dev/null +++ b/apps/university-web/public/svgs/my/album-white.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/apps/university-web/public/svgs/my/alert-error-red.svg b/apps/university-web/public/svgs/my/alert-error-red.svg new file mode 100644 index 00000000..93288b99 --- /dev/null +++ b/apps/university-web/public/svgs/my/alert-error-red.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/apps/university-web/public/svgs/my/book.svg b/apps/university-web/public/svgs/my/book.svg new file mode 100644 index 00000000..6af6ac07 --- /dev/null +++ b/apps/university-web/public/svgs/my/book.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/apps/university-web/public/svgs/my/check-blue.svg b/apps/university-web/public/svgs/my/check-blue.svg new file mode 100644 index 00000000..aef5b3d5 --- /dev/null +++ b/apps/university-web/public/svgs/my/check-blue.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/apps/university-web/public/svgs/my/earth.svg b/apps/university-web/public/svgs/my/earth.svg new file mode 100644 index 00000000..7378c5db --- /dev/null +++ b/apps/university-web/public/svgs/my/earth.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/apps/university-web/public/svgs/my/graduation-primary.svg b/apps/university-web/public/svgs/my/graduation-primary.svg new file mode 100644 index 00000000..87aba80b --- /dev/null +++ b/apps/university-web/public/svgs/my/graduation-primary.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/apps/university-web/public/svgs/my/index.ts b/apps/university-web/public/svgs/my/index.ts new file mode 100644 index 00000000..2ab6e06b --- /dev/null +++ b/apps/university-web/public/svgs/my/index.ts @@ -0,0 +1,43 @@ +import IconAlbumWhite from "./album-white.svg"; +import IconAlertErrorRed from "./alert-error-red.svg"; +import IconBook from "./book.svg"; +import IconCheckBlue from "./check-blue.svg"; +import IconEarth from "./earth.svg"; +import IconGraduationPrimary from "./graduation-primary.svg"; +import IconInterestUniversity from "./interest-university.svg"; +import IconLock from "./lock.svg"; +import IconMyInfoCardBookmark from "./my-info-card-bookmark.svg"; +import IconMyInfoCardMento from "./my-info-card-mento.svg"; +import IconMyInfoCardWish from "./my-info-card-wish.svg"; +import IconMyMenuArrow from "./my-menu-arrow.svg"; +import IconMyMenuCalendar from "./my-menu-calendar.svg"; +import IconMyMenuLock from "./my-menu-lock.svg"; +import IconMyMenuPerson from "./my-menu-person.svg"; +import IconPassword from "./password.svg"; +import IconSolidConnectionSmallLogo from "./solid-connection-small-logo.svg"; +import IconUniversity from "./university.svg"; +import IconVisibilityOff from "./visibility-off.svg"; +import IconVisibilityOn from "./visibility-on.svg"; + +export { + IconMyMenuArrow, + IconMyMenuCalendar, + IconMyMenuLock, + IconMyMenuPerson, + IconMyInfoCardBookmark, + IconMyInfoCardMento, + IconMyInfoCardWish, + IconInterestUniversity, + IconGraduationPrimary, + IconBook, + IconEarth, + IconLock, + IconPassword, + IconUniversity, + IconSolidConnectionSmallLogo, + IconAlbumWhite, + IconCheckBlue, + IconAlertErrorRed, + IconVisibilityOn, + IconVisibilityOff, +}; diff --git a/apps/university-web/public/svgs/my/interest-university.svg b/apps/university-web/public/svgs/my/interest-university.svg new file mode 100644 index 00000000..08d36d95 --- /dev/null +++ b/apps/university-web/public/svgs/my/interest-university.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/apps/university-web/public/svgs/my/like.svg b/apps/university-web/public/svgs/my/like.svg new file mode 100644 index 00000000..4a132e96 --- /dev/null +++ b/apps/university-web/public/svgs/my/like.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/apps/university-web/public/svgs/my/lock.svg b/apps/university-web/public/svgs/my/lock.svg new file mode 100644 index 00000000..8ba78f33 --- /dev/null +++ b/apps/university-web/public/svgs/my/lock.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/apps/university-web/public/svgs/my/my-info-card-bookmark.svg b/apps/university-web/public/svgs/my/my-info-card-bookmark.svg new file mode 100644 index 00000000..ef4453c1 --- /dev/null +++ b/apps/university-web/public/svgs/my/my-info-card-bookmark.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/apps/university-web/public/svgs/my/my-info-card-mento.svg b/apps/university-web/public/svgs/my/my-info-card-mento.svg new file mode 100644 index 00000000..f9a3056d --- /dev/null +++ b/apps/university-web/public/svgs/my/my-info-card-mento.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/apps/university-web/public/svgs/my/my-info-card-wish.svg b/apps/university-web/public/svgs/my/my-info-card-wish.svg new file mode 100644 index 00000000..fc1ae79d --- /dev/null +++ b/apps/university-web/public/svgs/my/my-info-card-wish.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/apps/university-web/public/svgs/my/my-menu-arrow.svg b/apps/university-web/public/svgs/my/my-menu-arrow.svg new file mode 100644 index 00000000..6a36d5fd --- /dev/null +++ b/apps/university-web/public/svgs/my/my-menu-arrow.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/apps/university-web/public/svgs/my/my-menu-calendar.svg b/apps/university-web/public/svgs/my/my-menu-calendar.svg new file mode 100644 index 00000000..4fa44694 --- /dev/null +++ b/apps/university-web/public/svgs/my/my-menu-calendar.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/apps/university-web/public/svgs/my/my-menu-lock.svg b/apps/university-web/public/svgs/my/my-menu-lock.svg new file mode 100644 index 00000000..1d54235e --- /dev/null +++ b/apps/university-web/public/svgs/my/my-menu-lock.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/apps/university-web/public/svgs/my/my-menu-person.svg b/apps/university-web/public/svgs/my/my-menu-person.svg new file mode 100644 index 00000000..a88ac47b --- /dev/null +++ b/apps/university-web/public/svgs/my/my-menu-person.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/apps/university-web/public/svgs/my/password.svg b/apps/university-web/public/svgs/my/password.svg new file mode 100644 index 00000000..e42f6ff8 --- /dev/null +++ b/apps/university-web/public/svgs/my/password.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/apps/university-web/public/svgs/my/solid-connection-small-logo.svg b/apps/university-web/public/svgs/my/solid-connection-small-logo.svg new file mode 100644 index 00000000..0fdd1484 --- /dev/null +++ b/apps/university-web/public/svgs/my/solid-connection-small-logo.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/apps/university-web/public/svgs/my/university.svg b/apps/university-web/public/svgs/my/university.svg new file mode 100644 index 00000000..811872a8 --- /dev/null +++ b/apps/university-web/public/svgs/my/university.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/apps/university-web/public/svgs/my/visibility-off.svg b/apps/university-web/public/svgs/my/visibility-off.svg new file mode 100644 index 00000000..650d50ca --- /dev/null +++ b/apps/university-web/public/svgs/my/visibility-off.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/apps/university-web/public/svgs/my/visibility-on.svg b/apps/university-web/public/svgs/my/visibility-on.svg new file mode 100644 index 00000000..61aa8931 --- /dev/null +++ b/apps/university-web/public/svgs/my/visibility-on.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/apps/university-web/public/svgs/objects-and-tools.svg b/apps/university-web/public/svgs/objects-and-tools.svg new file mode 100644 index 00000000..783d5c61 --- /dev/null +++ b/apps/university-web/public/svgs/objects-and-tools.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/apps/university-web/public/svgs/placeholders/image-placeholder.svg b/apps/university-web/public/svgs/placeholders/image-placeholder.svg new file mode 100644 index 00000000..97189bfe --- /dev/null +++ b/apps/university-web/public/svgs/placeholders/image-placeholder.svg @@ -0,0 +1,7 @@ + + + + + + Image unavailable + diff --git a/apps/university-web/public/svgs/placeholders/news-thumbnail-placeholder.svg b/apps/university-web/public/svgs/placeholders/news-thumbnail-placeholder.svg new file mode 100644 index 00000000..661f0b57 --- /dev/null +++ b/apps/university-web/public/svgs/placeholders/news-thumbnail-placeholder.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/apps/university-web/public/svgs/placeholders/university-background-placeholder.svg b/apps/university-web/public/svgs/placeholders/university-background-placeholder.svg new file mode 100644 index 00000000..48336c13 --- /dev/null +++ b/apps/university-web/public/svgs/placeholders/university-background-placeholder.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/apps/university-web/public/svgs/placeholders/university-logo-placeholder.svg b/apps/university-web/public/svgs/placeholders/university-logo-placeholder.svg new file mode 100644 index 00000000..66df5f8d --- /dev/null +++ b/apps/university-web/public/svgs/placeholders/university-logo-placeholder.svg @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/university-web/public/svgs/post-checkbox-filled.svg b/apps/university-web/public/svgs/post-checkbox-filled.svg new file mode 100644 index 00000000..7275d13c --- /dev/null +++ b/apps/university-web/public/svgs/post-checkbox-filled.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/apps/university-web/public/svgs/post-checkbox-outlined.svg b/apps/university-web/public/svgs/post-checkbox-outlined.svg new file mode 100644 index 00000000..8e415f91 --- /dev/null +++ b/apps/university-web/public/svgs/post-checkbox-outlined.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/apps/university-web/public/svgs/post-like-filled.svg b/apps/university-web/public/svgs/post-like-filled.svg new file mode 100644 index 00000000..823228c7 --- /dev/null +++ b/apps/university-web/public/svgs/post-like-filled.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/apps/university-web/public/svgs/post-like-outline.svg b/apps/university-web/public/svgs/post-like-outline.svg new file mode 100644 index 00000000..123297db --- /dev/null +++ b/apps/university-web/public/svgs/post-like-outline.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/apps/university-web/public/svgs/score-banner.svg b/apps/university-web/public/svgs/score-banner.svg new file mode 100644 index 00000000..aebb452a --- /dev/null +++ b/apps/university-web/public/svgs/score-banner.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/apps/university-web/public/svgs/score/edit-filled.svg b/apps/university-web/public/svgs/score/edit-filled.svg new file mode 100644 index 00000000..2e8662be --- /dev/null +++ b/apps/university-web/public/svgs/score/edit-filled.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/apps/university-web/public/svgs/score/index.ts b/apps/university-web/public/svgs/score/index.ts new file mode 100644 index 00000000..64356b92 --- /dev/null +++ b/apps/university-web/public/svgs/score/index.ts @@ -0,0 +1,3 @@ +import IconEditFilled from "./edit-filled.svg"; + +export { IconEditFilled }; diff --git a/apps/university-web/public/svgs/search-banner.svg b/apps/university-web/public/svgs/search-banner.svg new file mode 100644 index 00000000..1a63476f --- /dev/null +++ b/apps/university-web/public/svgs/search-banner.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/apps/university-web/public/svgs/search-filled.svg b/apps/university-web/public/svgs/search-filled.svg new file mode 100644 index 00000000..57189f62 --- /dev/null +++ b/apps/university-web/public/svgs/search-filled.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/apps/university-web/public/svgs/search/down-arrow.svg b/apps/university-web/public/svgs/search/down-arrow.svg new file mode 100644 index 00000000..63cf6487 --- /dev/null +++ b/apps/university-web/public/svgs/search/down-arrow.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/apps/university-web/public/svgs/search/hat-color.svg b/apps/university-web/public/svgs/search/hat-color.svg new file mode 100644 index 00000000..a2c6541b --- /dev/null +++ b/apps/university-web/public/svgs/search/hat-color.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/apps/university-web/public/svgs/search/hat-gray.svg b/apps/university-web/public/svgs/search/hat-gray.svg new file mode 100644 index 00000000..786f5ba4 --- /dev/null +++ b/apps/university-web/public/svgs/search/hat-gray.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/apps/university-web/public/svgs/search/index.ts b/apps/university-web/public/svgs/search/index.ts new file mode 100644 index 00000000..1382f5fb --- /dev/null +++ b/apps/university-web/public/svgs/search/index.ts @@ -0,0 +1,8 @@ +import IconDownArrow from "./down-arrow.svg"; +import IconHatColor from "./hat-color.svg"; +import IconHatGray from "./hat-gray.svg"; +import IconLocationColor from "./location-color.svg"; +import IconLocationGray from "./location-gray.svg"; +import IconSearch from "./search.svg"; + +export { IconSearch, IconHatColor, IconHatGray, IconLocationColor, IconLocationGray, IconDownArrow }; diff --git a/apps/university-web/public/svgs/search/location-color.svg b/apps/university-web/public/svgs/search/location-color.svg new file mode 100644 index 00000000..753db91b --- /dev/null +++ b/apps/university-web/public/svgs/search/location-color.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/apps/university-web/public/svgs/search/location-gray.svg b/apps/university-web/public/svgs/search/location-gray.svg new file mode 100644 index 00000000..da0053d3 --- /dev/null +++ b/apps/university-web/public/svgs/search/location-gray.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/apps/university-web/public/svgs/search/search.svg b/apps/university-web/public/svgs/search/search.svg new file mode 100644 index 00000000..7f27ffb3 --- /dev/null +++ b/apps/university-web/public/svgs/search/search.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/apps/university-web/public/svgs/shareIcon.svg b/apps/university-web/public/svgs/shareIcon.svg new file mode 100644 index 00000000..95219b16 --- /dev/null +++ b/apps/university-web/public/svgs/shareIcon.svg @@ -0,0 +1,4 @@ + + + + diff --git a/apps/university-web/public/svgs/shareIconFilled.svg b/apps/university-web/public/svgs/shareIconFilled.svg new file mode 100644 index 00000000..6f01d5b0 --- /dev/null +++ b/apps/university-web/public/svgs/shareIconFilled.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/apps/university-web/public/svgs/signup-region-america.svg b/apps/university-web/public/svgs/signup-region-america.svg new file mode 100644 index 00000000..ae75a9dd --- /dev/null +++ b/apps/university-web/public/svgs/signup-region-america.svg @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/apps/university-web/public/svgs/signup-region-asia.svg b/apps/university-web/public/svgs/signup-region-asia.svg new file mode 100644 index 00000000..0ae40359 --- /dev/null +++ b/apps/university-web/public/svgs/signup-region-asia.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/apps/university-web/public/svgs/signup-region-europe.svg b/apps/university-web/public/svgs/signup-region-europe.svg new file mode 100644 index 00000000..91df547b --- /dev/null +++ b/apps/university-web/public/svgs/signup-region-europe.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/apps/university-web/public/svgs/signup-region-world.svg b/apps/university-web/public/svgs/signup-region-world.svg new file mode 100644 index 00000000..760da304 --- /dev/null +++ b/apps/university-web/public/svgs/signup-region-world.svg @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/apps/university-web/public/svgs/solid-connection-full-black-logo.svg b/apps/university-web/public/svgs/solid-connection-full-black-logo.svg new file mode 100644 index 00000000..24ea1a58 --- /dev/null +++ b/apps/university-web/public/svgs/solid-connection-full-black-logo.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/apps/university-web/public/svgs/speaker.svg b/apps/university-web/public/svgs/speaker.svg new file mode 100644 index 00000000..7f90508a --- /dev/null +++ b/apps/university-web/public/svgs/speaker.svg @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/apps/university-web/public/svgs/sub-comment.svg b/apps/university-web/public/svgs/sub-comment.svg new file mode 100644 index 00000000..1d1842e4 --- /dev/null +++ b/apps/university-web/public/svgs/sub-comment.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/university-web/public/svgs/tabler-search.svg b/apps/university-web/public/svgs/tabler-search.svg new file mode 100644 index 00000000..aed97be8 --- /dev/null +++ b/apps/university-web/public/svgs/tabler-search.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/apps/university-web/public/svgs/toast/cap.svg b/apps/university-web/public/svgs/toast/cap.svg new file mode 100644 index 00000000..c0e82300 --- /dev/null +++ b/apps/university-web/public/svgs/toast/cap.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/apps/university-web/public/svgs/toast/index.ts b/apps/university-web/public/svgs/toast/index.ts new file mode 100644 index 00000000..c4cffe67 --- /dev/null +++ b/apps/university-web/public/svgs/toast/index.ts @@ -0,0 +1,7 @@ +import IconToastCap from "./cap.svg"; +import IconToastLike from "./like.svg"; +import IconToastLink from "./link.svg"; +import IconToastLogo from "./logo.svg"; +import IconToastUniv from "./univ.svg"; + +export { IconToastCap, IconToastLike, IconToastLink, IconToastLogo, IconToastUniv }; diff --git a/apps/university-web/public/svgs/toast/like.svg b/apps/university-web/public/svgs/toast/like.svg new file mode 100644 index 00000000..3b60f210 --- /dev/null +++ b/apps/university-web/public/svgs/toast/like.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/apps/university-web/public/svgs/toast/link.svg b/apps/university-web/public/svgs/toast/link.svg new file mode 100644 index 00000000..33940627 --- /dev/null +++ b/apps/university-web/public/svgs/toast/link.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/apps/university-web/public/svgs/toast/logo.svg b/apps/university-web/public/svgs/toast/logo.svg new file mode 100644 index 00000000..2896762a --- /dev/null +++ b/apps/university-web/public/svgs/toast/logo.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/apps/university-web/public/svgs/toast/univ.svg b/apps/university-web/public/svgs/toast/univ.svg new file mode 100644 index 00000000..b6da0fda --- /dev/null +++ b/apps/university-web/public/svgs/toast/univ.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/apps/university-web/public/svgs/ui/check-blue.svg b/apps/university-web/public/svgs/ui/check-blue.svg new file mode 100644 index 00000000..0d5bf90c --- /dev/null +++ b/apps/university-web/public/svgs/ui/check-blue.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/apps/university-web/public/svgs/ui/exp-red.svg b/apps/university-web/public/svgs/ui/exp-red.svg new file mode 100644 index 00000000..93288b99 --- /dev/null +++ b/apps/university-web/public/svgs/ui/exp-red.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/apps/university-web/public/svgs/ui/eye-off.svg b/apps/university-web/public/svgs/ui/eye-off.svg new file mode 100644 index 00000000..a063a03a --- /dev/null +++ b/apps/university-web/public/svgs/ui/eye-off.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/apps/university-web/public/svgs/ui/eye-on.svg b/apps/university-web/public/svgs/ui/eye-on.svg new file mode 100644 index 00000000..8e3cd4ad --- /dev/null +++ b/apps/university-web/public/svgs/ui/eye-on.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/apps/university-web/public/svgs/ui/index.ts b/apps/university-web/public/svgs/ui/index.ts new file mode 100644 index 00000000..b0d81ad9 --- /dev/null +++ b/apps/university-web/public/svgs/ui/index.ts @@ -0,0 +1,6 @@ +import IconCheckBlue from "./check-blue.svg"; +import IconExpRed from "./exp-red.svg"; +import IconEyeOff from "./eye-off.svg"; +import IconEyeOn from "./eye-on.svg"; + +export { IconCheckBlue, IconExpRed, IconEyeOff, IconEyeOn }; diff --git a/apps/university-web/public/ui/ArrowDropDownFilled.svg b/apps/university-web/public/ui/ArrowDropDownFilled.svg new file mode 100644 index 00000000..b0484419 --- /dev/null +++ b/apps/university-web/public/ui/ArrowDropDownFilled.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/university-web/sentry.edge.config.ts b/apps/university-web/sentry.edge.config.ts new file mode 100644 index 00000000..4cc98473 --- /dev/null +++ b/apps/university-web/sentry.edge.config.ts @@ -0,0 +1,20 @@ +// This file configures the initialization of Sentry for edge features (middleware, edge routes, and so on). +// The config you add here will be used whenever one of the edge features is loaded. +// Note that this config is unrelated to the Vercel Edge Runtime and is also required when running locally. +// https://docs.sentry.io/platforms/javascript/guides/nextjs/ +import * as Sentry from "@sentry/nextjs"; + +// 프로덕션 환경에서만 Sentry 초기화 +if (process.env.NODE_ENV === "production") { + Sentry.init({ + dsn: process.env.SENTRY_DSN || "", + environment: process.env.SENTRY_ENVIRONMENT || "production", + + // Performance Monitoring: 프로덕션에서 30% 샘플링 + // https://docs.sentry.io/platforms/javascript/configuration/options/#traces-sample-rate + tracesSampleRate: 0.3, + + // Setting this option to true will print useful information to the console while you're setting up Sentry. + debug: false, + }); +} diff --git a/apps/university-web/sentry.server.config.ts b/apps/university-web/sentry.server.config.ts new file mode 100644 index 00000000..7b71a33d --- /dev/null +++ b/apps/university-web/sentry.server.config.ts @@ -0,0 +1,23 @@ +// This file configures the initialization of Sentry on the server. +// The config you add here will be used whenever the server handles a request. +// https://docs.sentry.io/platforms/javascript/guides/nextjs/ +import * as Sentry from "@sentry/nextjs"; + +// 프로덕션 환경에서만 Sentry 초기화 +if (process.env.NODE_ENV === "production") { + Sentry.init({ + dsn: process.env.SENTRY_DSN || "", + environment: process.env.SENTRY_ENVIRONMENT || "production", + + // Keep default PII collection off; add explicit context only where needed. + // https://docs.sentry.io/platforms/javascript/guides/nextjs/configuration/options/#sendDefaultPii + sendDefaultPii: false, + + // Performance Monitoring: 프로덕션에서 30% 샘플링 + // https://docs.sentry.io/platforms/javascript/configuration/options/#traces-sample-rate + tracesSampleRate: 0.3, + + // Setting this option to true will print useful information to the console while you're setting up Sentry. + debug: false, + }); +} diff --git a/apps/university-web/src/apis/Admin/api.ts b/apps/university-web/src/apis/Admin/api.ts new file mode 100644 index 00000000..753da589 --- /dev/null +++ b/apps/university-web/src/apis/Admin/api.ts @@ -0,0 +1,176 @@ +import { axiosInstance } from "@/utils/axiosInstance"; + +export interface VerifyLanguageTestResponse { + id: number; + languageTestType: string; + languageTestScore: string; + verifyStatus: string; + rejectedReason: null; +} + +export type VerifyLanguageTestRequest = Record; + +export interface LanguageTestListResponseSort { + sorted: boolean; + unsorted: boolean; + empty: boolean; +} + +export interface LanguageTestListResponsePageable { + pageNumber: number; + pageSize: number; + sort: LanguageTestListResponsePageableSort; + offset: number; + paged: boolean; + unpaged: boolean; +} + +export interface LanguageTestListResponsePageableSort { + sorted: boolean; + unsorted: boolean; + empty: boolean; +} + +export interface LanguageTestListResponseContentItem { + languageTestScoreStatusResponse: LanguageTestListResponseContentItemLanguageTestScoreStatusResponse; + siteUserResponse: LanguageTestListResponseContentItemSiteUserResponse; +} + +export interface LanguageTestListResponseContentItemSiteUserResponse { + id: number; + nickname: string; + profileImageUrl: string; +} + +export interface LanguageTestListResponseContentItemLanguageTestScoreStatusResponse { + id: number; + languageTestResponse: LanguageTestListResponseContentItemLanguageTestScoreStatusResponseLanguageTestResponse; + verifyStatus: string; + rejectedReason: null; + createdAt: string; + updatedAt: string; +} + +export interface LanguageTestListResponseContentItemLanguageTestScoreStatusResponseLanguageTestResponse { + languageTestType: string; + languageTestScore: string; + languageTestReportUrl: string; +} + +export interface LanguageTestListResponse { + content: LanguageTestListResponseContentItem[]; + pageable: LanguageTestListResponsePageable; + totalElements: number; + totalPages: number; + last: boolean; + size: number; + number: number; + sort: LanguageTestListResponseSort; + numberOfElements: number; + first: boolean; + empty: boolean; +} + +export interface VerifyGpaResponse { + id: number; + gpa: number; + gpaCriteria: number; + verifyStatus: string; + rejectedReason: null; +} + +export type VerifyGpaRequest = Record; + +export interface GpaListResponseSort { + sorted: boolean; + unsorted: boolean; + empty: boolean; +} + +export interface GpaListResponsePageable { + pageNumber: number; + pageSize: number; + sort: GpaListResponsePageableSort; + offset: number; + paged: boolean; + unpaged: boolean; +} + +export interface GpaListResponsePageableSort { + sorted: boolean; + unsorted: boolean; + empty: boolean; +} + +export interface GpaListResponseContentItem { + gpaScoreStatusResponse: GpaListResponseContentItemGpaScoreStatusResponse; + siteUserResponse: GpaListResponseContentItemSiteUserResponse; +} + +export interface GpaListResponseContentItemSiteUserResponse { + id: number; + nickname: string; + profileImageUrl: string; +} + +export interface GpaListResponseContentItemGpaScoreStatusResponse { + id: number; + gpaResponse: GpaListResponseContentItemGpaScoreStatusResponseGpaResponse; + verifyStatus: string; + rejectedReason: null; + createdAt: string; + updatedAt: string; +} + +export interface GpaListResponseContentItemGpaScoreStatusResponseGpaResponse { + gpa: number; + gpaCriteria: number; + gpaReportUrl: string; +} + +export interface GpaListResponse { + content: GpaListResponseContentItem[]; + pageable: GpaListResponsePageable; + totalElements: number; + totalPages: number; + last: boolean; + size: number; + number: number; + sort: GpaListResponseSort; + numberOfElements: number; + first: boolean; + empty: boolean; +} + +export const adminApi = { + putVerifyLanguageTest: async (params: { + languageTestScoreId: string | number; + data?: VerifyLanguageTestRequest; + }): Promise => { + const res = await axiosInstance.put( + `/admin/scores/language-tests/${params.languageTestScoreId}`, + params?.data, + ); + return res.data; + }, + + getLanguageTestList: async (params: { params?: Record }): Promise => { + const res = await axiosInstance.get(`/admin/scores/language-tests?page=1&size=10`, { + params: params?.params, + }); + return res.data; + }, + + putVerifyGpa: async (params: { + gpaScoreId: string | number; + data?: VerifyGpaRequest; + }): Promise => { + const res = await axiosInstance.put(`/admin/scores/gpas/${params.gpaScoreId}`, params?.data); + return res.data; + }, + + getGpaList: async (params: { params?: Record }): Promise => { + const res = await axiosInstance.get(`/admin/scores/gpas`, { params: params?.params }); + return res.data; + }, +}; diff --git a/apps/university-web/src/apis/Admin/getGpaList.ts b/apps/university-web/src/apis/Admin/getGpaList.ts new file mode 100644 index 00000000..8af4619b --- /dev/null +++ b/apps/university-web/src/apis/Admin/getGpaList.ts @@ -0,0 +1,13 @@ +import { useQuery } from "@tanstack/react-query"; +import type { AxiosError } from "axios"; +import { QueryKeys } from "../queryKeys"; +import { adminApi, type GpaListResponse } from "./api"; + +const useGetGpaList = (params?: Record) => { + return useQuery({ + queryKey: [QueryKeys.Admin.gpaList, params], + queryFn: () => adminApi.getGpaList(params ? { params } : {}), + }); +}; + +export default useGetGpaList; diff --git a/apps/university-web/src/apis/Admin/getLanguageTestList.ts b/apps/university-web/src/apis/Admin/getLanguageTestList.ts new file mode 100644 index 00000000..28cd6fd4 --- /dev/null +++ b/apps/university-web/src/apis/Admin/getLanguageTestList.ts @@ -0,0 +1,13 @@ +import { useQuery } from "@tanstack/react-query"; +import type { AxiosError } from "axios"; +import { QueryKeys } from "../queryKeys"; +import { adminApi, type LanguageTestListResponse } from "./api"; + +const useGetLanguageTestList = (params?: Record) => { + return useQuery({ + queryKey: [QueryKeys.Admin.languageTestList, params], + queryFn: () => adminApi.getLanguageTestList(params ? { params } : {}), + }); +}; + +export default useGetLanguageTestList; diff --git a/apps/university-web/src/apis/Admin/index.ts b/apps/university-web/src/apis/Admin/index.ts new file mode 100644 index 00000000..f7d22c2a --- /dev/null +++ b/apps/university-web/src/apis/Admin/index.ts @@ -0,0 +1,5 @@ +export { adminApi } from "./api"; +export { default as getGpaList } from "./getGpaList"; +export { default as getLanguageTestList } from "./getLanguageTestList"; +export { default as putVerifyGpa } from "./putVerifyGpa"; +export { default as putVerifyLanguageTest } from "./putVerifyLanguageTest"; diff --git a/apps/university-web/src/apis/Admin/putVerifyGpa.ts b/apps/university-web/src/apis/Admin/putVerifyGpa.ts new file mode 100644 index 00000000..9d834b2f --- /dev/null +++ b/apps/university-web/src/apis/Admin/putVerifyGpa.ts @@ -0,0 +1,11 @@ +import { useMutation } from "@tanstack/react-query"; +import type { AxiosError } from "axios"; +import { adminApi, type VerifyGpaRequest, type VerifyGpaResponse } from "./api"; + +const usePutVerifyGpa = () => { + return useMutation({ + mutationFn: (variables) => adminApi.putVerifyGpa(variables), + }); +}; + +export default usePutVerifyGpa; diff --git a/apps/university-web/src/apis/Admin/putVerifyLanguageTest.ts b/apps/university-web/src/apis/Admin/putVerifyLanguageTest.ts new file mode 100644 index 00000000..0e04811c --- /dev/null +++ b/apps/university-web/src/apis/Admin/putVerifyLanguageTest.ts @@ -0,0 +1,15 @@ +import { useMutation } from "@tanstack/react-query"; +import type { AxiosError } from "axios"; +import { adminApi, type VerifyLanguageTestRequest, type VerifyLanguageTestResponse } from "./api"; + +const usePutVerifyLanguageTest = () => { + return useMutation< + VerifyLanguageTestResponse, + AxiosError, + { languageTestScoreId: string | number; data: VerifyLanguageTestRequest } + >({ + mutationFn: (variables) => adminApi.putVerifyLanguageTest(variables), + }); +}; + +export default usePutVerifyLanguageTest; diff --git a/apps/university-web/src/apis/Auth/api.ts b/apps/university-web/src/apis/Auth/api.ts new file mode 100644 index 00000000..3f04e115 --- /dev/null +++ b/apps/university-web/src/apis/Auth/api.ts @@ -0,0 +1,145 @@ +import { axiosInstance, publicAxiosInstance } from "@/utils/axiosInstance"; + +export type SignOutResponse = Record; + +export type SignOutRequest = Record; + +// Apple Auth Types +export interface RegisteredAppleAuthResponse { + isRegistered: true; + accessToken: string; + refreshToken: string; +} + +export interface UnregisteredAppleAuthResponse { + isRegistered: false; + nickname: null; + email: string; + profileImageUrl: null; + signUpToken: string; +} + +export type AppleAuthResponse = RegisteredAppleAuthResponse | UnregisteredAppleAuthResponse; + +export interface AppleAuthRequest { + code: string; +} + +export interface RefreshTokenResponse { + accessToken: string; +} + +export type RefreshTokenRequest = Record; + +export interface EmailLoginResponse { + accessToken: string; + refreshToken: string; +} + +export interface EmailLoginRequest { + email: string; + password: string; +} + +export interface EmailVerificationResponse { + signUpToken: string; +} + +export interface EmailVerificationRequest { + email: string; + verificationCode: string; +} + +// Kakao Auth Types +export interface RegisteredKakaoAuthResponse { + isRegistered: true; + accessToken: string; + refreshToken: string; +} + +export interface UnregisteredKakaoAuthResponse { + isRegistered: false; + nickname: string; + email: string; + profileImageUrl: string; + signUpToken: string; +} + +export type KakaoAuthResponse = RegisteredKakaoAuthResponse | UnregisteredKakaoAuthResponse; + +export interface KakaoAuthRequest { + code: string; +} + +export type AccountResponse = undefined; + +export interface SignUpResponse { + accessToken: string; + refreshToken: string; +} + +export interface SignUpRequest { + signUpToken: string; + nickname: string; + profileImageUrl: string; + preparationStatus: string; + interestedRegions: string[]; + interestedCountries: string[]; +} + +export interface EmailSignUpRequest { + email: string; + password: string; +} + +export interface EmailSignUpResponse { + signUpToken: string; +} + +export const authApi = { + postSignOut: async (): Promise => { + const res = await axiosInstance.post(`/auth/sign-out`); + return res.data; + }, + + postAppleAuth: async (data: AppleAuthRequest): Promise => { + const res = await publicAxiosInstance.post(`/auth/apple`, data); + return res.data; + }, + + postRefreshToken: async (): Promise => { + const res = await publicAxiosInstance.post(`/auth/reissue`); + return res.data; + }, + + postEmailLogin: async (data: EmailLoginRequest): Promise => { + const res = await publicAxiosInstance.post(`/auth/email/sign-in`, data); + return res.data; + }, + + postEmailSignUp: async (data: EmailSignUpRequest): Promise => { + const res = await publicAxiosInstance.post(`/auth/email/sign-up`, data); + return res.data; + }, + + postKakaoAuth: async (data: KakaoAuthRequest): Promise => { + const res = await publicAxiosInstance.post(`/auth/kakao`, data); + return res.data; + }, + + deleteAccount: async (): Promise => { + const res = await axiosInstance.delete(`/auth/quit`); + return res.data; + }, + + postSignUp: async (data: SignUpRequest): Promise => { + // 임시 성별, 생년월일 추가. API 변경 시 삭제 + const payload = { + ...data, + birth: "2000-01-01", + gender: "PREFER_NOT_TO_SAY", + }; + const res = await publicAxiosInstance.post(`/auth/sign-up`, payload); + return res.data; + }, +}; diff --git a/apps/university-web/src/apis/Auth/deleteAccount.ts b/apps/university-web/src/apis/Auth/deleteAccount.ts new file mode 100644 index 00000000..9cadadc9 --- /dev/null +++ b/apps/university-web/src/apis/Auth/deleteAccount.ts @@ -0,0 +1,31 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; + +import type { AxiosError } from "axios"; +import { useRouter } from "next/navigation"; + +import useAuthStore from "@/lib/zustand/useAuthStore"; +import { type AccountResponse, authApi } from "./api"; + +/** + * @description 회원탈퇴를 위한 useMutation 커스텀 훅 + */ +const useDeleteUserAccount = () => { + const router = useRouter(); + const { clearAccessToken } = useAuthStore(); + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: () => authApi.deleteAccount(), + onMutate: () => { + // 낙관적 업데이트: 요청이 시작되면 바로 홈으로 이동 + router.replace("/"); + }, + onSuccess: () => { + // Zustand persist가 자동으로 localStorage에서 제거 + clearAccessToken(); + queryClient.clear(); + }, + }); +}; + +export default useDeleteUserAccount; diff --git a/apps/university-web/src/apis/Auth/index.ts b/apps/university-web/src/apis/Auth/index.ts new file mode 100644 index 00000000..cd56347d --- /dev/null +++ b/apps/university-web/src/apis/Auth/index.ts @@ -0,0 +1,25 @@ +export type { + AppleAuthRequest, + AppleAuthResponse, + EmailLoginRequest, + EmailLoginResponse, + EmailSignUpRequest, + EmailSignUpResponse, + KakaoAuthRequest, + KakaoAuthResponse, + SignUpRequest, + SignUpResponse, +} from "./api"; +export { authApi } from "./api"; + +// Client-side hooks +export { default as useDeleteUserAccount } from "./deleteAccount"; +export { default as usePostAppleAuth } from "./postAppleAuth"; +export { default as usePostEmailAuth } from "./postEmailLogin"; +export { default as usePostEmailSignUp } from "./postEmailVerification"; +export { default as usePostKakaoAuth } from "./postKakaoAuth"; +export { default as usePostLogout } from "./postSignOut"; +export { default as usePostSignUp } from "./postSignUp"; + +// Server-side functions +export { postReissueToken } from "./server"; diff --git a/apps/university-web/src/apis/Auth/postAppleAuth.ts b/apps/university-web/src/apis/Auth/postAppleAuth.ts new file mode 100644 index 00000000..dc024c39 --- /dev/null +++ b/apps/university-web/src/apis/Auth/postAppleAuth.ts @@ -0,0 +1,39 @@ +import { useMutation } from "@tanstack/react-query"; + +import type { AxiosError } from "axios"; +import { useRouter } from "next/navigation"; +import { showIconToast } from "@/lib/toast/showIconToast"; +import useAuthStore from "@/lib/zustand/useAuthStore"; +import { type AppleAuthRequest, type AppleAuthResponse, authApi } from "./api"; + +/** + * @description 애플 로그인을 위한 useMutation 커스텀 훅 + */ +const usePostAppleAuth = () => { + const router = useRouter(); + + return useMutation({ + mutationFn: (data) => authApi.postAppleAuth(data), + onSuccess: (data) => { + if (data.isRegistered) { + // 기존 회원일 시 - Zustand persist가 자동으로 localStorage에 저장 + // refreshToken은 서버에서 HTTP-only 쿠키로 자동 설정됨 + useAuthStore.getState().setAccessToken(data.accessToken); + + showIconToast("logo", "로그인에 성공했습니다."); + + setTimeout(() => { + router.push("/"); + }, 100); + } else { + // 새로운 회원일 시 - 회원가입 페이지로 이동 + router.push(`/sign-up?token=${data.signUpToken}`); + } + }, + onError: () => { + router.push("/login"); + }, + }); +}; + +export default usePostAppleAuth; diff --git a/apps/university-web/src/apis/Auth/postEmailLogin.ts b/apps/university-web/src/apis/Auth/postEmailLogin.ts new file mode 100644 index 00000000..8d1317bb --- /dev/null +++ b/apps/university-web/src/apis/Auth/postEmailLogin.ts @@ -0,0 +1,36 @@ +import { useMutation } from "@tanstack/react-query"; + +import type { AxiosError } from "axios"; +import { useRouter } from "next/navigation"; +import { showIconToast } from "@/lib/toast/showIconToast"; +import useAuthStore from "@/lib/zustand/useAuthStore"; +import { authApi, type EmailLoginRequest, type EmailLoginResponse } from "./api"; + +/** + * @description 이메일 로그인을 위한 useMutation 커스텀 훅 + */ +const usePostEmailAuth = () => { + const { setAccessToken } = useAuthStore(); + const router = useRouter(); + + return useMutation({ + mutationFn: (data) => authApi.postEmailLogin(data), + onSuccess: (data) => { + const { accessToken } = data; + + // Zustand persist가 자동으로 localStorage에 저장 + // refreshToken은 서버에서 HTTP-only 쿠키로 자동 설정됨 + setAccessToken(accessToken); + + showIconToast("logo", "로그인에 성공했습니다."); + + // Zustand persist middleware가 localStorage에 저장할 시간을 보장 + // 토큰 저장 후 리다이렉트하여 타이밍 이슈 방지 + setTimeout(() => { + router.push("/"); + }, 100); + }, + }); +}; + +export default usePostEmailAuth; diff --git a/apps/university-web/src/apis/Auth/postEmailVerification.ts b/apps/university-web/src/apis/Auth/postEmailVerification.ts new file mode 100644 index 00000000..59c6612d --- /dev/null +++ b/apps/university-web/src/apis/Auth/postEmailVerification.ts @@ -0,0 +1,15 @@ +import { useMutation } from "@tanstack/react-query"; +import type { AxiosError } from "axios"; + +import { authApi, type EmailSignUpRequest, type EmailSignUpResponse } from "./api"; + +/** + * @description 이메일 회원가입을 위한 useMutation 커스텀 훅 + */ +const usePostEmailSignUp = () => { + return useMutation({ + mutationFn: (data) => authApi.postEmailSignUp(data), + }); +}; + +export default usePostEmailSignUp; diff --git a/apps/university-web/src/apis/Auth/postKakaoAuth.ts b/apps/university-web/src/apis/Auth/postKakaoAuth.ts new file mode 100644 index 00000000..f8671416 --- /dev/null +++ b/apps/university-web/src/apis/Auth/postKakaoAuth.ts @@ -0,0 +1,39 @@ +import { useMutation } from "@tanstack/react-query"; + +import type { AxiosError } from "axios"; +import { useRouter } from "next/navigation"; +import { showIconToast } from "@/lib/toast/showIconToast"; +import useAuthStore from "@/lib/zustand/useAuthStore"; +import { authApi, type KakaoAuthRequest, type KakaoAuthResponse } from "./api"; + +/** + * @description 카카오 로그인을 위한 useMutation 커스텀 훅 + */ +const usePostKakaoAuth = () => { + const { setAccessToken } = useAuthStore(); + const router = useRouter(); + + return useMutation({ + mutationFn: (data) => authApi.postKakaoAuth(data), + onSuccess: (data) => { + if (data.isRegistered) { + // 기존 회원일 시 - Zustand persist가 자동으로 localStorage에 저장 + // refreshToken은 서버에서 HTTP-only 쿠키로 자동 설정됨 + setAccessToken(data.accessToken); + + showIconToast("logo", "로그인에 성공했습니다."); + setTimeout(() => { + router.push("/"); + }, 100); + } else { + // 새로운 회원일 시 - 회원가입 페이지로 이동 + router.push(`/sign-up?token=${data.signUpToken}`); + } + }, + onError: () => { + router.push("/login"); + }, + }); +}; + +export default usePostKakaoAuth; diff --git a/apps/university-web/src/apis/Auth/postRefreshToken.ts b/apps/university-web/src/apis/Auth/postRefreshToken.ts new file mode 100644 index 00000000..68fbf570 --- /dev/null +++ b/apps/university-web/src/apis/Auth/postRefreshToken.ts @@ -0,0 +1,11 @@ +import { useMutation } from "@tanstack/react-query"; +import type { AxiosError } from "axios"; +import { authApi, type RefreshTokenResponse } from "./api"; + +const usePostRefreshToken = () => { + return useMutation({ + mutationFn: () => authApi.postRefreshToken(), + }); +}; + +export default usePostRefreshToken; diff --git a/apps/university-web/src/apis/Auth/postSignOut.ts b/apps/university-web/src/apis/Auth/postSignOut.ts new file mode 100644 index 00000000..a983f61a --- /dev/null +++ b/apps/university-web/src/apis/Auth/postSignOut.ts @@ -0,0 +1,26 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import type { AxiosError } from "axios"; + +import useAuthStore from "@/lib/zustand/useAuthStore"; +import { authApi, type SignOutResponse } from "./api"; + +/** + * @description 로그아웃을 위한 useMutation 커스텀 훅 + */ +const usePostLogout = () => { + const { clearAccessToken } = useAuthStore(); + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: () => authApi.postSignOut(), + onSuccess: () => { + // Zustand persist가 자동으로 localStorage에서 제거 + clearAccessToken(); + queryClient.clear(); + // 로그아웃 후 홈으로 리다이렉트 + window.location.href = "/"; + }, + }); +}; + +export default usePostLogout; diff --git a/apps/university-web/src/apis/Auth/postSignUp.ts b/apps/university-web/src/apis/Auth/postSignUp.ts new file mode 100644 index 00000000..72860bde --- /dev/null +++ b/apps/university-web/src/apis/Auth/postSignUp.ts @@ -0,0 +1,15 @@ +import { useMutation } from "@tanstack/react-query"; +import type { AxiosError } from "axios"; + +import { authApi, type SignUpRequest, type SignUpResponse } from "./api"; + +/** + * @description 회원가입을 위한 useMutation 커스텀 훅 + */ +const usePostSignUp = () => { + return useMutation({ + mutationFn: (data) => authApi.postSignUp(data), + }); +}; + +export default usePostSignUp; diff --git a/apps/university-web/src/apis/Auth/server/index.ts b/apps/university-web/src/apis/Auth/server/index.ts new file mode 100644 index 00000000..09d60f03 --- /dev/null +++ b/apps/university-web/src/apis/Auth/server/index.ts @@ -0,0 +1 @@ +export { default as postReissueToken } from "./postReissueToken"; diff --git a/apps/university-web/src/apis/Auth/server/postReissueToken.ts b/apps/university-web/src/apis/Auth/server/postReissueToken.ts new file mode 100644 index 00000000..d7d35038 --- /dev/null +++ b/apps/university-web/src/apis/Auth/server/postReissueToken.ts @@ -0,0 +1,32 @@ +import useAuthStore from "@/lib/zustand/useAuthStore"; +import { publicAxiosInstance } from "@/utils/axiosInstance"; +import { isTokenExpired } from "@/utils/jwtUtils"; + +/** + * @description 토큰 재발급 서버사이드 함수 + * axiosInstance의 interceptor에서 사용됨 + */ +const postReissueToken = async (): Promise => { + try { + const response = await publicAxiosInstance.post<{ accessToken: string }>("/auth/reissue"); + const newAccessToken = response.data.accessToken; + + if (!newAccessToken) { + throw new Error("재발급된 토큰이 유효하지 않습니다."); + } + if (isTokenExpired(newAccessToken)) { + throw new Error("재발급된 토큰이 이미 만료되었습니다."); + } + + // 재발급 성공 시, 새로운 토큰을 Zustand 스토어에 저장 + useAuthStore.getState().setAccessToken(newAccessToken); + + return newAccessToken; + } catch (error) { + // 재발급 실패 시 스토어 상태 초기화 + useAuthStore.getState().clearAccessToken(); + throw error; + } +}; + +export default postReissueToken; diff --git a/apps/university-web/src/apis/MyPage/api.ts b/apps/university-web/src/apis/MyPage/api.ts new file mode 100644 index 00000000..8586b26d --- /dev/null +++ b/apps/university-web/src/apis/MyPage/api.ts @@ -0,0 +1,68 @@ +import type { AxiosResponse } from "axios"; +import type { UserRole } from "@/types/mentor"; +import type { BaseUserInfo } from "@/types/myInfo"; +import { axiosInstance } from "@/utils/axiosInstance"; + +// --- 타입 정의 --- +export interface MenteeInfo extends BaseUserInfo { + role: UserRole.MENTEE; + interestedCountries: string[]; +} + +export interface MentorInfo extends BaseUserInfo { + role: UserRole.MENTOR; + attendedUniversity: string; +} + +export interface AdminInfo extends BaseUserInfo { + role: UserRole.ADMIN; + attendedUniversity: string; +} + +export type MyInfoResponse = MenteeInfo | MentorInfo | AdminInfo; + +export type InterestedRegionCountryResponse = undefined; + +export type InterestedRegionCountryRequest = string[]; + +export interface ProfilePatchRequest { + nickname?: string; + file?: File; +} + +export interface PasswordPatchRequest { + currentPassword: string; + newPassword: string; + newPasswordConfirmation: string; +} + +export const myPageApi = { + getProfile: async (): Promise => { + const response: AxiosResponse = await axiosInstance.get("/my"); + return response.data; + }, + + patchProfile: async (data: ProfilePatchRequest): Promise => { + const formData = new FormData(); + if (data.nickname) { + formData.append("nickname", data.nickname); + } + if (data.file) { + formData.append("file", data.file); + } + const res = await axiosInstance.patch("/my", formData); + return res.data; + }, + + patchPassword: async (data: PasswordPatchRequest): Promise => { + const res = await axiosInstance.patch("/my/password", data); + return res.data; + }, + + patchInterestedRegionCountry: async ( + data: InterestedRegionCountryRequest, + ): Promise => { + const res = await axiosInstance.patch(`/my/interested-location`, data); + return res.data; + }, +}; diff --git a/apps/university-web/src/apis/MyPage/getProfile.ts b/apps/university-web/src/apis/MyPage/getProfile.ts new file mode 100644 index 00000000..3ca32d82 --- /dev/null +++ b/apps/university-web/src/apis/MyPage/getProfile.ts @@ -0,0 +1,37 @@ +import { type UseQueryResult, useMutationState, useQuery } from "@tanstack/react-query"; +import type { AxiosError } from "axios"; +import { QueryKeys } from "../queryKeys"; +import { type MyInfoResponse, myPageApi } from "./api"; + +type UseGetMyInfoResult = Omit, "data"> & { + data: MyInfoResponse | undefined; +}; + +const useGetMyInfo = (): UseGetMyInfoResult => { + const queryResult = useQuery({ + queryKey: [QueryKeys.MyPage.profile], + queryFn: () => myPageApi.getProfile(), + // staleTime을 무한으로 설정하여 불필요한 자동 refetch를 방지합니다. + staleTime: Infinity, + gcTime: 1000 * 60 * 30, // 예: 30분 + }); + + const pendingMutations = useMutationState({ + filters: { + mutationKey: [QueryKeys.MyPage.profile, "patch"], + status: "pending", + }, + select: (mutation) => { + return mutation.state.variables as Partial; + }, + }); + + const isOptimistic = pendingMutations.length > 0; + const pendingData = isOptimistic ? pendingMutations[0] : null; + + const displayData = isOptimistic && queryResult.data ? { ...queryResult.data, ...pendingData } : queryResult.data; + + return { ...queryResult, data: displayData as MyInfoResponse | undefined }; +}; + +export default useGetMyInfo; diff --git a/apps/university-web/src/apis/MyPage/index.ts b/apps/university-web/src/apis/MyPage/index.ts new file mode 100644 index 00000000..f431945c --- /dev/null +++ b/apps/university-web/src/apis/MyPage/index.ts @@ -0,0 +1,13 @@ +export { + type AdminInfo, + type MenteeInfo, + type MentorInfo, + type MyInfoResponse, + myPageApi, + type PasswordPatchRequest, + type ProfilePatchRequest, +} from "./api"; +export { default as useGetMyInfo } from "./getProfile"; +export { default as usePatchInterestedRegionCountry } from "./patchInterestedRegionCountry"; +export { default as usePatchMyPassword } from "./patchPassword"; +export { default as usePatchMyInfo } from "./patchProfile"; diff --git a/apps/university-web/src/apis/MyPage/patchInterestedRegionCountry.ts b/apps/university-web/src/apis/MyPage/patchInterestedRegionCountry.ts new file mode 100644 index 00000000..0b15e70f --- /dev/null +++ b/apps/university-web/src/apis/MyPage/patchInterestedRegionCountry.ts @@ -0,0 +1,11 @@ +import { useMutation } from "@tanstack/react-query"; +import type { AxiosError } from "axios"; +import { type InterestedRegionCountryRequest, type InterestedRegionCountryResponse, myPageApi } from "./api"; + +const usePatchInterestedRegionCountry = () => { + return useMutation({ + mutationFn: (data) => myPageApi.patchInterestedRegionCountry(data), + }); +}; + +export default usePatchInterestedRegionCountry; diff --git a/apps/university-web/src/apis/MyPage/patchPassword.ts b/apps/university-web/src/apis/MyPage/patchPassword.ts new file mode 100644 index 00000000..33728baf --- /dev/null +++ b/apps/university-web/src/apis/MyPage/patchPassword.ts @@ -0,0 +1,27 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; + +import type { AxiosError } from "axios"; +import { useRouter } from "next/navigation"; +import { showIconToast } from "@/lib/toast/showIconToast"; +import useAuthStore from "@/lib/zustand/useAuthStore"; +import { QueryKeys } from "../queryKeys"; +import { myPageApi, type PasswordPatchRequest } from "./api"; + +const usePatchMyPassword = () => { + const queryClient = useQueryClient(); + const router = useRouter(); + const { clearAccessToken } = useAuthStore(); + + return useMutation, PasswordPatchRequest>({ + mutationKey: [QueryKeys.MyPage.password, "patch"], + mutationFn: (data) => myPageApi.patchPassword(data), + onSuccess: () => { + clearAccessToken(); + queryClient.clear(); + showIconToast("logo", "비밀번호가 성공적으로 변경되었습니다."); + router.replace("/"); + }, + }); +}; + +export default usePatchMyPassword; diff --git a/apps/university-web/src/apis/MyPage/patchProfile.ts b/apps/university-web/src/apis/MyPage/patchProfile.ts new file mode 100644 index 00000000..573835b4 --- /dev/null +++ b/apps/university-web/src/apis/MyPage/patchProfile.ts @@ -0,0 +1,24 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import type { AxiosError } from "axios"; +import { showIconToast } from "@/lib/toast/showIconToast"; +import { QueryKeys } from "../queryKeys"; +import { myPageApi, type ProfilePatchRequest } from "./api"; + +const usePatchMyInfo = () => { + const queryClient = useQueryClient(); + + return useMutation, ProfilePatchRequest>({ + mutationKey: [QueryKeys.MyPage.profile, "patch"], + mutationFn: (data) => myPageApi.patchProfile(data), + onSettled: () => { + queryClient.invalidateQueries({ + queryKey: [QueryKeys.MyPage.profile], + }); + }, + onSuccess: () => { + showIconToast("logo", "프로필이 성공적으로 수정되었습니다."); + }, + }); +}; + +export default usePatchMyInfo; diff --git a/apps/university-web/src/apis/Scores/api.ts b/apps/university-web/src/apis/Scores/api.ts new file mode 100644 index 00000000..e1961939 --- /dev/null +++ b/apps/university-web/src/apis/Scores/api.ts @@ -0,0 +1,79 @@ +import type { AxiosResponse } from "axios"; +import type { GpaScore, LanguageTestEnum, LanguageTestScore } from "@/types/score"; +import { axiosInstance } from "@/utils/axiosInstance"; + +// ====== Query Keys ====== +export const ScoresQueryKeys = { + myGpaScore: "myGpaScore", + myLanguageTestScore: "myLanguageTestScore", +} as const; + +// ====== Types ====== +export interface UseMyGpaScoreResponse { + gpaScoreStatusResponseList: GpaScore[]; +} + +export interface UseGetMyLanguageTestScoreResponse { + languageTestScoreStatusResponseList: LanguageTestScore[]; +} + +export interface UsePostGpaScoreRequest { + gpaScoreRequest: { + gpa: number; + gpaCriteria: number; + issueDate: string; // yyyy-MM-dd + }; + file: Blob; +} + +export interface UsePostLanguageTestScoreRequest { + languageTestScoreRequest: { + languageTestType: LanguageTestEnum; + languageTestScore: string; + issueDate: string; // yyyy-MM-dd + }; + file: File; +} + +// ====== API Functions ====== +export const scoresApi = { + /** + * 내 학점 점수 조회 + */ + getMyGpaScore: async (): Promise> => { + return axiosInstance.get("/scores/gpas"); + }, + + /** + * 내 어학 점수 조회 + */ + getMyLanguageTestScore: async (): Promise> => { + return axiosInstance.get("/scores/language-tests"); + }, + + /** + * 학점 점수 제출 + */ + postGpaScore: async (request: UsePostGpaScoreRequest): Promise> => { + const formData = new FormData(); + formData.append( + "gpaScoreRequest", + new Blob([JSON.stringify(request.gpaScoreRequest)], { type: "application/json" }), + ); + formData.append("file", request.file); + return axiosInstance.post("/scores/gpas", formData); + }, + + /** + * 어학 점수 제출 + */ + postLanguageTestScore: async (request: UsePostLanguageTestScoreRequest): Promise> => { + const formData = new FormData(); + formData.append( + "languageTestScoreRequest", + new Blob([JSON.stringify(request.languageTestScoreRequest)], { type: "application/json" }), + ); + formData.append("file", request.file); + return axiosInstance.post("/scores/language-tests", formData); + }, +}; diff --git a/apps/university-web/src/apis/Scores/getGpaList.ts b/apps/university-web/src/apis/Scores/getGpaList.ts new file mode 100644 index 00000000..00437ae8 --- /dev/null +++ b/apps/university-web/src/apis/Scores/getGpaList.ts @@ -0,0 +1,17 @@ +import { useQuery } from "@tanstack/react-query"; + +import { ScoresQueryKeys, scoresApi } from "./api"; + +/** + * @description 내 학점 점수 조회 훅 + */ +const useGetMyGpaScore = () => { + return useQuery({ + queryKey: [ScoresQueryKeys.myGpaScore], + queryFn: scoresApi.getMyGpaScore, + staleTime: Infinity, + select: (data) => data.data.gpaScoreStatusResponseList, + }); +}; + +export default useGetMyGpaScore; diff --git a/apps/university-web/src/apis/Scores/getLanguageTestList.ts b/apps/university-web/src/apis/Scores/getLanguageTestList.ts new file mode 100644 index 00000000..22a42549 --- /dev/null +++ b/apps/university-web/src/apis/Scores/getLanguageTestList.ts @@ -0,0 +1,17 @@ +import { useQuery } from "@tanstack/react-query"; + +import { ScoresQueryKeys, scoresApi } from "./api"; + +/** + * @description 내 어학 점수 조회 훅 + */ +const useGetMyLanguageTestScore = () => { + return useQuery({ + queryKey: [ScoresQueryKeys.myLanguageTestScore], + queryFn: scoresApi.getMyLanguageTestScore, + staleTime: Infinity, + select: (data) => data.data.languageTestScoreStatusResponseList, + }); +}; + +export default useGetMyLanguageTestScore; diff --git a/apps/university-web/src/apis/Scores/index.ts b/apps/university-web/src/apis/Scores/index.ts new file mode 100644 index 00000000..529612eb --- /dev/null +++ b/apps/university-web/src/apis/Scores/index.ts @@ -0,0 +1,12 @@ +export type { + UseGetMyLanguageTestScoreResponse, + UseMyGpaScoreResponse, + UsePostGpaScoreRequest, + UsePostLanguageTestScoreRequest, +} from "./api"; +export { ScoresQueryKeys, scoresApi } from "./api"; + +export { default as useGetMyGpaScore } from "./getGpaList"; +export { default as useGetMyLanguageTestScore } from "./getLanguageTestList"; +export { default as usePostGpaScore } from "./postCreateGpa"; +export { default as usePostLanguageTestScore } from "./postCreateLanguageTest"; diff --git a/apps/university-web/src/apis/Scores/postCreateGpa.ts b/apps/university-web/src/apis/Scores/postCreateGpa.ts new file mode 100644 index 00000000..8c33f3f7 --- /dev/null +++ b/apps/university-web/src/apis/Scores/postCreateGpa.ts @@ -0,0 +1,22 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; + +import { showIconToast } from "@/lib/toast/showIconToast"; +import { ScoresQueryKeys, scoresApi, type UsePostGpaScoreRequest } from "./api"; + +/** + * @description 학점 점수 제출 훅 + */ +export const usePostGpaScore = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (request: UsePostGpaScoreRequest) => scoresApi.postGpaScore(request), + + onSuccess: () => { + showIconToast("logo", "학점 정보가 성공적으로 제출되었습니다."); + queryClient.invalidateQueries({ queryKey: [ScoresQueryKeys.myGpaScore] }); + }, + }); +}; + +export default usePostGpaScore; diff --git a/apps/university-web/src/apis/Scores/postCreateLanguageTest.ts b/apps/university-web/src/apis/Scores/postCreateLanguageTest.ts new file mode 100644 index 00000000..93f415e2 --- /dev/null +++ b/apps/university-web/src/apis/Scores/postCreateLanguageTest.ts @@ -0,0 +1,22 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; + +import { showIconToast } from "@/lib/toast/showIconToast"; +import { ScoresQueryKeys, scoresApi, type UsePostLanguageTestScoreRequest } from "./api"; + +/** + * @description 어학 점수 제출 훅 + */ +export const usePostLanguageTestScore = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (request: UsePostLanguageTestScoreRequest) => scoresApi.postLanguageTestScore(request), + + onSuccess: () => { + showIconToast("logo", "어학 성적이 성공적으로 제출되었습니다."); + queryClient.invalidateQueries({ queryKey: [ScoresQueryKeys.myLanguageTestScore] }); + }, + }); +}; + +export default usePostLanguageTestScore; diff --git a/apps/university-web/src/apis/applications/api.ts b/apps/university-web/src/apis/applications/api.ts new file mode 100644 index 00000000..6e233dd5 --- /dev/null +++ b/apps/university-web/src/apis/applications/api.ts @@ -0,0 +1,58 @@ +import type { AxiosResponse } from "axios"; +import type { ApplicationListResponse } from "@/types/application"; +import { axiosInstance } from "@/utils/axiosInstance"; + +// ====== Query Keys ====== +export const ApplicationsQueryKeys = { + competitorsApplicationList: "competitorsApplicationList", +} as const; + +// ====== Types ====== +export interface UseSubmitApplicationResponse { + isSuccess: boolean; +} + +export interface UseSubmitApplicationRequest { + gpaScoreId: number; + languageTestScoreId: number; + universityChoiceRequest: { + firstChoiceUniversityId: number | null; + secondChoiceUniversityId: number | null; + thirdChoiceUniversityId: number | null; + }; +} + +export interface CompetitorsResponse { + competitors: Array<{ + id: number; + name: string; + score: number; + }>; +} + +// ====== API Functions ====== +export const applicationsApi = { + /** + * 지원 목록 조회 + */ + getApplicationsList: async (): Promise> => { + return axiosInstance.get("/applications"); + }, + + /** + * 지원 제출 + */ + postSubmitApplication: async ( + request: UseSubmitApplicationRequest, + ): Promise> => { + return axiosInstance.post("/applications", request); + }, + + /** + * 경쟁자 목록 조회 + */ + getCompetitors: async (config?: { params?: Record }): Promise => { + const res = await axiosInstance.get("/applications/competitors", config); + return res.data; + }, +}; diff --git a/apps/university-web/src/apis/applications/getApplicants.ts b/apps/university-web/src/apis/applications/getApplicants.ts new file mode 100644 index 00000000..f7da9024 --- /dev/null +++ b/apps/university-web/src/apis/applications/getApplicants.ts @@ -0,0 +1,27 @@ +import { type UseQueryOptions, type UseQueryResult, useQuery } from "@tanstack/react-query"; +import type { AxiosError, AxiosResponse } from "axios"; + +import type { ApplicationListResponse } from "@/types/application"; +import { ApplicationsQueryKeys, applicationsApi } from "./api"; + +type UseGetApplicationsListOptions = Omit< + UseQueryOptions, AxiosError<{ message: string }>, ApplicationListResponse>, + "queryKey" | "queryFn" +>; + +/** + * @description 지원 목록 조회 훅 + */ +const useGetApplicationsList = ( + props?: UseGetApplicationsListOptions, +): UseQueryResult> => { + return useQuery({ + queryKey: [ApplicationsQueryKeys.competitorsApplicationList], + queryFn: applicationsApi.getApplicationsList, + staleTime: 1000 * 60 * 5, // 5분간 캐시 + select: (response) => response.data, + ...props, + }); +}; + +export default useGetApplicationsList; diff --git a/apps/university-web/src/apis/applications/getCompetitors.ts b/apps/university-web/src/apis/applications/getCompetitors.ts new file mode 100644 index 00000000..6b3bda67 --- /dev/null +++ b/apps/university-web/src/apis/applications/getCompetitors.ts @@ -0,0 +1,13 @@ +import { useQuery } from "@tanstack/react-query"; +import type { AxiosError } from "axios"; +import { QueryKeys } from "../queryKeys"; +import { applicationsApi, type CompetitorsResponse } from "./api"; + +const useGetCompetitors = (params?: Record) => { + return useQuery({ + queryKey: [QueryKeys.applications.competitors, params], + queryFn: () => applicationsApi.getCompetitors(params ? { params } : {}), + }); +}; + +export default useGetCompetitors; diff --git a/apps/university-web/src/apis/applications/index.ts b/apps/university-web/src/apis/applications/index.ts new file mode 100644 index 00000000..f0b0f16b --- /dev/null +++ b/apps/university-web/src/apis/applications/index.ts @@ -0,0 +1,5 @@ +export type { UseSubmitApplicationRequest, UseSubmitApplicationResponse } from "./api"; +export { ApplicationsQueryKeys, applicationsApi } from "./api"; +export { default as useGetApplicationsList } from "./getApplicants"; +export { default as useGetCompetitors } from "./getCompetitors"; +export { default as usePostSubmitApplication } from "./postSubmitApplication"; diff --git a/apps/university-web/src/apis/applications/postSubmitApplication.ts b/apps/university-web/src/apis/applications/postSubmitApplication.ts new file mode 100644 index 00000000..8a3ab1ef --- /dev/null +++ b/apps/university-web/src/apis/applications/postSubmitApplication.ts @@ -0,0 +1,32 @@ +import { type UseMutationOptions, type UseMutationResult, useMutation } from "@tanstack/react-query"; +import type { AxiosError, AxiosResponse } from "axios"; + +import { applicationsApi, type UseSubmitApplicationRequest, type UseSubmitApplicationResponse } from "./api"; + +/** + * @description 지원 제출 훅 + */ +const usePostSubmitApplication = ( + props?: UseMutationOptions< + AxiosResponse, + AxiosError<{ message: string }>, + UseSubmitApplicationRequest, + unknown + >, +): UseMutationResult< + AxiosResponse, + AxiosError<{ message: string }>, + UseSubmitApplicationRequest, + unknown +> => { + return useMutation< + AxiosResponse, + AxiosError<{ message: string }>, + UseSubmitApplicationRequest + >({ + ...props, + mutationFn: applicationsApi.postSubmitApplication, + }); +}; + +export default usePostSubmitApplication; diff --git a/apps/university-web/src/apis/chat/api.ts b/apps/university-web/src/apis/chat/api.ts new file mode 100644 index 00000000..f9ddd7d0 --- /dev/null +++ b/apps/university-web/src/apis/chat/api.ts @@ -0,0 +1,77 @@ +import type { AxiosResponse } from "axios"; +import type { ChatMessage, ChatPartner, ChatRoom } from "@/types/chat"; +import { axiosInstance } from "@/utils/axiosInstance"; +import { + normalizeChatMessage, + normalizeChatPartner, + normalizeChatRoom, + type RawChatMessage, + type RawChatPartner, + type RawChatRoom, +} from "./normalize"; + +// QueryKeys for chat domain +export const ChatQueryKeys = { + chatRooms: "chatRooms", + chatHistories: "chatHistories", + partnerInfo: "partnerInfo", +} as const; + +// Re-export types from @/types/chat +export type { ChatMessage, ChatRoom, ChatPartner }; + +export interface ChatHistoriesResponse { + nextPageNumber: number; // 다음 페이지가 없다면 -1 + content: ChatMessage[]; +} + +export interface ChatRoomListResponse { + chatRooms: ChatRoom[]; +} + +interface GetChatHistoriesParams { + roomId: number; + size?: number; + page?: number; +} + +interface RawChatHistoriesResponse { + nextPageNumber: number; + content: RawChatMessage[]; +} + +interface RawChatRoomListResponse { + chatRooms: RawChatRoom[]; +} + +export const chatApi = { + getChatHistories: async ({ roomId, size = 20, page = 0 }: GetChatHistoriesParams): Promise => { + const res = await axiosInstance.get(`/chats/rooms/${roomId}`, { + params: { + size, + page, + }, + }); + return { + nextPageNumber: res.data.nextPageNumber, + content: (res.data.content ?? []).map(normalizeChatMessage), + }; + }, + + getChatRooms: async (): Promise => { + const res = await axiosInstance.get("/chats/rooms"); + return { + chatRooms: (res.data.chatRooms ?? []).map(normalizeChatRoom), + }; + }, + + putReadChatRoom: async (roomId: number): Promise => { + const response: AxiosResponse = await axiosInstance.put(`/chats/rooms/${roomId}/read`); + return response.data; + }, + + getChatPartner: async (roomId: number): Promise => { + const res = await axiosInstance.get(`/chats/rooms/${roomId}/partner`); + return normalizeChatPartner(res.data); + }, +}; diff --git a/apps/university-web/src/apis/chat/getChatMessages.ts b/apps/university-web/src/apis/chat/getChatMessages.ts new file mode 100644 index 00000000..192d49c1 --- /dev/null +++ b/apps/university-web/src/apis/chat/getChatMessages.ts @@ -0,0 +1,38 @@ +import { useInfiniteQuery } from "@tanstack/react-query"; +import type { AxiosError } from "axios"; +import { type ChatHistoriesResponse, type ChatMessage, ChatQueryKeys, chatApi } from "./api"; + +/** + * @description 채팅 히스토리를 무한 스크롤로 가져오는 훅 + */ +const useGetChatHistories = (roomId: number, size: number = 20) => { + return useInfiniteQuery< + ChatHistoriesResponse, + AxiosError, + { + pages: ChatHistoriesResponse[]; + pageParams: number[]; + messages: ChatMessage[]; + }, + [string, number], + number + >({ + queryKey: [ChatQueryKeys.chatHistories, roomId], + queryFn: ({ pageParam = 0 }: { pageParam?: number }) => chatApi.getChatHistories({ roomId, size, page: pageParam }), + initialPageParam: 0, + getNextPageParam: (lastPage: ChatHistoriesResponse) => { + // nextPageNumber가 -1이면 더 이상 페이지가 없음 + return lastPage.nextPageNumber === -1 ? undefined : lastPage.nextPageNumber; + }, + staleTime: 1000 * 60 * 5, // 5분간 캐시 + refetchOnMount: "always", + enabled: !!roomId, // roomId가 있을 때만 쿼리 실행 + select: (data) => ({ + pages: data.pages, + pageParams: data.pageParams, + messages: data.pages.flatMap((page) => page.content), + }), + }); +}; + +export default useGetChatHistories; diff --git a/apps/university-web/src/apis/chat/getChatPartner.ts b/apps/university-web/src/apis/chat/getChatPartner.ts new file mode 100644 index 00000000..56be7d48 --- /dev/null +++ b/apps/university-web/src/apis/chat/getChatPartner.ts @@ -0,0 +1,17 @@ +import { useQuery } from "@tanstack/react-query"; +import type { AxiosError } from "axios"; +import { type ChatPartner, ChatQueryKeys, chatApi } from "./api"; + +/** + * @description 채팅 상대방 정보를 가져오는 훅 + */ +const useGetPartnerInfo = (roomId: number) => { + return useQuery({ + queryKey: [ChatQueryKeys.partnerInfo, roomId], + queryFn: () => chatApi.getChatPartner(roomId), + staleTime: 1000 * 60 * 5, + enabled: !!roomId, + }); +}; + +export default useGetPartnerInfo; diff --git a/apps/university-web/src/apis/chat/getChatRooms.ts b/apps/university-web/src/apis/chat/getChatRooms.ts new file mode 100644 index 00000000..b96a43d2 --- /dev/null +++ b/apps/university-web/src/apis/chat/getChatRooms.ts @@ -0,0 +1,17 @@ +import { useQuery } from "@tanstack/react-query"; +import type { AxiosError } from "axios"; +import { ChatQueryKeys, type ChatRoom, type ChatRoomListResponse, chatApi } from "./api"; + +/** + * @description 채팅방 목록을 가져오는 훅 + */ +const useGetChatRooms = () => { + return useQuery({ + queryKey: [ChatQueryKeys.chatRooms], + queryFn: chatApi.getChatRooms, + staleTime: 1000 * 60 * 5, // 5분간 캐시 + select: (data) => data.chatRooms, + }); +}; + +export default useGetChatRooms; diff --git a/apps/university-web/src/apis/chat/index.ts b/apps/university-web/src/apis/chat/index.ts new file mode 100644 index 00000000..e1a190a4 --- /dev/null +++ b/apps/university-web/src/apis/chat/index.ts @@ -0,0 +1,6 @@ +export type { ChatHistoriesResponse, ChatMessage, ChatPartner, ChatRoom, ChatRoomListResponse } from "./api"; +export { ChatQueryKeys, chatApi } from "./api"; +export { default as useGetChatHistories } from "./getChatMessages"; +export { default as useGetPartnerInfo } from "./getChatPartner"; +export { default as useGetChatRooms } from "./getChatRooms"; +export { default as usePutChatRead } from "./putReadChatRoom"; diff --git a/apps/university-web/src/apis/chat/normalize.ts b/apps/university-web/src/apis/chat/normalize.ts new file mode 100644 index 00000000..19534fa3 --- /dev/null +++ b/apps/university-web/src/apis/chat/normalize.ts @@ -0,0 +1,107 @@ +import type { ChatAttachment, ChatMessage, ChatPartner, ChatRoom } from "@/types/chat"; + +type NumericLike = number | string | null | undefined; + +interface RawChatAttachment { + id?: NumericLike; + isImage: boolean; + url: string; + thumbnailUrl?: string | null; + createdAt: string; +} + +export interface RawChatMessage { + id?: NumericLike; + content: string; + senderId?: NumericLike; + siteUserId?: NumericLike; + createdAt: string; + attachments?: RawChatAttachment[]; +} + +export interface RawChatPartner { + partnerId?: NumericLike; + siteUserId?: NumericLike; + nickname: string; + profileUrl?: string | null; + university?: string | null; +} + +export interface RawChatRoom { + id: number; + lastChatMessage: string; + lastReceivedTime: string; + partner: RawChatPartner; + unReadCount: number; +} + +const toNumber = (value: NumericLike): number => { + if (typeof value === "number" && Number.isFinite(value)) { + return value; + } + + if (typeof value === "string" && value.trim() !== "") { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : 0; + } + + return 0; +}; + +const createStableHash = (value: string): number => { + let hash = 0; + + for (let i = 0; i < value.length; i++) { + hash = (hash * 31 + value.charCodeAt(i)) | 0; + } + + const normalized = Math.abs(hash); + return normalized === 0 ? 1 : normalized; +}; + +const getFallbackMessageId = (message: RawChatMessage): number => { + const senderId = toNumber(message.senderId ?? message.siteUserId); + const attachmentSignature = (message.attachments ?? []) + .map((attachment) => `${attachment.isImage ? "image" : "file"}:${attachment.url}:${attachment.createdAt}`) + .join(","); + const seed = `${senderId}|${message.createdAt}|${message.content}|${attachmentSignature}`; + + // 서버에서 id가 누락되는 경우를 대비해 항상 동일한 임시 음수 id를 생성합니다. + return -createStableHash(seed); +}; + +const normalizeAttachment = (attachment: RawChatAttachment): ChatAttachment => ({ + id: toNumber(attachment.id), + isImage: attachment.isImage, + url: attachment.url, + thumbnailUrl: attachment.thumbnailUrl ?? null, + createdAt: attachment.createdAt, +}); + +export const normalizeChatMessage = (message: RawChatMessage): ChatMessage => { + const parsedId = toNumber(message.id); + const normalizedId = parsedId > 0 ? parsedId : getFallbackMessageId(message); + + return { + id: normalizedId, + content: message.content, + senderId: toNumber(message.senderId ?? message.siteUserId), + createdAt: message.createdAt, + attachments: (message.attachments ?? []).map(normalizeAttachment), + }; +}; + +export const normalizeChatPartner = (partner: RawChatPartner): ChatPartner => ({ + partnerId: toNumber(partner.partnerId ?? partner.siteUserId), + nickname: partner.nickname, + profileUrl: partner.profileUrl ?? null, + university: partner.university ?? null, +}); + +export const normalizeChatRoom = (room: RawChatRoom): ChatRoom => ({ + id: room.id, + lastChatMessage: room.lastChatMessage, + lastReceivedTime: room.lastReceivedTime, + partner: normalizeChatPartner(room.partner), + unReadCount: room.unReadCount, +}); diff --git a/apps/university-web/src/apis/chat/putReadChatRoom.ts b/apps/university-web/src/apis/chat/putReadChatRoom.ts new file mode 100644 index 00000000..0602a25c --- /dev/null +++ b/apps/university-web/src/apis/chat/putReadChatRoom.ts @@ -0,0 +1,21 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import type { AxiosError } from "axios"; +import { ChatQueryKeys, chatApi } from "./api"; + +/** + * @description 채팅방 읽음 처리 훅 + */ +const usePutChatRead = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: chatApi.putReadChatRoom, + onSuccess: () => { + // 채팅방 목록 쿼리를 무효화하여 새로 고침 + queryClient.invalidateQueries({ queryKey: [ChatQueryKeys.chatRooms] }); + }, + onError: (error) => {}, + }); +}; + +export default usePutChatRead; diff --git a/apps/university-web/src/apis/community/api.ts b/apps/university-web/src/apis/community/api.ts new file mode 100644 index 00000000..52e7ba6d --- /dev/null +++ b/apps/university-web/src/apis/community/api.ts @@ -0,0 +1,153 @@ +import type { AxiosResponse } from "axios"; +import { COMMUNITY_MAX_UPLOAD_IMAGES } from "@/constants/community"; +import type { + CommentCreateRequest, + CommentIdResponse, + ListPost, + Post, + PostCreateRequest, + PostIdResponse, + PostLikeResponse, + PostUpdateRequest, +} from "@/types/community"; +import { axiosInstance, publicAxiosInstance } from "@/utils/axiosInstance"; + +// QueryKeys for community domain +export const CommunityQueryKeys = { + posts: "posts", + postList: "postList1", // 기존 api/boards와 동일한 키 유지 +} as const; + +export interface BoardListResponse { + 0: string; + 1: string; + 2: string; + 3: string; +} + +export interface BoardResponseItem { + id: number; + title: string; + content: string; + likeCount: number; + commentCount: number; + createdAt: string; + updatedAt: string; + postCategory: string; + postThumbnailUrl: null | string; +} + +export interface BoardResponse { + 0: BoardResponseItem[]; + 1: BoardResponseItem[]; + 2: BoardResponseItem[]; + 3: BoardResponseItem[]; +} + +// Delete response types +export interface DeletePostResponse { + message: string; + postId: number; +} + +// Re-export types from @/types/community for convenience +export type { + Post, + PostCreateRequest, + PostIdResponse, + PostUpdateRequest, + PostLikeResponse, + CommentCreateRequest, + CommentIdResponse, + ListPost, +}; + +export const communityApi = { + /** + * 게시글 목록 조회 (클라이언트) + */ + getPostList: (boardCode: string, category: string | null = null): Promise> => { + const params = category && category !== "전체" ? { category } : {}; + return publicAxiosInstance.get(`/boards/${boardCode}`, { params }); + }, + + getBoardList: async (params?: Record): Promise => { + const res = await axiosInstance.get(`/boards`, { params }); + return res.data; + }, + + getBoard: async (boardCode: string, params?: Record): Promise => { + const res = await axiosInstance.get(`/boards/${boardCode}`, { params }); + return res.data; + }, + + getPostDetail: async (postId: number): Promise => { + const response: AxiosResponse = await axiosInstance.get(`/posts/${postId}`); + return response.data; + }, + + createPost: async (request: PostCreateRequest): Promise => { + const convertedRequest: FormData = new FormData(); + convertedRequest.append( + "postCreateRequest", + new Blob([JSON.stringify(request.postCreateRequest)], { type: "application/json" }), + ); + request.file.slice(0, COMMUNITY_MAX_UPLOAD_IMAGES).forEach((file) => { + convertedRequest.append("file", file); + }); + + const response: AxiosResponse = await axiosInstance.post(`/posts`, convertedRequest, { + headers: { "Content-Type": "multipart/form-data" }, + }); + + return { + ...response.data, + boardCode: request.postCreateRequest.boardCode, + }; + }, + + updatePost: async (postId: number, request: PostUpdateRequest): Promise => { + const convertedRequest: FormData = new FormData(); + convertedRequest.append( + "postUpdateRequest", + new Blob([JSON.stringify(request.postUpdateRequest)], { type: "application/json" }), + ); + request.file.slice(0, COMMUNITY_MAX_UPLOAD_IMAGES).forEach((file) => { + convertedRequest.append("file", file); + }); + + const response: AxiosResponse = await axiosInstance.patch(`/posts/${postId}`, convertedRequest, { + headers: { "Content-Type": "multipart/form-data" }, + }); + return response.data; + }, + + deletePost: async (postId: number): Promise> => { + return axiosInstance.delete(`/posts/${postId}`); + }, + + likePost: async (postId: number): Promise => { + const response: AxiosResponse = await axiosInstance.post(`/posts/${postId}/like`); + return response.data; + }, + + unlikePost: async (postId: number): Promise => { + const response: AxiosResponse = await axiosInstance.delete(`/posts/${postId}/like`); + return response.data; + }, + + createComment: async (request: CommentCreateRequest): Promise => { + const response: AxiosResponse = await axiosInstance.post(`/comments`, request); + return response.data; + }, + + deleteComment: async (commentId: number): Promise => { + const response: AxiosResponse = await axiosInstance.delete(`/comments/${commentId}`); + return response.data; + }, + + updateComment: async (commentId: number, data: { content: string }): Promise => { + const res = await axiosInstance.patch(`/comments/${commentId}`, data); + return res.data; + }, +}; diff --git a/apps/university-web/src/apis/community/deleteComment.ts b/apps/university-web/src/apis/community/deleteComment.ts new file mode 100644 index 00000000..5f22aee0 --- /dev/null +++ b/apps/university-web/src/apis/community/deleteComment.ts @@ -0,0 +1,28 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import type { AxiosError } from "axios"; + +import { showIconToast } from "@/lib/toast/showIconToast"; +import { type CommentIdResponse, CommunityQueryKeys, communityApi } from "./api"; + +interface DeleteCommentRequest { + commentId: number; + postId: number; +} + +/** + * @description 댓글 삭제를 위한 useMutation 커스텀 훅 + */ +const useDeleteComment = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ commentId }) => communityApi.deleteComment(commentId), + onSuccess: (_data, variables) => { + // 해당 게시글 상세 쿼리를 무효화하여 댓글 목록 갱신 + queryClient.invalidateQueries({ queryKey: [CommunityQueryKeys.posts, variables.postId] }); + showIconToast("logo", "댓글이 삭제되었습니다."); + }, + }); +}; + +export default useDeleteComment; diff --git a/apps/university-web/src/apis/community/deleteLikePost.ts b/apps/university-web/src/apis/community/deleteLikePost.ts new file mode 100644 index 00000000..82c4027d --- /dev/null +++ b/apps/university-web/src/apis/community/deleteLikePost.ts @@ -0,0 +1,21 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import type { AxiosError } from "axios"; + +import { CommunityQueryKeys, communityApi, type PostLikeResponse } from "./api"; + +/** + * @description 게시글 좋아요 취소를 위한 useMutation 커스텀 훅 + */ +const useDeleteLike = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: communityApi.unlikePost, + onSuccess: (_data, postId) => { + // 해당 게시글 상세 쿼리를 무효화하여 최신 데이터 반영 + queryClient.invalidateQueries({ queryKey: [CommunityQueryKeys.posts, postId] }); + }, + }); +}; + +export default useDeleteLike; diff --git a/apps/university-web/src/apis/community/deletePost.ts b/apps/university-web/src/apis/community/deletePost.ts new file mode 100644 index 00000000..e7f42384 --- /dev/null +++ b/apps/university-web/src/apis/community/deletePost.ts @@ -0,0 +1,64 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; + +import type { AxiosError, AxiosResponse } from "axios"; +import { useRouter } from "next/navigation"; +import { showIconToast } from "@/lib/toast/showIconToast"; +import useAuthStore from "@/lib/zustand/useAuthStore"; +import { CommunityQueryKeys, communityApi, type DeletePostResponse } from "./api"; + +interface DeletePostVariables { + postId: number; + boardCode?: string; +} + +/** + * @description ISR 페이지를 revalidate하는 함수 + * @param boardCode - 게시판 코드 + * @param accessToken - 사용자 인증 토큰 + */ +const revalidateCommunityPage = async (boardCode: string, accessToken: string) => { + try { + if (!accessToken) { + return; + } + + await fetch("/api/revalidate", { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${accessToken}`, + }, + body: JSON.stringify({ boardCode }), + }); + } catch (error) {} +}; + +/** + * @description 게시글 삭제를 위한 useMutation 커스텀 훅 + */ +const useDeletePost = () => { + const router = useRouter(); + const queryClient = useQueryClient(); + const { accessToken } = useAuthStore(); + + return useMutation, AxiosError, DeletePostVariables>({ + mutationFn: ({ postId }) => communityApi.deletePost(postId), + onSuccess: async (_result, variables) => { + // 'posts' 쿼리 키를 가진 모든 쿼리를 무효화하여 + // 게시글 목록을 다시 불러오도록 합니다. + queryClient.invalidateQueries({ queryKey: [CommunityQueryKeys.posts] }); + + // ISR 페이지 revalidate + if (variables.boardCode && accessToken) { + await revalidateCommunityPage(variables.boardCode, accessToken); + } + + showIconToast("logo", "게시글이 성공적으로 삭제되었습니다."); + + // 게시글 목록 페이지 이동 + router.replace(`/community/${variables.boardCode || "FREE"}`); + }, + }); +}; + +export default useDeletePost; diff --git a/apps/university-web/src/apis/community/getBoard.ts b/apps/university-web/src/apis/community/getBoard.ts new file mode 100644 index 00000000..f4614784 --- /dev/null +++ b/apps/university-web/src/apis/community/getBoard.ts @@ -0,0 +1,14 @@ +import { useQuery } from "@tanstack/react-query"; +import type { AxiosError } from "axios"; +import { QueryKeys } from "../queryKeys"; +import { type BoardResponse, communityApi } from "./api"; + +const useGetBoard = (boardCode: string | number, params?: Record) => { + return useQuery({ + queryKey: [QueryKeys.community.board, boardCode, params], + queryFn: () => communityApi.getBoard(boardCode as string, params), + enabled: !!boardCode, + }); +}; + +export default useGetBoard; diff --git a/apps/university-web/src/apis/community/getBoardList.ts b/apps/university-web/src/apis/community/getBoardList.ts new file mode 100644 index 00000000..b431eb01 --- /dev/null +++ b/apps/university-web/src/apis/community/getBoardList.ts @@ -0,0 +1,13 @@ +import { useQuery } from "@tanstack/react-query"; +import type { AxiosError } from "axios"; +import { QueryKeys } from "../queryKeys"; +import { type BoardListResponse, communityApi } from "./api"; + +const useGetBoardList = (params?: Record) => { + return useQuery({ + queryKey: [QueryKeys.community.boardList, params], + queryFn: () => communityApi.getBoardList(params ? { params } : {}), + }); +}; + +export default useGetBoardList; diff --git a/apps/university-web/src/apis/community/getPostDetail.ts b/apps/university-web/src/apis/community/getPostDetail.ts new file mode 100644 index 00000000..8323dad7 --- /dev/null +++ b/apps/university-web/src/apis/community/getPostDetail.ts @@ -0,0 +1,16 @@ +import { useQuery } from "@tanstack/react-query"; +import type { AxiosError } from "axios"; +import { CommunityQueryKeys, communityApi, type Post } from "./api"; + +/** + * @description 게시글 상세 조회를 위한 useQuery 커스텀 훅 + */ +const useGetPostDetail = (postId: number) => { + return useQuery({ + queryKey: [CommunityQueryKeys.posts, postId], + queryFn: () => communityApi.getPostDetail(postId), + enabled: !!postId, + }); +}; + +export default useGetPostDetail; diff --git a/apps/university-web/src/apis/community/getPostList.ts b/apps/university-web/src/apis/community/getPostList.ts new file mode 100644 index 00000000..28885d0a --- /dev/null +++ b/apps/university-web/src/apis/community/getPostList.ts @@ -0,0 +1,27 @@ +import { useQuery } from "@tanstack/react-query"; + +import { CommunityQueryKeys, communityApi } from "./api"; + +interface UseGetPostListProps { + boardCode: string; + category?: string | null; +} + +/** + * @description 게시글 목록 조회 훅 + */ +const useGetPostList = ({ boardCode, category = null }: UseGetPostListProps) => { + return useQuery({ + queryKey: [CommunityQueryKeys.postList, boardCode, category], + queryFn: () => communityApi.getPostList(boardCode, category), + staleTime: Infinity, + gcTime: 1000 * 60 * 30, // 30분 + select: (response) => { + return [...response.data].sort((a, b) => { + return new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime(); + }); + }, + }); +}; + +export default useGetPostList; diff --git a/apps/university-web/src/apis/community/index.ts b/apps/university-web/src/apis/community/index.ts new file mode 100644 index 00000000..d396a31d --- /dev/null +++ b/apps/university-web/src/apis/community/index.ts @@ -0,0 +1,26 @@ +export type { + CommentCreateRequest, + CommentIdResponse, + ListPost, + Post, + PostCreateRequest, + PostIdResponse, + PostLikeResponse, + PostUpdateRequest, +} from "./api"; +export { CommunityQueryKeys, communityApi } from "./api"; +export { default as useDeleteComment } from "./deleteComment"; +export { default as useDeleteLike } from "./deleteLikePost"; +export { default as useDeletePost } from "./deletePost"; +export { default as useGetBoard } from "./getBoard"; +export { default as useGetBoardList } from "./getBoardList"; +export { default as useGetPostDetail } from "./getPostDetail"; +export { default as useGetPostList } from "./getPostList"; +export { default as usePatchUpdateComment } from "./patchUpdateComment"; +export { default as useUpdatePost } from "./patchUpdatePost"; +export { default as useCreateComment } from "./postCreateComment"; +export { default as useCreatePost } from "./postCreatePost"; +export { default as usePostLike } from "./postLikePost"; + +// Server-side functions +export { getPostListServer } from "./server"; diff --git a/apps/university-web/src/apis/community/patchUpdateComment.ts b/apps/university-web/src/apis/community/patchUpdateComment.ts new file mode 100644 index 00000000..8b235b55 --- /dev/null +++ b/apps/university-web/src/apis/community/patchUpdateComment.ts @@ -0,0 +1,11 @@ +import { useMutation } from "@tanstack/react-query"; +import type { AxiosError } from "axios"; +import { type CommentIdResponse, communityApi } from "./api"; + +const usePatchUpdateComment = () => { + return useMutation({ + mutationFn: ({ commentId, content }) => communityApi.updateComment(commentId, { content }), + }); +}; + +export default usePatchUpdateComment; diff --git a/apps/university-web/src/apis/community/patchUpdatePost.ts b/apps/university-web/src/apis/community/patchUpdatePost.ts new file mode 100644 index 00000000..a45e948d --- /dev/null +++ b/apps/university-web/src/apis/community/patchUpdatePost.ts @@ -0,0 +1,59 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import type { AxiosError } from "axios"; +import { showIconToast } from "@/lib/toast/showIconToast"; +import useAuthStore from "@/lib/zustand/useAuthStore"; +import { CommunityQueryKeys, communityApi, type PostIdResponse, type PostUpdateRequest } from "./api"; + +interface UpdatePostVariables { + postId: number; + data: PostUpdateRequest; + boardCode?: string; +} + +/** + * @description ISR 페이지를 revalidate하는 함수 + * @param boardCode - 게시판 코드 + * @param accessToken - 사용자 인증 토큰 + */ +const revalidateCommunityPage = async (boardCode: string, accessToken: string) => { + try { + if (!accessToken) { + return; + } + + await fetch("/api/revalidate", { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${accessToken}`, + }, + body: JSON.stringify({ boardCode }), + }); + } catch (error) {} +}; + +/** + * @description 게시글 수정을 위한 useMutation 커스텀 훅 + */ +const useUpdatePost = () => { + const queryClient = useQueryClient(); + const { accessToken } = useAuthStore(); + + return useMutation({ + mutationFn: ({ postId, data }) => communityApi.updatePost(postId, data), + onSuccess: async (_result, variables) => { + // 해당 게시글 상세 쿼리와 목록 쿼리를 무효화 + queryClient.invalidateQueries({ queryKey: [CommunityQueryKeys.posts, variables.postId] }); + queryClient.invalidateQueries({ queryKey: [CommunityQueryKeys.posts] }); + + // ISR 페이지 revalidate + if (variables.boardCode && accessToken) { + await revalidateCommunityPage(variables.boardCode, accessToken); + } + + showIconToast("logo", "게시글이 수정되었습니다."); + }, + }); +}; + +export default useUpdatePost; diff --git a/apps/university-web/src/apis/community/postCreateComment.ts b/apps/university-web/src/apis/community/postCreateComment.ts new file mode 100644 index 00000000..5fea1a65 --- /dev/null +++ b/apps/university-web/src/apis/community/postCreateComment.ts @@ -0,0 +1,23 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import type { AxiosError } from "axios"; + +import { showIconToast } from "@/lib/toast/showIconToast"; +import { type CommentCreateRequest, type CommentIdResponse, CommunityQueryKeys, communityApi } from "./api"; + +/** + * @description 댓글 생성을 위한 useMutation 커스텀 훅 + */ +const useCreateComment = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: communityApi.createComment, + onSuccess: (_data, variables) => { + // 해당 게시글 상세 쿼리를 무효화하여 댓글 목록 갱신 + queryClient.invalidateQueries({ queryKey: [CommunityQueryKeys.posts, variables.postId] }); + showIconToast("logo", "댓글이 등록되었습니다."); + }, + }); +}; + +export default useCreateComment; diff --git a/apps/university-web/src/apis/community/postCreatePost.ts b/apps/university-web/src/apis/community/postCreatePost.ts new file mode 100644 index 00000000..aec6291c --- /dev/null +++ b/apps/university-web/src/apis/community/postCreatePost.ts @@ -0,0 +1,52 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import type { AxiosError } from "axios"; +import { showIconToast } from "@/lib/toast/showIconToast"; +import useAuthStore from "@/lib/zustand/useAuthStore"; +import { CommunityQueryKeys, communityApi, type PostCreateRequest, type PostIdResponse } from "./api"; + +/** + * @description ISR 페이지를 revalidate하는 함수 + * @param boardCode - 게시판 코드 + * @param accessToken - 사용자 인증 토큰 + */ +const revalidateCommunityPage = async (boardCode: string, accessToken: string) => { + try { + if (!accessToken) { + return; + } + + await fetch("/api/revalidate", { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${accessToken}`, + }, + body: JSON.stringify({ boardCode }), + }); + } catch (error) {} +}; + +/** + * @description 게시글 생성을 위한 useMutation 커스텀 훅 + */ +const useCreatePost = () => { + const queryClient = useQueryClient(); + const { accessToken } = useAuthStore(); + + return useMutation({ + mutationFn: communityApi.createPost, + onSuccess: async (data) => { + // 게시글 목록 쿼리를 무효화하여 최신 목록 반영 + queryClient.invalidateQueries({ queryKey: [CommunityQueryKeys.posts] }); + + // ISR 페이지 revalidate (사용자 인증 토큰 사용) + if (accessToken) { + await revalidateCommunityPage(data.boardCode, accessToken); + } + + showIconToast("logo", "게시글이 등록되었습니다."); + }, + }); +}; + +export default useCreatePost; diff --git a/apps/university-web/src/apis/community/postLikePost.ts b/apps/university-web/src/apis/community/postLikePost.ts new file mode 100644 index 00000000..6ddfb937 --- /dev/null +++ b/apps/university-web/src/apis/community/postLikePost.ts @@ -0,0 +1,21 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import type { AxiosError } from "axios"; + +import { CommunityQueryKeys, communityApi, type PostLikeResponse } from "./api"; + +/** + * @description 게시글 좋아요를 위한 useMutation 커스텀 훅 + */ +const usePostLike = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: communityApi.likePost, + onSuccess: (_data, postId) => { + // 해당 게시글 상세 쿼리를 무효화하여 최신 데이터 반영 + queryClient.invalidateQueries({ queryKey: [CommunityQueryKeys.posts, postId] }); + }, + }); +}; + +export default usePostLike; diff --git a/apps/university-web/src/apis/community/server.ts b/apps/university-web/src/apis/community/server.ts new file mode 100644 index 00000000..9f394816 --- /dev/null +++ b/apps/university-web/src/apis/community/server.ts @@ -0,0 +1,37 @@ +import type { ListPost } from "@/types/community"; +import serverFetch, { type ServerFetchResult } from "@/utils/serverFetchUtil"; + +interface GetPostListParams { + boardCode: string; + category?: string | null; + revalidate?: number | false; +} + +/** + * @description 게시글 목록을 서버에서 가져오는 함수 (ISR 지원) + * @param boardCode - 게시판 코드 + * @param category - 카테고리 (선택) + * @param revalidate - ISR revalidate 시간(초) 또는 false (무한 캐시) + * @returns Promise> + */ +export const getPostListServer = async ({ + boardCode, + category = null, + revalidate = false, +}: GetPostListParams): Promise> => { + const params = new URLSearchParams(); + if (category && category !== "전체") { + params.append("category", category); + } + + const queryString = params.toString(); + const url = `/boards/${boardCode}${queryString ? `?${queryString}` : ""}`; + + return serverFetch(url, { + method: "GET", + next: { + ...(revalidate !== false && { revalidate }), + tags: [`posts-${boardCode}`], + }, + }); +}; diff --git a/apps/university-web/src/apis/image-upload/api.ts b/apps/university-web/src/apis/image-upload/api.ts new file mode 100644 index 00000000..fb95fe64 --- /dev/null +++ b/apps/university-web/src/apis/image-upload/api.ts @@ -0,0 +1,97 @@ +import type { AxiosResponse } from "axios"; +import type { FileResponse } from "@/types/file"; +import { axiosInstance, publicAxiosInstance } from "@/utils/axiosInstance"; + +// ====== Types ====== +export type SlackNotificationResponse = undefined; +export type SlackNotificationRequest = Record; + +export interface UploadLanguageTestReportResponse { + fileUrl: string; +} + +export interface UploadProfileImageResponse { + fileUrl: string; +} + +export interface UploadChatImageResponse { + fileUrl: string; +} + +export interface UploadGpaReportResponse { + fileUrl: string; +} + +// ====== API Functions ====== +export const imageUploadApi = { + /** + * 슬랙 알림 전송 + */ + postSlackNotification: async (params: { data?: SlackNotificationRequest }): Promise => { + void params; + throw new Error("Slack webhook notification must be proxied through a server-side endpoint."); + }, + + /** + * 어학 성적 증명서 업로드 + */ + postUploadLanguageTestReport: async (file: File): Promise => { + const formData = new FormData(); + formData.append("file", file); + const res = await axiosInstance.post(`/file/language-test`, formData, { + headers: { "Content-Type": "multipart/form-data" }, + }); + return res.data; + }, + + /** + * 프로필 이미지 업로드 (로그인 후) + */ + postUploadProfileImage: async (file: File): Promise => { + const formData = new FormData(); + formData.append("file", file); + const res = await axiosInstance.post(`/file/profile/post`, formData, { + headers: { "Content-Type": "multipart/form-data" }, + }); + return res.data; + }, + + /** + * 채팅 이미지 업로드 (로그인 후) + */ + postUploadChatImages: async (files: File[]): Promise => { + const formData = new FormData(); + files.forEach((file) => { + formData.append("files", file); + }); + + const res = await axiosInstance.post(`/file/chat`, formData, { + headers: { "Content-Type": "multipart/form-data" }, + }); + return res.data.map((image) => image.fileUrl); + }, + + /** + * 프로필 이미지 업로드 (회원가입 전, 공개 API) + */ + postUploadProfileImageBeforeSignup: async (file: File): Promise => { + const formData = new FormData(); + formData.append("file", file); + const response: AxiosResponse = await publicAxiosInstance.post("/file/profile/pre", formData, { + headers: { "Content-Type": "multipart/form-data" }, + }); + return response.data; + }, + + /** + * 학점 증명서 업로드 + */ + postUploadGpaReport: async (file: File): Promise => { + const formData = new FormData(); + formData.append("file", file); + const res = await axiosInstance.post(`/file/gpa`, formData, { + headers: { "Content-Type": "multipart/form-data" }, + }); + return res.data; + }, +}; diff --git a/apps/university-web/src/apis/image-upload/index.ts b/apps/university-web/src/apis/image-upload/index.ts new file mode 100644 index 00000000..601ad158 --- /dev/null +++ b/apps/university-web/src/apis/image-upload/index.ts @@ -0,0 +1,14 @@ +export type { + UploadChatImageResponse, + UploadGpaReportResponse, + UploadLanguageTestReportResponse, + UploadProfileImageResponse, +} from "./api"; +export { imageUploadApi } from "./api"; + +export { default as useSlackNotification } from "./postSlackNotification"; +export { default as useUploadChatImages } from "./postUploadChatImages"; +export { default as useUploadGpaReport } from "./postUploadGpaReport"; +export { default as useUploadLanguageTestReport } from "./postUploadLanguageTestReport"; +export { default as useUploadProfileImage } from "./postUploadProfileImage"; +export { default as useUploadProfileImagePublic } from "./postUploadProfileImageBeforeSignup"; diff --git a/apps/university-web/src/apis/image-upload/postSlackNotification.ts b/apps/university-web/src/apis/image-upload/postSlackNotification.ts new file mode 100644 index 00000000..f4b397a8 --- /dev/null +++ b/apps/university-web/src/apis/image-upload/postSlackNotification.ts @@ -0,0 +1,11 @@ +import { useMutation } from "@tanstack/react-query"; +import type { AxiosError } from "axios"; +import { imageUploadApi, type SlackNotificationRequest, type SlackNotificationResponse } from "./api"; + +const usePostSlackNotification = () => { + return useMutation({ + mutationFn: (data) => imageUploadApi.postSlackNotification({ data }), + }); +}; + +export default usePostSlackNotification; diff --git a/apps/university-web/src/apis/image-upload/postUploadChatImages.ts b/apps/university-web/src/apis/image-upload/postUploadChatImages.ts new file mode 100644 index 00000000..60325642 --- /dev/null +++ b/apps/university-web/src/apis/image-upload/postUploadChatImages.ts @@ -0,0 +1,13 @@ +import { useMutation } from "@tanstack/react-query"; +import type { AxiosError } from "axios"; +import { SKIP_GLOBAL_ERROR_TOAST_META } from "@/lib/react-query/errorToastMeta"; +import { imageUploadApi } from "./api"; + +const useUploadChatImages = () => { + return useMutation({ + mutationFn: (files) => imageUploadApi.postUploadChatImages(files), + meta: SKIP_GLOBAL_ERROR_TOAST_META, + }); +}; + +export default useUploadChatImages; diff --git a/apps/university-web/src/apis/image-upload/postUploadGpaReport.ts b/apps/university-web/src/apis/image-upload/postUploadGpaReport.ts new file mode 100644 index 00000000..f8ef7529 --- /dev/null +++ b/apps/university-web/src/apis/image-upload/postUploadGpaReport.ts @@ -0,0 +1,11 @@ +import { useMutation } from "@tanstack/react-query"; +import type { AxiosError } from "axios"; +import { imageUploadApi, type UploadGpaReportResponse } from "./api"; + +const usePostUploadGpaReport = () => { + return useMutation({ + mutationFn: (file) => imageUploadApi.postUploadGpaReport(file), + }); +}; + +export default usePostUploadGpaReport; diff --git a/apps/university-web/src/apis/image-upload/postUploadLanguageTestReport.ts b/apps/university-web/src/apis/image-upload/postUploadLanguageTestReport.ts new file mode 100644 index 00000000..6a939941 --- /dev/null +++ b/apps/university-web/src/apis/image-upload/postUploadLanguageTestReport.ts @@ -0,0 +1,11 @@ +import { useMutation } from "@tanstack/react-query"; +import type { AxiosError } from "axios"; +import { imageUploadApi, type UploadLanguageTestReportResponse } from "./api"; + +const usePostUploadLanguageTestReport = () => { + return useMutation({ + mutationFn: (file) => imageUploadApi.postUploadLanguageTestReport(file), + }); +}; + +export default usePostUploadLanguageTestReport; diff --git a/apps/university-web/src/apis/image-upload/postUploadProfileImage.ts b/apps/university-web/src/apis/image-upload/postUploadProfileImage.ts new file mode 100644 index 00000000..74d8fe5f --- /dev/null +++ b/apps/university-web/src/apis/image-upload/postUploadProfileImage.ts @@ -0,0 +1,13 @@ +import { useMutation } from "@tanstack/react-query"; +import type { AxiosError } from "axios"; +import { SKIP_GLOBAL_ERROR_TOAST_META } from "@/lib/react-query/errorToastMeta"; +import { imageUploadApi, type UploadProfileImageResponse } from "./api"; + +const usePostUploadProfileImage = () => { + return useMutation({ + mutationFn: (file) => imageUploadApi.postUploadProfileImage(file), + meta: SKIP_GLOBAL_ERROR_TOAST_META, + }); +}; + +export default usePostUploadProfileImage; diff --git a/apps/university-web/src/apis/image-upload/postUploadProfileImageBeforeSignup.ts b/apps/university-web/src/apis/image-upload/postUploadProfileImageBeforeSignup.ts new file mode 100644 index 00000000..67fa0122 --- /dev/null +++ b/apps/university-web/src/apis/image-upload/postUploadProfileImageBeforeSignup.ts @@ -0,0 +1,15 @@ +import { useMutation } from "@tanstack/react-query"; +import type { AxiosError } from "axios"; +import type { FileResponse } from "@/types/file"; +import { imageUploadApi } from "./api"; + +/** + * @description 프로필 이미지 업로드를 위한 useMutation 커스텀 훅 (회원가입 전 공개 API) + */ +const useUploadProfileImagePublic = () => { + return useMutation({ + mutationFn: imageUploadApi.postUploadProfileImageBeforeSignup, + }); +}; + +export default useUploadProfileImagePublic; diff --git a/apps/university-web/src/apis/kakao-api/api.ts b/apps/university-web/src/apis/kakao-api/api.ts new file mode 100644 index 00000000..73d7b358 --- /dev/null +++ b/apps/university-web/src/apis/kakao-api/api.ts @@ -0,0 +1,34 @@ +import { axiosInstance } from "@/utils/axiosInstance"; + +export type KakaoUserIdsResponse = undefined; + +export type KakaoUnlinkResponse = undefined; + +export type KakaoUnlinkRequest = Record; + +export type KakaoInfoResponse = undefined; + +export const kakaoApiApi = { + getKakaoUserIds: async (params: { params?: Record }): Promise => { + const res = await axiosInstance.get(`https://kapi.kakao.com/v1/user/ids?order=dsc`, { + params: params?.params, + }); + return res.data; + }, + + postKakaoUnlink: async (params: { data?: KakaoUnlinkRequest }): Promise => { + const res = await axiosInstance.post( + `https://kapi.kakao.com/v1/user/unlink?target_id_type=user_id&target_id=3715136239`, + params?.data, + ); + return res.data; + }, + + getKakaoInfo: async (params: { params?: Record }): Promise => { + const res = await axiosInstance.get( + `https://kapi.kakao.com/v2/user/me?property_keys=["kakao_account.email"]&target_id_type=user_id&target_id=3715136239`, + { params: params?.params }, + ); + return res.data; + }, +}; diff --git a/apps/university-web/src/apis/kakao-api/getKakaoInfo.ts b/apps/university-web/src/apis/kakao-api/getKakaoInfo.ts new file mode 100644 index 00000000..6ed75f14 --- /dev/null +++ b/apps/university-web/src/apis/kakao-api/getKakaoInfo.ts @@ -0,0 +1,13 @@ +import { useQuery } from "@tanstack/react-query"; +import type { AxiosError } from "axios"; +import { QueryKeys } from "../queryKeys"; +import { type KakaoInfoResponse, kakaoApiApi } from "./api"; + +const useGetKakaoInfo = (params?: Record) => { + return useQuery({ + queryKey: [QueryKeys["kakao-api"].kakaoInfo, params], + queryFn: () => kakaoApiApi.getKakaoInfo(params ? { params } : {}), + }); +}; + +export default useGetKakaoInfo; diff --git a/apps/university-web/src/apis/kakao-api/getKakaoUserIds.ts b/apps/university-web/src/apis/kakao-api/getKakaoUserIds.ts new file mode 100644 index 00000000..1da936b4 --- /dev/null +++ b/apps/university-web/src/apis/kakao-api/getKakaoUserIds.ts @@ -0,0 +1,13 @@ +import { useQuery } from "@tanstack/react-query"; +import type { AxiosError } from "axios"; +import { QueryKeys } from "../queryKeys"; +import { type KakaoUserIdsResponse, kakaoApiApi } from "./api"; + +const useGetKakaoUserIds = (params?: Record) => { + return useQuery({ + queryKey: [QueryKeys["kakao-api"].kakaoUserIds, params], + queryFn: () => kakaoApiApi.getKakaoUserIds(params ? { params } : {}), + }); +}; + +export default useGetKakaoUserIds; diff --git a/apps/university-web/src/apis/kakao-api/index.ts b/apps/university-web/src/apis/kakao-api/index.ts new file mode 100644 index 00000000..0acb2db7 --- /dev/null +++ b/apps/university-web/src/apis/kakao-api/index.ts @@ -0,0 +1,4 @@ +export { kakaoApiApi } from "./api"; +export { default as getKakaoInfo } from "./getKakaoInfo"; +export { default as getKakaoUserIds } from "./getKakaoUserIds"; +export { default as postKakaoUnlink } from "./postKakaoUnlink"; diff --git a/apps/university-web/src/apis/kakao-api/postKakaoUnlink.ts b/apps/university-web/src/apis/kakao-api/postKakaoUnlink.ts new file mode 100644 index 00000000..35714879 --- /dev/null +++ b/apps/university-web/src/apis/kakao-api/postKakaoUnlink.ts @@ -0,0 +1,11 @@ +import { useMutation } from "@tanstack/react-query"; +import type { AxiosError } from "axios"; +import { type KakaoUnlinkRequest, type KakaoUnlinkResponse, kakaoApiApi } from "./api"; + +const usePostKakaoUnlink = () => { + return useMutation({ + mutationFn: (data) => kakaoApiApi.postKakaoUnlink({ data }), + }); +}; + +export default usePostKakaoUnlink; diff --git a/apps/university-web/src/apis/mentor/api.ts b/apps/university-web/src/apis/mentor/api.ts new file mode 100644 index 00000000..872a7f2c --- /dev/null +++ b/apps/university-web/src/apis/mentor/api.ts @@ -0,0 +1,199 @@ +import type { MentoringListItem, VerifyStatus } from "@/types/mentee"; +import { + type MentorCardDetail, + type MentorCardPreview, + MentoringApprovalStatus, + type MentoringItem, +} from "@/types/mentor"; +import type { CountryCode } from "@/types/university"; +import { axiosInstance } from "@/utils/axiosInstance"; + +// QueryKeys for mentor domain +export const MentorQueryKeys = { + myMentorProfile: "myMentorProfile", + mentoringList: "mentoringList", + mentoringNewCount: "mentoringNewCount", + applyMentoringList: "applyMentoringList", + mentorList: "mentorList", + mentorDetail: "mentorDetail", +} as const; + +// Re-export types +export type { MentorCardPreview, MentorCardDetail, MentoringItem }; +export type { MentoringListItem, VerifyStatus }; +export { MentoringApprovalStatus }; + +// Response types +export interface MentoringListResponse { + content: MentoringItem[]; + nextPageNumber: number; +} + +export interface GetMentoringNewCountResponse { + uncheckedCount: number; +} + +export interface ApplyMentoringListResponse { + content: MentoringListItem[]; + nextPageNumber: number; +} + +export interface MentorListResponse { + nextPageNumber: number; + content: MentorCardDetail[]; +} + +export interface MatchedMentorsResponse { + content: MentorCardDetail[]; + nextPageNumber: number; + totalElements: number; +} + +export interface PatchApprovalStatusRequest { + status: MentoringApprovalStatus; + mentoringId: number; +} + +export interface PatchApprovalStatusResponse { + mentoringId: number; + chatRoomId: number; +} + +export interface PatchCheckMentoringsRequest { + checkedMentoringIds: number[]; +} + +export interface PatchCheckMentoringsResponse { + checkedMentoringIds: number[]; +} + +export interface PostApplyMentoringRequest { + mentorId: number; +} + +export interface PostApplyMentoringResponse { + mentoringId: number; +} + +export interface PostMentorApplicationRequest { + preparationStatus: "AFTER_EXCHANGE"; + universitySelectType: "CATALOG"; + country: CountryCode; + universityId: number; + term: string; + verificationFile: File; +} + +export interface PutMyMentorProfileRequest { + channels: { type: string; url: string }[]; + passTip: string; + introduction: string; +} + +const OFFSET = 5; +const MENTORS_OFFSET = 10; +const MENTEE_OFFSET = 3; + +export const mentorApi = { + // === Mentor (멘토) APIs === + getMentorMyProfile: async (): Promise => { + const res = await axiosInstance.get("/mentor/my"); + return res.data; + }, + + getMentoringList: async (page: number, size: number = OFFSET): Promise => { + const endpoint = `/mentor/mentorings?size=${size}&page=${page}`; + const res = await axiosInstance.get(endpoint); + return res.data; + }, + + getMentoringUncheckedCount: async (): Promise => { + const endpoint = "/mentor/mentorings/check"; + const res = await axiosInstance.get(endpoint); + return res.data; + }, + + patchApprovalStatus: async (props: PatchApprovalStatusRequest): Promise => { + const { status, mentoringId } = props; + const res = await axiosInstance.patch(`/mentor/mentorings/${mentoringId}`, { + status, + }); + return res.data; + }, + + patchMentorCheckMentorings: async (body: PatchCheckMentoringsRequest): Promise => { + const res = await axiosInstance.patch("/mentor/mentorings/check", body); + return res.data; + }, + + postMentorApplication: async (body: PostMentorApplicationRequest): Promise => { + const formData = new FormData(); + const applicationData = { + preparationStatus: body.preparationStatus, + universitySelectType: body.universitySelectType, + country: body.country, + universityId: body.universityId, + term: body.term, + }; + formData.append( + "mentorApplicationRequest", + new Blob([JSON.stringify(applicationData)], { type: "application/json" }), + ); + formData.append("file", body.verificationFile); + const res = await axiosInstance.post("/mentees/mentor-applications", formData, { + headers: { "Content-Type": "multipart/form-data" }, + }); + return res.data; + }, + + putMyMentorProfile: async (body: PutMyMentorProfileRequest): Promise => { + const res = await axiosInstance.put("/mentor/my", body); + return res.data; + }, + + // === Mentee (멘티) APIs === + getApplyMentoringList: async ( + verifyStatus: VerifyStatus, + page: number, + size: number = MENTEE_OFFSET, + ): Promise => { + const res = await axiosInstance.get( + `/mentee/mentorings?verify-status=${verifyStatus}&size=${size}&page=${page}`, + ); + return res.data; + }, + + patchMenteeCheckMentorings: async (body: PatchCheckMentoringsRequest): Promise => { + const res = await axiosInstance.patch("/mentee/mentorings/check", body); + return res.data; + }, + + postApplyMentoring: async (body: PostApplyMentoringRequest): Promise => { + const res = await axiosInstance.post("/mentee/mentorings", body); + return res.data; + }, + + // === Mentors (멘토 목록) APIs === + getMentorList: async (region: string, page: number, size: number = MENTORS_OFFSET): Promise => { + const res = await axiosInstance.get(`/mentors?region=${region}&page=${page}&size=${size}`); + return res.data; + }, + + getMentorDetail: async (mentorId: number): Promise => { + const res = await axiosInstance.get(`/mentors/${mentorId}`); + return res.data; + }, + + getMatchedMentors: async (params: { + defaultSize: string | number; + defaultPage: string | number; + params?: Record; + }): Promise => { + const { defaultSize, defaultPage, params: queryParams } = params; + const res = await axiosInstance.get( + `/mentors/matched?size=${defaultSize}&page=${defaultPage}`, + { params: queryParams }, + ); + return res.data; + }, +}; diff --git a/apps/university-web/src/apis/mentor/getAppliedMentorings.ts b/apps/university-web/src/apis/mentor/getAppliedMentorings.ts new file mode 100644 index 00000000..17ee3b3f --- /dev/null +++ b/apps/university-web/src/apis/mentor/getAppliedMentorings.ts @@ -0,0 +1,41 @@ +import { useInfiniteQuery, useQueryClient } from "@tanstack/react-query"; +import type { AxiosError } from "axios"; +import { + type ApplyMentoringListResponse, + type MentoringListItem, + MentorQueryKeys, + mentorApi, + type VerifyStatus, +} from "./api"; + +/** + * @description 신청한 멘토링 목록 조회 훅 (무한 스크롤) + */ +const useGetApplyMentoringList = (verifyStatus: VerifyStatus) => { + return useInfiniteQuery({ + queryKey: [MentorQueryKeys.applyMentoringList, verifyStatus], + queryFn: ({ pageParam = 0 }) => mentorApi.getApplyMentoringList(verifyStatus, pageParam), + initialPageParam: 0, + getNextPageParam: (lastPage) => (lastPage.nextPageNumber === -1 ? undefined : lastPage.nextPageNumber), + staleTime: 1000 * 60 * 5, // 5분간 캐시 + select: (data) => data.pages.flatMap((p) => p.content), + }); +}; + +// 멘토링 리스트 프리페치용 훅 +export const usePrefetchApplyMentoringList = () => { + const queryClient = useQueryClient(); + + const prefetchList = (verifyStatus: VerifyStatus) => { + queryClient.prefetchInfiniteQuery({ + queryKey: [MentorQueryKeys.applyMentoringList, verifyStatus], + queryFn: ({ pageParam = 0 }) => mentorApi.getApplyMentoringList(verifyStatus, pageParam as number), + initialPageParam: 0, + staleTime: 1000 * 60 * 5, + }); + }; + + return { prefetchList }; +}; + +export default useGetApplyMentoringList; diff --git a/apps/university-web/src/apis/mentor/getMatchedMentors.ts b/apps/university-web/src/apis/mentor/getMatchedMentors.ts new file mode 100644 index 00000000..7b30c622 --- /dev/null +++ b/apps/university-web/src/apis/mentor/getMatchedMentors.ts @@ -0,0 +1,18 @@ +import { useQuery } from "@tanstack/react-query"; +import type { AxiosError } from "axios"; +import { QueryKeys } from "../queryKeys"; +import { type MatchedMentorsResponse, mentorApi } from "./api"; + +const useGetMatchedMentors = ( + defaultSize: string | number, + defaultPage: string | number, + params?: Record, +) => { + return useQuery({ + queryKey: [QueryKeys.mentor.matchedMentors, defaultSize, defaultPage, params], + queryFn: () => mentorApi.getMatchedMentors({ defaultSize, defaultPage, params }), + enabled: !!defaultSize && !!defaultPage, + }); +}; + +export default useGetMatchedMentors; diff --git a/apps/university-web/src/apis/mentor/getMentorDetail.ts b/apps/university-web/src/apis/mentor/getMentorDetail.ts new file mode 100644 index 00000000..75cbc778 --- /dev/null +++ b/apps/university-web/src/apis/mentor/getMentorDetail.ts @@ -0,0 +1,17 @@ +import { useQuery } from "@tanstack/react-query"; +import type { AxiosError } from "axios"; +import { type MentorCardDetail, MentorQueryKeys, mentorApi } from "./api"; + +/** + * @description 멘토 상세 조회 훅 + */ +const useGetMentorDetail = (mentorId: number | null) => { + return useQuery({ + queryKey: [MentorQueryKeys.mentorDetail, mentorId!], + queryFn: () => mentorApi.getMentorDetail(mentorId!), + enabled: mentorId !== null, + staleTime: 1000 * 60 * 5, // 5분간 캐시 + }); +}; + +export default useGetMentorDetail; diff --git a/apps/university-web/src/apis/mentor/getMentorList.ts b/apps/university-web/src/apis/mentor/getMentorList.ts new file mode 100644 index 00000000..8e41e741 --- /dev/null +++ b/apps/university-web/src/apis/mentor/getMentorList.ts @@ -0,0 +1,39 @@ +import { useInfiniteQuery, useQueryClient } from "@tanstack/react-query"; +import type { AxiosError } from "axios"; +import { type MentorCardDetail, type MentorListResponse, MentorQueryKeys, mentorApi } from "./api"; + +interface UseGetMentorListRequest { + region?: string; +} + +/** + * @description 멘토 목록 조회 훅 (무한 스크롤) + */ +const useGetMentorList = ({ region = "" }: UseGetMentorListRequest = {}) => { + return useInfiniteQuery({ + queryKey: [MentorQueryKeys.mentorList, region], + queryFn: ({ pageParam = 0 }) => mentorApi.getMentorList(region, pageParam), + initialPageParam: 0, + getNextPageParam: (lastPage) => (lastPage.nextPageNumber === -1 ? undefined : lastPage.nextPageNumber), + staleTime: 1000 * 60 * 5, + select: (data) => data.pages.flatMap((p) => p.content), + }); +}; + +// 탭 프리페치용 훅 +export const usePrefetchMentorList = () => { + const queryClient = useQueryClient(); + + const prefetchMentorList = (region: string) => { + queryClient.prefetchInfiniteQuery({ + queryKey: [MentorQueryKeys.mentorList, region], + queryFn: ({ pageParam = 0 }) => mentorApi.getMentorList(region, pageParam as number), + initialPageParam: 0, + staleTime: 1000 * 60 * 5, + }); + }; + + return { prefetchMentorList }; +}; + +export default useGetMentorList; diff --git a/apps/university-web/src/apis/mentor/getMyMentorPage.ts b/apps/university-web/src/apis/mentor/getMyMentorPage.ts new file mode 100644 index 00000000..7f0a7448 --- /dev/null +++ b/apps/university-web/src/apis/mentor/getMyMentorPage.ts @@ -0,0 +1,16 @@ +import { useQuery } from "@tanstack/react-query"; +import type { AxiosError } from "axios"; +import { type MentorCardPreview, MentorQueryKeys, mentorApi } from "./api"; + +/** + * @description 멘토 마이 프로필 조회 훅 + */ +const useGetMentorMyProfile = () => { + return useQuery({ + queryKey: [MentorQueryKeys.myMentorProfile], + queryFn: mentorApi.getMentorMyProfile, + staleTime: 1000 * 60 * 5, // 5분간 캐시 + }); +}; + +export default useGetMentorMyProfile; diff --git a/apps/university-web/src/apis/mentor/getReceivedMentorings.ts b/apps/university-web/src/apis/mentor/getReceivedMentorings.ts new file mode 100644 index 00000000..7355a7a1 --- /dev/null +++ b/apps/university-web/src/apis/mentor/getReceivedMentorings.ts @@ -0,0 +1,24 @@ +import { useInfiniteQuery } from "@tanstack/react-query"; +import type { AxiosError } from "axios"; +import { type MentoringItem, type MentoringListResponse, MentorQueryKeys, mentorApi } from "./api"; + +const OFFSET = 5; + +/** + * @description 받은 멘토링 목록 조회 훅 (무한 스크롤) + */ +const useGetMentoringList = ({ size = OFFSET }: { size?: number } = {}) => { + return useInfiniteQuery({ + queryKey: [MentorQueryKeys.mentoringList, size], + queryFn: ({ pageParam = 0 }) => mentorApi.getMentoringList(pageParam, size), + initialPageParam: 0, + getNextPageParam: (lastPage) => { + return lastPage.nextPageNumber !== -1 ? lastPage.nextPageNumber : undefined; + }, + refetchInterval: 1000 * 60 * 10, // ⏱️ 10분마다 자동 재요청 + staleTime: 1000 * 60 * 5, // fresh 상태 유지 + select: (data) => data.pages.flatMap((page) => page.content), + }); +}; + +export default useGetMentoringList; diff --git a/apps/university-web/src/apis/mentor/getUnconfirmedMentoringCount.ts b/apps/university-web/src/apis/mentor/getUnconfirmedMentoringCount.ts new file mode 100644 index 00000000..3490970f --- /dev/null +++ b/apps/university-web/src/apis/mentor/getUnconfirmedMentoringCount.ts @@ -0,0 +1,16 @@ +import { useQuery } from "@tanstack/react-query"; +import type { AxiosError } from "axios"; +import { type GetMentoringNewCountResponse, MentorQueryKeys, mentorApi } from "./api"; + +const useGetUnconfirmedMentoringCount = (enabled: boolean = true) => { + return useQuery({ + queryKey: [MentorQueryKeys.mentoringNewCount], + queryFn: mentorApi.getMentoringUncheckedCount, + enabled, + refetchInterval: 1000 * 60 * 10, // ⏱️ 10분마다 자동 재요청 + staleTime: 1000 * 60 * 5, // fresh 상태 유지 + select: (data) => data.uncheckedCount, + }); +}; + +export default useGetUnconfirmedMentoringCount; diff --git a/apps/university-web/src/apis/mentor/index.ts b/apps/university-web/src/apis/mentor/index.ts new file mode 100644 index 00000000..df700ed1 --- /dev/null +++ b/apps/university-web/src/apis/mentor/index.ts @@ -0,0 +1,23 @@ +export type { + MentorCardDetail, + MentorCardPreview, + MentoringApprovalStatus, + MentoringItem, + MentoringListItem, + PostMentorApplicationRequest, + PutMyMentorProfileRequest, + VerifyStatus, +} from "./api"; +export { MentorQueryKeys, mentorApi } from "./api"; +export { default as useGetApplyMentoringList, usePrefetchApplyMentoringList } from "./getAppliedMentorings"; +export { default as useGetMentorDetail } from "./getMentorDetail"; +export { default as useGetMentorList, usePrefetchMentorList } from "./getMentorList"; +export { default as useGetMentorMyProfile } from "./getMyMentorPage"; +export { default as useGetMentoringList } from "./getReceivedMentorings"; +export { default as useGetUnconfirmedMentoringCount } from "./getUnconfirmedMentoringCount"; +export { default as usePatchMentorCheckMentorings } from "./patchConfirmMentoring"; +export { default as usePatchMenteeCheckMentorings } from "./patchMenteeCheckMentorings"; +export { default as usePatchApprovalStatus } from "./patchMentoringStatus"; +export { default as usePostApplyMentoring } from "./postApplyMentoring"; +export { default as usePostMentorApplication } from "./postMentorApplication"; +export { default as usePutMyMentorProfile } from "./putUpdateMyMentorPage"; diff --git a/apps/university-web/src/apis/mentor/patchConfirmMentoring.ts b/apps/university-web/src/apis/mentor/patchConfirmMentoring.ts new file mode 100644 index 00000000..a423e814 --- /dev/null +++ b/apps/university-web/src/apis/mentor/patchConfirmMentoring.ts @@ -0,0 +1,22 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import type { AxiosError } from "axios"; +import { MentorQueryKeys, mentorApi, type PatchCheckMentoringsRequest, type PatchCheckMentoringsResponse } from "./api"; + +/** + * @description 멘토 멘토링 확인 처리 훅 + */ +const usePatchMentorCheckMentorings = () => { + const queriesClient = useQueryClient(); + return useMutation({ + onSuccess: () => { + // 멘토링 체크 상태 변경 후 멘토링 목록 쿼리 무효화 + Promise.all([ + queriesClient.invalidateQueries({ queryKey: [MentorQueryKeys.mentoringList] }), + queriesClient.invalidateQueries({ queryKey: [MentorQueryKeys.mentoringNewCount] }), + ]); + }, + mutationFn: mentorApi.patchMentorCheckMentorings, + }); +}; + +export default usePatchMentorCheckMentorings; diff --git a/apps/university-web/src/apis/mentor/patchMenteeCheckMentorings.ts b/apps/university-web/src/apis/mentor/patchMenteeCheckMentorings.ts new file mode 100644 index 00000000..25231d86 --- /dev/null +++ b/apps/university-web/src/apis/mentor/patchMenteeCheckMentorings.ts @@ -0,0 +1,14 @@ +import { useMutation } from "@tanstack/react-query"; +import type { AxiosError } from "axios"; +import { mentorApi, type PatchCheckMentoringsRequest, type PatchCheckMentoringsResponse } from "./api"; + +/** + * @description 멘티 멘토링 확인 처리 훅 + */ +const usePatchMenteeCheckMentorings = () => { + return useMutation({ + mutationFn: mentorApi.patchMenteeCheckMentorings, + }); +}; + +export default usePatchMenteeCheckMentorings; diff --git a/apps/university-web/src/apis/mentor/patchMentoringStatus.ts b/apps/university-web/src/apis/mentor/patchMentoringStatus.ts new file mode 100644 index 00000000..76fb6cd9 --- /dev/null +++ b/apps/university-web/src/apis/mentor/patchMentoringStatus.ts @@ -0,0 +1,63 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; + +import type { AxiosError } from "axios"; +import { useRouter } from "next/navigation"; + +import { customAlert } from "@/lib/zustand/useAlertModalStore"; +import { customConfirm } from "@/lib/zustand/useConfirmModalStore"; +import { IconSmile, IconUnSmile } from "@/public/svgs/mentor"; +import { + MentoringApprovalStatus, + MentorQueryKeys, + mentorApi, + type PatchApprovalStatusRequest, + type PatchApprovalStatusResponse, +} from "./api"; + +/** + * @description 멘토링 승인/거절 훅 + */ +const usePatchApprovalStatus = () => { + const router = useRouter(); + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: mentorApi.patchApprovalStatus, + onSuccess: async (data, variables) => { + // 멘토링 상태 변경 후 쿼리 무효화 + await Promise.all([ + queryClient.invalidateQueries({ queryKey: [MentorQueryKeys.mentoringList] }), + queryClient.invalidateQueries({ queryKey: [MentorQueryKeys.mentoringNewCount] }), + ]); + + if (variables.status === MentoringApprovalStatus.REJECTED) { + customAlert({ + title: "멘티 신청을 거절했어요.", + icon: IconUnSmile, + content: "현재까지 누적해서 거절했어요. 누적 5회 거절 시 활동에 제약이 있으니 유의해주세요.", + buttonText: "닫기", + }); + } else if (variables.status === MentoringApprovalStatus.APPROVED) { + const ok = await customConfirm({ + title: "멘티 신청이 완료되었어요!", + content: "지금 바로 멘티에게 메시지를 전송해보세요", + icon: IconSmile, + rejectMessage: "닫기", + approveMessage: "1:1 채팅 바로가기", + }); + if (ok) { + router.push(`/mentor/chat/${data.chatRoomId}`); + } + } + }, + onError: (_error) => { + customAlert({ + title: "멘토링 상태 변경 실패", + content: "멘토링 상태 변경 중 오류가 발생했습니다. 다시 시도해주세요.", + buttonText: "확인", + }); + }, + }); +}; + +export default usePatchApprovalStatus; diff --git a/apps/university-web/src/apis/mentor/postApplyMentoring.ts b/apps/university-web/src/apis/mentor/postApplyMentoring.ts new file mode 100644 index 00000000..d371f7ef --- /dev/null +++ b/apps/university-web/src/apis/mentor/postApplyMentoring.ts @@ -0,0 +1,20 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import type { AxiosError } from "axios"; + +import { MentorQueryKeys, mentorApi, type PostApplyMentoringRequest, type PostApplyMentoringResponse } from "./api"; + +/** + * @description 멘토링 신청 훅 + */ +const usePostApplyMentoring = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: mentorApi.postApplyMentoring, + onSuccess: async () => { + // 멘토링 신청 후 멘토 목록을 새로고침 + await queryClient.invalidateQueries({ queryKey: [MentorQueryKeys.applyMentoringList] }); + }, + }); +}; + +export default usePostApplyMentoring; diff --git a/apps/university-web/src/apis/mentor/postMentorApplication.ts b/apps/university-web/src/apis/mentor/postMentorApplication.ts new file mode 100644 index 00000000..dfa02e8c --- /dev/null +++ b/apps/university-web/src/apis/mentor/postMentorApplication.ts @@ -0,0 +1,15 @@ +import { useMutation } from "@tanstack/react-query"; +import type { AxiosError } from "axios"; + +import { mentorApi, type PostMentorApplicationRequest } from "./api"; + +/** + * @description 멘토 신청 훅 + */ +const usePostMentorApplication = () => { + return useMutation({ + mutationFn: mentorApi.postMentorApplication, + }); +}; + +export default usePostMentorApplication; diff --git a/apps/university-web/src/apis/mentor/putUpdateMyMentorPage.ts b/apps/university-web/src/apis/mentor/putUpdateMyMentorPage.ts new file mode 100644 index 00000000..69a59090 --- /dev/null +++ b/apps/university-web/src/apis/mentor/putUpdateMyMentorPage.ts @@ -0,0 +1,22 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import type { AxiosError } from "axios"; +import { MentorQueryKeys, mentorApi, type PutMyMentorProfileRequest } from "./api"; + +/** + * @description 내 멘토 프로필 수정 훅 + */ +const usePutMyMentorProfile = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: mentorApi.putMyMentorProfile, + onSuccess: () => { + // 멘토 프로필 데이터를 stale로 만들어 다음 요청 시 새로운 데이터를 가져오도록 함 + queryClient.invalidateQueries({ + queryKey: [MentorQueryKeys.myMentorProfile], + }); + }, + }); +}; + +export default usePutMyMentorProfile; diff --git a/apps/university-web/src/apis/news/api.ts b/apps/university-web/src/apis/news/api.ts new file mode 100644 index 00000000..dccf7dc5 --- /dev/null +++ b/apps/university-web/src/apis/news/api.ts @@ -0,0 +1,106 @@ +import type { AxiosResponse } from "axios"; +import type { ArticleFormData } from "@/components/mentor/ArticleBottomSheetModal/lib/schema"; +import type { Article } from "@/types/news"; +import { axiosInstance } from "@/utils/axiosInstance"; + +// ====== Query Keys ====== +export const NewsQueryKeys = { + articleList: "articleList", + postAddArticle: "postAddArticle", + putModifyArticle: "putModifyArticle", +} as const; + +// ====== Types ====== +export interface ArticleListResponse { + newsResponseList: Article[]; +} + +export interface PostArticleLikeResponse { + isLiked: boolean; + likeCount: number; +} + +export interface DeleteArticleLikeResponse { + isLiked: boolean; + likeCount: number; +} + +export type UsePostAddArticleRequest = ArticleFormData; + +export type UsePutModifyArticleRequest = { + body: ArticleFormData & { isImageDeleted?: boolean }; + articleId: number; +}; + +// ====== API Functions ====== +export const newsApi = { + /** + * 아티클 목록 조회 + */ + getArticleList: async (userId: number): Promise => { + const response: AxiosResponse = await axiosInstance.get(`/news?author-id=${userId}`); + return response.data; + }, + + /** + * 아티클 추가 + */ + postAddArticle: async (body: UsePostAddArticleRequest): Promise
=> { + const newsCreateRequest = { + title: body.title, + description: body.description, + url: body.url || "", + }; + + const formData = new FormData(); + formData.append("newsCreateRequest", new Blob([JSON.stringify(newsCreateRequest)], { type: "application/json" })); + if (body.file) { + formData.append("file", body.file); + } + const response: AxiosResponse
= await axiosInstance.post("/news", formData); + return response.data; + }, + + /** + * 아티클 수정 + */ + putModifyArticle: async (props: UsePutModifyArticleRequest): Promise
=> { + const { body, articleId } = props; + const newsUpdateRequest = { + title: body.title, + description: body.description, + url: body.url || "", + resetToDefaultImage: body.isImageDeleted === true, + }; + const formData = new FormData(); + formData.append("newsUpdateRequest", new Blob([JSON.stringify(newsUpdateRequest)], { type: "application/json" })); + if (body.file) formData.append("file", body.file); + + const response: AxiosResponse
= await axiosInstance.put(`/news/${articleId}`, formData); + return response.data; + }, + + /** + * 아티클 삭제 + */ + deleteArticle: async (articleId: number): Promise => { + const response: AxiosResponse = await axiosInstance.delete(`/news/${articleId}`); + return response.data; + }, + + /** + * 아티클 좋아요 + */ + postArticleLike: async (articleId: number): Promise => { + const response: AxiosResponse = await axiosInstance.post(`/news/${articleId}/like`); + return response.data; + }, + + /** + * 아티클 좋아요 취소 + */ + deleteArticleLike: async (articleId: number): Promise => { + const response: AxiosResponse = await axiosInstance.delete(`/news/${articleId}/like`); + return response.data; + }, +}; diff --git a/apps/university-web/src/apis/news/deleteLikeNews.ts b/apps/university-web/src/apis/news/deleteLikeNews.ts new file mode 100644 index 00000000..67898dba --- /dev/null +++ b/apps/university-web/src/apis/news/deleteLikeNews.ts @@ -0,0 +1,51 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import type { AxiosError } from "axios"; + +import { type ArticleListResponse, type DeleteArticleLikeResponse, NewsQueryKeys, newsApi } from "./api"; + +type ArticleLikeMutationContext = { + previousArticleList?: ArticleListResponse; +}; + +/** + * @description 아티클 좋아요 취소 훅 + */ +const useDeleteArticleLike = (userId: number | null) => { + const queryClient = useQueryClient(); + const queryKey = [NewsQueryKeys.articleList, userId]; + + return useMutation, number, ArticleLikeMutationContext>({ + mutationFn: newsApi.deleteArticleLike, + + onMutate: async (unlikedArticleId) => { + await queryClient.cancelQueries({ queryKey }); + + const previousArticleList = queryClient.getQueryData(queryKey); + + queryClient.setQueryData(queryKey, (oldData) => { + if (!oldData) return { newsResponseList: [] }; + return { + newsResponseList: oldData.newsResponseList.map((article) => + article.id === unlikedArticleId + ? { + ...article, + isLiked: false, + likeCount: Math.max(0, (article.likeCount ?? 1) - 1), + } + : article, + ), + }; + }); + + return { previousArticleList }; + }, + + onError: (_err, _variables, context) => { + if (context?.previousArticleList) { + queryClient.setQueryData(queryKey, context.previousArticleList); + } + }, + }); +}; + +export default useDeleteArticleLike; diff --git a/apps/university-web/src/apis/news/deleteNews.ts b/apps/university-web/src/apis/news/deleteNews.ts new file mode 100644 index 00000000..ebe1be74 --- /dev/null +++ b/apps/university-web/src/apis/news/deleteNews.ts @@ -0,0 +1,47 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import type { AxiosError } from "axios"; +import type { Article } from "@/types/news"; +import { type ArticleListResponse, NewsQueryKeys, newsApi } from "./api"; + +type ArticleDeleteMutationContext = { + previousArticleList?: Article[]; +}; + +/** + * @description 아티클 삭제 훅 + */ +const useDeleteArticle = (userId: number | null) => { + const queryClient = useQueryClient(); + const queryKey = [NewsQueryKeys.articleList, userId]; + + return useMutation, number, ArticleDeleteMutationContext>({ + mutationFn: newsApi.deleteArticle, + + onMutate: async (deletedArticleId) => { + await queryClient.cancelQueries({ queryKey }); + + const previousArticleList = queryClient.getQueryData(queryKey); + + queryClient.setQueryData(queryKey, (oldData) => { + if (!oldData) return { newsResponseList: [] }; + return { + newsResponseList: oldData.newsResponseList.filter((article) => article.id !== deletedArticleId), + }; + }); + + return { previousArticleList }; + }, + + onError: (_error, _variables, context) => { + if (context?.previousArticleList) { + queryClient.setQueryData(queryKey, context.previousArticleList); + } + }, + + onSettled: () => { + queryClient.invalidateQueries({ queryKey }); + }, + }); +}; + +export default useDeleteArticle; diff --git a/apps/university-web/src/apis/news/getNewsList.ts b/apps/university-web/src/apis/news/getNewsList.ts new file mode 100644 index 00000000..5fb3325b --- /dev/null +++ b/apps/university-web/src/apis/news/getNewsList.ts @@ -0,0 +1,20 @@ +import { useQuery } from "@tanstack/react-query"; +import type { AxiosError } from "axios"; + +import type { Article } from "@/types/news"; +import { type ArticleListResponse, NewsQueryKeys, newsApi } from "./api"; + +/** + * @description 아티클 목록 조회 훅 + */ +const useGetArticleList = (userId: number, options?: { enabled?: boolean }) => { + return useQuery({ + queryKey: [NewsQueryKeys.articleList, userId], + queryFn: () => newsApi.getArticleList(userId), + staleTime: 1000 * 60 * 10, // 10분 + enabled: userId !== 0 && (options?.enabled ?? true), + select: (data) => data.newsResponseList, + }); +}; + +export default useGetArticleList; diff --git a/apps/university-web/src/apis/news/index.ts b/apps/university-web/src/apis/news/index.ts new file mode 100644 index 00000000..000e3daa --- /dev/null +++ b/apps/university-web/src/apis/news/index.ts @@ -0,0 +1,15 @@ +export type { + ArticleListResponse, + DeleteArticleLikeResponse, + PostArticleLikeResponse, + UsePostAddArticleRequest, + UsePutModifyArticleRequest, +} from "./api"; +export { NewsQueryKeys, newsApi } from "./api"; +export { default as useDeleteArticleLike } from "./deleteLikeNews"; +export { default as useDeleteArticle } from "./deleteNews"; +// News (아티클) hooks +export { default as useGetArticleList } from "./getNewsList"; +export { default as usePostAddArticle } from "./postCreateNews"; +export { default as usePostArticleLike } from "./postLikeNews"; +export { default as usePutModifyArticle } from "./putUpdateNews"; diff --git a/apps/university-web/src/apis/news/postCreateNews.ts b/apps/university-web/src/apis/news/postCreateNews.ts new file mode 100644 index 00000000..fdfb2db7 --- /dev/null +++ b/apps/university-web/src/apis/news/postCreateNews.ts @@ -0,0 +1,54 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import type { AxiosError } from "axios"; +import ArticleThumbUrlPng from "@/public/images/article-thumb.png"; +import type { Article } from "@/types/news"; +import { type ArticleListResponse, NewsQueryKeys, newsApi, type UsePostAddArticleRequest } from "./api"; + +type ArticleMutationContext = { + previousArticleContainer?: ArticleListResponse; +}; + +/** + * @description 아티클 추가 훅 + */ +const usePostAddArticle = (userId: number | null) => { + const queryClient = useQueryClient(); + const queryKey = [NewsQueryKeys.articleList, userId]; + + return useMutation, UsePostAddArticleRequest, ArticleMutationContext>({ + mutationFn: newsApi.postAddArticle, + onMutate: async (newArticle) => { + await queryClient.cancelQueries({ queryKey }); + + const previousArticleContainer = queryClient.getQueryData(queryKey); + + queryClient.setQueryData(queryKey, (oldData) => { + if (!oldData) return { newsResponseList: [] }; + + const optimisticArticle: Article = { + id: Date.now(), // 임시 ID + title: newArticle.title, + description: newArticle.description, + url: newArticle.url || "", + thumbnailUrl: newArticle.file ? URL.createObjectURL(newArticle.file) : ArticleThumbUrlPng.src, + updatedAt: new Date().toISOString(), + }; + + return { + newsResponseList: [optimisticArticle, ...oldData.newsResponseList], + }; + }); + return { previousArticleContainer }; + }, + onError: (_error, _variables, context) => { + if (context?.previousArticleContainer) { + queryClient.setQueryData(queryKey, context.previousArticleContainer); + } + }, + onSettled: () => { + queryClient.invalidateQueries({ queryKey }); + }, + }); +}; + +export default usePostAddArticle; diff --git a/apps/university-web/src/apis/news/postLikeNews.ts b/apps/university-web/src/apis/news/postLikeNews.ts new file mode 100644 index 00000000..86adc752 --- /dev/null +++ b/apps/university-web/src/apis/news/postLikeNews.ts @@ -0,0 +1,52 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import type { AxiosError } from "axios"; + +import type { Article } from "@/types/news"; +import { type ArticleListResponse, NewsQueryKeys, newsApi, type PostArticleLikeResponse } from "./api"; + +type ArticleLikeMutationContext = { + previousArticleList?: Article[]; +}; + +/** + * @description 아티클 좋아요 훅 + */ +const usePostArticleLike = (userId: number | null) => { + const queryClient = useQueryClient(); + const queryKey = [NewsQueryKeys.articleList, userId]; + + return useMutation, number, ArticleLikeMutationContext>({ + mutationFn: newsApi.postArticleLike, + + onMutate: async (likedArticleId) => { + await queryClient.cancelQueries({ queryKey }); + + const previousArticleList = queryClient.getQueryData(queryKey); + + queryClient.setQueryData(queryKey, (oldData) => { + if (!oldData) return { newsResponseList: [] }; + return { + newsResponseList: oldData.newsResponseList.map((article) => + article.id === likedArticleId + ? { + ...article, + isLiked: true, + likeCount: (article.likeCount ?? 0) + 1, + } + : article, + ), + }; + }); + + return { previousArticleList }; + }, + + onError: (_err, _variables, context) => { + if (context?.previousArticleList) { + queryClient.setQueryData(queryKey, context.previousArticleList); + } + }, + }); +}; + +export default usePostArticleLike; diff --git a/apps/university-web/src/apis/news/putUpdateNews.ts b/apps/university-web/src/apis/news/putUpdateNews.ts new file mode 100644 index 00000000..c0e93d8f --- /dev/null +++ b/apps/university-web/src/apis/news/putUpdateNews.ts @@ -0,0 +1,56 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import type { AxiosError } from "axios"; +import type { Article } from "@/types/news"; +import { type ArticleListResponse, NewsQueryKeys, newsApi, type UsePutModifyArticleRequest } from "./api"; + +type ArticleMutationContext = { + previousArticleList?: Article[]; +}; + +/** + * @description 아티클 수정 훅 + */ +const usePutModifyArticle = (userId: number | null) => { + const queryClient = useQueryClient(); + const queryKey = [NewsQueryKeys.articleList, userId]; + + return useMutation, UsePutModifyArticleRequest, ArticleMutationContext>({ + mutationFn: newsApi.putModifyArticle, + onMutate: async (variables) => { + await queryClient.cancelQueries({ queryKey }); + const previousArticleList = queryClient.getQueryData(queryKey); + + queryClient.setQueryData(queryKey, (oldData) => { + if (!oldData) return { newsResponseList: [] }; + + return { + newsResponseList: oldData.newsResponseList.map((article) => { + if (article.id === variables.articleId) { + const optimisticData = variables.body; + + return { + ...article, + title: optimisticData.title, + description: optimisticData.description, + url: optimisticData.url || "", + thumbnailUrl: optimisticData.file ? URL.createObjectURL(optimisticData.file) : article.thumbnailUrl, + }; + } + return article; + }), + }; + }); + return { previousArticleList }; + }, + onError: (_error, _variables, context) => { + if (context?.previousArticleList) { + queryClient.setQueryData(queryKey, context.previousArticleList); + } + }, + onSettled: () => { + queryClient.invalidateQueries({ queryKey }); + }, + }); +}; + +export default usePutModifyArticle; diff --git a/apps/university-web/src/apis/news/server/getNewsList.ts b/apps/university-web/src/apis/news/server/getNewsList.ts new file mode 100644 index 00000000..c51bdccd --- /dev/null +++ b/apps/university-web/src/apis/news/server/getNewsList.ts @@ -0,0 +1,21 @@ +import type { ArticleListResponse } from "@/apis/news"; +import type { News } from "@/types/news"; +import serverFetch from "@/utils/serverFetchUtil"; + +export const getHomeNewsList = async (): Promise => { + const response = await serverFetch("/news"); + + if (!response.ok) { + return []; + } + + return response.data.newsResponseList + .map((news) => ({ + id: news.id, + title: news.title, + description: news.description, + imageUrl: news.thumbnailUrl, + url: news.url, + })) + .sort((a, b) => a.id - b.id); +}; diff --git a/apps/university-web/src/apis/queryKeys.ts b/apps/university-web/src/apis/queryKeys.ts new file mode 100644 index 00000000..3963ad5a --- /dev/null +++ b/apps/university-web/src/apis/queryKeys.ts @@ -0,0 +1,132 @@ +/** + * React Query Keys + * Bruno 폴더 구조를 기반으로 자동 생성됨 + */ + +export const QueryKeys = { + Auth: { + folder: "Auth.folder" as const, + signOut: "Auth.signOut" as const, + appleAuth: "Auth.appleAuth" as const, + refreshToken: "Auth.refreshToken" as const, + emailLogin: "Auth.emailLogin" as const, + emailVerification: "Auth.emailVerification" as const, + kakaoAuth: "Auth.kakaoAuth" as const, + account: "Auth.account" as const, + signUp: "Auth.signUp" as const, + }, + news: { + folder: "news.folder" as const, + newsList: "news.newsList" as const, + news: "news.news" as const, + updateNews: "news.updateNews" as const, + likeNews: "news.likeNews" as const, + createNews: "news.createNews" as const, + }, + reports: { + folder: "reports.folder" as const, + report: "reports.report" as const, + }, + chat: { + folder: "chat.folder" as const, + chatMessages: "chat.chatMessages" as const, + chatRooms: "chat.chatRooms" as const, + readChatRoom: "chat.readChatRoom" as const, + chatPartner: "chat.chatPartner" as const, + }, + universities: { + folder: "universities.folder" as const, + recommendedUniversities: "universities.recommendedUniversities" as const, + wishList: "universities.wishList" as const, + wish: "universities.wish" as const, + addWish: "universities.addWish" as const, + isWish: "universities.isWish" as const, + universityDetail: "universities.universityDetail" as const, + searchText: "universities.searchText" as const, + searchFilter: "universities.searchFilter" as const, + byRegionCountry: "universities.byRegionCountry" as const, + }, + MyPage: { + folder: "MyPage.folder" as const, + interestedRegionCountry: "MyPage.interestedRegionCountry" as const, + profile: "MyPage.profile" as const, + password: "MyPage.password" as const, + }, + applications: { + folder: "applications.folder" as const, + competitors: "applications.competitors" as const, + submitApplication: "applications.submitApplication" as const, + applicants: "applications.applicants" as const, + }, + community: { + folder: "community.folder" as const, + boardList: "community.boardList" as const, + board: "community.board" as const, + comment: "community.comment" as const, + updateComment: "community.updateComment" as const, + createComment: "community.createComment" as const, + post: "community.post" as const, + updatePost: "community.updatePost" as const, + createPost: "community.createPost" as const, + postDetail: "community.postDetail" as const, + likePost: "community.likePost" as const, + }, + Scores: { + folder: "Scores.folder" as const, + createLanguageTest: "Scores.createLanguageTest" as const, + languageTestList: "Scores.languageTestList" as const, + createGpa: "Scores.createGpa" as const, + gpaList: "Scores.gpaList" as const, + }, + Admin: { + folder: "Admin.folder" as const, + verifyLanguageTest: "Admin.verifyLanguageTest" as const, + languageTestList: "Admin.languageTestList" as const, + verifyGpa: "Admin.verifyGpa" as const, + gpaList: "Admin.gpaList" as const, + }, + users: { + folder: "users.folder" as const, + nicknameExists: "users.nicknameExists" as const, + blockUser: "users.blockUser" as const, + unblockUser: "users.unblockUser" as const, + blockedUsers: "users.blockedUsers" as const, + }, + mentor: { + folder: "mentor.folder" as const, + matchedMentors: "mentor.matchedMentors" as const, + applyMentoring: "mentor.applyMentoring" as const, + confirmMentoring: "mentor.confirmMentoring" as const, + appliedMentorings: "mentor.appliedMentorings" as const, + mentorList: "mentor.mentorList" as const, + mentorDetail: "mentor.mentorDetail" as const, + myMentorPage: "mentor.myMentorPage" as const, + updateMyMentorPage: "mentor.updateMyMentorPage" as const, + mentoringStatus: "mentor.mentoringStatus" as const, + receivedMentorings: "mentor.receivedMentorings" as const, + unconfirmedMentoringCount: "mentor.unconfirmedMentoringCount" as const, + }, + "kakao-api": { + folder: "kakao-api.folder" as const, + kakaoUserIds: "kakao-api.kakaoUserIds" as const, + kakaoUnlink: "kakao-api.kakaoUnlink" as const, + kakaoInfo: "kakao-api.kakaoInfo" as const, + }, + "collection.bru": { + collection: "collection.bru.collection" as const, + }, + environments: { + dev: "environments.dev" as const, + local: "environments.local" as const, + }, + "image-upload": { + folder: "image-upload.folder" as const, + slackNotification: "image-upload.slackNotification" as const, + uploadLanguageTestReport: "image-upload.uploadLanguageTestReport" as const, + uploadProfileImage: "image-upload.uploadProfileImage" as const, + uploadProfileImageBeforeSignup: "image-upload.uploadProfileImageBeforeSignup" as const, + uploadGpaReport: "image-upload.uploadGpaReport" as const, + }, +} as const; + +export type QueryKey = (typeof QueryKeys)[keyof typeof QueryKeys]; diff --git a/apps/university-web/src/apis/reports/api.ts b/apps/university-web/src/apis/reports/api.ts new file mode 100644 index 00000000..c1417589 --- /dev/null +++ b/apps/university-web/src/apis/reports/api.ts @@ -0,0 +1,21 @@ +import type { AxiosResponse } from "axios"; +import type { ReportType } from "@/types/reports"; +import { axiosInstance } from "@/utils/axiosInstance"; + +// ====== Types ====== +export interface UsePostReportsRequest { + targetType: "POST"; // 지금은 게시글 신고 기능만 존재 + targetId: number; // 신고하려는 리소스의 ID + reportType: ReportType; +} + +// ====== API Functions ====== +export const reportsApi = { + /** + * 신고 등록 + */ + postReport: async (body: UsePostReportsRequest): Promise => { + const response: AxiosResponse = await axiosInstance.post(`/reports`, body); + return response.data; + }, +}; diff --git a/apps/university-web/src/apis/reports/index.ts b/apps/university-web/src/apis/reports/index.ts new file mode 100644 index 00000000..25277463 --- /dev/null +++ b/apps/university-web/src/apis/reports/index.ts @@ -0,0 +1,3 @@ +export type { UsePostReportsRequest } from "./api"; +export { reportsApi } from "./api"; +export { default as usePostReports } from "./postReport"; diff --git a/apps/university-web/src/apis/reports/postReport.ts b/apps/university-web/src/apis/reports/postReport.ts new file mode 100644 index 00000000..c47d3aa5 --- /dev/null +++ b/apps/university-web/src/apis/reports/postReport.ts @@ -0,0 +1,20 @@ +import { useMutation } from "@tanstack/react-query"; + +import type { AxiosError } from "axios"; + +import { showIconToast } from "@/lib/toast/showIconToast"; +import { reportsApi, type UsePostReportsRequest } from "./api"; + +/** + * @description 신고 등록 훅 + */ +const usePostReports = () => { + return useMutation, UsePostReportsRequest>({ + mutationFn: reportsApi.postReport, + onSuccess: () => { + showIconToast("logo", "신고가 성공적으로 등록되었습니다."); + }, + }); +}; + +export default usePostReports; diff --git a/apps/university-web/src/apis/universities/api.ts b/apps/university-web/src/apis/universities/api.ts new file mode 100644 index 00000000..6b0055a3 --- /dev/null +++ b/apps/university-web/src/apis/universities/api.ts @@ -0,0 +1,195 @@ +import type { HomeUniversityName } from "@/types/university"; +import { axiosInstance, publicAxiosInstance } from "@/utils/axiosInstance"; + +export interface RecommendedUniversitiesResponseRecommendedUniversitiesItem { + id: number; + term: string; + koreanName: string; + region: string; + country: string; + logoImageUrl: string; + backgroundImageUrl: string; + studentCapacity: number; + languageRequirements: RecommendedUniversitiesResponseRecommendedUniversitiesItemLanguageRequirementsItem[]; +} + +export interface RecommendedUniversitiesResponseRecommendedUniversitiesItemLanguageRequirementsItem { + languageTestType: string; + minScore: string; +} + +export interface RecommendedUniversitiesResponse { + recommendedUniversities: RecommendedUniversitiesResponseRecommendedUniversitiesItem[]; +} + +export interface WishListResponseItem { + id: number; + term: string; + koreanName: string; + region: string; + country: string; + logoImageUrl: string; + backgroundImageUrl: string; + studentCapacity: number; + languageRequirements: WishListResponseItemLanguageRequirementsItem[]; +} + +export interface WishListResponseItemLanguageRequirementsItem { + languageTestType: string; + minScore: string; +} + +export interface WishListResponse { + 0: WishListResponseItem[]; + 1: WishListResponseItem[]; +} + +export type WishResponse = undefined; + +export type AddWishResponse = undefined; + +export type AddWishRequest = Record; + +export type IsWishResponse = undefined; + +export interface UniversityDetailResponseLanguageRequirementsItem { + languageTestType: string; + minScore: string; +} + +export interface UniversityDetailResponse { + id: number; + term: string; + koreanName: string; + englishName: string; + formatName: string; + region: string; + country: string; + homepageUrl: string; + logoImageUrl: string; + backgroundImageUrl: string; + detailsForLocal: string; + studentCapacity: number; + tuitionFeeType: string; + semesterAvailableForDispatch: string; + languageRequirements: UniversityDetailResponseLanguageRequirementsItem[]; + detailsForLanguage: string; + gpaRequirement: string; + gpaRequirementCriteria: string; + semesterRequirement: string; + detailsForApply: null; + detailsForMajor: string; + detailsForAccommodation: null; + detailsForEnglishCourse: null; + details: string; + accommodationUrl: string; + englishCourseUrl: string; +} + +export interface SearchTextResponseUnivApplyInfoPreviewsItem { + id: number; + term: string; + koreanName: string; + region: string; + country: string; + logoImageUrl: string; + backgroundImageUrl: string; + studentCapacity: number; + languageRequirements: SearchTextResponseUnivApplyInfoPreviewsItemLanguageRequirementsItem[]; + homeUniversityName?: HomeUniversityName; +} + +export interface SearchTextResponseUnivApplyInfoPreviewsItemLanguageRequirementsItem { + languageTestType: string; + minScore: string; +} + +export interface SearchTextResponse { + univApplyInfoPreviews: SearchTextResponseUnivApplyInfoPreviewsItem[]; +} + +export interface SearchFilterResponseUnivApplyInfoPreviewsItem { + id: number; + term: string; + koreanName: string; + region: string; + country: string; + logoImageUrl: string; + backgroundImageUrl: string; + studentCapacity: number; + languageRequirements: SearchFilterResponseUnivApplyInfoPreviewsItemLanguageRequirementsItem[]; +} + +export interface SearchFilterResponseUnivApplyInfoPreviewsItemLanguageRequirementsItem { + languageTestType: string; + minScore: string; +} + +export interface SearchFilterResponse { + univApplyInfoPreviews: SearchFilterResponseUnivApplyInfoPreviewsItem[]; +} + +export type ByRegionCountryResponse = undefined; + +export const universitiesApi = { + getRecommendedUniversities: async (params?: { isLogin?: boolean }): Promise => { + const instance = params?.isLogin ? axiosInstance : publicAxiosInstance; + const res = await instance.get(`/univ-apply-infos/recommend`); + return res.data; + }, + + getWishList: async (params: { params?: Record }): Promise => { + const res = await axiosInstance.get(`/univ-apply-infos/like`, { params: params?.params }); + return res.data; + }, + + deleteWish: async (params: { univApplyInfoId: string | number }): Promise => { + const res = await axiosInstance.delete(`/univ-apply-infos/${params.univApplyInfoId}/like`); + return res.data; + }, + + postAddWish: async (params: { + univApplyInfoId: string | number; + data?: AddWishRequest; + }): Promise => { + const res = await axiosInstance.post( + `/univ-apply-infos/${params.univApplyInfoId}/like`, + params?.data, + ); + return res.data; + }, + + getIsWish: async (params: { + univApplyInfoId: string | number; + params?: Record; + }): Promise => { + const res = await axiosInstance.get(`/univ-apply-infos/${params.univApplyInfoId}/like`, { + params: params?.params, + }); + return res.data; + }, + + getUniversityDetail: async (params: { univApplyInfoId: string | number }): Promise => { + const res = await publicAxiosInstance.get(`/univ-apply-infos/${params.univApplyInfoId}`); + return res.data; + }, + + getSearchText: async (params?: { value?: string }): Promise => { + const res = await publicAxiosInstance.get(`/univ-apply-infos/search/text`, { + params: { value: params?.value ?? "" }, + }); + return res.data; + }, + + getSearchFilter: async (params?: { params?: Record }): Promise => { + const res = await publicAxiosInstance.get(`/univ-apply-infos/search/filter`, { + params: params?.params, + }); + return res.data; + }, + + getByRegionCountry: async (params: { params?: Record }): Promise => { + const res = await axiosInstance.get(`/universities/search`, { params: params?.params }); + return res.data; + }, +}; diff --git a/apps/university-web/src/apis/universities/deleteWish.ts b/apps/university-web/src/apis/universities/deleteWish.ts new file mode 100644 index 00000000..c76c58ec --- /dev/null +++ b/apps/university-web/src/apis/universities/deleteWish.ts @@ -0,0 +1,20 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import type { AxiosError } from "axios"; +import { QueryKeys } from "../queryKeys"; +import { universitiesApi, type WishResponse } from "./api"; + +/** + * @description 위시리스트에서 학교를 삭제하는 useMutation 커스텀 훅 + */ +const useDeleteWish = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (universityInfoForApplyId) => universitiesApi.deleteWish({ univApplyInfoId: universityInfoForApplyId }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: [QueryKeys.universities.wishList] }); + }, + }); +}; + +export default useDeleteWish; diff --git a/apps/university-web/src/apis/universities/getByRegionCountry.ts b/apps/university-web/src/apis/universities/getByRegionCountry.ts new file mode 100644 index 00000000..ae66da51 --- /dev/null +++ b/apps/university-web/src/apis/universities/getByRegionCountry.ts @@ -0,0 +1,13 @@ +import { useQuery } from "@tanstack/react-query"; +import type { AxiosError } from "axios"; +import { QueryKeys } from "../queryKeys"; +import { type ByRegionCountryResponse, universitiesApi } from "./api"; + +const useGetByRegionCountry = (params?: Record) => { + return useQuery({ + queryKey: [QueryKeys.universities.byRegionCountry, params], + queryFn: () => universitiesApi.getByRegionCountry(params ? { params } : {}), + }); +}; + +export default useGetByRegionCountry; diff --git a/apps/university-web/src/apis/universities/getIsWish.ts b/apps/university-web/src/apis/universities/getIsWish.ts new file mode 100644 index 00000000..8d8e85c5 --- /dev/null +++ b/apps/university-web/src/apis/universities/getIsWish.ts @@ -0,0 +1,14 @@ +import { useQuery } from "@tanstack/react-query"; +import type { AxiosError } from "axios"; +import { QueryKeys } from "../queryKeys"; +import { type IsWishResponse, universitiesApi } from "./api"; + +const useGetIsWish = (univApplyInfoId: string | number, params?: Record) => { + return useQuery({ + queryKey: [QueryKeys.universities.isWish, univApplyInfoId, params], + queryFn: () => universitiesApi.getIsWish({ univApplyInfoId, params }), + enabled: !!univApplyInfoId, + }); +}; + +export default useGetIsWish; diff --git a/apps/university-web/src/apis/universities/getRecommendedUniversities.ts b/apps/university-web/src/apis/universities/getRecommendedUniversities.ts new file mode 100644 index 00000000..5d267ac3 --- /dev/null +++ b/apps/university-web/src/apis/universities/getRecommendedUniversities.ts @@ -0,0 +1,24 @@ +import { useQuery } from "@tanstack/react-query"; +import type { AxiosError } from "axios"; +import type { ListUniversity } from "@/types/university"; +import { QueryKeys } from "../queryKeys"; +import { type RecommendedUniversitiesResponse, universitiesApi } from "./api"; + +type UseGetRecommendedUniversitiesParams = { + isLogin: boolean; +}; + +/** + * @description 추천 대학 목록 조회를 위한 useQuery 커스텀 훅 + * @param params.isLogin - 로그인 여부 (인스턴스 결정에 사용) + */ +const useGetRecommendedUniversities = ({ isLogin }: UseGetRecommendedUniversitiesParams) => { + return useQuery({ + queryKey: [QueryKeys.universities.recommendedUniversities, isLogin], + queryFn: () => universitiesApi.getRecommendedUniversities({ isLogin }), + staleTime: 1000 * 60 * 5, + select: (data) => data.recommendedUniversities as unknown as ListUniversity[], + }); +}; + +export default useGetRecommendedUniversities; diff --git a/apps/university-web/src/apis/universities/getSearchFilter.ts b/apps/university-web/src/apis/universities/getSearchFilter.ts new file mode 100644 index 00000000..6094fb57 --- /dev/null +++ b/apps/university-web/src/apis/universities/getSearchFilter.ts @@ -0,0 +1,68 @@ +import { useQuery } from "@tanstack/react-query"; +import type { AxiosError } from "axios"; +import { useMemo } from "react"; +import { isMatchedHomeUniversityName } from "@/constants/university"; +import type { CountryCode, HomeUniversityName, LanguageTestType, ListUniversity } from "@/types/university"; +import { QueryKeys } from "../queryKeys"; +import { type SearchFilterResponse, universitiesApi } from "./api"; + +export interface UniversitySearchFilterParams { + languageTestType?: LanguageTestType; + testScore?: number; + countryCode?: CountryCode[]; +} + +// API 응답에 homeUniversityName이 포함된 타입 +interface ListUniversityWithHome extends ListUniversity { + homeUniversityName?: HomeUniversityName; +} + +/** + * @description 필터로 대학 검색을 위한 useQuery 커스텀 훅 + * @param filters - 검색 필터 파라미터 + * @param homeUniversityName - 홈 대학교 이름 (선택적 필터) + */ +const useGetUniversitySearchByFilter = ( + filters: UniversitySearchFilterParams, + homeUniversityName?: HomeUniversityName, +) => { + // 필터 파라미터 구성 + const buildParams = () => { + const params: Record = {}; + if (filters.languageTestType) { + params.languageTestType = filters.languageTestType; + } + if (filters.testScore !== undefined) { + params.testScore = String(filters.testScore); + } + if (filters.countryCode && filters.countryCode.length > 0) { + params.countryCode = filters.countryCode; + } + return params; + }; + + const query = useQuery({ + queryKey: [QueryKeys.universities.searchFilter, filters], + queryFn: () => universitiesApi.getSearchFilter({ params: buildParams() }), + enabled: Object.values(filters).some((value) => { + if (Array.isArray(value)) return value.length > 0; + return value !== undefined && value !== ""; + }), + select: (data) => data.univApplyInfoPreviews as unknown as ListUniversityWithHome[], + }); + + // homeUniversityName으로 필터링 + const filteredData = useMemo(() => { + if (!query.data || !homeUniversityName) return query.data; + return query.data.filter((university) => + isMatchedHomeUniversityName(university.homeUniversityName, homeUniversityName), + ); + }, [query.data, homeUniversityName]); + + return { + ...query, + data: filteredData, + }; +}; + +export default useGetUniversitySearchByFilter; diff --git a/apps/university-web/src/apis/universities/getSearchText.ts b/apps/university-web/src/apis/universities/getSearchText.ts new file mode 100644 index 00000000..ed9cefbb --- /dev/null +++ b/apps/university-web/src/apis/universities/getSearchText.ts @@ -0,0 +1,70 @@ +import { useQuery } from "@tanstack/react-query"; + +import type { AxiosError } from "axios"; +import { useMemo } from "react"; +import { isMatchedHomeUniversityName } from "@/constants/university"; +import type { HomeUniversityName, ListUniversity } from "@/types/university"; +import { QueryKeys } from "../queryKeys"; +import { type SearchTextResponse, universitiesApi } from "./api"; + +// API 응답에 homeUniversityName이 포함된 타입 +interface ListUniversityWithHome extends ListUniversity { + homeUniversityName?: HomeUniversityName; +} + +/** + * @description 대학 검색을 위한 useQuery 커스텀 훅 + * 모든 대학 데이터를 한 번만 가져와 캐싱하고, 검색어에 따라 클라이언트에서 필터링합니다. + * @param searchValue - 검색어 + * @param homeUniversityName - 홈 대학교 이름 (선택적 필터) + */ +const useUniversitySearch = (searchValue: string, homeUniversityName?: HomeUniversityName) => { + // 1. 모든 대학 데이터를 한 번만 가져와 'Infinity' 캐시로 저장합니다. + const { + data: allUniversities, + isLoading, + isError, + error, + } = useQuery({ + queryKey: [QueryKeys.universities.searchText], + queryFn: () => universitiesApi.getSearchText({ value: "" }), + staleTime: Infinity, + gcTime: Infinity, + select: (data) => data.univApplyInfoPreviews as unknown as ListUniversityWithHome[], + }); + + // 2. 검색어와 homeUniversityName에 따라 필터링합니다. + const filteredUniversities = useMemo(() => { + const normalizedSearchValue = searchValue.trim().toLowerCase(); + + if (!allUniversities) { + return []; + } + + let filtered = allUniversities; + + // homeUniversityName 필터링 + if (homeUniversityName) { + filtered = filtered.filter((university) => + isMatchedHomeUniversityName(university.homeUniversityName, homeUniversityName), + ); + } + + // 검색어 필터링 + if (normalizedSearchValue) { + filtered = filtered.filter((university) => university.koreanName.toLowerCase().includes(normalizedSearchValue)); + } + + return filtered; + }, [allUniversities, searchValue, homeUniversityName]); + + return { + data: filteredUniversities, + isLoading, + isError, + error, + totalCount: allUniversities?.length || 0, + }; +}; + +export default useUniversitySearch; diff --git a/apps/university-web/src/apis/universities/getUniversityDetail.ts b/apps/university-web/src/apis/universities/getUniversityDetail.ts new file mode 100644 index 00000000..e5a3c6d1 --- /dev/null +++ b/apps/university-web/src/apis/universities/getUniversityDetail.ts @@ -0,0 +1,20 @@ +import { useQuery } from "@tanstack/react-query"; +import type { AxiosError } from "axios"; +import type { University } from "@/types/university"; +import { QueryKeys } from "../queryKeys"; +import { type UniversityDetailResponse, universitiesApi } from "./api"; + +/** + * @description 대학 상세 조회를 위한 useQuery 커스텀 훅 + * @param universityInfoForApplyId - 대학 ID + */ +const useGetUniversityDetail = (universityInfoForApplyId: number) => { + return useQuery({ + queryKey: [QueryKeys.universities.universityDetail, universityInfoForApplyId], + queryFn: () => universitiesApi.getUniversityDetail({ univApplyInfoId: universityInfoForApplyId }), + enabled: !!universityInfoForApplyId, + select: (data) => data as unknown as University, + }); +}; + +export default useGetUniversityDetail; diff --git a/apps/university-web/src/apis/universities/getWishList.ts b/apps/university-web/src/apis/universities/getWishList.ts new file mode 100644 index 00000000..3bd08cf7 --- /dev/null +++ b/apps/university-web/src/apis/universities/getWishList.ts @@ -0,0 +1,21 @@ +import { useQuery } from "@tanstack/react-query"; +import type { AxiosError } from "axios"; +import type { ListUniversity } from "@/types/university"; +import { QueryKeys } from "../queryKeys"; +import { universitiesApi, type WishListResponse } from "./api"; + +/** + * @description 내 위시리스트 대학 목록 조회를 위한 useQuery 커스텀 훅 + * @param enabled - 쿼리 활성화 여부 + */ +const useGetWishList = (enabled: boolean = true) => { + return useQuery({ + queryKey: [QueryKeys.universities.wishList], + queryFn: () => universitiesApi.getWishList({}), + staleTime: 1000 * 60 * 5, + select: (data) => data as unknown as ListUniversity[], + enabled, + }); +}; + +export default useGetWishList; diff --git a/apps/university-web/src/apis/universities/index.ts b/apps/university-web/src/apis/universities/index.ts new file mode 100644 index 00000000..95a44032 --- /dev/null +++ b/apps/university-web/src/apis/universities/index.ts @@ -0,0 +1,10 @@ +export { universitiesApi } from "./api"; +export { default as useDeleteWish } from "./deleteWish"; +export { default as useGetByRegionCountry } from "./getByRegionCountry"; +export { default as useGetIsWish } from "./getIsWish"; +export { default as useGetRecommendedUniversities } from "./getRecommendedUniversities"; +export { default as useGetUniversitySearchByFilter, type UniversitySearchFilterParams } from "./getSearchFilter"; +export { default as useUniversitySearch } from "./getSearchText"; +export { default as useGetUniversityDetail } from "./getUniversityDetail"; +export { default as useGetWishList } from "./getWishList"; +export { default as usePostAddWish } from "./postAddWish"; diff --git a/apps/university-web/src/apis/universities/postAddWish.ts b/apps/university-web/src/apis/universities/postAddWish.ts new file mode 100644 index 00000000..59645505 --- /dev/null +++ b/apps/university-web/src/apis/universities/postAddWish.ts @@ -0,0 +1,21 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import type { AxiosError } from "axios"; +import { QueryKeys } from "../queryKeys"; +import { type AddWishResponse, universitiesApi } from "./api"; + +/** + * @description 위시리스트에 학교를 추가하는 useMutation 커스텀 훅 + */ +const usePostAddWish = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (universityInfoForApplyId) => + universitiesApi.postAddWish({ univApplyInfoId: universityInfoForApplyId }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: [QueryKeys.universities.wishList] }); + }, + }); +}; + +export default usePostAddWish; diff --git a/apps/university-web/src/apis/universities/server/assertUniversitySsgResponse.ts b/apps/university-web/src/apis/universities/server/assertUniversitySsgResponse.ts new file mode 100644 index 00000000..7e861e1e --- /dev/null +++ b/apps/university-web/src/apis/universities/server/assertUniversitySsgResponse.ts @@ -0,0 +1,16 @@ +import type { ServerFetchResult } from "@/utils/serverFetchUtil"; + +const MAX_ERROR_PREVIEW_LENGTH = 300; + +export const assertUniversitySsgResponse = (response: ServerFetchResult, context: string): T => { + if (response.ok) { + return response.data; + } + + const errorPreview = + response.error.length > MAX_ERROR_PREVIEW_LENGTH + ? `${response.error.slice(0, MAX_ERROR_PREVIEW_LENGTH)}...` + : response.error; + + throw new Error(`[university-web SSG] ${context} failed with ${response.status}: ${errorPreview}`); +}; diff --git a/apps/university-web/src/apis/universities/server/getRecommendedUniversity.ts b/apps/university-web/src/apis/universities/server/getRecommendedUniversity.ts new file mode 100644 index 00000000..26372b73 --- /dev/null +++ b/apps/university-web/src/apis/universities/server/getRecommendedUniversity.ts @@ -0,0 +1,17 @@ +import type { ListUniversity } from "@/types/university"; +import serverFetch from "@/utils/serverFetchUtil"; + +type GetRecommendedUniversityResponse = { recommendedUniversities: ListUniversity[] }; + +const getRecommendedUniversity = async () => { + const endpoint = "/univ-apply-infos/recommend"; + + const res = await serverFetch(endpoint); + + if (!res.ok) { + } + + return res; +}; + +export default getRecommendedUniversity; diff --git a/apps/university-web/src/apis/universities/server/getSearchUniversitiesByFilter.ts b/apps/university-web/src/apis/universities/server/getSearchUniversitiesByFilter.ts new file mode 100644 index 00000000..a6b5dee7 --- /dev/null +++ b/apps/university-web/src/apis/universities/server/getSearchUniversitiesByFilter.ts @@ -0,0 +1,50 @@ +import { URLSearchParams } from "node:url"; +import type { CountryCode, LanguageTestType, ListUniversity } from "@/types/university"; +import serverFetch from "@/utils/serverFetchUtil"; +import { assertUniversitySsgResponse } from "./assertUniversitySsgResponse"; + +interface UniversitySearchResponse { + univApplyInfoPreviews: ListUniversity[]; +} + +/** + * 필터 검색에 사용될 파라미터 타입 + */ +export interface UniversitySearchFilterParams { + languageTestType?: LanguageTestType; + testScore?: number; + countryCode?: CountryCode[]; +} + +export const getSearchUniversitiesByFilter = async ( + filters: UniversitySearchFilterParams, +): Promise => { + const params = new URLSearchParams(); + + if (filters.languageTestType) { + params.append("languageTestType", filters.languageTestType); + } + if (filters.testScore !== undefined) { + params.append("testScore", String(filters.testScore)); + } + // countryCode는 여러 개일 수 있으므로 각각 append 해줍니다. + if (filters.countryCode) { + filters.countryCode.forEach((code) => params.append("countryCode", code)); + } + + // 필터 값이 하나도 없으면 빈 배열을 반환합니다. + if (params.size === 0) { + return []; + } + + const endpoint = `/univ-apply-infos/search/filter?${params.toString()}`; + const response = await serverFetch(endpoint); + return assertUniversitySsgResponse(response, `search universities by filter (${params.toString()})`) + .univApplyInfoPreviews; +}; + +export const getSearchUniversitiesAllRegions = async (): Promise => { + const endpoint = `/univ-apply-infos/search/text?value=`; + const response = await serverFetch(endpoint); + return assertUniversitySsgResponse(response, "search all universities").univApplyInfoPreviews; +}; diff --git a/apps/university-web/src/apis/universities/server/getSearchUniversitiesByText.ts b/apps/university-web/src/apis/universities/server/getSearchUniversitiesByText.ts new file mode 100644 index 00000000..45dfa54a --- /dev/null +++ b/apps/university-web/src/apis/universities/server/getSearchUniversitiesByText.ts @@ -0,0 +1,45 @@ +import { type AllRegionsUniversityList, type ListUniversity, RegionEnumExtend } from "@/types/university"; +import serverFetch from "@/utils/serverFetchUtil"; +import { assertUniversitySsgResponse } from "./assertUniversitySsgResponse"; + +// --- 타입 정의 --- +interface UniversitySearchResponse { + univApplyInfoPreviews: ListUniversity[]; +} + +export const getUniversitiesByText = async (value: string): Promise => { + if (value === null || value === undefined) { + return []; + } + const endpoint = `/univ-apply-infos/search/text?value=${encodeURIComponent(value)}`; + const response = await serverFetch(endpoint); + return assertUniversitySsgResponse(response, `search universities by text "${value}"`).univApplyInfoPreviews; +}; + +export const getAllUniversities = async (): Promise => { + return getUniversitiesByText(""); +}; + +export const getCategorizedUniversities = async (): Promise => { + // 1. 단 한 번의 API 호출로 모든 대학 데이터를 가져옵니다. + const allUniversities = await getAllUniversities(); + + const categorizedList: AllRegionsUniversityList = { + [RegionEnumExtend.ALL]: allUniversities, + [RegionEnumExtend.AMERICAS]: [], + [RegionEnumExtend.EUROPE]: [], + [RegionEnumExtend.ASIA]: [], + [RegionEnumExtend.CHINA]: [], + }; + if (!allUniversities) return categorizedList; + + for (const university of allUniversities) { + const region = university.region as RegionEnumExtend; // API 응답의 region 타입을 enum으로 간주 + + if (region && Object.hasOwn(categorizedList, region)) { + categorizedList[region].push(university); + } + } + + return categorizedList; +}; diff --git a/apps/university-web/src/apis/universities/server/getUniversityDetail.ts b/apps/university-web/src/apis/universities/server/getUniversityDetail.ts new file mode 100644 index 00000000..4dbfa715 --- /dev/null +++ b/apps/university-web/src/apis/universities/server/getUniversityDetail.ts @@ -0,0 +1,47 @@ +import type { University } from "@/types/university"; +import serverFetch from "@/utils/serverFetchUtil"; +import { assertUniversitySsgResponse } from "./assertUniversitySsgResponse"; + +type UniversityDetailFetchSuccess = { + ok: true; + status: number; + data: University; +}; + +type UniversityDetailFetchFailure = { + ok: false; + status: number; + error: string; +}; + +export type UniversityDetailFetchResult = UniversityDetailFetchSuccess | UniversityDetailFetchFailure; + +export const getUniversityDetailWithStatus = async ( + universityInfoForApplyId: number, +): Promise => { + const result = await serverFetch(`/univ-apply-infos/${universityInfoForApplyId}`); + + if (!result.ok) { + return { + ok: false, + status: result.status, + error: result.error, + }; + } + + return { + ok: true, + status: result.status, + data: result.data, + }; +}; + +export const getUniversityDetailForSsg = async (universityInfoForApplyId: number): Promise => { + const result = await serverFetch(`/univ-apply-infos/${universityInfoForApplyId}`); + return assertUniversitySsgResponse(result, `get university detail ${universityInfoForApplyId}`); +}; + +export const getUniversityDetail = async (universityInfoForApplyId: number): Promise => { + const result = await getUniversityDetailWithStatus(universityInfoForApplyId); + return result.ok ? result.data : undefined; +}; diff --git a/apps/university-web/src/apis/universities/server/index.ts b/apps/university-web/src/apis/universities/server/index.ts new file mode 100644 index 00000000..f8993606 --- /dev/null +++ b/apps/university-web/src/apis/universities/server/index.ts @@ -0,0 +1,14 @@ +// Server-side exports +export { default as getRecommendedUniversity } from "./getRecommendedUniversity"; +export { + getSearchUniversitiesAllRegions, + getSearchUniversitiesByFilter, + type UniversitySearchFilterParams, +} from "./getSearchUniversitiesByFilter"; +export { getAllUniversities, getCategorizedUniversities, getUniversitiesByText } from "./getSearchUniversitiesByText"; +export { + getUniversityDetail, + getUniversityDetailForSsg, + getUniversityDetailWithStatus, + type UniversityDetailFetchResult, +} from "./getUniversityDetail"; diff --git a/apps/university-web/src/apis/users/api.ts b/apps/university-web/src/apis/users/api.ts new file mode 100644 index 00000000..0fca8aef --- /dev/null +++ b/apps/university-web/src/apis/users/api.ts @@ -0,0 +1,52 @@ +import { axiosInstance } from "@/utils/axiosInstance"; + +export interface NicknameExistsResponse { + exists: boolean; +} + +export type BlockUserResponse = undefined; + +export type BlockUserRequest = Record; + +export type UnblockUserRequest = Record; + +export type UnblockUserResponse = undefined; + +export interface BlockedUsersResponseContentItem { + id: number; + blockedId: number; + nickname: string; + createdAt: string; +} + +export interface BlockedUsersResponse { + content: BlockedUsersResponseContentItem[]; + nextPageNumber: number; +} + +export const usersApi = { + getNicknameExists: async (params: { params?: Record }): Promise => { + const res = await axiosInstance.get(`/users/exists?nickname=abc`, { + params: params?.params, + }); + return res.data; + }, + + postBlockUser: async (params: { + blockedId: string | number; + data?: BlockUserRequest; + }): Promise => { + const res = await axiosInstance.post(`/users/block/${params.blockedId}`, params?.data); + return res.data; + }, + + deleteUnblockUser: async (params: { blockedId: string | number }): Promise => { + const res = await axiosInstance.delete(`/users/block/${params.blockedId}`); + return res.data; + }, + + getBlockedUsers: async (params: { params?: Record }): Promise => { + const res = await axiosInstance.get(`/users/blocks`, { params: params?.params }); + return res.data; + }, +}; diff --git a/apps/university-web/src/apis/users/deleteUnblockUser.ts b/apps/university-web/src/apis/users/deleteUnblockUser.ts new file mode 100644 index 00000000..b46b67db --- /dev/null +++ b/apps/university-web/src/apis/users/deleteUnblockUser.ts @@ -0,0 +1,11 @@ +import { useMutation } from "@tanstack/react-query"; +import type { AxiosError } from "axios"; +import { type UnblockUserRequest, type UnblockUserResponse, usersApi } from "./api"; + +const useDeleteUnblockUser = () => { + return useMutation({ + mutationFn: (variables) => usersApi.deleteUnblockUser(variables), + }); +}; + +export default useDeleteUnblockUser; diff --git a/apps/university-web/src/apis/users/getBlockedUsers.ts b/apps/university-web/src/apis/users/getBlockedUsers.ts new file mode 100644 index 00000000..44bc7b51 --- /dev/null +++ b/apps/university-web/src/apis/users/getBlockedUsers.ts @@ -0,0 +1,13 @@ +import { useQuery } from "@tanstack/react-query"; +import type { AxiosError } from "axios"; +import { QueryKeys } from "../queryKeys"; +import { type BlockedUsersResponse, usersApi } from "./api"; + +const useGetBlockedUsers = (params?: Record) => { + return useQuery({ + queryKey: [QueryKeys.users.blockedUsers, params], + queryFn: () => usersApi.getBlockedUsers(params ? { params } : {}), + }); +}; + +export default useGetBlockedUsers; diff --git a/apps/university-web/src/apis/users/getNicknameExists.ts b/apps/university-web/src/apis/users/getNicknameExists.ts new file mode 100644 index 00000000..86224fd4 --- /dev/null +++ b/apps/university-web/src/apis/users/getNicknameExists.ts @@ -0,0 +1,13 @@ +import { useQuery } from "@tanstack/react-query"; +import type { AxiosError } from "axios"; +import { QueryKeys } from "../queryKeys"; +import { type NicknameExistsResponse, usersApi } from "./api"; + +const useGetNicknameExists = (params?: Record) => { + return useQuery({ + queryKey: [QueryKeys.users.nicknameExists, params], + queryFn: () => usersApi.getNicknameExists(params ? { params } : {}), + }); +}; + +export default useGetNicknameExists; diff --git a/apps/university-web/src/apis/users/index.ts b/apps/university-web/src/apis/users/index.ts new file mode 100644 index 00000000..ff51c891 --- /dev/null +++ b/apps/university-web/src/apis/users/index.ts @@ -0,0 +1,5 @@ +export { usersApi } from "./api"; +export { default as deleteUnblockUser } from "./deleteUnblockUser"; +export { default as getBlockedUsers } from "./getBlockedUsers"; +export { default as getNicknameExists } from "./getNicknameExists"; +export { default as postBlockUser } from "./postBlockUser"; diff --git a/apps/university-web/src/apis/users/postBlockUser.ts b/apps/university-web/src/apis/users/postBlockUser.ts new file mode 100644 index 00000000..1c6fde9d --- /dev/null +++ b/apps/university-web/src/apis/users/postBlockUser.ts @@ -0,0 +1,11 @@ +import { useMutation } from "@tanstack/react-query"; +import type { AxiosError } from "axios"; +import { type BlockUserRequest, type BlockUserResponse, usersApi } from "./api"; + +const usePostBlockUser = () => { + return useMutation({ + mutationFn: (variables) => usersApi.postBlockUser(variables), + }); +}; + +export default usePostBlockUser; diff --git a/apps/university-web/src/app/apple-icon.png b/apps/university-web/src/app/apple-icon.png new file mode 100644 index 00000000..7e0ef844 Binary files /dev/null and b/apps/university-web/src/app/apple-icon.png differ diff --git a/apps/university-web/src/app/favicon.ico b/apps/university-web/src/app/favicon.ico new file mode 100644 index 00000000..ec7d51d1 Binary files /dev/null and b/apps/university-web/src/app/favicon.ico differ diff --git a/apps/university-web/src/app/global-error.tsx b/apps/university-web/src/app/global-error.tsx new file mode 100644 index 00000000..a2db9ba6 --- /dev/null +++ b/apps/university-web/src/app/global-error.tsx @@ -0,0 +1,23 @@ +"use client"; + +import * as Sentry from "@sentry/nextjs"; +import NextError from "next/error"; +import { useEffect } from "react"; + +// Error boundaries must be Client Components + +const GlobalError = ({ error }: { error: Error & { digest?: string } }) => { + useEffect(() => { + Sentry.captureException(error); + }, [error]); + + return ( + + + + + + ); +}; + +export default GlobalError; diff --git a/apps/university-web/src/app/icon.png b/apps/university-web/src/app/icon.png new file mode 100644 index 00000000..7e0ef844 Binary files /dev/null and b/apps/university-web/src/app/icon.png differ diff --git a/apps/university-web/src/app/layout.tsx b/apps/university-web/src/app/layout.tsx new file mode 100644 index 00000000..33b8d0e2 --- /dev/null +++ b/apps/university-web/src/app/layout.tsx @@ -0,0 +1,92 @@ +import type { Metadata, Viewport } from "next"; +import localFont from "next/font/local"; +import type { ReactNode } from "react"; +import { Toaster } from "react-hot-toast"; + +import GlobalLayout from "@/components/layout/GlobalLayout"; +import ReissueProvider from "@/components/layout/ReissueProvider"; +import QueryProvider from "@/lib/react-query/QueryProvider"; +import AppleScriptLoader from "@/lib/ScriptLoader/AppleScriptLoader"; +import "@/styles/globals.css"; +import { GoogleAnalytics } from "@next/third-parties/google"; +import { SpeedInsights } from "@vercel/speed-insights/next"; + +const siteUrl = process.env.NEXT_PUBLIC_WEB_URL || "https://solid-connection.com"; + +export const metadata: Metadata = { + metadataBase: new URL(siteUrl), + title: "솔리드 커넥션", + description: "솔리드 커넥션. 교환학생의 첫 걸음", + verification: { + other: { + "naver-site-verification": "dd46eae7f62548ac6d6fc34647df8e2bea591e62", + }, + }, +}; + +const pretendard = localFont({ + src: "../../public/fonts/PretendardVariable.woff2", + display: "swap", + weight: "45 920", + variable: "--font-pretendard", + preload: true, + fallback: [ + "system-ui", + "-apple-system", + "BlinkMacSystemFont", + "Apple SD Gothic Neo", + "Malgun Gothic", + "맑은 고딕", + "sans-serif", + ], +}); + +declare global { + interface Window { + Kakao: { + init: (key: string) => void; + Auth: { + authorize: (options: { redirectUri: string }) => void; + }; + }; + AppleID: { + auth: { + init: (config: object) => void; + signIn: () => Promise; + }; + }; + } +} + +export const viewport: Viewport = { + width: "device-width", + initialScale: 1, + maximumScale: 5, + userScalable: true, + viewportFit: "cover", // iOS Safe Area support +}; + +const RootLayout = ({ children }: { children: ReactNode }) => ( + + + + + + + + {children} + + + + + +); + +export default RootLayout; diff --git a/apps/university-web/src/app/not-found.tsx b/apps/university-web/src/app/not-found.tsx new file mode 100644 index 00000000..8e396ff0 --- /dev/null +++ b/apps/university-web/src/app/not-found.tsx @@ -0,0 +1,14 @@ +import { IconNotFound } from "@/public/svgs/loading"; + +const NotFoundPage = () => { + return ( +
+ +
+ {"존재하지 않는 페이지입니다"} +
+
+ ); +}; + +export default NotFoundPage; diff --git a/apps/university-web/src/app/opengraph-image.png b/apps/university-web/src/app/opengraph-image.png new file mode 100644 index 00000000..ad66adf9 Binary files /dev/null and b/apps/university-web/src/app/opengraph-image.png differ diff --git a/apps/university-web/src/app/robots.ts b/apps/university-web/src/app/robots.ts new file mode 100644 index 00000000..a4c63744 --- /dev/null +++ b/apps/university-web/src/app/robots.ts @@ -0,0 +1,34 @@ +import type { MetadataRoute } from "next"; + +const DEFAULT_SITE_URL = "https://www.solid-connection.com"; + +const getSiteUrl = () => (process.env.NEXT_PUBLIC_WEB_URL ?? DEFAULT_SITE_URL).replace(/\/$/, ""); + +export default function robots(): MetadataRoute.Robots { + const siteUrl = getSiteUrl(); + const vercelEnv = process.env.VERCEL_ENV; + const isNonIndexEnvironment = + vercelEnv === "preview" || + vercelEnv === "development" || + siteUrl.includes("stage") || + siteUrl.includes("localhost") || + siteUrl.includes("127.0.0.1"); + + if (isNonIndexEnvironment) { + return { + rules: { + userAgent: "*", + disallow: "/", + }, + }; + } + + return { + rules: { + userAgent: "*", + allow: "/", + }, + sitemap: `${siteUrl}/sitemap.xml`, + host: new URL(siteUrl).host, + }; +} diff --git a/apps/university-web/src/app/sitemap.ts b/apps/university-web/src/app/sitemap.ts new file mode 100644 index 00000000..b3f1231b --- /dev/null +++ b/apps/university-web/src/app/sitemap.ts @@ -0,0 +1,38 @@ +import type { MetadataRoute } from "next"; + +import { HOME_UNIVERSITY_SLUGS } from "@/constants/university"; + +const DEFAULT_SITE_URL = "https://www.solid-connection.com"; + +const getSiteUrl = () => (process.env.NEXT_PUBLIC_WEB_URL ?? DEFAULT_SITE_URL).replace(/\/$/, ""); + +export default function sitemap(): MetadataRoute.Sitemap { + const siteUrl = getSiteUrl(); + + if (siteUrl.includes("stage") || siteUrl.includes("localhost") || siteUrl.includes("127.0.0.1")) { + return []; + } + + const lastModified = new Date(); + + return [ + { + url: `${siteUrl}/university`, + lastModified, + changeFrequency: "weekly", + priority: 0.8, + }, + { + url: `${siteUrl}/university/search`, + lastModified, + changeFrequency: "weekly", + priority: 0.6, + }, + ...HOME_UNIVERSITY_SLUGS.map((slug) => ({ + url: `${siteUrl}/university/${slug}`, + lastModified, + changeFrequency: "weekly" as const, + priority: 0.7, + })), + ]; +} diff --git a/apps/web/src/app/university/(home)/_ui/HomeUniversityCard.tsx b/apps/university-web/src/app/university/(home)/_ui/HomeUniversityCard.tsx similarity index 100% rename from apps/web/src/app/university/(home)/_ui/HomeUniversityCard.tsx rename to apps/university-web/src/app/university/(home)/_ui/HomeUniversityCard.tsx diff --git a/apps/web/src/app/university/(home)/layout.tsx b/apps/university-web/src/app/university/(home)/layout.tsx similarity index 100% rename from apps/web/src/app/university/(home)/layout.tsx rename to apps/university-web/src/app/university/(home)/layout.tsx diff --git a/apps/web/src/app/university/(home)/page.tsx b/apps/university-web/src/app/university/(home)/page.tsx similarity index 96% rename from apps/web/src/app/university/(home)/page.tsx rename to apps/university-web/src/app/university/(home)/page.tsx index 9e37d4d4..d94a2cc9 100644 --- a/apps/web/src/app/university/(home)/page.tsx +++ b/apps/university-web/src/app/university/(home)/page.tsx @@ -5,7 +5,7 @@ import { createUrl } from "@/utils/seo"; import HomeUniversityCard from "./_ui/HomeUniversityCard"; -export const revalidate = 3600; // 1시간마다 재검증 (ISR) +export const revalidate = false; export const metadata: Metadata = { title: "대학 선택 | 솔리드커넥션", diff --git a/apps/web/src/app/university/[homeUniversity]/[id]/_ui/UniversityDetail/_ui/InfoSection.tsx b/apps/university-web/src/app/university/[homeUniversity]/[id]/_ui/UniversityDetail/_ui/InfoSection.tsx similarity index 100% rename from apps/web/src/app/university/[homeUniversity]/[id]/_ui/UniversityDetail/_ui/InfoSection.tsx rename to apps/university-web/src/app/university/[homeUniversity]/[id]/_ui/UniversityDetail/_ui/InfoSection.tsx diff --git a/apps/web/src/app/university/[homeUniversity]/[id]/_ui/UniversityDetail/_ui/LanguageSection.tsx b/apps/university-web/src/app/university/[homeUniversity]/[id]/_ui/UniversityDetail/_ui/LanguageSection.tsx similarity index 100% rename from apps/web/src/app/university/[homeUniversity]/[id]/_ui/UniversityDetail/_ui/LanguageSection.tsx rename to apps/university-web/src/app/university/[homeUniversity]/[id]/_ui/UniversityDetail/_ui/LanguageSection.tsx diff --git a/apps/web/src/app/university/[homeUniversity]/[id]/_ui/UniversityDetail/_ui/MapSection.tsx b/apps/university-web/src/app/university/[homeUniversity]/[id]/_ui/UniversityDetail/_ui/MapSection.tsx similarity index 100% rename from apps/web/src/app/university/[homeUniversity]/[id]/_ui/UniversityDetail/_ui/MapSection.tsx rename to apps/university-web/src/app/university/[homeUniversity]/[id]/_ui/UniversityDetail/_ui/MapSection.tsx diff --git a/apps/web/src/app/university/[homeUniversity]/[id]/_ui/UniversityDetail/_ui/TitleSection.tsx b/apps/university-web/src/app/university/[homeUniversity]/[id]/_ui/UniversityDetail/_ui/TitleSection.tsx similarity index 100% rename from apps/web/src/app/university/[homeUniversity]/[id]/_ui/UniversityDetail/_ui/TitleSection.tsx rename to apps/university-web/src/app/university/[homeUniversity]/[id]/_ui/UniversityDetail/_ui/TitleSection.tsx diff --git a/apps/web/src/app/university/[homeUniversity]/[id]/_ui/UniversityDetail/_ui/UniversityBtns.tsx b/apps/university-web/src/app/university/[homeUniversity]/[id]/_ui/UniversityDetail/_ui/UniversityBtns.tsx similarity index 100% rename from apps/web/src/app/university/[homeUniversity]/[id]/_ui/UniversityDetail/_ui/UniversityBtns.tsx rename to apps/university-web/src/app/university/[homeUniversity]/[id]/_ui/UniversityDetail/_ui/UniversityBtns.tsx diff --git a/apps/web/src/app/university/[homeUniversity]/[id]/_ui/UniversityDetail/index.tsx b/apps/university-web/src/app/university/[homeUniversity]/[id]/_ui/UniversityDetail/index.tsx similarity index 100% rename from apps/web/src/app/university/[homeUniversity]/[id]/_ui/UniversityDetail/index.tsx rename to apps/university-web/src/app/university/[homeUniversity]/[id]/_ui/UniversityDetail/index.tsx diff --git a/apps/web/src/app/university/[homeUniversity]/[id]/_ui/UniversityDetailPreparingFallback.tsx b/apps/university-web/src/app/university/[homeUniversity]/[id]/_ui/UniversityDetailPreparingFallback.tsx similarity index 100% rename from apps/web/src/app/university/[homeUniversity]/[id]/_ui/UniversityDetailPreparingFallback.tsx rename to apps/university-web/src/app/university/[homeUniversity]/[id]/_ui/UniversityDetailPreparingFallback.tsx diff --git a/apps/web/src/app/university/[homeUniversity]/[id]/page.tsx b/apps/university-web/src/app/university/[homeUniversity]/[id]/page.tsx similarity index 80% rename from apps/web/src/app/university/[homeUniversity]/[id]/page.tsx rename to apps/university-web/src/app/university/[homeUniversity]/[id]/page.tsx index 9734afd1..7668f157 100644 --- a/apps/web/src/app/university/[homeUniversity]/[id]/page.tsx +++ b/apps/university-web/src/app/university/[homeUniversity]/[id]/page.tsx @@ -1,17 +1,19 @@ import type { Metadata } from "next"; import { notFound } from "next/navigation"; -import { getAllUniversities, getUniversityDetail, getUniversityDetailWithStatus } from "@/apis/universities/server"; +import { getAllUniversities, getUniversityDetailWithStatus } from "@/apis/universities/server"; import TopDetailNavigation from "@/components/layout/TopDetailNavigation"; import { getHomeUniversityBySlug, HOME_UNIVERSITY_SLUGS, isMatchedHomeUniversityName } from "@/constants/university"; import type { HomeUniversitySlug } from "@/types/university"; -import { createAbsoluteUrl, createUrl, NO_INDEX_ROBOTS } from "@/utils/seo"; +import { normalizeImageUrlToUploadCdn } from "@/utils/cdnUrl"; +import { createUrl, NO_INDEX_ROBOTS } from "@/utils/seo"; // UniversityDetail 컴포넌트 import UniversityDetail from "./_ui/UniversityDetail"; import UniversityDetailPreparingFallback from "./_ui/UniversityDetailPreparingFallback"; export const revalidate = false; // 완전 정적 생성 +export const dynamicParams = false; // 모든 homeUniversity + id 조합에 대해 정적 경로 생성 export async function generateStaticParams() { @@ -44,28 +46,56 @@ type PageProps = { params: Promise<{ homeUniversity: string; id: string }>; }; +const resolveMetadataImageUrl = (backgroundImageUrl: string | null | undefined) => { + const normalizedImageUrl = normalizeImageUrlToUploadCdn(backgroundImageUrl); + + if (!normalizedImageUrl) { + return createUrl("/images/article-thumb.png"); + } + + if (normalizedImageUrl.startsWith("http")) { + return normalizedImageUrl; + } + + return createUrl(normalizedImageUrl); +}; + export async function generateMetadata({ params }: PageProps): Promise { const { homeUniversity, id } = await params; // 유효한 슬러그인지 확인 if (!HOME_UNIVERSITY_SLUGS.includes(homeUniversity as HomeUniversitySlug)) { - return { title: "파견 학교 상세" }; + return { + title: "파견 학교 상세", + robots: NO_INDEX_ROBOTS, + }; } - const universityData = await getUniversityDetail(Number(id)); + const universityId = Number(id); - if (!universityData) { + if (Number.isNaN(universityId)) { return { title: "파견 학교 상세", robots: NO_INDEX_ROBOTS, }; } + const universityDetailResult = await getUniversityDetailWithStatus(universityId); + + if (!universityDetailResult.ok) { + return { + title: "파견 학교 상세", + robots: NO_INDEX_ROBOTS, + }; + } + + const universityData = universityDetailResult.data; + const homeUniversityInfo = getHomeUniversityBySlug(homeUniversity); const convertedKoreanName = universityData.koreanName; const pageUrl = createUrl(`/university/${homeUniversity}/${id}`); - const imageUrl = createAbsoluteUrl(universityData.backgroundImageUrl, "/images/article-thumb.png"); + const imageUrl = resolveMetadataImageUrl(universityData.backgroundImageUrl); const countryExchangeKeyword = `${universityData.country} 교환학생`; const description = `${convertedKoreanName}(${universityData.englishName}) ${countryExchangeKeyword} 프로그램. 모집인원 ${universityData.studentCapacity}명. ${homeUniversityInfo?.shortName || ""} 학생을 위한 교환학생 정보.`; @@ -149,7 +179,7 @@ const CollegeDetailPage = async ({ params }: PageProps) => { alternateName: universityData.englishName, url: pageUrl, description: `${convertedKoreanName}(${universityData.englishName}) ${countryExchangeKeyword} 프로그램 정보`, - image: createAbsoluteUrl(universityData.backgroundImageUrl, "/images/article-thumb.png"), + image: resolveMetadataImageUrl(universityData.backgroundImageUrl), }; return ( diff --git a/apps/university-web/src/app/university/[homeUniversity]/_ui/RegionFilter.tsx b/apps/university-web/src/app/university/[homeUniversity]/_ui/RegionFilter.tsx new file mode 100644 index 00000000..8f524966 --- /dev/null +++ b/apps/university-web/src/app/university/[homeUniversity]/_ui/RegionFilter.tsx @@ -0,0 +1,46 @@ +"use client"; + +import clsx from "clsx"; + +import { RegionEnumExtend } from "@/types/university"; + +const REGIONS = [ + { value: RegionEnumExtend.ALL, label: "전체" }, + { value: RegionEnumExtend.AMERICAS, label: "미주권" }, + { value: RegionEnumExtend.EUROPE, label: "유럽권" }, + { value: RegionEnumExtend.ASIA, label: "아시아권" }, + { value: RegionEnumExtend.CHINA, label: "중국권" }, +] as const; + +interface RegionFilterProps { + selectedRegions: RegionEnumExtend[]; + onRegionChange: (region: RegionEnumExtend) => void; +} + +const RegionFilter = ({ selectedRegions, onRegionChange }: RegionFilterProps) => { + return ( +
+ {REGIONS.map(({ value, label }) => { + const isSelected = selectedRegions.includes(value); + + return ( + + ); + })} +
+ ); +}; + +export default RegionFilter; diff --git a/apps/web/src/app/university/[homeUniversity]/_ui/SearchBar.tsx b/apps/university-web/src/app/university/[homeUniversity]/_ui/SearchBar.tsx similarity index 100% rename from apps/web/src/app/university/[homeUniversity]/_ui/SearchBar.tsx rename to apps/university-web/src/app/university/[homeUniversity]/_ui/SearchBar.tsx diff --git a/apps/university-web/src/app/university/[homeUniversity]/_ui/UniversityListContent.tsx b/apps/university-web/src/app/university/[homeUniversity]/_ui/UniversityListContent.tsx new file mode 100644 index 00000000..87976a49 --- /dev/null +++ b/apps/university-web/src/app/university/[homeUniversity]/_ui/UniversityListContent.tsx @@ -0,0 +1,193 @@ +"use client"; + +import Link from "next/link"; +import { useSearchParams } from "next/navigation"; +import { Suspense, useEffect, useMemo, useState } from "react"; + +import FloatingUpBtn from "@/components/ui/FloatingUpBtn"; +import UniversityCards from "@/components/university/UniversityCards"; +import { COUNTRY_CODE_MAP } from "@/constants/university"; +import { IconSearch } from "@/public/svgs/search"; +import { + type CountryCode, + type HomeUniversitySlug, + LanguageTestType, + type ListUniversity, + RegionEnumExtend, +} from "@/types/university"; + +import RegionFilter from "./RegionFilter"; +import SearchBar from "./SearchBar"; + +interface UniversityListContentProps { + universities: ListUniversity[]; + homeUniversitySlug: HomeUniversitySlug; +} + +const isCountryCode = (value: string): value is CountryCode => Object.hasOwn(COUNTRY_CODE_MAP, value); + +const isRegionFilterValue = (value: string): value is RegionEnumExtend => + value === RegionEnumExtend.ALL || + value === RegionEnumExtend.AMERICAS || + value === RegionEnumExtend.EUROPE || + value === RegionEnumExtend.ASIA || + value === RegionEnumExtend.CHINA; + +const isLanguageTestType = (value: string | null): value is LanguageTestType => + value !== null && Object.values(LanguageTestType).includes(value as LanguageTestType); + +const REGION_FILTER_ITEMS = [ + { value: RegionEnumExtend.ALL, label: "전체" }, + { value: RegionEnumExtend.AMERICAS, label: "미주권" }, + { value: RegionEnumExtend.EUROPE, label: "유럽권" }, + { value: RegionEnumExtend.ASIA, label: "아시아권" }, + { value: RegionEnumExtend.CHINA, label: "중국권" }, +] as const; + +const UniversityListPrerenderFallback = ({ universities, homeUniversitySlug }: UniversityListContentProps) => ( +
+
+
+ + 학교명 또는 국가 검색 +
+ + + 조건 검색 + + + + +
+ +
+ {REGION_FILTER_ITEMS.map(({ value, label }) => ( + + {label} + + ))} +
+ +
+ 총 {universities.length}개 학교 +
+ + {universities.length === 0 ? ( +
+

검색 결과가 없습니다.

+

다른 검색어나 필터를 시도해보세요.

+
+ ) : ( + + )} +
+); + +const UniversityListContentInner = ({ universities, homeUniversitySlug }: UniversityListContentProps) => { + const searchParams = useSearchParams(); + const querySearchText = searchParams.get("searchText") ?? ""; + const languageTestTypeParam = searchParams.get("languageTestType"); + const queryLanguageTestType = isLanguageTestType(languageTestTypeParam) ? languageTestTypeParam : null; + const queryCountryCodesKey = searchParams.getAll("countryCode").filter(isCountryCode).join(","); + const queryRegionsKey = searchParams.getAll("region").filter(isRegionFilterValue).join(","); + const queryRegions = useMemo(() => { + const regions = queryRegionsKey.split(",").filter(isRegionFilterValue); + return regions.length > 0 ? regions : [RegionEnumExtend.ALL]; + }, [queryRegionsKey]); + + const [searchText, setSearchText] = useState(querySearchText.trim()); + const [selectedRegions, setSelectedRegions] = useState(queryRegions); + + useEffect(() => { + setSearchText(querySearchText.trim()); + }, [querySearchText]); + + useEffect(() => { + setSelectedRegions(queryRegions); + }, [queryRegions]); + + // 검색어 및 지역 필터링 + const filteredUniversities = useMemo(() => { + let result = universities; + const queryCountryCodes = queryCountryCodesKey.split(",").filter(Boolean).filter(isCountryCode); + + // 검색어 필터링 + if (searchText.trim()) { + const searchLower = searchText.toLowerCase().trim(); + result = result.filter( + (uni) => uni.koreanName.toLowerCase().includes(searchLower) || uni.country.toLowerCase().includes(searchLower), + ); + } + + // 지역 필터링 + if (!selectedRegions.includes(RegionEnumExtend.ALL)) { + result = result.filter((uni) => selectedRegions.some((region) => uni.region === region)); + } + + if (queryLanguageTestType) { + result = result.filter((uni) => + uni.languageRequirements.some((requirement) => requirement.languageTestType === queryLanguageTestType), + ); + } + + if (queryCountryCodes.length > 0) { + const countryNames = queryCountryCodes.map((countryCode) => COUNTRY_CODE_MAP[countryCode]); + result = result.filter((uni) => countryNames.includes(uni.country)); + } + + return result; + }, [universities, searchText, selectedRegions, queryLanguageTestType, queryCountryCodesKey]); + + return ( +
+ + + setSelectedRegions([region])} /> + + {/* 결과 카운트 */} +
+ 총 {filteredUniversities.length}개 학교 +
+ + {/* 결과 표시 */} + {filteredUniversities.length === 0 ? ( +
+

검색 결과가 없습니다.

+

다른 검색어나 필터를 시도해보세요.

+
+ ) : ( + + )} + + +
+ ); +}; + +const UniversityListContent = (props: UniversityListContentProps) => ( + }> + + +); + +export default UniversityListContent; diff --git a/apps/university-web/src/app/university/[homeUniversity]/page.tsx b/apps/university-web/src/app/university/[homeUniversity]/page.tsx new file mode 100644 index 00000000..324f7ed3 --- /dev/null +++ b/apps/university-web/src/app/university/[homeUniversity]/page.tsx @@ -0,0 +1,71 @@ +import type { Metadata } from "next"; +import { notFound } from "next/navigation"; + +import { getSearchUniversitiesAllRegions } from "@/apis/universities/server"; +import TopDetailNavigation from "@/components/layout/TopDetailNavigation"; +import { getHomeUniversityBySlug, HOME_UNIVERSITY_SLUGS, isMatchedHomeUniversityName } from "@/constants/university"; +import type { HomeUniversitySlug } from "@/types/university"; + +import UniversityListContent from "./_ui/UniversityListContent"; + +export const revalidate = false; +export const dynamicParams = false; + +// 정적 경로 생성 +export async function generateStaticParams() { + return HOME_UNIVERSITY_SLUGS.map((slug) => ({ + homeUniversity: slug, + })); +} + +type PageProps = { + params: Promise<{ homeUniversity: string }>; +}; + +export async function generateMetadata({ params }: PageProps): Promise { + const { homeUniversity } = await params; + const universityInfo = getHomeUniversityBySlug(homeUniversity); + + if (!universityInfo) { + return { + title: "파견 학교 목록", + }; + } + + return { + title: `${universityInfo.shortName} 교환학생 파견 학교 목록 | 솔리드커넥션`, + description: `${universityInfo.name} 학생들을 위한 교환학생 파견 학교 정보를 확인하세요.`, + }; +} + +const UniversityListPage = async ({ params }: PageProps) => { + const { homeUniversity } = await params; + + // 유효한 슬러그인지 확인 + if (!HOME_UNIVERSITY_SLUGS.includes(homeUniversity as HomeUniversitySlug)) { + notFound(); + } + + const homeUniversitySlug = homeUniversity as HomeUniversitySlug; + const universityInfo = getHomeUniversityBySlug(homeUniversitySlug); + + if (!universityInfo) { + notFound(); + } + + const allUniversities = await getSearchUniversitiesAllRegions(); + + // homeUniversityName으로 프론트에서 필터링 + const filteredUniversities = allUniversities.filter((university) => + isMatchedHomeUniversityName(university.homeUniversityName, universityInfo.name), + ); + + return ( + <> + + + + ); +}; + +export default UniversityListPage; diff --git a/apps/web/src/app/university/[homeUniversity]/search/_ui/SearchPageContent.tsx b/apps/university-web/src/app/university/[homeUniversity]/search/_ui/SearchPageContent.tsx similarity index 100% rename from apps/web/src/app/university/[homeUniversity]/search/_ui/SearchPageContent.tsx rename to apps/university-web/src/app/university/[homeUniversity]/search/_ui/SearchPageContent.tsx diff --git a/apps/web/src/app/university/[homeUniversity]/search/page.tsx b/apps/university-web/src/app/university/[homeUniversity]/search/page.tsx similarity index 96% rename from apps/web/src/app/university/[homeUniversity]/search/page.tsx rename to apps/university-web/src/app/university/[homeUniversity]/search/page.tsx index a5931948..2d31245f 100644 --- a/apps/web/src/app/university/[homeUniversity]/search/page.tsx +++ b/apps/university-web/src/app/university/[homeUniversity]/search/page.tsx @@ -8,7 +8,8 @@ import { NO_INDEX_ROBOTS } from "@/utils/seo"; import SearchPageContent from "./_ui/SearchPageContent"; -export const revalidate = 3600; // ISR +export const revalidate = false; +export const dynamicParams = false; // 정적 경로 생성 export async function generateStaticParams() { diff --git a/apps/web/src/app/university/search/PageContent.tsx b/apps/university-web/src/app/university/search/PageContent.tsx similarity index 100% rename from apps/web/src/app/university/search/PageContent.tsx rename to apps/university-web/src/app/university/search/PageContent.tsx diff --git a/apps/web/src/app/university/search/SearchBar.tsx b/apps/university-web/src/app/university/search/SearchBar.tsx similarity index 100% rename from apps/web/src/app/university/search/SearchBar.tsx rename to apps/university-web/src/app/university/search/SearchBar.tsx diff --git a/apps/web/src/app/university/search/SearchClientContent.tsx b/apps/university-web/src/app/university/search/SearchClientContent.tsx similarity index 100% rename from apps/web/src/app/university/search/SearchClientContent.tsx rename to apps/university-web/src/app/university/search/SearchClientContent.tsx diff --git a/apps/web/src/app/university/search/page.tsx b/apps/university-web/src/app/university/search/page.tsx similarity index 95% rename from apps/web/src/app/university/search/page.tsx rename to apps/university-web/src/app/university/search/page.tsx index a097fb88..fb439c1d 100644 --- a/apps/web/src/app/university/search/page.tsx +++ b/apps/university-web/src/app/university/search/page.tsx @@ -4,6 +4,8 @@ import TopDetailNavigation from "@/components/layout/TopDetailNavigation"; import { NO_INDEX_ROBOTS } from "@/utils/seo"; import SearchClientContent from "./SearchClientContent"; +export const revalidate = false; + export const metadata: Metadata = { title: "파견 학교 목록", robots: NO_INDEX_ROBOTS, diff --git a/apps/university-web/src/components/DevToolsErrorSuppressor.tsx b/apps/university-web/src/components/DevToolsErrorSuppressor.tsx new file mode 100644 index 00000000..e69de29b diff --git a/apps/university-web/src/components/button/BlockBtn.tsx b/apps/university-web/src/components/button/BlockBtn.tsx new file mode 100644 index 00000000..8f951824 --- /dev/null +++ b/apps/university-web/src/components/button/BlockBtn.tsx @@ -0,0 +1,40 @@ +import { cva, type VariantProps } from "class-variance-authority"; +import * as React from "react"; + +import { cn } from "@/lib/utils"; + +const blockBtnVariants = cva("h-13 w-full min-w-80 max-w-screen-sm rounded-lg flex items-center justify-center", { + variants: { + variant: { + default: "bg-primary hover:bg-primary/90 disabled:bg-k-100", + secondary: "bg-secondary hover:bg-secondary/90 disabled:bg-k-100", + }, + text: { + default: "typo-medium-1 text-white", + }, + }, + defaultVariants: { + variant: "default", + text: "default", + }, +}); + +export interface BlockBtnProps + extends React.ButtonHTMLAttributes, + VariantProps { + onClick: () => void; + children: React.ReactNode; +} + +const BlockBtn = React.forwardRef( + ({ className, variant, text, children, ...props }, ref) => { + return ( + + ); + }, +); +BlockBtn.displayName = "BlockBtn"; + +export default BlockBtn; diff --git a/apps/university-web/src/components/button/BlockToggleBtn.tsx b/apps/university-web/src/components/button/BlockToggleBtn.tsx new file mode 100644 index 00000000..503421b0 --- /dev/null +++ b/apps/university-web/src/components/button/BlockToggleBtn.tsx @@ -0,0 +1,47 @@ +type BlockToggleBtnProps = { + children: React.ReactNode; + className?: string; + style?: React.CSSProperties; + backgroundColor?: string; + textColor?: string; + disableBackgroundColor?: string; + disableTextColor?: string; + onClick?: () => void; + isToggled?: boolean; +}; + +const BlockToggleBtn = ({ + children, + className = "", + style, + backgroundColor, + textColor, + disableBackgroundColor, + disableTextColor, + onClick, + isToggled = true, +}: BlockToggleBtnProps) => { + // CSS 변수 대신 Tailwind 클래스 사용 + const baseClasses = + "w-full h-11 border-none rounded-lg cursor-pointer transition-all duration-500 typo-medium-1 font-serif"; + const enabledClasses = isToggled ? "bg-primary text-white" : "bg-gray-200 text-gray-500"; + + const customStyles = { + backgroundColor: isToggled ? backgroundColor : disableBackgroundColor, + color: isToggled ? textColor : disableTextColor, + ...style, + }; + + return ( + + ); +}; + +export default BlockToggleBtn; diff --git a/apps/university-web/src/components/button/RoundBtn.tsx b/apps/university-web/src/components/button/RoundBtn.tsx new file mode 100644 index 00000000..356bde98 --- /dev/null +++ b/apps/university-web/src/components/button/RoundBtn.tsx @@ -0,0 +1,42 @@ +import { cva, type VariantProps } from "class-variance-authority"; +import * as React from "react"; + +import { cn } from "@/lib/utils"; + +const roundBtnVariants = cva("h-[2.375rem] w-[6.375rem] rounded-3xl px-4 py-2.5 ", { + variants: { + variant: { + default: "bg-primary hover:bg-primary/90 text-k-0", + secondary: "bg-secondary hover:bg-secondary/90 text-k-0", + "secondary-400": "bg-secondary-400 hover:bg-secondary-400/90 text-k-0", + inactive: "bg-k-100 hover:bg-k-100/90 text-k-0", + }, + text: { + default: "typo-bold-6", + }, + }, + defaultVariants: { + variant: "default", + text: "default", + }, +}); + +export interface RoundBtnProps + extends React.ButtonHTMLAttributes, + VariantProps { + onClick?: () => void; + children: React.ReactNode; +} + +const RoundBtn = React.forwardRef( + ({ className, variant, text, children, ...props }, ref) => { + return ( + + ); + }, +); +RoundBtn.displayName = "RoundBtn"; + +export default RoundBtn; diff --git a/apps/university-web/src/components/home/NewsCards.tsx b/apps/university-web/src/components/home/NewsCards.tsx new file mode 100644 index 00000000..85494c0a --- /dev/null +++ b/apps/university-web/src/components/home/NewsCards.tsx @@ -0,0 +1,34 @@ +import Image from "@/components/ui/FallbackImage"; + +import type { News } from "@/types/news"; + +type NewsCardsProps = { + newsList: News[]; +}; + +const NewsCards = ({ newsList }: NewsCardsProps) => { + return ( +
+ ); +}; + +export default NewsCards; diff --git a/apps/university-web/src/components/layout/AuthInitializer.tsx b/apps/university-web/src/components/layout/AuthInitializer.tsx new file mode 100644 index 00000000..e69de29b diff --git a/apps/university-web/src/components/layout/GlobalLayout/index.tsx b/apps/university-web/src/components/layout/GlobalLayout/index.tsx new file mode 100644 index 00000000..aee5bc8a --- /dev/null +++ b/apps/university-web/src/components/layout/GlobalLayout/index.tsx @@ -0,0 +1,27 @@ +import type { ReactNode } from "react"; + +import AIInspectorFab from "./ui/AIInspectorFab/index"; +import BottomNavigation from "./ui/BottomNavigation"; +import ClientModal from "./ui/ClientModal"; + +// import ServerModal from "./ui/ServerModal"; + +// const BottomNavigationDynamic = dynamic(() => import("./ui/BottomNavigation"), { ssr: false, loading: () => null }); + +type LayoutProps = { + children: ReactNode; +}; + +const GlobalLayout = ({ children }: LayoutProps) => { + return ( +
+ {children} + + + + {/* */} +
+ ); +}; + +export default GlobalLayout; diff --git a/apps/university-web/src/components/layout/GlobalLayout/ui/AIInspectorFab/index.tsx b/apps/university-web/src/components/layout/GlobalLayout/ui/AIInspectorFab/index.tsx new file mode 100644 index 00000000..c5a57db1 --- /dev/null +++ b/apps/university-web/src/components/layout/GlobalLayout/ui/AIInspectorFab/index.tsx @@ -0,0 +1,204 @@ +"use client"; + +import { + AiInspectorRequestError, + createAiInspectorRequest, + useAiInspectorSelection, +} from "@solid-connect/ai-inspector"; +import { Bot, Target, X } from "lucide-react"; +import { useState } from "react"; +import { showIconToast } from "@/lib/toast/showIconToast"; +import useAuthStore from "@/lib/zustand/useAuthStore"; +import { UserRole } from "@/types/mentor"; + +const AIInspectorFab = () => { + const { serverRole, clientRole, setClientRole, isInitialized, accessToken } = useAuthStore(); + const isAdmin = isInitialized && serverRole === UserRole.ADMIN; + + const { isInspecting, setIsInspecting, hoverRect, selection, clearSelection, resetInspector } = + useAiInspectorSelection({ isEnabled: isAdmin }); + const [instruction, setInstruction] = useState(""); + const [isSaving, setIsSaving] = useState(false); + const [isMenuOpen, setIsMenuOpen] = useState(false); + + if (!isAdmin) { + return null; + } + + const resetForm = () => { + setInstruction(""); + resetInspector(); + }; + + const handleToggleInspector = () => { + setIsInspecting((prev) => !prev); + clearSelection(); + setInstruction(""); + }; + + const handleSwitchToMentorView = () => { + setClientRole(UserRole.MENTOR); + showIconToast("logo", "멘토 UI 보기로 전환되었습니다."); + }; + + const handleSwitchToMenteeView = () => { + setClientRole(UserRole.MENTEE); + showIconToast("logo", "멘티 UI 보기로 전환되었습니다."); + }; + + const handleSave = async () => { + if (!selection) { + showIconToast("logo", "먼저 수정할 요소를 선택해주세요."); + return; + } + + if (!instruction.trim()) { + showIconToast("logo", "수정 요청 문구를 입력해주세요."); + return; + } + + if (!accessToken) { + showIconToast("logo", "로그인 세션이 만료되었습니다. 다시 로그인해주세요."); + return; + } + + setIsSaving(true); + try { + const result = await createAiInspectorRequest({ + accessToken, + payload: { + instruction: instruction.trim(), + pageUrl: window.location.href, + selection, + }, + }); + + showIconToast("logo", `요청이 저장되었습니다. (${result.taskId.slice(0, 8)})`); + resetForm(); + } catch (error) { + if (error instanceof AiInspectorRequestError) { + showIconToast("logo", error.message); + } else { + showIconToast("logo", "요청 저장에 실패했습니다. 잠시 후 다시 시도해주세요."); + } + } finally { + setIsSaving(false); + } + }; + + return ( + <> + {isInspecting && hoverRect && ( +
+ )} + +
+ {selection && ( +
+
+

AI 인스펙터 요청

+ +
+ +
+
selector: {selection.selector}
+
tag: {selection.tagName}
+ {selection.textSnippet &&
text: {selection.textSnippet}
} +
+ +