Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions qa/e2e/fixtures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
});
Expand Down
92 changes: 92 additions & 0 deletions qa/render/site-channels.test.tsx
Original file line number Diff line number Diff line change
@@ -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(
<SiteFooter
settings={settings([
{ key: "kakao", url: "pf.kakao.com/_a" },
{ key: "naver_blog", url: "blog.naver.com/a" },
{ key: "youtube", url: "youtube.com/@a", enabled: false },
])}
/>,
);
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(<SiteFooter settings={settings([])} />);
expect(html).not.toContain('aria-label="채널"');
});

it("사업자등록번호·대표변호사도 이름과 값이 나뉜다 (전화 줄과 같은 규칙)", () => {
const html = renderToStaticMarkup(<SiteFooter settings={settings([])} />);
expect(html).toMatch(/<dt[^>]*>사업자등록번호<\/dt>\s*<dd[^>]*data-field="content.business_number"[^>]*>471-47-01088<\/dd>/);
expect(html).toMatch(/<dt[^>]*>대표변호사<\/dt>\s*<dd[^>]*data-field="content.representative"[^>]*>유환진<\/dd>/);
});
});
9 changes: 9 additions & 0 deletions src/app/(admin)/admin/(dashboard)/settings/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };

Expand All @@ -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")
Expand Down
190 changes: 190 additions & 0 deletions src/app/(admin)/admin/(dashboard)/settings/channels-editor.tsx
Original file line number Diff line number Diff line change
@@ -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_<key>
* 라 폼의 포커스 감지가 미리보기 로고를 찾는다(channels.<key>).
*/
export function ChannelsEditor({
initial,
/** 배지·드래그처럼 input 이벤트가 없는 변경 뒤에 폼이 초안을 다시 읽게 알린다 */
onChange,
}: {
initial: SiteChannel[];
onChange?: () => void;
}) {
const [items, setItems] = useState<SiteChannel[]>(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<SiteChannel>) {
setItems((prev) => prev.map((c) => (c.key === key ? { ...c, ...patch } : c)));
}

return (
<DndContext id={dndId} sensors={sensors} collisionDetection={closestCenter} onDragEnd={onDragEnd}>
<SortableContext items={items.map((c) => c.key)} strategy={verticalListSortingStrategy}>
<ul className="a-list">
{items.map((channel, index) => (
<ChannelRow
key={channel.key}
channel={channel}
position={index}
onUrl={(url) => update(channel.key, { url })}
onToggle={() => update(channel.key, { enabled: !channel.enabled })}
/>
))}
</ul>
</SortableContext>
{/* 서버로 가는 값. 주소는 https 를 붙여 정리한 것 */}
<input
type="hidden"
name="channels_json"
value={JSON.stringify(
items.map((c) => ({ key: c.key, url: normalizeChannelUrl(c.url), enabled: c.enabled })),
)}
/>
</DndContext>
);
}

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 (
<li
ref={setNodeRef}
data-dragging={isDragging || undefined}
style={{ transform: CSS.Transform.toString(transform), transition }}
>
<div className="a-row a-row-channel" data-off={!channel.enabled || undefined}>
<span className="a-row-ord">
<button
type="button"
className="a-drag"
aria-label={`${meta.label} 순서 이동`}
{...attributes}
{...listeners}
>
⠿
</button>
<span>{position + 1}</span>
</span>

<span className="a-channel-logo" aria-hidden="true">
<ChannelLogo channel={channel.key} />
</span>

<div className="min-w-0">
<label htmlFor={inputId} className="a-row-name">
{meta.label}
</label>
<div className="a-channel-url">
<input
id={inputId}
name={`channel_url_${channel.key}`}
value={channel.url}
onChange={(e) => 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 ? (
<span id={`${inputId}-error`} className="a-field-error">
{error}
</span>
) : normalized ? (
<span className="a-hint" style={{ color: "var(--a-ok)" }}>
확인됨
</span>
) : null}
</div>
</div>

<button
type="button"
onClick={onToggle}
className={`a-badge ${channel.enabled ? "a-badge-ok" : "a-badge-off"}`}
aria-pressed={channel.enabled}
title={channel.enabled ? "누르면 숨깁니다" : "누르면 사이트에 보입니다"}
>
{channel.enabled ? "노출중" : "숨김"}
</button>
</div>
</li>
);
}
26 changes: 22 additions & 4 deletions src/app/(admin)/admin/(dashboard)/settings/settings-form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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 };
Expand Down Expand Up @@ -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(() => {
Expand Down Expand Up @@ -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)}`);
}
}

Expand Down Expand Up @@ -226,6 +233,17 @@ export function SettingsForm({
</div>
</section>

<section className="flex flex-col gap-4">
<div>
<h2 className="text-sm font-semibold">채널</h2>
<p className="a-hint mt-1">
주소를 넣고 순서를 정합니다. 숨김으로 두면 주소는 남고 사이트에서만
빠집니다. 카카오톡 채널은 상담 버튼에도 쓰입니다.
</p>
</div>
<ChannelsEditor initial={settings.content.channels} onChange={syncFromForm} />
</section>

<section className="flex flex-col gap-4">
<div>
<h2 className="text-sm font-semibold">사이트 색</h2>
Expand Down
Loading
Loading