useSponsorPrompt.ts 4.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120
  1. /**
  2. * Sponsor-prompt toast hook. Fires once per browser session: after auth
  3. * resolves, hits /sponsor-prompt/check; if a trigger is eligible, displays a
  4. * persistent toast with a "View supporters" CTA that links to the public
  5. * sponsors page with a Matomo-trackable `?from=app-toast-{milestone}` param.
  6. *
  7. * The 14-day cooldown + already-seen-milestone deduplication is owned by the
  8. * backend service. The hook trusts the check endpoint's verdict, and the moment
  9. * it actually renders the toast it POSTs /dismiss to anchor the cooldown — being
  10. * *shown* is what arms the 14-day gate, not the user clicking the CTA. (Clicking
  11. * is optional; without this record-on-show, an ignored toast would never persist
  12. * any state and would re-fire on every fresh browser session.)
  13. */
  14. import { useEffect, useRef } from 'react';
  15. import { useTranslation } from 'react-i18next';
  16. import { useQuery } from '@tanstack/react-query';
  17. import { useAuth } from '../contexts/AuthContext';
  18. import { useToast } from '../contexts/ToastContext';
  19. import { api, sponsorPromptApi, type SponsorPromptCheckResponse } from '../api/client';
  20. import { getCurrencySymbol } from '../utils/currency';
  21. import { fleetAudience, sponsorHref, type SponsorAudience } from '../utils/fleetAudience';
  22. const TOAST_ID = 'sponsor-prompt';
  23. const SESSION_SHOWN_KEY = 'sponsorPromptShown';
  24. function _num(v: unknown, fallback = 0): number {
  25. return typeof v === 'number' ? v : fallback;
  26. }
  27. function _str(v: unknown, fallback = ''): string {
  28. return typeof v === 'string' ? v : fallback;
  29. }
  30. function buildMessage(
  31. t: ReturnType<typeof useTranslation>['t'],
  32. trigger: SponsorPromptCheckResponse,
  33. currencyCode: string,
  34. audience: SponsorAudience,
  35. printerCount: number,
  36. ): string | null {
  37. // A business install has earned the same milestone, but the ask is different:
  38. // support contract and invoicing, not a personal donation. One toast either
  39. // way — the fleet only changes which one.
  40. if (audience === 'business') {
  41. return t('sponsors.toastBusiness', { count: printerCount });
  42. }
  43. const family = trigger.family;
  44. const payload = trigger.payload ?? {};
  45. const threshold = trigger.threshold ?? 0;
  46. switch (family) {
  47. case 'prints':
  48. return t('sponsors.toastPrints', { count: _num(payload.count, threshold) });
  49. case 'archives':
  50. return t('sponsors.toastArchives', { count: _num(payload.count, threshold) });
  51. case 'cost': {
  52. const total = _num(payload.total, threshold);
  53. const symbol = getCurrencySymbol(currencyCode);
  54. return t('sponsors.toastCost', { total: `${symbol}${total}` });
  55. }
  56. case 'anniversary':
  57. return t('sponsors.toastAnniversary');
  58. case 'version-update':
  59. return t('sponsors.toastVersionUpdate', { version: _str(payload.to) });
  60. default:
  61. return null;
  62. }
  63. }
  64. export function useSponsorPrompt(currencyCode = 'EUR') {
  65. const { t } = useTranslation();
  66. const { loading } = useAuth();
  67. const { showPersistentToast } = useToast();
  68. const firedRef = useRef(false);
  69. // Fleet size decides which ask the toast makes. The printers list is already
  70. // cached app-wide, so this is normally a cache read; on a cold start we wait
  71. // for it rather than pitch a print farm as if it were a hobbyist. An error
  72. // (no permission, offline) settles the query too and falls back to personal.
  73. const { data: printers, isPending: printersPending } = useQuery({
  74. queryKey: ['printers'],
  75. queryFn: api.getPrinters,
  76. retry: false,
  77. });
  78. useEffect(() => {
  79. if (loading || printersPending || firedRef.current) return;
  80. if (sessionStorage.getItem(SESSION_SHOWN_KEY)) {
  81. firedRef.current = true;
  82. return;
  83. }
  84. firedRef.current = true;
  85. sessionStorage.setItem(SESSION_SHOWN_KEY, '1');
  86. const printerCount = printers?.length ?? 0;
  87. const audience = fleetAudience(printerCount);
  88. (async () => {
  89. try {
  90. const result = await sponsorPromptApi.check();
  91. if (!result.show || !result.milestone) return;
  92. const message = buildMessage(t, result, currencyCode, audience, printerCount);
  93. if (!message) return;
  94. showPersistentToast(TOAST_ID, message, 'info', {
  95. action: {
  96. label:
  97. audience === 'business'
  98. ? t('sponsors.businessCta', 'Bambuddy for business')
  99. : t('sponsors.viewSupporters', 'View supporters'),
  100. href: sponsorHref(audience, `app-toast-${result.milestone}`),
  101. },
  102. });
  103. // Anchor the 14-day cooldown as soon as the toast is on screen, so an
  104. // ignored toast doesn't re-fire on the next browser session.
  105. void sponsorPromptApi.dismiss(result.milestone);
  106. } catch {
  107. // Network / 401 — silently skip; next session retries.
  108. }
  109. })();
  110. }, [loading, printersPending, printers, t, showPersistentToast, currencyCode]);
  111. }