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
-
+
@@ -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 = ''
+const SEO_END = ''
+const ROOT_PLACEHOLDER = ''
+
+const escapeHtml = (value) =>
+ value
+ .replace(/&/g, '&')
+ .replace(//g, '>')
+ .replace(/"/g, '"')
+
+/** 라우트 하나의 `` 메타 블록을 만듭니다. */
+const buildHead = ({ meta, siteUrl, resolveTitle, ogDescription, ogImagePath }) => {
+ const title = escapeHtml(resolveTitle(meta.title))
+ const tags = [`${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() {
-
+
Calendar
+ i/O
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),
+ },
+ },
},
- },
+ }
})