mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-07-12 15:53:58 +00:00
8e5f27949f
Resolves #4029 ## Description: Client-side marketing-email consent capture — the client half of #4029. It consumes the already-merged API (`GET /users/@me` marketing-consent state + `POST /marketing/consent`); **no backend changes**. **Three surfaces:** - **Post-login prompt** — a small, non-blocking toast docked top-right, shown once after login when the player's consent is undecided (`no_response`) and a verified email is on file. Yes / No thanks / dismiss all record a decision, and it never re-nags. - **Account → "Account Settings" tab** — a persistent on/off toggle to change or withdraw consent at any time (the GDPR change/withdraw path). - **No-email state** — when the account has no verified email, the tab offers to bind one (magic link to a plain email — the backend's `new-association` path — or link Google) so the player can subscribe. Styling matches the game's existing panels (`bg-surface`, `border-white/10`, `shadow-[var(--shadow-malibu-blue)]`, malibu-blue accent), reusing `o-button` and the account modal's existing email field/handlers. **Changes:** - New `<marketing-consent-toast>` (`src/client/MarketingConsentToast.ts`), mounted in `index.html`, registered in `Main.ts`. - `AccountModal.ts`: new "Account Settings" tab (toggle + bind-email state), optimistic with revert-on-failure. - `Api.ts`: `setMarketingConsent()` → `POST /marketing/consent`, invalidates cached `/users/@me`. - `ApiSchemas.ts`: optional `player.marketingConsent { consented, hasEmail }` on `UserMeResponse`. - `en.json`: `marketing_consent.*` + `account_modal.marketing_*` / `tab_settings`. ## Please complete the following: - [x] I have added screenshots for all UI updates <!-- attaching in a follow-up comment --> - [x] I process any text displayed to the user through translateText() and I've added it to the en.json file - [x] I have added relevant tests to the test directory ## Please put your Discord username so you can be contacted if a bug or regression is found: Iamlewis --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
154 lines
5.2 KiB
TypeScript
154 lines
5.2 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
const { setMarketingConsent } = vi.hoisted(() => ({
|
|
setMarketingConsent: vi.fn(async () => true),
|
|
}));
|
|
vi.mock("../../src/client/Api", () => ({ setMarketingConsent }));
|
|
vi.mock("../../src/client/Utils", () => ({
|
|
translateText: (key: string) => key,
|
|
}));
|
|
|
|
import { MarketingConsentToast } from "../../src/client/components/MarketingConsentToast";
|
|
|
|
type Consent = "approved" | "denied" | "no_response";
|
|
|
|
function userMe(consented: Consent, hasEmail: boolean) {
|
|
return {
|
|
user: { email: "player@example.com" },
|
|
player: {
|
|
publicId: "p",
|
|
marketingConsent: { consented, hasEmail },
|
|
},
|
|
};
|
|
}
|
|
|
|
function fireUserMe(detail: unknown) {
|
|
document.dispatchEvent(new CustomEvent("userMeResponse", { detail }));
|
|
}
|
|
|
|
describe("marketing-consent-toast", () => {
|
|
let el: MarketingConsentToast;
|
|
|
|
beforeEach(async () => {
|
|
// The @customElement decorator's define() side-effect doesn't run under the
|
|
// test transform, so register the element explicitly (as other client
|
|
// component tests do).
|
|
if (!customElements.get("marketing-consent-toast")) {
|
|
customElements.define("marketing-consent-toast", MarketingConsentToast);
|
|
}
|
|
el = document.createElement(
|
|
"marketing-consent-toast",
|
|
) as MarketingConsentToast;
|
|
document.body.appendChild(el);
|
|
// Let the initial (hidden) render settle before firing consent events.
|
|
await el.updateComplete;
|
|
});
|
|
|
|
afterEach(() => {
|
|
el.remove();
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
async function shown(): Promise<boolean> {
|
|
// Flush any in-flight async decide() (it awaits setMarketingConsent).
|
|
await new Promise((r) => setTimeout(r, 0));
|
|
await el.updateComplete;
|
|
return el.querySelector('[role="dialog"]') !== null;
|
|
}
|
|
|
|
// Click the button whose accessible name / text matches, e.g. the "yes" or
|
|
// "no" action or the dismiss ("dismiss") X.
|
|
function clickButton(match: RegExp): void {
|
|
const btn = [...el.querySelectorAll("button")].find((b) =>
|
|
match.test(
|
|
`${b.getAttribute("aria-label") ?? ""} ${b.textContent ?? ""}`,
|
|
),
|
|
);
|
|
btn?.click();
|
|
}
|
|
|
|
it("shows when consent is undecided and an email is on file", async () => {
|
|
fireUserMe(userMe("no_response", true));
|
|
expect(await shown()).toBe(true);
|
|
});
|
|
|
|
it("stays hidden when there is no email on the account", async () => {
|
|
fireUserMe(userMe("no_response", false));
|
|
expect(await shown()).toBe(false);
|
|
});
|
|
|
|
it("stays hidden once a decision already exists", async () => {
|
|
fireUserMe(userMe("approved", true));
|
|
expect(await shown()).toBe(false);
|
|
fireUserMe(userMe("denied", true));
|
|
expect(await shown()).toBe(false);
|
|
});
|
|
|
|
it("stays hidden when the player is not logged in", async () => {
|
|
fireUserMe(false);
|
|
expect(await shown()).toBe(false);
|
|
});
|
|
|
|
it("records approval on 'yes' and does not reappear", async () => {
|
|
fireUserMe(userMe("no_response", true));
|
|
expect(await shown()).toBe(true);
|
|
|
|
clickButton(/marketing_consent\.yes/);
|
|
expect(await shown()).toBe(false);
|
|
expect(setMarketingConsent).toHaveBeenCalledWith(true);
|
|
|
|
// Re-firing the undecided state must not resurrect the prompt.
|
|
fireUserMe(userMe("no_response", true));
|
|
expect(await shown()).toBe(false);
|
|
});
|
|
|
|
it("records a denial on 'no thanks'", async () => {
|
|
fireUserMe(userMe("no_response", true));
|
|
expect(await shown()).toBe(true);
|
|
|
|
clickButton(/marketing_consent\.no/);
|
|
expect(await shown()).toBe(false);
|
|
expect(setMarketingConsent).toHaveBeenCalledWith(false);
|
|
});
|
|
|
|
it("records nothing on a subtle dismiss (X) — leaves state undecided", async () => {
|
|
fireUserMe(userMe("no_response", true));
|
|
expect(await shown()).toBe(true);
|
|
|
|
clickButton(/marketing_consent\.dismiss/);
|
|
expect(await shown()).toBe(false);
|
|
expect(setMarketingConsent).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("keeps the toast up when the write fails (no silent success)", async () => {
|
|
setMarketingConsent.mockResolvedValueOnce(false);
|
|
fireUserMe(userMe("no_response", true));
|
|
expect(await shown()).toBe(true);
|
|
|
|
clickButton(/marketing_consent\.yes/);
|
|
expect(setMarketingConsent).toHaveBeenCalledWith(true);
|
|
// The request failed, so the prompt stays visible for a retry.
|
|
expect(await shown()).toBe(true);
|
|
});
|
|
|
|
// Responsive layout: a full-width top banner on small screens (so it doesn't
|
|
// crop text on phones like the 14 Pro Max) and a top-right 236px card on sm+.
|
|
// jsdom has no layout engine to evaluate media queries, so assert the
|
|
// Tailwind classes that drive the two layouts are present.
|
|
it("uses a full-width banner on mobile and a top-right card on sm+", async () => {
|
|
fireUserMe(userMe("no_response", true));
|
|
await el.updateComplete;
|
|
const dialog = el.querySelector('[role="dialog"]') as HTMLElement;
|
|
const cls = dialog.className;
|
|
|
|
// Mobile (default): pinned left+right, auto width → spans the viewport.
|
|
for (const c of ["left-4", "right-4", "w-auto"]) {
|
|
expect(cls).toContain(c);
|
|
}
|
|
// sm+ (tablet/desktop): release the left edge and become a fixed 236px card.
|
|
for (const c of ["sm:left-auto", "sm:right-4", "sm:w-[236px]"]) {
|
|
expect(cls).toContain(c);
|
|
}
|
|
});
|
|
});
|