diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 1a9d79f..02dc7ae 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -16,7 +16,8 @@ jobs: - name: Node 설치 uses: actions/setup-node@v3 with: - node-version: 18 + # Vite 7은 Node 20.19+ 를 요구합니다. + node-version: 20 - name: 의존성 설치 run: npm install @@ -26,6 +27,8 @@ jobs: env: VITE_SERVER_URL: ${{ secrets.VITE_SERVER_URL }} VITE_KAKAO_API: ${{ secrets.VITE_KAKAO_API }} + # canonical / og:url / sitemap에 쓰이는 배포 도메인입니다. (예: https://calio.co.kr) + VITE_SITE_URL: ${{ secrets.VITE_SITE_URL }} - name: AWS 로그인 uses: aws-actions/configure-aws-credentials@v2 @@ -34,8 +37,28 @@ jobs: aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} aws-region: ${{ secrets.AWS_REGION }} - - name: S3 업로드 - run: aws s3 sync dist s3://${{ secrets.S3_BUCKET_NAME }} --delete + # 파일명에 해시가 붙는 /assets 는 영구 캐시, HTML은 매번 재검증하도록 나눠서 올립니다. + # assets에는 --delete를 쓰지 않습니다. 배포 시점에 열려 있던 구버전 탭이 + # 이전 해시의 lazy 청크를 요청하므로, 지우면 라우트 이동이 실패합니다. + # 해시 파일명이라 충돌은 없고, 오래된 청크는 필요 시 수동/수명주기 정책으로 정리합니다. + - name: S3 업로드 (해시 자산 - 장기 캐시) + run: | + aws s3 sync dist/assets s3://${{ secrets.S3_BUCKET_NAME }}/assets \ + --cache-control "public, max-age=31536000, immutable" + + - name: S3 업로드 (아이콘 / robots / sitemap) + run: | + aws s3 sync dist s3://${{ secrets.S3_BUCKET_NAME }} --delete \ + --exclude "assets/*" --exclude "*.html" \ + --cache-control "public, max-age=3600" + + # 라우트별 프리렌더 결과(index.html, login/index.html, ...)를 한 번에 올립니다. + # HTML은 항상 재검증해야 배포 직후 구버전 문서가 남지 않습니다. + - name: S3 업로드 (HTML - 캐시 재검증) + run: | + aws s3 sync dist s3://${{ secrets.S3_BUCKET_NAME }} \ + --exclude "*" --include "*.html" --exclude "assets/*" \ + --cache-control "no-cache, must-revalidate" --content-type "text/html; charset=utf-8" - name: CloudFront 캐시 삭제 run: aws cloudfront create-invalidation --distribution-id "${{ secrets.CLOUDFRONT_DISTRIBUTION_ID }}" --paths "/*" diff --git a/.github/workflows/lighthouse.yml b/.github/workflows/lighthouse.yml new file mode 100644 index 0000000..e00908a --- /dev/null +++ b/.github/workflows/lighthouse.yml @@ -0,0 +1,48 @@ +name: Lighthouse CI + +on: + pull_request: + branches: + - develop + - main + workflow_dispatch: + +# 같은 PR에 새 커밋이 올라오면 이전 실행은 취소합니다. +concurrency: + group: lighthouse-${{ github.ref }} + cancel-in-progress: true + +jobs: + lighthouse: + runs-on: ubuntu-latest + + steps: + - name: 코드 가져오기 + uses: actions/checkout@v4 + + - name: Node 설치 + uses: actions/setup-node@v4 + with: + node-version: 20 + + - name: 의존성 설치 + run: npm install + + - name: 빌드 + run: npm run build + env: + VITE_SERVER_URL: ${{ secrets.VITE_SERVER_URL }} + VITE_KAKAO_API: ${{ secrets.VITE_KAKAO_API }} + # canonical / og:url이 실제 배포 도메인으로 나와야 SEO 감사 결과가 배포 환경과 같아집니다. + VITE_SITE_URL: ${{ secrets.VITE_SITE_URL }} + + # 임계값은 lighthouserc.json에 있고, 측정 기준(2026-07-31, 모바일 프리셋)은 다음과 같습니다. + # SEO 100 / 접근성 94 / 모범사례 96 / 성능 79 + # FCP 1.8s · LCP 5.4s · TBT 0ms · CLS 0 · script 147kB · font 598kB + # + # SEO / 접근성 / 스크립트 용량 / CLS는 실패(error)로 막고, 성능은 경고(warn)만 냅니다. + # CI 러너 성능 편차로 점수가 흔들리기 때문에 관계없는 PR까지 막히는 것을 피하기 위함입니다. + # 성능 79의 원인은 전적으로 CDN 웹폰트 598kB(전체 전송량의 78%)입니다. + # 폰트를 서브셋/자체 호스팅하면 임계값을 함께 올려주세요. + - name: Lighthouse CI 실행 + run: npx --yes @lhci/cli@0.15.1 autorun diff --git a/.gitignore b/.gitignore index d366541..608fee1 100644 --- a/.gitignore +++ b/.gitignore @@ -24,3 +24,6 @@ dist-ssr *.sw? .env .eslintcache + +# Lighthouse CI +.lighthouseci diff --git a/README.md b/README.md index 5366155..c06ea6f 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ 말 한마디로 일정이 완성되는 AI 일정 관리 서비스
2026 Capstone Frontend

- Calio 대표 이미지 + Calio 대표 이미지

@@ -91,12 +91,24 @@ npm run dev npm run build ``` +빌드 마지막 단계에서 `scripts/prerender.mjs`가 라우트별 정적 HTML을 생성합니다. + +- `/`, `/login`은 실제 React 트리를 렌더해 본문까지 HTML에 담습니다. JS를 실행하지 않는 + 크롤러와 링크 미리보기 봇이 읽는 내용이자, 번들 다운로드 전에 보이는 첫 화면입니다. +- 로그인 이후 화면(`/calendar` 등)은 본문 없이 `noindex` 메타만 맞춘 HTML을 만듭니다. +- 라우트별 제목/설명/색인 여부의 원본은 `src/shared/seo/routeMeta.ts` 한 곳입니다. + 런타임(`PageMeta`)과 빌드 타임(프리렌더)이 같은 상수를 사용합니다. + +> ⚠️ `/login` 등 루트가 아닌 경로의 정적 HTML이 실제로 서빙되려면 CloudFront에 +> `infra/cloudfront-spa-router.js` 함수를 연결해야 합니다. 연결 전에는 모든 경로가 +> 기존처럼 `/index.html`(랜딩)로 폴백되며, 앱 동작에는 문제가 없습니다. + ## 📜 Scripts -| Command | Description | -| ----------------- | --------------------------------------------------- | -| `npm run dev` | Vite 개발 서버를 실행합니다. | -| `npm run build` | TypeScript 빌드 후 Vite 프로덕션 빌드를 생성합니다. | +| Command | Description | +| ----------------- | -------------------------------------------------------------- | +| `npm run dev` | Vite 개발 서버를 실행합니다. | +| `npm run build` | TypeScript 빌드 → Vite 프로덕션 빌드 → 라우트별 프리렌더. | | `npm run lint` | ESLint로 전체 코드를 검사합니다. | | `npm run preview` | 빌드 결과를 로컬에서 미리 확인합니다. | | `npm run prepare` | Husky Git hook을 설치합니다. | @@ -108,12 +120,14 @@ npm run build ```bash VITE_DEV_MODE=true VITE_SERVER_URL=https://example.com +VITE_SITE_URL=https://calio.co.kr ``` -| Key | Description | -| ----------------- | ------------------------------------------------------------------------------------------------ | -| `VITE_DEV_MODE` | React Query DevTools 표시 여부를 제어합니다. 로컬에서는 `true`, 배포에서는 `false`를 권장합니다. | -| `VITE_SERVER_URL` | API 서버 URL입니다. | +| Key | Description | +| ----------------- | --------------------------------------------------------------------------------------------------- | +| `VITE_DEV_MODE` | React Query DevTools 표시 여부를 제어합니다. 로컬에서는 `true`, 배포에서는 `false`를 권장합니다. | +| `VITE_SERVER_URL` | API 서버 URL입니다. | +| `VITE_SITE_URL` | 배포 도메인입니다. `canonical`, `og:url`, `sitemap.xml`에 사용되며 배포 워크플로 시크릿과 맞춥니다. | ## 🗂 Project Structure diff --git a/docs/readme-cover.webp b/docs/readme-cover.webp new file mode 100644 index 0000000..ca038ba Binary files /dev/null and b/docs/readme-cover.webp differ diff --git a/index.html b/index.html index 1757d7f..535872b 100644 --- a/index.html +++ b/index.html @@ -2,14 +2,136 @@ - - - 말하는 대로 움직이는 일정, Calio + + + + Calio(캘리오) | 말 한마디로 일정이 완성되는 AI 일정 관리 + + + + + + + + + + + + + + +

