[feat] solution/frontend,nginx: 랜딩·요금·사례를 프리렌더 — 크롤러가 빈 종이를 받던 것

실측(2026-09-07): `curl /` 가 3,021바이트에 본문 0자·`<a>` 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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019fteiJNvAEbTnUKq8fSqoj
This commit is contained in:
Mina Choi 2026-09-07 11:30:27 +09:00
parent 9aa93b282c
commit 533de126fb
28 changed files with 850 additions and 183 deletions

3
.gitignore vendored
View File

@ -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 설정: 서버마다 다르므로 실제 파일은 커밋하지 않는다. 템플릿만 커밋한다.

View File

@ -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

View File

@ -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";
}

514
package-lock.json generated
View File

@ -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",

View File

@ -1,45 +0,0 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<!--
2026-09-07: noindex 를 걷었다. 이 자리는 관리자 화면이 아니라 **랜딩**이고,
로그인 뒤 빌더로 갈릴 뿐이다. 색인을 막아 두는 동안 이 호스트에는 검색엔진이
들어올 문이 하나(sitemap)뿐이었고, nofollow 때문에 랜딩→발행 사이트로 이어지는
발견 경로도 함께 죽어 있었다.
★ body 가 빈 SPA 셸이라 렌더링에 기대야 한다 — 랜딩 프리렌더 전까지는 절반짜리다.
-->
<meta name="robots" content="index, follow, max-snippet:-1, max-image-preview:large" />
<meta name="description" content="네이버 플레이스만 있으면 검색·AI 답변에 인용되는 우리 가게 홈페이지가 만들어집니다. 주소·영업시간·메뉴가 구조화 데이터로 함께 나갑니다." />
<!-- ★ 호스트를 여기 적지 않는다. Vite 가 빌드 때 VITE_PUBLISH_HOST 로 치환한다
(compose 가 루트 SITE_PUBLIC_HOST 를 흘려보낸다) — 두 곳에 적으면 조용히 갈라진다. -->
<link rel="canonical" href="https://%VITE_PUBLISH_HOST%/" />
<meta property="og:type" content="website" />
<meta property="og:site_name" content="Web4Ai" />
<meta property="og:locale" content="ko_KR" />
<meta property="og:title" content="Web4Ai · AI 웹 빌더" />
<meta property="og:description" content="네이버 플레이스만 있으면 검색·AI 답변에 인용되는 우리 가게 홈페이지가 만들어집니다." />
<meta property="og:url" content="https://%VITE_PUBLISH_HOST%/" />
<meta name="twitter:card" content="summary_large_image" />
<!-- self-host Pretendard 가 어떤 환경에서든 못 뜰 때 한글이 굴림/맑은고딕으로 떨어지지 않게 하는 안전망 -->
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@300..900&family=Noto+Serif+KR:wght@400;600;700&display=swap"
rel="stylesheet"
/>
<!-- 7080 아이템(가요 다방·일력·승차권) 전용. 이 셋이 없으면 간판/명조가 고딕으로 떨어져 감성이 사라진다 -->
<link
href="https://fonts.googleapis.com/css2?family=Gugi&family=Gowun+Batang:wght@400;700&family=Nanum+Pen+Script&display=swap"
rel="stylesheet"
/>
<link rel="icon" type="image/svg+xml" href="/brand/favicon-w4a.svg?v=14" />
<meta name="theme-color" content="#1463ff" />
<title>Web4Ai · AI 웹 빌더</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/app/main.tsx"></script>
</body>
</html>

View File

@ -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",

View File

