Procházet zdrojové kódy

fix(sponsor): anchor 14-day toast cooldown on show, not just on CTA click (#2477)

    The sponsor toast re-fired on every fresh browser session. The backend
    owns the 14-day cooldown but only persists the anchor (last_shown_at) and
    the seen-milestone record inside POST /sponsor-prompt/dismiss, and the hook
    only called dismiss from the "View supporters" CTA onClick. A user who saw
    the toast but never clicked the CTA persisted no state; the per-tab
    sessionStorage guard hid the re-fire within one session, but every new
    session re-checked against empty state and re-showed the same milestone.

    Record the toast as shown the moment it renders (POST /dismiss right after
    showPersistentToast) so display is what arms the cooldown. CTA click stays
    optional and just navigates. Frontend-only; backend cooldown logic unchanged.
maziggy před 2 měsíci
rodič
revize
85bd68b0cc

+ 114 - 0
frontend/src/__tests__/hooks/useSponsorPrompt.test.tsx

@@ -0,0 +1,114 @@
+/**
+ * Tests for the in-app sponsor-toast hook (#2477 regression guard).
+ *
+ * The bug: the 14-day cooldown is backend-owned, but the anchor is only
+ * persisted by POST /sponsor-prompt/dismiss — and the hook used to call
+ * dismiss ONLY from the "View supporters" CTA's onClick. A user who saw the
+ * toast but never clicked it persisted no state, so the toast re-fired on
+ * every fresh browser session. The fix records-on-show: the hook POSTs
+ * /dismiss the moment it renders the toast. These tests pin that contract so
+ * it can't silently regress back to click-only anchoring.
+ */
+
+import { describe, it, expect, afterEach, beforeEach, vi } from 'vitest';
+import { renderHook, waitFor, cleanup } from '@testing-library/react';
+import { http, HttpResponse } from 'msw';
+import { server } from '../mocks/server';
+import { useSponsorPrompt } from '../../hooks/useSponsorPrompt';
+
+// The hook only needs `loading` from auth and `showPersistentToast` from the
+// toast context — mock both so the test doesn't drag in the real providers
+// (auth bootstrap, toast portal). The real sponsorPromptApi still runs and
+// hits MSW, which is exactly what we want to assert on.
+vi.mock('../../contexts/AuthContext', () => ({
+  useAuth: () => ({ loading: false }),
+}));
+
+const showPersistentToast = vi.fn();
+vi.mock('../../contexts/ToastContext', () => ({
+  useToast: () => ({ showPersistentToast }),
+}));
+
+beforeEach(() => {
+  showPersistentToast.mockClear();
+  sessionStorage.clear();
+});
+
+afterEach(() => {
+  cleanup();
+});
+
+describe('useSponsorPrompt', () => {
+  it('records the toast as shown (POSTs /dismiss) as soon as it renders, without a CTA click', async () => {
+    const dismissed: string[] = [];
+    server.use(
+      http.get('/api/v1/sponsor-prompt/check', () =>
+        HttpResponse.json({
+          show: true,
+          milestone: 'prints-500',
+          family: 'prints',
+          threshold: 500,
+          payload: { count: 512 },
+        }),
+      ),
+      http.post('/api/v1/sponsor-prompt/dismiss', async ({ request }) => {
+        const body = (await request.json()) as { milestone: string };
+        dismissed.push(body.milestone);
+        return new HttpResponse(null, { status: 204 });
+      }),
+    );
+
+    renderHook(() => useSponsorPrompt('EUR'));
+
+    // The toast is shown...
+    await waitFor(() => expect(showPersistentToast).toHaveBeenCalledTimes(1));
+    // ...and the cooldown is anchored on show, not on any CTA interaction.
+    await waitFor(() => expect(dismissed).toEqual(['prints-500']));
+
+    // The CTA is present for navigation but carries no onClick side effect —
+    // anchoring no longer depends on the user clicking through.
+    const options = showPersistentToast.mock.calls[0][3];
+    expect(options.action.href).toContain('from=app-toast-prints-500');
+    expect(options.action.onClick).toBeUndefined();
+  });
+
+  it('does not show a toast or anchor the cooldown when the check returns show:false', async () => {
+    let dismissCalls = 0;
+    server.use(
+      http.get('/api/v1/sponsor-prompt/check', () => HttpResponse.json({ show: false })),
+      http.post('/api/v1/sponsor-prompt/dismiss', () => {
+        dismissCalls += 1;
+        return new HttpResponse(null, { status: 204 });
+      }),
+    );
+
+    renderHook(() => useSponsorPrompt('EUR'));
+
+    // Give the async effect a chance to run before asserting the negatives.
+    await waitFor(() => expect(sessionStorage.getItem('sponsorPromptShown')).toBe('1'));
+    expect(showPersistentToast).not.toHaveBeenCalled();
+    expect(dismissCalls).toBe(0);
+  });
+
+  it('does not re-check within the same browser session (sessionStorage guard)', async () => {
+    let checkCalls = 0;
+    server.use(
+      http.get('/api/v1/sponsor-prompt/check', () => {
+        checkCalls += 1;
+        return HttpResponse.json({ show: false });
+      }),
+      http.post('/api/v1/sponsor-prompt/dismiss', () => new HttpResponse(null, { status: 204 })),
+    );
+
+    const first = renderHook(() => useSponsorPrompt('EUR'));
+    await waitFor(() => expect(checkCalls).toBe(1));
+    first.unmount();
+
+    // A second mount in the same session (e.g. a route change that remounts
+    // Layout) must not re-run the check — that per-tab guard is what keeps the
+    // toast from flashing repeatedly while a session is open.
+    renderHook(() => useSponsorPrompt('EUR'));
+    await new Promise((r) => setTimeout(r, 20));
+    expect(checkCalls).toBe(1);
+  });
+});

+ 8 - 4
frontend/src/hooks/useSponsorPrompt.ts

@@ -5,7 +5,11 @@
  * sponsors page with a Matomo-trackable `?from=app-toast-{milestone}` param.
  * sponsors page with a Matomo-trackable `?from=app-toast-{milestone}` param.
  *
  *
  * The 14-day cooldown + already-seen-milestone deduplication is owned by the
  * The 14-day cooldown + already-seen-milestone deduplication is owned by the
- * backend service — the hook just trusts the check endpoint's verdict.
+ * backend service. The hook trusts the check endpoint's verdict, and the moment
+ * it actually renders the toast it POSTs /dismiss to anchor the cooldown — being
+ * *shown* is what arms the 14-day gate, not the user clicking the CTA. (Clicking
+ * is optional; without this record-on-show, an ignored toast would never persist
+ * any state and would re-fire on every fresh browser session.)
  */
  */
 import { useEffect, useRef } from 'react';
 import { useEffect, useRef } from 'react';
 import { useTranslation } from 'react-i18next';
 import { useTranslation } from 'react-i18next';
@@ -77,11 +81,11 @@ export function useSponsorPrompt(currencyCode = 'EUR') {
           action: {
           action: {
             label: t('sponsors.viewSupporters', 'View supporters'),
             label: t('sponsors.viewSupporters', 'View supporters'),
             href: `https://bambuddy.cool/sponsors.html?from=app-toast-${result.milestone}`,
             href: `https://bambuddy.cool/sponsors.html?from=app-toast-${result.milestone}`,
-            onClick: () => {
-              void sponsorPromptApi.dismiss(result.milestone!);
-            },
           },
           },
         });
         });
+        // Anchor the 14-day cooldown as soon as the toast is on screen, so an
+        // ignored toast doesn't re-fire on the next browser session.
+        void sponsorPromptApi.dismiss(result.milestone);
       } catch {
       } catch {
         // Network / 401 — silently skip; next session retries.
         // Network / 401 — silently skip; next session retries.
       }
       }

Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
static/assets/index-Bp9OAjMR.js


+ 1 - 1
static/index.html

@@ -26,7 +26,7 @@
 
 
     <!-- Splash screens for iOS -->
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-DqJZ0C8s.js"></script>
+    <script type="module" crossorigin src="/assets/index-Bp9OAjMR.js"></script>
     <link rel="stylesheet" crossorigin href="/assets/index-BxVhuRti.css">
     <link rel="stylesheet" crossorigin href="/assets/index-BxVhuRti.css">
   </head>
   </head>
   <body>
   <body>

Některé soubory nejsou zobrazeny, neboť je v těchto rozdílových datech změněno mnoho souborů