사이트 색
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 (
+ {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;
+ }
+}