@ -0,0 +1,24 @@
import type {Config} from '@react-router/dev/config';
/**
* 프리렌더 설정.
*
* ★ 왜 하나 — 랜딩은 `<div id="root"></div>` 만 내보내는 CSR 이었다. 실측(2026-09-07):
* `curl /` 는 3,021바이트에 `<a>` 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;

View File

@ -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(
<StrictMode>
<Providers>
<RouterProvider router={router} />
</Providers>
</StrictMode>,
);

View File

@ -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 (
<div className="flex h-screen items-center justify-center text-muted-foreground">
<Loader2 className="size-5 animate-spin" />
</div>
);
}
/*
* ★ 로그인해도 랜딩을 보여 준다 (2026-09-04, 사장님 지적)
* 예전엔 여기서 /sites 로 튕겼다. 그러면 **로그인한 사장님은 랜딩·요금에 갈 길이 없다** —
* 로고가 '/' 를 걸고 있어도 눌리는 순간 목록으로 돌아온다.
* "로그인 직후엔 내 사이트로" 는 로그인·가입 화면이 직접 보낸다(LoginPage homePath).
*/
void user;
return <LandingPage />;
}
export const router = createBrowserRouter([
{path: '/login', element: <LoginPage homePath="/sites" />},
// 로그인 화면의 [회원가입] 이 여기로 온다. 이 줄이 없으면 링크는 있고 목적지만 404 다.
{path: '/signup', element: <SignupPage />},
// 비로그인 = 랜딩, 로그인 = 내 사이트. Home 이 그걸 가른다.
{path: '/', element: <Home />},
// 로그인 전 화면. ★ 랜딩과 같은 껍데기(MarketingShell)를 쓴다 — 사이드바 없는 문서형이다.
{path: '/pricing', element: <PricingPage />},
{path: '/showcase', element: <ShowcasePage />},
// 로그인한 사장님의 홈. 만든 사이트를 열고 고치는 자리다.
{
path: '/sites',
element: (
<RequireAuth>
<SitesPage />
</RequireAuth>
),
},
{
path: '/account',
element: (
<RequireAuth>
<AccountPage />
</RequireAuth>
),
},
/**
* 빌더는 로그인 화면을 앞에 세우지 않는다 — 위저드를 열자마자 로그인부터 만나면
* 만들어 보기도 전에 막힌다.
*
* 대신 세션은 조용히 확보한다 — VITE_AUTO_LOGIN_ID·PW 가 주입돼 있으면 useAutoLogin() 이
* 그 계정으로 붙고, 없으면 서버가 필요한 순간(2단계 검색)에만 알린다.
*
* 에디터(6단계)는 전체 화면이 필요해 AppShell 을 스스로 끄고 켠다 — BuilderPage 참조.
*/
{path: '/builder', element: <BuilderPage />},
/**
* ★ 내부 운영 화면(/places, /local-content, /seo)은 여기 없다 — 최상단 `admin/` 앱으로 나갔다.
* 라우트 가드는 화면을 가리지 **번들은 못 가린다**: 한 앱이면 내부 라우트 이름과 코드가
* 사장님 브라우저에 그대로 내려간다. `UserRole.DEVELOPER` 주석의
* "고객사에 존재를 노출하지 않는다"를 지키려면 번들 자체가 갈라져야 한다.
* (ARCHITECTURE.md 4절)
*/
// 개발 빌드에서만 열리는 컴포넌트 쇼케이스. 운영 번들에서는 라우트 자체가 없다.
...(import.meta.env.DEV ? [{path: '/dev/showcase', element: <DevShowcasePage />}] : []),
{path: '*', element: <NotFoundPage />},
]);

View File

@ -0,0 +1,14 @@
import {Outlet} from 'react-router';
import {RequireAuth} from './RequireAuth';
/**
* 인증이 필요한 라우트들의 부모. 예전 `router.tsx` 가 페이지마다 <RequireAuth> 로
* 감싸던 것을 레이아웃 라우트 하나로 모았다 — 가드가 한 자리에 있어야 빠뜨리지 않는다.
*/
export default function RequireAuthLayout() {
return (
<RequireAuth>
<Outlet />
</RequireAuth>
);
}

View File

