diff --git a/qa/e2e/fixtures.ts b/qa/e2e/fixtures.ts index ade3691..c15406d 100644 --- a/qa/e2e/fixtures.ts +++ b/qa/e2e/fixtures.ts @@ -14,6 +14,8 @@ export const test = base.extend<{ admin: Page; blocked: string[] }>({ const req = route.request(); const method = req.method(); if (method === "GET" || method === "HEAD" || method === "OPTIONS") return route.continue(); + // Next 개발 서버 내부 요청(오류 오버레이의 스택 조회 등)은 앱의 쓰기가 아니다. 막되 세지는 않는다. + if (new URL(req.url()).pathname.startsWith("/__nextjs")) return route.abort("blockedbyclient"); blocked.push(`${method} ${req.url()}`); return route.abort("blockedbyclient"); }); diff --git a/qa/render/site-channels.test.tsx b/qa/render/site-channels.test.tsx new file mode 100644 index 0000000..b2bf964 --- /dev/null +++ b/qa/render/site-channels.test.tsx @@ -0,0 +1,92 @@ +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it } from "vitest"; +import { SiteFooter } from "@/components/site/site-footer"; +import { + channelUrlError, + normalizeChannelUrl, + normalizeChannels, + visibleChannels, +} from "@/lib/site-channels"; +import { DEFAULT_COLORS, DEFAULT_CONTENT, DEFAULT_TYPOGRAPHY, type SiteSettings } from "@/lib/site-settings"; + +const settings = (channels: unknown): SiteSettings => ({ + id: "s", + key: "default", + colors: DEFAULT_COLORS, + typography: DEFAULT_TYPOGRAPHY, + content: { + ...DEFAULT_CONTENT, + site_name: "유앤파트너스", + phone: "02-6214-1114", + business_number: "471-47-01088", + representative: "유환진", + address: "", + channels: normalizeChannels(channels), + }, +}); + +describe("채널 · 값 다루기", () => { + it("https 없이 넣어도 붙이고, 빈 값은 빈 값", () => { + expect(normalizeChannelUrl(" blog.naver.com/x ")).toBe("https://blog.naver.com/x"); + expect(normalizeChannelUrl("http://youtu.be/a")).toBe("http://youtu.be/a"); + expect(normalizeChannelUrl("")).toBe(""); + }); + + it("그 서비스 주소가 아니면 거부, 빈 주소는 통과", () => { + expect(channelUrlError("youtube", "https://www.instagram.com/x")).toMatch(/유튜브 주소가 아닙니다/); + expect(channelUrlError("youtube", "https://youtu.be/abc")).toBeNull(); + expect(channelUrlError("naver_blog", "https://m.blog.naver.com/x")).toBeNull(); + expect(channelUrlError("kakao", "https://pf.kakao.com/_x")).toBeNull(); + expect(channelUrlError("kakao", "")).toBeNull(); + expect(channelUrlError("instagram", "https://not a url")).toMatch(/형식/); + }); + + it("저장된 순서를 지키고, 빠진 채널은 뒤에 붙이며, 모르는 키·중복은 버린다", () => { + const list = normalizeChannels([ + { key: "youtube", url: "youtube.com/@a", enabled: false }, + { key: "kakao", url: "pf.kakao.com/_a" }, + { key: "kakao", url: "dup" }, + { key: "tiktok", url: "x" }, + ]); + expect(list.map((c) => c.key)).toEqual(["youtube", "kakao", "naver_blog", "instagram"]); + expect(list[0]).toEqual({ key: "youtube", url: "https://youtube.com/@a", enabled: false }); + expect(list[2]).toEqual({ key: "naver_blog", url: "", enabled: true }); + }); + + it("사이트에 보이는 것 = 노출 켜짐 + 주소 있음", () => { + const list = normalizeChannels([ + { key: "youtube", url: "youtube.com/@a", enabled: false }, + { key: "kakao", url: "pf.kakao.com/_a" }, + ]); + expect(visibleChannels(list).map((c) => c.key)).toEqual(["kakao"]); + }); +}); + +describe("꼬리말 · 채널 로고", () => { + it("관리자 순서대로 로고 링크가 나오고, 숨김·빈 채널은 빠진다", () => { + const html = renderToStaticMarkup( + , + ); + const order = [...html.matchAll(/data-field="channels\.([a-z_]+)"/g)].map((m) => m[1]); + expect(order).toEqual(["kakao", "naver_blog"]); + expect(html).toContain('aria-label="카카오톡 채널"'); + expect(html).toContain('target="_blank"'); + }); + + it("채널이 하나도 없으면 줄 자체가 없다", () => { + const html = renderToStaticMarkup(); + expect(html).not.toContain('aria-label="채널"'); + }); + + it("사업자등록번호·대표변호사도 이름과 값이 나뉜다 (전화 줄과 같은 규칙)", () => { + const html = renderToStaticMarkup(); + expect(html).toMatch(/]*>사업자등록번호<\/dt>\s*]*data-field="content.business_number"[^>]*>471-47-01088<\/dd>/); + expect(html).toMatch(/]*>대표변호사<\/dt>\s*]*data-field="content.representative"[^>]*>유환진<\/dd>/); + }); +}); diff --git a/src/app/(admin)/admin/(dashboard)/settings/actions.ts b/src/app/(admin)/admin/(dashboard)/settings/actions.ts index e65cc5c..157e2a9 100644 --- a/src/app/(admin)/admin/(dashboard)/settings/actions.ts +++ b/src/app/(admin)/admin/(dashboard)/settings/actions.ts @@ -8,6 +8,7 @@ import { contentFromFormData, typographyFromFormData, } from "@/lib/site-settings"; +import { CHANNEL_META, channelUrlError } from "@/lib/site-channels"; export type ActionState = { error: string | null }; @@ -19,6 +20,14 @@ export async function updateSiteSettings( const typography = typographyFromFormData(formData); const content = contentFromFormData(formData); + // 채널 주소는 그 서비스 도메인이어야 한다. 브라우저 표시는 편의, 여기서 다시 본다. + for (const channel of content.channels) { + const problem = channelUrlError(channel.key, channel.url); + if (problem) { + return { error: `${CHANNEL_META[channel.key].label}: ${problem}` }; + } + } + const supabase = await createClient(); const { error } = await supabase .from("site_settings") diff --git a/src/app/(admin)/admin/(dashboard)/settings/channels-editor.tsx b/src/app/(admin)/admin/(dashboard)/settings/channels-editor.tsx new file mode 100644 index 0000000..3326a0a --- /dev/null +++ b/src/app/(admin)/admin/(dashboard)/settings/channels-editor.tsx @@ -0,0 +1,190 @@ +"use client"; + +import { useCallback, useEffect, useId, useRef, useState } from "react"; +import { + DndContext, + KeyboardSensor, + PointerSensor, + closestCenter, + useSensor, + useSensors, + type DragEndEvent, +} from "@dnd-kit/core"; +import { + SortableContext, + arrayMove, + sortableKeyboardCoordinates, + useSortable, + verticalListSortingStrategy, +} from "@dnd-kit/sortable"; +import { CSS } from "@dnd-kit/utilities"; +import { ChannelLogo } from "@/components/site/channel-logo"; +import { + CHANNEL_META, + channelUrlError, + normalizeChannelUrl, + type SiteChannel, +} from "@/lib/site-channels"; + +/** + * 채널 목록 편집. 페이지·블록 목록과 같은 격자(⠿ 순서 · 로고 · 이름+주소 · 노출 배지). + * 값은 channels_json 숨은 칸 하나로 폼에 실린다. 주소 칸 이름은 channel_url_ + * 라 폼의 포커스 감지가 미리보기 로고를 찾는다(channels.). + */ +export function ChannelsEditor({ + initial, + /** 배지·드래그처럼 input 이벤트가 없는 변경 뒤에 폼이 초안을 다시 읽게 알린다 */ + onChange, +}: { + initial: SiteChannel[]; + onChange?: () => void; +}) { + const [items, setItems] = useState(initial); + // dnd-kit 이 만드는 aria id 가 서버·클라이언트에서 같도록 고정한다(hydration 경고 방지). + const dndId = useId(); + // 부모의 콜백은 렌더마다 새로 만들어지므로 ref 로 최신 것만 들고 있는다(효과 안에서 갱신). + const onChangeRef = useRef(onChange); + useEffect(() => { + onChangeRef.current = onChange; + }, [onChange]); + const first = useRef(true); + + // 숨은 칸(channels_json)의 값이 새로 그려진 뒤에 알린다. 그 전에 읽으면 이전 값이다. + useEffect(() => { + if (first.current) { + first.current = false; + return; + } + onChangeRef.current?.(); + }, [items]); + const sensors = useSensors( + useSensor(PointerSensor, { activationConstraint: { distance: 4 } }), + useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }), + ); + + const onDragEnd = useCallback((e: DragEndEvent) => { + const { active, over } = e; + if (!over || active.id === over.id) return; + setItems((prev) => { + const from = prev.findIndex((c) => c.key === active.id); + const to = prev.findIndex((c) => c.key === over.id); + return arrayMove(prev, from, to); + }); + }, []); + + function update(key: SiteChannel["key"], patch: Partial) { + setItems((prev) => prev.map((c) => (c.key === key ? { ...c, ...patch } : c))); + } + + return ( + + c.key)} strategy={verticalListSortingStrategy}> +
    + {items.map((channel, index) => ( + update(channel.key, { url })} + onToggle={() => update(channel.key, { enabled: !channel.enabled })} + /> + ))} +
+
+ {/* 서버로 가는 값. 주소는 https 를 붙여 정리한 것 */} + ({ key: c.key, url: normalizeChannelUrl(c.url), enabled: c.enabled })), + )} + /> +
+ ); +} + +function ChannelRow({ + channel, + position, + onUrl, + onToggle, +}: { + channel: SiteChannel; + position: number; + onUrl: (url: string) => void; + onToggle: () => void; +}) { + const { attributes, listeners, setNodeRef, transform, transition, isDragging } = + useSortable({ id: channel.key }); + const meta = CHANNEL_META[channel.key]; + const normalized = normalizeChannelUrl(channel.url); + const error = channelUrlError(channel.key, normalized); + const inputId = `channel-url-${channel.key}`; + + return ( +
  • +
    + + + {position + 1} + + + + +
    + +
    + onUrl(e.target.value)} + placeholder={meta.placeholder} + inputMode="url" + autoComplete="off" + spellCheck={false} + aria-invalid={error ? true : undefined} + aria-describedby={error ? `${inputId}-error` : undefined} + className="a-input" + /> + {error ? ( + + {error} + + ) : normalized ? ( + + 확인됨 + + ) : null} +
    +
    + + +
    +
  • + ); +} diff --git a/src/app/(admin)/admin/(dashboard)/settings/settings-form.tsx b/src/app/(admin)/admin/(dashboard)/settings/settings-form.tsx index 8d2f3c7..8cbdb2e 100644 --- a/src/app/(admin)/admin/(dashboard)/settings/settings-form.tsx +++ b/src/app/(admin)/admin/(dashboard)/settings/settings-form.tsx @@ -5,7 +5,7 @@ import { PageSections } from "@/components/site/page-sections"; import type { PageSection } from "@/lib/sections"; import { FONT_OPTIONS, - parseContent, + contentFromFormData, parseTypography, type SiteColors, type SiteContent, @@ -21,6 +21,7 @@ import { toast } from "@/app/(admin)/admin/toast"; import { useUnsavedGuard } from "@/app/(admin)/admin/unsaved"; import { updateSiteSettings, type ActionState } from "./actions"; import { AddressSearchInput } from "./address-search-input"; +import { ChannelsEditor } from "./channels-editor"; import { ThemeColorSection } from "./theme-color-section"; const initialState: ActionState = { error: null }; @@ -72,9 +73,10 @@ export function SettingsForm({ // 폼 전체에서 한 번에 읽는다. AddressSearchInput 처럼 내부 상태를 가진 칸도 함께 잡힌다. function syncFromForm() { if (!formRef.current) return; - const raw = Object.fromEntries(new FormData(formRef.current)); - setContent(parseContent(raw)); - setTypography(parseTypography(raw)); + const formData = new FormData(formRef.current); + // 저장 액션과 같은 함수로 읽는다. 그래야 channels_json 같은 묶음 칸도 초안에 들어온다. + setContent(contentFromFormData(formData)); + setTypography(parseTypography(Object.fromEntries(formData))); } useEffect(() => { @@ -104,6 +106,11 @@ export function SettingsForm({ const name = target.name ?? ""; if (name && CONTENT_KEYS.has(name as keyof SiteContent)) { setActiveField(`content.${name === "address_detail" ? "address" : name}`); + return; + } + // 채널 주소 칸 → 미리보기 꼬리말의 그 로고 + if (name.startsWith("channel_url_")) { + setActiveField(`channels.${name.slice("channel_url_".length)}`); } } @@ -226,6 +233,17 @@ export function SettingsForm({ +
    +
    +

    채널

    +

    + 주소를 넣고 순서를 정합니다. 숨김으로 두면 주소는 남고 사이트에서만 + 빠집니다. 카카오톡 채널은 상담 버튼에도 쓰입니다. +

    +
    + +
    +

    사이트 색

    diff --git a/src/app/(admin)/admin/admin.css b/src/app/(admin)/admin/admin.css index 7d2da31..2b07b32 100644 --- a/src/app/(admin)/admin/admin.css +++ b/src/app/(admin)/admin/admin.css @@ -1603,6 +1603,56 @@ li[data-dragging] .a-drag { font-style: italic; } +/* ─────────────────────────── 사이트 설정 · 채널 목록 ─────────────────────────── + * 페이지·블록 목록과 같은 a-row 위에 로고 칸 하나 더. 숨긴 행은 흐리게. */ +.a-row-channel { + grid-template-columns: auto 2.125rem minmax(0, 1fr) auto; + align-items: start; +} + +.a-row-channel[data-off] .a-channel-logo, +.a-row-channel[data-off] .a-row-name { + opacity: 0.55; +} + +.a-channel-logo { + display: block; + width: 2.125rem; + height: 2.125rem; + margin-top: 0.125rem; + border-radius: 0.5rem; + overflow: hidden; +} + +.a-row-channel .a-row-name { + cursor: text; +} + +.a-channel-url { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.375rem 0.625rem; + margin-top: 0.375rem; +} + +.a-channel-url .a-input { + flex: 1 1 14rem; + min-height: 2rem; + padding-block: 0.25rem; +} + +@media (max-width: 640px) { + .a-row-channel { + grid-template-columns: auto 2.125rem minmax(0, 1fr); + } + + .a-row-channel > .a-badge { + grid-column: 3; + justify-self: start; + } +} + /* 좌 편집 / 우 미리보기. 세 화면이 같은 격자를 쓴다. */ .a-workbench { display: grid; diff --git a/src/app/globals.css b/src/app/globals.css index a9c0039..32a9f60 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -1089,3 +1089,34 @@ body { stroke-dashoffset: 0; } } + +/* ─────────── 꼬리말 채널 로고 ─────────── + * 공식 마크를 그대로 쓰므로 색은 로고가 갖는다. 링크는 크기·포커스·살짝 뜨기만. */ +.m-channel { + display: block; + width: 2.125rem; + height: 2.125rem; + border-radius: 0.5rem; + overflow: hidden; + transition: + transform 0.18s cubic-bezier(0.23, 1, 0.32, 1), + box-shadow 0.18s cubic-bezier(0.23, 1, 0.32, 1); +} + +.m-channel:focus-visible { + outline: 3px solid var(--primary); + outline-offset: 2px; +} + +@media (hover: hover) and (pointer: fine) { + .m-channel:hover { + transform: translateY(-1px); + box-shadow: 0 6px 14px -8px rgb(0 0 0 / 0.35); + } +} + +@media (prefers-reduced-motion: reduce) { + .m-channel { + transition: none; + } +} diff --git a/src/components/site/channel-logo.tsx b/src/components/site/channel-logo.tsx new file mode 100644 index 0000000..41949f9 --- /dev/null +++ b/src/components/site/channel-logo.tsx @@ -0,0 +1,73 @@ +import type { ChannelKey } from "@/lib/site-channels"; + +/** + * 채널 로고. 각 서비스의 공식 색·마크를 인라인 SVG 로 그린다(외부 이미지 요청 없음). + * 크기는 부모가 정한다(width/height 100%). + */ +export function ChannelLogo({ channel }: { channel: ChannelKey }) { + switch (channel) { + case "kakao": + return ( + + ); + case "naver_blog": + // 네이버 블로그: 초록 바탕에 흰 말풍선, 그 안에 "b" + return ( + + ); + case "youtube": + return ( + + ); + case "instagram": + return ( + + ); + } +} diff --git a/src/components/site/site-footer.tsx b/src/components/site/site-footer.tsx index 613a10e..188c8e1 100644 --- a/src/components/site/site-footer.tsx +++ b/src/components/site/site-footer.tsx @@ -1,4 +1,6 @@ import { KakaoMap } from "@/components/site/kakao-map"; +import { ChannelLogo } from "@/components/site/channel-logo"; +import { CHANNEL_META, visibleChannels } from "@/lib/site-channels"; import type { SiteSettings } from "@/lib/site-settings"; function display(value: string) { @@ -8,6 +10,7 @@ function display(value: string) { export function SiteFooter({ settings }: { settings: SiteSettings }) { const { content } = settings; const privacyUrl = content.privacy_policy_url.trim(); + const channels = visibleChannels(content.channels); return (
    -
    -

    - 사업자등록번호 {display(content.business_number)} -

    -

    - 대표변호사 {display(content.representative)} -

    -
    + {/* 전화·이메일 줄과 같은 규칙: 이름은 회색, 값만 강조색 */} +
    +
    +
    사업자등록번호
    +
    + {display(content.business_number)} +
    +
    +
    +
    대표변호사
    +
    + {display(content.representative)} +
    +
    +
    {privacyUrl ? ( @@ -89,6 +99,30 @@ export function SiteFooter({ settings }: { settings: SiteSettings }) {
    + {channels.length > 0 && ( +
      + {channels.map((channel) => ( +
    • + + + +
    • + ))} +
    + )} + {content.address.trim() && (
    diff --git a/src/lib/site-channels.ts b/src/lib/site-channels.ts new file mode 100644 index 0000000..99791dc --- /dev/null +++ b/src/lib/site-channels.ts @@ -0,0 +1,102 @@ +/** + * 채널 링크(카카오톡 채널 · 네이버 블로그 · 유튜브 · 인스타그램). + * + * site_settings.content.channels 에 순서 있는 배열로 산다: [{ key, url, enabled }]. + * 관리자가 정하는 것은 주소 · 순서 · 노출 여부 셋. 로고·크기·위치는 코드가 고정한다. + * 채널 종류는 넷으로 고정. 늘리려면 CHANNEL_KEYS 와 CHANNEL_META 에 한 줄씩. + * + * 이 파일은 브라우저·서버·테스트 어디서나 돈다. + */ + +export const CHANNEL_KEYS = ["kakao", "naver_blog", "youtube", "instagram"] as const; +export type ChannelKey = (typeof CHANNEL_KEYS)[number]; + +export type SiteChannel = { + key: ChannelKey; + url: string; + enabled: boolean; +}; + +export const CHANNEL_META: Record< + ChannelKey, + { label: string; placeholder: string; hosts: string[] } +> = { + kakao: { + label: "카카오톡 채널", + placeholder: "pf.kakao.com/_xaBcDe", + hosts: ["pf.kakao.com", "open.kakao.com"], + }, + naver_blog: { + label: "네이버 블로그", + placeholder: "blog.naver.com/아이디", + hosts: ["blog.naver.com", "m.blog.naver.com"], + }, + youtube: { + label: "유튜브", + placeholder: "youtube.com/@채널", + hosts: ["youtube.com", "www.youtube.com", "m.youtube.com", "youtu.be"], + }, + instagram: { + label: "인스타그램", + placeholder: "instagram.com/아이디", + hosts: ["instagram.com", "www.instagram.com"], + }, +}; + +function isChannelKey(value: unknown): value is ChannelKey { + return typeof value === "string" && (CHANNEL_KEYS as readonly string[]).includes(value); +} + +/** "blog.naver.com/x" 처럼 https 없이 붙여 넣어도 받는다. 빈 값은 빈 값. */ +export function normalizeChannelUrl(raw: unknown): string { + if (typeof raw !== "string") return ""; + const trimmed = raw.trim(); + if (trimmed === "") return ""; + return /^https?:\/\//i.test(trimmed) ? trimmed : `https://${trimmed}`; +} + +/** + * 그 서비스 주소가 맞는지. 유튜브 칸에 인스타 주소를 넣는 실수를 막는다. + * 빈 주소는 오류가 아니다(아직 안 넣은 것). 오류면 사람에게 보여줄 문장을 돌려준다. + */ +export function channelUrlError(key: ChannelKey, url: string): string | null { + if (url === "") return null; + let host: string; + try { + host = new URL(url).hostname.toLowerCase(); + } catch { + return "주소 형식이 아닙니다."; + } + const ok = CHANNEL_META[key].hosts.some((h) => host === h || host.endsWith(`.${h}`)); + return ok ? null : `${CHANNEL_META[key].label} 주소가 아닙니다.`; +} + +/** + * DB 값 → 항상 네 채널이 한 번씩, 저장된 순서대로. 빠진 채널은 뒤에 붙는다(주소 없음 · 노출). + * 모르는 키나 중복은 버린다. + */ +export function normalizeChannels(raw: unknown): SiteChannel[] { + const out: SiteChannel[] = []; + const seen = new Set(); + if (Array.isArray(raw)) { + for (const item of raw) { + const o = item && typeof item === "object" ? (item as Record) : {}; + if (!isChannelKey(o.key) || seen.has(o.key)) continue; + seen.add(o.key); + out.push({ + key: o.key, + url: normalizeChannelUrl(o.url), + enabled: o.enabled !== false, + }); + } + } + for (const key of CHANNEL_KEYS) { + if (!seen.has(key)) out.push({ key, url: "", enabled: true }); + } + return out; +} + +/** 사이트에 실제로 보일 것: 노출 켜짐 + 주소 있음. 순서 유지. */ +export function visibleChannels(channels: SiteChannel[]): SiteChannel[] { + return channels.filter((c) => c.enabled && c.url !== ""); +} diff --git a/src/lib/site-settings.ts b/src/lib/site-settings.ts index ccdc9bc..09a1190 100644 --- a/src/lib/site-settings.ts +++ b/src/lib/site-settings.ts @@ -1,3 +1,4 @@ +import { normalizeChannels, type SiteChannel } from "@/lib/site-channels"; /** * 사이트 전역 설정 (브랜드 / 타이포 / 공통 콘텐츠). * DB jsonb 스키마는 앱이 정의하고, 공개 사이트는 CSS 변수로 주입한다. @@ -34,6 +35,8 @@ export type SiteContent = { business_number: string; representative: string; privacy_policy_url: string; + /** 채널 링크. 순서 있는 배열. 자세한 규칙은 site-channels.ts */ + channels: SiteChannel[]; }; export type SiteSettings = { @@ -108,7 +111,7 @@ export const DEFAULT_CONTENT: SiteContent = { email: "", business_number: "", representative: "", - privacy_policy_url: "", + privacy_policy_url: "", channels: normalizeChannels([]), }; export const COLOR_FIELDS: { key: keyof SiteColors; label: string }[] = [ @@ -195,6 +198,7 @@ export function parseContent(raw: unknown): SiteContent { o.privacy_policy_url, DEFAULT_CONTENT.privacy_policy_url, ), + channels: normalizeChannels(o.channels), }; } @@ -268,5 +272,15 @@ export function contentFromFormData(formData: FormData): SiteContent { business_number: formData.get("business_number"), representative: formData.get("representative"), privacy_policy_url: formData.get("privacy_policy_url"), + channels: parseJson(formData.get("channels_json")), }); } + +function parseJson(value: FormDataEntryValue | null): unknown { + if (typeof value !== "string") return undefined; + try { + return JSON.parse(value); + } catch { + return undefined; + } +}