From 533de126fb534d24af3f370f511755e40ee644da Mon Sep 17 00:00:00 2001 From: Mina Choi Date: Mon, 7 Sep 2026 11:30:27 +0900 Subject: [PATCH] =?UTF-8?q?[feat]=20solution/frontend,nginx:=20=EB=9E=9C?= =?UTF-8?q?=EB=94=A9=C2=B7=EC=9A=94=EA=B8=88=C2=B7=EC=82=AC=EB=A1=80?= =?UTF-8?q?=EB=A5=BC=20=ED=94=84=EB=A6=AC=EB=A0=8C=EB=8D=94=20=E2=80=94=20?= =?UTF-8?q?=ED=81=AC=EB=A1=A4=EB=9F=AC=EA=B0=80=20=EB=B9=88=20=EC=A2=85?= =?UTF-8?q?=EC=9D=B4=EB=A5=BC=20=EB=B0=9B=EB=8D=98=20=EA=B2=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 실측(2026-09-07): `curl /` 가 3,021바이트에 본문 0자·`` 0개였다. 같은 호스트의 발행본은 48,072바이트다. 구글은 JS 를 실행하지만 **렌더링 큐가 따로** 돌고 신규 도메인은 뒤로 밀린다 — 그동안 색인에는 "제목만 있고 내용 없는 페이지"로 들어가 있다. 서치콘솔이 "URL이 Google에 등록되어 있음"이라고 답하면서도 브랜드명 검색에조차 안 걸리던 이유다. 스크립트를 새로 짜지 않았다. react-router 7.17 에 프리렌더가 내장돼 있고 `ssr: false` 와 함께 쓰면 런타임 Node 서버 없이 지정한 경로만 HTML 로 굽는다 — 나머지는 지금까지처럼 SPA 폴백이다. 배포 구조가 그대로다. - react-router.config.ts: `ssr:false` + `prerender: ['/', '/pricing', '/showcase']`. 로그인 뒤에만 의미가 있는 화면은 굽지 않는다(구울 내용이 사용자별이다) - src/root.tsx · src/routes.ts: 예전 index.html + app/router.tsx 가 하던 일. 가드는 페이지마다 감싸지 않고 RequireAuthLayout 레이아웃 라우트 하나로 모았다 - 랜딩·요금·사례에 meta export: 제목을 브랜드가 아니라 **검색어**로 시작하게 바꿨다. 예전 제목("Web4Ai · AI 웹 빌더")에는 사람이 치는 말이 한 단어도 없었다. 랜딩에 Organization JSON-LD 추가 — 발행본에는 있는데 정작 랜딩엔 없었다 - src/lib/site.ts: 발행 호스트의 단일 출처. 모듈 최상위의 `window.location` 폴백을 전부 걷었다 — 서버 번들은 라우트를 한 파일로 묶어서 프리렌더 대상이 아닌 화면의 최상위 코드도 빌드 때 실행된다(실측: BuilderPage 에서 빌드가 죽었다) - LoginPage: homePath 기본값 `/` → `/sites`. 예전엔 router.tsx 가 넘기던 값이라 라우트 모듈로 옮기면서 그대로 두면 로그인 후 랜딩으로 갔다 - nginx: SPA 폴백을 `/index.html` → `/__spa-fallback.html`. 프리렌더 뒤로 `/index.html` 은 **랜딩이 구워진 파일**이라, 그리로 넘기면 `/builder` 에 랜딩 HTML 이 내려가고 클라이언트가 다른 주소로 하이드레이트한다 - nginx/Dockerfile: 산출물이 `dist` → `build/client`. 경로가 어긋나면 COPY 가 조용히 빈 디렉토리를 만들고 컨테이너는 정상으로 뜬다 - site/seo/robots.ts: `/builder` `/login` `/signup` `/sites` `/account` Disallow. 이 경로들은 빈 SPA 폴백을 받는다 — 긁히면 호스트 전체에 저품질 신호가 쌓인다 검증: tsc·eslint·react-router build 통과. 랜딩 3,021B → 21,799B, 본문 1,278자, 링크 6개. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_019fteiJNvAEbTnUKq8fSqoj --- .gitignore | 3 + nginx/Dockerfile | 4 +- nginx/site.conf.example | 12 +- package-lock.json | 514 +++++++++++++++++- solution/frontend/index.html | 45 -- solution/frontend/package.json | 11 +- solution/frontend/react-router.config.ts | 24 + solution/frontend/src/app/main.tsx | 14 - solution/frontend/src/app/router.tsx | 98 ---- .../components/layout/RequireAuthLayout.tsx | 14 + .../src/features/builder/CanvasView.tsx | 5 +- .../src/features/marketing/ShowcaseGrid.tsx | 12 +- .../src/features/publish/PublishModal.tsx | 5 +- solution/frontend/src/lib/site.ts | 15 + solution/frontend/src/pages/AccountPage.tsx | 3 + solution/frontend/src/pages/BuilderPage.tsx | 8 +- .../frontend/src/pages/DevShowcasePage.tsx | 3 + solution/frontend/src/pages/LandingPage.tsx | 41 ++ solution/frontend/src/pages/LoginPage.tsx | 6 +- solution/frontend/src/pages/NotFoundPage.tsx | 3 + solution/frontend/src/pages/PricingPage.tsx | 17 + solution/frontend/src/pages/ShowcasePage.tsx | 17 + solution/frontend/src/pages/SignupPage.tsx | 3 + solution/frontend/src/pages/SitesPage.tsx | 8 +- solution/frontend/src/root.tsx | 82 +++ solution/frontend/src/routes.ts | 42 ++ solution/frontend/vite.config.ts | 9 +- solution/site/src/seo/robots.ts | 15 +- 28 files changed, 850 insertions(+), 183 deletions(-) delete mode 100644 solution/frontend/index.html create mode 100644 solution/frontend/react-router.config.ts delete mode 100644 solution/frontend/src/app/main.tsx delete mode 100644 solution/frontend/src/app/router.tsx create mode 100644 solution/frontend/src/components/layout/RequireAuthLayout.tsx create mode 100644 solution/frontend/src/lib/site.ts create mode 100644 solution/frontend/src/root.tsx create mode 100644 solution/frontend/src/routes.ts diff --git a/.gitignore b/.gitignore index 8e202b1..2d394da 100644 --- a/.gitignore +++ b/.gitignore @@ -26,6 +26,9 @@ solution/backend/openapi.json # 여기 생긴다. 어느 쪽이든 payload 로 다시 굽는 재생성물이라 git 이 관리할 대상이 아니다. solution/site/out/ solution/site/dist/ +# React Router 프레임워크 모드(solution/frontend)의 산출물 +build/ +.react-router/ admin/dist/ # nginx 설정: 서버마다 다르므로 실제 파일은 커밋하지 않는다. 템플릿만 커밋한다. diff --git a/nginx/Dockerfile b/nginx/Dockerfile index f370b72..0913c69 100644 --- a/nginx/Dockerfile +++ b/nginx/Dockerfile @@ -43,4 +43,6 @@ ENV VITE_API_BASE_URL=$VITE_API_BASE_URL \ RUN npm run build -w @o2o/frontend FROM nginx:alpine -COPY --from=build /app/solution/frontend/dist /srv/app +# ★ 프레임워크 모드(React Router)의 산출물은 `build/client` 다 — 예전 `dist` 가 아니다. +# 경로가 어긋나면 COPY 가 조용히 빈 디렉토리를 만들고 컨테이너는 정상으로 뜬다. +COPY --from=build /app/solution/frontend/build/client /srv/app diff --git a/nginx/site.conf.example b/nginx/site.conf.example index 9f31085..16b79e3 100644 --- a/nginx/site.conf.example +++ b/nginx/site.conf.example @@ -121,12 +121,16 @@ server { try_files $uri =404; } - # SPA 다. 없는 경로는 index.html 로 넘겨 클라이언트 라우터가 받게 한다. - # ★ index.html 은 캐시하지 않는다 — 여기에 번들 해시가 박혀 있어서, 캐시되면 - # 새로 배포해도 브라우저가 옛 번들 주소를 계속 부른다(404 → 흰 화면). + # ★ 폴백은 `/index.html` 이 아니라 `__spa-fallback.html` 이다. + # 프리렌더를 켠 뒤로 `/index.html` 은 **랜딩이 구워진 파일**이다 — 여기로 넘기면 + # `/builder` 를 열었는데 랜딩 HTML 이 내려가고, 클라이언트 라우터는 다른 주소로 + # 하이드레이트한다. 화면은 뜨는데 한 번 깜빡이고 마크업이 어긋나는 종류다. + # 구워진 경로(`/` `/pricing` `/showcase`)는 그 앞의 `$uri/index.html` 이 먼저 잡는다. + # ★ HTML 은 캐시하지 않는다 — 번들 해시가 박혀 있어서, 캐시되면 새로 배포해도 + # 브라우저가 옛 번들 주소를 계속 부른다(404 → 흰 화면). location / { root /srv/app; - try_files $uri $uri/index.html /index.html; + try_files $uri $uri/index.html /__spa-fallback.html; add_header Cache-Control "no-cache"; } diff --git a/package-lock.json b/package-lock.json index 032438f..d6075fc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -215,6 +215,19 @@ "node": ">=6.9.0" } }, + "node_modules/@babel/helper-annotate-as-pure": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.29.7.tgz", + "integrity": "sha512-OoK6239jHPuSQOoS0kfTVKn0b/rVTk0seKq4Gd2UMLtmOVLjDC0ki3e+c90Trqv2gMfvJFqkiljrr568+qddiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/helper-compilation-targets": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", @@ -232,6 +245,28 @@ "node": ">=6.9.0" } }, + "node_modules/@babel/helper-create-class-features-plugin": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.29.7.tgz", + "integrity": "sha512-IY3ZD9Tmooqr3TUhc3DUWxiuo8xx1DWLhd5M7hQ+ZWJamqM2BbalrBJb2MisSLoYorOj75U03qULCxQTY9r3hg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.29.7", + "@babel/helper-member-expression-to-functions": "^7.29.7", + "@babel/helper-optimise-call-expression": "^7.29.7", + "@babel/helper-replace-supers": "^7.29.7", + "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7", + "@babel/traverse": "^7.29.7", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, "node_modules/@babel/helper-globals": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", @@ -242,6 +277,20 @@ "node": ">=6.9.0" } }, + "node_modules/@babel/helper-member-expression-to-functions": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.29.7.tgz", + "integrity": "sha512-j+7JYmk1JYDtACIGj0QJqqWZjoUpMoEikQGADMaHgCMCSDqd2+P32rfcibUNrGOMWrlzK1WJBdxrB3JJQZwWtg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/helper-module-imports": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", @@ -274,6 +323,19 @@ "@babel/core": "^7.0.0" } }, + "node_modules/@babel/helper-optimise-call-expression": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.29.7.tgz", + "integrity": "sha512-+kmGVjcT9RGYzoDwdwEqEvGgKe3BYq+O1iGzjFubaNgZHwYHP6lsF2Yghf4kEuv9BV7tYDZ913aBW9am6YKong==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/helper-plugin-utils": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", @@ -284,6 +346,38 @@ "node": ">=6.9.0" } }, + "node_modules/@babel/helper-replace-supers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.29.7.tgz", + "integrity": "sha512-atfGXWSeCiF4DnKZIfmJfQRkSw9b9gNNXR1kqKjbhG4pGYCOnkp8OcTB8E3NXjBu8NpheSnOeNKz8KT7UNFTmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-member-expression-to-functions": "^7.29.7", + "@babel/helper-optimise-call-expression": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-skip-transparent-expression-wrappers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.29.7.tgz", + "integrity": "sha512-brcMGQaVzIeUb+6/bs1Av0f8YuNNjKY2JyvfRCsFuFsdKccEQ5Ges2y74D74NZ1Rz8lKJ9ksJkfqwQFJ/iNEyQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/helper-string-parser": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", @@ -344,6 +438,55 @@ "node": ">=6.0.0" } }, + "node_modules/@babel/plugin-syntax-jsx": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.29.7.tgz", + "integrity": "sha512-TSu8+mHCoEaaCDEZ0I3+6mvTBYR4PCxQwf2z9/r5Tbztv6NaLR3B9thGTTxX2WGuGHJqRiAbKPeGTJ5XWXVg6A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-typescript": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.29.7.tgz", + "integrity": "sha512-ngr+82Sh0xMz25TPCZi+nC2iTzjfCdWS2ONXTp/PtSCHCgaCNBpdMqgvJ2ccdLlClVZ7sisIgB914j/JFe+RZA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-modules-commonjs": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.29.7.tgz", + "integrity": "sha512-j0vCldybPC5b5dwCQOJ21uKtHzt7hxLygJTg9eF1ScfaikEDNfzn94XoW5Fi+seBR0nCyL23xaBFFkq7dTM8XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, "node_modules/@babel/plugin-transform-react-jsx-self": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz", @@ -376,6 +519,46 @@ "@babel/core": "^7.0.0-0" } }, + "node_modules/@babel/plugin-transform-typescript": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.29.7.tgz", + "integrity": "sha512-jK52h8LaLc7JarhQV2ofeFMts4H7vnOXnqZNA6fYglBTZewRBE51KWt3BUltW1P+KoPsYkHoJeXePuz4zo2LMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.29.7", + "@babel/helper-create-class-features-plugin": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7", + "@babel/plugin-syntax-typescript": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/preset-typescript": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/preset-typescript/-/preset-typescript-7.29.7.tgz", + "integrity": "sha512-/Foi8vKY2EVbed/1eZx0gJEEwHAIxogrySI7rULcRIvhZzbvoE/b5qG5Ghc0WKAFKOHA9SD1x7RsFlOYdutIiQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "@babel/plugin-syntax-jsx": "^7.29.7", + "@babel/plugin-transform-modules-commonjs": "^7.29.7", + "@babel/plugin-transform-typescript": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, "node_modules/@babel/template": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", @@ -1425,6 +1608,12 @@ "jsep": "^0.4.0||^1.0.0" } }, + "node_modules/@mjackson/node-fetch-server": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/@mjackson/node-fetch-server/-/node-fetch-server-0.2.0.tgz", + "integrity": "sha512-EMlH1e30yzmTpGLQjlFmaDAjyOeZhng1/XCd7DExR8PNAnG/G1tyruZxEoUe11ClnwGhGrtsdnyyUx1frSzjng==", + "license": "MIT" + }, "node_modules/@napi-rs/lzma-linux-x64-gnu": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", @@ -1631,6 +1820,153 @@ "openapi3-ts": "4.5.0" } }, + "node_modules/@react-router/dev": { + "version": "7.18.3", + "resolved": "https://registry.npmjs.org/@react-router/dev/-/dev-7.18.3.tgz", + "integrity": "sha512-smLBdktEcLw1BgjaeWZG+TDRpmK9Mry4DzgNv4q556/Kq9qDo9Lfxu9Gp4/4BGOctFQG2UuyvxOnOhz0tEyzWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.27.7", + "@babel/generator": "^7.27.5", + "@babel/parser": "^7.27.7", + "@babel/plugin-syntax-jsx": "^7.27.1", + "@babel/preset-typescript": "^7.27.1", + "@babel/traverse": "^7.27.7", + "@babel/types": "^7.27.7", + "@react-router/node": "7.18.3", + "@remix-run/node-fetch-server": "^0.13.0", + "arg": "^5.0.1", + "babel-dead-code-elimination": "^1.0.6", + "chokidar": "^4.0.0", + "dedent": "^1.5.3", + "es-module-lexer": "^1.3.1", + "exit-hook": "2.2.1", + "isbot": "^5.1.11", + "jsesc": "3.0.2", + "lodash": "^4.17.21", + "p-map": "^7.0.3", + "pathe": "^1.1.2", + "picocolors": "^1.1.1", + "pkg-types": "^2.3.0", + "prettier": "^3.6.2", + "react-refresh": "^0.14.0", + "semver": "^7.3.7", + "tinyglobby": "^0.2.14", + "valibot": "^1.2.0", + "vite-node": "^3.2.2" + }, + "bin": { + "react-router": "bin.js" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@react-router/serve": "^7.18.3", + "@vitejs/plugin-rsc": "~0.5.21", + "react-router": "^7.18.3", + "react-server-dom-webpack": "^19.2.3", + "typescript": "^5.1.0 || ^6.0.0", + "vite": "^5.1.0 || ^6.0.0 || ^7.0.0 || ^8.0.0", + "wrangler": "^3.28.2 || ^4.0.0" + }, + "peerDependenciesMeta": { + "@react-router/serve": { + "optional": true + }, + "@vitejs/plugin-rsc": { + "optional": true + }, + "react-server-dom-webpack": { + "optional": true + }, + "typescript": { + "optional": true + }, + "wrangler": { + "optional": true + } + } + }, + "node_modules/@react-router/dev/node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@react-router/dev/node_modules/jsesc": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.0.2.tgz", + "integrity": "sha512-xKqzzWXDttJuOcawBt4KnKHHIf5oQ/Cxax+0PWFG+DFDgHNAdi+TXECADI+RYiFUMmx8792xsMbbgXj4CwnP4g==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/@react-router/dev/node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@react-router/dev/node_modules/react-refresh": { + "version": "0.14.2", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.14.2.tgz", + "integrity": "sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/@react-router/dev/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@react-router/node": { + "version": "7.18.3", + "resolved": "https://registry.npmjs.org/@react-router/node/-/node-7.18.3.tgz", + "integrity": "sha512-wIBFSsmp+uA/F2MEHN1BxFoWAhh9rdIH/Zd39KYyLhZSeb35YlRi8ARtOMDYj7EqhhZfkoetf/SEmYywK3nUkA==", + "license": "MIT", + "dependencies": { + "@mjackson/node-fetch-server": "^0.2.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react-router": "7.18.3", + "typescript": "^5.1.0 || ^6.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@remix-run/node-fetch-server": { + "version": "0.13.3", + "resolved": "https://registry.npmjs.org/@remix-run/node-fetch-server/-/node-fetch-server-0.13.3.tgz", + "integrity": "sha512-UfjOXed/DQteaM5VyTfqTeGpHwyL2J5aoRGY6cydip4tt1ehNNeSwuXCC7AEGE0RWBs/7bgKxYkL/B/+UDe4AA==", + "dev": true, + "license": "MIT" + }, "node_modules/@rolldown/pluginutils": { "version": "1.0.0-rc.3", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz", @@ -3376,6 +3712,13 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/arg": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", + "dev": true, + "license": "MIT" + }, "node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", @@ -3478,6 +3821,19 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/babel-dead-code-elimination": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/babel-dead-code-elimination/-/babel-dead-code-elimination-1.0.12.tgz", + "integrity": "sha512-GERT7L2TiYcYDtYk1IpD+ASAYXjKbLTDPhBtYj7X1NuRMDTMtAx9kyBenub1Ev41lo91OHCKdmP+egTDmfQ7Ig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.23.7", + "@babel/parser": "^7.23.6", + "@babel/traverse": "^7.23.7", + "@babel/types": "^7.23.6" + } + }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -3556,6 +3912,16 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/call-bind": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz", @@ -3767,6 +4133,13 @@ "dev": true, "license": "MIT" }, + "node_modules/confbox": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.3.1.tgz", + "integrity": "sha512-cKUSoKa8YxFZZSmraVi7onONx3amu77ngK3kGpsYHDH7drPwCRkQE1RYMPlLRrMtnciRj274XNRxcHxnKmDSnA==", + "dev": true, + "license": "MIT" + }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -3881,6 +4254,21 @@ } } }, + "node_modules/dedent": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.2.tgz", + "integrity": "sha512-WzMx3mW98SN+zn3hgemf4OzdmyNhhhKz5Ay0pUfQiMQ3e1g+xmTJWp/pKdwKVXhdSkAEGIIzqeuWrL3mV/AXbA==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "babel-plugin-macros": "^3.1.0" + }, + "peerDependenciesMeta": { + "babel-plugin-macros": { + "optional": true + } + } + }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -4545,6 +4933,19 @@ "url": "https://github.com/sindresorhus/execa?sponsor=1" } }, + "node_modules/exit-hook": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/exit-hook/-/exit-hook-2.2.1.tgz", + "integrity": "sha512-eNTPlAD67BmP31LDINZ3U7HSF8l57TxOY2PmBJ1shpCvpnxBF93mWCE8YHBnXs8qiUZJc9WDcWIeC3a2HIAMfw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/expect-type": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", @@ -4565,6 +4966,13 @@ "node": ">=16.9.0" } }, + "node_modules/exsolve": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.1.1.tgz", + "integrity": "sha512-9U/jZUgjnSGyntRr6y5Muu1MJcwFl6kPu7k8qLF0IMNfLqvw0NZ4nnVDq0RVoZ0RvCyumib4Ez3KYrVfilrw+g==", + "dev": true, + "license": "MIT" + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -5640,6 +6048,15 @@ "dev": true, "license": "MIT" }, + "node_modules/isbot": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/isbot/-/isbot-5.2.2.tgz", + "integrity": "sha512-iQcBXcd+Rv/pkubRyGh2utW2j1oPG5hZY6TUhVPpqK4G+o3IbxpJNx04hgksjc/N7GK5pEorUxDeg31cFgEk/w==", + "license": "Unlicense", + "engines": { + "node": ">=18" + } + }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -6826,6 +7243,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/p-map": { + "version": "7.0.7", + "resolved": "https://registry.npmjs.org/p-map/-/p-map-7.0.7.tgz", + "integrity": "sha512-VaWRu2i4FJNRtiRWCuuQRgfQ1B7a6+gMSrO+3j0EQi/k0ULfS9kosRxGoiqwzIjZTDI02tGfk5mXXltLg6QtfQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -6895,6 +7325,18 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/pkg-types": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.2.tgz", + "integrity": "sha512-v0sVXzj7oPGysr543YYZLYbcJNJsKikSsp/fFzoxQ12ewY3ZZr7oCPC8y7OlmxfYB3QPvriXmuPD8KZggE1vqg==", + "dev": true, + "license": "MIT", + "dependencies": { + "confbox": "^0.3.0", + "exsolve": "^1.1.1", + "pathe": "^2.0.3" + } + }, "node_modules/pony-cause": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/pony-cause/-/pony-cause-1.1.1.tgz", @@ -6953,6 +7395,22 @@ "node": ">= 0.8.0" } }, + "node_modules/prettier": { + "version": "3.9.6", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.6.tgz", + "integrity": "sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -7042,9 +7500,9 @@ } }, "node_modules/react-router": { - "version": "7.18.2", - "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.2.tgz", - "integrity": "sha512-aUVMjFm3GAPTTZL7oYr5E7ETiqfQCHRLH+B+5afnICvf0r7kkK4eR6SMuwbSTJw/7t+12khT/Kahij49fqOCIg==", + "version": "7.18.3", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.3.tgz", + "integrity": "sha512-gyXgtdr5uACJ5b1Q4udzjVV+tb/rlHIMJKuJ0e89R4Kzgz47z/rgP0dIKxktqIEUhDHluGTPJJH/wRha7CyqsA==", "license": "MIT", "dependencies": { "cookie": "^1.0.1", @@ -8137,7 +8595,7 @@ "version": "5.8.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", @@ -8272,6 +8730,21 @@ "node": ">= 4" } }, + "node_modules/valibot": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/valibot/-/valibot-1.4.2.tgz", + "integrity": "sha512-gjdCvJ6d3RyHAneqxMYMW9QMCwYMb3jpOO0IyHZV1bnRHFBHrX3VkIILt5XYR0WhwHiH7Mty8ovuPZ/O3gamrg==", + "devOptional": true, + "license": "MIT", + "peerDependencies": { + "typescript": ">=5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, "node_modules/validator": { "version": "13.15.23", "resolved": "https://registry.npmjs.org/validator/-/validator-13.15.23.tgz", @@ -8356,6 +8829,36 @@ } } }, + "node_modules/vite-node": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", + "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.4.1", + "es-module-lexer": "^1.7.0", + "pathe": "^2.0.3", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vite-node/node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, "node_modules/vite/node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -8791,10 +9294,12 @@ "@dnd-kit/utilities": "^3.2.2", "@hookform/resolvers": "^5.4.0", "@o2o/shared": "*", + "@react-router/node": "^7.18.3", "@tailwindcss/vite": "^4.1.14", "@tanstack/react-query": "^5.62.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "isbot": "^5", "lucide-react": "^0.546.0", "motion": "^12.23.24", "react": "^19.0.1", @@ -8808,6 +9313,7 @@ "zustand": "^5.0.14" }, "devDependencies": { + "@react-router/dev": "^7.18.3", "@types/node": "^22.14.0", "@types/react": "^19.2.17", "@types/react-dom": "^19.2.3", diff --git a/solution/frontend/index.html b/solution/frontend/index.html deleted file mode 100644 index 6c3f519..0000000 --- a/solution/frontend/index.html +++ /dev/null @@ -1,45 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - Web4Ai · AI 웹 빌더 - - -
- - - diff --git a/solution/frontend/package.json b/solution/frontend/package.json index 0ffa08b..01b234a 100644 --- a/solution/frontend/package.json +++ b/solution/frontend/package.json @@ -4,11 +4,11 @@ "version": "0.0.0", "type": "module", "scripts": { - "dev": "vite --port=3000 --host=0.0.0.0", - "build": "tsc --noEmit && eslint src && vite build", + "dev": "react-router dev --port=3000 --host=0.0.0.0", + "build": "react-router typegen && tsc --noEmit && eslint src && react-router build", "preview": "vite preview", - "clean": "rm -rf dist", - "lint": "tsc --noEmit && eslint src", + "clean": "rm -rf build .react-router", + "lint": "react-router typegen && tsc --noEmit && eslint src", "orval": "orval --config ./orval.config.ts" }, "dependencies": { @@ -17,10 +17,12 @@ "@dnd-kit/utilities": "^3.2.2", "@hookform/resolvers": "^5.4.0", "@o2o/shared": "*", + "@react-router/node": "^7.18.3", "@tailwindcss/vite": "^4.1.14", "@tanstack/react-query": "^5.62.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "isbot": "^5", "lucide-react": "^0.546.0", "motion": "^12.23.24", "react": "^19.0.1", @@ -34,6 +36,7 @@ "zustand": "^5.0.14" }, "devDependencies": { + "@react-router/dev": "^7.18.3", "@types/node": "^22.14.0", "@types/react": "^19.2.17", "@types/react-dom": "^19.2.3", diff --git a/solution/frontend/react-router.config.ts b/solution/frontend/react-router.config.ts new file mode 100644 index 0000000..0273023 --- /dev/null +++ b/solution/frontend/react-router.config.ts @@ -0,0 +1,24 @@ +import type {Config} from '@react-router/dev/config'; + +/** + * 프리렌더 설정. + * + * ★ 왜 하나 — 랜딩은 `
` 만 내보내는 CSR 이었다. 실측(2026-09-07): + * `curl /` 는 3,021바이트에 `
` 0개·본문 0자. 같은 호스트의 발행본은 48,072바이트다. + * 구글은 JS 를 실행하지만 **렌더링 큐가 따로** 돌고 신규 도메인은 뒤로 밀린다 — + * 그동안 색인에는 "제목만 있고 내용 없는 페이지"로 들어가 있다. + * + * ★ `ssr: false` 와 함께 쓴다. 런타임 Node 서버를 두지 않는다는 뜻이다 — + * 아래 목록만 HTML 로 굽고, 나머지(`/builder` `/login` `/sites` `/account`)는 + * 지금까지처럼 SPA 폴백으로 나간다. nginx 설정도 배포 구조도 그대로다. + * + * ★ 로그인 뒤에만 의미가 있는 화면은 굽지 않는다. 구울 내용이 없고(데이터가 사용자별), + * robots.txt 로 막을 대상이기도 하다. + */ +export default { + // 이 레포는 소스가 `src/` 밑이다(기본값 `app/` 이 아니다). + // root.tsx · routes.ts 를 여기서 찾는다. + appDirectory: 'src', + ssr: false, + prerender: ['/', '/pricing', '/showcase'], +} satisfies Config; diff --git a/solution/frontend/src/app/main.tsx b/solution/frontend/src/app/main.tsx deleted file mode 100644 index 867493b..0000000 --- a/solution/frontend/src/app/main.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import {StrictMode} from 'react'; -import {createRoot} from 'react-dom/client'; -import {RouterProvider} from 'react-router'; -import {Providers} from './provider'; -import {router} from './router'; -import '../index.css'; - -createRoot(document.getElementById('root')!).render( - - - - - , -); diff --git a/solution/frontend/src/app/router.tsx b/solution/frontend/src/app/router.tsx deleted file mode 100644 index 2621c72..0000000 --- a/solution/frontend/src/app/router.tsx +++ /dev/null @@ -1,98 +0,0 @@ -import {createBrowserRouter, Navigate} from 'react-router'; -import {Loader2} from 'lucide-react'; -import {AccountPage} from '@/pages/AccountPage'; -import {BuilderPage} from '@/pages/BuilderPage'; -import {DevShowcasePage} from '@/pages/DevShowcasePage'; -import {LandingPage} from '@/pages/LandingPage'; -import {LoginPage} from '@/pages/LoginPage'; -import {NotFoundPage} from '@/pages/NotFoundPage'; -import {PricingPage} from '@/pages/PricingPage'; -import {ShowcasePage} from '@/pages/ShowcasePage'; -import {SignupPage} from '@/pages/SignupPage'; -import {SitesPage} from '@/pages/SitesPage'; -import {RequireAuth} from '@/components/layout/RequireAuth'; -import {useAuthStore} from '@/stores/auth'; - -/** - * 첫 화면은 로그인 여부로 갈린다 — 이미 사이트를 가진 사장님의 용건은 "새로 만들기"가 아니라 - * "내 것 고치기"다. 비로그인은 **랜딩**을 본다. - * - * ★ 예전엔 비로그인을 곧장 위저드로 보냈다. 그러면 이 제품이 무엇을 파는 물건인지 말할 자리가 - * 한 곳도 없다 — 처음 온 사람이 업종 선택 화면부터 만난다. - * ★ 복구가 끝나기 전에 판단하면 새로고침할 때마다 화면이 한 번 번쩍이고 목록으로 튄다. - */ -function Home() { - const isRestoring = useAuthStore((s) => s.isRestoring); - const user = useAuthStore((s) => s.user); - - if (isRestoring) { - return ( -
- -
- ); - } - /* - * ★ 로그인해도 랜딩을 보여 준다 (2026-09-04, 사장님 지적) - * 예전엔 여기서 /sites 로 튕겼다. 그러면 **로그인한 사장님은 랜딩·요금에 갈 길이 없다** — - * 로고가 '/' 를 걸고 있어도 눌리는 순간 목록으로 돌아온다. - * "로그인 직후엔 내 사이트로" 는 로그인·가입 화면이 직접 보낸다(LoginPage homePath). - */ - void user; - return ; -} - -export const router = createBrowserRouter([ - {path: '/login', element: }, - // 로그인 화면의 [회원가입] 이 여기로 온다. 이 줄이 없으면 링크는 있고 목적지만 404 다. - {path: '/signup', element: }, - - // 비로그인 = 랜딩, 로그인 = 내 사이트. Home 이 그걸 가른다. - {path: '/', element: }, - - // 로그인 전 화면. ★ 랜딩과 같은 껍데기(MarketingShell)를 쓴다 — 사이드바 없는 문서형이다. - {path: '/pricing', element: }, - {path: '/showcase', element: }, - - // 로그인한 사장님의 홈. 만든 사이트를 열고 고치는 자리다. - { - path: '/sites', - element: ( - - - - ), - }, - { - path: '/account', - element: ( - - - - ), - }, - - /** - * 빌더는 로그인 화면을 앞에 세우지 않는다 — 위저드를 열자마자 로그인부터 만나면 - * 만들어 보기도 전에 막힌다. - * - * 대신 세션은 조용히 확보한다 — VITE_AUTO_LOGIN_ID·PW 가 주입돼 있으면 useAutoLogin() 이 - * 그 계정으로 붙고, 없으면 서버가 필요한 순간(2단계 검색)에만 알린다. - * - * 에디터(6단계)는 전체 화면이 필요해 AppShell 을 스스로 끄고 켠다 — BuilderPage 참조. - */ - {path: '/builder', element: }, - - /** - * ★ 내부 운영 화면(/places, /local-content, /seo)은 여기 없다 — 최상단 `admin/` 앱으로 나갔다. - * 라우트 가드는 화면을 가리지 **번들은 못 가린다**: 한 앱이면 내부 라우트 이름과 코드가 - * 사장님 브라우저에 그대로 내려간다. `UserRole.DEVELOPER` 주석의 - * "고객사에 존재를 노출하지 않는다"를 지키려면 번들 자체가 갈라져야 한다. - * (ARCHITECTURE.md 4절) - */ - - // 개발 빌드에서만 열리는 컴포넌트 쇼케이스. 운영 번들에서는 라우트 자체가 없다. - ...(import.meta.env.DEV ? [{path: '/dev/showcase', element: }] : []), - - {path: '*', element: }, -]); diff --git a/solution/frontend/src/components/layout/RequireAuthLayout.tsx b/solution/frontend/src/components/layout/RequireAuthLayout.tsx new file mode 100644 index 0000000..75eb24f --- /dev/null +++ b/solution/frontend/src/components/layout/RequireAuthLayout.tsx @@ -0,0 +1,14 @@ +import {Outlet} from 'react-router'; +import {RequireAuth} from './RequireAuth'; + +/** + * 인증이 필요한 라우트들의 부모. 예전 `router.tsx` 가 페이지마다 로 + * 감싸던 것을 레이아웃 라우트 하나로 모았다 — 가드가 한 자리에 있어야 빠뜨리지 않는다. + */ +export default function RequireAuthLayout() { + return ( + + + + ); +} diff --git a/solution/frontend/src/features/builder/CanvasView.tsx b/solution/frontend/src/features/builder/CanvasView.tsx index 4ed1789..a41eee3 100644 --- a/solution/frontend/src/features/builder/CanvasView.tsx +++ b/solution/frontend/src/features/builder/CanvasView.tsx @@ -6,11 +6,14 @@ import {deriveSurfaces} from '@/lib/color'; import {cn} from '@/lib/utils'; import {useBuilderStore, useCurrentTemplate} from '@/stores/builder'; import {resolveVariant} from './canvas/registry'; +import {PUBLISH_HOST} from '@/lib/site'; // ★ 호스트를 상수로 박지 않는다. PublishModal 과 **다른 주소**를 보여주면 사장님은 // 미리보기에서 본 주소와 발행 후 안내받는 주소가 달라 어느 쪽이 진짜인지 알 수 없다. // 같은 규칙(VITE_PUBLISH_HOST → 없으면 현재 호스트)을 쓴다. -const PUBLISH_HOST = import.meta.env.VITE_PUBLISH_HOST ?? window.location.host; +// ★ window 로 떨어지지 않는다. 서버 번들은 라우트를 한 파일로 묶어서, 프리렌더가 아닌 +// 화면의 모듈 최상위 코드도 빌드 때 한 번 실행된다 — 여기서 window 를 만지면 빌드가 죽는다. +// (PUBLISH_HOST 는 @/lib/site 가 유일한 출처다) const VIEWPORT_FRAME: Record = { pc: 'w-full max-w-5xl shadow-md', diff --git a/solution/frontend/src/features/marketing/ShowcaseGrid.tsx b/solution/frontend/src/features/marketing/ShowcaseGrid.tsx index 9405014..3ec3925 100644 --- a/solution/frontend/src/features/marketing/ShowcaseGrid.tsx +++ b/solution/frontend/src/features/marketing/ShowcaseGrid.tsx @@ -1,6 +1,7 @@ import {useEffect, useState, type CSSProperties} from 'react'; import {Building2, Coffee, ImageOff, Stethoscope, UtensilsCrossed} from 'lucide-react'; import {PlaceCategory} from '@o2o/shared'; +import {ORIGIN} from '@/lib/site'; import {fetchShowcase, type ShowcaseItem} from './showcaseApi'; const CATEGORY_ICON: Record = { @@ -17,11 +18,14 @@ const CATEGORY_LABEL: Record = { [PlaceCategory.CLINIC]: '피부과 · 성형외과', }; -/** 발행 사이트 주소는 루트 상대경로로 온다(`/s/`). 발행 호스트는 번들에 구워진 값이다. */ -const PUBLISH_HOST = import.meta.env.VITE_PUBLISH_HOST ?? window.location.host; - +/** + * 발행 사이트 주소는 루트 상대경로로 온다(`/s/`). + * + * ★ window 로 떨어지지 않는다. 이 컴포넌트는 랜딩(`/`)과 `/showcase` 에 있고 그 둘은 + * 프리렌더 대상이다 — 빌드 때 브라우저 없이 한 번 그려지므로 window 를 만지면 죽는다. + */ function siteHref(url: string): string { - return `${window.location.protocol}//${PUBLISH_HOST}${url}`; + return `${ORIGIN}${url}`; } /** diff --git a/solution/frontend/src/features/publish/PublishModal.tsx b/solution/frontend/src/features/publish/PublishModal.tsx index 0e8aaac..3b66929 100644 --- a/solution/frontend/src/features/publish/PublishModal.tsx +++ b/solution/frontend/src/features/publish/PublishModal.tsx @@ -19,6 +19,7 @@ import {cn} from '@/lib/utils'; import {useBuilderStore} from '@/stores/builder'; import {runPublishGate, type GateFinding} from './publishGate'; import {checkSiteSlug, reserveSiteSlug} from './siteSlug'; +import {PUBLISH_HOST} from '@/lib/site'; import {localValidate, SlugField, type SlugStatus} from './SlugField'; import { GATE_REASON_LABEL, @@ -29,7 +30,9 @@ import { // 개발에서는 admin 과 같은 :3000을 공개 주소로 쓴다. `/s` 요청은 Vite가 정적 사이트 // 서버(:3001)로 프록시한다. 운영에서는 VITE_PUBLISH_HOST로 공개 호스트를 명시할 수 있다. -const PUBLISH_HOST = import.meta.env.VITE_PUBLISH_HOST ?? window.location.host; +// ★ window 로 떨어지지 않는다. 서버 번들은 라우트를 한 파일로 묶어서, 프리렌더가 아닌 +// 화면의 모듈 최상위 코드도 빌드 때 한 번 실행된다 — 여기서 window 를 만지면 빌드가 죽는다. +// (PUBLISH_HOST 는 @/lib/site 가 유일한 출처다) export function PublishModal() { const isOpen = useBuilderStore((s) => s.isPublishModalOpen); diff --git a/solution/frontend/src/lib/site.ts b/solution/frontend/src/lib/site.ts new file mode 100644 index 0000000..eedc0d1 --- /dev/null +++ b/solution/frontend/src/lib/site.ts @@ -0,0 +1,15 @@ +/** + * 이 앱이 서비스되는 공개 주소. + * + * ★ 호스트를 문자열로 두 곳에 적지 않는다 — compose 가 루트 `SITE_PUBLIC_HOST` 를 + * `VITE_PUBLISH_HOST` 로 흘려보낸다(AGENTS.md). 여기가 프론트에서의 유일한 자리다. + * ★ `window.location` 으로 떨어지지 않는다. 프리렌더는 브라우저 없이 도는데, + * 그때 window 를 만지면 빌드가 죽는다. + */ +const HOST = import.meta.env.VITE_PUBLISH_HOST || 'web4ai.o2osolution.ai'; + +/** `https://web4ai.o2osolution.ai` — 끝 슬래시 없음. */ +export const ORIGIN = `https://${HOST}`; + +/** 발행 사이트 주소를 만들 때 쓰는 호스트. */ +export const PUBLISH_HOST = HOST; diff --git a/solution/frontend/src/pages/AccountPage.tsx b/solution/frontend/src/pages/AccountPage.tsx index 79fa550..6c2b9fb 100644 --- a/solution/frontend/src/pages/AccountPage.tsx +++ b/solution/frontend/src/pages/AccountPage.tsx @@ -142,3 +142,6 @@ function Field({label, children}: {label: string; children: React.ReactNode}) { ); } + +// 라우트 모듈은 default export 를 요구한다(routes.ts 가 이 파일을 가리킨다). +export default AccountPage; diff --git a/solution/frontend/src/pages/BuilderPage.tsx b/solution/frontend/src/pages/BuilderPage.tsx index bedb166..9f596bc 100644 --- a/solution/frontend/src/pages/BuilderPage.tsx +++ b/solution/frontend/src/pages/BuilderPage.tsx @@ -18,9 +18,12 @@ import {useAutoLogin} from '@/hooks/useAutoLogin'; import {usePlaceSync} from '@/hooks/usePlaceSync'; import {userLabel, useAuthStore} from '@/stores/auth'; import {useBuilderStore} from '@/stores/builder'; +import {ORIGIN} from '@/lib/site'; /** 발행 사이트 렌더러의 개발 서버. 프로덕션에서는 실제 발행 주소로 바뀐다. */ -const SITE_PREVIEW_URL = import.meta.env.VITE_SITE_PREVIEW_URL ?? window.location.origin; +// ★ window 로 떨어지지 않는다. 서버 번들은 라우트를 한 파일로 묶어서, 프리렌더가 아닌 +// 화면의 모듈 최상위 코드도 빌드 때 한 번 실행된다 — 여기서 window 를 만지면 빌드가 죽는다. +const SITE_PREVIEW_URL = import.meta.env.VITE_SITE_PREVIEW_URL || ORIGIN; /** 랜딩이 `?industry=` 로 넘길 수 있는 값. 주소창 값이라 아무 문자열이나 들어올 수 있다. */ const INDUSTRY_VALUES: IndustryType[] = ['stay', 'cafe', 'restaurant', 'clinic']; @@ -307,3 +310,6 @@ function BuilderNotice({ ); } + +// 라우트 모듈은 default export 를 요구한다(routes.ts 가 이 파일을 가리킨다). +export default BuilderPage; diff --git a/solution/frontend/src/pages/DevShowcasePage.tsx b/solution/frontend/src/pages/DevShowcasePage.tsx index 1a0dfc0..1be8b83 100644 --- a/solution/frontend/src/pages/DevShowcasePage.tsx +++ b/solution/frontend/src/pages/DevShowcasePage.tsx @@ -701,3 +701,6 @@ function GroupList({ ); } + +// 라우트 모듈은 default export 를 요구한다(routes.ts 가 이 파일을 가리킨다). +export default DevShowcasePage; diff --git a/solution/frontend/src/pages/LandingPage.tsx b/solution/frontend/src/pages/LandingPage.tsx index 0698917..8352693 100644 --- a/solution/frontend/src/pages/LandingPage.tsx +++ b/solution/frontend/src/pages/LandingPage.tsx @@ -4,6 +4,44 @@ import {ArrowRight, ArrowUp, Check} from 'lucide-react'; import {MarketingShell, Section, SectionHead} from '@/components/layout/MarketingShell'; import {ShowcaseGrid, ShowcasePeeks} from '@/features/marketing/ShowcaseGrid'; import {Button} from '@/components/ui/button'; +import type {MetaFunction} from 'react-router'; +import {ORIGIN} from '@/lib/site'; +/** + * ★ 제목은 브랜드가 아니라 **사람이 검색창에 치는 말**로 시작한다. "Web4Ai" 는 검색량이 + * 없는 신규 이름이고, 예전 제목("Web4Ai · AI 웹 빌더")에는 검색어가 한 단어도 없었다. + * seo 규칙: 핵심 키워드 앞, 브랜드 뒤, 50~60자. + */ +export const meta: MetaFunction = () => [ + {title: '가게 홈페이지 만들기 · AI 검색에 인용되는 방식으로 | Web4Ai'}, + { + name: 'description', + content: + '네이버 플레이스만 있으면 우리 가게 홈페이지가 만들어집니다. 주소·영업시간·메뉴가 구조화 데이터로 함께 나가 검색엔진과 AI 답변이 함께 읽습니다. 사업자가 확인한 정보만 발행합니다.', + }, + {name: 'robots', content: 'index, follow, max-snippet:-1, max-image-preview:large'}, + {tagName: 'link', rel: 'canonical', href: `${ORIGIN}/`}, + {property: 'og:title', content: '가게 홈페이지 만들기 · AI 검색에 인용되는 방식으로'}, + { + property: 'og:description', + content: '네이버 플레이스만 있으면 검색·AI 답변이 함께 읽는 우리 가게 홈페이지가 만들어집니다.', + }, + {property: 'og:url', content: `${ORIGIN}/`}, + // 이 앱이 무엇인지 기계에게 말하는 유일한 자리다. 발행본에는 LodgingBusiness 가 나가는데 + // 정작 랜딩에는 구조화 데이터가 하나도 없었다. + { + 'script:ld+json': { + '@context': 'https://schema.org', + '@type': 'Organization', + '@id': `${ORIGIN}/#org`, + name: 'Web4Ai', + url: `${ORIGIN}/`, + logo: `${ORIGIN}/brand/favicon-w4a.svg`, + description: + '네이버 플레이스 정보를 사업자가 확인해 가게 공식 홈페이지로 발행하는 서비스. 검색엔진과 AI 답변엔진이 함께 읽는 형식으로 내보냅니다.', + }, + }, +]; + /** * 히어로 제목의 앞말. '홈페이지' 는 고정이고 이 여섯이 돌아간다. @@ -279,3 +317,6 @@ export function LandingPage() { ); } + +// 라우트 모듈은 default export 를 요구한다(routes.ts 가 이 파일을 가리킨다). +export default LandingPage; diff --git a/solution/frontend/src/pages/LoginPage.tsx b/solution/frontend/src/pages/LoginPage.tsx index 08478b3..2733f7a 100644 --- a/solution/frontend/src/pages/LoginPage.tsx +++ b/solution/frontend/src/pages/LoginPage.tsx @@ -19,7 +19,8 @@ import {useAuthStore} from '@/stores/auth'; */ export function LoginPage({ selfServe = true, - homePath = '/', + // 로그인 직후엔 내 사이트로. 예전엔 router.tsx 가 넘기던 값이다. + homePath = '/sites', }: { selfServe?: boolean; /** @@ -165,3 +166,6 @@ export function LoginPage({ ); } + +// 라우트 모듈은 default export 를 요구한다(routes.ts 가 이 파일을 가리킨다). +export default LoginPage; diff --git a/solution/frontend/src/pages/NotFoundPage.tsx b/solution/frontend/src/pages/NotFoundPage.tsx index 9170b7b..e103633 100644 --- a/solution/frontend/src/pages/NotFoundPage.tsx +++ b/solution/frontend/src/pages/NotFoundPage.tsx @@ -14,3 +14,6 @@ export function NotFoundPage() { ); } + +// 라우트 모듈은 default export 를 요구한다(routes.ts 가 이 파일을 가리킨다). +export default NotFoundPage; diff --git a/solution/frontend/src/pages/PricingPage.tsx b/solution/frontend/src/pages/PricingPage.tsx index 0388cb7..98f7d8b 100644 --- a/solution/frontend/src/pages/PricingPage.tsx +++ b/solution/frontend/src/pages/PricingPage.tsx @@ -2,6 +2,20 @@ import {Link} from 'react-router'; import {Check} from 'lucide-react'; import {MarketingShell, Section} from '@/components/layout/MarketingShell'; import {Button} from '@/components/ui/button'; +import type {MetaFunction} from 'react-router'; +import {ORIGIN} from '@/lib/site'; +export const meta: MetaFunction = () => [ + {title: '요금 · 가게 홈페이지 제작 비용 | Web4Ai'}, + { + name: 'description', + content: 'Web4Ai 로 가게 홈페이지를 만들고 발행하는 데 드는 비용입니다. 발행 후에도 AI 인용 상태를 계속 확인합니다.', + }, + {name: 'robots', content: 'index, follow'}, + {tagName: 'link', rel: 'canonical', href: `${ORIGIN}/pricing`}, + {property: 'og:title', content: '요금 · 가게 홈페이지 제작 비용'}, + {property: 'og:url', content: `${ORIGIN}/pricing`}, +]; + /** * 요금 — 플랜 하나다. @@ -107,3 +121,6 @@ export function PricingPage() { ); } + +// 라우트 모듈은 default export 를 요구한다(routes.ts 가 이 파일을 가리킨다). +export default PricingPage; diff --git a/solution/frontend/src/pages/ShowcasePage.tsx b/solution/frontend/src/pages/ShowcasePage.tsx index 7dd3fed..fcafe95 100644 --- a/solution/frontend/src/pages/ShowcasePage.tsx +++ b/solution/frontend/src/pages/ShowcasePage.tsx @@ -2,6 +2,20 @@ import {Link} from 'react-router'; import {MarketingShell, Section} from '@/components/layout/MarketingShell'; import {ShowcaseGrid} from '@/features/marketing/ShowcaseGrid'; import {Button} from '@/components/ui/button'; +import type {MetaFunction} from 'react-router'; +import {ORIGIN} from '@/lib/site'; +export const meta: MetaFunction = () => [ + {title: '먼저 시작한 가게들 · 발행 사례 | Web4Ai'}, + { + name: 'description', + content: 'Web4Ai 로 실제 발행된 가게 홈페이지 사례입니다. 모든 정보는 사업자가 확인한 것만 담겨 있습니다.', + }, + {name: 'robots', content: 'index, follow'}, + {tagName: 'link', rel: 'canonical', href: `${ORIGIN}/showcase`}, + {property: 'og:title', content: '먼저 시작한 가게들 · 발행 사례'}, + {property: 'og:url', content: `${ORIGIN}/showcase`}, +]; + /** * 이렇게 나옵니다 — 실제로 발행된 홈페이지 목록. @@ -35,3 +49,6 @@ export function ShowcasePage() { ); } + +// 라우트 모듈은 default export 를 요구한다(routes.ts 가 이 파일을 가리킨다). +export default ShowcasePage; diff --git a/solution/frontend/src/pages/SignupPage.tsx b/solution/frontend/src/pages/SignupPage.tsx index 0ec2026..6048fa1 100644 --- a/solution/frontend/src/pages/SignupPage.tsx +++ b/solution/frontend/src/pages/SignupPage.tsx @@ -219,3 +219,6 @@ export function SignupPage() { ); } + +// 라우트 모듈은 default export 를 요구한다(routes.ts 가 이 파일을 가리킨다). +export default SignupPage; diff --git a/solution/frontend/src/pages/SitesPage.tsx b/solution/frontend/src/pages/SitesPage.tsx index f6f10e7..7c70a0f 100644 --- a/solution/frontend/src/pages/SitesPage.tsx +++ b/solution/frontend/src/pages/SitesPage.tsx @@ -18,9 +18,12 @@ import {AppShell, EmptyState, PageContainer} from '@/components/layout/AppShell' import {Badge} from '@/components/ui/badge'; import {Button} from '@/components/ui/button'; import {notify, notifyApiError} from '@/lib/notify'; +import {PUBLISH_HOST} from '@/lib/site'; // 발행본 주소는 PublishModal·CanvasView 와 같은 규칙이다 — 세 곳이 다른 주소를 말하면 안 된다. -const PUBLISH_HOST = import.meta.env.VITE_PUBLISH_HOST ?? window.location.host; +// ★ window 로 떨어지지 않는다. 서버 번들은 라우트를 한 파일로 묶어서, 프리렌더가 아닌 +// 화면의 모듈 최상위 코드도 빌드 때 한 번 실행된다 — 여기서 window 를 만지면 빌드가 죽는다. +// (PUBLISH_HOST 는 @/lib/site 가 유일한 출처다) const CATEGORY_ICON: Record = { [PlaceCategory.LODGING]: Building2, @@ -211,3 +214,6 @@ export function SitesPage() { ); } + +// 라우트 모듈은 default export 를 요구한다(routes.ts 가 이 파일을 가리킨다). +export default SitesPage; diff --git a/solution/frontend/src/root.tsx b/solution/frontend/src/root.tsx new file mode 100644 index 0000000..cece0f4 --- /dev/null +++ b/solution/frontend/src/root.tsx @@ -0,0 +1,82 @@ +import {isRouteErrorResponse, Links, Meta, Outlet, Scripts, ScrollRestoration} from 'react-router'; +import type {LinksFunction, MetaFunction} from 'react-router'; +import {Providers} from '@/app/provider'; +import './index.css'; + +/** + * 문서 껍데기. 예전 `index.html` 이 하던 일을 여기서 한다 — + * 프레임워크 모드에는 index.html 이 없고, 이 컴포넌트가 부터 그린다. + * + * ★ 발행 호스트를 여기 문자열로 적지 않는다. `VITE_PUBLISH_HOST` 는 compose 가 루트의 + * `SITE_PUBLIC_HOST` 를 흘려보내는 값이다 — 두 곳에 적으면 canonical 과 화면 주소가 + * 조용히 갈라진다(AGENTS.md). + */ +const PUBLISH_HOST = import.meta.env.VITE_PUBLISH_HOST || 'web4ai.o2osolution.ai'; +export const ORIGIN = `https://${PUBLISH_HOST}`; + +export const links: LinksFunction = () => [ + {rel: 'icon', type: 'image/svg+xml', href: '/brand/favicon-w4a.svg?v=14'}, + // self-host Pretendard 가 어떤 환경에서든 못 뜰 때 한글이 굴림/맑은고딕으로 떨어지지 않게 하는 안전망 + {rel: 'preconnect', href: 'https://fonts.googleapis.com'}, + {rel: 'preconnect', href: 'https://fonts.gstatic.com', crossOrigin: 'anonymous'}, + { + rel: 'stylesheet', + href: 'https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@300..900&family=Noto+Serif+KR:wght@400;600;700&display=swap', + }, + // 7080 아이템(가요 다방·일력·승차권) 전용. 이 셋이 없으면 간판/명조가 고딕으로 떨어져 감성이 사라진다 + { + rel: 'stylesheet', + href: 'https://fonts.googleapis.com/css2?family=Gugi&family=Gowun+Batang:wght@400;700&family=Nanum+Pen+Script&display=swap', + }, +]; + +/** 라우트가 자기 것을 안 내놓을 때의 기본값. 각 페이지는 `meta` 를 export 해 덮어쓴다. */ +export const meta: MetaFunction = () => [ + {title: 'Web4Ai'}, + {property: 'og:site_name', content: 'Web4Ai'}, + {property: 'og:locale', content: 'ko_KR'}, + {property: 'og:type', content: 'website'}, + {name: 'twitter:card', content: 'summary_large_image'}, +]; + +export function Layout({children}: {children: React.ReactNode}) { + return ( + + + + + + + + + + {children} + + + + + ); +} + +export default function Root() { + return ( + + + + ); +} + +export function ErrorBoundary({error}: {error: unknown}) { + const is404 = isRouteErrorResponse(error) && error.status === 404; + return ( +
+

{is404 ? '404' : '오류'}

+

+ {is404 ? '없는 주소입니다.' : '화면을 그리는 중 문제가 생겼습니다.'} +

+
+ 홈으로 + +
+ ); +} diff --git a/solution/frontend/src/routes.ts b/solution/frontend/src/routes.ts new file mode 100644 index 0000000..dac2bb8 --- /dev/null +++ b/solution/frontend/src/routes.ts @@ -0,0 +1,42 @@ +import {index, layout, route, type RouteConfig} from '@react-router/dev/routes'; + +/** + * 라우트 표. 예전 `app/router.tsx` 의 배열이 여기로 왔다. + * + * ★ 순서·경로는 그대로다. 바뀐 건 선언 방식뿐이고, 페이지 컴포넌트는 손대지 않았다. + * ★ 프리렌더 대상은 여기가 아니라 `react-router.config.ts` 가 정한다. + * 목록에 없는 경로는 지금까지처럼 SPA 폴백으로 나간다. + */ +export default [ + // 비로그인 = 랜딩. ★ 예전엔 로그인 여부로 갈랐는데 지금은 둘 다 랜딩이다 + // (2026-09-04, 사장님 지적: 로그인하면 랜딩·요금에 갈 길이 없었다). + index('pages/LandingPage.tsx'), + + route('login', 'pages/LoginPage.tsx'), + // 로그인 화면의 [회원가입] 이 여기로 온다. 이 줄이 없으면 링크는 있고 목적지만 404 다. + route('signup', 'pages/SignupPage.tsx'), + + // 로그인 전 화면. ★ 랜딩과 같은 껍데기(MarketingShell)를 쓴다 — 사이드바 없는 문서형이다. + route('pricing', 'pages/PricingPage.tsx'), + route('showcase', 'pages/ShowcasePage.tsx'), + + // 로그인한 사장님의 홈. 예전엔 페이지마다 로 감쌌는데, + // 가드는 한 자리에 있어야 빠뜨리지 않아서 레이아웃 라우트로 모았다. + layout('components/layout/RequireAuthLayout.tsx', [ + route('sites', 'pages/SitesPage.tsx'), + route('account', 'pages/AccountPage.tsx'), + ]), + + /** + * 빌더는 로그인 화면을 앞에 세우지 않는다 — 위저드를 열자마자 로그인부터 만나면 + * 만들어 보기도 전에 막힌다. 세션은 useAutoLogin() 이 조용히 확보한다. + */ + route('builder', 'pages/BuilderPage.tsx'), + + // 개발 빌드에서만 열린다. 운영 번들에는 라우트 자체가 없다. + ...(process.env.NODE_ENV !== 'production' + ? [route('dev/showcase', 'pages/DevShowcasePage.tsx')] + : []), + + route('*', 'pages/NotFoundPage.tsx'), +] satisfies RouteConfig; diff --git a/solution/frontend/vite.config.ts b/solution/frontend/vite.config.ts index 66f41dd..738d6bb 100644 --- a/solution/frontend/vite.config.ts +++ b/solution/frontend/vite.config.ts @@ -1,10 +1,12 @@ +import {reactRouter} from '@react-router/dev/vite'; import tailwindcss from '@tailwindcss/vite'; -import react from '@vitejs/plugin-react'; import path from 'path'; import {defineConfig} from 'vite'; export default defineConfig({ - plugins: [react(), tailwindcss()], + // ★ @vitejs/plugin-react 를 따로 넣지 않는다 — reactRouter() 가 안에서 켠다. + // 둘 다 넣으면 리프레시 런타임이 두 번 주입돼 HMR 이 깨진다. + plugins: [reactRouter(), tailwindcss()], resolve: { alias: { '@': path.resolve(__dirname, 'src'), @@ -18,6 +20,9 @@ export default defineConfig({ '@o2o/shared': path.resolve(__dirname, '../shared/src'), }, }, + // ★ 산출물이 `dist/` 가 아니라 `build/client/` 로 나간다(프레임워크 모드 기본값). + // nginx/Dockerfile 의 COPY 경로가 이 값과 맞아야 한다 — 어긋나면 빈 이미지가 구워지고 + // 컨테이너는 정상으로 뜬다. build: { // ★ 발행본과 **같은 오리진**을 쓰므로 `/assets/` 를 서로 뺏는다. 빌더 번들만 다른 // 디렉토리로 뺀다 — 안 그러면 nginx 의 `/assets/` 규칙이 발행본 것만 주고 diff --git a/solution/site/src/seo/robots.ts b/solution/site/src/seo/robots.ts index fd6fd81..a75d653 100644 --- a/solution/site/src/seo/robots.ts +++ b/solution/site/src/seo/robots.ts @@ -33,11 +33,22 @@ const SEARCH_CRAWLERS = [ 'Daumoa', // 다음 ]; +/** + * 앱 전용 경로 — 크롤러가 볼 것이 없는 자리. + * + * ★ 로그인해야 내용이 생기거나(`/sites` `/account`), 도구 화면이라(`/builder`) 색인할 + * 내용이 아예 없다. 프리렌더를 켠 뒤 이 경로들은 빈 SPA 폴백을 받는데, 그걸 긁히면 + * 호스트 전체에 "내용 없는 페이지" 신호가 쌓인다. + * ★ `/showcase` 는 막지 않는다 — 프리렌더 대상이고 발행 사례를 담는다. + */ +const APP_ONLY_PATHS = ['/builder', '/login', '/signup', '/sites', '/account']; + /** 크롤러 허용 블록 — 루트용과 사이트용이 같은 목록을 쓴다. */ function allowBlocks(): string[] { - const blocks: string[] = ['User-agent: *', 'Allow: /', '']; + const deny = APP_ONLY_PATHS.map((path) => `Disallow: ${path}`); + const blocks: string[] = ['User-agent: *', 'Allow: /', ...deny, '']; for (const bot of [...SEARCH_CRAWLERS, ...AI_CRAWLERS]) { - blocks.push(`User-agent: ${bot}`, 'Allow: /', ''); + blocks.push(`User-agent: ${bot}`, 'Allow: /', ...deny, ''); } return blocks; }