@ -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<ViewportMode, string> = {
pc: 'w-full max-w-5xl shadow-md',

View File

@ -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<number, typeof Building2> = {
@ -17,11 +18,14 @@ const CATEGORY_LABEL: Record<number, string> = {
[PlaceCategory.CLINIC]: '피부과 · 성형외과',
};
/** 발행 사이트 주소는 루트 상대경로로 온다(`/s/<slug>`). 발행 호스트는 번들에 구워진 값이다. */
const PUBLISH_HOST = import.meta.env.VITE_PUBLISH_HOST ?? window.location.host;
/**
* 발행 사이트 주소는 루트 상대경로로 온다(`/s/<slug>`).
*
* ★ window 로 떨어지지 않는다. 이 컴포넌트는 랜딩(`/`)과 `/showcase` 에 있고 그 둘은
* 프리렌더 대상이다 — 빌드 때 브라우저 없이 한 번 그려지므로 window 를 만지면 죽는다.
*/
function siteHref(url: string): string {
return `${window.location.protocol}//${PUBLISH_HOST}${url}`;
return `${ORIGIN}${url}`;
}
/**

View File

@ -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);

View File

@ -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;

View File

@ -142,3 +142,6 @@ function Field({label, children}: {label: string; children: React.ReactNode}) {
</label>
);
}
// 라우트 모듈은 default export 를 요구한다(routes.ts 가 이 파일을 가리킨다).
export default AccountPage;

View File

@ -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({
</div>
);
}
// 라우트 모듈은 default export 를 요구한다(routes.ts 가 이 파일을 가리킨다).
export default BuilderPage;

View File

@ -701,3 +701,6 @@ function GroupList({
</div>
);
}
// 라우트 모듈은 default export 를 요구한다(routes.ts 가 이 파일을 가리킨다).
export default DevShowcasePage;

View File

@ -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() {
</MarketingShell>
);
}
// 라우트 모듈은 default export 를 요구한다(routes.ts 가 이 파일을 가리킨다).
export default LandingPage;

View File

@ -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({
</div>
);
}
// 라우트 모듈은 default export 를 요구한다(routes.ts 가 이 파일을 가리킨다).
export default LoginPage;

View File

@ -14,3 +14,6 @@ export function NotFoundPage() {
</div>
);
}
// 라우트 모듈은 default export 를 요구한다(routes.ts 가 이 파일을 가리킨다).
export default NotFoundPage;

View File

@ -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() {
</MarketingShell>
);
}
// 라우트 모듈은 default export 를 요구한다(routes.ts 가 이 파일을 가리킨다).
export default PricingPage;

View File

@ -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() {
</MarketingShell>
);
}
// 라우트 모듈은 default export 를 요구한다(routes.ts 가 이 파일을 가리킨다).
export default ShowcasePage;

View File

@ -219,3 +219,6 @@ export function SignupPage() {
</div>
);
}
// 라우트 모듈은 default export 를 요구한다(routes.ts 가 이 파일을 가리킨다).
export default SignupPage;

View File

@ -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<number, typeof Building2> = {
[PlaceCategory.LODGING]: Building2,
@ -211,3 +214,6 @@ export function SitesPage() {
</AppShell>
);
}
// 라우트 모듈은 default export 를 요구한다(routes.ts 가 이 파일을 가리킨다).
export default SitesPage;

View File

@ -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 이 없고, 이 컴포넌트가 <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 (
<html lang="ko">
<head>
<meta charSet="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta name="theme-color" content="#1463ff" />
<Meta />
<Links />
</head>
<body>
{children}
<ScrollRestoration />
<Scripts />
</body>
</html>
);
}
export default function Root() {
return (
<Providers>
<Outlet />
</Providers>
);
}
export function ErrorBoundary({error}: {error: unknown}) {
const is404 = isRouteErrorResponse(error) && error.status === 404;
return (
<div className="flex min-h-screen flex-col items-center justify-center gap-3 px-6 text-center">
<p className="text-5xl font-extrabold tracking-tight">{is404 ? '404' : '오류'}</p>
<p className="text-muted-foreground">
{is404 ? '없는 주소입니다.' : '화면을 그리는 중 문제가 생겼습니다.'}
</p>
<a href="/" className="underline underline-offset-4">
홈으로
</a>
</div>
);
}

View File

@ -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'),
// 로그인한 사장님의 홈. 예전엔 페이지마다 <RequireAuth> 로 감쌌는데,
// 가드는 한 자리에 있어야 빠뜨리지 않아서 레이아웃 라우트로 모았다.
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;

View File

@ -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/` 규칙이 발행본 것만 주고

View File

@ -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;
}