Show random pattern on end screen (#1930)

## Description:

To advertise patterns, show a random, purchasable pattern on the end
screen.

* Refactored the pattern button into a reusable PatternButton lit
component
* Used tailwind instead of CSS for styling because the CSS affects lit
components due to using the light-dom
* Removed the tooltip, didn't seem necessary since there is already a
big "purchase" button under the pattern

<img width="383" height="556" alt="Screenshot 2025-08-25 at 1 26 26 PM"
src="https://github.com/user-attachments/assets/3f109cea-2759-4a07-9322-4a1a30b43503"
/>

## Please complete the following:

- [x] I have added screenshots for all UI updates
- [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
- [x] I confirm I have thoroughly tested these changes and take full
responsibility for any bugs introduced

## Please put your Discord username so you can be contacted if a bug or
regression is found:

evan
This commit is contained in:
evanpelle
2025-08-25 13:27:08 -07:00
committed by GitHub
parent 3f1efc624f
commit 1d8484843f
5 changed files with 331 additions and 383 deletions
+2
View File
@@ -430,6 +430,8 @@
"not_enough_money": "Not enough money"
},
"win_modal": {
"support_openfront": "Support OpenFront!",
"territory_pattern": "Purchase a territory pattern to support OpenFront!",
"died": "You died",
"your_team": "Your team won!",
"other_team": "{team} team has won!",
+2 -2
View File
@@ -2,7 +2,7 @@ import { UserMeResponse } from "../core/ApiSchemas";
import { Cosmetics, CosmeticsSchema, Pattern } from "../core/CosmeticSchemas";
import { getApiBase, getAuthHeader } from "./jwt";
export async function patterns(
export async function fetchPatterns(
userMe: UserMeResponse | null,
): Promise<Map<string, Pattern>> {
const cosmetics = await getCosmetics();
@@ -12,7 +12,7 @@ export async function patterns(
}
const patterns: Map<string, Pattern> = new Map();
const playerFlares = new Set(userMe?.player.flares);
const playerFlares = new Set(userMe?.player?.flares ?? []);
for (const name in cosmetics.patterns) {
const patternData = cosmetics.patterns[name];
+21 -262
View File
@@ -1,18 +1,15 @@
import { base64url } from "jose";
import type { TemplateResult } from "lit";
import { html, LitElement, render } from "lit";
import { customElement, query, state } from "lit/decorators.js";
import { UserMeResponse } from "../core/ApiSchemas";
import { Pattern } from "../core/CosmeticSchemas";
import { UserSettings } from "../core/game/UserSettings";
import { PatternDecoder } from "../core/PatternDecoder";
import "./components/Difficulties";
import "./components/Maps";
import { handlePurchase, patterns } from "./Cosmetics";
import "./components/PatternButton";
import { renderPatternPreview } from "./components/PatternButton";
import { fetchPatterns, handlePurchase } from "./Cosmetics";
import { translateText } from "./Utils";
const BUTTON_WIDTH = 150;
@customElement("territory-patterns-modal")
export class TerritoryPatternsModal extends LitElement {
@query("o-modal") private modalEl!: HTMLElement & {
@@ -22,10 +19,7 @@ export class TerritoryPatternsModal extends LitElement {
public previewButton: HTMLElement | null = null;
@state() private selectedPattern: Pattern | undefined;
@state() private hoveredPattern: Pattern | null = null;
@state() private hoverPosition = { x: 0, y: 0 };
@state() private selectedPattern: Pattern | null;
@state() private keySequence: string[] = [];
@state() private showChocoPattern = false;
@@ -48,12 +42,12 @@ export class TerritoryPatternsModal extends LitElement {
async onUserMe(userMeResponse: UserMeResponse | null) {
if (userMeResponse === null) {
this.userSettings.setSelectedPatternName(undefined);
this.selectedPattern = undefined;
this.selectedPattern = null;
}
this.patterns = await patterns(userMeResponse);
this.patterns = await fetchPatterns(userMeResponse);
const storedPatternName = this.userSettings.getSelectedPatternName();
if (storedPatternName) {
this.selectedPattern = this.patterns.get(storedPatternName);
this.selectedPattern = this.patterns.get(storedPatternName) ?? null;
}
this.refresh();
}
@@ -94,86 +88,18 @@ export class TerritoryPatternsModal extends LitElement {
return this;
}
private renderTooltip(): TemplateResult | null {
if (this.hoveredPattern && this.hoveredPattern.product !== undefined) {
return html`
<div
class="fixed z-[10000] px-3 py-2 rounded bg-black text-white text-sm pointer-events-none shadow-md"
style="top: ${this.hoverPosition.y + 12}px; left: ${this.hoverPosition
.x + 12}px;"
>
${translateText("territory_patterns.blocked.purchase")}
</div>
`;
}
return null;
}
private renderPatternButton(pattern: Pattern): TemplateResult {
const isSelected = this.selectedPattern?.name === pattern.name;
return html`
<div style="flex: 0 1 calc(25% - 1rem); max-width: calc(25% - 1rem);">
<button
class="border p-2 rounded-lg shadow text-black dark:text-white text-left w-full
${isSelected
? "bg-blue-500 text-white"
: "bg-gray-100 hover:bg-gray-200 dark:bg-gray-800 dark:hover:bg-gray-700"}
${pattern.product !== null ? "opacity-50 cursor-not-allowed" : ""}"
@click=${() =>
pattern.product === null && this.selectPattern(pattern)}
@mouseenter=${(e: MouseEvent) => this.handleMouseEnter(pattern, e)}
@mousemove=${(e: MouseEvent) => this.handleMouseMove(e)}
@mouseleave=${() => this.handleMouseLeave()}
>
<div class="text-sm font-bold mb-1">
${translatePatternName("territory_patterns.pattern", pattern.name)}
</div>
<div
class="preview-container"
style="
width: 100%;
aspect-ratio: 1;
display: flex;
align-items: center;
justify-content: center;
background: #fff;
border-radius: 8px;
overflow: hidden;
"
>
${this.renderPatternPreview(
pattern.pattern,
BUTTON_WIDTH,
BUTTON_WIDTH,
)}
</div>
</button>
${pattern.product !== null
? html`
<button
class="w-full mt-2 px-3 py-1 bg-green-500 hover:bg-green-600 text-white text-xs font-medium rounded transition-colors"
@click=${(e: Event) => {
e.stopPropagation();
handlePurchase(pattern.product!.priceId);
}}
>
${translateText("territory_patterns.purchase")}
(${pattern.product!.price})
</button>
`
: null}
</div>
`;
}
private renderPatternGrid(): TemplateResult {
const buttons: TemplateResult[] = [];
for (const [name, pattern] of this.patterns) {
if (!this.showChocoPattern && name === "choco") continue;
const result = this.renderPatternButton(pattern);
buttons.push(result);
buttons.push(html`
<pattern-button
.pattern=${pattern}
.onSelect=${(p: Pattern | null) => this.selectPattern(p)}
.onPurchase=${(priceId: string) => handlePurchase(priceId)}
></pattern-button>
`);
}
return html`
@@ -181,33 +107,10 @@ export class TerritoryPatternsModal extends LitElement {
class="flex flex-wrap gap-4 p-2"
style="justify-content: center; align-items: flex-start;"
>
<button
class="border p-2 rounded-lg shadow text-black dark:text-white text-left
${this.selectedPattern === undefined
? "bg-blue-500 text-white"
: "bg-gray-100 hover:bg-gray-200 dark:bg-gray-800 dark:hover:bg-gray-700"}"
style="flex: 0 1 calc(25% - 1rem); max-width: calc(25% - 1rem);"
@click=${() => this.selectPattern(undefined)}
>
<div class="text-sm font-bold mb-1">
${translateText("territory_patterns.pattern.default")}
</div>
<div
class="preview-container"
style="
width: 100%;
aspect-ratio: 1;
display: flex;
align-items: center;
justify-content: center;
background: #fff;
border-radius: 8px;
overflow: hidden;
"
>
${this.renderBlankPreview(BUTTON_WIDTH, BUTTON_WIDTH)}
</div>
</button>
<pattern-button
.pattern=${null}
.onSelect=${(p: Pattern | null) => this.selectPattern(null)}
></pattern-button>
${buttons}
</div>
`;
@@ -216,7 +119,6 @@ export class TerritoryPatternsModal extends LitElement {
render() {
if (!this.isActive) return html``;
return html`
${this.renderTooltip()}
<o-modal
id="territoryPatternsModal"
title="${translateText("territory_patterns.title")}"
@@ -238,68 +140,16 @@ export class TerritoryPatternsModal extends LitElement {
window.removeEventListener("keydown", this.handleKeyDown);
}
private selectPattern(pattern: Pattern | undefined) {
private selectPattern(pattern: Pattern | null) {
this.userSettings.setSelectedPatternName(pattern?.name);
this.selectedPattern = pattern;
this.refresh();
this.close();
}
private renderPatternPreview(
pattern?: string,
width?: number,
height?: number,
): TemplateResult {
return html`
<img src="${generatePreviewDataUrl(pattern, width, height)}"></img>
`;
}
private renderBlankPreview(width: number, height: number): TemplateResult {
return html`
<div
style="
display: flex;
align-items: center;
justify-content: center;
height: ${height}px;
width: ${width}px;
background-color: #ffffff;
border-radius: 4px;
box-sizing: border-box;
overflow: hidden;
position: relative;
border: 1px solid #ccc;
"
>
<div
style="display: grid; grid-template-columns: repeat(2, ${width /
2}px); grid-template-rows: repeat(2, ${height / 2}px);"
>
<div
style="background-color: #fff; border: 1px solid rgba(0, 0, 0, 0.1); width: ${width /
2}px; height: ${height / 2}px;"
></div>
<div
style="background-color: #fff; border: 1px solid rgba(0, 0, 0, 0.1); width: ${width /
2}px; height: ${height / 2}px;"
></div>
<div
style="background-color: #fff; border: 1px solid rgba(0, 0, 0, 0.1); width: ${width /
2}px; height: ${height / 2}px;"
></div>
<div
style="background-color: #fff; border: 1px solid rgba(0, 0, 0, 0.1); width: ${width /
2}px; height: ${height / 2}px;"
></div>
</div>
</div>
`;
}
public async refresh() {
const preview = this.renderPatternPreview(
this.selectedPattern?.pattern,
const preview = renderPatternPreview(
this.selectedPattern?.pattern ?? null,
48,
48,
);
@@ -318,95 +168,4 @@ export class TerritoryPatternsModal extends LitElement {
render(preview, this.previewButton);
this.requestUpdate();
}
private handleMouseEnter(pattern: Pattern, event: MouseEvent) {
if (pattern.product !== null) {
this.hoveredPattern = pattern;
this.hoverPosition = { x: event.clientX, y: event.clientY };
}
}
private handleMouseMove(event: MouseEvent) {
if (this.hoveredPattern) {
this.hoverPosition = { x: event.clientX, y: event.clientY };
}
}
private handleMouseLeave() {
this.hoveredPattern = null;
}
}
const patternCache = new Map<string, string>();
const DEFAULT_PATTERN_B64 = "AAAAAA"; // Empty 2x2 pattern
const COLOR_SET = [0, 0, 0, 255]; // Black
const COLOR_UNSET = [255, 255, 255, 255]; // White
export function generatePreviewDataUrl(
pattern?: string,
width?: number,
height?: number,
): string {
pattern ??= DEFAULT_PATTERN_B64;
const patternLookupKey = `${pattern}-${width}-${height}`;
if (patternCache.has(patternLookupKey)) {
return patternCache.get(patternLookupKey)!;
}
// Calculate canvas size
let decoder: PatternDecoder;
try {
decoder = new PatternDecoder(pattern, base64url.decode);
} catch (e) {
console.error("Error decoding pattern", e);
return "";
}
const scaledWidth = decoder.scaledWidth();
const scaledHeight = decoder.scaledHeight();
width =
width === undefined
? scaledWidth
: Math.max(1, Math.floor(width / scaledWidth)) * scaledWidth;
height =
height === undefined
? scaledHeight
: Math.max(1, Math.floor(height / scaledHeight)) * scaledHeight;
// Create the canvas
const canvas = document.createElement("canvas");
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext("2d");
if (!ctx) throw new Error("2D context not supported");
// Create an image
const imageData = ctx.createImageData(width, height);
const data = imageData.data;
let i = 0;
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const rgba = decoder.isSet(x, y) ? COLOR_SET : COLOR_UNSET;
data[i++] = rgba[0]; // Red
data[i++] = rgba[1]; // Green
data[i++] = rgba[2]; // Blue
data[i++] = rgba[3]; // Alpha
}
}
// Create a data URL
ctx.putImageData(imageData, 0, 0);
const dataUrl = canvas.toDataURL("image/png");
patternCache.set(patternLookupKey, dataUrl);
return dataUrl;
}
function translatePatternName(prefix: string, patternName: string): string {
const translation = translateText(`${prefix}.${patternName}`);
if (translation.startsWith(prefix)) {
// Translation was not found, fallback to pattern name
return patternName[0].toUpperCase() + patternName.substring(1);
}
return translation;
}
+216
View File
@@ -0,0 +1,216 @@
import { base64url } from "jose";
import { html, LitElement, TemplateResult } from "lit";
import { customElement, property } from "lit/decorators.js";
import { Pattern } from "../../core/CosmeticSchemas";
import { PatternDecoder } from "../../core/PatternDecoder";
import { translateText } from "../Utils";
export const BUTTON_WIDTH = 150;
@customElement("pattern-button")
export class PatternButton extends LitElement {
@property({ type: Object })
pattern: Pattern | null = null;
@property({ type: Function })
onSelect?: (pattern: Pattern | null) => void;
@property({ type: Function })
onPurchase?: (priceId: string) => void;
createRenderRoot() {
return this;
}
private translatePatternName(prefix: string, patternName: string): string {
const translation = translateText(`${prefix}.${patternName}`);
if (translation.startsWith(prefix)) {
return patternName[0].toUpperCase() + patternName.substring(1);
}
return translation;
}
private handleClick() {
const isDefaultPattern = this.pattern === null;
if (isDefaultPattern || this.pattern?.product === null) {
this.onSelect?.(this.pattern);
}
}
private handlePurchase(e: Event) {
e.stopPropagation();
if (this.pattern?.product) {
this.onPurchase?.(this.pattern.product.priceId);
}
}
render() {
const isDefaultPattern = this.pattern === null;
const isPurchasable = !isDefaultPattern && this.pattern?.product !== null;
return html`
<div
class="flex flex-col items-center gap-2 p-3 bg-white/10 rounded-lg max-w-[200px]"
>
<button
class="bg-white/90 border-2 border-black/10 rounded-lg p-2 cursor-pointer transition-all duration-200 w-full
hover:bg-white hover:-translate-y-0.5 hover:shadow-lg hover:shadow-black/20
disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:translate-y-0 disabled:hover:shadow-none"
?disabled=${isPurchasable}
@click=${this.handleClick}
>
<div class="text-sm font-bold text-gray-800 mb-2 text-center">
${isDefaultPattern
? translateText("territory_patterns.pattern.default")
: this.translatePatternName(
"territory_patterns.pattern",
this.pattern!.name,
)}
</div>
<div
class="w-[120px] h-[120px] flex items-center justify-center bg-white rounded p-1 mx-auto"
style="overflow: hidden;"
>
${renderPatternPreview(
this.pattern?.pattern ?? null,
BUTTON_WIDTH,
BUTTON_WIDTH,
)}
</div>
</button>
${isPurchasable
? html`
<button
class="w-full px-4 py-2 bg-green-500 text-white border-0 rounded-md text-sm font-semibold cursor-pointer transition-colors duration-200
hover:bg-green-600"
@click=${this.handlePurchase}
>
${translateText("territory_patterns.purchase")}
(${this.pattern!.product!.price})
</button>
`
: null}
</div>
`;
}
}
export function renderPatternPreview(
pattern: string | null,
width: number,
height: number,
): TemplateResult {
if (pattern === null) {
return renderBlankPreview(width, height);
}
const dataUrl = generatePreviewDataUrl(pattern, width, height);
return html`<img
src="${dataUrl}"
alt="Pattern preview"
class="w-full h-full object-contain"
style="image-rendering: pixelated; image-rendering: -moz-crisp-edges; image-rendering: crisp-edges;"
/>`;
}
function renderBlankPreview(width: number, height: number): TemplateResult {
return html`
<div
style="
display: flex;
align-items: center;
justify-content: center;
height: ${height}px;
width: ${width}px;
background-color: #ffffff;
border-radius: 4px;
box-sizing: border-box;
overflow: hidden;
position: relative;
border: 1px solid #ccc;
"
>
<div
style="display: grid; grid-template-columns: 1fr 1fr; grid-template-rows: 1fr 1fr; gap: 0; width: calc(100% - 1px); height: calc(100% - 2px); box-sizing: border-box;"
>
<div
style="background-color: #fff; border: 1px solid rgba(0, 0, 0, 0.1); box-sizing: border-box;"
></div>
<div
style="background-color: #fff; border: 1px solid rgba(0, 0, 0, 0.1); box-sizing: border-box;"
></div>
<div
style="background-color: #fff; border: 1px solid rgba(0, 0, 0, 0.1); box-sizing: border-box;"
></div>
<div
style="background-color: #fff; border: 1px solid rgba(0, 0, 0, 0.1); box-sizing: border-box;"
></div>
</div>
</div>
`;
}
const patternCache = new Map<string, string>();
const DEFAULT_PATTERN_B64 = "AAAAAA"; // Empty 2x2 pattern
const COLOR_SET = [0, 0, 0, 255]; // Black
const COLOR_UNSET = [255, 255, 255, 255]; // White
function generatePreviewDataUrl(
pattern?: string,
width?: number,
height?: number,
): string {
pattern ??= DEFAULT_PATTERN_B64;
const patternLookupKey = `${pattern}-${width}-${height}`;
if (patternCache.has(patternLookupKey)) {
return patternCache.get(patternLookupKey)!;
}
// Calculate canvas size
let decoder: PatternDecoder;
try {
decoder = new PatternDecoder(pattern, base64url.decode);
} catch (e) {
console.error("Error decoding pattern", e);
return "";
}
const scaledWidth = decoder.scaledWidth();
const scaledHeight = decoder.scaledHeight();
width =
width === undefined
? scaledWidth
: Math.max(1, Math.floor(width / scaledWidth)) * scaledWidth;
height =
height === undefined
? scaledHeight
: Math.max(1, Math.floor(height / scaledHeight)) * scaledHeight;
// Create the canvas
const canvas = document.createElement("canvas");
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext("2d");
if (!ctx) throw new Error("2D context not supported");
// Create an image
const imageData = ctx.createImageData(width, height);
const data = imageData.data;
let i = 0;
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const rgba = decoder.isSet(x, y) ? COLOR_SET : COLOR_UNSET;
data[i++] = rgba[0]; // Red
data[i++] = rgba[1]; // Green
data[i++] = rgba[2]; // Blue
data[i++] = rgba[3]; // Alpha
}
}
// Create a data URL
ctx.putImageData(imageData, 0, 0);
const dataUrl = canvas.toDataURL("image/png");
patternCache.set(patternLookupKey, dataUrl);
return dataUrl;
}
+90 -119
View File
@@ -1,9 +1,13 @@
import { LitElement, css, html } from "lit";
import { LitElement, TemplateResult, html } from "lit";
import { customElement, state } from "lit/decorators.js";
import { translateText } from "../../../client/Utils";
import { Pattern } from "../../../core/CosmeticSchemas";
import { EventBus } from "../../../core/EventBus";
import { GameUpdateType } from "../../../core/game/GameUpdates";
import { GameView } from "../../../core/game/GameView";
import "../../components/PatternButton";
import { fetchPatterns, handlePurchase } from "../../Cosmetics";
import { getUserMe } from "../../jwt";
import { SendWinnerEvent } from "../../Transport";
import { GutterAdModalEvent } from "./GutterAdModal";
import { Layer } from "./Layer";
@@ -21,6 +25,9 @@ export class WinModal extends LitElement implements Layer {
@state()
showButtons = false;
@state()
private patternContent: TemplateResult | null = null;
private _title: string;
// Override to prevent shadow DOM creation
@@ -28,153 +35,117 @@ export class WinModal extends LitElement implements Layer {
return this;
}
static styles = css`
.win-modal {
display: none;
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background-color: rgba(30, 30, 30, 0.7);
padding: 25px;
border-radius: 10px;
z-index: 9999;
box-shadow: 0 0 20px rgba(0, 0, 0, 0.5);
backdrop-filter: blur(5px);
color: white;
width: 350px;
transition:
opacity 0.3s ease-in-out,
visibility 0.3s ease-in-out;
}
.win-modal.visible {
display: block;
animation: fadeIn 0.3s ease-out;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translate(-50%, -48%);
}
to {
opacity: 1;
transform: translate(-50%, -50%);
}
}
.win-modal h2 {
margin: 0 0 15px 0;
font-size: 26px;
text-align: center;
color: white;
}
.win-modal p {
margin: 0 0 20px 0;
text-align: center;
background-color: rgba(0, 0, 0, 0.3);
padding: 10px;
border-radius: 5px;
}
.button-container {
display: flex;
justify-content: space-between;
gap: 10px;
}
.win-modal button {
flex: 1;
padding: 12px;
font-size: 16px;
cursor: pointer;
background: rgba(0, 150, 255, 0.6);
color: white;
border: none;
border-radius: 5px;
transition:
background-color 0.2s ease,
transform 0.1s ease;
}
.win-modal button:hover {
background: rgba(0, 150, 255, 0.8);
transform: translateY(-1px);
}
.win-modal button:active {
transform: translateY(1px);
}
@media (max-width: 768px) {
.win-modal {
width: 90%;
max-width: 300px;
padding: 20px;
}
.win-modal h2 {
font-size: 26px;
}
.win-modal button {
padding: 10px;
font-size: 14px;
}
}
`;
constructor() {
super();
// Add styles to document
const styleEl = document.createElement("style");
styleEl.textContent = WinModal.styles.toString();
document.head.appendChild(styleEl);
}
render() {
return html`
<div class="win-modal ${this.isVisible ? "visible" : ""}">
<h2>${this._title || ""}</h2>
<div
class="${this.isVisible
? "fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 bg-gray-800/70 p-6 rounded-lg z-[9999] shadow-2xl backdrop-blur-sm text-white w-[350px] max-w-[90%] md:max-w-[350px] animate-fadeIn"
: "hidden"}"
>
<h2 class="m-0 mb-4 text-[26px] text-center text-white">
${this._title || ""}
</h2>
${this.innerHtml()}
<div
class="button-container ${this.showButtons ? "visible" : "hidden"}"
class="${this.showButtons
? "flex justify-between gap-2.5"
: "hidden"}"
>
<button @click=${this._handleExit}>
<button
@click=${this._handleExit}
class="flex-1 px-3 py-3 text-base cursor-pointer bg-blue-500/60 text-white border-0 rounded transition-all duration-200 hover:bg-blue-500/80 hover:-translate-y-px active:translate-y-px"
>
${translateText("win_modal.exit")}
</button>
<button @click=${this.hide}>
<button
@click=${this.hide}
class="flex-1 px-3 py-3 text-base cursor-pointer bg-blue-500/60 text-white border-0 rounded transition-all duration-200 hover:bg-blue-500/80 hover:-translate-y-px active:translate-y-px"
>
${translateText("win_modal.keep")}
</button>
</div>
</div>
<style>
@keyframes fadeIn {
from {
opacity: 0;
transform: translate(-50%, -48%);
}
to {
opacity: 1;
transform: translate(-50%, -50%);
}
}
.animate-fadeIn {
animation: fadeIn 0.3s ease-out;
}
</style>
`;
}
innerHtml() {
return html`<p>
return this.renderPatternButton();
}
renderPatternButton() {
return html`
<div class="text-center mb-6 bg-black/30 p-2.5 rounded">
<h3 class="text-xl font-semibold text-white mb-3">
${translateText("win_modal.support_openfront")}
</h3>
<p class="text-white mb-3">
${translateText("win_modal.territory_pattern")}
</p>
<div class="flex justify-center">${this.patternContent}</div>
</div>
`;
}
async loadPatternContent() {
const me = await getUserMe();
const patterns = await fetchPatterns(me !== false ? me : null);
const purchasable = Array.from(patterns.values()).filter(
(p) => p.product !== null,
);
if (purchasable.length === 0) {
this.patternContent = html``;
return;
}
const pattern = purchasable[Math.floor(Math.random() * purchasable.length)];
this.patternContent = html`
<pattern-button
.pattern=${pattern}
.onSelect=${(p: Pattern | null) => {}}
.onPurchase=${(priceId: string) => handlePurchase(priceId)}
></pattern-button>
`;
}
steamWishlist(): TemplateResult {
return html`<p class="m-0 mb-5 text-center bg-black/30 p-2.5 rounded">
<a
href="https://store.steampowered.com/app/3560670"
target="_blank"
rel="noopener noreferrer"
style="
color: #4a9eff;
text-decoration: underline;
font-weight: 500;
transition: color 0.2s ease;
font-size: 24px;
"
onmouseover="this.style.color='#6db3ff'"
onmouseout="this.style.color='#4a9eff'"
class="text-[#4a9eff] underline font-medium transition-colors duration-200 text-2xl hover:text-[#6db3ff]"
>
${translateText("win_modal.wishlist")}
</a>
</p>`;
}
show() {
async show() {
await this.loadPatternContent();
this.eventBus.emit(new GutterAdModalEvent(true));
setTimeout(() => {
this.isVisible = true;