mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-21 09:10:42 +00:00
Merge branch 'v30'
This commit is contained in:
@@ -10,6 +10,7 @@ resources/.DS_Store
|
||||
.DS_Store
|
||||
.clinic/
|
||||
CLAUDE.md
|
||||
.claude/
|
||||
.idea/
|
||||
# this is autogenerated by script
|
||||
src/assets/
|
||||
|
||||
+1
-1
@@ -178,7 +178,7 @@
|
||||
class="fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 z-99999"
|
||||
></div>
|
||||
|
||||
<gutter-ads></gutter-ads>
|
||||
<homepage-promos></homepage-promos>
|
||||
|
||||
<!-- Main container with responsive padding -->
|
||||
<main-layout class="contents">
|
||||
|
||||
@@ -951,7 +951,14 @@
|
||||
"search": "Search..."
|
||||
},
|
||||
"cosmetics": {
|
||||
"artist_label": "Artist:"
|
||||
"artist_label": "Artist:",
|
||||
"color_label": "Color:",
|
||||
"common": "Common",
|
||||
"uncommon": "Uncommon",
|
||||
"rare": "Rare",
|
||||
"epic": "Epic",
|
||||
"legendary": "Legendary",
|
||||
"adfree": "ad-free for life!"
|
||||
},
|
||||
"flag_input": {
|
||||
"title": "Select Flag",
|
||||
|
||||
@@ -41,12 +41,7 @@ export class FlagInputModal extends BaseModal {
|
||||
.map(
|
||||
([key, flag]) => html`
|
||||
<flag-button
|
||||
.flag=${{
|
||||
key: `flag:${key}`,
|
||||
name: flag.name,
|
||||
url: flag.url,
|
||||
artist: flag.artist,
|
||||
}}
|
||||
.flag=${{ ...flag, key: `flag:${key}` }}
|
||||
.selected=${selectedFlag === `flag:${key}`}
|
||||
.onSelect=${onSelect}
|
||||
></flag-button>
|
||||
@@ -88,7 +83,7 @@ export class FlagInputModal extends BaseModal {
|
||||
|
||||
return html`
|
||||
<div
|
||||
class="pt-1 flex flex-wrap gap-1.5 justify-center items-stretch content-start"
|
||||
class="flex flex-wrap gap-4 p-8 justify-center items-stretch content-start"
|
||||
>
|
||||
${noFlag} ${cosmeticFlags} ${countryFlags}
|
||||
</div>
|
||||
|
||||
@@ -1,162 +0,0 @@
|
||||
import { LitElement, css, html } from "lit";
|
||||
import { customElement, state } from "lit/decorators.js";
|
||||
import { FOOTER_AD_MIN_HEIGHT } from "./components/HomeFooterAd";
|
||||
|
||||
@customElement("gutter-ads")
|
||||
export class GutterAds extends LitElement {
|
||||
@state()
|
||||
private isVisible: boolean = false;
|
||||
|
||||
@state()
|
||||
private adLoaded: boolean = false;
|
||||
|
||||
@state()
|
||||
private hasFooterAd: boolean = false;
|
||||
|
||||
private onResize = () => {
|
||||
const isDesktop = window.innerWidth >= 640;
|
||||
this.hasFooterAd = isDesktop && window.innerHeight >= FOOTER_AD_MIN_HEIGHT;
|
||||
};
|
||||
|
||||
private leftAdType: string = "standard_iab_left2";
|
||||
private rightAdType: string = "standard_iab_rght1";
|
||||
private leftContainerId: string = "gutter-ad-container-left";
|
||||
private rightContainerId: string = "gutter-ad-container-right";
|
||||
|
||||
// Override createRenderRoot to disable shadow DOM
|
||||
createRenderRoot() {
|
||||
return this;
|
||||
}
|
||||
|
||||
static styles = css``;
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this.onResize();
|
||||
window.addEventListener("resize", this.onResize);
|
||||
document.addEventListener("userMeResponse", () => {
|
||||
if (window.adsEnabled) {
|
||||
console.log("showing gutter ads");
|
||||
this.show();
|
||||
} else {
|
||||
console.log("not showing gutter ads");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Called after the component's DOM is first rendered
|
||||
firstUpdated() {
|
||||
// DOM is guaranteed to be available here
|
||||
console.log("GutterAdModal DOM is ready");
|
||||
}
|
||||
|
||||
public show(): void {
|
||||
this.isVisible = true;
|
||||
this.requestUpdate();
|
||||
|
||||
// Wait for the update to complete, then load ads
|
||||
this.updateComplete.then(() => {
|
||||
this.loadAds();
|
||||
});
|
||||
}
|
||||
|
||||
public close(): void {
|
||||
try {
|
||||
window.ramp.destroyUnits(this.leftAdType);
|
||||
window.ramp.destroyUnits(this.rightAdType);
|
||||
console.log("successfully destroyed gutter ads");
|
||||
} catch (e) {
|
||||
console.error("error destroying gutter ads", e);
|
||||
}
|
||||
}
|
||||
|
||||
private loadAds(): void {
|
||||
console.log("loading ramp ads");
|
||||
// Ensure the container elements exist before loading ads
|
||||
const leftContainer = this.querySelector(`#${this.leftContainerId}`);
|
||||
const rightContainer = this.querySelector(`#${this.rightContainerId}`);
|
||||
|
||||
if (!leftContainer || !rightContainer) {
|
||||
console.warn("Ad containers not found in DOM");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!window.ramp) {
|
||||
console.warn("Playwire RAMP not available");
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.adLoaded) {
|
||||
console.log("Ads already loaded, skipping");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
window.ramp.que.push(() => {
|
||||
try {
|
||||
window.ramp.spaAddAds([
|
||||
{
|
||||
type: this.leftAdType,
|
||||
selectorId: this.leftContainerId,
|
||||
},
|
||||
{
|
||||
type: this.rightAdType,
|
||||
selectorId: this.rightContainerId,
|
||||
},
|
||||
]);
|
||||
this.adLoaded = true;
|
||||
console.log(
|
||||
"Playwire ads loaded:",
|
||||
this.leftAdType,
|
||||
this.rightAdType,
|
||||
);
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to load Playwire ads:", error);
|
||||
}
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
window.removeEventListener("resize", this.onResize);
|
||||
}
|
||||
|
||||
render() {
|
||||
if (!this.isVisible) {
|
||||
return html``;
|
||||
}
|
||||
|
||||
return html`
|
||||
<!-- Left Gutter Ad -->
|
||||
<div
|
||||
class="hidden xl:flex fixed transform -translate-y-1/2 w-[160px] min-h-[600px] z-40 pointer-events-auto items-center justify-center xl:[--half-content:10.5cm] 2xl:[--half-content:12.5cm]"
|
||||
style="left: calc(50% - var(--half-content) - 208px); top: calc(50% + 10px${this
|
||||
.hasFooterAd
|
||||
? " - 1.2cm"
|
||||
: ""});"
|
||||
>
|
||||
<div
|
||||
id="${this.leftContainerId}"
|
||||
class="w-full h-full flex items-center justify-center p-2"
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<!-- Right Gutter Ad -->
|
||||
<div
|
||||
class="hidden xl:flex fixed transform -translate-y-1/2 w-[160px] min-h-[600px] z-40 pointer-events-auto items-center justify-center xl:[--half-content:10.5cm] 2xl:[--half-content:12.5cm]"
|
||||
style="left: calc(50% + var(--half-content) + 48px); top: calc(50% + 10px${this
|
||||
.hasFooterAd
|
||||
? " - 1.2cm"
|
||||
: ""});"
|
||||
>
|
||||
<div
|
||||
id="${this.rightContainerId}"
|
||||
class="w-full h-full flex items-center justify-center p-2"
|
||||
></div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
import { LitElement, css, html, nothing } from "lit";
|
||||
import { customElement, state } from "lit/decorators.js";
|
||||
|
||||
export const FOOTER_AD_MIN_HEIGHT = 880;
|
||||
|
||||
const FOOTER_AD_TYPE = "standard_iab_head2";
|
||||
const FOOTER_AD_CONTAINER_ID = "home-footer-ad-container";
|
||||
|
||||
// ─── Gutter Ads ──────────────────────────────────────────────────────────────
|
||||
|
||||
@customElement("homepage-promos")
|
||||
export class HomepagePromos extends LitElement {
|
||||
@state() private isVisible: boolean = false;
|
||||
@state() private adLoaded: boolean = false;
|
||||
private cornerAdLoaded: boolean = false;
|
||||
@state() private hasFooterAd: boolean = false;
|
||||
|
||||
private onResize = () => {
|
||||
const isDesktop = window.innerWidth >= 640;
|
||||
this.hasFooterAd = isDesktop && window.innerHeight >= FOOTER_AD_MIN_HEIGHT;
|
||||
};
|
||||
|
||||
private onUserMeResponse = () => {
|
||||
if (window.adsEnabled) {
|
||||
console.log("showing homepage ads");
|
||||
this.show();
|
||||
this.loadCornerAdVideo();
|
||||
} else {
|
||||
console.log("not showing homepage ads");
|
||||
}
|
||||
};
|
||||
|
||||
private leftAdType: string = "standard_iab_left2";
|
||||
private rightAdType: string = "standard_iab_rght1";
|
||||
private leftContainerId: string = "gutter-ad-container-left";
|
||||
private rightContainerId: string = "gutter-ad-container-right";
|
||||
|
||||
createRenderRoot() {
|
||||
return this;
|
||||
}
|
||||
|
||||
static styles = css``;
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this.onResize();
|
||||
window.addEventListener("resize", this.onResize);
|
||||
document.addEventListener("userMeResponse", this.onUserMeResponse);
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
window.removeEventListener("resize", this.onResize);
|
||||
document.removeEventListener("userMeResponse", this.onUserMeResponse);
|
||||
}
|
||||
|
||||
public show(): void {
|
||||
this.isVisible = true;
|
||||
this.requestUpdate();
|
||||
this.updateComplete.then(() => {
|
||||
this.loadGutterAds();
|
||||
});
|
||||
}
|
||||
|
||||
public close(): void {
|
||||
try {
|
||||
// Keep corner video ad alive.
|
||||
window.ramp.destroyUnits(this.leftAdType);
|
||||
window.ramp.destroyUnits(this.rightAdType);
|
||||
console.log("successfully destroyed gutter ads");
|
||||
} catch (e) {
|
||||
console.error("error destroying gutter ads", e);
|
||||
}
|
||||
}
|
||||
|
||||
private loadGutterAds(): void {
|
||||
console.log("loading ramp gutter ads");
|
||||
const leftContainer = this.querySelector(`#${this.leftContainerId}`);
|
||||
const rightContainer = this.querySelector(`#${this.rightContainerId}`);
|
||||
|
||||
if (!leftContainer || !rightContainer) {
|
||||
console.warn("Ad containers not found in DOM");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!window.ramp) {
|
||||
console.warn("Playwire RAMP not available");
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.adLoaded) {
|
||||
console.log("Ads already loaded, skipping");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
window.ramp.que.push(() => {
|
||||
try {
|
||||
window.ramp.spaAddAds([
|
||||
{ type: this.leftAdType, selectorId: this.leftContainerId },
|
||||
{ type: this.rightAdType, selectorId: this.rightContainerId },
|
||||
]);
|
||||
this.adLoaded = true;
|
||||
console.log("Gutter ads loaded:", this.leftAdType, this.rightAdType);
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to load gutter ads:", error);
|
||||
}
|
||||
}
|
||||
|
||||
private loadCornerAdVideo(): void {
|
||||
if (this.cornerAdLoaded) return;
|
||||
if (window.innerWidth < 1280) return;
|
||||
if (!window.ramp) {
|
||||
console.warn("Playwire RAMP not available for corner_ad_video");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
window.ramp.que.push(() => {
|
||||
try {
|
||||
window.ramp
|
||||
.addUnits([{ type: "corner_ad_video" }])
|
||||
.then(() => {
|
||||
this.cornerAdLoaded = true;
|
||||
window.ramp.displayUnits();
|
||||
console.log("corner_ad_video loaded");
|
||||
})
|
||||
.catch((e: unknown) => {
|
||||
console.error("Failed to display corner_ad_video:", e);
|
||||
});
|
||||
} catch (e) {
|
||||
console.error("Failed to add corner_ad_video:", e);
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to load corner_ad_video:", error);
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
if (!this.isVisible) {
|
||||
return html``;
|
||||
}
|
||||
|
||||
return html`
|
||||
<!-- Left Gutter Ad -->
|
||||
<div
|
||||
class="hidden xl:flex fixed transform -translate-y-1/2 w-[160px] min-h-[600px] z-40 pointer-events-auto items-center justify-center xl:[--half-content:10.5cm] 2xl:[--half-content:12.5cm]"
|
||||
style="left: calc(50% - var(--half-content) - 208px); top: calc(50% + 10px${this
|
||||
.hasFooterAd
|
||||
? " - 1.2cm"
|
||||
: ""});"
|
||||
>
|
||||
<div
|
||||
id="${this.leftContainerId}"
|
||||
class="w-full h-full flex items-center justify-center p-2"
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<!-- Right Gutter Ad -->
|
||||
<div
|
||||
class="hidden xl:flex fixed transform -translate-y-1/2 w-[160px] min-h-[600px] z-40 pointer-events-auto items-center justify-center xl:[--half-content:10.5cm] 2xl:[--half-content:12.5cm]"
|
||||
style="left: calc(50% + var(--half-content) + 48px); top: calc(50% + 10px${this
|
||||
.hasFooterAd
|
||||
? " - 1.2cm"
|
||||
: ""});"
|
||||
>
|
||||
<div
|
||||
id="${this.rightContainerId}"
|
||||
class="w-full h-full flex items-center justify-center p-2"
|
||||
></div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Footer Ad ───────────────────────────────────────────────────────────────
|
||||
|
||||
@customElement("home-footer-ad")
|
||||
export class HomeFooterAd extends LitElement {
|
||||
@state() private shouldShow: boolean = false;
|
||||
|
||||
createRenderRoot() {
|
||||
return this;
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this.style.display = "contents";
|
||||
document.addEventListener("userMeResponse", this.onUserMeResponse);
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
document.removeEventListener("userMeResponse", this.onUserMeResponse);
|
||||
this.destroyAd();
|
||||
}
|
||||
|
||||
private onUserMeResponse = () => {
|
||||
const isDesktop = window.innerWidth >= 640;
|
||||
if (
|
||||
!window.adsEnabled ||
|
||||
(isDesktop && window.innerHeight < FOOTER_AD_MIN_HEIGHT)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
this.shouldShow = true;
|
||||
this.updateComplete.then(() => {
|
||||
this.loadAd();
|
||||
});
|
||||
};
|
||||
|
||||
private loadAd(): void {
|
||||
if (!window.ramp) {
|
||||
console.warn("Playwire RAMP not available for footer ad");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
window.ramp.que.push(() => {
|
||||
try {
|
||||
window.ramp.spaAddAds([
|
||||
{ type: FOOTER_AD_TYPE, selectorId: FOOTER_AD_CONTAINER_ID },
|
||||
]);
|
||||
console.log("Footer ad loaded:", FOOTER_AD_TYPE);
|
||||
} catch (e) {
|
||||
console.error("Failed to add footer ad:", e);
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to load footer ad:", error);
|
||||
}
|
||||
}
|
||||
|
||||
private destroyAd(): void {
|
||||
try {
|
||||
window.ramp.destroyUnits(FOOTER_AD_TYPE);
|
||||
console.log("successfully destroyed footer ad");
|
||||
} catch (e) {
|
||||
console.error("error destroying footer ad", e);
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
if (!this.shouldShow) {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
return html`
|
||||
<div
|
||||
id="${FOOTER_AD_CONTAINER_ID}"
|
||||
class="flex justify-center items-center w-full pointer-events-auto [&_*]:!m-0 [&_*]:!p-0"
|
||||
style="margin: 0; padding: 0; line-height: 0;"
|
||||
></div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
+5
-8
@@ -27,8 +27,8 @@ import "./GameModeSelector";
|
||||
import { GameModeSelector } from "./GameModeSelector";
|
||||
import { GameStartingModal } from "./GameStartingModal";
|
||||
import "./GoogleAdElement";
|
||||
import { GutterAds } from "./GutterAds";
|
||||
import { HelpModal } from "./HelpModal";
|
||||
import { HomepagePromos } from "./HomepagePromos";
|
||||
import { HostLobbyModal as HostPrivateLobbyModal } from "./HostLobbyModal";
|
||||
import { JoinLobbyModal } from "./JoinLobbyModal";
|
||||
import "./LangSelector";
|
||||
@@ -60,7 +60,6 @@ import {
|
||||
} from "./Utils";
|
||||
import "./components/DesktopNavBar";
|
||||
import "./components/Footer";
|
||||
import "./components/HomeFooterAd";
|
||||
import "./components/MainLayout";
|
||||
import "./components/MobileNavBar";
|
||||
import "./components/PlayPage";
|
||||
@@ -244,7 +243,6 @@ class Client {
|
||||
private tokenLoginModal: TokenLoginModal;
|
||||
private matchmakingModal: MatchmakingModal;
|
||||
|
||||
private gutterAds: GutterAds;
|
||||
private turnstileTokenPromise: Promise<{
|
||||
token: string;
|
||||
createdAt: number;
|
||||
@@ -304,10 +302,9 @@ class Client {
|
||||
}
|
||||
});
|
||||
|
||||
const gutterAds = document.querySelector("gutter-ads");
|
||||
if (!(gutterAds instanceof GutterAds))
|
||||
throw new Error("Missing gutter-ads");
|
||||
this.gutterAds = gutterAds;
|
||||
const gutterAds = document.querySelector("homepage-promos");
|
||||
if (!(gutterAds instanceof HomepagePromos))
|
||||
throw new Error("Missing homepage-promos");
|
||||
|
||||
document.addEventListener("join-lobby", this.handleJoinLobby.bind(this));
|
||||
document.addEventListener("leave-lobby", this.handleLeaveLobby.bind(this));
|
||||
@@ -778,7 +775,7 @@ class Client {
|
||||
"token-login",
|
||||
"matchmaking-modal",
|
||||
"lang-selector",
|
||||
"gutter-ads",
|
||||
"homepage-promos",
|
||||
].forEach((tag) => {
|
||||
const modal = document.querySelector(tag) as HTMLElement & {
|
||||
close?: () => void;
|
||||
|
||||
+3
-9
@@ -159,7 +159,7 @@ export class StoreModal extends BaseModal {
|
||||
|
||||
return html`
|
||||
<div
|
||||
class="flex flex-wrap gap-4 p-2 justify-center items-stretch content-start"
|
||||
class="flex flex-wrap gap-4 p-8 justify-center items-stretch content-start"
|
||||
>
|
||||
${buttons}
|
||||
</div>
|
||||
@@ -179,13 +179,7 @@ export class StoreModal extends BaseModal {
|
||||
const selectedFlag = new UserSettings().getFlag() ?? "";
|
||||
buttons.push(html`
|
||||
<flag-button
|
||||
.flag=${{
|
||||
key: `flag:${key}`,
|
||||
name: flag.name,
|
||||
url: flag.url,
|
||||
product: flag.product,
|
||||
artist: flag.artist,
|
||||
}}
|
||||
.flag=${{ ...flag, key: `flag:${key}` }}
|
||||
.selected=${selectedFlag === `flag:${key}`}
|
||||
.requiresPurchase=${rel === "purchasable"}
|
||||
.onPurchase=${() => handlePurchase(flag.product!)}
|
||||
@@ -203,7 +197,7 @@ export class StoreModal extends BaseModal {
|
||||
|
||||
return html`
|
||||
<div
|
||||
class="flex flex-wrap gap-4 p-2 justify-center items-stretch content-start"
|
||||
class="flex flex-wrap gap-4 p-8 justify-center items-stretch content-start"
|
||||
>
|
||||
${buttons}
|
||||
</div>
|
||||
|
||||
@@ -131,7 +131,7 @@ export class TerritoryPatternsModal extends BaseModal {
|
||||
return html`
|
||||
<div class="flex flex-col">
|
||||
<div
|
||||
class="flex flex-wrap gap-4 p-2 justify-center items-stretch content-start"
|
||||
class="flex flex-wrap gap-4 p-8 justify-center items-stretch content-start"
|
||||
>
|
||||
${buttons}
|
||||
</div>
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
import { html, LitElement, nothing } from "lit";
|
||||
import { customElement, property } from "lit/decorators.js";
|
||||
import { translateText } from "../Utils";
|
||||
|
||||
@customElement("artist-info")
|
||||
export class ArtistInfo extends LitElement {
|
||||
@property({ type: String })
|
||||
artist?: string;
|
||||
|
||||
createRenderRoot() {
|
||||
return this;
|
||||
}
|
||||
|
||||
render() {
|
||||
if (!this.artist) {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
return html`
|
||||
<div
|
||||
class="absolute -top-1 -right-1 z-10 group/artist"
|
||||
@click=${(e: Event) => e.stopPropagation()}
|
||||
>
|
||||
<div
|
||||
class="w-6 h-6 rounded-full bg-white/20 hover:bg-white/40 flex items-center justify-center cursor-help transition-colors duration-150"
|
||||
>
|
||||
<span class="text-xs font-bold text-white/70">?</span>
|
||||
</div>
|
||||
<div
|
||||
class="hidden group-hover/artist:block absolute top-7 right-0 bg-zinc-800 text-white text-xs px-2.5 py-1.5 rounded shadow-lg whitespace-nowrap z-20 border border-white/10"
|
||||
>
|
||||
${translateText("cosmetics.artist_label")} ${this.artist}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,432 @@
|
||||
import { html, LitElement } from "lit";
|
||||
import { customElement, property } from "lit/decorators.js";
|
||||
import { Product } from "../../core/CosmeticSchemas";
|
||||
import "./PurchaseButton";
|
||||
|
||||
type Rarity = "common" | "uncommon" | "rare" | "epic" | "legendary" | string;
|
||||
|
||||
interface RarityConfig {
|
||||
gradient: string;
|
||||
border: string;
|
||||
glow: string;
|
||||
hoverGlowSize: string;
|
||||
nameColor: string;
|
||||
legendary?: boolean;
|
||||
shimmer?: boolean;
|
||||
shimmerColor?: string; // rgb triplet e.g. "255,200,80"
|
||||
borderSweep?: boolean;
|
||||
borderSweepColor?: string; // rgb triplet e.g. "192,132,252"
|
||||
}
|
||||
|
||||
const rarityConfig: Record<string, RarityConfig> = {
|
||||
common: {
|
||||
gradient: "rgba(80,80,80,0.55)",
|
||||
border: "rgba(255,255,255,0.15)",
|
||||
glow: "rgba(255,255,255,0.5)",
|
||||
hoverGlowSize: "10px",
|
||||
nameColor: "rgba(255,255,255,0.7)",
|
||||
},
|
||||
uncommon: {
|
||||
gradient: "rgba(30,100,30,0.65)",
|
||||
border: "rgba(74,222,128,0.45)",
|
||||
glow: "rgba(74,222,128,0.6)",
|
||||
hoverGlowSize: "12px",
|
||||
nameColor: "rgba(255,255,255,1)",
|
||||
},
|
||||
rare: {
|
||||
gradient: "rgba(20,60,160,0.70)",
|
||||
border: "rgba(96,165,250,0.50)",
|
||||
glow: "rgba(96,165,250,0.7)",
|
||||
hoverGlowSize: "14px",
|
||||
nameColor: "rgba(255,255,255,1)",
|
||||
},
|
||||
epic: {
|
||||
gradient: "rgba(90,20,160,0.75)",
|
||||
border: "rgba(192,132,252,0.60)",
|
||||
glow: "rgba(192,132,252,0.85)",
|
||||
hoverGlowSize: "14px",
|
||||
nameColor: "rgba(255,255,255,1)",
|
||||
shimmer: true,
|
||||
shimmerColor: "192,132,252",
|
||||
},
|
||||
legendary: {
|
||||
gradient: "rgba(180,80,0,0.75)",
|
||||
border: "rgba(251,146,60,0.65)",
|
||||
glow: "rgba(251,146,60,0.95)",
|
||||
hoverGlowSize: "25px",
|
||||
nameColor: "rgba(255,255,255,1)",
|
||||
legendary: true,
|
||||
shimmer: true,
|
||||
shimmerColor: "255,200,80",
|
||||
borderSweep: true,
|
||||
borderSweepColor: "255,200,80",
|
||||
},
|
||||
};
|
||||
|
||||
const fallback = rarityConfig["common"];
|
||||
|
||||
const STYLE_ID = "cosmetic-container-styles";
|
||||
if (!document.getElementById(STYLE_ID)) {
|
||||
const style = document.createElement("style");
|
||||
style.id = STYLE_ID;
|
||||
style.textContent = `
|
||||
@keyframes legendary-pulse {
|
||||
0% { box-shadow: 0 0 15px rgba(251,146,60,0.8), 0 0 30px rgba(251,146,60,0.4); }
|
||||
50% { box-shadow: 0 0 25px rgba(251,146,60,0.9), 0 0 45px rgba(251,146,60,0.5); }
|
||||
100% { box-shadow: 0 0 15px rgba(251,146,60,0.8), 0 0 30px rgba(251,146,60,0.4); }
|
||||
}
|
||||
@keyframes legendary-shimmer {
|
||||
0% { left: -60%; }
|
||||
100% { left: 160%; }
|
||||
}
|
||||
@keyframes legendary-border-sweep {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
@keyframes sparkle-twinkle-0 {
|
||||
0%, 100% { opacity: 0; transform: scale(0.5) rotate(0deg); }
|
||||
40%, 60% { opacity: 1; transform: scale(1.2) rotate(20deg); }
|
||||
}
|
||||
@keyframes sparkle-twinkle-1 {
|
||||
0%, 100% { opacity: 0; transform: scale(0.5) rotate(0deg); }
|
||||
30%, 55% { opacity: 1; transform: scale(1.1) rotate(-15deg); }
|
||||
}
|
||||
@keyframes sparkle-twinkle-2 {
|
||||
0%, 100% { opacity: 0; transform: scale(0.5) rotate(0deg); }
|
||||
45%, 65% { opacity: 1; transform: scale(1.3) rotate(10deg); }
|
||||
}
|
||||
@keyframes sparkle-twinkle-3 {
|
||||
0%, 100% { opacity: 0; transform: scale(0.5) rotate(0deg); }
|
||||
35%, 58% { opacity: 1; transform: scale(1.0) rotate(-20deg); }
|
||||
}
|
||||
.legendary-hovered {
|
||||
animation: legendary-pulse 1.4s ease-in-out infinite;
|
||||
}
|
||||
.legendary-shimmer.active {
|
||||
animation: legendary-shimmer 0.8s ease-in-out;
|
||||
}
|
||||
.legendary-border-sweep {
|
||||
animation: legendary-border-sweep 8s linear infinite;
|
||||
}
|
||||
.legendary-sparkle-0 { animation: sparkle-twinkle-0 1.6s ease-in-out infinite; }
|
||||
.legendary-sparkle-1 { animation: sparkle-twinkle-1 1.9s ease-in-out infinite 0.3s; }
|
||||
.legendary-sparkle-2 { animation: sparkle-twinkle-2 1.7s ease-in-out infinite 0.7s; }
|
||||
.legendary-sparkle-3 { animation: sparkle-twinkle-3 2.0s ease-in-out infinite 0.1s; }
|
||||
@keyframes cosmetic-spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
.cosmetic-loading-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(0,0,0,0.6);
|
||||
border-radius: 0.75rem;
|
||||
z-index: 20;
|
||||
}
|
||||
.cosmetic-loading-spinner {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 4px solid rgba(255,255,255,0.2);
|
||||
border-top-color: rgb(74,222,128);
|
||||
border-radius: 50%;
|
||||
animation: cosmetic-spin 0.8s linear infinite;
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
|
||||
@customElement("cosmetic-container")
|
||||
export class CosmeticContainer extends LitElement {
|
||||
@property({ type: String })
|
||||
rarity: Rarity = "common";
|
||||
|
||||
@property({ type: Boolean })
|
||||
selected: boolean = false;
|
||||
|
||||
@property({ type: String })
|
||||
name: string = "";
|
||||
|
||||
@property({ type: Object })
|
||||
product: Product | null = null;
|
||||
|
||||
@property({ type: Function })
|
||||
onPurchase?: () => void;
|
||||
|
||||
private static _backdrop: HTMLDivElement | null = null;
|
||||
private static _ensureBackdrop(): HTMLDivElement {
|
||||
if (!CosmeticContainer._backdrop) {
|
||||
const el = document.createElement("div");
|
||||
el.style.cssText = `
|
||||
pointer-events: none;
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0,0,0,0);
|
||||
z-index: 9;
|
||||
transition: background 0.3s ease;
|
||||
`;
|
||||
document.body.appendChild(el);
|
||||
CosmeticContainer._backdrop = el;
|
||||
}
|
||||
return CosmeticContainer._backdrop;
|
||||
}
|
||||
|
||||
private _shimmer: HTMLDivElement | null = null;
|
||||
private _borderSweep: HTMLDivElement | null = null;
|
||||
private _sparkles: HTMLDivElement[] = [];
|
||||
private _glowColor = fallback.glow;
|
||||
private _glowSize = fallback.hoverGlowSize;
|
||||
private _isLegendary = false;
|
||||
private _hasGlint = false;
|
||||
private _hasBorderSweep = false;
|
||||
private _loading = false;
|
||||
private _loadingOverlay: HTMLDivElement | null = null;
|
||||
|
||||
createRenderRoot() {
|
||||
return this;
|
||||
}
|
||||
|
||||
private applyHostStyles() {
|
||||
const cfg = rarityConfig[this.rarity] ?? fallback;
|
||||
this._glowColor = cfg.glow;
|
||||
this._glowSize = cfg.hoverGlowSize;
|
||||
this._isLegendary = !!cfg.legendary;
|
||||
this._hasGlint = !!cfg.shimmer;
|
||||
this._hasBorderSweep = !!cfg.borderSweep;
|
||||
|
||||
this.style.position = "relative";
|
||||
this.style.overflow = "hidden";
|
||||
this.style.background = `linear-gradient(to top, ${cfg.gradient} 0%, rgba(15,15,20,0.85) 100%)`;
|
||||
this.style.border = `1px solid ${this.selected ? cfg.glow : cfg.border}`;
|
||||
this.style.backdropFilter = "blur(8px)";
|
||||
this.style.borderRadius = "0.75rem";
|
||||
this.style.transition =
|
||||
"border-color 0.2s, background 0.2s, transform 0.2s cubic-bezier(0.34, 1.56, 0.64, 1), box-shadow 0.2s";
|
||||
this.style.zIndex = "0";
|
||||
this.style.cursor = this.product ? "pointer" : "";
|
||||
|
||||
if (this.selected) {
|
||||
this.style.boxShadow = `0 0 18px ${cfg.glow}`;
|
||||
} else if (!this.classList.contains("legendary-hovered")) {
|
||||
this.style.boxShadow = "";
|
||||
}
|
||||
}
|
||||
|
||||
private _ensureLegendaryElements() {
|
||||
if (this._shimmer || this._borderSweep) return;
|
||||
|
||||
// Shimmer sweep — epic and legendary
|
||||
if (this._hasGlint) {
|
||||
const shimmer = document.createElement("div");
|
||||
shimmer.className = "legendary-shimmer";
|
||||
shimmer.style.cssText = `
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: -60%;
|
||||
width: 40%;
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, transparent 0%, rgba(${(rarityConfig[this.rarity] ?? fallback).shimmerColor ?? "255,200,80"},0.45) 50%, transparent 100%);
|
||||
transform: skewX(-15deg);
|
||||
z-index: 10;
|
||||
display: none;
|
||||
`;
|
||||
this.appendChild(shimmer);
|
||||
this._shimmer = shimmer;
|
||||
}
|
||||
|
||||
if (!this._hasBorderSweep) return;
|
||||
const sweepWrap = document.createElement("div");
|
||||
sweepWrap.style.cssText = `
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
inset: -2px;
|
||||
border-radius: 0.85rem;
|
||||
z-index: -1;
|
||||
overflow: hidden;
|
||||
display: none;
|
||||
`;
|
||||
const sweepInner = document.createElement("div");
|
||||
sweepInner.className = "legendary-border-sweep";
|
||||
const sc =
|
||||
(rarityConfig[this.rarity] ?? fallback).borderSweepColor ?? "255,200,80";
|
||||
sweepInner.style.cssText = `
|
||||
position: absolute;
|
||||
inset: -100%;
|
||||
background: conic-gradient(
|
||||
from 0deg,
|
||||
transparent 0deg,
|
||||
rgba(${sc},0.0) 60deg,
|
||||
rgba(${sc},0.9) 120deg,
|
||||
rgba(${sc},1) 180deg,
|
||||
rgba(${sc},0.9) 240deg,
|
||||
rgba(${sc},0.0) 300deg,
|
||||
transparent 360deg
|
||||
);
|
||||
`;
|
||||
// Inner mask to hide center, show only border ring
|
||||
const sweepMask = document.createElement("div");
|
||||
sweepMask.style.cssText = `
|
||||
position: absolute;
|
||||
inset: 2px;
|
||||
border-radius: 0.75rem;
|
||||
background: transparent;
|
||||
`;
|
||||
sweepWrap.appendChild(sweepInner);
|
||||
sweepWrap.appendChild(sweepMask);
|
||||
this.appendChild(sweepWrap);
|
||||
this._borderSweep = sweepWrap;
|
||||
|
||||
// Corner sparkles ✦
|
||||
const corners = [
|
||||
{ top: "4px", left: "4px" },
|
||||
{ top: "4px", right: "4px" },
|
||||
{ bottom: "4px", left: "4px" },
|
||||
{ bottom: "4px", right: "4px" },
|
||||
];
|
||||
this._sparkles = corners.map((pos, i) => {
|
||||
const el = document.createElement("div");
|
||||
el.className = `legendary-sparkle-${i}`;
|
||||
el.textContent = "✦";
|
||||
el.style.cssText = `
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
font-size: 10px;
|
||||
color: rgba(255,220,100,0.9);
|
||||
text-shadow: 0 0 6px rgba(255,200,60,1);
|
||||
z-index: 11;
|
||||
opacity: 0;
|
||||
display: none;
|
||||
line-height: 1;
|
||||
`;
|
||||
Object.assign(el.style, pos);
|
||||
this.appendChild(el);
|
||||
return el;
|
||||
});
|
||||
}
|
||||
|
||||
private _onClick = () => {
|
||||
if (CosmeticContainer._backdrop) {
|
||||
CosmeticContainer._backdrop.style.background = "rgba(0,0,0,0)";
|
||||
}
|
||||
if (this.product && this.onPurchase && !this._loading) {
|
||||
this._loading = true;
|
||||
this._showLoadingOverlay();
|
||||
Promise.resolve(this.onPurchase()).catch(() => {
|
||||
this._hideLoadingOverlay();
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
private _showLoadingOverlay() {
|
||||
if (this._loadingOverlay) return;
|
||||
const overlay = document.createElement("div");
|
||||
overlay.className = "cosmetic-loading-overlay";
|
||||
overlay.innerHTML = `<div class="cosmetic-loading-spinner"></div>`;
|
||||
this.appendChild(overlay);
|
||||
this._loadingOverlay = overlay;
|
||||
}
|
||||
|
||||
private _hideLoadingOverlay() {
|
||||
this._loadingOverlay?.remove();
|
||||
this._loadingOverlay = null;
|
||||
this._loading = false;
|
||||
}
|
||||
|
||||
private _onMouseEnter = () => {
|
||||
if (this._hasGlint || this._hasBorderSweep) {
|
||||
this._ensureLegendaryElements();
|
||||
}
|
||||
if (this._isLegendary) {
|
||||
this.style.transform = "scale(1.12)";
|
||||
this.style.zIndex = "10";
|
||||
this.classList.add("legendary-hovered");
|
||||
this._sparkles.forEach((s) => (s.style.display = "block"));
|
||||
CosmeticContainer._ensureBackdrop().style.background = "rgba(0,0,0,0.6)";
|
||||
}
|
||||
if (this._hasBorderSweep && this._borderSweep) {
|
||||
this._borderSweep.style.display = "block";
|
||||
}
|
||||
if (this._hasGlint && this._shimmer) {
|
||||
this._shimmer.style.display = "block";
|
||||
this._shimmer.classList.remove("active");
|
||||
void this._shimmer.offsetWidth;
|
||||
this._shimmer.classList.add("active");
|
||||
}
|
||||
if (!this._isLegendary && !this.selected) {
|
||||
this.style.boxShadow = `0 0 ${this._glowSize} ${this._glowColor}`;
|
||||
}
|
||||
};
|
||||
|
||||
private _onMouseLeave = () => {
|
||||
if (this._isLegendary) {
|
||||
this.style.transform = "";
|
||||
this.style.zIndex = "0";
|
||||
this.classList.remove("legendary-hovered");
|
||||
this._sparkles.forEach((s) => (s.style.display = "none"));
|
||||
if (CosmeticContainer._backdrop) {
|
||||
CosmeticContainer._backdrop.style.background = "rgba(0,0,0,0)";
|
||||
}
|
||||
}
|
||||
if (this._hasGlint && this._shimmer) this._shimmer.style.display = "none";
|
||||
if (this._hasBorderSweep && this._borderSweep)
|
||||
this._borderSweep.style.display = "none";
|
||||
if (!this.selected) this.style.boxShadow = "";
|
||||
};
|
||||
|
||||
private _nameEl: HTMLDivElement | null = null;
|
||||
|
||||
private _updateNameEl() {
|
||||
if (this.name) {
|
||||
this._nameEl ??= document.createElement("div");
|
||||
const cfg = rarityConfig[this.rarity] ?? fallback;
|
||||
this._nameEl.className = `text-xs font-bold uppercase tracking-wider text-center truncate w-full`;
|
||||
this._nameEl.style.color = cfg.nameColor;
|
||||
this._nameEl.title = this.name;
|
||||
this._nameEl.textContent = this.name;
|
||||
// Always ensure it's the first child
|
||||
if (this.firstChild !== this._nameEl) {
|
||||
this.prepend(this._nameEl);
|
||||
}
|
||||
} else if (this._nameEl) {
|
||||
this._nameEl.remove();
|
||||
this._nameEl = null;
|
||||
}
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this.applyHostStyles();
|
||||
this._updateNameEl();
|
||||
this.addEventListener("mouseenter", this._onMouseEnter);
|
||||
this.addEventListener("mouseleave", this._onMouseLeave);
|
||||
this.addEventListener("click", this._onClick);
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
this.removeEventListener("mouseenter", this._onMouseEnter);
|
||||
this.removeEventListener("mouseleave", this._onMouseLeave);
|
||||
this.removeEventListener("click", this._onClick);
|
||||
}
|
||||
|
||||
updated() {
|
||||
this.applyHostStyles();
|
||||
this._updateNameEl();
|
||||
}
|
||||
|
||||
render() {
|
||||
return html`
|
||||
<slot></slot>
|
||||
${this.product && this.onPurchase
|
||||
? html`<purchase-button
|
||||
.product=${this.product}
|
||||
.rarity=${this.rarity}
|
||||
.onPurchase=${this.onPurchase}
|
||||
></purchase-button>`
|
||||
: null}
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { html, LitElement, nothing } from "lit";
|
||||
import { customElement, property } from "lit/decorators.js";
|
||||
import { translateCosmetic } from "../Cosmetics";
|
||||
import { translateText } from "../Utils";
|
||||
|
||||
const rarityColors: Record<string, string> = {
|
||||
common: "text-white/60",
|
||||
uncommon: "text-green-400",
|
||||
rare: "text-blue-400",
|
||||
epic: "text-purple-300",
|
||||
legendary: "text-orange-400",
|
||||
};
|
||||
|
||||
@customElement("cosmetic-info")
|
||||
export class CosmeticInfo extends LitElement {
|
||||
@property({ type: String })
|
||||
artist?: string;
|
||||
|
||||
@property({ type: String })
|
||||
rarity?: string;
|
||||
|
||||
@property({ type: String })
|
||||
colorPalette?: string;
|
||||
|
||||
createRenderRoot() {
|
||||
return this;
|
||||
}
|
||||
|
||||
render() {
|
||||
if (!this.artist && !this.rarity && !this.colorPalette) {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
const rarityColor = rarityColors[this.rarity ?? ""] ?? "text-white/70";
|
||||
|
||||
return html`
|
||||
<div
|
||||
class="absolute -top-1 -right-1 z-10 group/artist"
|
||||
@click=${(e: Event) => e.stopPropagation()}
|
||||
>
|
||||
<div
|
||||
class="w-6 h-6 rounded-full bg-white/20 hover:bg-white/40 flex items-center justify-center cursor-help transition-colors duration-150"
|
||||
>
|
||||
<span class="text-xs font-bold text-white/70">?</span>
|
||||
</div>
|
||||
<div
|
||||
class="hidden group-hover/artist:block absolute top-7 right-0 bg-zinc-800 text-white text-xs px-2.5 py-1.5 rounded shadow-lg whitespace-nowrap z-20 border border-white/10 flex flex-col gap-0.5"
|
||||
>
|
||||
${this.rarity
|
||||
? html`<div
|
||||
class="font-bold uppercase tracking-wider ${rarityColor}"
|
||||
>
|
||||
${translateText(`cosmetics.${this.rarity}`) || this.rarity}
|
||||
</div>`
|
||||
: nothing}
|
||||
<div class="text-green-400 font-bold">
|
||||
${translateText("cosmetics.adfree")}
|
||||
</div>
|
||||
${this.colorPalette
|
||||
? html`<div>
|
||||
${translateText("cosmetics.color_label")}
|
||||
${translateCosmetic(
|
||||
"territory_patterns.color_palette",
|
||||
this.colorPalette,
|
||||
)}
|
||||
</div>`
|
||||
: nothing}
|
||||
${this.artist
|
||||
? html`<div>
|
||||
${translateText("cosmetics.artist_label")} ${this.artist}
|
||||
</div>`
|
||||
: nothing}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -1,17 +1,11 @@
|
||||
import { html, LitElement } from "lit";
|
||||
import { customElement, property } from "lit/decorators.js";
|
||||
import { Product } from "../../core/CosmeticSchemas";
|
||||
import { Flag } from "../../core/CosmeticSchemas";
|
||||
import { translateCosmetic } from "../Cosmetics";
|
||||
import "./ArtistInfo";
|
||||
import "./PurchaseButton";
|
||||
import "./CosmeticContainer";
|
||||
import "./CosmeticInfo";
|
||||
|
||||
export interface FlagItem {
|
||||
key: string;
|
||||
name: string;
|
||||
url: string;
|
||||
product?: Product | null;
|
||||
artist?: string;
|
||||
}
|
||||
export type FlagItem = Flag & { key: string };
|
||||
|
||||
@customElement("flag-button")
|
||||
export class FlagButton extends LitElement {
|
||||
@@ -35,35 +29,33 @@ export class FlagButton extends LitElement {
|
||||
}
|
||||
|
||||
private handleClick() {
|
||||
if (this.requiresPurchase) {
|
||||
this.onPurchase?.();
|
||||
return;
|
||||
}
|
||||
this.onSelect?.(this.flag.key);
|
||||
}
|
||||
|
||||
render() {
|
||||
return html`
|
||||
<div
|
||||
class="flex flex-col items-center justify-between gap-1 p-1.5 bg-white/5 backdrop-blur-sm border rounded-lg w-36 h-full transition-all duration-200 ${this
|
||||
.selected
|
||||
? "border-green-500 shadow-[0_0_15px_rgba(34,197,94,0.5)]"
|
||||
: "hover:bg-white/10 hover:border-white/20 hover:shadow-xl border-white/10"}"
|
||||
<cosmetic-container
|
||||
class="flex flex-col items-center justify-between gap-1 p-1.5 w-36 h-full"
|
||||
.rarity=${this.flag.rarity ?? "common"}
|
||||
.selected=${this.selected}
|
||||
.product=${this.requiresPurchase && this.flag.product
|
||||
? this.flag.product
|
||||
: null}
|
||||
.onPurchase=${() => this.onPurchase?.()}
|
||||
.name=${translateCosmetic("flags", this.flag.name)}
|
||||
>
|
||||
<button
|
||||
class="group relative flex flex-col items-center w-full gap-1 rounded-lg cursor-pointer transition-all duration-200
|
||||
disabled:cursor-not-allowed flex-1"
|
||||
?disabled=${this.requiresPurchase}
|
||||
class="group relative flex flex-col items-center w-full gap-1 rounded-lg cursor-pointer transition-all duration-200 flex-1"
|
||||
@click=${this.handleClick}
|
||||
>
|
||||
<artist-info .artist=${this.flag.artist}></artist-info>
|
||||
<div
|
||||
class="text-[10px] font-bold text-white uppercase tracking-wider mt-1 ${this
|
||||
.flag.artist
|
||||
? "pr-5"
|
||||
: ""} text-center truncate w-full ${this.requiresPurchase
|
||||
? "opacity-50"
|
||||
: ""}"
|
||||
title="${translateCosmetic("flags", this.flag.name)}"
|
||||
>
|
||||
${translateCosmetic("flags", this.flag.name)}
|
||||
</div>
|
||||
<cosmetic-info
|
||||
.artist=${this.flag.artist}
|
||||
.rarity=${this.flag.rarity}
|
||||
></cosmetic-info>
|
||||
|
||||
<div
|
||||
class="w-full aspect-square flex items-center justify-center bg-white/5 rounded-lg p-2 border border-white/10 group-hover:border-white/20 transition-colors duration-200 overflow-hidden"
|
||||
@@ -84,16 +76,7 @@ export class FlagButton extends LitElement {
|
||||
/>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
${this.requiresPurchase && this.flag.product
|
||||
? html`
|
||||
<purchase-button
|
||||
.product=${this.flag.product}
|
||||
.onPurchase=${() => this.onPurchase?.()}
|
||||
></purchase-button>
|
||||
`
|
||||
: null}
|
||||
</div>
|
||||
</cosmetic-container>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,86 +0,0 @@
|
||||
import { LitElement, html, nothing } from "lit";
|
||||
import { customElement, state } from "lit/decorators.js";
|
||||
|
||||
export const FOOTER_AD_MIN_HEIGHT = 880;
|
||||
const FOOTER_AD_TYPE = "standard_iab_head2";
|
||||
const FOOTER_AD_CONTAINER_ID = "home-footer-ad-container";
|
||||
|
||||
@customElement("home-footer-ad")
|
||||
export class HomeFooterAd extends LitElement {
|
||||
@state() private shouldShow: boolean = false;
|
||||
|
||||
createRenderRoot() {
|
||||
return this;
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this.style.display = "contents";
|
||||
document.addEventListener("userMeResponse", this.onUserMeResponse);
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
document.removeEventListener("userMeResponse", this.onUserMeResponse);
|
||||
this.destroyAd();
|
||||
}
|
||||
|
||||
private onUserMeResponse = () => {
|
||||
const isDesktop = window.innerWidth >= 640;
|
||||
if (
|
||||
!window.adsEnabled ||
|
||||
(isDesktop && window.innerHeight < FOOTER_AD_MIN_HEIGHT)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
this.shouldShow = true;
|
||||
this.updateComplete.then(() => {
|
||||
this.loadAd();
|
||||
});
|
||||
};
|
||||
|
||||
private loadAd(): void {
|
||||
if (!window.ramp) {
|
||||
console.warn("Playwire RAMP not available for footer ad");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
window.ramp.que.push(() => {
|
||||
try {
|
||||
window.ramp.spaAddAds([
|
||||
{ type: FOOTER_AD_TYPE, selectorId: FOOTER_AD_CONTAINER_ID },
|
||||
]);
|
||||
console.log("Footer ad loaded:", FOOTER_AD_TYPE);
|
||||
} catch (e) {
|
||||
console.error("Failed to add footer ad:", e);
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to load footer ad:", error);
|
||||
}
|
||||
}
|
||||
|
||||
private destroyAd(): void {
|
||||
try {
|
||||
window.ramp.destroyUnits(FOOTER_AD_TYPE);
|
||||
console.log("successfully destroyed footer ad");
|
||||
} catch (e) {
|
||||
console.error("error destroying footer ad", e);
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
if (!this.shouldShow) {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
return html`
|
||||
<div
|
||||
id="${FOOTER_AD_CONTAINER_ID}"
|
||||
class="flex justify-center items-center w-full pointer-events-auto [&_*]:!m-0 [&_*]:!p-0"
|
||||
style="margin: 0; padding: 0; line-height: 0;"
|
||||
></div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -11,8 +11,8 @@ import { PatternDecoder } from "../../core/PatternDecoder";
|
||||
import { PlayerPattern } from "../../core/Schemas";
|
||||
import { translateCosmetic } from "../Cosmetics";
|
||||
import { translateText } from "../Utils";
|
||||
import "./ArtistInfo";
|
||||
import "./PurchaseButton";
|
||||
import "./CosmeticContainer";
|
||||
import "./CosmeticInfo";
|
||||
|
||||
export const BUTTON_WIDTH = 150;
|
||||
|
||||
@@ -40,6 +40,10 @@ export class PatternButton extends LitElement {
|
||||
}
|
||||
|
||||
private handleClick() {
|
||||
if (this.requiresPurchase) {
|
||||
this.handlePurchase();
|
||||
return;
|
||||
}
|
||||
if (this.pattern === null) {
|
||||
this.onSelect?.(null);
|
||||
return;
|
||||
@@ -61,57 +65,27 @@ export class PatternButton extends LitElement {
|
||||
const isDefaultPattern = this.pattern === null;
|
||||
|
||||
return html`
|
||||
<div
|
||||
class="no-crazygames flex flex-col items-center justify-between gap-2 p-3 bg-white/5 backdrop-blur-sm border rounded-xl w-48 h-full transition-all duration-200 ${this
|
||||
.selected
|
||||
? "border-green-500 shadow-[0_0_15px_rgba(34,197,94,0.5)]"
|
||||
: "hover:bg-white/10 hover:border-white/20 hover:shadow-xl border-white/10"}"
|
||||
<cosmetic-container
|
||||
class="no-crazygames flex flex-col items-center justify-between gap-2 p-3 w-48 h-full"
|
||||
.rarity=${this.pattern?.rarity ?? "common"}
|
||||
.selected=${this.selected}
|
||||
.product=${this.requiresPurchase && this.pattern?.product
|
||||
? this.pattern.product
|
||||
: null}
|
||||
.onPurchase=${() => this.handlePurchase()}
|
||||
.name=${isDefaultPattern
|
||||
? translateText("territory_patterns.pattern.default")
|
||||
: translateCosmetic("territory_patterns.pattern", this.pattern!.name)}
|
||||
>
|
||||
<button
|
||||
class="group relative flex flex-col items-center w-full gap-2 rounded-lg cursor-pointer transition-all duration-200
|
||||
disabled:cursor-not-allowed flex-1"
|
||||
?disabled=${this.requiresPurchase}
|
||||
class="group relative flex flex-col items-center w-full gap-2 rounded-lg cursor-pointer transition-all duration-200 flex-1"
|
||||
@click=${this.handleClick}
|
||||
>
|
||||
<artist-info .artist=${this.pattern?.artist}></artist-info>
|
||||
<div class="flex flex-col items-center w-full">
|
||||
<div
|
||||
class="text-xs font-bold text-white uppercase tracking-wider mb-1 ${this
|
||||
.pattern?.artist
|
||||
? "pr-5"
|
||||
: ""} text-center truncate w-full ${this.requiresPurchase
|
||||
? "opacity-50"
|
||||
: ""}"
|
||||
title="${isDefaultPattern
|
||||
? translateText("territory_patterns.pattern.default")
|
||||
: translateCosmetic(
|
||||
"territory_patterns.pattern",
|
||||
this.pattern!.name,
|
||||
)}"
|
||||
>
|
||||
${isDefaultPattern
|
||||
? translateText("territory_patterns.pattern.default")
|
||||
: translateCosmetic(
|
||||
"territory_patterns.pattern",
|
||||
this.pattern!.name,
|
||||
)}
|
||||
</div>
|
||||
${this.colorPalette !== null
|
||||
? html`
|
||||
<div
|
||||
class="text-[10px] font-bold text-white/40 uppercase tracking-widest mb-2 text-center truncate w-full ${this
|
||||
.requiresPurchase
|
||||
? "opacity-50"
|
||||
: ""}"
|
||||
>
|
||||
${translateCosmetic(
|
||||
"territory_patterns.color_palette",
|
||||
this.colorPalette!.name,
|
||||
)}
|
||||
</div>
|
||||
`
|
||||
: html`<div class="h-[22px] mb-2 w-full"></div>`}
|
||||
</div>
|
||||
<cosmetic-info
|
||||
.artist=${this.pattern?.artist}
|
||||
.rarity=${this.pattern?.rarity}
|
||||
.colorPalette=${this.colorPalette?.name ?? undefined}
|
||||
></cosmetic-info>
|
||||
|
||||
<div
|
||||
class="w-full aspect-square flex items-center justify-center bg-white/5 rounded-lg p-2 border border-white/10 group-hover:border-white/20 transition-colors duration-200 overflow-hidden"
|
||||
@@ -129,16 +103,7 @@ export class PatternButton extends LitElement {
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
${this.requiresPurchase && this.pattern?.product
|
||||
? html`
|
||||
<purchase-button
|
||||
.product=${this.pattern.product}
|
||||
.onPurchase=${() => this.handlePurchase()}
|
||||
></purchase-button>
|
||||
`
|
||||
: null}
|
||||
</div>
|
||||
</cosmetic-container>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,11 +3,156 @@ import { customElement, property } from "lit/decorators.js";
|
||||
import { Product } from "../../core/CosmeticSchemas";
|
||||
import { translateText } from "../Utils";
|
||||
|
||||
const PURCHASE_STYLE_ID = "purchase-button-styles";
|
||||
if (!document.getElementById(PURCHASE_STYLE_ID)) {
|
||||
const style = document.createElement("style");
|
||||
style.id = PURCHASE_STYLE_ID;
|
||||
style.textContent = `
|
||||
@keyframes purchase-streak {
|
||||
0% { left: -60%; opacity: 0; }
|
||||
10% { opacity: 1; }
|
||||
90% { opacity: 1; }
|
||||
100% { left: 160%; opacity: 0; }
|
||||
}
|
||||
.purchase-sparkle-streak {
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: -60%;
|
||||
width: 40%;
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, transparent 0%, rgba(134,239,172,0.5) 50%, transparent 100%);
|
||||
transform: skewX(-15deg);
|
||||
opacity: 0;
|
||||
}
|
||||
cosmetic-container:hover .purchase-sparkle-streak {
|
||||
animation: purchase-streak 0.7s ease-in-out;
|
||||
}
|
||||
cosmetic-container:hover .purchase-sparkle-btn {
|
||||
background: rgb(34,197,94);
|
||||
border-color: rgb(74,222,128);
|
||||
color: white;
|
||||
box-shadow: 0 0 20px rgba(74,222,128,0.6);
|
||||
}
|
||||
@keyframes purchase-pulse {
|
||||
0% { box-shadow: 0 0 15px rgba(74,222,128,0.6), 0 0 30px rgba(34,197,94,0.3); }
|
||||
50% { box-shadow: 0 0 25px rgba(74,222,128,0.9), 0 0 50px rgba(34,197,94,0.5); }
|
||||
100% { box-shadow: 0 0 15px rgba(74,222,128,0.6), 0 0 30px rgba(34,197,94,0.3); }
|
||||
}
|
||||
.purchase-sparkle-btn:hover {
|
||||
background: rgb(22,163,74) !important;
|
||||
border-color: rgb(74,222,128) !important;
|
||||
color: white !important;
|
||||
animation: purchase-pulse 1.2s ease-in-out infinite !important;
|
||||
}
|
||||
@keyframes purchase-ember-0 {
|
||||
0% { transform: translateY(0) translateX(0) scale(1); opacity: 0.9; }
|
||||
100% { transform: translateY(-35px) translateX(5px) scale(0.2); opacity: 0; }
|
||||
}
|
||||
@keyframes purchase-ember-1 {
|
||||
0% { transform: translateY(0) translateX(0) scale(1); opacity: 0.9; }
|
||||
100% { transform: translateY(-30px) translateX(-6px) scale(0.3); opacity: 0; }
|
||||
}
|
||||
@keyframes purchase-ember-2 {
|
||||
0% { transform: translateY(0) translateX(0) scale(1); opacity: 0.9; }
|
||||
100% { transform: translateY(-40px) translateX(3px) scale(0.2); opacity: 0; }
|
||||
}
|
||||
@keyframes purchase-ember-3 {
|
||||
0% { transform: translateY(0) translateX(0) scale(1); opacity: 0.9; }
|
||||
100% { transform: translateY(-28px) translateX(-4px) scale(0.3); opacity: 0; }
|
||||
}
|
||||
.purchase-ember {
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
width: 3px;
|
||||
height: 3px;
|
||||
border-radius: 50%;
|
||||
background: rgba(74,222,128,0.9);
|
||||
box-shadow: 0 0 4px rgba(74,222,128,0.8);
|
||||
opacity: 0;
|
||||
display: none;
|
||||
}
|
||||
.purchase-ember-0 { left: 20%; animation: purchase-ember-0 1.2s ease-out infinite; }
|
||||
.purchase-ember-1 { left: 40%; animation: purchase-ember-1 1.5s ease-out infinite 0.25s; }
|
||||
.purchase-ember-2 { left: 60%; animation: purchase-ember-2 1.3s ease-out infinite 0.5s; }
|
||||
.purchase-ember-3 { left: 80%; animation: purchase-ember-3 1.6s ease-out infinite 0.15s; }
|
||||
cosmetic-container:hover .purchase-ember {
|
||||
display: block;
|
||||
}
|
||||
@keyframes purchase-burst-a { 0% { transform: translateY(0) translateX(0) scale(1.2); opacity:1; } 100% { transform: translateY(-70px) translateX(14px) scale(0); opacity:0; } }
|
||||
@keyframes purchase-burst-b { 0% { transform: translateY(0) translateX(0) scale(1.2); opacity:1; } 100% { transform: translateY(-60px) translateX(-12px) scale(0); opacity:0; } }
|
||||
@keyframes purchase-burst-c { 0% { transform: translateY(0) translateX(0) scale(1.2); opacity:1; } 100% { transform: translateY(-80px) translateX(8px) scale(0); opacity:0; } }
|
||||
@keyframes purchase-burst-d { 0% { transform: translateY(0) translateX(0) scale(1.2); opacity:1; } 100% { transform: translateY(-55px) translateX(-16px) scale(0); opacity:0; } }
|
||||
@keyframes purchase-burst-e { 0% { transform: translateY(0) translateX(0) scale(1.2); opacity:1; } 100% { transform: translateY(-75px) translateX(18px) scale(0); opacity:0; } }
|
||||
@keyframes purchase-burst-f { 0% { transform: translateY(0) translateX(0) scale(1.2); opacity:1; } 100% { transform: translateY(-65px) translateX(-6px) scale(0); opacity:0; } }
|
||||
.purchase-burst {
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
width: 4px;
|
||||
height: 4px;
|
||||
border-radius: 50%;
|
||||
background: rgba(74,222,128,1);
|
||||
box-shadow: 0 0 6px rgba(74,222,128,0.9), 0 0 2px rgba(255,255,255,0.5);
|
||||
opacity: 0;
|
||||
display: none;
|
||||
}
|
||||
.purchase-burst-0 { left: 3%; animation: purchase-burst-a 0.9s ease-out infinite 0.00s; }
|
||||
.purchase-burst-1 { left: 8%; animation: purchase-burst-d 1.1s ease-out infinite 0.73s; }
|
||||
.purchase-burst-2 { left: 12%; animation: purchase-burst-c 0.95s ease-out infinite 0.41s; }
|
||||
.purchase-burst-3 { left: 16%; animation: purchase-burst-f 1.05s ease-out infinite 0.17s; }
|
||||
.purchase-burst-4 { left: 20%; animation: purchase-burst-b 0.85s ease-out infinite 0.89s; }
|
||||
.purchase-burst-5 { left: 24%; animation: purchase-burst-e 1.0s ease-out infinite 0.53s; }
|
||||
.purchase-burst-6 { left: 28%; animation: purchase-burst-a 1.1s ease-out infinite 0.29s; }
|
||||
.purchase-burst-7 { left: 32%; animation: purchase-burst-c 0.9s ease-out infinite 0.97s; }
|
||||
.purchase-burst-8 { left: 36%; animation: purchase-burst-f 1.05s ease-out infinite 0.61s; }
|
||||
.purchase-burst-9 { left: 40%; animation: purchase-burst-d 0.95s ease-out infinite 0.07s; }
|
||||
.purchase-burst-10 { left: 44%; animation: purchase-burst-b 1.0s ease-out infinite 0.83s; }
|
||||
.purchase-burst-11 { left: 48%; animation: purchase-burst-e 0.85s ease-out infinite 0.37s; }
|
||||
.purchase-burst-12 { left: 52%; animation: purchase-burst-a 1.1s ease-out infinite 0.67s; }
|
||||
.purchase-burst-13 { left: 56%; animation: purchase-burst-f 0.9s ease-out infinite 0.11s; }
|
||||
.purchase-burst-14 { left: 60%; animation: purchase-burst-c 1.05s ease-out infinite 0.79s; }
|
||||
.purchase-burst-15 { left: 64%; animation: purchase-burst-d 0.95s ease-out infinite 0.47s; }
|
||||
.purchase-burst-16 { left: 68%; animation: purchase-burst-b 1.0s ease-out infinite 0.23s; }
|
||||
.purchase-burst-17 { left: 72%; animation: purchase-burst-e 0.85s ease-out infinite 1.03s; }
|
||||
.purchase-burst-18 { left: 76%; animation: purchase-burst-a 1.1s ease-out infinite 0.57s; }
|
||||
.purchase-burst-19 { left: 80%; animation: purchase-burst-f 0.95s ease-out infinite 0.31s; }
|
||||
.purchase-burst-20 { left: 6%; animation: purchase-burst-b 0.92s ease-out infinite 0.15s; }
|
||||
.purchase-burst-21 { left: 14%; animation: purchase-burst-e 1.08s ease-out infinite 0.86s; }
|
||||
.purchase-burst-22 { left: 22%; animation: purchase-burst-a 0.88s ease-out infinite 0.44s; }
|
||||
.purchase-burst-23 { left: 30%; animation: purchase-burst-d 1.02s ease-out infinite 0.71s; }
|
||||
.purchase-burst-24 { left: 38%; animation: purchase-burst-f 0.93s ease-out infinite 0.03s; }
|
||||
.purchase-burst-25 { left: 46%; animation: purchase-burst-c 1.07s ease-out infinite 0.59s; }
|
||||
.purchase-burst-26 { left: 54%; animation: purchase-burst-b 0.87s ease-out infinite 0.92s; }
|
||||
.purchase-burst-27 { left: 62%; animation: purchase-burst-e 0.98s ease-out infinite 0.26s; }
|
||||
.purchase-burst-28 { left: 70%; animation: purchase-burst-a 1.12s ease-out infinite 0.64s; }
|
||||
.purchase-burst-29 { left: 78%; animation: purchase-burst-d 0.91s ease-out infinite 0.38s; }
|
||||
.purchase-burst-30 { left: 84%; animation: purchase-burst-c 1.03s ease-out infinite 0.77s; }
|
||||
.purchase-burst-31 { left: 88%; animation: purchase-burst-f 0.86s ease-out infinite 0.09s; }
|
||||
.purchase-burst-32 { left: 92%; animation: purchase-burst-b 1.06s ease-out infinite 0.52s; }
|
||||
.purchase-burst-33 { left: 96%; animation: purchase-burst-e 0.94s ease-out infinite 0.81s; }
|
||||
.purchase-burst-34 { left: 10%; animation: purchase-burst-d 0.89s ease-out infinite 0.34s; }
|
||||
.purchase-burst-35 { left: 26%; animation: purchase-burst-a 1.04s ease-out infinite 0.96s; }
|
||||
.purchase-burst-36 { left: 42%; animation: purchase-burst-f 0.91s ease-out infinite 0.19s; }
|
||||
.purchase-burst-37 { left: 58%; animation: purchase-burst-c 1.09s ease-out infinite 0.69s; }
|
||||
.purchase-burst-38 { left: 74%; animation: purchase-burst-b 0.87s ease-out infinite 0.46s; }
|
||||
.purchase-burst-39 { left: 90%; animation: purchase-burst-e 1.01s ease-out infinite 0.13s; }
|
||||
.purchase-btn-wrap:hover .purchase-burst {
|
||||
display: block;
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
|
||||
@customElement("purchase-button")
|
||||
export class PurchaseButton extends LitElement {
|
||||
@property({ type: Object })
|
||||
product!: Product;
|
||||
|
||||
@property({ type: String })
|
||||
rarity: string = "common";
|
||||
|
||||
@property({ type: Function })
|
||||
onPurchase?: () => void;
|
||||
|
||||
@@ -17,19 +162,42 @@ export class PurchaseButton extends LitElement {
|
||||
|
||||
private handleClick(e: Event) {
|
||||
e.stopPropagation();
|
||||
this.onPurchase?.();
|
||||
const container = this.closest("cosmetic-container") as HTMLElement | null;
|
||||
if (container && !container.querySelector(".cosmetic-loading-overlay")) {
|
||||
const overlay = document.createElement("div");
|
||||
overlay.className = "cosmetic-loading-overlay";
|
||||
overlay.innerHTML = `<div class="cosmetic-loading-spinner"></div>`;
|
||||
container.appendChild(overlay);
|
||||
}
|
||||
Promise.resolve(this.onPurchase?.()).catch(() => {
|
||||
container?.querySelector(".cosmetic-loading-overlay")?.remove();
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
return html`
|
||||
<div class="no-crazygames w-full mt-2">
|
||||
<div class="no-crazygames w-full mt-2 relative purchase-btn-wrap">
|
||||
${this.rarity !== "common"
|
||||
? html`<span class="purchase-ember purchase-ember-0"></span>
|
||||
<span class="purchase-ember purchase-ember-1"></span>
|
||||
<span class="purchase-ember purchase-ember-2"></span>
|
||||
<span class="purchase-ember purchase-ember-3"></span>
|
||||
${Array.from(
|
||||
{ length: 40 },
|
||||
(_, i) =>
|
||||
html`<span
|
||||
class="purchase-burst purchase-burst-${i}"
|
||||
></span>`,
|
||||
)}`
|
||||
: null}
|
||||
<button
|
||||
class="w-full px-4 py-2 bg-green-500/20 text-green-400 border border-green-500/30 rounded-lg text-xs font-bold uppercase tracking-wider cursor-pointer transition-all duration-200
|
||||
hover:bg-green-500/30 hover:shadow-[0_0_15px_rgba(74,222,128,0.2)]"
|
||||
class="purchase-sparkle-btn relative overflow-hidden w-full px-4 py-2 bg-green-500/20 text-green-400 border border-green-500/30 rounded-lg text-xs font-bold uppercase tracking-wider cursor-pointer transition-all duration-200
|
||||
hover:bg-green-500 hover:border-green-400 hover:text-white hover:shadow-[0_0_20px_rgba(74,222,128,0.6)]"
|
||||
@click=${this.handleClick}
|
||||
>
|
||||
<span class="purchase-sparkle-streak"></span>
|
||||
${translateText("territory_patterns.purchase")}
|
||||
<span class="ml-1 text-white/60">(${this.product.price})</span>
|
||||
<span class="ml-1 text-white/50">(${this.product.price})</span>
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
@@ -51,8 +51,17 @@ export const ColorPaletteSchema = z.object({
|
||||
secondaryColor: z.string(),
|
||||
});
|
||||
|
||||
export const PatternSchema = z.object({
|
||||
const CosmeticSchema = z.object({
|
||||
name: CosmeticNameSchema,
|
||||
affiliateCode: z.string().nullable(),
|
||||
product: ProductSchema.nullable(),
|
||||
artist: z.string().optional(),
|
||||
rarity: z
|
||||
.enum(["common", "uncommon", "rare", "epic", "legendary"])
|
||||
.or(z.string()),
|
||||
});
|
||||
|
||||
export const PatternSchema = CosmeticSchema.extend({
|
||||
pattern: PatternDataSchema,
|
||||
colorPalettes: z
|
||||
.object({
|
||||
@@ -61,17 +70,10 @@ export const PatternSchema = z.object({
|
||||
})
|
||||
.array()
|
||||
.optional(),
|
||||
affiliateCode: z.string().nullable(),
|
||||
product: ProductSchema.nullable(),
|
||||
artist: z.string().optional(),
|
||||
});
|
||||
|
||||
export const FlagSchema = z.object({
|
||||
name: CosmeticNameSchema,
|
||||
export const FlagSchema = CosmeticSchema.extend({
|
||||
url: z.string(),
|
||||
affiliateCode: z.string().nullable(),
|
||||
product: ProductSchema.nullable(),
|
||||
artist: z.string().optional(),
|
||||
});
|
||||
|
||||
// Schema for resources/cosmetics/cosmetics.json
|
||||
|
||||
+50
-17
@@ -6,7 +6,6 @@ import {
|
||||
pattern,
|
||||
resolveConfusablesTransformer,
|
||||
resolveLeetSpeakTransformer,
|
||||
skipNonAlphabeticTransformer,
|
||||
toAsciiLowerCaseTransformer,
|
||||
} from "obscenity";
|
||||
import countries from "resources/countries.json";
|
||||
@@ -47,31 +46,52 @@ export const shadowNames = [
|
||||
"AlmostPottyTrained",
|
||||
];
|
||||
|
||||
export function createMatcher(bannedWords: string[]): RegExpMatcher {
|
||||
const customDataset = new DataSet<{ originalWord: string }>().addAll(
|
||||
function buildDataset(bannedWords: string[], dedup: boolean) {
|
||||
const dataset = new DataSet<{ originalWord: string }>().addAll(
|
||||
englishDataset,
|
||||
);
|
||||
|
||||
for (const word of bannedWords) {
|
||||
try {
|
||||
customDataset.addPhrase((phrase) =>
|
||||
phrase.setMetadata({ originalWord: word }).addPattern(pattern`${word}`),
|
||||
const w = dedup ? word.toLowerCase().replace(/(.)\1+/g, "$1") : word;
|
||||
dataset.addPhrase((phrase) =>
|
||||
phrase.setMetadata({ originalWord: word }).addPattern(pattern`${w}`),
|
||||
);
|
||||
} catch (e) {
|
||||
console.error(`Invalid banned word pattern "${word}": ${e}`);
|
||||
}
|
||||
}
|
||||
return dataset.build();
|
||||
}
|
||||
|
||||
return new RegExpMatcher({
|
||||
...customDataset.build(),
|
||||
export function createMatcher(bannedWords: string[]): RegExpMatcher {
|
||||
const baseTransformers = [
|
||||
toAsciiLowerCaseTransformer(),
|
||||
resolveConfusablesTransformer(),
|
||||
resolveLeetSpeakTransformer(),
|
||||
];
|
||||
// substringMatcher: literal patterns, no collapse — catches "niggertesting" as a substring
|
||||
// collapseMatcher: deduped patterns + collapse transformer — catches "niiiigger", "hiiitler"
|
||||
const substringMatcher = new RegExpMatcher({
|
||||
...buildDataset(bannedWords, false),
|
||||
blacklistMatcherTransformers: baseTransformers,
|
||||
});
|
||||
const collapseMatcher = new RegExpMatcher({
|
||||
...buildDataset(bannedWords, true),
|
||||
blacklistMatcherTransformers: [
|
||||
toAsciiLowerCaseTransformer(),
|
||||
resolveConfusablesTransformer(),
|
||||
resolveLeetSpeakTransformer(),
|
||||
...baseTransformers,
|
||||
collapseDuplicatesTransformer(),
|
||||
skipNonAlphabeticTransformer(),
|
||||
],
|
||||
});
|
||||
return {
|
||||
hasMatch: (input: string) =>
|
||||
input.toLowerCase().includes("kkk") ||
|
||||
substringMatcher.hasMatch(input) ||
|
||||
collapseMatcher.hasMatch(input),
|
||||
getAllMatches: (input: string, sorted?: boolean) => [
|
||||
...substringMatcher.getAllMatches(input, sorted),
|
||||
...collapseMatcher.getAllMatches(input, sorted),
|
||||
],
|
||||
} as unknown as RegExpMatcher;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -100,9 +120,19 @@ function censorWithMatcher(
|
||||
? shadowNames[simpleHash(username) % shadowNames.length]
|
||||
: username;
|
||||
|
||||
const clanTagIsProfane = clanTag ? matcher.hasMatch(clanTag) : false;
|
||||
const censoredClanTag =
|
||||
clanTag && !clanTagIsProfane ? clanTag.toUpperCase() : null;
|
||||
const clanTagIsProfane = clanTag
|
||||
? matcher.hasMatch(clanTag) || clanTag.toLowerCase() === "ss"
|
||||
: false;
|
||||
const usernameIsProfane = matcher.hasMatch(nameWithoutClan);
|
||||
|
||||
const censoredName = usernameIsProfane
|
||||
? shadowNames[simpleHash(nameWithoutClan) % shadowNames.length]
|
||||
: nameWithoutClan;
|
||||
|
||||
// Restore clan tag only if it's clean, otherwise remove it entirely
|
||||
if (clanTag && !clanTagIsProfane) {
|
||||
return `[${clanTag.toUpperCase()}] ${censoredName}`;
|
||||
}
|
||||
|
||||
return { username: censoredName, clanTag: censoredClanTag };
|
||||
}
|
||||
@@ -242,8 +272,11 @@ export class PrivilegeCheckerImpl implements PrivilegeChecker {
|
||||
}
|
||||
}
|
||||
|
||||
// Default matcher with no custom banned words (just englishDataset)
|
||||
const defaultMatcher = createMatcher([]);
|
||||
// Words the englishDataset misses or only catches as standalone tokens.
|
||||
// These are always enforced even when the remote banned-words list is unavailable.
|
||||
const baselineBannedWords = ["nigger", "nigga", "chink", "spic", "kike"];
|
||||
|
||||
const defaultMatcher = createMatcher(baselineBannedWords);
|
||||
|
||||
export class FailOpenPrivilegeChecker implements PrivilegeChecker {
|
||||
isAllowed(flares: string[], refs: PlayerCosmeticRefs): CosmeticResult {
|
||||
|
||||
+117
-37
@@ -12,6 +12,13 @@ const bannedWords = [
|
||||
"auschwitz",
|
||||
"whitepower",
|
||||
"heil",
|
||||
"nigger",
|
||||
"nigga",
|
||||
"chink",
|
||||
"spic",
|
||||
"kike",
|
||||
"faggot",
|
||||
"retard",
|
||||
"chair", // Test word to verify custom banned words work
|
||||
];
|
||||
|
||||
@@ -36,6 +43,7 @@ const flagCosmetics = {
|
||||
url: "https://example.com/cool.png",
|
||||
affiliateCode: null,
|
||||
product: { productId: "prod_1", priceId: "price_1", price: "$4.99" },
|
||||
rarity: "common",
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -51,45 +59,84 @@ describe("UsernameCensor", () => {
|
||||
expect(matcher.hasMatch("hitler")).toBe(true);
|
||||
expect(matcher.hasMatch("nazi")).toBe(true);
|
||||
expect(matcher.hasMatch("auschwitz")).toBe(true);
|
||||
});
|
||||
|
||||
test("detects custom banned words like 'chair'", () => {
|
||||
expect(matcher.hasMatch("chair")).toBe(true);
|
||||
expect(matcher.hasMatch("Chair")).toBe(true);
|
||||
expect(matcher.hasMatch("CHAIR")).toBe(true);
|
||||
expect(matcher.hasMatch("MyChairName")).toBe(true);
|
||||
expect(matcher.hasMatch("nigger")).toBe(true);
|
||||
expect(matcher.hasMatch("nigga")).toBe(true);
|
||||
expect(matcher.hasMatch("chink")).toBe(true);
|
||||
expect(matcher.hasMatch("spic")).toBe(true);
|
||||
expect(matcher.hasMatch("kike")).toBe(true);
|
||||
expect(matcher.hasMatch("faggot")).toBe(true);
|
||||
expect(matcher.hasMatch("retard")).toBe(true);
|
||||
});
|
||||
|
||||
test("detects banned words case-insensitively", () => {
|
||||
expect(matcher.hasMatch("Hitler")).toBe(true);
|
||||
expect(matcher.hasMatch("NAZI")).toBe(true);
|
||||
expect(matcher.hasMatch("Adolf")).toBe(true);
|
||||
expect(matcher.hasMatch("NIGGER")).toBe(true);
|
||||
expect(matcher.hasMatch("Nigga")).toBe(true);
|
||||
expect(matcher.hasMatch("FAGGOT")).toBe(true);
|
||||
expect(matcher.hasMatch("Retard")).toBe(true);
|
||||
});
|
||||
|
||||
test("detects banned words with leet speak", () => {
|
||||
expect(matcher.hasMatch("h1tl3r")).toBe(true);
|
||||
expect(matcher.hasMatch("4d0lf")).toBe(true);
|
||||
expect(matcher.hasMatch("n4z1")).toBe(true);
|
||||
expect(matcher.hasMatch("n1gg3r")).toBe(true);
|
||||
expect(matcher.hasMatch("f4gg0t")).toBe(true);
|
||||
expect(matcher.hasMatch("r3t4rd")).toBe(true);
|
||||
});
|
||||
|
||||
test("detects banned words with duplicated characters", () => {
|
||||
expect(matcher.hasMatch("hiiitler")).toBe(true);
|
||||
expect(matcher.hasMatch("naazzii")).toBe(true);
|
||||
expect(matcher.hasMatch("niiiigger")).toBe(true);
|
||||
expect(matcher.hasMatch("faaggot")).toBe(true);
|
||||
});
|
||||
|
||||
test("detects banned words with accented characters", () => {
|
||||
test("detects banned words with accented/confusable characters", () => {
|
||||
expect(matcher.hasMatch("Adölf")).toBe(true);
|
||||
expect(matcher.hasMatch("nïgger")).toBe(true);
|
||||
});
|
||||
|
||||
test("detects banned words as substrings", () => {
|
||||
expect(matcher.hasMatch("xhitlerx")).toBe(true);
|
||||
expect(matcher.hasMatch("IloveNazi")).toBe(true);
|
||||
// Regression: slur + suffix / prefix must be caught
|
||||
expect(matcher.hasMatch("niggertesting")).toBe(true);
|
||||
expect(matcher.hasMatch("testingnigger")).toBe(true);
|
||||
expect(matcher.hasMatch("xnazix")).toBe(true);
|
||||
expect(matcher.hasMatch("faggotry")).toBe(true);
|
||||
expect(matcher.hasMatch("retarded")).toBe(true);
|
||||
expect(matcher.hasMatch("MyChairName")).toBe(true);
|
||||
});
|
||||
|
||||
test("detects banned words with underscores/dots/numbers mixed in", () => {
|
||||
// These should NOT bypass the filter (skipNonAlphabetic was intentionally removed)
|
||||
// Words separated by non-alpha chars are treated as separate tokens
|
||||
expect(matcher.hasMatch("n.i.g.g.e.r")).toBe(false); // dots break the word
|
||||
expect(matcher.hasMatch("hi_tler")).toBe(false); // underscore breaks it
|
||||
});
|
||||
|
||||
test("allows clean usernames", () => {
|
||||
expect(matcher.hasMatch("CoolPlayer")).toBe(false);
|
||||
expect(matcher.hasMatch("GameMaster")).toBe(false);
|
||||
expect(matcher.hasMatch("xXx_Sniper_xXx")).toBe(false);
|
||||
expect(matcher.hasMatch("ProGamer123")).toBe(false);
|
||||
expect(matcher.hasMatch("NightOwl")).toBe(false);
|
||||
expect(matcher.hasMatch("DragonSlayer")).toBe(false);
|
||||
});
|
||||
|
||||
test("does not false-positive on words containing banned substrings legitimately", () => {
|
||||
// "snigger" is whitelisted in englishDataset
|
||||
expect(matcher.hasMatch("snigger")).toBe(false);
|
||||
});
|
||||
|
||||
test("catches kkk as substring", () => {
|
||||
expect(matcher.hasMatch("kkk")).toBe(true);
|
||||
expect(matcher.hasMatch("KKK")).toBe(true);
|
||||
expect(matcher.hasMatch("kkklover")).toBe(true);
|
||||
expect(matcher.hasMatch("ilovekkkboys")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -115,40 +162,73 @@ describe("UsernameCensor", () => {
|
||||
expect(shadowNames).toContain(result.username);
|
||||
});
|
||||
|
||||
test("removes profane clan tag but keeps clean username", () => {
|
||||
const result = checker.censor("CoolPlayer", "NAZI");
|
||||
expect(result.username).toBe("CoolPlayer");
|
||||
expect(result.clanTag).toBeNull();
|
||||
});
|
||||
describe("clan tag censoring", () => {
|
||||
test("removes profane clan tag, keeps clean username", () => {
|
||||
expect(checker.censorUsername("[NAZI]CoolPlayer")).toBe("CoolPlayer");
|
||||
expect(checker.censorUsername("[ADOLF]CoolPlayer")).toBe("CoolPlayer");
|
||||
expect(checker.censorUsername("[HEIL]CoolPlayer")).toBe("CoolPlayer");
|
||||
});
|
||||
|
||||
test("removes clan tag with leet speak profanity", () => {
|
||||
const result = checker.censor("CoolPlayer", "N4Z1");
|
||||
expect(result.username).toBe("CoolPlayer");
|
||||
expect(result.clanTag).toBeNull();
|
||||
});
|
||||
test("removes clan tag that is a slur abbreviation", () => {
|
||||
// [NIG] is caught as a standalone word by englishDataset's |nig| pattern
|
||||
expect(checker.censorUsername("[NIG]CoolPlayer")).toBe("CoolPlayer");
|
||||
expect(checker.censorUsername("[NIGG]CoolPlayer")).toBe("CoolPlayer");
|
||||
});
|
||||
|
||||
test("removes clan tag with uppercased banned word", () => {
|
||||
const result = checker.censor("CoolPlayer", "ADOLF");
|
||||
expect(result.username).toBe("CoolPlayer");
|
||||
expect(result.clanTag).toBeNull();
|
||||
});
|
||||
test("removes clan tag containing full slur (≤5 chars)", () => {
|
||||
// Clan tags are capped at 5 chars — only slurs that fit are catchable this way
|
||||
expect(checker.censorUsername("[NIGGA]CoolPlayer")).toBe("CoolPlayer");
|
||||
expect(checker.censorUsername("[CHINK]CoolPlayer")).toBe("CoolPlayer");
|
||||
expect(checker.censorUsername("[SPIC]CoolPlayer")).toBe("CoolPlayer");
|
||||
expect(checker.censorUsername("[KIKE]CoolPlayer")).toBe("CoolPlayer");
|
||||
});
|
||||
|
||||
test("removes clan tag containing banned word substring", () => {
|
||||
const result = checker.censor("CoolPlayer", "JEWS");
|
||||
expect(result.username).toBe("CoolPlayer");
|
||||
expect(result.clanTag).toBeNull();
|
||||
});
|
||||
test("removes clan tag with leet speak profanity (≤5 chars)", () => {
|
||||
expect(checker.censorUsername("[N4Z1]CoolPlayer")).toBe("CoolPlayer");
|
||||
});
|
||||
|
||||
test("removes profane clan tag and censors profane username", () => {
|
||||
const result = checker.censor("hitler", "NAZI");
|
||||
expect(result.clanTag).toBeNull();
|
||||
expect(shadowNames).toContain(result.username);
|
||||
});
|
||||
test("removes clan tag containing banned word as substring (≤5 chars)", () => {
|
||||
expect(checker.censorUsername("[JEWS]CoolPlayer")).toBe("CoolPlayer");
|
||||
expect(checker.censorUsername("[NAZI]CoolPlayer")).toBe("CoolPlayer");
|
||||
});
|
||||
|
||||
test("removes leet speak profane clan tag and censors leet speak username", () => {
|
||||
const result = checker.censor("h1tl3r", "N4Z1");
|
||||
expect(result.clanTag).toBeNull();
|
||||
expect(shadowNames).toContain(result.username);
|
||||
test("removes [SS] clan tag", () => {
|
||||
expect(checker.censorUsername("[SS]Player")).toBe("Player");
|
||||
expect(checker.censorUsername("[ss]Player")).toBe("Player");
|
||||
});
|
||||
|
||||
test("removes [KKK] clan tag", () => {
|
||||
expect(checker.censorUsername("[KKK]Player")).toBe("Player");
|
||||
});
|
||||
|
||||
test("keeps clean clan tag when username is clean", () => {
|
||||
expect(checker.censorUsername("[COOL]Player")).toBe("[COOL] Player");
|
||||
expect(checker.censorUsername("[PRO]Player")).toBe("[PRO] Player");
|
||||
});
|
||||
|
||||
test("keeps clean clan tag, censors profane username", () => {
|
||||
const result = checker.censorUsername("[COOL]nigger");
|
||||
expect(result).toMatch(/^\[COOL\] /);
|
||||
expect(shadowNames).toContain(result.replace("[COOL] ", ""));
|
||||
});
|
||||
|
||||
test("removes profane clan tag and censors profane username", () => {
|
||||
const result = checker.censorUsername("[NAZI]hitler");
|
||||
expect(shadowNames).toContain(result);
|
||||
expect(result).not.toContain("[");
|
||||
});
|
||||
|
||||
test("removes profane clan tag and censors leet speak username", () => {
|
||||
const result = checker.censorUsername("[N4Z1]h1tl3r");
|
||||
expect(shadowNames).toContain(result);
|
||||
expect(result).not.toContain("[");
|
||||
});
|
||||
|
||||
test("removes profane clan tag with slur, censors profane username", () => {
|
||||
const result = checker.censorUsername("[NIG]nigger");
|
||||
expect(shadowNames).toContain(result);
|
||||
expect(result).not.toContain("[");
|
||||
});
|
||||
});
|
||||
|
||||
test("returns deterministic shadow name for same input", () => {
|
||||
|
||||
Reference in New Issue
Block a user