Skip to content
Open
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 locales/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2034,6 +2034,8 @@ export default {
rewarded_dialog_title: "Ready to train?",
rewarded_dialog_subtitle: "Watch a quick ad to unlock your workout : it keeps Workout.cool free for everyone.",
rewarded_watch_ad: "Watch Ad & Start",
rewarded_ad_error: "The ad could not be shown. Your reward was not unlocked.",
rewarded_ad_retry: "Retry",
rewarded_or: "or",
rewarded_go_premium: "Skip Ads Forever",
rewarded_premium_hint: "Premium members never see ads and start instantly",
Expand Down
2 changes: 2 additions & 0 deletions locales/es.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2035,6 +2035,8 @@ export default {
rewarded_dialog_title: "¿Listo para entrenar?",
rewarded_dialog_subtitle: "Mira un breve anuncio para desbloquear tu entrenamiento : ayuda a mantener Workout.cool gratis.",
rewarded_watch_ad: "Ver anuncio y empezar",
rewarded_ad_error: "No se pudo mostrar el anuncio. Tu recompensa no se ha desbloqueado.",
rewarded_ad_retry: "Reintentar",
rewarded_or: "o",
rewarded_go_premium: "Sin anuncios nunca más",
rewarded_premium_hint: "Los miembros Premium nunca ven anuncios y empiezan al instante",
Expand Down
2 changes: 2 additions & 0 deletions locales/fr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2059,6 +2059,8 @@ export default {
rewarded_dialog_title: "Prêt à t'entraîner ?",
rewarded_dialog_subtitle: "Regarde une courte pub pour débloquer ta séance : ça permet de garder Workout.cool gratuit.",
rewarded_watch_ad: "Regarder la pub & commencer",
rewarded_ad_error: "La pub n'a pas pu être affichée. Votre récompense n'a pas été débloquée.",
rewarded_ad_retry: "Réessayer",
rewarded_or: "ou",
rewarded_go_premium: "Plus jamais de pubs",
rewarded_premium_hint: "Les membres Premium ne voient jamais de pubs et démarrent instantanément",
Expand Down
2 changes: 2 additions & 0 deletions locales/pt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2036,6 +2036,8 @@ export default {
rewarded_dialog_title: "Pronto para treinar?",
rewarded_dialog_subtitle: "Assista a um breve anúncio para desbloquear seu treino : ajuda a manter o Workout.cool gratuito.",
rewarded_watch_ad: "Assistir anúncio e começar",
rewarded_ad_error: "Não foi possível exibir o anúncio. Sua recompensa não foi desbloqueada.",
rewarded_ad_retry: "Tentar novamente",
rewarded_or: "ou",
rewarded_go_premium: "Sem anúncios para sempre",
rewarded_premium_hint: "Membros Premium nunca veem anúncios e começam instantaneamente",
Expand Down
2 changes: 2 additions & 0 deletions locales/ru.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2037,6 +2037,8 @@ export default {
rewarded_dialog_subtitle:
"Посмотрите короткую рекламу, чтобы разблокировать тренировку : это помогает Workout.cool оставаться бесплатным.",
rewarded_watch_ad: "Смотреть рекламу и начать",
rewarded_ad_error: "Не удалось показать рекламу. Награда не была разблокирована.",
rewarded_ad_retry: "Повторить",
rewarded_or: "или",
rewarded_go_premium: "Без рекламы навсегда",
rewarded_premium_hint: "Участники Premium никогда не видят рекламу и начинают мгновенно",
Expand Down
2 changes: 2 additions & 0 deletions locales/zh-CN.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1969,6 +1969,8 @@ export default {
rewarded_dialog_title: "准备好训练了吗?",
rewarded_dialog_subtitle: "观看短视频广告解锁训练 : 帮助Workout.cool保持免费。",
rewarded_watch_ad: "观看广告并开始",
rewarded_ad_error: "广告未能展示,奖励尚未解锁。",
rewarded_ad_retry: "重试",
rewarded_or: "或",
rewarded_go_premium: "永远告别广告",
rewarded_premium_hint: "Premium会员永远不看广告,立即开始训练",
Expand Down
90 changes: 70 additions & 20 deletions src/components/ads/custom/RewardedAdGate.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
"use client";

import { useCallback, useState } from "react";
import { useCallback, useEffect, useReducer, useRef, useState } from "react";
import Link from "next/link";
import { Crown, Dumbbell, Loader2, Tv, Zap } from "lucide-react";
import { AlertCircle, Crown, Dumbbell, Loader2, RotateCw, Tv, Zap } from "lucide-react";
import { useI18n } from "locales/client";

import { REWARDED_AD_TIMEOUT_MS, rewardedAdGateReducer } from "./rewarded-ad-state";

import { useIsPremium } from "@/shared/lib/premium/use-premium";
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog";

Expand All @@ -27,7 +29,8 @@ export function RewardedAdGate({ onRewardGranted, children }: RewardedAdGateProp
const t = useI18n();
const isPremium = useIsPremium();
const [open, setOpen] = useState(false);
const [loading, setLoading] = useState(false);
const [adState, dispatch] = useReducer(rewardedAdGateReducer, { status: "idle" });
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);

const handleClick = useCallback(() => {
if (isPremium) {
Expand All @@ -37,32 +40,75 @@ export function RewardedAdGate({ onRewardGranted, children }: RewardedAdGateProp
setOpen(true);
}, [isPremium, onRewardGranted]);

const handleWatchAd = useCallback(() => {
if (!window.ezRewardedAds?.ready) {
const clearAdTimeout = useCallback(() => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
timeoutRef.current = null;
}
}, []);

useEffect(() => clearAdTimeout, [clearAdTimeout]);

useEffect(() => {
if (adState.status === "success") {
setOpen(false);
onRewardGranted();
}
}, [adState.status, onRewardGranted]);

const requestAd = useCallback(() => {
const rewardedAds = window.ezRewardedAds;

if (!rewardedAds?.ready) {
dispatch({ type: "failure", reason: "blocked" });
return;
}

setLoading(true);
dispatch({ type: "start" });
clearAdTimeout();
timeoutRef.current = setTimeout(() => {
timeoutRef.current = null;
dispatch({ type: "timeout" });
}, REWARDED_AD_TIMEOUT_MS);

window.ezRewardedAds.requestAndShow((result) => {
setLoading(false);
setOpen(false);
rewardedAds.requestAndShow((result) => {
clearAdTimeout();

if (result.reward || !result.status) {
onRewardGranted();
if (result.status && result.reward) {
dispatch({ type: "success" });
} else {
dispatch({ type: "failure", reason: "failed" });
}
});
}, [onRewardGranted]);
}, [clearAdTimeout]);

const handleWatchAd = useCallback(() => {
if (adState.status === "idle") requestAd();
}, [adState.status, requestAd]);

const handleRetry = useCallback(() => {
if (adState.status !== "error") return;

dispatch({ type: "retry" });
requestAd();
}, [adState.status, requestAd]);

const isLoading = adState.status === "loading";
const isError = adState.status === "error";

return (
<>
<button className="flex items-center justify-center gap-2 w-full" onClick={handleClick} type="button">
{children}
</button>

<Dialog onOpenChange={setOpen} open={open}>
<Dialog
onOpenChange={(nextOpen) => {
if (!nextOpen) clearAdTimeout();
setOpen(nextOpen);
}}
open={open}
>
<DialogContent className="max-w-sm">
<DialogHeader className="items-center text-center">
<div className="mx-auto w-16 h-16 rounded-2xl bg-gradient-to-br from-green-500 to-emerald-600 flex items-center justify-center mb-2 shadow-lg shadow-green-500/20">
Expand All @@ -73,25 +119,29 @@ export function RewardedAdGate({ onRewardGranted, children }: RewardedAdGateProp
</DialogHeader>

<div className="space-y-3 pt-2">
{/* Watch Ad — primary action */}
{isError ? (
<div className="flex items-start gap-2 rounded-xl bg-red-50 dark:bg-red-950/40 p-3 text-red-700 dark:text-red-300">
<AlertCircle className="mt-0.5 h-4 w-4 shrink-0" />
<p className="text-sm">{t("ads.rewarded_ad_error")}</p>
</div>
) : null}

<button
className="w-full flex items-center justify-center gap-2.5 py-3.5 rounded-xl bg-green-600 hover:bg-green-700 text-white font-semibold transition-colors disabled:opacity-50"
disabled={loading}
onClick={handleWatchAd}
disabled={isLoading}
onClick={isError ? handleRetry : handleWatchAd}
type="button"
>
{loading ? <Loader2 className="w-5 h-5 animate-spin" /> : <Tv className="w-5 h-5" />}
{t("ads.rewarded_watch_ad")}
{isLoading ? <Loader2 className="w-5 h-5 animate-spin" /> : isError ? <RotateCw className="w-5 h-5" /> : <Tv className="w-5 h-5" />}
{isError ? t("ads.rewarded_ad_retry") : t("ads.rewarded_watch_ad")}
</button>

{/* Divider */}
<div className="flex items-center gap-3">
<div className="flex-1 h-px bg-slate-200 dark:bg-slate-700" />
<span className="text-xs text-slate-400 dark:text-slate-500 uppercase tracking-wide">{t("ads.rewarded_or")}</span>
<div className="flex-1 h-px bg-slate-200 dark:bg-slate-700" />
</div>

{/* Premium upsell */}
<Link
className="w-full flex items-center justify-center gap-2.5 py-3.5 rounded-xl bg-gradient-to-r from-amber-500 to-orange-500 hover:from-amber-600 hover:to-orange-600 text-white font-semibold transition-all shadow-lg shadow-amber-500/20"
href="/premium"
Expand Down
54 changes: 54 additions & 0 deletions src/components/ads/custom/rewarded-ad-state.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { describe, expect, it } from "vitest";

import {
REWARDED_AD_TIMEOUT_MS,
rewardedAdGateReducer,
type RewardedAdGateState,
} from "./rewarded-ad-state";

const idle: RewardedAdGateState = { status: "idle" };
const loading: RewardedAdGateState = { status: "loading" };

describe("rewardedAdGateReducer", () => {
it("moves from idle to loading", () => {
expect(rewardedAdGateReducer(idle, { type: "start" })).toEqual({ status: "loading" });
});

it("only grants from loading on an explicit success event", () => {
expect(rewardedAdGateReducer(loading, { type: "success" })).toEqual({ status: "success" });
});

it("does not grant when the ad request fails", () => {
expect(rewardedAdGateReducer(loading, { type: "failure", reason: "failed" })).toEqual({
status: "error",
failureReason: "failed",
});
});

it("does not grant when the ad SDK is blocked or unavailable", () => {
expect(rewardedAdGateReducer(idle, { type: "failure", reason: "blocked" })).toEqual({
status: "error",
failureReason: "blocked",
});
expect(rewardedAdGateReducer(loading, { type: "failure", reason: "blocked" })).toEqual({
status: "error",
failureReason: "blocked",
});
});

it("does not grant when the request times out", () => {
expect(rewardedAdGateReducer(loading, { type: "timeout" })).toEqual({
status: "error",
failureReason: "timeout",
});
expect(REWARDED_AD_TIMEOUT_MS).toBe(30_000);
});

it("ignores late callbacks after a timeout and supports retrying", () => {
const timedOut = rewardedAdGateReducer(loading, { type: "timeout" });

expect(rewardedAdGateReducer(timedOut, { type: "success" })).toEqual(timedOut);
expect(rewardedAdGateReducer(timedOut, { type: "retry" })).toEqual({ status: "idle" });
expect(rewardedAdGateReducer({ status: "idle" }, { type: "start" })).toEqual(loading);
});
});
39 changes: 39 additions & 0 deletions src/components/ads/custom/rewarded-ad-state.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
export type RewardedAdGateStatus = "idle" | "loading" | "success" | "error";

export type RewardedAdFailureReason = "blocked" | "failed" | "timeout";

export interface RewardedAdGateState {
status: RewardedAdGateStatus;
failureReason?: RewardedAdFailureReason;
}

export type RewardedAdGateEvent =
| { type: "start" }
| { type: "success" }
| { type: "failure"; reason: Exclude<RewardedAdFailureReason, "timeout"> }
| { type: "timeout" }
| { type: "retry" };

export const REWARDED_AD_TIMEOUT_MS = 30_000;

export function rewardedAdGateReducer(
state: RewardedAdGateState,
event: RewardedAdGateEvent,
): RewardedAdGateState {
switch (event.type) {
case "start":
return state.status === "idle" ? { status: "loading" } : state;
case "success":
return state.status === "loading" ? { status: "success" } : state;
case "failure":
if (state.status === "loading") return { status: "error", failureReason: event.reason };
if (state.status === "idle" && event.reason === "blocked") {
return { status: "error", failureReason: "blocked" };
}
return state;
case "timeout":
return state.status === "loading" ? { status: "error", failureReason: "timeout" } : state;
case "retry":
return state.status === "error" ? { status: "idle" } : state;
}
}