o2o-site-AEO/solution/frontend/src/features/publish/SlugField.tsx
Mina Choi c85c577349 이름: solution/front → solution/frontend
`backend` 옆에 `front` 가 있을 이유가 없었다. negosium 의 negodata/front 를 그대로
베꼈고 그게 왜 front 인지는 따져보지 않았다 — 근거 없이 들여온 이름이라 바로잡는다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019uYhHQdssRubirPirrdJJC
2026-08-31 15:27:16 +09:00

132 lines
5.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import {useEffect, useState} from 'react';
import {Check, Loader2, TriangleAlert} from 'lucide-react';
import {Badge} from '@/components/ui/badge';
import {Button} from '@/components/ui/button';
import {Input} from '@/components/ui/input';
import {cn} from '@/lib/utils';
/**
* 발행 주소(네임스페이스) 입력.
*
* ★ 왜 사장님이 직접 고르는가
* 주소는 한 번 발행되면 AI 검색이 색인하는 영구 식별자다. 상호에서 자동 생성하면
* ① 한글이 그대로 들어가 URL 이 퍼센트 인코딩 범벅이 되고
* ② 음차하면 규칙이 사람마다 달라 같은 가게가 두 주소를 갖는다.
* 사장님이 고르면 두 문제가 동시에 사라진다 — 대신 **중복 여부를 먼저 확인해야** 한다.
*
* 서버 규칙과 같은 정규식을 쓴다. 다만 최종 판정은 항상 서버다(여기 통과는 왕복을 줄이는 용도).
*/
const SLUG_RE = /^[a-z0-9](?:[a-z0-9-]{1,48}[a-z0-9])$/;
export type SlugStatus =
| {kind: 'idle'}
| {kind: 'invalid'; reason: string}
| {kind: 'checking'}
| {kind: 'available'}
| {kind: 'taken'; suggestion?: string};
export function localValidate(slug: string): string | null {
const s = slug.trim();
if (!s) return '주소를 입력해 주세요.';
if (/[^\x00-\x7F]/.test(s)) return '영문 소문자·숫자·하이픈만 쓸 수 있습니다 (한글 불가).';
if (s !== s.toLowerCase()) return '대문자는 쓸 수 없습니다.';
if (s.length < 3 || s.length > 50) return '3자 이상 50자 이하로 입력해 주세요.';
if (!SLUG_RE.test(s)) return '영문 소문자·숫자·하이픈만, 하이픈으로 시작·끝날 수 없습니다.';
if (s.includes('--')) return '하이픈을 연달아 쓸 수 없습니다.';
return null;
}
export function SlugField({
value,
host,
status,
disabled,
onChange,
onCheck,
}: {
value: string;
host: string;
status: SlugStatus;
disabled?: boolean;
onChange: (next: string) => void;
onCheck: () => void;
}) {
const [touched, setTouched] = useState(false);
const localError = touched ? localValidate(value) : null;
// 입력이 바뀌면 이전 확인 결과는 무효다 — 부모가 idle 로 되돌리도록 알린다.
useEffect(() => setTouched(Boolean(value)), [value]);
return (
<section className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-xs font-bold">사이트 주소</span>
{status.kind === 'available' && (
<Badge variant="success" className="text-[10px]">
사용 가능
</Badge>
)}
{status.kind === 'taken' && (
<Badge variant="warning" className="text-[10px]">
이미 사용 중
</Badge>
)}
</div>
<div className="flex items-center gap-1.5">
<div className="flex min-w-0 flex-1 items-center rounded-lg border border-border bg-card pl-3 focus-within:border-primary">
{/* 주소는 경로형이다(<host>/s/<slug>) — 접두사를 앞에 붙여 실제 모양 그대로 보여준다.
서브도메인처럼 보이면 사장님이 열리지 않는 주소를 기대하게 된다. */}
<span className="shrink-0 whitespace-nowrap text-[11px] text-muted-foreground">
{host}/s/
</span>
<Input
value={value}
onChange={(e) => onChange(e.target.value.toLowerCase())}
onKeyDown={(e) => {
if (e.key === 'Enter' && !localValidate(value)) onCheck();
}}
placeholder="my-store"
disabled={disabled}
className="border-0 focus-visible:ring-0"
/>
</div>
<Button
size="sm"
variant="outline"
onClick={onCheck}
disabled={disabled || Boolean(localValidate(value)) || status.kind === 'checking'}
>
{status.kind === 'checking' ? <Loader2 className="animate-spin" /> : <Check />}
<span>중복 확인</span>
</Button>
</div>
<p
className={cn(
'text-[11px] leading-relaxed',
localError || status.kind === 'invalid' || status.kind === 'taken'
? 'text-destructive'
: 'text-muted-foreground',
)}
>
{localError ??
(status.kind === 'invalid'
? status.reason
: status.kind === 'taken'
? `이미 사용 중인 주소입니다.${status.suggestion ? ` ‘${status.suggestion}’ 는 어떠세요?` : ''}`
: status.kind === 'available'
? `https://${host}/s/${value} 로 발행됩니다.`
: '영문 소문자·숫자·하이픈. 발행 후에는 바꿀 수 없습니다 — AI 검색이 이 주소로 색인합니다.')}
</p>
{status.kind === 'taken' && status.suggestion && (
<Button size="sm" variant="ghost" onClick={() => onChange(status.suggestion!)}>
<TriangleAlert />
<span>‘{status.suggestion}’ 로 바꾸기</span>
</Button>
)}
</section>
);
}