Skip to content

Commit ccac646

Browse files
authored
feat: implement CTA toast context and enhance toast display logic (#152)
* Introduced CTAToastProvider and useCTAToast hook for managing toast state. * Refactored toast display logic to prevent duplicate toasts within a session. * Updated components to utilize the new context for showing random CTA toasts. * Adjusted timer durations for displaying toasts in various components for improved user experience. * Cleaned up and organized toast-related code for better maintainability.
1 parent d2178fa commit ccac646

8 files changed

Lines changed: 155 additions & 80 deletions

File tree

src/app/(app)/components/cta-toasts/index.tsx

Lines changed: 35 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -240,21 +240,40 @@ function DefaultToastFeedbackButton(
240240
);
241241
}
242242

243+
export const CTA_TOASTS = [
244+
{
245+
id: "premium-donation-toast-client-page",
246+
title: "Support My Work",
247+
description:
248+
"Your contribution helps me maintain and improve this project for everyone! 🚀",
249+
show: customPremiumToast,
250+
},
251+
{
252+
id: "default-donation-toast-client-page",
253+
title: "Love this project?",
254+
description:
255+
"Help me keep this free tool running! Your support enables me to add new features and maintain the service. 🙏",
256+
show: customDefaultToast,
257+
},
258+
] as const satisfies {
259+
id: string;
260+
title: string;
261+
description: string;
262+
show: (toast: ToastProps) => void;
263+
}[];
264+
243265
export const showRandomCTAToast = () => {
244-
// Randomly show either default or premium donation toast
245-
if (Math.random() <= 0.5) {
246-
customPremiumToast({
247-
id: "premium-donation-toast-client-page",
248-
title: "Support My Work",
249-
description:
250-
"Your contribution helps me maintain and improve this project for everyone! 🚀",
251-
});
252-
} else {
253-
customDefaultToast({
254-
id: "default-donation-toast-client-page",
255-
title: "Love this project?",
256-
description:
257-
"Help me keep this free tool running! Your support enables me to add new features and maintain the service. 🙏",
258-
});
259-
}
266+
// Randomly show a CTA toast
267+
const variant = CTA_TOASTS[Math.floor(Math.random() * CTA_TOASTS.length)];
268+
269+
variant.show({
270+
id: variant.id,
271+
title: variant.title,
272+
description: variant.description,
273+
});
260274
};
275+
276+
/**
277+
* Slight delay to prevent the toast from appearing too quickly
278+
*/
279+
export const CTA_TOAST_TIMEOUT = 3_000; // in ms

src/app/(app)/components/invoice-pdf-download-link.tsx

Lines changed: 18 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,12 @@ import { LOADING_BUTTON_TEXT, LOADING_BUTTON_TIMEOUT } from "./invoice-form";
1616
import { StripeInvoicePdfTemplate } from "./invoice-pdf-stripe-template";
1717
import { InvoicePdfTemplate } from "./invoice-pdf-template";
1818

19-
import { customDefaultToast, customPremiumToast } from "./cta-toasts";
19+
import { showRandomCTAToast } from "./cta-toasts";
2020
import { useDeviceContext } from "@/contexts/device-context";
2121
import { isTelegramInAppBrowser } from "@/utils/is-telegram-in-app-browser";
22+
import { CTA_TOAST_LAST_SHOWN_STORAGE_KEY } from "../hooks/use-show-random-cta-toast";
23+
import { useCTAToast } from "../contexts/cta-toast-context";
24+
import { CTA_TOAST_TIMEOUT } from "./cta-toasts";
2225

