[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:
parent
9aa93b282c
commit
533de126fb
3
.gitignore
vendored
3
.gitignore
vendored
@ -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 설정: 서버마다 다르므로 실제 파일은 커밋하지 않는다. 템플릿만 커밋한다.
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
514
package-lock.json
generated
@ -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",
|
||||
|
||||
@ -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>
|
||||
@ -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",
|
||||
|
||||
24
solution/frontend/react-router.config.ts
Normal file
24
solution/frontend/react-router.config.ts
Normal 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;
|
||||
@ -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>,
|
||||
);
|
||||
@ -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 />},
|
||||
]);
|
||||
@ -0,0 +1,14 @@
|
||||
import {Outlet} from 'react-router';
|
||||
import {RequireAuth} from './RequireAuth';
|
||||
|
||||
/**
|
||||
* 인증이 필요한 라우트들의 부모. 예전 `router.tsx` 가 페이지마다 <RequireAuth> 로
|
||||
* 감싸던 것을 레이아웃 라우트 하나로 모았다 — 가드가 한 자리에 있어야 빠뜨리지 않는다.
|
||||
*/
|
||||
export default function RequireAuthLayout() {
|
||||
return (
|
||||
<RequireAuth>
|
||||
<Outlet />
|
||||
</RequireAuth>
|
||||
);
|
||||
}
|
||||
@ -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',
|
||||
|
||||
@ -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}`;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -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);
|
||||
|
||||
15
solution/frontend/src/lib/site.ts
Normal file
15
solution/frontend/src/lib/site.ts
Normal 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;
|
||||
@ -142,3 +142,6 @@ function Field({label, children}: {label: string; children: React.ReactNode}) {
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
// 라우트 모듈은 default export 를 요구한다(routes.ts 가 이 파일을 가리킨다).
|
||||
export default AccountPage;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -701,3 +701,6 @@ function GroupList({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 라우트 모듈은 default export 를 요구한다(routes.ts 가 이 파일을 가리킨다).
|
||||
export default DevShowcasePage;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -14,3 +14,6 @@ export function NotFoundPage() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 라우트 모듈은 default export 를 요구한다(routes.ts 가 이 파일을 가리킨다).
|
||||
export default NotFoundPage;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -219,3 +219,6 @@ export function SignupPage() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 라우트 모듈은 default export 를 요구한다(routes.ts 가 이 파일을 가리킨다).
|
||||
export default SignupPage;
|
||||
|
||||
@ -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;
|
||||
|
||||
82
solution/frontend/src/root.tsx
Normal file
82
solution/frontend/src/root.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
42
solution/frontend/src/routes.ts
Normal file
42
solution/frontend/src/routes.ts
Normal 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;
|
||||
@ -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/` 규칙이 발행본 것만 주고
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user