From 2d28bfcd017cea7dbca8ca84750ec9cc21ddc218 Mon Sep 17 00:00:00 2001 From: Evan Date: Mon, 6 Apr 2026 11:38:24 -0700 Subject: [PATCH] Add Rarity to cosmetic items (#3587) ## Description: https://github.com/user-attachments/assets/f2216dec-72aa-497a-89cc-169c2a40021e * Fortnite-style rarity system for cosmetics: New CosmeticContainer component applies tier-based visual styling (gradient backgrounds, glowing borders, hover effects) to flag and pattern cards across 5 rarity tiers: Common, Uncommon, Rare, Epic, and Legendary * Legendary hover effects: Scale-up animation, pulsing orange glow, shimmer sweep, rotating border sweep, corner sparkles, and screen dimming backdrop * Epic hover effects: Purple shimmer sweep glint on hover * Purchase button overhaul: Green ember particles on container hover (non-common only), 40-particle burst stream on button hover, pulsating green glow, shimmer streak animation, and loading spinner on click * Clickable cosmetic cards: Clicking anywhere on a purchasable card (not just the purchase button) triggers the purchase flow * Refactored components: ArtistInfo renamed to CosmeticInfo (now shows rarity and color palette in tooltip), * Forward-compatible rarity schema: rarity field uses .or(z.string()) so unknown backend values won't break the client ## 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 --- resources/lang/en.json | 8 +- src/client/FlagInputModal.ts | 9 +- src/client/Store.ts | 12 +- src/client/TerritoryPatternsModal.ts | 2 +- src/client/components/ArtistInfo.ts | 37 -- src/client/components/CosmeticContainer.ts | 432 +++++++++++++++++++++ src/client/components/CosmeticInfo.ts | 74 ++++ src/client/components/FlagButton.ts | 63 ++- src/client/components/PatternButton.ts | 83 ++-- src/client/components/PurchaseButton.ts | 178 ++++++++- src/core/CosmeticSchemas.ts | 20 +- tests/Privilege.test.ts | 1 + 12 files changed, 751 insertions(+), 168 deletions(-) delete mode 100644 src/client/components/ArtistInfo.ts create mode 100644 src/client/components/CosmeticContainer.ts create mode 100644 src/client/components/CosmeticInfo.ts diff --git a/resources/lang/en.json b/resources/lang/en.json index e32ce0f9f..00aee7e70 100644 --- a/resources/lang/en.json +++ b/resources/lang/en.json @@ -920,7 +920,13 @@ "selected": "selected" }, "cosmetics": { - "artist_label": "Artist:" + "artist_label": "Artist:", + "color_label": "Color:", + "common": "Common", + "uncommon": "Uncommon", + "rare": "Rare", + "epic": "Epic", + "legendary": "Legendary" }, "flag_input": { "title": "Select Flag", diff --git a/src/client/FlagInputModal.ts b/src/client/FlagInputModal.ts index ff27763f8..8ad0e3856 100644 --- a/src/client/FlagInputModal.ts +++ b/src/client/FlagInputModal.ts @@ -40,12 +40,7 @@ export class FlagInputModal extends BaseModal { .map( ([key, flag]) => html` @@ -87,7 +82,7 @@ export class FlagInputModal extends BaseModal { return html`
${noFlag} ${cosmeticFlags} ${countryFlags}
diff --git a/src/client/Store.ts b/src/client/Store.ts index d6d469518..b241345b7 100644 --- a/src/client/Store.ts +++ b/src/client/Store.ts @@ -159,7 +159,7 @@ export class StoreModal extends BaseModal { return html`
${buttons}
@@ -179,13 +179,7 @@ export class StoreModal extends BaseModal { const selectedFlag = new UserSettings().getFlag() ?? ""; buttons.push(html` handlePurchase(flag.product!)} @@ -203,7 +197,7 @@ export class StoreModal extends BaseModal { return html`
${buttons}
diff --git a/src/client/TerritoryPatternsModal.ts b/src/client/TerritoryPatternsModal.ts index 9d9c995ed..9f8c9f6d9 100644 --- a/src/client/TerritoryPatternsModal.ts +++ b/src/client/TerritoryPatternsModal.ts @@ -115,7 +115,7 @@ export class TerritoryPatternsModal extends BaseModal { return html`
${buttons}
diff --git a/src/client/components/ArtistInfo.ts b/src/client/components/ArtistInfo.ts deleted file mode 100644 index 3a55856aa..000000000 --- a/src/client/components/ArtistInfo.ts +++ /dev/null @@ -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` -
e.stopPropagation()} - > -
- ? -
- -
- `; - } -} diff --git a/src/client/components/CosmeticContainer.ts b/src/client/components/CosmeticContainer.ts new file mode 100644 index 000000000..b6bf60bf4 --- /dev/null +++ b/src/client/components/CosmeticContainer.ts @@ -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 = { + 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 = `
`; + 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` + + ${this.product && this.onPurchase + ? html`` + : null} + `; + } +} diff --git a/src/client/components/CosmeticInfo.ts b/src/client/components/CosmeticInfo.ts new file mode 100644 index 000000000..5958c46ec --- /dev/null +++ b/src/client/components/CosmeticInfo.ts @@ -0,0 +1,74 @@ +import { html, LitElement, nothing } from "lit"; +import { customElement, property } from "lit/decorators.js"; +import { translateCosmetic } from "../Cosmetics"; +import { translateText } from "../Utils"; + +const rarityColors: Record = { + 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` +
e.stopPropagation()} + > +
+ ? +
+ +
+ `; + } +} diff --git a/src/client/components/FlagButton.ts b/src/client/components/FlagButton.ts index 938087ef2..2e209991c 100644 --- a/src/client/components/FlagButton.ts +++ b/src/client/components/FlagButton.ts @@ -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` -
this.onPurchase?.()} + .name=${translateCosmetic("flags", this.flag.name)} > - - ${this.requiresPurchase && this.flag.product - ? html` - this.onPurchase?.()} - > - ` - : null} -
+ `; } } diff --git a/src/client/components/PatternButton.ts b/src/client/components/PatternButton.ts index fb9aba4c8..6f52633ca 100644 --- a/src/client/components/PatternButton.ts +++ b/src/client/components/PatternButton.ts @@ -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` -
this.handlePurchase()} + .name=${isDefaultPattern + ? translateText("territory_patterns.pattern.default") + : translateCosmetic("territory_patterns.pattern", this.pattern!.name)} > - - ${this.requiresPurchase && this.pattern?.product - ? html` - this.handlePurchase()} - > - ` - : null} -
+ `; } } diff --git a/src/client/components/PurchaseButton.ts b/src/client/components/PurchaseButton.ts index 47bacf4b1..e2c08817a 100644 --- a/src/client/components/PurchaseButton.ts +++ b/src/client/components/PurchaseButton.ts @@ -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 = `
`; + container.appendChild(overlay); + } + Promise.resolve(this.onPurchase?.()).catch(() => { + container?.querySelector(".cosmetic-loading-overlay")?.remove(); + }); } render() { return html` -
+
+ ${this.rarity !== "common" + ? html` + + + + ${Array.from( + { length: 40 }, + (_, i) => + html``, + )}` + : null}
`; diff --git a/src/core/CosmeticSchemas.ts b/src/core/CosmeticSchemas.ts index 586a44055..bd808cdcc 100644 --- a/src/core/CosmeticSchemas.ts +++ b/src/core/CosmeticSchemas.ts @@ -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 diff --git a/tests/Privilege.test.ts b/tests/Privilege.test.ts index 3d70bcf5a..dbf471871 100644 --- a/tests/Privilege.test.ts +++ b/tests/Privilege.test.ts @@ -43,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", }, }, };