2326
// Separate button states into a memoized component
2427
const ButtonContent = ({
@@ -52,6 +55,7 @@ export function InvoicePDFDownloadLink({
5255
setErrorWhileGeneratingPdfIsShown: (error: boolean) => void;
5356
}) {
5457
const { inAppInfo } = useDeviceContext();
58+
const { markToastAsShown } = useCTAToast();
5559

5660
const [{ loading: pdfLoading, url, error }, updatePdfInstance] = usePDF();
5761
const [isLoading, setIsLoading] = useState(false);
@@ -106,24 +110,19 @@ export function InvoicePDFDownloadLink({
106110
// close all other toasts (if any)
107111
toast.dismiss();
108112

109-
// Randomly show either default or premium donation toast after 2.5 seconds
113+
// Show a CTA toast
110114
setTimeout(() => {
111-
if (Math.random() <= 0.5) {
112-
customPremiumToast({
113-
id: "premium-donation-toast",
114-
title: "Support My Work",
115-
description:
116-
"Your contribution helps me maintain and improve this project for everyone! 🚀",
117-
});
118-
} else {
119-
customDefaultToast({
120-
id: "default-donation-toast",
121-
title: "Love this project?",
122-
description:
123-
"Your support helps me keep this free tool running and build even better features! 🙏",
124-
});
125-
}
126-
}, 3000);
115+
showRandomCTAToast();
116+
117+
// Mark toast as shown in session to prevent duplicate toasts
118+
markToastAsShown();
119+
120+
// Update timestamp to prevent other CTA toasts from showing for 7 days
121+
localStorage.setItem(
122+
CTA_TOAST_LAST_SHOWN_STORAGE_KEY,
123+
String(Date.now()),
124+
);
125+
}, CTA_TOAST_TIMEOUT);
127126
}
128127
},
129128
[
@@ -134,6 +133,7 @@ export function InvoicePDFDownloadLink({
134133
error,
135134
trackDownload,
136135
isTelegramPreviewBrowser,
136+
markToastAsShown,
137137
],
138138
);
139139

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
"use client";
2+
3+
import { createContext, useContext, useState, useCallback } from "react";
4+
5+
interface CTAToastContextValue {
6+
isToastShownInSession: boolean;
7+
markToastAsShown: () => void;
8+
}
9+
10+
const CTAToastContext = createContext<CTAToastContextValue | undefined>(
11+
undefined,
12+
);
13+
14+
export function CTAToastProvider({ children }: { children: React.ReactNode }) {
15+
const [isToastShownInSession, setIsToastShownInSession] = useState(false);
16+
17+
const markToastAsShown = useCallback(() => {
18+
setIsToastShownInSession(true);
19+
}, []);
20+
21+
return (
22+
<CTAToastContext.Provider
23+
value={{ isToastShownInSession, markToastAsShown }}
24+
>
25+
{children}
26+
</CTAToastContext.Provider>
27+
);
28+
}
29+
30+
export function useCTAToast() {
31+
const context = useContext(CTAToastContext);
32+
33+
if (context === undefined) {
34+
throw new Error("useCTAToast must be used within a CTAToastProvider");
35+
}
36+
37+
return context;
38+
}

src/app/(app)/hooks/use-show-random-cta-toast.tsx

Lines changed: 25 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3,26 +3,35 @@
33
import { useEffect } from "react";
44
import { umamiTrackEvent } from "@/lib/umami-analytics-track-event";
55
import { showRandomCTAToast } from "../components/cta-toasts";
6+
import { useCTAToast } from "../contexts/cta-toast-context";
67
import dayjs from "dayjs";
78
import duration from "dayjs/plugin/duration";
89

910
dayjs.extend(duration);
1011

11-
const STORAGE_KEY = "EASY_INVOICE_CTA_LAST_SHOWN_AT";
12+
export const CTA_TOAST_LAST_SHOWN_STORAGE_KEY =
13+
"EASY_INVOICE_CTA_LAST_SHOWN_AT";
1214
const COOLDOWN_DAYS = 7;
13-
const MIN_TIME_ON_PAGE = 15_000; // in milliseconds
14-
const IDLE_TIME = 5_000; // in milliseconds
15+
const MIN_TIME_ON_PAGE = 10_000; // in ms
16+
const IDLE_TIME = 7_000; // in ms
1517

1618
/**
1719
* This hook is used to show a CTA toast after a certain number of interactions with the page.
1820
*
19-
* - User stays on page 10s (MIN_TIME_ON_PAGE) → we consider them present.
21+
* - User stays on page Xs (MIN_TIME_ON_PAGE) → we consider them "present".
2022
* - Any real interaction (type, click) → we mark them engaged.
2123
* - Once they stop interacting for X seconds (IDLE_TIME) → toast appears.
2224
*/
2325
export function useShowRandomCTAToast() {
26+
const { isToastShownInSession, markToastAsShown } = useCTAToast();
27+
2428
useEffect(() => {
25-
const last = localStorage.getItem(STORAGE_KEY);
29+
// Skip if a CTA toast was already shown in this session (e.g., from PDF download)
30+
if (isToastShownInSession) {
31+
umamiTrackEvent("cta_toast_skipped_session");
32+
return;
33+
}
34+
const last = localStorage.getItem(CTA_TOAST_LAST_SHOWN_STORAGE_KEY);
2635

2736
// Check if the last time the CTA toast was shown was less than 7 days ago
2837
// Parse and validate the stored timestamp
@@ -31,15 +40,16 @@ export function useShowRandomCTAToast() {
3140

3241
// If invalid timestamp, clear it from storage and treat as not in cooldown
3342
if (last && !isValidTimestamp) {
34-
localStorage.removeItem(STORAGE_KEY);
43+
localStorage.removeItem(CTA_TOAST_LAST_SHOWN_STORAGE_KEY);
3544
}
3645

3746
const isWithinCooldownPeriod =
3847
isValidTimestamp &&
3948
dayjs().diff(dayjs(parsedLast)) <
4049
dayjs.duration(COOLDOWN_DAYS, "day").asMilliseconds();
4150

42-
// If the last time the CTA toast was shown was less than 7 days ago, skip showing the toast
51+
// If the last time any CTA toast was shown was less than 7 days ago, skip showing the toast
52+
// This includes both the random CTA toast and the PDF download donation toast
4353
if (last && isWithinCooldownPeriod) {
4454
umamiTrackEvent("cta_toast_skipped_recently");
4555
return;
@@ -59,8 +69,14 @@ export function useShowRandomCTAToast() {
5969
triggered = true;
6070
showRandomCTAToast();
6171

72+
// mark toast as shown in session to prevent duplicate toasts
73+
markToastAsShown();
74+
6275
// save last shown timestamp to localStorage
63-
localStorage.setItem(STORAGE_KEY, String(Date.now()));
76+
localStorage.setItem(
77+
CTA_TOAST_LAST_SHOWN_STORAGE_KEY,
78+
String(Date.now()),
79+
);
6480

6581
// track event
6682
umamiTrackEvent("cta_toast_shown");
@@ -108,5 +124,5 @@ export function useShowRandomCTAToast() {
108124
window.removeEventListener("pointerdown", resetIdle);
109125
window.removeEventListener("keydown", resetIdle);
110126
};
111-
}, []);
127+
}, [isToastShownInSession, markToastAsShown]);
112128
}

src/app/(app)/page.client.tsx

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,13 @@ import { z } from "zod";
4646
import { InvoiceClientPage } from "./components";
4747
import { InvoicePDFDownloadLink } from "./components/invoice-pdf-download-link";
4848
import { handleInvoiceNumberBreakingChange } from "./utils/invoice-number-breaking-change";
49-
import { useShowRandomCTAToast } from "./hooks/use-show-random-cta-toast";
49+
import {
50+
CTA_TOAST_LAST_SHOWN_STORAGE_KEY,
51+
useShowRandomCTAToast,
52+
} from "./hooks/use-show-random-cta-toast";
53+
import { CTA_TOAST_TIMEOUT, showRandomCTAToast } from "./components/cta-toasts";
54+
import { useCTAToast } from "./contexts/cta-toast-context";
55+
5056
// import { DevLocalStorageView } from "./components/dev/dev-local-storage-view";
5157
// import { InvoicePDFDownloadMultipleLanguages } from "./components/invoice-pdf-download-multiple-languages";
5258

@@ -66,6 +72,8 @@ export function AppPageClient({
6672
const router = useRouter();
6773
const searchParams = useSearchParams();
6874

75+
const { markToastAsShown } = useCTAToast();
76+
6977
const urlTemplateSearchParam = searchParams.get("template");
7078

7179
// Validate template parameter with zod
@@ -369,6 +377,20 @@ export function AppPageClient({
369377

370378
// analytics track event
371379
umamiTrackEvent("share_invoice_link");
380+
381+
// Show a CTA toast
382+
setTimeout(() => {
383+
showRandomCTAToast();
384+
385+
// Mark toast as shown in session to prevent duplicate toasts
386+
markToastAsShown();
387+
388+
// Update timestamp to prevent other CTA toasts from showing
389+
localStorage.setItem(
390+
CTA_TOAST_LAST_SHOWN_STORAGE_KEY,
391+
String(Date.now()),
392+
);
393+
}, CTA_TOAST_TIMEOUT);
372394
} catch (error) {
373395
console.error("Failed to share invoice:", error);
374396
toast.error("Failed to generate shareable link");

src/app/(app)/page.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import type { Metadata } from "next";
22
import { AppPageClient } from "./page.client";
33
import { APP_URL, STATIC_ASSETS_URL } from "@/config";
44
import { fetchGithubStars } from "@/actions/fetch-github-stars";
5+
import { CTAToastProvider } from "./contexts/cta-toast-context";
56

67
// we generate metadata here, because we need access to searchParams (in layout we don't have it)
78
export async function generateMetadata({
@@ -125,5 +126,9 @@ export async function generateMetadata({
125126
export default async function AppPage() {
126127
const githubStarsCount = await fetchGithubStars();
127128

128-
return <AppPageClient githubStarsCount={githubStarsCount} />;
129+
return (
130+
<CTAToastProvider>
131+
<AppPageClient githubStarsCount={githubStarsCount} />
132+
</CTAToastProvider>
133+
);
129134
}

src/app/[locale]/about/components/landing-cta-toast.tsx

Lines changed: 6 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,49 +1,21 @@
11
"use client";
22

3-
import {
4-
customDefaultToast,
5-
customPremiumToast,
6-
} from "@/app/(app)/components/cta-toasts";
3+
import { umamiTrackEvent } from "@/lib/umami-analytics-track-event";
74
import { useEffect } from "react";
5+
import { showRandomCTAToast } from "@/app/(app)/components/cta-toasts";
86

97
/**
108
* This component is used to show a CTA toast on the landing page.
11-
*
12-
* It is used to encourage users to support the project.
13-
*
14-
* It is only shown on the production environment.
15-
*
16-
* It is shown after 7 seconds on the page.
17-
*
189
*/
1910
export const LandingCtaToast = () => {
20-
// Show CTA toast every minute
2111
useEffect(() => {
22-
// only show on production
23-
if (process.env.NODE_ENV !== "production") {
24-
return;
25-
}
26-
2712
const showCTAToast = () => {
28-
// Randomly show either default or premium donation toast
29-
if (Math.random() <= 0.5) {
30-
customPremiumToast({
31-
id: "premium-donation-toast-landing-page",
32-
title: "Support My Work",
33-
description:
34-
"Your contribution helps me maintain and improve this project for everyone! 🚀",
35-
});
36-
} else {
37-
customDefaultToast({
38-
id: "default-donation-toast-landing-page",
39-
title: "Love this project?",
40-
description:
41-
"Help me keep building amazing tools! Your support means the world to me. ✨",
42-
});
43-
}
13+
showRandomCTAToast();
14+
15+
umamiTrackEvent("cta_toast_shown_landing_page");
4416
};
4517

46-
// Show cta toast after 15 seconds on the page
18+
// Show cta toast after X seconds on the page
4719
const initialTimer = setTimeout(showCTAToast, 15_000);
4820

4921
return () => {

src/components/ui/dialog.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,10 @@ const DialogContent = React.forwardRef<
4444
{...props}
4545
>
4646
{children}
47-
<DialogPrimitive.Close className="focus-visible:outline-ring/70 group absolute right-3 top-3 flex size-7 items-center justify-center rounded-lg outline-offset-2 transition-colors focus-visible:outline focus-visible:outline-2 disabled:pointer-events-none">
47+
<DialogPrimitive.Close
48+
className="focus-visible:outline-ring/70 group absolute right-3 top-3 flex size-7 items-center justify-center rounded-lg outline-offset-2 transition-colors focus-visible:outline focus-visible:outline-2 disabled:pointer-events-none"
49+
title="Close"
50+
>
4851
<X
4952
size={16}
5053
strokeWidth={2}

0 commit comments

Comments
 (0)