+ `;
+ }
+
+ // 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`
+
+ ${this.renderEmailField()}
+
+
+
+ ${translateText("account_modal.or")}
+
+
+
+ ${this.renderLinkGoogleButton()}
+
+ `;
+ }
+
+ // 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`
+
+
+ `;
+ }
+
+ private async setConsent(consented: boolean): Promise {
+ 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``;
@@ -518,29 +639,7 @@ export class AccountModal extends BaseModal {
-
-
-
-
-
-
+
${this.renderEmailField()}
diff --git a/src/client/Api.ts b/src/client/Api.ts
index c260dc716..4e8e2ea9a 100644
--- a/src/client/Api.ts
+++ b/src/client/Api.ts
@@ -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 {
+ 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,
diff --git a/src/client/Main.ts b/src/client/Main.ts
index 3fba486d4..5a9d4ead7 100644
--- a/src/client/Main.ts
+++ b/src/client/Main.ts
@@ -67,6 +67,7 @@ import {
isInIframe,
translateText,
} from "./Utils";
+import "./components/MarketingConsentToast";
import { installSafariPinchZoomBlocker } from "./utilities/DisableSafariPinchZoom";
import "./components/DesktopNavBar";
diff --git a/src/client/components/MarketingConsentToast.ts b/src/client/components/MarketingConsentToast.ts
new file mode 100644
index 000000000..d007cd7e0
--- /dev/null
+++ b/src/client/components/MarketingConsentToast.ts
@@ -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).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`
+
+
+
+
+
+ ${translateText("marketing_consent.title")}
+
+
+ ${translateText("marketing_consent.body")}
+
+
+
+
+
+
+
+
+ ${this.errored
+ ? html`
+ ${translateText("marketing_consent.error")}
+
`
+ : nothing}
+
+ `;
+ }
+}
diff --git a/src/core/ApiSchemas.ts b/src/core/ApiSchemas.ts
index 1dfed0a9e..75e90f8a5 100644
--- a/src/core/ApiSchemas.ts
+++ b/src/core/ApiSchemas.ts
@@ -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;
diff --git a/tests/client/MarketingConsentToast.test.ts b/tests/client/MarketingConsentToast.test.ts
new file mode 100644
index 000000000..c296a0f50
--- /dev/null
+++ b/tests/client/MarketingConsentToast.test.ts
@@ -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 {
+ // 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);
+ }
+ });
+});