실측(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
148 lines
5.7 KiB
TypeScript
148 lines
5.7 KiB
TypeScript
import {useEffect, useState, type FormEvent} from 'react';
|
|
import {KeyRound, Loader2} from 'lucide-react';
|
|
import {AuthProvider, updateMe, useMe} from '@/api';
|
|
import {AppShell, PageContainer} from '@/components/layout/AppShell';
|
|
import {Button} from '@/components/ui/button';
|
|
import {Input} from '@/components/ui/input';
|
|
import {notify, notifyApiError} from '@/lib/notify';
|
|
import {toAuthUser, useAuthStore} from '@/stores/auth';
|
|
|
|
/**
|
|
* 내 정보 — `PATCH /v1/auth/me` 한 곳이 받는 것만 그린다.
|
|
*
|
|
* ★ 상호(company)는 읽기 전용이다. Req_UpdateMe 에 없다 — 자기 소속을 스스로 바꾸지 못하게
|
|
* 일부러 뺀 필드라, 입력칸을 두면 저장을 눌러도 아무 일이 안 일어난다.
|
|
* ★ 구글 계정에는 바꿀 비밀번호가 없다(서버가 ACCOUNT_PROVIDER_CONFLICT 로 막는다) —
|
|
* 입력칸 자체를 그리지 않는다.
|
|
*/
|
|
export function AccountPage() {
|
|
const {data, isLoading, refetch} = useMe();
|
|
const setUser = useAuthStore((s) => s.setUser);
|
|
|
|
const [name, setName] = useState('');
|
|
const [email, setEmail] = useState('');
|
|
const [contact, setContact] = useState('');
|
|
const [password, setPassword] = useState('');
|
|
const [isSaving, setIsSaving] = useState(false);
|
|
|
|
// 서버 값이 도착하면 한 번 채운다. 타이핑 중에 덮어쓰지 않게 응답이 바뀔 때만 돈다.
|
|
useEffect(() => {
|
|
if (!data) return;
|
|
setName(data.name ?? '');
|
|
setEmail(data.email ?? '');
|
|
setContact(data.contact_number ?? '');
|
|
}, [data]);
|
|
|
|
const isGoogle = data?.provider === AuthProvider.GOOGLE;
|
|
|
|
const handleSubmit = async (event: FormEvent) => {
|
|
event.preventDefault();
|
|
setIsSaving(true);
|
|
try {
|
|
const res = await updateMe({
|
|
name,
|
|
email,
|
|
contact_number: contact,
|
|
...(password ? {password} : {}),
|
|
});
|
|
if (!res.result?.success) {
|
|
notifyApiError({data: res}, '저장하지 못했습니다.');
|
|
return;
|
|
}
|
|
// 사이드바가 이름을 들고 있다 — 저장하고 스토어를 안 갱신하면 새로고침 전까지 옛 이름이다.
|
|
if (res.user_id && res.id) setUser(toAuthUser(res));
|
|
setPassword('');
|
|
notify.success('저장했습니다.');
|
|
await refetch();
|
|
} catch (error) {
|
|
notifyApiError(error, '저장하지 못했습니다.');
|
|
} finally {
|
|
setIsSaving(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<AppShell>
|
|
<PageContainer title="내 정보" description="이름·연락처와 로그인 정보를 관리합니다.">
|
|
{isLoading ? (
|
|
<div className="flex items-center justify-center py-20 text-muted-foreground">
|
|
<Loader2 className="size-5 animate-spin" />
|
|
</div>
|
|
) : (
|
|
<form onSubmit={handleSubmit} className="max-w-lg space-y-5">
|
|
<section className="space-y-3 rounded-xl border border-border bg-card p-5">
|
|
<Field label="로그인 아이디">
|
|
<p className="text-sm">{isGoogle ? (data?.email ?? '구글 계정') : (data?.id ?? '')}</p>
|
|
<p className="mt-0.5 text-xs text-muted-foreground">
|
|
{isGoogle ? '구글 계정으로 로그인합니다.' : '아이디는 바꿀 수 없습니다.'}
|
|
</p>
|
|
</Field>
|
|
|
|
<Field label="상호">
|
|
<p className="text-sm">{data?.company?.name ?? '-'}</p>
|
|
<p className="mt-0.5 text-xs text-muted-foreground">
|
|
상호 변경은 고객센터로 문의해 주세요.
|
|
</p>
|
|
</Field>
|
|
</section>
|
|
|
|
<section className="space-y-4 rounded-xl border border-border bg-card p-5">
|
|
<Field label="이름">
|
|
<Input value={name} onChange={(e) => setName(e.target.value)} placeholder="홍길동" />
|
|
</Field>
|
|
<Field label="이메일">
|
|
<Input
|
|
type="email"
|
|
value={email}
|
|
onChange={(e) => setEmail(e.target.value)}
|
|
placeholder="owner@example.com"
|
|
/>
|
|
</Field>
|
|
<Field label="연락처">
|
|
<Input value={contact} onChange={(e) => setContact(e.target.value)} placeholder="010-0000-0000" />
|
|
</Field>
|
|
</section>
|
|
|
|
{!isGoogle && (
|
|
<section className="space-y-4 rounded-xl border border-border bg-card p-5">
|
|
<Field label="새 비밀번호">
|
|
<Input
|
|
type="password"
|
|
value={password}
|
|
onChange={(e) => setPassword(e.target.value)}
|
|
placeholder="비우면 지금 비밀번호를 그대로 씁니다"
|
|
autoComplete="new-password"
|
|
/>
|
|
</Field>
|
|
</section>
|
|
)}
|
|
|
|
{isGoogle && (
|
|
<p className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
|
<KeyRound className="size-3.5" />
|
|
구글 계정이라 비밀번호가 없습니다 — 비밀번호는 구글에서 관리합니다.
|
|
</p>
|
|
)}
|
|
|
|
<Button type="submit" variant="primary" isLoading={isSaving}>
|
|
저장
|
|
</Button>
|
|
</form>
|
|
)}
|
|
</PageContainer>
|
|
</AppShell>
|
|
);
|
|
}
|
|
|
|
function Field({label, children}: {label: string; children: React.ReactNode}) {
|
|
return (
|
|
<label className="block">
|
|
<span className="mb-1.5 block text-xs font-medium text-muted-foreground">{label}</span>
|
|
{children}
|
|
</label>
|
|
);
|
|
}
|
|
|
|
// 라우트 모듈은 default export 를 요구한다(routes.ts 가 이 파일을 가리킨다).
|
|
export default AccountPage;
|