+ diff --git a/infra/cloudfront-spa-router.js b/infra/cloudfront-spa-router.js new file mode 100644 index 0000000..fa7f6ba --- /dev/null +++ b/infra/cloudfront-spa-router.js @@ -0,0 +1,40 @@ +/* + CloudFront Function (viewer request) + + 라우트별 정적 HTML(`/login` -> `/login/index.html`)을 서빙하기 위한 URL 재작성입니다. + 이 함수를 붙이지 않으면 dist의 라우트별 HTML은 사용되지 않고, + 모든 경로가 기존처럼 `/index.html`(랜딩) 하나로 서빙됩니다. + + 적용 방법 + 1) CloudFront 콘솔 > 함수 > 함수 생성 + - 이름: calio-spa-router (계정 내 유일해야 하고, 생성 후 변경 불가) + - 런타임: cloudfront-js-2.0 + 2) 이 파일 내용을 붙여넣고 게시(Publish) + 3) 배포 > 동작 > 기본 동작 편집 > 뷰어 요청에 이 함수 연결 + 4) 무효화(/*) 후 확인: + curl -sI https://calio.co.kr/login | head -1 # 200 + curl -s https://calio.co.kr/login | grep '' # 로그인 | Calio + + 동작 규칙 + - 확장자가 있는 요청(/assets/x.js, /robots.txt 등)은 그대로 통과시킵니다. + - 그 외 경로는 `<경로>/index.html`로 바꿉니다. + - 존재하지 않는 경로는 S3가 403을 반환하고, 기존 오류 페이지 설정에 따라 + `/index.html`로 폴백됩니다. (지금 동작과 동일) +*/ +function handler(event) { + var request = event.request + var uri = request.uri + + // 파일 요청(확장자 포함)은 재작성하지 않습니다. + if (uri.indexOf('.') !== -1) { + return request + } + + if (uri.endsWith('/')) { + request.uri = uri + 'index.html' + } else { + request.uri = uri + '/index.html' + } + + return request +} diff --git a/lighthouserc.json b/lighthouserc.json new file mode 100644 index 0000000..ab3ab53 --- /dev/null +++ b/lighthouserc.json @@ -0,0 +1,24 @@ +{ + "ci": { + "collect": { + "startServerCommand": "npx vite preview --port 4173", + "startServerReadyPattern": "Local:", + "url": ["http://localhost:4173/"], + "numberOfRuns": 3 + }, + "assert": { + "assertions": { + "categories:seo": ["error", { "minScore": 1 }], + "categories:accessibility": ["error", { "minScore": 0.9 }], + "categories:best-practices": ["warn", { "minScore": 0.9 }], + "categories:performance": ["warn", { "minScore": 0.75 }], + "resource-summary:script:size": ["error", { "maxNumericValue": 200000 }], + "resource-summary:font:size": ["warn", { "maxNumericValue": 650000 }], + "cumulative-layout-shift": ["error", { "maxNumericValue": 0.1 }] + } + }, + "upload": { + "target": "temporary-public-storage" + } + } +} diff --git a/package-lock.json b/package-lock.json index 7fc40e8..2e23c93 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,12 +15,14 @@ "@tanstack/react-query": "^5.90.16", "@tanstack/react-query-devtools": "^5.91.2", "axios": "^1.13.2", - "moment": "^2.30.1", + "dayjs": "^1.11.21", + "lucide-react": "^1.17.0", "react": "^19.2.0", "react-big-calendar": "^1.19.4", "react-day-picker": "^9.13.0", "react-dom": "^19.2.0", "react-hook-form": "^7.69.0", + "react-kakao-maps-sdk": "^1.2.1", "react-router-dom": "^7.11.0", "yup": "^1.7.1", "zustand": "^5.0.9" @@ -28,6 +30,8 @@ "devDependencies": { "@commitlint/cli": "^20.3.0", "@commitlint/config-conventional": "^20.3.0", + "@emotion/cache": "^11.14.0", + "@emotion/server": "^11.11.0", "@eslint/js": "^9.39.1", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.1", @@ -49,6 +53,7 @@ "globals": "^16.5.0", "husky": "^9.1.7", "jsdom": "^27.4.0", + "kakao.maps.d.ts": "^0.1.40", "lint-staged": "^16.2.7", "prettier": "^3.7.4", "typescript": "~5.9.3", @@ -882,6 +887,27 @@ "csstype": "^3.0.2" } }, + "node_modules/@emotion/server": { + "version": "11.11.0", + "resolved": "https://registry.npmjs.org/@emotion/server/-/server-11.11.0.tgz", + "integrity": "sha512-6q89fj2z8VBTx9w93kJ5n51hsmtYuFPtZgnc1L8VzRx9ti4EU6EyvF6Nn1H1x3vcCQCF7u2dB2lY4AYJwUW4PA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@emotion/utils": "^1.2.1", + "html-tokenize": "^2.0.0", + "multipipe": "^1.0.2", + "through": "^2.3.8" + }, + "peerDependencies": { + "@emotion/css": "^11.0.0-rc.0" + }, + "peerDependenciesMeta": { + "@emotion/css": { + "optional": true + } + } + }, "node_modules/@emotion/sheet": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/@emotion/sheet/-/sheet-1.4.0.tgz", @@ -3783,6 +3809,13 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/buffer-from": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-0.1.2.tgz", + "integrity": "sha512-RiWIenusJsmI2KcvqQABB83tLxCByE3upSP8QU3rJDMVFGPWLvPQJt/O1Su9moRWeH7d+Q2HYb68f6+v+tw2vg==", + "dev": true, + "license": "MIT" + }, "node_modules/call-bind": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", @@ -4144,6 +4177,13 @@ "url": "https://opencollective.com/express" } }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "dev": true, + "license": "MIT" + }, "node_modules/cosmiconfig": { "version": "9.0.1", "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-9.0.1.tgz", @@ -4372,9 +4412,9 @@ "license": "MIT" }, "node_modules/dayjs": { - "version": "1.11.19", - "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.19.tgz", - "integrity": "sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw==", + "version": "1.11.21", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.21.tgz", + "integrity": "sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA==", "license": "MIT" }, "node_modules/debug": { @@ -4530,6 +4570,49 @@ "node": ">= 0.4" } }, + "node_modules/duplexer2": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/duplexer2/-/duplexer2-0.1.4.tgz", + "integrity": "sha512-asLFVfWWtJ90ZyOUHMqk7/S2w2guQKxUI2itj3d92ADHhxUSbCMGi1f1cBcJ7xM1To+pE/Khbwo1yuNbMEPKeA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "readable-stream": "^2.0.2" + } + }, + "node_modules/duplexer2/node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/duplexer2/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "dev": true, + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/duplexer2/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, "node_modules/electron-to-chromium": { "version": "1.5.307", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.307.tgz", @@ -5855,6 +5938,23 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" } }, + "node_modules/html-tokenize": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/html-tokenize/-/html-tokenize-2.0.1.tgz", + "integrity": "sha512-QY6S+hZ0f5m1WT8WffYN+Hg+xm/w5I8XeUcAq/ZYP5wVC8xbKi4Whhru3FtrAebD5EhBW8rmFzkDI6eCAuFe2w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-from": "~0.1.1", + "inherits": "~2.0.1", + "minimist": "~1.2.5", + "readable-stream": "~1.0.27-1", + "through2": "~0.4.1" + }, + "bin": { + "html-tokenize": "bin/cmd.js" + } + }, "node_modules/http-proxy-agent": { "version": "7.0.2", "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", @@ -5965,6 +6065,13 @@ "node": ">=8" } }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true, + "license": "ISC" + }, "node_modules/ini": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/ini/-/ini-4.1.1.tgz", @@ -6612,6 +6719,13 @@ "node": ">=4.0" } }, + "node_modules/kakao.maps.d.ts": { + "version": "0.1.40", + "resolved": "https://registry.npmjs.org/kakao.maps.d.ts/-/kakao.maps.d.ts-0.1.40.tgz", + "integrity": "sha512-nX69MB1ok04epe3OqS+/tEeWBbU31GSQbvDPJmQRRltzzqn6t4jBsO5v1nzalUjCKzwcH2CptOc767NZ7Hbu3g==", + "license": "MIT", + "peer": true + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -6843,6 +6957,15 @@ "yallist": "^3.0.2" } }, + "node_modules/lucide-react": { + "version": "1.28.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.28.0.tgz", + "integrity": "sha512-fARAFJULsGuDDydjp6+6blekG/sBIM29TerzLjc9bQUKAcEfrSc4ZQKb25KRz4OMKd87cZTb5dgq0w/T6KufVg==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/luxon": { "version": "3.7.2", "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.7.2.tgz", @@ -7018,6 +7141,17 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, + "node_modules/multipipe": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/multipipe/-/multipipe-1.0.2.tgz", + "integrity": "sha512-6uiC9OvY71vzSGX8lZvSqscE7ft9nPupJ8fMjrCNRAUy2LREUW42UL+V/NTrogr6rFgRydUrCX4ZitfpSNkSCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "duplexer2": "^0.1.2", + "object-assign": "^4.1.0" + } + }, "node_modules/nanoid": { "version": "3.3.11", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", @@ -7511,6 +7645,13 @@ "dev": true, "license": "MIT" }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "dev": true, + "license": "MIT" + }, "node_modules/prop-types": { "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", @@ -7640,6 +7781,21 @@ "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", "license": "MIT" }, + "node_modules/react-kakao-maps-sdk": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/react-kakao-maps-sdk/-/react-kakao-maps-sdk-1.2.1.tgz", + "integrity": "sha512-qvdt+82D/MxTxmgF9tXaqa6eNqMUiFOeEdn+PyU0u9EHoxeD9WMJeHkum/GWrbP62MR0qWPtZ/G4iM9f2/1TPQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.22.15", + "use-sync-external-store": "^1.6.0" + }, + "peerDependencies": { + "kakao.maps.d.ts": "^0.1.40", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/react-lifecycles-compat": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz", @@ -7704,6 +7860,26 @@ "react-dom": ">=18" } }, + "node_modules/readable-stream": { + "version": "1.0.34", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-1.0.34.tgz", + "integrity": "sha512-ok1qVCJuRkNmvebYikljxJA/UEsKwLl2nI1OmaqAu4/UE+h0wKCHok4XkL/gvi39OacXvw59RJUOFUkDib2rHg==", + "dev": true, + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.1", + "isarray": "0.0.1", + "string_decoder": "~0.10.x" + } + }, + "node_modules/readable-stream/node_modules/isarray": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz", + "integrity": "sha512-D2S+3GLxWH+uhrNEcoh/fnmYeP8E8/zHl644d/jdA0g2uyXvy3sb0qxotE+ne0LtccHknQzWwZEzhak7oJ0COQ==", + "dev": true, + "license": "MIT" + }, "node_modules/redent": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", @@ -7902,6 +8078,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "dev": true, + "license": "MIT" + }, "node_modules/safe-push-apply": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", @@ -8241,6 +8424,13 @@ "node": ">= 0.4" } }, + "node_modules/string_decoder": { + "version": "0.10.31", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-0.10.31.tgz", + "integrity": "sha512-ev2QzSzWPYmy9GuqfIVildA4OdcGLeFZQrq5ys6RtiuF+RQQiZWr8TZNyAcuVXyQRYfEO+MsoB/1BuQVhOJuoQ==", + "dev": true, + "license": "MIT" + }, "node_modules/string-argv": { "version": "0.3.2", "resolved": "https://registry.npmjs.org/string-argv/-/string-argv-0.3.2.tgz", @@ -8482,6 +8672,24 @@ "url": "https://opencollective.com/synckit" } }, + "node_modules/through": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", + "integrity": "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==", + "dev": true, + "license": "MIT" + }, + "node_modules/through2": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/through2/-/through2-0.4.2.tgz", + "integrity": "sha512-45Llu+EwHKtAZYTPPVn3XZHBgakWMN3rokhEv5hu596XP+cNgplMg+Gj+1nmAvj+L0K7+N49zBKx5rah5u0QIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "readable-stream": "~1.0.17", + "xtend": "~2.1.1" + } + }, "node_modules/tiny-case": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/tiny-case/-/tiny-case-1.0.3.tgz", @@ -8894,6 +9102,22 @@ "punycode": "^2.1.0" } }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, "node_modules/vite": { "version": "7.3.1", "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", @@ -9411,6 +9635,25 @@ "dev": true, "license": "MIT" }, + "node_modules/xtend": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-2.1.2.tgz", + "integrity": "sha512-vMNKzr2rHP9Dp/e1NQFnLQlwlhp9L/LfvnsVdHxN1f+uggyVI3i08uD14GPvCToPkdsRfyPqIyYGmIk58V98ZQ==", + "dev": true, + "dependencies": { + "object-keys": "~0.4.0" + }, + "engines": { + "node": ">=0.4" + } + }, + "node_modules/xtend/node_modules/object-keys": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-0.4.0.tgz", + "integrity": "sha512-ncrLw+X55z7bkl5PnUvHwFK9FcGuFYo9gtjws2XtSzL+aZ8tm830P60WJ0dSmFVaSalWieW5MD7kEdnXda9yJw==", + "dev": true, + "license": "MIT" + }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", diff --git a/package.json b/package.json index 4a8834a..dee609b 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "type": "module", "scripts": { "dev": "vite", - "build": "tsc -b && vite build", + "build": "tsc -b && vite build && node scripts/prerender.mjs", "lint": "eslint .", "preview": "vite preview", "prepare": "husky install" @@ -18,8 +18,8 @@ "@tanstack/react-query": "^5.90.16", "@tanstack/react-query-devtools": "^5.91.2", "axios": "^1.13.2", + "dayjs": "^1.11.21", "lucide-react": "^1.17.0", - "moment": "^2.30.1", "react": "^19.2.0", "react-big-calendar": "^1.19.4", "react-day-picker": "^9.13.0", @@ -33,6 +33,8 @@ "devDependencies": { "@commitlint/cli": "^20.3.0", "@commitlint/config-conventional": "^20.3.0", + "@emotion/cache": "^11.14.0", + "@emotion/server": "^11.11.0", "@eslint/js": "^9.39.1", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.1", diff --git a/public/google624d91fe654e5b97.html b/public/google624d91fe654e5b97.html new file mode 100644 index 0000000..29b4dfd --- /dev/null +++ b/public/google624d91fe654e5b97.html @@ -0,0 +1 @@ +google-site-verification: google624d91fe654e5b97.html \ No newline at end of file diff --git a/public/og-image.jpg b/public/og-image.jpg new file mode 100644 index 0000000..7dc6e08 Binary files /dev/null and b/public/og-image.jpg differ diff --git a/public/readme-cover.png b/public/readme-cover.png deleted file mode 100644 index 751e5cf..0000000 Binary files a/public/readme-cover.png and /dev/null differ diff --git a/scripts/prerender.mjs b/scripts/prerender.mjs new file mode 100644 index 0000000..184cbba --- /dev/null +++ b/scripts/prerender.mjs @@ -0,0 +1,141 @@ +import { mkdir, readFile, writeFile } from 'node:fs/promises' +import path from 'node:path' +import process from 'node:process' +import { fileURLToPath } from 'node:url' + +import { createServer } from 'vite' + +/* + `vite build` 이후에 실행되어, 라우트별 정적 HTML을 dist에 추가로 만듭니다. + + Vite의 SSR 모듈 로더를 그대로 쓰기 때문에 별도 SSR 번들을 만들 필요가 없고, + alias / svgr / TS 설정이 앱과 100% 동일하게 적용됩니다. +*/ + +const ROOT = path.resolve(fileURLToPath(new URL('..', import.meta.url))) +const DIST = path.join(ROOT, 'dist') + +const SEO_START = '<!--seo:start-->' +const SEO_END = '<!--seo:end-->' +const ROOT_PLACEHOLDER = '<div id="root"></div>' + +const escapeHtml = (value) => + value + .replace(/&/g, '&') + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/"/g, '"') + +/** 라우트 하나의 `<head>` 메타 블록을 만듭니다. */ +const buildHead = ({ meta, siteUrl, resolveTitle, ogDescription, ogImagePath }) => { + const title = escapeHtml(resolveTitle(meta.title)) + const tags = [`<title>${title}`] + + if (meta.description) { + tags.push(``) + } + + tags.push(``) + + // canonical은 색인 대상 페이지에만 넣습니다. noindex 페이지에 있으면 신호가 충돌합니다. + if (!meta.noIndex && meta.canonicalPath) { + tags.push(``) + } + + tags.push( + '', + '', + '', + '', + ``, + ) + + // 링크 미리보기는 색인 여부와 무관하게 랜딩 정보를 보여주는 편이 자연스럽습니다. + const description = meta.noIndex ? ogDescription : meta.description || ogDescription + tags.push( + ``, + ``, + ``, + '', + '', + '', + '', + ``, + ``, + ``, + ) + + return tags.map((tag) => ` ${tag}`).join('\n') +} + +const replaceBetween = (source, start, end, replacement) => { + const startIndex = source.indexOf(start) + const endIndex = source.indexOf(end) + + if (startIndex === -1 || endIndex === -1) { + throw new Error(`index.html에서 ${start} / ${end} 마커를 찾지 못했습니다.`) + } + + return source.slice(0, startIndex + start.length) + replacement + source.slice(endIndex) +} + +/** `/login` -> `dist/login/index.html`, `/` -> `dist/index.html` */ +const outputPathFor = (routePath) => + routePath === '/' + ? path.join(DIST, 'index.html') + : path.join(DIST, routePath.replace(/^\//, ''), 'index.html') + +const main = async () => { + const template = await readFile(path.join(DIST, 'index.html'), 'utf-8') + + const vite = await createServer({ + server: { middlewareMode: true }, + appType: 'custom', + logLevel: 'warn', + }) + + try { + const { render } = await vite.ssrLoadModule('/src/entry-server.tsx') + const meta = await vite.ssrLoadModule('/src/shared/seo/routeMeta.ts') + const { PRERENDER_ROUTES, resolveTitle, resolveSiteUrl } = meta + const siteUrl = resolveSiteUrl(process.env.VITE_SITE_URL) + + for (const route of PRERENDER_ROUTES) { + const head = buildHead({ + meta: route.meta, + siteUrl, + resolveTitle, + ogDescription: meta.LANDING_OG_DESCRIPTION, + ogImagePath: meta.OG_IMAGE_PATH, + }) + + let body = `
` + let styles = '' + + if (route.prerender) { + const rendered = await render(route.path) + body = `
${rendered.html}
` + styles = rendered.styles + } + + let html = replaceBetween(template, SEO_START, SEO_END, `\n${head}\n `) + html = html.replace(ROOT_PLACEHOLDER, body) + if (styles) html = html.replace('', `${styles}\n `) + + const outputPath = outputPathFor(route.path) + await mkdir(path.dirname(outputPath), { recursive: true }) + await writeFile(outputPath, html, 'utf-8') + + const sizeKb = (Buffer.byteLength(html) / 1024).toFixed(1) + const label = route.prerender ? '프리렌더' : '메타만' + console.log(` ${route.path.padEnd(10)} ${label} ${sizeKb}kB -> ${path.relative(ROOT, outputPath)}`) + } + } finally { + await vite.close() + } +} + +main().catch((error) => { + console.error('[prerender] 실패:', error) + process.exit(1) +}) diff --git a/src/app/main.tsx b/src/app/main.tsx index 5fed027..b28da2e 100644 --- a/src/app/main.tsx +++ b/src/app/main.tsx @@ -1,11 +1,11 @@ import { ThemeProvider } from '@emotion/react' import { QueryClientProvider } from '@tanstack/react-query' import { ReactQueryDevtools } from '@tanstack/react-query-devtools' -import React, { useEffect, useState } from 'react' +import React, { useEffect } from 'react' import ReactDOM from 'react-dom/client' import { RouterProvider } from 'react-router-dom' -import { authRouter, mainRouter } from '@/routes/Router' +import { disposeAuthRouter, disposeMainRouter, getAuthRouter, getMainRouter } from '@/routes/Router' import axiosInstance from '@/shared/api/axios' import { resetAuthRecoveryState } from '@/shared/api/axios' import GlobalStyle from '@/shared/styles/GlobalStyle' @@ -18,8 +18,13 @@ import { queryClient } from '../shared/api/queryClient' // eslint-disable-next-line react-refresh/only-export-components const App = () => { const { isLoggedIn, login, logout } = useAuthStore() - const [isInitializing, setIsInitializing] = useState(true) + /* + 첫 렌더를 `/members/me` 응답까지 기다리지 않습니다. + 이전에는 응답 전까지 null을 렌더해서 API가 느리거나 죽으면 흰 화면만 보였고, + 검색 유입(= 항상 로그아웃 상태)의 첫 페인트가 API 왕복만큼 밀렸습니다. + 저장된 로그인 힌트로 라우터를 먼저 띄우고, 응답이 오면 상태를 확정합니다. + */ useEffect(() => { const initAuth = async () => { try { @@ -34,17 +39,19 @@ const App = () => { // eslint-disable-next-line @typescript-eslint/no-unused-vars } catch (error) { logout() - } finally { - setIsInitializing(false) } } initAuth() }, [login, logout]) - if (isInitializing) return null + /* 라우터 교체 후, 더 이상 쓰지 않는 쪽의 history 구독을 해제합니다. */ + useEffect(() => { + if (isLoggedIn) disposeAuthRouter() + else disposeMainRouter() + }, [isLoggedIn]) - return + return } ReactDOM.createRoot(document.getElementById('root')!).render( diff --git a/src/entry-server.tsx b/src/entry-server.tsx new file mode 100644 index 0000000..1d2c6ed --- /dev/null +++ b/src/entry-server.tsx @@ -0,0 +1,44 @@ +import createCache from '@emotion/cache' +import { CacheProvider, ThemeProvider } from '@emotion/react' +import createEmotionServer from '@emotion/server/create-instance' +import { renderToString } from 'react-dom/server' +import { createStaticHandler, createStaticRouter, StaticRouterProvider } from 'react-router-dom' + +import AuthRoutes from '@/routes/AuthRoutes' +import GlobalStyle from '@/shared/styles/GlobalStyle' +import { theme } from '@/shared/styles/theme' + +/** + * 빌드 타임 프리렌더 전용 엔트리입니다. (`scripts/prerender.mjs`에서만 사용) + * + * 실제 라우트 트리(`AuthRoutes`)를 그대로 렌더하므로, 정적 HTML과 React가 + * 마운트한 뒤의 화면이 어긋날 수 없습니다. + * + * 하이드레이션은 하지 않습니다. 앱은 로그인 힌트에 따라 다른 라우터를 고르기 때문에 + * (`main.tsx`) 서버가 고른 트리와 클라이언트가 고른 트리가 다를 수 있어, + * `createRoot`로 정적 HTML을 통째로 교체하는 편이 안전합니다. + * 정적 HTML의 역할은 어디까지나 "JS 실행 전에 보이는 첫 화면"입니다. + */ +export const render = async (path: string) => { + const handler = createStaticHandler([AuthRoutes]) + const context = await handler.query(new Request(`http://prerender.local${path}`)) + + if (context instanceof Response) { + throw new Error(`프리렌더 중 리다이렉트가 발생했습니다: ${path}`) + } + + const router = createStaticRouter(handler.dataRoutes, context) + const cache = createCache({ key: 'css' }) + const { extractCriticalToChunks, constructStyleTagsFromChunks } = createEmotionServer(cache) + + const html = renderToString( + + + + + + , + ) + + return { html, styles: constructStyleTagsFromChunks(extractCriticalToChunks(html)) } +} diff --git a/src/features/Calendar/components/CalendarToolbar/CalendarToolbar.tsx b/src/features/Calendar/components/CalendarToolbar/CalendarToolbar.tsx index 642a6d5..3d66c2b 100644 --- a/src/features/Calendar/components/CalendarToolbar/CalendarToolbar.tsx +++ b/src/features/Calendar/components/CalendarToolbar/CalendarToolbar.tsx @@ -1,8 +1,8 @@ -import moment from 'moment' import { type ToolbarProps } from 'react-big-calendar' import Arrow from '@/assets/icons/common/chevron.svg?react' import { theme } from '@/shared/styles/theme' +import dayjs from '@/shared/utils/dayjs' import { CustomViewButton } from '../CustomViewButton/CustomViewButton' import * as S from './CalendarToolbar.style' @@ -13,7 +13,7 @@ const CustomToolbar = ({ onNavigate, view, }: ToolbarProps) => { - const formattedLabel = moment(date).format('YYYY년 M월') + const formattedLabel = dayjs(date).format('YYYY년 M월') return ( diff --git a/src/features/Calendar/components/CustomCalendar/CalendarModals.tsx b/src/features/Calendar/components/CustomCalendar/CalendarModals.tsx index 0c2d33d..3303bdd 100644 --- a/src/features/Calendar/components/CustomCalendar/CalendarModals.tsx +++ b/src/features/Calendar/components/CustomCalendar/CalendarModals.tsx @@ -1,4 +1,3 @@ -import moment from 'moment' import { useMemo, useState } from 'react' import { useLocation } from 'react-router-dom' @@ -8,6 +7,7 @@ import type { ItemEditorDraft } from '@/shared/types/modal/itemEditor' import ScheduleEditorModal from '@/shared/ui/Modals/ScheduleEditor' import TodoEditorModal from '@/shared/ui/Modals/TodoEditor' import { buildDefaultItemEditorDraft } from '@/shared/utils' +import dayjs from '@/shared/utils/dayjs' import type { CalendarEventActions } from './CustomCalendar.types' @@ -105,7 +105,7 @@ const CalendarModals = ({ const safeDetailEventId = isModalEditing && !isTodoModal ? modalEventId : null const occurrenceDate = useMemo(() => { if (modalEvent?.occurrenceDate) { - return moment(modalEvent.occurrenceDate).format('YYYY-MM-DDTHH:mm:ss') + return dayjs(modalEvent.occurrenceDate).format('YYYY-MM-DDTHH:mm:ss') } const base = modalEvent?.start instanceof Date @@ -113,7 +113,7 @@ const CalendarModals = ({ : modalEvent?.start ? new Date(modalEvent.start) : modalDate - return base ? moment(base).format('YYYY-MM-DDTHH:mm:ss') : '' + return base ? dayjs(base).format('YYYY-MM-DDTHH:mm:ss') : '' }, [modalDate, modalEvent]) const { data } = useDetailEventQuery(safeDetailEventId, occurrenceDate) const detailEvent = useMemo(() => { diff --git a/src/features/Calendar/components/CustomCalendar/CustomCalendar.tsx b/src/features/Calendar/components/CustomCalendar/CustomCalendar.tsx index 1b84c93..d762356 100644 --- a/src/features/Calendar/components/CustomCalendar/CustomCalendar.tsx +++ b/src/features/Calendar/components/CustomCalendar/CustomCalendar.tsx @@ -1,22 +1,21 @@ /** @jsxImportSource @emotion/react */ -import 'moment/locale/ko' import 'react-big-calendar/lib/css/react-big-calendar.css' -import moment from 'moment' import type { View } from 'react-big-calendar' -import { Calendar, momentLocalizer } from 'react-big-calendar' +import { Calendar, dayjsLocalizer } from 'react-big-calendar' import withDragAndDrop from 'react-big-calendar/lib/addons/dragAndDrop' import { useCustomCalendarController } from '@/features/Calendar/hooks/useCustomCalendarController' import type { CalendarEvent } from '@/shared/types/calendar/types' +import dayjs from '@/shared/utils/dayjs' import CalendarModals from './CalendarModals' import * as S from './CustomCalendar.style' import CustomCalendarDialogs from './CustomCalendarDialogs' import CustomCalendarMobileActions from './CustomCalendarMobileActions' -moment.locale('ko') -const localizer = momentLocalizer(moment) +// ko 로케일 적용은 shared/utils/dayjs에서 전역으로 처리된다. +const localizer = dayjsLocalizer(dayjs) const DragAndDropCalendar = withDragAndDrop(Calendar) export type { SelectDateSource } from './CustomCalendar.types' diff --git a/src/features/Calendar/components/CustomCalendar/CustomCalendarMobileActions.tsx b/src/features/Calendar/components/CustomCalendar/CustomCalendarMobileActions.tsx index 6dcbdae..f42b772 100644 --- a/src/features/Calendar/components/CustomCalendar/CustomCalendarMobileActions.tsx +++ b/src/features/Calendar/components/CustomCalendar/CustomCalendarMobileActions.tsx @@ -1,8 +1,8 @@ -import moment from 'moment' import type { View } from 'react-big-calendar' import Plus from '@/assets/icons/common/plus.svg?react' import { theme } from '@/shared/styles/theme' +import dayjs from '@/shared/utils/dayjs' import { CustomViewButton } from '../CustomViewButton/CustomViewButton' import * as S from './CustomCalendar.style' @@ -15,7 +15,7 @@ type CustomCalendarMobileActionsProps = { } const buildDefaultAddEventDate = (currentDate: Date) => - moment(currentDate).startOf('day').set({ hour: 9, minute: 0, second: 0, millisecond: 0 }).toDate() + dayjs(currentDate).startOf('day').hour(9).toDate() const CustomCalendarMobileActions = ({ view, diff --git a/src/features/Calendar/components/CustomEvent/CustomMonthEvent.tsx b/src/features/Calendar/components/CustomEvent/CustomMonthEvent.tsx index 8f8b7f0..3d9b1a3 100644 --- a/src/features/Calendar/components/CustomEvent/CustomMonthEvent.tsx +++ b/src/features/Calendar/components/CustomEvent/CustomMonthEvent.tsx @@ -1,9 +1,9 @@ -import moment from 'moment' import { type MouseEvent, useRef, useState } from 'react' import type { EventProps } from 'react-big-calendar' import { createPortal } from 'react-dom' import People from '@/assets/icons/people.svg?react' +import dayjs from '@/shared/utils/dayjs' import { getColorPalette } from '../../utils/colorPalette' import type { CalendarEvent } from '../CustomView/CustomDayView' @@ -14,7 +14,7 @@ const formatTimeRange = (event: CalendarEvent) => { return '종일' } - const start = moment(event.start).format('HH:mm') + const start = dayjs(event.start).format('HH:mm') return `${start}` } diff --git a/src/features/Calendar/components/CustomEvent/CustomWeekEvent.tsx b/src/features/Calendar/components/CustomEvent/CustomWeekEvent.tsx index a0ea818..cce7b5b 100644 --- a/src/features/Calendar/components/CustomEvent/CustomWeekEvent.tsx +++ b/src/features/Calendar/components/CustomEvent/CustomWeekEvent.tsx @@ -1,8 +1,8 @@ -import moment from 'moment' import React, { type MouseEvent, useRef, useState } from 'react' import { createPortal } from 'react-dom' import People from '@/assets/icons/people.svg?react' +import dayjs from '@/shared/utils/dayjs' import { getColorPalette } from '../../utils/colorPalette' import type { CalendarEvent } from '../CustomView/CustomDayView' @@ -12,8 +12,8 @@ const formatTimeRange = (event: CalendarEvent) => { if (event.isAllDay) { return '종일' } - const start = moment(event.start).format('HH:mm') - const end = moment(event.end).format('HH:mm') + const start = dayjs(event.start).format('HH:mm') + const end = dayjs(event.end).format('HH:mm') return `${start} ~ ${end}` } diff --git a/src/features/Calendar/components/CustomView/CustomDayView.tsx b/src/features/Calendar/components/CustomView/CustomDayView.tsx index e303cf0..d380e33 100644 --- a/src/features/Calendar/components/CustomView/CustomDayView.tsx +++ b/src/features/Calendar/components/CustomView/CustomDayView.tsx @@ -1,9 +1,9 @@ -import moment from 'moment' import React, { useCallback, useEffect, useRef, useState } from 'react' import type { NavigateAction, ViewStatic } from 'react-big-calendar' import { formatWeekday } from '@/features/Calendar/utils/formatters' import type { CalendarEvent } from '@/shared/types/calendar/types' +import dayjs from '@/shared/utils/dayjs' import { TIMED_SLOT_CONFIG } from '../../domain/constants' import { @@ -50,8 +50,8 @@ const CustomDayView: React.FC & ViewStatic = ({ onEventClick, onEventDoubleClick, }) => { - const currentDate = moment(date) - const isToday = currentDate.isSame(moment(), 'day') + const currentDate = dayjs(date) + const isToday = currentDate.isSame(dayjs(), 'day') // 날짜 전체를 차지하는 이벤트만 추려서 위쪽 배너에서 보여줍니다. const allDayEvents = events @@ -105,7 +105,7 @@ const CustomDayView: React.FC & ViewStatic = ({ {formatWeekday(date)}요일 - {moment(date).format('D')} + {dayjs(date).format('D')} @@ -149,14 +149,14 @@ const CustomDayView: React.FC & ViewStatic = ({ ) } -CustomDayView.title = (date: Date) => moment(date).format('YYYY년 M월 D일') +CustomDayView.title = (date: Date) => dayjs(date).format('YYYY년 M월 D일') CustomDayView.navigate = (date: Date, action: NavigateAction) => { switch (action) { case 'PREV': - return moment(date).subtract(1, 'day').toDate() + return dayjs(date).subtract(1, 'day').toDate() case 'NEXT': - return moment(date).add(1, 'day').toDate() + return dayjs(date).add(1, 'day').toDate() default: return date } diff --git a/src/features/Calendar/components/CustomView/CustomWeekView.tsx b/src/features/Calendar/components/CustomView/CustomWeekView.tsx index f03cc8d..45faf2d 100644 --- a/src/features/Calendar/components/CustomView/CustomWeekView.tsx +++ b/src/features/Calendar/components/CustomView/CustomWeekView.tsx @@ -1,4 +1,3 @@ -import moment from 'moment' import React from 'react' import type { NavigateAction, ViewStatic } from 'react-big-calendar' import type { EventInteractionArgs } from 'react-big-calendar/lib/addons/dragAndDrop' @@ -20,6 +19,7 @@ import { KOREAN_WEEKDAYS, } from '@/features/Calendar/utils/weekViewLayout' import type { CalendarEvent } from '@/shared/types/calendar/types' +import dayjs from '@/shared/utils/dayjs' import { TodoCheckbox } from '../CustomEvent/CustomEvent.style' import * as S from './weekView' @@ -41,7 +41,7 @@ const formatTime = (event: CalendarEvent) => { if (event.isAllDay || isDateOnlyString(event.start)) { return '종일' } - return moment(event.start).format('HH:mm') + return dayjs(event.start).format('HH:mm') } const buildEventAriaLabel = (event: CalendarEvent) => { @@ -68,7 +68,7 @@ const CustomWeekView: React.ComponentType & ViewStatic = (({ [weekDays], ) const allDaySectionRef = React.useRef(null) - const today = moment() + const today = dayjs() const weeklyAllDayEvents = React.useMemo(() => getWeeklyAllDayEvents(events), [events]) const allDaySegments = React.useMemo( () => buildAllDaySegments(weeklyAllDayEvents, weekDayDates), @@ -341,16 +341,16 @@ const CustomWeekView: React.ComponentType & ViewStatic = (({ CustomWeekView.navigate = (date: Date, action: NavigateAction) => { switch (action) { case 'PREV': - return moment(date).subtract(1, 'week').toDate() + return dayjs(date).subtract(1, 'week').toDate() case 'NEXT': - return moment(date).add(1, 'week').toDate() + return dayjs(date).add(1, 'week').toDate() default: return date } } CustomWeekView.title = (date: Date) => { - const start = moment(date).startOf('week') + const start = dayjs(date).startOf('week') const end = start.clone().endOf('week') return `${start.format('YYYY년 M월 D일')} - ${end.format('M월 D일')}` } diff --git a/src/features/Calendar/components/CustomView/dayView/renderers.tsx b/src/features/Calendar/components/CustomView/dayView/renderers.tsx index 8b46bee..62135ec 100644 --- a/src/features/Calendar/components/CustomView/dayView/renderers.tsx +++ b/src/features/Calendar/components/CustomView/dayView/renderers.tsx @@ -1,7 +1,7 @@ -import moment from 'moment' import type { MutableRefObject, Ref } from 'react' import People from '@/assets/icons/people.svg?react' +import dayjs from '@/shared/utils/dayjs' import type { CalendarEvent } from '../../../../../shared/types/calendar/types' import { TIMED_SLOT_CONFIG } from '../../../domain/constants' @@ -26,7 +26,7 @@ export const renderTimeSlotRows = ( const hourLabel = `${String(hour).padStart(2, '0')}:00` const isLast = index === TOTAL_SLOTS - 1 - const slotDate = moment(date).hour(hour).minute(0).second(0).millisecond(0).toDate() + const slotDate = dayjs(date).hour(hour).minute(0).second(0).millisecond(0).toDate() if (isLast) { return null diff --git a/src/features/Calendar/hooks/useCalendarCreateEvent.ts b/src/features/Calendar/hooks/useCalendarCreateEvent.ts index 1b963f6..1c12f1d 100644 --- a/src/features/Calendar/hooks/useCalendarCreateEvent.ts +++ b/src/features/Calendar/hooks/useCalendarCreateEvent.ts @@ -1,9 +1,9 @@ -import moment from 'moment' import { useCallback } from 'react' import type { SlotInfo, View } from 'react-big-calendar' import { Views } from 'react-big-calendar' import type { CalendarEvent } from '@/shared/types/calendar/types' +import dayjs from '@/shared/utils/dayjs' type UseCalendarCreateEventArgs = { view: View @@ -23,9 +23,7 @@ export const useCalendarCreateEvent = ({ view === Views.WEEK && slotInfo.action === 'select' && slotInfo.slots.length === 1 if (slotInfo.action !== 'doubleClick' && !isWeekSingleClick) return false - const start = moment(slotInfo.start) - .set({ hour: 9, minute: 0, second: 0, millisecond: 0 }) - .toDate() + const start = dayjs(slotInfo.start).hour(9).minute(0).second(0).millisecond(0).toDate() const createdId = enqueueEvent(start, false) if (createdId != null) { onCreated(start, createdId) diff --git a/src/features/Calendar/hooks/useCalendarCreateHandlers.ts b/src/features/Calendar/hooks/useCalendarCreateHandlers.ts index 02b9e7a..dee1589 100644 --- a/src/features/Calendar/hooks/useCalendarCreateHandlers.ts +++ b/src/features/Calendar/hooks/useCalendarCreateHandlers.ts @@ -1,9 +1,9 @@ -import moment from 'moment' import { useCallback } from 'react' import type { SlotInfo, View } from 'react-big-calendar' import { useCalendarCreateEvent } from '@/features/Calendar/hooks' import type { CalendarEvent } from '@/shared/types/calendar/types' +import dayjs from '@/shared/utils/dayjs' type UseCalendarCreateHandlersArgs = { view: View @@ -33,9 +33,9 @@ export const useCalendarCreateHandlers = ({ const handleDayViewCreateEvent = useCallback( (slotDate: Date) => { - const startBase = moment(slotDate).set({ second: 0, millisecond: 0 }) + const startBase = dayjs(slotDate).second(0).millisecond(0) const snappedMinute = startBase.minute() < 30 ? 0 : 30 - const start = startBase.set({ minute: snappedMinute }).toDate() + const start = startBase.minute(snappedMinute).toDate() const createdId = enqueueEvent(start, false) if (createdId != null) { onAddEvent(start, createdId) diff --git a/src/features/Calendar/hooks/useCalendarDateRange.ts b/src/features/Calendar/hooks/useCalendarDateRange.ts index 08ad22e..f83a3ed 100644 --- a/src/features/Calendar/hooks/useCalendarDateRange.ts +++ b/src/features/Calendar/hooks/useCalendarDateRange.ts @@ -1,11 +1,12 @@ -import moment from 'moment' import { useMemo } from 'react' import { type View, Views } from 'react-big-calendar' +import dayjs from '@/shared/utils/dayjs' + // 캘린더 뷰와 기준 날짜로 API 조회 범위를 계산하는 훅 export const useCalendarDateRange = (view: View, date: Date) => useMemo(() => { - const base = moment(date) + const base = dayjs(date) if (view === Views.MONTH) { return { startDate: base.clone().startOf('month').format('YYYY-MM-DD'), diff --git a/src/features/Calendar/hooks/useCalendarDayViewTiming.ts b/src/features/Calendar/hooks/useCalendarDayViewTiming.ts index e6be172..78d6b3c 100644 --- a/src/features/Calendar/hooks/useCalendarDayViewTiming.ts +++ b/src/features/Calendar/hooks/useCalendarDayViewTiming.ts @@ -1,10 +1,10 @@ -import moment from 'moment' import { useCallback } from 'react' import { getEventOccurrenceScope } from '@/features/Calendar/utils/helpers/calendarRecurrenceScope' import { resolveOccurrenceDateTime } from '@/features/Calendar/utils/helpers/dayViewHelpers' import type { RecurrenceEventSeriesScope } from '@/shared/constants/recurrenceScope' import type { CalendarEvent } from '@/shared/types/calendar/types' +import dayjs from '@/shared/utils/dayjs' import { useToastStore } from '@/store/useToastStore' import type { PatchTodoTiming } from './useCalendarTodoTimingPatch' @@ -79,8 +79,8 @@ export const useCalendarDayViewTiming = ({ ...(patchScope ? { scope: patchScope } : {}), }, eventData: { - startTime: moment(start).format('YYYY-MM-DDTHH:mm:ss'), - endTime: moment(end).format('YYYY-MM-DDTHH:mm:ss'), + startTime: dayjs(start).format('YYYY-MM-DDTHH:mm:ss'), + endTime: dayjs(end).format('YYYY-MM-DDTHH:mm:ss'), isAllDay: false, }, }) diff --git a/src/features/Calendar/hooks/useCalendarDeleteConfirm.ts b/src/features/Calendar/hooks/useCalendarDeleteConfirm.ts index a4dd48e..d36a667 100644 --- a/src/features/Calendar/hooks/useCalendarDeleteConfirm.ts +++ b/src/features/Calendar/hooks/useCalendarDeleteConfirm.ts @@ -1,9 +1,9 @@ -import moment from 'moment' import { useCallback, useState } from 'react' import type { DeleteConfirmState } from '@/features/Calendar/components/CustomCalendar/CustomCalendar.types' import { getEventOccurrenceScope } from '@/features/Calendar/utils/helpers/calendarRecurrenceScope' import type { CalendarEvent } from '@/shared/types/calendar/types' +import dayjs from '@/shared/utils/dayjs' type UseCalendarDeleteConfirmArgs = { events: CalendarEvent[] @@ -67,7 +67,7 @@ export const useCalendarDeleteConfirm = ({ eventId, params: { ...(isRecurringEvent ? { scope: getEventOccurrenceScope(isRecurringEvent) } : {}), - occurrenceDate: moment(occurrenceDate).format('YYYY-MM-DDTHH:mm:ss'), + occurrenceDate: dayjs(occurrenceDate).format('YYYY-MM-DDTHH:mm:ss'), }, }, { diff --git a/src/features/Calendar/hooks/useCalendarDraftEvent.ts b/src/features/Calendar/hooks/useCalendarDraftEvent.ts index f794066..f76d35c 100644 --- a/src/features/Calendar/hooks/useCalendarDraftEvent.ts +++ b/src/features/Calendar/hooks/useCalendarDraftEvent.ts @@ -1,7 +1,7 @@ -import moment from 'moment' import { useCallback } from 'react' import type { CalendarEvent } from '@/shared/types/calendar/types' +import dayjs from '@/shared/utils/dayjs' type UseCalendarDraftEventArgs = { events: CalendarEvent[] @@ -53,11 +53,11 @@ export const useCalendarDraftEvent = ({ if (modal.isOpen && !isModalEditing && modal.eventId != null) { const draftEvent = events.find((eventItem) => eventItem.id === modal.eventId) if (draftEvent) { - const currentStart = moment(draftEvent.start) - const currentEnd = moment(draftEvent.end) + const currentStart = dayjs(draftEvent.start) + const currentEnd = dayjs(draftEvent.end) const shouldKeepAllDay = draftEvent.isAllDay ?? allDay const durationMs = Math.max(currentEnd.diff(currentStart), 0) - const nextStart = shouldKeepAllDay ? moment(start).startOf('day') : moment(start) + const nextStart = shouldKeepAllDay ? dayjs(start).startOf('day') : dayjs(start) const nextEnd = shouldKeepAllDay ? (() => { const spanDays = Math.max( diff --git a/src/features/Calendar/hooks/useCalendarDragDrop.ts b/src/features/Calendar/hooks/useCalendarDragDrop.ts index 6cfed1f..8d502a7 100644 --- a/src/features/Calendar/hooks/useCalendarDragDrop.ts +++ b/src/features/Calendar/hooks/useCalendarDragDrop.ts @@ -1,4 +1,3 @@ -import moment from 'moment' import { useCallback } from 'react' import type { View } from 'react-big-calendar' import { Views } from 'react-big-calendar' @@ -12,6 +11,7 @@ import type { RecurrenceGroup, RecurrenceTodoScope, } from '@/shared/types/recurrence/recurrence' +import dayjs from '@/shared/utils/dayjs' import { normalizeRecurrenceGroupPayload, toWeekday, @@ -124,7 +124,7 @@ export const useCalendarDragDrop = ({ moveEvent(args) if (view !== Views.MONTH && view !== Views.WEEK) return if (event.type === 'todo') { - const defaultOccurrenceDate = moment(event.occurrenceDate ?? event.start).format( + const defaultOccurrenceDate = dayjs(event.occurrenceDate ?? event.start).format( 'YYYY-MM-DD', ) patchTodoTiming(event, start as Date, { @@ -133,8 +133,8 @@ export const useCalendarDragDrop = ({ }) return } - const nextStart = moment(start).format('YYYY-MM-DDTHH:mm:ss') - const nextEnd = moment(end).format('YYYY-MM-DDTHH:mm:ss') + const nextStart = dayjs(start).format('YYYY-MM-DDTHH:mm:ss') + const nextEnd = dayjs(end).format('YYYY-MM-DDTHH:mm:ss') const occurrenceDate = resolveOccurrenceDateTime(event.occurrenceDate, event.start) patchEventMutate({ eventId: event.id, diff --git a/src/features/Calendar/hooks/useCalendarEvents.ts b/src/features/Calendar/hooks/useCalendarEvents.ts index ff780a6..26399a5 100644 --- a/src/features/Calendar/hooks/useCalendarEvents.ts +++ b/src/features/Calendar/hooks/useCalendarEvents.ts @@ -1,8 +1,8 @@ -import moment from 'moment' import { useCallback, useEffect, useState } from 'react' import type { EventInteractionArgs } from 'react-big-calendar/lib/addons/dragAndDrop' import type { CalendarEvent } from '@/shared/types/calendar/types' +import dayjs from '@/shared/utils/dayjs' import { createEvent, normalizeDate, updateEventRange } from '../utils/helpers/calendarPageHelpers' @@ -102,8 +102,8 @@ export const useCalendarEvents = (options: UseCalendarEventsOptions = {}) => { event.id === eventId && (occurrenceDate ? event.occurrenceDate === occurrenceDate : true) ? { ...event, - start: moment(start).format('YYYY-MM-DDTHH:mm'), - end: moment(end).format('YYYY-MM-DDTHH:mm'), + start: dayjs(start).format('YYYY-MM-DDTHH:mm'), + end: dayjs(end).format('YYYY-MM-DDTHH:mm'), isAllDay: allDay, } : event, diff --git a/src/features/Calendar/hooks/useCalendarKeyDelete.ts b/src/features/Calendar/hooks/useCalendarKeyDelete.ts index 56fbf7b..b4b7a7b 100644 --- a/src/features/Calendar/hooks/useCalendarKeyDelete.ts +++ b/src/features/Calendar/hooks/useCalendarKeyDelete.ts @@ -1,4 +1,3 @@ -import moment from 'moment' import { useEffect } from 'react' import { normalizeDate } from '@/features/Calendar/utils/helpers/calendarPageHelpers' @@ -9,6 +8,7 @@ import { import { RECURRENCE_TODO_SCOPE } from '@/shared/constants/recurrenceScope' import type { CalendarEvent } from '@/shared/types/calendar/types' import type { RecurrenceTodoScope } from '@/shared/types/recurrence/recurrence' +import dayjs from '@/shared/utils/dayjs' type UseCalendarKeyDeleteArgs = { isModalOpen: boolean @@ -86,7 +86,7 @@ export const useCalendarKeyDelete = ({ if (selectedEvent.type === 'todo') { onDeleteTodo({ todoId: selectedEvent.id, - occurrenceDate: selectedEvent.occurrenceDate ?? moment(baseDate).format('YYYY-MM-DD'), + occurrenceDate: selectedEvent.occurrenceDate ?? dayjs(baseDate).format('YYYY-MM-DD'), scope: isRecurringEvent ? RECURRENCE_TODO_SCOPE.THIS_TODO : undefined, }) onClearSelection() diff --git a/src/features/Calendar/hooks/useCalendarNavigation.ts b/src/features/Calendar/hooks/useCalendarNavigation.ts index 128f90e..2cfac85 100644 --- a/src/features/Calendar/hooks/useCalendarNavigation.ts +++ b/src/features/Calendar/hooks/useCalendarNavigation.ts @@ -1,8 +1,9 @@ -import moment from 'moment' import { useCallback } from 'react' import type { View } from 'react-big-calendar' import { Views } from 'react-big-calendar' +import dayjs from '@/shared/utils/dayjs' + type UseCalendarNavigationArgs = { view: View date: Date @@ -16,7 +17,7 @@ type UseCalendarNavigationArgs = { // 캘린더뷰가 변할 때, selectedDate을 뷰의 시작 날짜로 초기화 const getViewStartDate = (baseDate: Date, baseView: View) => { - const base = moment(baseDate) + const base = dayjs(baseDate) if (baseView === Views.MONTH) { return base.startOf('month').toDate() } diff --git a/src/features/Calendar/hooks/useCalendarRbcProps.tsx b/src/features/Calendar/hooks/useCalendarRbcProps.tsx index ec411fd..dfbc3ea 100644 --- a/src/features/Calendar/hooks/useCalendarRbcProps.tsx +++ b/src/features/Calendar/hooks/useCalendarRbcProps.tsx @@ -1,5 +1,4 @@ // 훅: react-big-calendar 설정/컴포넌트/props를 생성합니다. -import moment from 'moment' import type { ComponentProps, ComponentType } from 'react' import { useMemo } from 'react' import type { @@ -24,6 +23,7 @@ import { import { getEventOccurrenceKey } from '@/features/Calendar/utils/helpers/dayViewHelpers' import { getViewConfig } from '@/features/Calendar/utils/viewConfig' import type { CalendarEvent } from '@/shared/types/calendar/types' +import dayjs from '@/shared/utils/dayjs' import { buildCalendarConfig } from '../domain/config' @@ -184,8 +184,8 @@ export const useCalendarRbcProps = ({ daySpan?: (start: Date, end: Date) => number } nextLocalizer.daySpan = (start: Date, end: Date) => { - const startDay = moment(start).startOf('day') - const endDay = moment(end).startOf('day') + const startDay = dayjs(start).startOf('day') + const endDay = dayjs(end).startOf('day') const diff = endDay.diff(startDay, 'days') return diff + 1 } @@ -213,10 +213,10 @@ export const useCalendarRbcProps = ({ eventPropGetter: view === Views.MONTH ? (event) => { - const start = moment(event.start) - const end = moment(event.end) - const monthStart = moment(date).startOf('month') - const monthEnd = moment(date).endOf('month') + const start = dayjs(event.start) + const end = dayjs(event.end) + const monthStart = dayjs(date).startOf('month') + const monthEnd = dayjs(date).endOf('month') const overlaps = end.isSameOrAfter(monthStart, 'day') && start.isSameOrBefore(monthEnd, 'day') return overlaps ? {} : { style: { display: 'none' } } diff --git a/src/features/Calendar/hooks/useCalendarTodoActions.ts b/src/features/Calendar/hooks/useCalendarTodoActions.ts index a0c37c8..869f201 100644 --- a/src/features/Calendar/hooks/useCalendarTodoActions.ts +++ b/src/features/Calendar/hooks/useCalendarTodoActions.ts @@ -1,7 +1,7 @@ -import moment from 'moment' import { useCallback } from 'react' import type { CalendarEvent } from '@/shared/types/calendar/types' +import dayjs from '@/shared/utils/dayjs' type UseCalendarTodoActionsArgs = { events: CalendarEvent[] @@ -29,7 +29,7 @@ export const useCalendarTodoActions = ({ toggleEventDone(eventId, 'todo') patchCompleteTodoMutate({ todoId: eventId, - occurrenceDate: moment(target.start).format('YYYY-MM-DD'), + occurrenceDate: dayjs(target.start).format('YYYY-MM-DD'), isCompleted: nextCompleted, }) }, diff --git a/src/features/Calendar/hooks/useCalendarTodoTimingPatch.ts b/src/features/Calendar/hooks/useCalendarTodoTimingPatch.ts index 008d689..3090feb 100644 --- a/src/features/Calendar/hooks/useCalendarTodoTimingPatch.ts +++ b/src/features/Calendar/hooks/useCalendarTodoTimingPatch.ts @@ -1,4 +1,3 @@ -import moment from 'moment' import { useCallback, useRef } from 'react' import { buildRecurringGroupForFutureDrop } from '@/features/Calendar/hooks/useCalendarDragDrop' @@ -10,6 +9,7 @@ import { getDetailTodo } from '@/shared/api/todo/api' import type { CalendarEvent } from '@/shared/types/calendar/types' import type { RecurrenceGroup, RecurrenceTodoScope } from '@/shared/types/recurrence/recurrence' import type { PatchTodoRequestDTO } from '@/shared/types/todo/types' +import dayjs from '@/shared/utils/dayjs' type PatchTodoPayload = { todoId: number @@ -50,12 +50,12 @@ export const useCalendarTodoTimingPatch = ({ patchTodoMutate }: UseCalendarTodoT start: Date, options?: { scope?: RecurrenceTodoScope; occurrenceDate?: string }, ) => { - const startDate = moment(start).format('YYYY-MM-DD') + const startDate = dayjs(start).format('YYYY-MM-DD') const occurrenceDate = options?.occurrenceDate ?? - moment(todoEvent.occurrenceDate ?? todoEvent.start).format('YYYY-MM-DD') + dayjs(todoEvent.occurrenceDate ?? todoEvent.start).format('YYYY-MM-DD') const patchScope = options?.scope ?? getTodoOccurrenceScope(Boolean(todoEvent.isRecurring)) - const dueTime = todoEvent.isAllDay ? undefined : moment(start).format('HH:mm') + const dueTime = todoEvent.isAllDay ? undefined : dayjs(start).format('HH:mm') const submitPatch = (recurrenceGroup?: RecurrenceGroup) => { patchTodoMutate({ diff --git a/src/features/Calendar/hooks/useCalendarViewCreationHandlers.ts b/src/features/Calendar/hooks/useCalendarViewCreationHandlers.ts index aecc88d..847583e 100644 --- a/src/features/Calendar/hooks/useCalendarViewCreationHandlers.ts +++ b/src/features/Calendar/hooks/useCalendarViewCreationHandlers.ts @@ -1,9 +1,9 @@ -import moment from 'moment' import { useCallback } from 'react' import { useCalendarCreateHandlers } from '@/features/Calendar/hooks/useCalendarCreateHandlers' import { useDayViewHandlers } from '@/features/Calendar/hooks/useDayViewHandlers' import type { CalendarEvent } from '@/shared/types/calendar/types' +import dayjs from '@/shared/utils/dayjs' type UseCalendarViewCreationHandlersArgs = { view: Parameters[0]['view'] @@ -55,7 +55,7 @@ export const useCalendarViewCreationHandlers = ({ const handleWeekViewCreateEvent = useCallback( (slotDate: Date) => { - const start = moment(slotDate).startOf('day').set({ hour: 9, minute: 0, second: 0 }).toDate() + const start = dayjs(slotDate).startOf('day').hour(9).minute(0).second(0).toDate() const createdId = enqueueEvent(start, false) if (createdId != null) { handleAddEvent(start, createdId) diff --git a/src/features/Calendar/utils/formatters.ts b/src/features/Calendar/utils/formatters.ts index 1da4027..7b54b3c 100644 --- a/src/features/Calendar/utils/formatters.ts +++ b/src/features/Calendar/utils/formatters.ts @@ -1,15 +1,14 @@ -import moment from 'moment' - import { WEEK_DAYS } from '@/shared/constants/event' +import dayjs from '@/shared/utils/dayjs' // 주 단위 헤더에서 사용할 요일(일~토) 문자열을 반환한다. -export const formatWeekday = (date: Date) => WEEK_DAYS[moment(date).day()] +export const formatWeekday = (date: Date) => WEEK_DAYS[dayjs(date).day()] // 월간/일간 헤더에서 날짜가 1일이면 "M/D", 아니면 "D" 형태로 표시한다. export const formatDayHeaderLabel = (date: Date) => { - const dayMoment = moment(date) + const dayMoment = dayjs(date) return dayMoment.date() === 1 ? dayMoment.format('M/D') : dayMoment.format('D') } // 월간 셀의 숫자 라벨을 한 자리 또는 두 자리 날짜로 정규화한다. -export const formatDayNumber = (date: Date) => moment(date).format('D') +export const formatDayNumber = (date: Date) => dayjs(date).format('D') diff --git a/src/features/Calendar/utils/helpers/calendarPageHelpers.ts b/src/features/Calendar/utils/helpers/calendarPageHelpers.ts index 5dbfc6c..ddf01c9 100644 --- a/src/features/Calendar/utils/helpers/calendarPageHelpers.ts +++ b/src/features/Calendar/utils/helpers/calendarPageHelpers.ts @@ -1,7 +1,6 @@ -import moment from 'moment' - import { theme } from '@/shared/styles/theme' import type { CalendarEvent } from '@/shared/types/calendar/types' +import dayjs from '@/shared/utils/dayjs' import { DEFAULT_ALL_DAY_TITLE, @@ -18,9 +17,10 @@ const buildEventId = (prevCount: number, date: Date) => date.valueOf() + prevCou /** 기본 제목/기간을 가지는 새 캘린더 이벤트를 생성합니다. */ export const createEvent = (date: Date, index: number, allDay = false): CalendarEvent => { - const start = moment(date) - const eventDurationMs = moment.duration(DEFAULT_EVENT_DURATION_HOURS, 'hours') - const end = allDay ? start.clone().endOf('day') : start.clone().add(eventDurationMs) + const start = dayjs(date) + const end = allDay + ? start.clone().endOf('day') + : start.clone().add(DEFAULT_EVENT_DURATION_HOURS, 'hour') return { id: buildEventId(index, date), title: allDay ? DEFAULT_ALL_DAY_TITLE : DEFAULT_EVENT_TITLE, @@ -53,8 +53,8 @@ export const updateEventRange = ( (occurrenceDate ? item.occurrenceDate === occurrenceDate : true) ? { ...item, - start: moment(start).format('YYYY-MM-DDTHH:mm'), - end: moment(end).format('YYYY-MM-DDTHH:mm'), + start: dayjs(start).format('YYYY-MM-DDTHH:mm'), + end: dayjs(end).format('YYYY-MM-DDTHH:mm'), } : item, ) @@ -64,7 +64,7 @@ export const getDayPropStyle = (calendarDate: Date, selectedDate: Date | null) = if (!selectedDate) { return {} } - return moment(selectedDate).isSame(calendarDate, 'day') + return dayjs(selectedDate).isSame(calendarDate, 'day') ? { style: { backgroundColor: theme.colors.lightGray, diff --git a/src/features/Calendar/utils/helpers/dayViewHelpers.ts b/src/features/Calendar/utils/helpers/dayViewHelpers.ts index 6d04d6b..76ec59c 100644 --- a/src/features/Calendar/utils/helpers/dayViewHelpers.ts +++ b/src/features/Calendar/utils/helpers/dayViewHelpers.ts @@ -1,7 +1,7 @@ -import moment from 'moment' import type { stringOrDate } from 'react-big-calendar' import type { CalendarEvent } from '@/shared/types/calendar/types' +import dayjs, { type Dayjs } from '@/shared/utils/dayjs' import { TIMED_SLOT_CONFIG } from '../../domain/constants' import { getColorPalette } from '../colorPalette' @@ -25,23 +25,23 @@ export const isDateOnlyString = (value?: stringOrDate) => // day view 렌더링 전 이벤트를 시작 시각 기준으로 정렬한다. export const compareByStart = (a: CalendarEvent, b: CalendarEvent) => - moment(a.start).diff(moment(b.start)) + dayjs(a.start).diff(dayjs(b.start)) // 반복 일정의 각 occurrence를 안정적으로 식별하기 위한 키를 생성한다. export const getEventOccurrenceKey = (event: CalendarEvent) => - `${event.id}_${moment(event.start).format('YYYY-MM-DDTHH:mm')}` + `${event.id}_${dayjs(event.start).format('YYYY-MM-DDTHH:mm')}` // occurrenceDate가 있으면 우선 사용하고, 없으면 start를 사용해 일관된 datetime 문자열을 만든다. export const resolveOccurrenceDateTime = ( occurrenceDate: CalendarEvent['occurrenceDate'] | undefined, fallbackStart: CalendarEvent['start'] | Date, -) => moment(occurrenceDate ?? fallbackStart).format('YYYY-MM-DDTHH:mm:ss') +) => dayjs(occurrenceDate ?? fallbackStart).format('YYYY-MM-DDTHH:mm:ss') // 며칠짜리 일정이 특정 날짜를 포함하는지 판별한다. export const eventCoversDate = (event: CalendarEvent, date: Date) => { - const start = moment(event.start) - const end = moment(event.end) - return moment(date).isBetween(start.startOf('day'), end.endOf('day'), undefined, '[]') + const start = dayjs(event.start) + const end = dayjs(event.end) + return dayjs(date).isBetween(start.startOf('day'), end.endOf('day'), undefined, '[]') } // 이벤트를 오전/오후 컬럼 단위 시각 슬롯으로 변환하고 겹침 레이아웃 정보를 계산한다. @@ -53,23 +53,23 @@ export const buildTimedSlots = ( const { MIN_HEIGHT, MAX_VISUAL_HOURS, COLUMNS } = TIMED_SLOT_CONFIG const columns: TimedSlotEvent[][] = COLUMNS.map(() => []) - const dayStart = moment(date).startOf('day') + const dayStart = dayjs(date).startOf('day') const dayEnd = dayStart.clone().add(24, 'hours') const noon = dayStart.clone().add(12, 'hours') events.forEach((event) => { const palette = getColorPalette(event.color) - const start = moment(event.start) - const end = moment(event.end) - const clampedStart = moment.max(start, dayStart) - const clampedEnd = moment.min(end, dayEnd) + const start = dayjs(event.start) + const end = dayjs(event.end) + const clampedStart = start.isAfter(dayStart) ? start : dayStart + const clampedEnd = end.isBefore(dayEnd) ? end : dayEnd if (!clampedEnd.isAfter(clampedStart)) { return } // 표시 구간(한 컬럼 내 시작/종료)을 실제 픽셀 좌표(top/height)로 변환한다. - const pushSegment = (segmentStart: moment.Moment, segmentEnd: moment.Moment) => { + const pushSegment = (segmentStart: Dayjs, segmentEnd: Dayjs) => { const columnIndex = segmentStart.hour() < 12 ? 0 : 1 const columnStart = dayStart.clone().add(COLUMNS[columnIndex], 'hours') const minutesSinceColumnStart = segmentStart.diff(columnStart, 'minutes') diff --git a/src/features/Calendar/utils/viewConfig.ts b/src/features/Calendar/utils/viewConfig.ts index c4cdfb1..b1d5c6f 100644 --- a/src/features/Calendar/utils/viewConfig.ts +++ b/src/features/Calendar/utils/viewConfig.ts @@ -1,9 +1,10 @@ -import moment from 'moment' import type { ComponentType } from 'react' import { createElement } from 'react' import type { Components, Formats, HeaderProps, View } from 'react-big-calendar' import { Views } from 'react-big-calendar' +import dayjs from '@/shared/utils/dayjs' + import CustomHeader from '../components/CalendarHeader/CalendarHeader' import type { CalendarEvent } from '../components/CustomView/CustomDayView' import { formatDayHeaderLabel, formatDayNumber, formatWeekday } from './formatters' @@ -21,7 +22,7 @@ type ViewConfig = { // react-big-calendar format 콜백을 앱 공통 포맷으로 연결한다. const weekdayFormat = (date: Date) => formatWeekday(date) const dayHeaderFormat = (date: Date) => formatDayHeaderLabel(date) -const timeGutterFormat = (date: Date) => moment(date).format('HH:00') +const timeGutterFormat = (date: Date) => dayjs(date).format('HH:00') // 주간 헤더에서만 "+" 액션을 주입할 수 있도록 동적 헤더 컴포넌트를 만든다. const createWeekHeader = (options?: ViewConfigOptions): ComponentType => { diff --git a/src/features/Calendar/utils/weekViewLayout.ts b/src/features/Calendar/utils/weekViewLayout.ts index 2686099..c8551fc 100644 --- a/src/features/Calendar/utils/weekViewLayout.ts +++ b/src/features/Calendar/utils/weekViewLayout.ts @@ -1,6 +1,5 @@ -import moment from 'moment' - import type { CalendarEvent } from '@/shared/types/calendar/types' +import dayjs from '@/shared/utils/dayjs' import { compareByStart, @@ -20,7 +19,7 @@ export type AllDaySegment = { } export const buildWeekDays = (date: Date) => { - const weekStart = moment(date).startOf('week') + const weekStart = dayjs(date).startOf('week') return Array.from({ length: 7 }, (_, index) => weekStart.clone().add(index, 'day')) } @@ -82,8 +81,8 @@ export const buildWeekDropRange = ( targetDate: Date, dropAsAllDay: boolean, ) => { - const originalStart = moment(draggingEvent.start) - const originalEnd = moment(draggingEvent.end) + const originalStart = dayjs(draggingEvent.start) + const originalEnd = dayjs(draggingEvent.end) const originalStartDay = originalStart.clone().startOf('day') const originalEndDay = originalEnd.clone().startOf('day') const originalAllDay = draggingEvent.isAllDay || isDateOnlyString(draggingEvent.start) @@ -91,13 +90,12 @@ export const buildWeekDropRange = ( const useAllDayTime = dropAsAllDay || originalAllDay const nextStart = useAllDayTime - ? moment(targetDate).startOf('day') - : moment(targetDate).set({ - hour: originalStart.hour(), - minute: originalStart.minute(), - second: originalStart.second(), - millisecond: originalStart.millisecond(), - }) + ? dayjs(targetDate).startOf('day') + : dayjs(targetDate) + .hour(originalStart.hour()) + .minute(originalStart.minute()) + .second(originalStart.second()) + .millisecond(originalStart.millisecond()) const nextEnd = useAllDayTime ? (() => { const spanDays = Math.max(originalEndDay.diff(originalStartDay, 'days') + 1, 1) diff --git a/src/features/Friends/SharedScheduleItem.tsx b/src/features/Friends/SharedScheduleItem.tsx index 53cad40..7e111d6 100644 --- a/src/features/Friends/SharedScheduleItem.tsx +++ b/src/features/Friends/SharedScheduleItem.tsx @@ -13,6 +13,7 @@ interface SharedScheduleItemProps { endDate?: string sharerName: string accentColor: string + isOwner?: boolean onCancelSuccess?: () => void } @@ -23,6 +24,7 @@ export default function SharedScheduleItem({ endDate, sharerName = '이름없음', accentColor = '#5c6ac4', + isOwner = false, onCancelSuccess, }: SharedScheduleItemProps) { const queryClient = useQueryClient() @@ -44,25 +46,27 @@ export default function SharedScheduleItem({ ? formatDate(startDate) : `${formatDate(startDate)} - ${formatDate(endDate)}` - const leaveEventMutation = useMutation({ - mutationFn: () => eventShareApi.leaveEvent(eventId), + const cancelShareMutation = useMutation({ + mutationFn: () => + isOwner ? eventShareApi.deleteEventParticipants(eventId) : eventShareApi.leaveEvent(eventId), onSuccess: (response) => { if (response.isSuccess) { queryClient.invalidateQueries({ queryKey: ['calendar'] }) queryClient.invalidateQueries({ queryKey: ['events'] }) queryClient.invalidateQueries({ queryKey: ['todos'] }) + queryClient.invalidateQueries({ queryKey: ['sharedEvents'] }) showToast({ - title: '그룹 탈퇴 완료', - message: '성공적으로 탈퇴되었습니다.', + title: isOwner ? '공유 해제 완료' : '그룹 탈퇴 완료', + message: isOwner ? '성공적으로 공유가 해제되었습니다.' : '성공적으로 탈퇴되었습니다.', toastType: 'success', }) onCancelSuccess?.() } else { showToast({ - title: '탈퇴 처리 실패', - message: response.message || '탈퇴 처리에 실패했습니다.', + title: isOwner ? '공유 해제 실패' : '탈퇴 처리 실패', + message: response.message || '처리에 실패했습니다.', toastType: 'error', }) } @@ -80,8 +84,12 @@ export default function SharedScheduleItem({ }) const handleCancelShare = () => { - if (window.confirm('정말 이 공유 이벤트에서 탈퇴하시겠습니까?')) { - leaveEventMutation.mutate() + const confirmMessage = isOwner + ? '소유자가 공유를 해제하면 다른 참가자들의 공유 일정도 함께 삭제됩니다. 정말 공유를 해제하시겠습니까?' + : '정말 이 공유 이벤트에서 탈퇴하시겠습니까?' + + if (window.confirm(confirmMessage)) { + cancelShareMutation.mutate() } } @@ -94,14 +102,14 @@ export default function SharedScheduleItem({ - 공유자: {sharerName} + {isOwner ? '내 이벤트(Owner)' : `공유자: ${sharerName}`} - {leaveEventMutation.isPending ? '취소 중...' : '공유 취소'} + {cancelShareMutation.isPending ? '처리 중...' : isOwner ? '공유 해제' : '공유 취소'} diff --git a/src/pages/auth/Landing.styles.ts b/src/pages/auth/Landing.styles.ts index 2534de0..a9d6a11 100644 --- a/src/pages/auth/Landing.styles.ts +++ b/src/pages/auth/Landing.styles.ts @@ -320,7 +320,8 @@ export const Circle = styled.div<{ } ` -export const BackgroundText = styled.h1` +/** 배경 장식용 텍스트입니다. h1은 HeroTitle 하나만 유지해야 하므로 div로 둡니다. */ +export const BackgroundText = styled.div` position: absolute; z-index: 0; top: 24px; diff --git a/src/pages/auth/Landing.tsx b/src/pages/auth/Landing.tsx index dc3303d..562fbb0 100644 --- a/src/pages/auth/Landing.tsx +++ b/src/pages/auth/Landing.tsx @@ -7,6 +7,8 @@ import GoogleIcon from '@/assets/social/google.svg?react' import KakaoIcon from '@/assets/social/kakao.svg?react' import NaverIcon from '@/assets/social/naver.svg?react' import { redirectToSocialLogin } from '@/shared/api/auth/auth' +import { LANDING_META } from '@/shared/seo/routeMeta' +import PageMeta from '@/shared/ui/common/PageMeta/PageMeta' import { features } from './components/features' import { FeatureVisual } from './components/FeatureVisual' @@ -43,6 +45,8 @@ export default function Landing() { return ( + {/* 구조화 데이터(JSON-LD)는 index.html에 정적으로 넣어 JS 없는 크롤러도 읽게 했습니다. */} + @@ -57,7 +61,7 @@ export default function Landing() { - + diff --git a/src/pages/auth/Login.tsx b/src/pages/auth/Login.tsx index 4827d12..faa0098 100644 --- a/src/pages/auth/Login.tsx +++ b/src/pages/auth/Login.tsx @@ -4,12 +4,16 @@ import LandingFriends from '@/assets/login/LandingFriends.svg' import LandingRobot from '@/assets/login/LandingRobot.svg' import logo from '@/assets/logo.svg' import LoginCard from '@/features/Auth/components/LoginCard/LoginCard' +import { LOGIN_META } from '@/shared/seo/routeMeta' +import PageMeta from '@/shared/ui/common/PageMeta/PageMeta' import * as S from './Login.styles' export default function Login() { return ( + {/* 로그인 화면은 검색 노출 가치가 없고, 랜딩과 내용이 겹쳐 색인에서 제외합니다. */} + diff --git a/src/pages/common/ErrorPage/ErrorPage.tsx b/src/pages/common/ErrorPage/ErrorPage.tsx index 31cd797..2965329 100644 --- a/src/pages/common/ErrorPage/ErrorPage.tsx +++ b/src/pages/common/ErrorPage/ErrorPage.tsx @@ -1,18 +1,69 @@ +import { useEffect } from 'react' import { useNavigate, useRouteError, useSearchParams } from 'react-router-dom' +import { NOT_FOUND_META } from '@/shared/seo/routeMeta' +import PageMeta from '@/shared/ui/common/PageMeta/PageMeta' + import * as S from './ErrorPage.styles' +const CHUNK_RELOAD_AT_KEY = 'calio.chunkReloadedAt' +const CHUNK_RELOAD_COOLDOWN_MS = 30_000 + +const isChunkLoadError = (error: unknown) => { + const message = error instanceof Error ? error.message : '' + + return /dynamically imported module|Importing a module script failed|error loading|Failed to fetch/i.test( + message, + ) +} + +/* + 배포 직후에는 열려 있던 구버전 탭이 이전 해시의 lazy 청크를 요청하다 실패할 수 있습니다. + 새 index.html을 받으면 해결되므로 자동 새로고침하되, + 새로고침으로도 해결되지 않는 경우(네트워크 장애 등)의 무한 루프를 막기 위해 + 최근에 이미 새로고침했다면 에러 화면을 그대로 보여줍니다. +*/ +const shouldReloadForChunkError = (error: unknown) => { + if (!isChunkLoadError(error)) return false + + try { + const lastReloadAt = Number(sessionStorage.getItem(CHUNK_RELOAD_AT_KEY) || 0) + + return Date.now() - lastReloadAt > CHUNK_RELOAD_COOLDOWN_MS + } catch { + // sessionStorage를 못 쓰는 환경에서는 루프 방지가 불가능하므로 새로고침하지 않습니다. + return false + } +} + export default function ErrorPage() { const navigate = useNavigate() const error = useRouteError() as { status?: number; statusText?: string; message?: string } | null const [searchParams] = useSearchParams() + const shouldReload = shouldReloadForChunkError(error) + + useEffect(() => { + if (!shouldReload) return + + try { + sessionStorage.setItem(CHUNK_RELOAD_AT_KEY, String(Date.now())) + } catch { + return + } + window.location.reload() + }, [shouldReload]) + const queryMessage = searchParams.get('message') const description = queryMessage || error?.statusText || error?.message || '요청하신 페이지를 찾을 수 없어요.' + /* 곧 새로고침될 예정이면 에러 문구가 깜빡이지 않도록 비워둡니다. */ + if (shouldReload) return null + return ( + 문제가 발생했어요 {description} diff --git a/src/pages/main/CalendarPage/CalendarPage.tsx b/src/pages/main/CalendarPage/CalendarPage.tsx index 8dc6475..f10e4d7 100644 --- a/src/pages/main/CalendarPage/CalendarPage.tsx +++ b/src/pages/main/CalendarPage/CalendarPage.tsx @@ -7,6 +7,8 @@ import { mapSettingsDefaultView } from '@/features/Calendar/hooks/useStoredCalen import { AIChatModalButton } from '@/features/Common/AIChatModalButton' import { SettingsAPI } from '@/shared/api/settings/settings' import { useCustomSuspenseQuery } from '@/shared/hooks/common/customQuery' +import { CALENDAR_META } from '@/shared/seo/routeMeta' +import PageMeta from '@/shared/ui/common/PageMeta/PageMeta' import * as S from './CalendarPage.styles' const CalendarPage = () => { @@ -21,6 +23,7 @@ const CalendarPage = () => { return ( +
+ )) diff --git a/src/pages/main/HomePage/HomePage.tsx b/src/pages/main/HomePage/HomePage.tsx index 8b41de2..0dc30ea 100644 --- a/src/pages/main/HomePage/HomePage.tsx +++ b/src/pages/main/HomePage/HomePage.tsx @@ -3,6 +3,8 @@ import AIChatModal from '@/features/Common/AIChatModal' import { fetchReminders, fetchTodayBriefing } from '@/shared/api/home/home' import { useCustomQuery } from '@/shared/hooks/common/customQuery' import { useSuggestions } from '@/shared/hooks/query/useSuggestion' +import { HOME_META } from '@/shared/seo/routeMeta' +import PageMeta from '@/shared/ui/common/PageMeta/PageMeta' import { SparkleIcon } from '@/shared/ui/icons/SparkleIcon' import * as S from './HomePage.styles' @@ -45,6 +47,7 @@ export default function HomePage() { return ( + {formatDateKorean(briefing?.date)} AI가 오늘의 일정을 한눈에, 쉽게 정리해드려요 diff --git a/src/pages/main/SettingPage/SettingsPage.tsx b/src/pages/main/SettingPage/SettingsPage.tsx index 2d07a0c..034d79b 100644 --- a/src/pages/main/SettingPage/SettingsPage.tsx +++ b/src/pages/main/SettingPage/SettingsPage.tsx @@ -10,7 +10,9 @@ import { queryClient } from '@/shared/api/queryClient' import { SettingsAPI } from '@/shared/api/settings/settings' import { useCustomQuery, useCustomSuspenseQuery } from '@/shared/hooks/common/customQuery' import { useSettingsMutation } from '@/shared/hooks/query' +import { SETTINGS_META } from '@/shared/seo/routeMeta' import type { CalendarView, ReminderTiming } from '@/shared/types/settings/settings' +import PageMeta from '@/shared/ui/common/PageMeta/PageMeta' import { useAuthStore } from '@/store/useAuthStore' import * as S from './Settings.styles' @@ -56,6 +58,7 @@ export default function SettingsPage() { return ( +

설정

diff --git a/src/pages/main/TodoListPage/TodoListPage.tsx b/src/pages/main/TodoListPage/TodoListPage.tsx index 5e44eeb..47a24a7 100644 --- a/src/pages/main/TodoListPage/TodoListPage.tsx +++ b/src/pages/main/TodoListPage/TodoListPage.tsx @@ -6,6 +6,8 @@ import TodoSection from '@/features/Todo/components/TodoSection/TodoSection' import TodoStatus from '@/features/Todo/components/TodoStatus/TodoStatus' import { getTodoProgressDateParam, getTodoWeekTitle } from '@/features/Todo/utils/todoPage' import { useGetTodoProgressQuery } from '@/shared/hooks/query/useTodoQueries' +import { TODO_META } from '@/shared/seo/routeMeta' +import PageMeta from '@/shared/ui/common/PageMeta/PageMeta' import * as S from './TodoListPage.styles' @@ -15,6 +17,7 @@ export default function TodoListPage() { const { data } = useGetTodoProgressQuery(todayParam) return ( + {titleLabel}
TO DO
diff --git a/src/routes/AuthRoutes.tsx b/src/routes/AuthRoutes.tsx index 119ea14..a0818c2 100644 --- a/src/routes/AuthRoutes.tsx +++ b/src/routes/AuthRoutes.tsx @@ -1,10 +1,12 @@ import type { RouteObject } from 'react-router-dom' import Landing from '@/pages/auth/Landing' -import Login from '@/pages/auth/Login' -import SocialCallback from '@/pages/auth/SocialCallback' import AuthLayout from '@/shared/layout/AuthLayout' +/* + Landing은 검색 유입의 첫 화면이라 진입 청크에 그대로 둡니다. + 나머지 라우트는 lazy로 분리해 랜딩 방문자가 내려받는 JS를 줄입니다. +*/ const AuthRoutes: RouteObject = { element: , children: [ @@ -14,11 +16,11 @@ const AuthRoutes: RouteObject = { }, { path: '/login', - element: , + lazy: async () => ({ Component: (await import('@/pages/auth/Login')).default }), }, { path: '/login/callback/:provider', - element: , + lazy: async () => ({ Component: (await import('@/pages/auth/SocialCallback')).default }), }, ], } diff --git a/src/routes/MainRoutes.tsx b/src/routes/MainRoutes.tsx index 40858e1..9973256 100644 --- a/src/routes/MainRoutes.tsx +++ b/src/routes/MainRoutes.tsx @@ -1,29 +1,36 @@ import type { RouteObject } from 'react-router-dom' -import CalendarPage from '@/pages/main/CalendarPage/CalendarPage' -import FriendsPage from '@/pages/main/FriendsPage/FriendsPage' -import Home from '@/pages/main/HomePage/HomePage' -import TodoListPage from '@/pages/main/TodoListPage/TodoListPage' import MainLayout from '@/shared/layout/MainLayout' +/* + 로그인 이후 화면은 모두 lazy로 분리합니다. + react-big-calendar, dayjs, kakao maps SDK 같은 무거운 의존성이 + 랜딩 진입 청크에 섞이지 않도록 하는 것이 목적입니다. +*/ const MainRoutes: RouteObject = { element: , children: [ { path: '/', - element: , + lazy: async () => ({ Component: (await import('@/pages/main/HomePage/HomePage')).default }), }, { path: '/calendar', - element: , + lazy: async () => ({ + Component: (await import('@/pages/main/CalendarPage/CalendarPage')).default, + }), }, { path: '/todo', - element: , + lazy: async () => ({ + Component: (await import('@/pages/main/TodoListPage/TodoListPage')).default, + }), }, { path: '/friends', - element: , + lazy: async () => ({ + Component: (await import('@/pages/main/FriendsPage/FriendsPage')).default, + }), }, ], } diff --git a/src/routes/Router.tsx b/src/routes/Router.tsx index 1bbc6a4..a20d17b 100644 --- a/src/routes/Router.tsx +++ b/src/routes/Router.tsx @@ -6,19 +6,53 @@ import AuthRoutes from './AuthRoutes' import MainRoutes from './MainRoutes' import SettingRoutes from './SettingRoutes' -export const authRouter = createBrowserRouter([ - AuthRoutes, - { - path: '*', - element: , - }, -]) - -export const mainRouter = createBrowserRouter([ - MainRoutes, - SettingRoutes, - { - path: '*', - element: , - }, -]) +/* + 라우터를 모듈 로드 시점에 만들면 두 라우터가 모두 현재 URL을 매칭해 초기화합니다. + 그 결과 로그아웃 상태의 랜딩에서도 mainRouter가 '/'에 해당하는 HomePage 청크를 내려받았습니다. + + 또 `createBrowserRouter`는 내부에서 바로 `initialize()`를 호출해 history를 구독하므로, + 호출할 때마다 살아있는 라우터가 하나씩 늘어납니다. + 실제로 쓰는 라우터를 한 번만 만들어 재사용합니다. +*/ +type Router = ReturnType + +let authRouter: Router | null = null +let mainRouter: Router | null = null + +/* + 라우트 lazy 청크 로드가 실패해도(배포 직후 구버전 탭 등) react-router 기본 + 에러 화면 대신 ErrorPage가 처리하도록 최상위에 errorElement를 둡니다. +*/ +export const getAuthRouter = () => + (authRouter ??= createBrowserRouter([ + { ...AuthRoutes, errorElement: }, + { + path: '*', + element: , + }, + ])) + +export const getMainRouter = () => + (mainRouter ??= createBrowserRouter([ + { ...MainRoutes, errorElement: }, + { ...SettingRoutes, errorElement: }, + { + path: '*', + element: , + }, + ])) + +/* + 로그인/로그아웃으로 라우터가 교체된 뒤에도 이전 라우터는 history 구독을 유지해, + 뒤로가기(popstate) 시 비활성 라우터가 URL을 매칭하며 자기 lazy 청크를 받아올 수 있습니다. + 교체 시점에 이전 라우터를 dispose하고, 다시 필요해지면 새로 만듭니다. +*/ +export const disposeAuthRouter = () => { + authRouter?.dispose() + authRouter = null +} + +export const disposeMainRouter = () => { + mainRouter?.dispose() + mainRouter = null +} diff --git a/src/routes/SettingRoutes.tsx b/src/routes/SettingRoutes.tsx index b270bbd..e1c4650 100644 --- a/src/routes/SettingRoutes.tsx +++ b/src/routes/SettingRoutes.tsx @@ -1,6 +1,5 @@ import type { RouteObject } from 'react-router-dom' -import SettingsPage from '@/pages/main/SettingPage/SettingsPage' import SettingLayout from '@/shared/layout/SettingLayout' const SettingRoutes: RouteObject = { @@ -9,7 +8,9 @@ const SettingRoutes: RouteObject = { children: [ { index: true, - element: , + lazy: async () => ({ + Component: (await import('@/pages/main/SettingPage/SettingsPage')).default, + }), }, ], } diff --git a/src/shared/api/friends/eventShare.ts b/src/shared/api/friends/eventShare.ts index d234902..23a077c 100644 --- a/src/shared/api/friends/eventShare.ts +++ b/src/shared/api/friends/eventShare.ts @@ -33,6 +33,12 @@ export const eventShareApi = { const { data } = await axiosInstance.get(`${BASE_URL}/invitations`) return data }, + deleteEventParticipants: async (eventId: number): Promise => { + const { data } = await axiosInstance.delete( + `/events/${eventId}/participants`, + ) + return data + }, leaveEvent: async (eventId: number): Promise => { const { data } = await axiosInstance.delete( `/events/${eventId}/participants/leave`, diff --git a/src/shared/hooks/addSchedule/useScheduleFooter.tsx b/src/shared/hooks/addSchedule/useScheduleFooter.tsx index 559c803..386ac94 100644 --- a/src/shared/hooks/addSchedule/useScheduleFooter.tsx +++ b/src/shared/hooks/addSchedule/useScheduleFooter.tsx @@ -1,4 +1,3 @@ -import moment from 'moment' import type { ReactNode } from 'react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { type UseFormGetValues } from 'react-hook-form' @@ -10,6 +9,7 @@ import type { EventColorType, ScheduleEditorFormValues } from '@/shared/types/ev import type { RecurrenceEventScope } from '@/shared/types/recurrence/recurrence' import type { RepeatConfig } from '@/shared/types/recurrence/repeat' import SelectColor from '@/shared/ui/scheduleTodo/SelectColor/SelectColor' +import dayjs from '@/shared/utils/dayjs' type UseScheduleFooterProps = { repeatConfig: RepeatConfig @@ -105,7 +105,7 @@ export const useScheduleFooter = ({ { eventId: eventIdRef.current, params: { - occurrenceDate: moment(occurrenceDateRef.current).format('YYYY-MM-DDTHH:mm:ss'), + occurrenceDate: dayjs(occurrenceDateRef.current).format('YYYY-MM-DDTHH:mm:ss'), }, }, { diff --git a/src/shared/hooks/addSchedule/useSchedulePatchController.ts b/src/shared/hooks/addSchedule/useSchedulePatchController.ts index 09781ca..99532a0 100644 --- a/src/shared/hooks/addSchedule/useSchedulePatchController.ts +++ b/src/shared/hooks/addSchedule/useSchedulePatchController.ts @@ -1,4 +1,3 @@ -import moment from 'moment' import { useCallback, useMemo } from 'react' import { useSchedulePatch } from '@/shared/hooks/addSchedule/useSchedulePatch' @@ -6,6 +5,7 @@ import { useCalendarMutation } from '@/shared/hooks/query/useCalendarMutation' import type { CalendarEvent } from '@/shared/types/calendar/types' import type { RepeatConfigSchema, ScheduleEditorFormValues } from '@/shared/types/event/event' import { defaultRepeatConfig } from '@/shared/types/recurrence/repeat' +import dayjs from '@/shared/utils/dayjs' import { buildDateTime as buildEditorDateTime } from '@/shared/utils/editorDateTime' import { mapRecurrenceGroupToRepeatConfig, @@ -25,7 +25,7 @@ export const useSchedulePatchController = ({ }: UseSchedulePatchControllerProps) => { // API 전송용 날짜/시간 포맷팅 const formatDateTime = useCallback( - (value: Date) => moment(value).format('YYYY-MM-DDTHH:mm:ss'), + (value: Date) => dayjs(value).format('YYYY-MM-DDTHH:mm:ss'), [], ) diff --git a/src/shared/hooks/addTodo/useTodoDetailHydration.ts b/src/shared/hooks/addTodo/useTodoDetailHydration.ts index 3c8a119..a8d123f 100644 --- a/src/shared/hooks/addTodo/useTodoDetailHydration.ts +++ b/src/shared/hooks/addTodo/useTodoDetailHydration.ts @@ -1,4 +1,3 @@ -import moment from 'moment' import { useEffect, useRef } from 'react' import type { UseFormSetValue } from 'react-hook-form' @@ -6,6 +5,7 @@ import { useGetDetailTodoQuery } from '@/shared/hooks/query/useTodoQueries' import type { CalendarEvent } from '@/shared/types/calendar/types' import { type RepeatConfigSchema, type TodoEditorFormValues } from '@/shared/types/event/event' import { defaultRepeatConfig } from '@/shared/types/recurrence/repeat' +import dayjs from '@/shared/utils/dayjs' import { mapRecurrenceGroupToRepeatConfig } from '@/shared/utils/index' type UseTodoDetailHydrationProps = { @@ -29,7 +29,7 @@ export const useTodoDetailHydration = ({ const isPersistedTodo = isEditing && eventId != null && eventId !== 0 && !initialEvent?.isTemporary const shouldFetchDetail = isPersistedTodo - const detailOccurrenceDate = moment(date).format('YYYY-MM-DD') + const detailOccurrenceDate = dayjs(date).format('YYYY-MM-DD') const { data: detailData } = useGetDetailTodoQuery( Number(eventId), detailOccurrenceDate, @@ -83,8 +83,8 @@ export const useTodoDetailHydration = ({ }, [detailData, isEditing, setValue]) const occurrenceDate = detailData?.result?.occurrenceDate - ? moment(detailData.result.occurrenceDate).format('YYYY-MM-DD') - : moment(todoDate ?? date).format('YYYY-MM-DD') + ? dayjs(detailData.result.occurrenceDate).format('YYYY-MM-DD') + : dayjs(todoDate ?? date).format('YYYY-MM-DD') return { detailData, diff --git a/src/shared/seo/routeMeta.ts b/src/shared/seo/routeMeta.ts new file mode 100644 index 0000000..328080a --- /dev/null +++ b/src/shared/seo/routeMeta.ts @@ -0,0 +1,65 @@ +/** + * 라우트별 메타 정보의 단일 소스입니다. + * + * - 런타임: 각 페이지가 `PageMeta`에 이 값을 그대로 전달합니다. + * - 빌드 타임: `scripts/prerender.mjs`가 같은 값으로 정적 HTML의 ``를 생성합니다. + * + * 두 경로가 같은 상수를 쓰므로 문구가 어긋날 일이 없습니다. + */ + +export type RouteMeta = { + title: string + description?: string + canonicalPath?: string + noIndex?: boolean +} + +export const SITE_NAME = 'Calio' + +/** `VITE_SITE_URL`이 없을 때 canonical / og:url / sitemap에 쓰는 기본 배포 도메인입니다. */ +export const DEFAULT_SITE_URL = 'https://calio.co.kr' + +/** 배포 도메인을 정규화합니다. (끝의 `/`를 제거해 경로와 이어 붙일 수 있게 만듭니다) */ +export const resolveSiteUrl = (siteUrl?: string) => (siteUrl || DEFAULT_SITE_URL).replace(/\/$/, '') + +/** og:image 등 절대 URL이 필요한 곳에서 쓰는 배포 도메인입니다. */ +export const OG_IMAGE_PATH = '/og-image.jpg' + +/** 문서 제목을 확정합니다. 이미 서비스명이 들어 있으면 접미사를 붙이지 않습니다. */ +export const resolveTitle = (title: string) => + title.includes(SITE_NAME) ? title : `${title} | ${SITE_NAME}` + +export const LANDING_META: RouteMeta = { + title: 'Calio(캘리오) | 말 한마디로 일정이 완성되는 AI 일정 관리', + description: + 'Calio는 자연어로 말하면 AI가 일정과 할 일을 자동으로 등록해주는 일정 관리 서비스입니다. 반복 일정 추천부터 친구와의 일정 공유까지 한 곳에서 관리하세요.', + canonicalPath: '/', +} + +/** 링크 미리보기용 설명입니다. 검색 스니펫보다 짧게 씁니다. */ +export const LANDING_OG_DESCRIPTION = + '자연어로 말하면 AI가 일정과 할 일을 자동으로 등록해줘요. 반복 일정 추천과 친구 일정 공유까지 한 번에.' + +export const LOGIN_META: RouteMeta = { title: '로그인', noIndex: true } +export const NOT_FOUND_META: RouteMeta = { title: '페이지를 찾을 수 없어요', noIndex: true } +export const HOME_META: RouteMeta = { title: '홈', noIndex: true } +export const CALENDAR_META: RouteMeta = { title: '캘린더', noIndex: true } +export const TODO_META: RouteMeta = { title: '할 일', noIndex: true } +export const FRIENDS_META: RouteMeta = { title: '친구', noIndex: true } +export const SETTINGS_META: RouteMeta = { title: '설정', noIndex: true } + +/** + * 정적 HTML을 생성할 경로 목록입니다. + * + * `prerender`가 true인 경로만 실제 React 트리를 렌더합니다. + * 나머지는 로그인 이후에만 도달하는 화면이라 본문 없이 메타만 맞춘 HTML을 만듭니다. + * (인증/데이터 없이 렌더할 수 없고, 크롤러에게도 보일 필요가 없습니다) + */ +export const PRERENDER_ROUTES: Array<{ path: string; meta: RouteMeta; prerender: boolean }> = [ + { path: '/', meta: LANDING_META, prerender: true }, + { path: '/login', meta: LOGIN_META, prerender: true }, + { path: '/calendar', meta: CALENDAR_META, prerender: false }, + { path: '/todo', meta: TODO_META, prerender: false }, + { path: '/friends', meta: FRIENDS_META, prerender: false }, + { path: '/settings', meta: SETTINGS_META, prerender: false }, +] diff --git a/src/shared/styles/GlobalStyle.tsx b/src/shared/styles/GlobalStyle.tsx index 8655b8f..dc95f06 100644 --- a/src/shared/styles/GlobalStyle.tsx +++ b/src/shared/styles/GlobalStyle.tsx @@ -7,25 +7,10 @@ export default function GlobalStyle() { HTMLElement) => { + const existing = document.head.querySelector(selector) + if (existing) return existing + + const created = createTag() + document.head.appendChild(created) + + return created +} + +const setMetaContent = (name: string, content: string) => { + const tag = upsertTag(`meta[name="${name}"]`, () => { + const meta = document.createElement('meta') + meta.setAttribute('name', name) + + return meta + }) + tag.setAttribute('content', content) +} + +const setPropertyContent = (property: string, content: string) => { + const tag = upsertTag(`meta[property="${property}"]`, () => { + const meta = document.createElement('meta') + meta.setAttribute('property', property) + + return meta + }) + tag.setAttribute('content', content) +} + +const removeTag = (selector: string) => { + document.head.querySelector(selector)?.remove() +} + +export default function PageMeta({ + title, + description, + canonicalPath, + noIndex = false, +}: PageMetaProps) { + const fullTitle = title.includes(SITE_NAME) ? title : `${title} | ${SITE_NAME}` + + useEffect(() => { + document.title = fullTitle + setMetaContent('robots', noIndex ? 'noindex, nofollow' : 'index, follow') + setPropertyContent('og:title', fullTitle) + + if (description) { + setMetaContent('description', description) + setPropertyContent('og:description', description) + } else { + // 이전 페이지(랜딩)의 description이 다른 페이지에 잔류하지 않도록 지웁니다. + removeTag('meta[name="description"]') + removeTag('meta[property="og:description"]') + } + + if (noIndex) { + // index.html의 canonical(`/`)·og:url이 남아 있으면 색인 제외 페이지가 랜딩을 가리키게 됩니다. + removeTag('link[rel="canonical"]') + removeTag('meta[property="og:url"]') + + return + } + + if (canonicalPath) { + // 배포 도메인이 지정돼 있으면 www/비www 같은 접근 경로와 무관하게 한 URL로 고정합니다. + const siteUrl = + (import.meta.env.VITE_SITE_URL as string | undefined)?.replace(/\/$/, '') || + window.location.origin + const canonicalUrl = `${siteUrl}${canonicalPath}` + const link = upsertTag('link[rel="canonical"]', () => { + const created = document.createElement('link') + created.setAttribute('rel', 'canonical') + + return created + }) + link.setAttribute('href', canonicalUrl) + setPropertyContent('og:url', canonicalUrl) + } + }, [fullTitle, description, canonicalPath, noIndex]) + + return null +} diff --git a/src/shared/utils/dayjs.ts b/src/shared/utils/dayjs.ts new file mode 100644 index 0000000..5dea535 --- /dev/null +++ b/src/shared/utils/dayjs.ts @@ -0,0 +1,22 @@ +import 'dayjs/locale/ko' + +import dayjs from 'dayjs' +import isBetween from 'dayjs/plugin/isBetween' +import isSameOrAfter from 'dayjs/plugin/isSameOrAfter' +import isSameOrBefore from 'dayjs/plugin/isSameOrBefore' + +/* + * 앱 전역에서 사용하는 dayjs 설정 모듈입니다. + * dayjs는 'dayjs'에서 직접 import하지 말고 반드시 이 모듈에서 import하세요. + * (플러그인 확장과 ko 로케일 적용이 보장되지 않으면 isBetween 등이 런타임에 터집니다) + * + * react-big-calendar의 dayjsLocalizer는 자신이 필요한 플러그인을 스스로 extend하지만, + * 앱 코드가 캘린더 로드 여부와 무관하게 동작하도록 여기서 명시적으로 extend합니다. + */ +dayjs.extend(isBetween) +dayjs.extend(isSameOrAfter) +dayjs.extend(isSameOrBefore) +dayjs.locale('ko') + +export type { Dayjs } from 'dayjs' +export default dayjs diff --git a/src/store/useAuthStore.ts b/src/store/useAuthStore.ts index 8a86e87..c637e94 100644 --- a/src/store/useAuthStore.ts +++ b/src/store/useAuthStore.ts @@ -6,8 +6,47 @@ interface AuthState { logout: () => void } +/** index.html의 정적 셸 인라인 스크립트도 이 키를 리터럴로 읽습니다. 변경 시 함께 수정하세요. */ +const AUTH_HINT_KEY = 'calio.isLoggedIn' + +/** + * 인증 쿠키는 httpOnly라서 클라이언트에서 읽을 수 없습니다. + * 그래서 마지막으로 확인된 로그인 여부만 힌트로 저장해 첫 렌더에 쓸 라우터를 결정합니다. + * 실제 인증 상태는 앱 부팅 시 `/members/me` 응답으로 항상 다시 확정됩니다. + */ +const readAuthHint = () => { + if (typeof window === 'undefined') return false + + try { + // OAuth 콜백 경로는 로그아웃 상태 라우터에만 존재하므로 힌트를 무시합니다. + if (window.location.pathname.startsWith('/login')) return false + + return window.localStorage.getItem(AUTH_HINT_KEY) === '1' + } catch { + // 시크릿 모드 등 localStorage를 쓸 수 없는 환경 + return false + } +} + +const writeAuthHint = (isLoggedIn: boolean) => { + if (typeof window === 'undefined') return + + try { + if (isLoggedIn) window.localStorage.setItem(AUTH_HINT_KEY, '1') + else window.localStorage.removeItem(AUTH_HINT_KEY) + } catch { + // 저장에 실패해도 동작에 영향은 없습니다. + } +} + export const useAuthStore = create((set) => ({ - isLoggedIn: false, - login: () => set({ isLoggedIn: true }), - logout: () => set({ isLoggedIn: false }), + isLoggedIn: readAuthHint(), + login: () => { + writeAuthHint(true) + set({ isLoggedIn: true }) + }, + logout: () => { + writeAuthHint(false) + set({ isLoggedIn: false }) + }, })) diff --git a/vite.config.ts b/vite.config.ts index d90c075..01c1faf 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,20 +1,134 @@ import react from '@vitejs/plugin-react-swc' import path from 'path' -import { defineConfig } from 'vite' +import { defineConfig, loadEnv, type Plugin } from 'vite' import svgr from 'vite-plugin-svgr' import tsconfigPaths from 'vite-tsconfig-paths' -export default defineConfig({ - plugins: [ - react({ - jsxImportSource: '@emotion/react', - }), - svgr(), - tsconfigPaths(), - ], - resolve: { - alias: { - '@': path.resolve(__dirname, 'src'), +import { resolveSiteUrl } from './src/shared/seo/routeMeta' + +/** + * robots.txt에서 크롤링 자체를 막는 경로입니다. + * + * OAuth 콜백만 막습니다. URL에 인가 코드가 실리므로 크롤러가 접근할 이유가 없습니다. + * 로그인 이후 화면(/calendar 등)은 여기 넣지 않습니다. Disallow하면 크롤러가 + * 페이지의 noindex 메타를 영영 읽지 못해, 외부 링크가 생겼을 때 "차단됨" 상태로 + * URL만 색인될 수 있습니다. 해당 페이지들은 PageMeta의 noindex에만 맡깁니다. + */ +const CRAWL_BLOCKED_PATHS = ['/login/callback/'] + +/** + * 사이트맵에 넣는 공개 경로입니다. + * + * `/login`은 프리렌더 대상이지만 색인 가치가 없어 noindex로 두고 사이트맵에서 제외합니다. + */ +const PUBLIC_ROUTES = [{ path: '/', priority: '1.0', changefreq: 'weekly' }] + +/** + * 배포 도메인을 한 곳에서 관리하는 플러그인입니다. + * - index.html의 `%SITE_URL%`을 실제 도메인으로 치환합니다. + * - robots.txt와 sitemap.xml을 빌드 결과물로 생성합니다. (public/에 두면 도메인이 이중 관리됩니다) + */ +function seoPlugin(siteUrl: string): Plugin { + const buildDate = new Date().toISOString().slice(0, 10) + + return { + name: 'calio-seo', + transformIndexHtml: { + order: 'pre', + handler: (html) => html.replaceAll('%SITE_URL%', siteUrl), + }, + generateBundle() { + const robots = [ + 'User-agent: *', + ...CRAWL_BLOCKED_PATHS.map((blockedPath) => `Disallow: ${blockedPath}`), + '', + `Sitemap: ${siteUrl}/sitemap.xml`, + '', + ].join('\n') + + const urls = PUBLIC_ROUTES.map( + ({ path: routePath, priority, changefreq }) => ` + ${siteUrl}${routePath} + ${buildDate} + ${changefreq} + ${priority} + `, + ).join('\n') + + const sitemap = ` + +${urls} + +` + + this.emitFile({ type: 'asset', fileName: 'robots.txt', source: robots }) + this.emitFile({ type: 'asset', fileName: 'sitemap.xml', source: sitemap }) + }, + } +} + +/** + * 배포마다 바뀌지 않는 프레임워크 코드만 별도 청크로 고정해 브라우저 캐시를 살립니다. + * + * 이 목록을 넓히면 안 됩니다. manualChunks로 지정한 청크는 "아직 배정되지 않은 의존성"까지 + * 함께 흡수하기 때문에, 예를 들어 react-big-calendar를 청크로 지정하면 React 코어까지 + * 그 청크로 끌려가고, 결국 진입 청크가 캘린더 청크 전체(348kB)를 정적으로 의존하게 됩니다. + * 나머지 라이브러리는 Rollup의 기본 분할에 맡기는 편이 안전합니다. + */ +const FRAMEWORK_PACKAGES = new Set([ + 'react', + 'react-dom', + 'react-router', + 'react-router-dom', + 'scheduler', +]) + +/** pnpm 중첩 경로까지 고려해 모듈이 속한 실제 패키지 이름을 뽑습니다. */ +const getPackageName = (id: string) => { + const segments = id.split('node_modules/') + const parts = segments[segments.length - 1].split('/') + + return parts[0].startsWith('@') ? `${parts[0]}/${parts[1]}` : parts[0] +} + +const manualChunk = (id: string) => { + if (!id.includes('node_modules/')) return + // CSS를 vendor 청크에 넣으면 진입 HTML에 스타일시트로 끌려옵니다. + if (id.endsWith('.css')) return + + const packageName = getPackageName(id) + + if (FRAMEWORK_PACKAGES.has(packageName) || packageName.startsWith('@emotion/')) { + return 'react-vendor' + } + + return +} + +export default defineConfig(({ mode }) => { + const env = loadEnv(mode, process.cwd(), '') + const siteUrl = resolveSiteUrl(env.VITE_SITE_URL) + + return { + plugins: [ + react({ + jsxImportSource: '@emotion/react', + }), + svgr(), + tsconfigPaths(), + seoPlugin(siteUrl), + ], + resolve: { + alias: { + '@': path.resolve(__dirname, 'src'), + }, + }, + build: { + rollupOptions: { + output: { + manualChunks: (id) => manualChunk(id), + }, + }, }, - }, + } })