diff --git a/resources/lang/en.json b/resources/lang/en.json index 35a2bd36df3..5d55d86f365 100644 --- a/resources/lang/en.json +++ b/resources/lang/en.json @@ -1732,11 +1732,19 @@ "milestones": "Milestones", "no_clans": "This player isn't in any clans.", "no_prestige": "Hasn't prestiged yet", - "not_found": "Player profile not found", + "not_found_body": "We couldn't find a player at this link. It may be mistyped, or the account may have been deleted.", + "not_found_title": "Player not found", + "play": "Play OpenFront", "prestige_history": "Prestige history", "share": "Share Profile", + "share_copy_link": "Copy link", + "share_discord": "Copy for Discord", + "share_native": "Share", + "share_text": "Check out {name}'s profile on OpenFront", + "share_x": "Share on X", "title": "Player Profile", - "view": "View Profile" + "view": "View Profile", + "view_leaderboard": "View leaderboard" }, "player_stats_table": { "arrived": "Arrived", @@ -1838,6 +1846,7 @@ "keep_holding": "Keep holding…", "load_failed": "Couldn't open Prestige. Check your connection and try again.", "ready": "Level 100! Until you prestige, XP only adds to your lifetime total.", + "share_text": "I just reached Prestige {rank} on OpenFront!", "stale": "Your level changed since this page loaded, so your account has been refreshed.", "stale_failed": "Your level changed since this page loaded. Reload the page to see where you are.", "submitting": "Prestiging…", @@ -1912,6 +1921,10 @@ "penalty_line": "−{percent}% XP ({source})", "prestige": "Prestige {prestige}", "prestige_short": "P{prestige}", + "share_moment_legend": "Share Legend", + "share_moment_level": "Share Level {level}", + "share_text_legend": "I just became a Legend on OpenFront!", + "share_text_level": "I just reached Level {level} on OpenFront!", "sign_in_to_earn": "Sign in to earn XP and level up.", "skip_reveal": "Skip", "tap_to_skip": "Tap to skip", diff --git a/src/client/AccountModal.ts b/src/client/AccountModal.ts index 2a940d80d8b..f826c007c9e 100644 --- a/src/client/AccountModal.ts +++ b/src/client/AccountModal.ts @@ -215,6 +215,7 @@ export class AccountModal extends BaseModal {
${this.renderTab(tab)}
diff --git a/src/client/Main.ts b/src/client/Main.ts index 82ab3103482..fabb7a0fe08 100644 --- a/src/client/Main.ts +++ b/src/client/Main.ts @@ -157,6 +157,7 @@ import { installDoubleTapZoomBlocker, installSafariPinchZoomBlocker, } from "./utilities/DisableSafariPinchZoom"; +import { playerProfileRouteArgs } from "./utilities/PlayerProfileUrl"; import "./components/DesktopNavBar"; import "./components/Footer"; @@ -475,6 +476,8 @@ class Client { tag: "player-profile-modal", pageId: "page-profile", }); + // The canonical profile link, `/player/` (see playerProfileUrl). + modalRouter.registerPath("profile", playerProfileRouteArgs); modalRouter.register("help", { tag: "help-modal", pageId: "page-help" }); modalRouter.register("news", { tag: "news-modal", pageId: "page-news" }); modalRouter.register("language", { @@ -1398,6 +1401,10 @@ class Client { console.log(`${spectate ? "spectating" : "joining"} lobby ${lobbyId}`); return; } + // A shared profile link (`/player/`) opens that profile. + if (modalRouter.routeFromPath()) { + return; + } if (modalRouter.routeFromHash()) { return; } diff --git a/src/client/ModalRouter.ts b/src/client/ModalRouter.ts index c7a71dfb98b..63a87286bf7 100644 --- a/src/client/ModalRouter.ts +++ b/src/client/ModalRouter.ts @@ -7,9 +7,22 @@ * * Lobby modals (join/host) and matchmaking are intentionally not registered: * they have their own URL state (path-based) or none at all. + * + * A modal can also be opened by a path link (`/player/`, see + * registerPath). While it stays open the URL keeps that path, with tab + * changes written to `?tab=`; closing it returns the URL to the home page, so + * a reload doesn't reopen it. Opening another modal, in-app or from the hash, + * hands the URL to the hash and leaves the path the same way. */ import { loadModal } from "./LazyModals"; +import { pagePin } from "./PagePin"; + +/** Parses a page location into a modal's open() args, or null if it isn't one. */ +export type PathRouteParser = (location: { + pathname: string; + search: string; +}) => Record | null; interface RegistryEntry { /** Custom element tag, e.g. "store-modal". */ @@ -34,11 +47,57 @@ class ModalRouter { private currentName: string | null = null; /** True while we're routing from the URL (suppress modal→URL sync). */ private routingFromUrl = false; + private pathRoutes = new Map(); + /** Name of the modal the URL's path (not its hash) reflects, if any. */ + private pathRoutedName: string | null = null; register(name: string, entry: RegistryEntry): void { this.registry.set(name, entry); } + /** Let a registered modal be opened by a path link as well as the hash. */ + registerPath(name: string, parse: PathRouteParser): void { + this.pathRoutes.set(name, parse); + } + + /** + * Open the modal the current path names, if one does. Returns true when it + * did (the open itself is asynchronous, as for routeFromHash). + */ + routeFromPath(): boolean { + for (const [name, parse] of this.pathRoutes) { + const entry = this.registry.get(name); + if (entry === undefined) continue; + const args = parse(window.location); + if (args === null) continue; + this.pathRoutedName = name; + void this.openRegistered(name, entry, args); + return true; + } + return false; + } + + /** True while the named modal is open from a path link. */ + isPathRouted(name: string): boolean { + return this.pathRoutedName === name; + } + + // The path link has had its say: put the URL back to the home page. The + // home page keeps the page's version pin when it has one (see + // currentPagePath in Utils, which this mirrors without its imports). A + // `#modal=` hash stays: it's another modal's, and that modal is still open. + private leavePath(): void { + if (this.pathRoutedName === null) return; + this.pathRoutedName = null; + const commit = pagePin(); + history.replaceState( + history.state, + "", + (commit === null ? "/" : `/v/${commit}/`) + + (this.isHashRouted() ? window.location.hash : ""), + ); + } + /** * Parse `window.location.hash` for `#modal=&...`. If present and * registered, open the modal with the remaining keys as args. Returns true @@ -66,6 +125,11 @@ class ModalRouter { args[key] = value; }); + // The hash names the modal now, as when one opens in-app (syncOpened). + // Left on, the path would outlive the modal it names: the router stops + // tracking that modal, so its close wouldn't clear the path, and the next + // reload would open it again. + this.leavePath(); void this.openRegistered(name, entry, args); return true; } @@ -85,8 +149,14 @@ class ModalRouter { return; } await customElements.whenDefined(entry.tag); - // A slow load can land after the URL has moved on to another modal. - if (this.currentHashParams().get("modal") !== name) return; + // A slow load can land after the URL has moved on to another modal. The + // URL names this one by its hash, or by its path while path-routed. + if ( + this.currentHashParams().get("modal") !== name && + this.pathRoutedName !== name + ) { + return; + } this.routingFromUrl = true; try { @@ -108,6 +178,8 @@ class ModalRouter { syncOpened(name: string, args?: Record): void { if (this.routingFromUrl) return; // we're driving the modal from the URL; don't loop if (!this.registry.has(name)) return; + // Another modal (or this one again) opened in-app: the hash takes over. + this.leavePath(); this.currentName = name; this.writeHash(name, args); } @@ -115,6 +187,13 @@ class ModalRouter { /** Called by BaseModal.close() when a router-managed modal closes. */ syncClosed(name: string): void { if (this.routingFromUrl) return; + // The path names this modal whichever modal the router last recorded, so + // its close always leaves the path. + if (this.pathRoutedName === name) { + if (this.currentName === name) this.currentName = null; + this.leavePath(); + return; + } if (this.currentName !== name) return; // not the active routed modal this.currentName = null; this.replaceHash(""); @@ -124,6 +203,24 @@ class ModalRouter { syncTab(name: string, tab: string): void { if (this.routingFromUrl) return; if (this.currentName !== name) return; + if (this.pathRoutedName === name) { + // Opened from a path: the path stays, and the tab rides as `?tab=`. + const search = new URLSearchParams(window.location.search); + if (tab) { + search.set("tab", tab); + } else { + search.delete("tab"); + } + const query = search.toString(); + history.replaceState( + history.state, + "", + window.location.pathname + + (query ? `?${query}` : "") + + window.location.hash, + ); + return; + } const params = this.currentHashParams(); params.set("modal", name); if (tab) { @@ -138,6 +235,8 @@ class ModalRouter { syncArgs(name: string, args: Record): void { if (this.routingFromUrl) return; if (this.currentName !== name) return; + // The path is the state of a path-routed modal; don't add a hash to it. + if (this.pathRoutedName === name) return; const params = this.currentHashParams(); params.set("modal", name); for (const [key, value] of Object.entries(args)) { diff --git a/src/client/MomentShare.ts b/src/client/MomentShare.ts new file mode 100644 index 00000000000..3ffb1797d15 --- /dev/null +++ b/src/client/MomentShare.ts @@ -0,0 +1,99 @@ +import type { GameXpPanelState } from "./components/GameXpPanel"; +import { + isMilestoneLevel, + levelsReachedInGame, + MAX_LEVEL, + MAX_PRESTIGE, + reachedLegendThisGame, +} from "./Progression"; +import { playerProfileUrl } from "./utilities/PlayerProfileUrl"; +import { translateText } from "./Utils"; + +// A progression moment worth sharing: a milestone level, becoming a Legend, +// or a new prestige rank. Shared as the player's profile link with the moment +// named in `?moment=`, so the link unfurls into a card for it. +export type ShareMoment = + | { kind: "level"; level: number } + | { kind: "legend" } + | { kind: "prestige"; rank: number }; + +/** + * The moment a finished game is worth sharing for, or null. Only the server's + * result counts (never a provisional figure), and only a milestone level or + * becoming a Legend: an ordinary level-up isn't shared. A Legend beats any + * level, and of several milestones reached in one game the highest is shared. + */ +export function gameShareMoment(view: GameXpPanelState): ShareMoment | null { + if (view.kind !== "result" || !view.data.eligible) return null; + const data = view.data; + if (reachedLegendThisGame(data)) return { kind: "legend" }; + const milestones = levelsReachedInGame(data) + .map((l) => l.level) + .filter(isMilestoneLevel); + if (milestones.length === 0) return null; + return { kind: "level", level: Math.max(...milestones) }; +} + +// The moments the site's card route draws: a level from 2 (the first one +// reached) to the last, and a prestige rank from 1 to the last. It answers +// anything else with a 400, which unfurls as no image at all. +function inRange(n: number, min: number, max: number): boolean { + return Number.isInteger(n) && n >= min && n <= max; +} + +/** + * The `?moment=` value: `level50`, `legend`, `prestige3`. Null for a level or + * rank the card route wouldn't draw (a surprising server value), so the + * share is dropped rather than sending a link that unfurls without a card. + */ +export function momentSlug(moment: ShareMoment): string | null { + switch (moment.kind) { + case "level": + return inRange(moment.level, 2, MAX_LEVEL) + ? `level${moment.level}` + : null; + case "legend": + return "legend"; + case "prestige": + return inRange(moment.rank, 1, MAX_PRESTIGE) + ? `prestige${moment.rank}` + : null; + } +} + +/** + * The player's profile link with the moment: `/player/?moment=level50`. + * Null when the moment has no slug (see momentSlug): nothing to share. + */ +export function momentShareUrl( + publicId: string, + moment: ShareMoment, +): string | null { + const slug = momentSlug(moment); + return slug === null ? null : `${playerProfileUrl(publicId)}?moment=${slug}`; +} + +/** The line that goes with the link ("I just reached Level 50 on OpenFront!"). */ +export function momentShareText(moment: ShareMoment): string { + switch (moment.kind) { + case "level": + return translateText("progression.share_text_level", { + level: moment.level, + }); + case "legend": + return translateText("progression.share_text_legend"); + case "prestige": + return translateText("prestige.share_text", { rank: moment.rank }); + } +} + +/** The share button's label for a game's moment ("Share Level 50"). */ +export function momentShareLabel( + moment: Exclude, +): string { + return moment.kind === "legend" + ? translateText("progression.share_moment_legend") + : translateText("progression.share_moment_level", { + level: moment.level, + }); +} diff --git a/src/client/PlayerProfileModal.ts b/src/client/PlayerProfileModal.ts index 86b21d198d0..72fe83ce94d 100644 --- a/src/client/PlayerProfileModal.ts +++ b/src/client/PlayerProfileModal.ts @@ -9,6 +9,7 @@ import { customElement, state } from "lit/decorators.js"; import { ClientEnv } from "src/client/ClientEnv"; import { fetchPublicPlayerProfile } from "./Api"; import "./ClanModal"; +import "./components/baseComponents/Button"; import "./components/baseComponents/stats/PlayerGameHistoryView"; import type { PlayerGameHistoryCache } from "./components/baseComponents/stats/PlayerGameHistoryView"; import "./components/baseComponents/stats/PlayerStatsTree"; @@ -17,13 +18,18 @@ import "./components/clan/ClanCard"; import "./components/PlayerName"; import "./components/ProfileCard"; import "./components/ProfileProgression"; +import "./components/ProfileShare"; import { modalHeader } from "./components/ui/ModalHeader"; import { usernameText } from "./components/ui/UsernameText"; import { verifiedBadge } from "./components/ui/VerifiedBadge"; import "./GameStatsModal"; import "./LeaderboardModal"; +import { modalRouter } from "./ModalRouter"; import { fetchPublicPlayerProgress } from "./ProgressionApi"; -import { playerProfileUrl } from "./utilities/PlayerProfileUrl"; +import { + parsePlayerProfilePath, + playerProfileUrl, +} from "./utilities/PlayerProfileUrl"; import { currentPagePath, translateText } from "./Utils"; export { playerProfileUrl }; @@ -45,6 +51,9 @@ export class PlayerProfileModal extends BaseModal { // Level / XP, when progression is on and the player has any. @state() private progress: PublicProgress | null = null; @state() private loading = false; + // The link this profile was opened from (`openfront.io/player/`), when + // it was opened from one: the not-found state shows it. + @state() private openedLink: string | null = null; private openedFrom: ProfileOrigin | null = null; // Mirrors the account modal's Games tab: keep the accumulated history list + // cursor across tab switches (and the game-stats detour) so re-entering Games @@ -238,10 +247,16 @@ export class PlayerProfileModal extends BaseModal { if (this.loading) { return this.renderLoadingSpinner(translateText("player_profile.loading")); } - if (!this.profileLoaded()) { + const publicId = this.publicId; + if (!this.profileLoaded() || publicId === null) { return this.renderNotFound(); } - // The card heads the stats; games and wins stay in the stats below it. + // The card heads the stats; games and wins stay in the stats below it, + // and the ways to share the profile follow it. + // + // A "Show my profile in search engines" setting will join the account + // settings' Privacy card once the API carries the flag and the site + // Worker honours it with noindex; profile links are indexable until then. return html` ${this.progress === null ? nothing @@ -252,6 +267,11 @@ export class PlayerProfileModal extends BaseModal { .progress=${this.progress} .openKey=${`profile-${this.openCount}`} >`} + @@ -290,15 +310,95 @@ export class PlayerProfileModal extends BaseModal { private renderNotFound() { return html` -
- 📊 -

- ${translateText("player_profile.not_found")} +

+
+ +
+

+ ${translateText("player_profile.not_found_title")} +

+

+ ${translateText("player_profile.not_found_body")}

+ ${this.openedLink === null + ? nothing + : html`

+ ${this.openedLink} +

`} + +
+ this.goHome()} + > + this.openLeaderboard()} + > +
`; } + // Close the profile onto the home page (the URL follows: see ModalRouter). + private goHome(): void { + this.close(); + } + + private openLeaderboard(): void { + this.close(); + document + .querySelector("leaderboard-modal") + ?.open(); + } + + // Opening another player's profile over this one: BaseModal syncs the URL + // only when a modal first opens, which would leave it naming the last + // player (its `/player/` path, or its `#modal=` hash), with tab changes + // written onto that and a reload opening the wrong profile. Hand the URL to + // the new player as an in-app open does. The router ignores this while it + // is the one opening the modal (from a path or the hash). + public override open(args?: Record): void { + const wasOpen = this.isOpen(); + const before = this.publicId; + super.open(args); + if (wasOpen && this.isOpen() && this.publicId !== before) { + modalRouter.syncOpened(this.routerName, args); + } + } + protected onOpen(args?: Record): void { const publicId = typeof args?.publicID === "string" && args.publicID.length > 0 @@ -348,6 +448,14 @@ export class PlayerProfileModal extends BaseModal { this.gameHistoryCache = null; this.gamesScrollTop = 0; this.restoreGamesScrollAfterOpen = false; + // Only while the path is this player's: the URL can still name the last + // one for the moment (see open()). + this.openedLink = + publicId !== null && + modalRouter.isPathRouted("profile") && + parsePlayerProfilePath(window.location.pathname) === publicId + ? `${window.location.host}${window.location.pathname}` + : null; this.loading = publicId !== null; if (publicId !== null) { void this.loadProfile(publicId); diff --git a/src/client/components/FriendsList.ts b/src/client/components/FriendsList.ts index 54a60ea9d6a..4efc5df89d5 100644 --- a/src/client/components/FriendsList.ts +++ b/src/client/components/FriendsList.ts @@ -10,14 +10,16 @@ import { sendFriendRequest, } from "../FriendsApi"; import { showInGameConfirm } from "../InGameModal"; +import { parsePlayerProfilePath } from "../utilities/PlayerProfileUrl"; import { showToast, translateText } from "../Utils"; import { playerNameLink } from "./ui/PlayerNameLink"; const PAGE_LIMIT = 20; /** - * Accept a pasted profile share link (`…#modal=profile&publicID=abc12345`) and - * keep only the id, mirroring the private-lobby join box. Only strings that are + * Accept a pasted profile share link (`…/player/abc12345`, or the older + * `…#modal=profile&publicID=abc12345`) and keep only the id, mirroring the + * private-lobby join box. Only strings that are * actually URLs are touched: usernames render as `wonder #5005`, so a bare `#` * must be left alone. */ @@ -32,6 +34,9 @@ export function extractPublicIdFromUrl(input: string): string { // so don't log; just hand the raw text back for the server to reject. return input; } + // The canonical link names the player in its path. + const fromPath = parsePlayerProfilePath(url.pathname); + if (fromPath !== null) return fromPath; // Modals are hash-routed (`…/#modal=profile&publicID=abc12345`), so the id is // in the fragment, not the query string — `url.searchParams` is always empty // here. Parse the fragment's own `key=value&…` pairs instead. diff --git a/src/client/components/GameXpPanel.ts b/src/client/components/GameXpPanel.ts index 62613304c85..043b20a8a9a 100644 --- a/src/client/components/GameXpPanel.ts +++ b/src/client/components/GameXpPanel.ts @@ -149,6 +149,8 @@ export class GameXpPanel extends LitElement { // The Legend result already announced (see announceLegend). private announcedLegend: GameXpEligible | null = null; private timers: ReturnType[] = []; + // The result last announced as settled (see updated()). + private settledView: GameXpPanelState | null = null; createRenderRoot() { return this; @@ -487,6 +489,7 @@ export class GameXpPanel extends LitElement { protected updated(changed: PropertyValues): void { super.updated(changed); + this.announceSettled(); // A Legend result shown without its reveal reaching level 100 (reduced // motion, a skip, or it arrived while the popup was hidden) still gets // its moment, once it's on screen. @@ -502,6 +505,21 @@ export class GameXpPanel extends LitElement { } } + // Tells the host once a result is showing in full: its reveal has ended or + // been skipped, or there was none. Once per result (`detail` is the view). + private announceSettled(): void { + const view = this.view; + if (view.kind !== "result" || this.reveal !== null) return; + if (this.settledView === view) return; + this.settledView = view; + this.dispatchEvent( + new CustomEvent("xp-reveal-settled", { + detail: view, + bubbles: true, + }), + ); + } + // Ends any reveal on its final state (a no-op when none is playing). private skipReveal(): void { this.clearTimers(); diff --git a/src/client/components/LegendCeremony.ts b/src/client/components/LegendCeremony.ts index 8d0eacc29a4..131a6296e8d 100644 --- a/src/client/components/LegendCeremony.ts +++ b/src/client/components/LegendCeremony.ts @@ -7,6 +7,7 @@ import { TemplateResult, } from "lit"; import { customElement, state } from "lit/decorators.js"; +import { momentShareText, momentShareUrl } from "../MomentShare"; import { MAX_PRESTIGE } from "../Progression"; import { translateText } from "../Utils"; import { @@ -15,6 +16,7 @@ import { renderHoneycomb, } from "./Ceremony"; import "./LevelBadge"; +import "./ProfileShare"; import { formatXp } from "./XpBar"; // Becoming a Legend (Prestige 10, level 99 to 100): the whole screen, once @@ -22,8 +24,9 @@ import { formatXp } from "./XpBar"; // it and the honeycomb pulsing in toward it, implodes to a point of light, and // the Legend crown drops in and lands hard: a gold flash, two shockwaves, the // screen shaking and two gold waves running out across the honeycomb. LEGEND -// slams in and a gold sweep runs through it, then a line on what it took, and -// Continue. At rest the crown breathes and the rays turn slowly. +// slams in and a gold sweep runs through it, then a line on what it took, a +// row to share it, and Continue. At rest the crown breathes and the rays turn +// slowly. // // Any click or key skips to the end; Escape (or Continue) then closes it. // Emits `legend-ceremony-closed` when it closes. @@ -59,6 +62,8 @@ export interface LegendMoment { lifetimeXp: number; // When it happened. at: Date; + // Whose it is, for the share link. Empty: no share row. + publicId: string; } const SEEN_PREFIX = "legendCeremonySeen:"; @@ -215,7 +220,8 @@ export class LegendCeremony extends LitElement { } // Every key while it's up stays with it. Before the end any key skips; - // at the end Escape closes and Tab stays on Continue. + // at the end Escape closes and Tab moves between the share buttons and + // Continue without leaving the ceremony. private onKeyDown = (e: KeyboardEvent): void => { if (this.moment === null) return; e.stopPropagation(); @@ -229,7 +235,19 @@ export class LegendCeremony extends LitElement { this.close(); } else if (e.key === "Tab") { e.preventDefault(); - this.continueButton()?.focus(); + const buttons = [ + ...(this.root()?.querySelectorAll( + "button:not([disabled])", + ) ?? []), + ]; + if (buttons.length === 0) return; + // From anywhere else, back to Continue (the last). + const at = buttons.indexOf(document.activeElement as HTMLButtonElement); + const next = + at === -1 + ? buttons.length - 1 + : (at + (e.shiftKey ? -1 : 1) + buttons.length) % buttons.length; + buttons[next].focus(); } }; @@ -371,6 +389,29 @@ export class LegendCeremony extends LitElement {
`; } + // Sharing it, between the line and Continue, the same as the prestige + // ceremony's row. The ceremony skips (or, done, does nothing) on any + // click; the row keeps its clicks. It is simply there, without the rise + // the Continue button has. + private renderShare(moment: LegendMoment): TemplateResult | typeof nothing { + if (moment.publicId === "") return nothing; + const share = { kind: "legend" } as const; + const url = momentShareUrl(moment.publicId, share); + if (url === null) return nothing; + return html``; + } + private renderCeremony(moment: LegendMoment): TemplateResult { const done = this.beat === "done"; const shaking = this.reached("impact") && !this.landed; @@ -407,6 +448,7 @@ export class LegendCeremony extends LitElement { ` : nothing} ${this.reached("line") ? this.renderLine(moment) : nothing} + ${done ? this.renderShare(moment) : nothing} ${done ? html`
transform: translateY(12px); } } + .lc-share { + margin-top: 24px; + padding: 0 16px; + } .lc-actions { - margin-top: 26px; + margin-top: 24px; display: flex; flex-direction: column; align-items: center; diff --git a/src/client/components/PrestigeFlow.ts b/src/client/components/PrestigeFlow.ts index 7ab81125f2c..74783eed0e1 100644 --- a/src/client/components/PrestigeFlow.ts +++ b/src/client/components/PrestigeFlow.ts @@ -12,7 +12,8 @@ import { PropertyValues, TemplateResult, } from "lit"; -import { customElement, state } from "lit/decorators.js"; +import { customElement, property, state } from "lit/decorators.js"; +import { momentShareText, momentShareUrl } from "../MomentShare"; import { MAX_LEVEL, MAX_PRESTIGE, @@ -29,6 +30,7 @@ import { } from "./Ceremony"; import { describeFlareCosmetic, FlareCosmeticView } from "./FlareCosmetic"; import "./LevelBadge"; +import "./ProfileShare"; import { formatXp } from "./XpBar"; // Prestiging, start to finish: the confirmation (the player's badge as it is @@ -165,6 +167,8 @@ function newIdempotencyKey(): string { @customElement("prestige-flow") export class PrestigeFlow extends LitElement { + // The player's public ID, for the ceremony's share link. Empty: no share. + @property({ type: String }) publicId = ""; @state() private stage: Stage = "closed"; // Progress before prestiging. @state() private prior: Progress | null = null; @@ -970,6 +974,7 @@ export class PrestigeFlow extends LitElement { ${translateText("prestige.ceremony_title", { rank })}
` : nothing} + ${done ? this.renderShare(rank) : nothing} ${done ? html`