`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
132 lines
5.0 KiB
TypeScript
132 lines
5.0 KiB
TypeScript
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>
|
||
);
|
||
}
|