/* ============================================================================ * 서명전에 — 앱인토스 인앱광고 스캐폴드 (Task #117) * ---------------------------------------------------------------------------- * 이 파일의 역할 * - TossAdapter.ads.* 위에 얇은 React 레이어를 얹어 스크린이 쉽게 소비하도록. * - 실 SDK (`@apps-in-toss/ads`) 가 붙기 전까지의 "합의된 API 모양" 을 고정. * - 서버 리워드 지급 엔드포인트(`POST /account/grant-ad-reward`) 는 아직 * 구현되지 않았으므로, 여기서는 그 경로를 "호출 시도" 하고 실패하면 UX * 메시지만 띄우는 스캐폴드로 남긴다. 실제 지급은 Task #111 IAP 결제 공사 * 붙일 때 같은 세션에서 공사한다. * * 제공하는 것 * 1) `useBannerAd({ adUnitId })` 훅 — `{ BannerElement, isVisible }` * 반환. JSX 최하단에 하나 두면 끝. * 2) `runRewardedAd({ adUnitId, accessToken, onReward })` — Promise. * SDK load → show → 'userEarnedReward' → 서버 지급 요청까지 체인. * 실패 케이스는 `RewardedAdError` 로 통일된 코드로 throw. * * 설계 원칙 * - ID 가 비어 있거나 SDK 미지원이면 **아무것도 렌더/실행하지 않는다.** * 빈 박스·에러 토스트 금지 (앱인토스 정책 · UX 마찰). * - 하루 리워드 상한 프런트 가드 (서버 상한과 이중). 로컬 키는 TossAdapter.storage * 로 저장해 앱인토스에서도 persist. * - 모든 외부 효과(SDK 호출, fetch) 는 try/catch 로 감싸 앱이 죽지 않게. * ========================================================================== */ const LOCAL_REWARD_KEY = "seomyeongjeone:ads:rewarded-daily"; const LOCAL_REWARD_LIMIT_FALLBACK = 3; // 서버에서 cap 이 내려오기 전 임시 상한. function _todayStampKST() { // KST 기준 하루 경계. (앱인토스는 국내 서비스라 KST 가 사용자가 기대하는 경계.) const now = new Date(); const kstMs = now.getTime() + (9 * 60 - now.getTimezoneOffset()) * 60 * 1000; const d = new Date(kstMs); return ( d.getUTCFullYear() * 10000 + (d.getUTCMonth() + 1) * 100 + d.getUTCDate() ); } async function _readRewardState() { try { const raw = await TossAdapter.storage.getItem(LOCAL_REWARD_KEY); if (!raw) return { stamp: _todayStampKST(), count: 0 }; const parsed = JSON.parse(String(raw)); const today = _todayStampKST(); if (!parsed || parsed.stamp !== today) { return { stamp: today, count: 0 }; } return { stamp: today, count: Math.max(0, Number(parsed.count) || 0) }; } catch (_) { return { stamp: _todayStampKST(), count: 0 }; } } async function _bumpRewardState() { const cur = await _readRewardState(); const next = { stamp: cur.stamp, count: cur.count + 1 }; try { await TossAdapter.storage.setItem(LOCAL_REWARD_KEY, JSON.stringify(next)); } catch (_) { /* storage 고장나도 광고는 돌게 둔다 */ } return next; } class RewardedAdError extends Error { constructor(code, message) { super(message || code); this.name = "RewardedAdError"; this.code = code; // "not-supported" | "no-ad-unit" | "cap-reached" | "sdk-load" | "sdk-show" | "reward-grant" } } /** 배너 훅. JSX 예: * const { BannerElement } = useBannerAd({ adUnitId: auth.adsBannerUnitId }); * return
...
; * * ID 가 비어 있으면 BannerElement 는 `null` 을 렌더. 호출측에서 조건부로 * 감쌀 필요 없음. useMemo 로 banner 인스턴스를 캐시해 rerender 시 재생성 * 하지 않는다 (TossBanner 내부 SDK 상태 초기화 비용 회피). */ function useBannerAd({ adUnitId } = {}) { const banner = React.useMemo(() => { if (!adUnitId) return null; try { return TossAdapter.ads.createBanner({ adUnitId }); } catch (err) { console.warn("[ads] createBanner failed:", err); return null; } }, [adUnitId]); const isVisible = !!(banner && banner.isSupported); const BannerElement = isVisible ? banner.Element : () => null; return { BannerElement, isVisible }; } /** 보상형 광고를 "한 번 돌려서 리워드 받기" 까지의 전체 플로우. * * @param {Object} opts * @param {string} opts.adUnitId — 광고 유닛 ID (필수). * @param {string} [opts.accessToken] — Supabase access_token. 서버 지급 시 Authorization 헤더에 실음. * @param {number} [opts.grantUnits] — 1회 지급 무료권 수. /client-config 의 ads_rewarded_grant_units. * @param {number} [opts.dailyCap] — 프런트 상한 (서버 상한과 이중 방어). * @param {(n: number) => void} [opts.onReward] — 리워드 성공 시 호출. 지급된 무료권 수 전달. * @returns {Promise<{ granted: number }>} — 성공 시 지급 단위 수 반환. * @throws {RewardedAdError} — 위에 기술한 code 로 실패 원인 식별. */ async function runRewardedAd({ adUnitId, accessToken, grantUnits = 1, dailyCap = LOCAL_REWARD_LIMIT_FALLBACK, onReward, } = {}) { if (!adUnitId) throw new RewardedAdError("no-ad-unit", "광고 유닛이 설정되지 않았어요."); if (!TossAdapter.ads.isRewardedSupported()) { throw new RewardedAdError("not-supported", "지금은 광고가 준비되지 않았어요."); } const cur = await _readRewardState(); if (cur.count >= Math.max(1, dailyCap)) { throw new RewardedAdError("cap-reached", "오늘 받을 수 있는 광고 보상을 다 받았어요. 내일 다시 시도해 주세요."); } try { await TossAdapter.ads.loadRewarded({ adUnitId }); } catch (err) { if (err?.name === "TossNotSupportedError") { throw new RewardedAdError("not-supported", "지금은 광고가 준비되지 않았어요."); } throw new RewardedAdError("sdk-load", err?.message || "광고를 불러오지 못했어요."); } // 보상형은 'userEarnedReward' 이벤트 수신이 성공 조건. Promise 로 감싸 // 이벤트 완료 or 실패 어느 쪽으로든 resolve/reject 되게 한다. const earned = await new Promise((resolve, reject) => { TossAdapter.ads .showRewarded({ adUnitId, onEvent: (ev) => { if (!ev || typeof ev !== "object") return; if (ev.type === "userEarnedReward") resolve(true); else if (ev.type === "closed") resolve(false); // 보상 없이 닫힘 else if (ev.type === "error") reject(new RewardedAdError("sdk-show", ev.error || "광고 재생에 실패했어요.")); }, }) .catch((err) => { if (err?.name === "TossNotSupportedError") { reject(new RewardedAdError("not-supported", "지금은 광고가 준비되지 않았어요.")); } else { reject(new RewardedAdError("sdk-show", err?.message || "광고 재생에 실패했어요.")); } }); }); if (!earned) { // 사용자가 끝까지 안 보고 닫은 경우 — 에러는 아니지만 지급도 없다. return { granted: 0 }; } // 서버에 리워드 지급 요청. 엔드포인트가 아직 없으면 404 → 그 경우엔 프런트 // 로컬 카운트만 올리고 "준비 중" 메시지를 남긴다. (SDK 가 이미 다 돌렸는데 // 서버가 죽으면 유저 경험상 최악이라 로컬에선 부드럽게 fail soft.) if (accessToken) { try { const res = await fetch("/account/grant-ad-reward", { method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${accessToken}`, }, body: JSON.stringify({ ad_unit_id: adUnitId, grant_units: grantUnits }), }); if (!res.ok) { // 엔드포인트 미구현(404) 또는 일시적 에러. // 광고는 본 상태이므로 local count 만 올리고 호출측에 경고만 남긴다. console.warn("[ads] grant-ad-reward failed:", res.status); throw new RewardedAdError( "reward-grant", res.status === 404 ? "지금은 광고 보상이 준비 중이에요. 잠시 후 다시 시도해 주세요." : "보상을 지급하지 못했어요. 잠시 후 다시 시도해 주세요." ); } } catch (err) { if (err instanceof RewardedAdError) throw err; throw new RewardedAdError("reward-grant", err?.message || "보상 서버에 연결하지 못했어요."); } } await _bumpRewardState(); if (typeof onReward === "function") { try { onReward(grantUnits); } catch (_) { /* noop */ } } return { granted: grantUnits }; } /** 오늘 남은 리워드 횟수 (UI 에 "X회 남음" 표시용). */ async function getRewardedQuotaToday({ dailyCap = LOCAL_REWARD_LIMIT_FALLBACK } = {}) { const cur = await _readRewardState(); const cap = Math.max(1, dailyCap); return { used: cur.count, cap, remaining: Math.max(0, cap - cur.count) }; } // 전역 노출 — Babel(dev) 경로에서 import 없이 바로 쓸 수 있게. window.SeomyeongAds = { useBannerAd, runRewardedAd, getRewardedQuotaToday, RewardedAdError, };