Files
OpenFrontIO/src/client/components/MarketingConsentToast.ts
T
8e5f27949f 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>
2026-07-10 10:13:37 +00:00

158 lines
5.8 KiB
TypeScript

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>
`;
}
}