Quartz에 Coupang Partners 배너 일괄 삽입하기(Adblock 대응 포함)
변경 요약
- 데스크톱/모바일에 맞는 쿠팡 파트너스 배너 컴포넌트를 신규 추가하고, 모든 페이지 하단(afterBody)에 노출되도록 레이아웃에 삽입.
- 광고 차단(Adblock) 등으로 배너 iframe이 숨겨졌을 때 고지 문구만 보이는 혼란을 방지하기 위해, 배너 표시 여부와 동기화되도록 고지 문구 노출을 제어.
변경된 파일
quartz/components/CoupangBanner.tsxquartz/components/scripts/coupangBanner.inline.tsquartz.layout.ts
주요 구현 포인트
- 배너 컴포넌트(
CoupangBanner.tsx)variant: 'desktop' | 'mobile'옵션으로 소스/크기를 분리.- 접근성/성능:
title,loading="lazy",referrerPolicy="unsafe-url"적용. - 고지 문구(
.coupang-banner__disclaimer)를 컴포넌트 내부에 포함하고, CSS로 기본 스타일 제공.
- 동기화 스크립트(
coupangBanner.inline.ts)- 배너 iframe의 가시성 변화를 관찰하여 고지 문구의 표시/숨김을 동기화.
- SPA 내비게이션(
nav이벤트) 시 바인딩/해제 및MutationObserver,requestAnimationFrame클린업 처리.
- 레이아웃 삽입(
quartz.layout.ts)afterBody슬롯에DesktopOnly(CoupangBanner({ variant: 'desktop' })),MobileOnly(CoupangBanner({ variant: 'mobile' }))추가.
왜 변경했는가
- 수익화 배너를 레이아웃 차원에서 일관되게 노출하려는 목적.
- 광고 차단 환경에서 고지 문구만 남아 UX가 혼란스러운 문제를 해소하고, 실제 배너 노출과 고지를 일치시킴.
운영 체크리스트
- 쿠팡 위젯
id,trackingCode는 본문 코드에서 비식별 플레이스홀더로 제공됩니다. 실제 배포 전, 본인 계정의 값으로 교체하세요. - 프리뷰:
npx quartz build --serve로 데스크톱/모바일 폭에서 iframe/고지 문구 동작 확인.
구현 코드(비식별 예시)
아래 예시는 그대로 복사해도 제 쿠팡 파트너스 코드가 사용되지 않도록 비식별 처리(플레이스홀더)되어 있습니다. 쿠팡 파트너스에서 iframe 형태의 배너를 생성해서 적용하세요.
1) 배너 컴포넌트: quartz/components/CoupangBanner.tsx
import { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } from "./types"
import { classNames } from "../util/lang"
// @ts-ignore
import coupangBannerScript from "./scripts/coupangBanner.inline"
type BannerVariant = "desktop" | "mobile"
interface CoupangBannerOptions {
variant?: BannerVariant
}
const widgetSrc = (id: string, width: number, height: number, trackingCode: string) =>
`https://ads-partners.coupang.com/widgets.html?id=${encodeURIComponent(id)}&template=banner&trackingCode=${encodeURIComponent(trackingCode)}&subId=&width=${width}&height=${height}`
const bannerSources: Record<BannerVariant, { src: string; width: number; height: number; title: string }> = {
desktop: {
// TODO: 쿠팡 파트너스 생성 코드의 id/trackingCode로 치환하세요.
src: widgetSrc("YOUR_WIDGET_ID_DESKTOP", 728, 90, "YOUR_TRACKING_CODE"),
width: 728,
height: 90,
title: "Coupang 광고 배너 (데스크탑)",
},
mobile: {
// TODO: 쿠팡 파트너스 생성 코드의 id/trackingCode로 치환하세요.
src: widgetSrc("YOUR_WIDGET_ID_MOBILE", 320, 50, "YOUR_TRACKING_CODE"),
width: 320,
height: 50,
title: "Coupang 광고 배너 (모바일)",
},
}
export default ((options?: CoupangBannerOptions) => {
const variant: BannerVariant = options?.variant ?? "desktop"
const banner = bannerSources[variant]
const CoupangBanner: QuartzComponent = ({ displayClass }: QuartzComponentProps) => {
return (
<div class={classNames(displayClass, "coupang-banner", `coupang-banner--${variant}`)}>
<iframe
src={banner.src}
width={banner.width}
height={banner.height}
frameBorder="0"
scrolling="no"
referrerPolicy="unsafe-url"
loading="lazy"
title={banner.title}
/>
<p class="coupang-banner__disclaimer">
이 포스팅은 쿠팡 파트너스 활동의 일환으로, 이에 따른 일정액의 수수료를 제공받습니다.
</p>
</div>
)
}
CoupangBanner.css = bannerStyles
CoupangBanner.afterDOMLoaded = coupangBannerScript
return CoupangBanner
}) satisfies QuartzComponentConstructor
const bannerStyles = `
.coupang-banner iframe {
display: block;
width: 100%;
height: auto;
border: none;
}
.coupang-banner__disclaimer {
margin-top: 1px;
margin-bottom: 1rem;
font-size: 0.875rem;
color: var(--gray);
line-height: calc(1em + 1px);
text-align: left;
}
.coupang-banner__disclaimer[hidden] {
display: none !important;
}
.coupang-banner--desktop iframe {
aspect-ratio: 728 / 90;
}
.coupang-banner--mobile iframe {
aspect-ratio: 320 / 50;
}
`2) 배너-고지 문구 동기화 스크립트: quartz/components/scripts/coupangBanner.inline.ts
// Hides the Coupang affiliate disclaimer when ad blockers remove or hide the banner iframe.
const bannerSelector = ".coupang-banner"
const disclaimerSelector = ".coupang-banner__disclaimer"
const iframeSelector = "iframe"
const setVisibility = (node: HTMLElement, visible: boolean) => {
const currentlyHidden = node.hasAttribute("hidden")
if (visible && currentlyHidden) {
node.removeAttribute("hidden")
} else if (!visible && !currentlyHidden) {
node.setAttribute("hidden", "")
}
}
const isIframeVisible = (iframe: HTMLIFrameElement | null) => {
if (!iframe) {
return false
}
const style = window.getComputedStyle(iframe)
if (style.display === "none" || style.visibility === "hidden" || style.opacity === "0") {
return false
}
const rect = iframe.getBoundingClientRect()
return rect.width > 0 && rect.height > 0
}
const bindBanner = (banner: Element) => {
const disclaimer = banner.querySelector<HTMLElement>(disclaimerSelector)
if (!disclaimer) {
return
}
let lastVisible: boolean | undefined
const setDisclaimerVisibility = (iframe: HTMLIFrameElement | null) => {
const visible = isIframeVisible(iframe)
if (lastVisible === visible) {
return
}
lastVisible = visible
setVisibility(disclaimer, visible)
}
const attachIframeListeners = (iframe: HTMLIFrameElement) => {
if (iframe.dataset.coupangBannerBound === "true") {
return
}
const handleLoad = () => setDisclaimerVisibility(iframe)
const handleError = () => setVisibility(disclaimer, false)
iframe.addEventListener("load", handleLoad)
iframe.addEventListener("error", handleError)
iframe.dataset.coupangBannerBound = "true"
window.addCleanup(() => {
iframe.removeEventListener("load", handleLoad)
iframe.removeEventListener("error", handleError)
delete iframe.dataset.coupangBannerBound
})
}
const evaluate = () => {
const iframe = banner.querySelector<HTMLIFrameElement>(iframeSelector)
if (iframe) {
attachIframeListeners(iframe)
}
setDisclaimerVisibility(iframe ?? null)
}
evaluate()
const rafId = window.requestAnimationFrame(evaluate)
window.addCleanup(() => window.cancelAnimationFrame(rafId))
const observer = new MutationObserver(evaluate)
observer.observe(banner, { childList: true, subtree: true, attributes: true })
window.addCleanup(() => observer.disconnect())
const delayedCheck = window.setTimeout(evaluate, 2000)
window.addCleanup(() => window.clearTimeout(delayedCheck))
}
document.addEventListener("nav", () => {
const banners = document.querySelectorAll(bannerSelector)
banners.forEach((banner) => bindBanner(banner))
})3) 레이아웃에 일괄 삽입: quartz.layout.ts
import * as Component from "./quartz/components"
export const sharedPageComponents = {
head: Component.Head(),
header: [],
afterBody: [
// ... 필요 컴포넌트들
Component.DesktopOnly(Component.CoupangBanner({ variant: "desktop" })),
Component.MobileOnly(Component.CoupangBanner({ variant: "mobile" })),
// ... 필요 컴포넌트들
],
// ...
}검증 및 체크리스트
- 프리뷰 검증:
npx quartz build --serve실행 → 데스크톱/모바일 폭에서 배너 로딩, 고지 문구 표시 확인. (테스트 환경에서는 쿠팡 배너가 뜨지 않습니다. 유니콘 기반 광고차단도 자동으로 작동하지 않습니다. 실제 테스트는 웹 - cloudflare 미리보기 배포 등 - 에서 수행하세요.) - 접근성:
iframe에title제공 여부 확인, 고지 문구가 스크린 리더에 이중 노출되지 않도록hidden속성 토글이 정상 동작하는지 확인. - 레이아웃:
afterBody위치가 페이지 흐름을 저해하지 않는지, 배너와 본문/푸터 간 간격 확인.
주의사항(비식별 처리)
- 본문 코드의
YOUR_WIDGET_ID_*,YOUR_TRACKING_CODE는 예시용입니다. 쿠팡 파트너스에서 배너 iframe 코드를 발행하고, 이를 이용해서 수정하세요. - 코드 스니펫은 제 개인 식별 정보가 제거된 상태이며, 그대로 복사해도 제 배너로 수익이 귀속되지 않습니다.

