/* ============================================================================
* 서명전에 — 앱인토스 인앱광고 스캐폴드 (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,
};