+ ${translateText("account_modal.username_temporary_notice")}
+
+ `;
+ }
+ const claimExpiresAt = this.player.usernameClaimExpiresAt;
+ if (this.player.usernameStatus === "claimed" && claimExpiresAt) {
+ return html`
+
+ ${translateText("account_modal.username_grace_warning", {
+ name: this.player.usernameBase ?? "",
+ date: this.formatDate(new Date(claimExpiresAt)),
+ })}
+
+ `;
+ }
+ return nothing;
+ }
+
+ render() {
+ if (!this.player || this.player.usernameStatus === undefined)
+ return nothing;
+ const cooldownEnd = this.cooldownEnd();
+ const locked = cooldownEnd !== null;
+ const trimmed = this.draft.trim();
+ const canSave =
+ !locked &&
+ !this.busy &&
+ trimmed.length >= MIN_ACCOUNT_USERNAME_LENGTH &&
+ this.error === "";
+ return html`
+
+
+ 🏷️
+ ${translateText("account_modal.username_title")}
+
+ ${this.player.username
+ ? html`
+ ${this.player.username}
+
`
+ : html`
+ ${translateText("account_modal.username_not_set")}
+
`}
+ ${this.renderNotices()}
+
+ {
+ if (e.key === "Enter" && canSave) void this.handleSave();
+ }}
+ placeholder=${translateText("account_modal.username_placeholder")}
+ maxlength=${MAX_ACCOUNT_USERNAME_LENGTH}
+ ?disabled=${locked || this.busy}
+ class="flex-1 min-w-0 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 disabled:opacity-50 disabled:hover:bg-white/5"
+ />
+
+
+ ${locked
+ ? html`
+ ${translateText("account_modal.username_cooldown_until", {
+ date: this.formatDate(cooldownEnd),
+ })}
+
`
+ : nothing}
+ ${this.error
+ ? html`
${this.error}
`
+ : nothing}
+
+ `;
+ }
+}
diff --git a/src/core/ApiSchemas.ts b/src/core/ApiSchemas.ts
index 219bb910a..bd0c587bd 100644
--- a/src/core/ApiSchemas.ts
+++ b/src/core/ApiSchemas.ts
@@ -108,6 +108,26 @@ export type ClaimAllRewardsResponse = z.infer<
typeof ClaimAllRewardsResponseSchema
>;
+// Account-username lifecycle. `unclaimed`: no bare-name reservation (default).
+// `claimed`: reservation held but subscription lapsed — the suffix shows again
+// and a grace deadline runs. `premium`: subscribed, bare display. `indefinite`:
+// admin-locked bare display. Statuses change server-side without client action
+// (Stripe webhooks, admin edits) — re-fetch /users/@me rather than caching.
+export const UsernameStatusSchema = z.enum([
+ "unclaimed",
+ "claimed",
+ "premium",
+ "indefinite",
+]);
+export type UsernameStatus = z.infer