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:
iamlewis
2026-07-10 11:13:37 +01:00
committed by GitHub
parent 921892e941
commit 8e5f27949f
8 changed files with 494 additions and 25 deletions
+124 -25
View File
@@ -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">
+36
View File
@@ -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,
+1
View File
@@ -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>
`;
}
}
+10
View File
@@ -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>;