mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-07-12 08:43:50 +00:00
feat: marketing email consent UI (post-login prompt + account settings) (#4554)
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>
This commit is contained in:
@@ -206,6 +206,7 @@
|
||||
></div>
|
||||
|
||||
<homepage-promos></homepage-promos>
|
||||
<marketing-consent-toast></marketing-consent-toast>
|
||||
|
||||
<!-- Main container with responsive padding -->
|
||||
<main-layout class="contents">
|
||||
|
||||
@@ -30,6 +30,9 @@
|
||||
"linked_to_google_email": "Linked to Google ({email})",
|
||||
"log_out": "Log Out",
|
||||
"manage_subscription": "Manage",
|
||||
"marketing_desc": "Occasional news, events, and game updates. Unsubscribe anytime.",
|
||||
"marketing_no_email": "Link an email to your account to subscribe to updates.",
|
||||
"marketing_title": "Email updates",
|
||||
"no_games": "No games played yet.",
|
||||
"no_stats": "No stats available yet. Play some games to start tracking.",
|
||||
"not_found": "Not Found",
|
||||
@@ -55,6 +58,7 @@
|
||||
"tab_account": "Account",
|
||||
"tab_friends": "Friends",
|
||||
"tab_games": "Games",
|
||||
"tab_settings": "Settings",
|
||||
"tab_stats": "Stats",
|
||||
"title": "Account",
|
||||
"your_subscription": "Your Subscription"
|
||||
@@ -1042,6 +1046,14 @@
|
||||
"search_results": "Search Results",
|
||||
"unfavorite": "Remove from favourites"
|
||||
},
|
||||
"marketing_consent": {
|
||||
"body": "News and updates by email. Change it anytime in your account.",
|
||||
"dismiss": "Dismiss",
|
||||
"error": "Couldn't save that. Please try again.",
|
||||
"no": "No thanks",
|
||||
"title": "Keep up with OpenFront?",
|
||||
"yes": "Yes please"
|
||||
},
|
||||
"matchmaking_button": {
|
||||
"description": "(ALPHA)",
|
||||
"login_required": "Login to play ranked!",
|
||||
|
||||
+124
-25
@@ -1,10 +1,15 @@
|
||||
import { html, TemplateResult } from "lit";
|
||||
import { html, nothing, TemplateResult } from "lit";
|
||||
import { customElement, state } from "lit/decorators.js";
|
||||
import { ClientEnv } from "src/client/ClientEnv";
|
||||
import { PlayerStatsTree, UserMeResponse } from "../core/ApiSchemas";
|
||||
import { assetUrl } from "../core/AssetUrls";
|
||||
import { Cosmetics } from "../core/CosmeticSchemas";
|
||||
import { fetchPlayerById, getUserMe, invalidateUserMe } from "./Api";
|
||||
import {
|
||||
fetchPlayerById,
|
||||
getUserMe,
|
||||
invalidateUserMe,
|
||||
setMarketingConsent,
|
||||
} from "./Api";
|
||||
import {
|
||||
discordLogin,
|
||||
googleLogin,
|
||||
@@ -38,6 +43,7 @@ export class AccountModal extends BaseModal {
|
||||
// Set on CrazyGames when a CrazyGames user is signed in. Their identity comes
|
||||
// from the SDK, not our backend user object.
|
||||
@state() private crazyGamesUser: CrazyGamesUser | null = null;
|
||||
@state() private consentBusy: boolean = false;
|
||||
|
||||
private userMeResponse: UserMeResponse | null = null;
|
||||
private statsTree: PlayerStatsTree | null = null;
|
||||
@@ -142,6 +148,7 @@ export class AccountModal extends BaseModal {
|
||||
{ key: "stats", label: translateText("account_modal.tab_stats") },
|
||||
{ key: "games", label: translateText("account_modal.tab_games") },
|
||||
{ key: "friends", label: translateText("account_modal.tab_friends") },
|
||||
{ key: "settings", label: translateText("account_modal.tab_settings") },
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -174,11 +181,125 @@ export class AccountModal extends BaseModal {
|
||||
return this.renderGamesTab();
|
||||
case "friends":
|
||||
return this.renderFriendsTab();
|
||||
case "settings":
|
||||
return this.renderSettingsTab();
|
||||
default:
|
||||
return this.renderAccountTab();
|
||||
}
|
||||
}
|
||||
|
||||
// Persistent marketing-consent control (client-driven consent). Mirrors the
|
||||
// post-login toast: a player can turn email updates on/off any time here, or
|
||||
// — when there's no verified email on the account — is told to link one.
|
||||
private renderSettingsTab(): TemplateResult {
|
||||
const consent = this.userMeResponse?.player?.marketingConsent;
|
||||
// The API didn't return consent state (older backend). The tab is always
|
||||
// shown, but with nothing to configure it stays empty rather than showing
|
||||
// a misleading "link an email" prompt.
|
||||
if (!consent) return html``;
|
||||
const hasEmail = consent.hasEmail;
|
||||
const on = consent.consented === "approved";
|
||||
return html`
|
||||
<div class="bg-white/5 rounded-xl border border-white/10 p-6">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div class="flex-1">
|
||||
<div class="text-white font-medium">
|
||||
${translateText("account_modal.marketing_title")}
|
||||
</div>
|
||||
<div class="text-white/50 text-sm mt-1">
|
||||
${hasEmail
|
||||
? translateText("account_modal.marketing_desc")
|
||||
: translateText("account_modal.marketing_no_email")}
|
||||
</div>
|
||||
</div>
|
||||
${hasEmail
|
||||
? html`<button
|
||||
role="switch"
|
||||
aria-checked=${on ? "true" : "false"}
|
||||
aria-label=${translateText("account_modal.marketing_title")}
|
||||
?disabled=${this.consentBusy}
|
||||
@click=${() => this.setConsent(!on)}
|
||||
class="relative inline-flex h-6 w-11 shrink-0 items-center rounded-full transition-colors duration-200 focus:outline-none focus:ring-2 focus:ring-malibu-blue/50 disabled:opacity-60 ${on
|
||||
? "bg-malibu-blue shadow-[var(--shadow-malibu-blue-pill)]"
|
||||
: "bg-white/15"}"
|
||||
>
|
||||
<span
|
||||
class="inline-block h-4 w-4 transform rounded-full bg-white transition-transform duration-200 ${on
|
||||
? "translate-x-6"
|
||||
: "translate-x-1"}"
|
||||
></span>
|
||||
</button>`
|
||||
: nothing}
|
||||
</div>
|
||||
${hasEmail ? nothing : this.renderEmailBinding()}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// No verified email on the account yet. Offer both ways to attach one:
|
||||
// a magic link to a plain email (the backend associates a not-yet-registered
|
||||
// email with the current session — the "new-association" path), or linking a
|
||||
// Google account. Reuses the login form's email field/handlers.
|
||||
private renderEmailBinding(): TemplateResult {
|
||||
return html`
|
||||
<div class="mt-4 space-y-3">
|
||||
${this.renderEmailField()}
|
||||
<div class="flex items-center gap-4 py-1">
|
||||
<div class="h-px bg-white/10 flex-1"></div>
|
||||
<span
|
||||
class="text-[10px] uppercase tracking-widest text-white/30 font-bold"
|
||||
>
|
||||
${translateText("account_modal.or")}
|
||||
</span>
|
||||
<div class="h-px bg-white/10 flex-1"></div>
|
||||
</div>
|
||||
${this.renderLinkGoogleButton()}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// Shared email input + "get magic link" button, used by both the sign-in form
|
||||
// and the Account Settings bind-an-email state so their styling and handlers
|
||||
// stay in sync.
|
||||
private renderEmailField(): TemplateResult {
|
||||
return html`
|
||||
<input
|
||||
type="email"
|
||||
.value=${this.email}
|
||||
@input=${this.handleEmailInput}
|
||||
placeholder=${translateText("account_modal.email_placeholder")}
|
||||
class="w-full px-4 py-3 bg-white/5 border border-white/10 rounded-xl text-white placeholder-white/20 focus:outline-none focus:ring-2 focus:ring-malibu-blue/50 focus:border-malibu-blue/50 transition-all font-medium hover:bg-white/10"
|
||||
/>
|
||||
<o-button
|
||||
variant="primary"
|
||||
width="block"
|
||||
size="md"
|
||||
translationKey="account_modal.get_magic_link"
|
||||
@click=${this.handleSubmit}
|
||||
></o-button>
|
||||
`;
|
||||
}
|
||||
|
||||
private async setConsent(consented: boolean): Promise<void> {
|
||||
const consent = this.userMeResponse?.player?.marketingConsent;
|
||||
if (!consent || this.consentBusy) return;
|
||||
const previous = consent.consented;
|
||||
const next = consented ? "approved" : "denied";
|
||||
if (previous === next) return;
|
||||
|
||||
// Optimistic: reflect the new state immediately, revert if the request fails.
|
||||
this.consentBusy = true;
|
||||
consent.consented = next;
|
||||
this.requestUpdate();
|
||||
|
||||
const ok = await setMarketingConsent(consented);
|
||||
if (!ok) {
|
||||
consent.consented = previous;
|
||||
}
|
||||
this.consentBusy = false;
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
private renderFriendsTab(): TemplateResult {
|
||||
const myPublicId = this.userMeResponse?.player?.publicId ?? "";
|
||||
return html`<friends-list .myPublicId=${myPublicId}></friends-list>`;
|
||||
@@ -518,29 +639,7 @@ export class AccountModal extends BaseModal {
|
||||
</div>
|
||||
|
||||
<!-- Email Recovery -->
|
||||
<div class="space-y-3">
|
||||
<div class="relative group">
|
||||
<input
|
||||
type="email"
|
||||
id="email"
|
||||
name="email"
|
||||
.value="${this.email}"
|
||||
@input="${this.handleEmailInput}"
|
||||
class="w-full pl-4 pr-12 py-3 bg-white/5 border border-white/10 rounded-xl text-white placeholder-white/20 focus:outline-none focus:ring-2 focus:ring-malibu-blue/50 focus:border-malibu-blue/50 transition-all font-medium hover:bg-white/10"
|
||||
placeholder="${translateText(
|
||||
"account_modal.email_placeholder",
|
||||
)}"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<o-button
|
||||
variant="primary"
|
||||
width="block"
|
||||
size="md"
|
||||
translationKey="account_modal.get_magic_link"
|
||||
@click=${this.handleSubmit}
|
||||
></o-button>
|
||||
</div>
|
||||
<div class="space-y-3">${this.renderEmailField()}</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-8 text-center border-t border-white/10 pt-6">
|
||||
|
||||
@@ -147,6 +147,42 @@ export function invalidateUserMe() {
|
||||
__userMe = null;
|
||||
}
|
||||
|
||||
// POST /marketing/consent — record the player's marketing-email choice
|
||||
// (client-driven consent). Called by the consent toast and account settings.
|
||||
// Invalidates the cached /users/@me so the new decision is reflected on the
|
||||
// next read. Returns true on success.
|
||||
export async function setMarketingConsent(
|
||||
consented: boolean,
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const response = await fetch(`${getApiBase()}/marketing/consent`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: await getAuthHeader(),
|
||||
},
|
||||
body: JSON.stringify({ consented }),
|
||||
});
|
||||
if (response.status === 401) {
|
||||
await logOut();
|
||||
return false;
|
||||
}
|
||||
if (!response.ok) {
|
||||
console.error(
|
||||
"setMarketingConsent: request failed",
|
||||
response.status,
|
||||
response.statusText,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
invalidateUserMe();
|
||||
return true;
|
||||
} catch (e) {
|
||||
console.error("setMarketingConsent: request failed", e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function purchaseWithCurrency(
|
||||
cosmeticType: "pattern" | "skin" | "flag" | "effect",
|
||||
cosmeticName: string,
|
||||
|
||||
@@ -67,6 +67,7 @@ import {
|
||||
isInIframe,
|
||||
translateText,
|
||||
} from "./Utils";
|
||||
import "./components/MarketingConsentToast";
|
||||
import { installSafariPinchZoomBlocker } from "./utilities/DisableSafariPinchZoom";
|
||||
|
||||
import "./components/DesktopNavBar";
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
import { html, LitElement, nothing } from "lit";
|
||||
import { customElement, state } from "lit/decorators.js";
|
||||
import type { UserMeResponse } from "../../core/ApiSchemas";
|
||||
import { setMarketingConsent } from "../Api";
|
||||
import { translateText } from "../Utils";
|
||||
|
||||
/**
|
||||
* A small, non-blocking prompt docked in the top-right corner that asks a
|
||||
* logged-in player whether they want marketing emails.
|
||||
*
|
||||
* Client-driven consent: it reads the player's consent state from the
|
||||
* `userMeResponse` document event (dispatched after login / on load) and only
|
||||
* appears when the player has not decided yet (`no_response`) and has an email
|
||||
* on file (`hasEmail`). The player's choice is recorded via POST
|
||||
* /marketing/consent; once answered it does not reappear. Players with no email
|
||||
* are handled in account settings ("link an email to subscribe"), not here.
|
||||
*/
|
||||
@customElement("marketing-consent-toast")
|
||||
export class MarketingConsentToast extends LitElement {
|
||||
@state() private visible = false;
|
||||
@state() private busy = false;
|
||||
@state() private errored = false;
|
||||
|
||||
// Set once the player acts on the prompt this session (answers or dismisses)
|
||||
// so it doesn't reappear until the next load. Not persisted — a dismissal
|
||||
// leaves the server decision at no_response, so it asks again next visit.
|
||||
private answered = false;
|
||||
|
||||
private onUserMeResponse = (event: Event) => {
|
||||
if (this.answered || this.visible) return;
|
||||
const detail = (event as CustomEvent<UserMeResponse | false>).detail;
|
||||
if (detail === false) return;
|
||||
const consent = detail.player.marketingConsent;
|
||||
if (consent?.consented === "no_response" && consent.hasEmail) {
|
||||
this.visible = true;
|
||||
}
|
||||
};
|
||||
|
||||
createRenderRoot() {
|
||||
return this;
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
document.addEventListener("userMeResponse", this.onUserMeResponse);
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
document.removeEventListener("userMeResponse", this.onUserMeResponse);
|
||||
}
|
||||
|
||||
// Record an explicit decision ("Yes please" -> approved, "No thanks" ->
|
||||
// denied). Awaits the write and disables the buttons while it's in flight, so
|
||||
// a failure surfaces (the toast stays, with an error) instead of a silent
|
||||
// false success that would re-nag on the next load.
|
||||
private async decide(consented: boolean) {
|
||||
if (this.busy) return;
|
||||
this.busy = true;
|
||||
this.errored = false;
|
||||
const ok = await setMarketingConsent(consented);
|
||||
this.busy = false;
|
||||
if (ok) {
|
||||
this.answered = true;
|
||||
this.visible = false;
|
||||
} else {
|
||||
this.errored = true;
|
||||
}
|
||||
}
|
||||
|
||||
// A subtle close (the X) is not an objection: it records nothing and leaves
|
||||
// the server decision at no_response, only hiding the prompt for this session
|
||||
// so it asks again on a later visit. Only "No thanks" records a denial.
|
||||
private dismiss() {
|
||||
if (this.busy) return;
|
||||
this.answered = true;
|
||||
this.visible = false;
|
||||
}
|
||||
|
||||
render() {
|
||||
if (!this.visible) return nothing;
|
||||
|
||||
return html`
|
||||
<div
|
||||
class="fixed top-16 z-[10000] left-4 right-4 w-auto sm:left-auto sm:right-4 sm:w-[236px] bg-surface border border-white/10 rounded-xl shadow-[var(--shadow-malibu-blue)] p-3"
|
||||
role="dialog"
|
||||
aria-label=${translateText("marketing_consent.title")}
|
||||
>
|
||||
<div class="flex items-start gap-2 mb-2">
|
||||
<svg
|
||||
class="shrink-0 mt-px text-aquarius"
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.9"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<rect x="3" y="5" width="18" height="14" rx="2" />
|
||||
<path d="m3 7 9 6 9-6" />
|
||||
</svg>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="text-xs font-bold text-white">
|
||||
${translateText("marketing_consent.title")}
|
||||
</div>
|
||||
<div class="text-[11px] leading-snug text-white/60 mt-0.5">
|
||||
${translateText("marketing_consent.body")}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
class="shrink-0 grid place-items-center w-5 h-5 -mt-0.5 -mr-0.5 rounded-md text-white/40 hover:text-white hover:bg-white/5 transition-colors cursor-pointer"
|
||||
aria-label=${translateText("marketing_consent.dismiss")}
|
||||
?disabled=${this.busy}
|
||||
@click=${() => this.dismiss()}
|
||||
>
|
||||
<svg
|
||||
width="13"
|
||||
height="13"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M18 6 6 18M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
class="flex-1 px-2 py-1.5 text-[10px] font-bold uppercase tracking-wider rounded-lg border border-white/10 bg-white/5 text-white/60 hover:bg-white/10 hover:text-white transition-all cursor-pointer"
|
||||
?disabled=${this.busy}
|
||||
@click=${() => this.decide(false)}
|
||||
>
|
||||
${translateText("marketing_consent.no")}
|
||||
</button>
|
||||
<button
|
||||
class="flex-1 px-2 py-1.5 text-[10px] font-bold uppercase tracking-wider rounded-lg border border-transparent bg-malibu-blue text-white shadow-[var(--shadow-malibu-blue-pill)] hover:bg-aquarius transition-all cursor-pointer whitespace-nowrap"
|
||||
?disabled=${this.busy}
|
||||
@click=${() => this.decide(true)}
|
||||
>
|
||||
${translateText("marketing_consent.yes")}
|
||||
</button>
|
||||
</div>
|
||||
${this.errored
|
||||
? html`<div class="mt-2 text-[10px] text-red-400">
|
||||
${translateText("marketing_consent.error")}
|
||||
</div>`
|
||||
: nothing}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -132,6 +132,16 @@ export const UserMeResponseSchema = z.object({
|
||||
cancelAtPeriodEnd: z.boolean(),
|
||||
})
|
||||
.nullable(),
|
||||
// Marketing-email consent state (client-driven consent). `consented` is the
|
||||
// player's current decision; `hasEmail` is whether a verified contact email
|
||||
// exists to subscribe. Optional so an older API without the field is treated
|
||||
// as "no consent UI".
|
||||
marketingConsent: z
|
||||
.object({
|
||||
consented: z.enum(["approved", "denied", "no_response"]),
|
||||
hasEmail: z.boolean(),
|
||||
})
|
||||
.optional(),
|
||||
}),
|
||||
});
|
||||
export type UserMeResponse = z.infer<typeof UserMeResponseSchema>;
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
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);
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user