Add support to purchase cosmetics with in-game currency (#3648)

## Description:

Caps & Plutonium can be used to purchase different cosmetics. 

* The cosmetic button can display pluto/caps/dollars
* Create a "purchaseCosmetic" helper function that handles purchase
logic

## Please complete the following:

- [x] I have added screenshots for all UI updates
- [x] I process any text displayed to the user through translateText()
and I've added it to the en.json file
- [x] I have added relevant tests to the test directory
- [x] I confirm I have thoroughly tested these changes and take full
responsibility for any bugs introduced

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

evan
This commit is contained in:
Evan
2026-04-13 10:19:43 -07:00
committed by GitHub
parent 9f1d0207ce
commit 616ba1c794
12 changed files with 342 additions and 48 deletions
+6 -1
View File
@@ -948,7 +948,12 @@
"no_flags": "No flags available. Check back later for new items.",
"no_skins": "No skins available. Check back later for new items.",
"no_packs": "No packs available. Check back later for new items.",
"currency_pack_purchase_success": "Currency pack purchase successful!"
"currency_pack_purchase_success": "Currency pack purchase successful!",
"checkout_failed": "Failed to create checkout session.",
"login_required": "You must be logged in to purchase with currency.",
"not_enough_currency": "Not enough currency for this purchase.",
"purchase_failed": "Purchase failed. Please try again.",
"purchase_success": "Purchase succeeded: {name}"
},
"territory_patterns": {
"title": "Skins",
+43
View File
@@ -92,6 +92,49 @@ export async function getUserMe(): Promise<UserMeResponse | false> {
return __userMe;
}
export function invalidateUserMe() {
__userMe = null;
}
export async function purchaseWithCurrency(
cosmeticType: "pattern" | "skin" | "flag",
cosmeticName: string,
currencyType: "hard" | "soft",
colorPaletteName?: string,
): Promise<boolean> {
try {
const response = await fetch(`${getApiBase()}/shop/purchase`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: await getAuthHeader(),
},
body: JSON.stringify({
cosmeticType,
cosmeticName,
currencyType,
colorPaletteName,
}),
});
if (response.status === 401) {
await logOut();
return false;
}
if (!response.ok) {
console.error(
"purchaseWithCurrency: request failed",
response.status,
response.statusText,
);
return false;
}
return true;
} catch (e) {
console.error("purchaseWithCurrency: request failed", e);
return false;
}
}
export async function createCheckoutSession(
priceId: string,
colorPaletteName?: string,
+74 -11
View File
@@ -15,7 +15,13 @@ import {
PlayerPattern,
} from "../core/Schemas";
import { UserSettings } from "../core/game/UserSettings";
import { createCheckoutSession, getApiBase, getUserMe } from "./Api";
import {
createCheckoutSession,
getApiBase,
getUserMe,
invalidateUserMe,
purchaseWithCurrency,
} from "./Api";
import { translateText } from "./Utils";
export const TEMP_FLARE_OFFSET = 1 * 60 * 1000; // 1 minute
@@ -23,17 +29,63 @@ export const TEMP_FLARE_OFFSET = 1 * 60 * 1000; // 1 minute
let __cosmetics: Promise<Cosmetics | null> | null = null;
let __cosmeticsHash: string | null = null;
export async function handlePurchase(
product: Product,
colorPaletteName?: string,
) {
const url = await createCheckoutSession(product.priceId, colorPaletteName);
if (url === false) {
alert("Failed to create checkout session.");
export type PaymentMethod = "dollar" | "hard" | "soft";
export async function purchaseCosmetic(
resolved: ResolvedCosmetic,
method: PaymentMethod,
): Promise<void> {
if (!resolved.cosmetic) return;
const c = resolved.cosmetic;
const colorPaletteName = resolved.colorPalette?.name;
if (method === "dollar") {
if (!c.product) {
alert(translateText("store.checkout_failed"));
return;
}
const url = await createCheckoutSession(
c.product.priceId,
colorPaletteName,
);
if (url === false) {
alert(translateText("store.checkout_failed"));
return;
}
window.location.href = url;
return;
}
window.location.href = url;
// Currency purchase (hard or soft)
const price = method === "hard" ? (c.priceHard ?? 0) : (c.priceSoft ?? 0);
const userMe = await getUserMe();
if (userMe === false) {
alert(translateText("store.login_required"));
return;
}
const balance =
method === "hard"
? (userMe.player.currency?.hard ?? 0)
: (userMe.player.currency?.soft ?? 0);
if (balance < price) {
alert(translateText("store.not_enough_currency"));
return;
}
const cosmeticType = resolved.type as "pattern" | "skin" | "flag";
const success = await purchaseWithCurrency(
cosmeticType,
c.name,
method,
colorPaletteName,
);
if (!success) {
alert(translateText("store.purchase_failed"));
return;
}
alert(translateText("store.purchase_success", { name: c.name }));
invalidateUserMe();
window.location.reload();
}
function simpleHash(str: string): string {
@@ -102,6 +154,8 @@ export function cosmeticRelationship(
wildcardFlare: string;
requiredFlare: string;
product: Product | null;
priceSoft?: number;
priceHard?: number;
affiliateCode: string | null;
itemAffiliateCode: string | null;
},
@@ -118,11 +172,16 @@ export function cosmeticRelationship(
return "owned";
}
if (opts.product === null) {
if (opts.affiliateCode !== opts.itemAffiliateCode) {
return "blocked";
}
if (opts.affiliateCode !== opts.itemAffiliateCode) {
// Purchasable if any purchase method is available
if (opts.priceSoft !== undefined || opts.priceHard !== undefined) {
return "purchasable";
}
if (opts.product === null) {
return "blocked";
}
@@ -166,6 +225,8 @@ export function patternRelationship(
wildcardFlare: "pattern:*",
requiredFlare: `pattern:${pattern.name}:${colorPalette.name}`,
product: pattern.product,
priceSoft: pattern.priceSoft,
priceHard: pattern.priceHard,
affiliateCode,
itemAffiliateCode: pattern.affiliateCode,
},
@@ -183,6 +244,8 @@ export function flagRelationship(
wildcardFlare: "flag:*",
requiredFlare: `flag:${flag.name}`,
product: flag.product,
priceSoft: flag.priceSoft,
priceHard: flag.priceHard,
affiliateCode,
itemAffiliateCode: flag.affiliateCode,
},
+4 -8
View File
@@ -10,9 +10,8 @@ import "./components/NotLoggedInWarning";
import { modalHeader } from "./components/ui/ModalHeader";
import {
fetchCosmetics,
handlePurchase,
purchaseCosmetic,
resolveCosmetics,
ResolvedCosmetic,
} from "./Cosmetics";
import { translateText } from "./Utils";
@@ -109,8 +108,7 @@ export class StoreModal extends BaseModal {
(r) => html`
<cosmetic-button
.resolved=${r}
.onPurchase=${(rc: ResolvedCosmetic) =>
handlePurchase(rc.cosmetic!.product!, rc.colorPalette?.name)}
.onPurchase=${purchaseCosmetic}
></cosmetic-button>
`,
)}
@@ -148,8 +146,7 @@ export class StoreModal extends BaseModal {
<cosmetic-button
.resolved=${r}
.selected=${selectedFlag === r.key}
.onPurchase=${(rc: ResolvedCosmetic) =>
handlePurchase(rc.cosmetic!.product!)}
.onPurchase=${purchaseCosmetic}
></cosmetic-button>
`,
)}
@@ -180,8 +177,7 @@ export class StoreModal extends BaseModal {
(r) => html`
<cosmetic-button
.resolved=${r}
.onPurchase=${(rc: ResolvedCosmetic) =>
handlePurchase(rc.cosmetic!.product!)}
.onPurchase=${purchaseCosmetic}
></cosmetic-button>
`,
)}
+18 -4
View File
@@ -2,7 +2,11 @@ import { html, LitElement, nothing, TemplateResult } from "lit";
import { customElement, property } from "lit/decorators.js";
import { Flag, Pack, Pattern } from "../../core/CosmeticSchemas";
import { PlayerPattern } from "../../core/Schemas";
import { ResolvedCosmetic, translateCosmetic } from "../Cosmetics";
import {
PaymentMethod,
ResolvedCosmetic,
translateCosmetic,
} from "../Cosmetics";
import { translateText } from "../Utils";
import "./CapIcon";
import "./CosmeticContainer";
@@ -22,7 +26,7 @@ export class CosmeticButton extends LitElement {
onSelect?: (resolved: ResolvedCosmetic) => void;
@property({ type: Function })
onPurchase?: (resolved: ResolvedCosmetic) => void;
onPurchase?: (resolved: ResolvedCosmetic, method: PaymentMethod) => void;
createRenderRoot() {
return this;
@@ -118,7 +122,17 @@ export class CosmeticButton extends LitElement {
.rarity=${c?.rarity ?? "common"}
.selected=${this.selected}
.product=${isPurchasable && c?.product ? c.product : null}
.onPurchase=${() => this.onPurchase?.(this.resolved)}
.priceHard=${isPurchasable ? (c?.priceHard ?? null) : null}
.priceSoft=${isPurchasable ? (c?.priceSoft ?? null) : null}
.onPurchaseDollar=${isPurchasable && c?.product
? () => this.onPurchase?.(this.resolved, "dollar")
: undefined}
.onPurchaseHard=${isPurchasable && c?.priceHard !== undefined
? () => this.onPurchase?.(this.resolved, "hard")
: undefined}
.onPurchaseSoft=${isPurchasable && c?.priceSoft !== undefined
? () => this.onPurchase?.(this.resolved, "soft")
: undefined}
.name=${this.displayName}
>
<button
@@ -127,7 +141,7 @@ export class CosmeticButton extends LitElement {
: "gap-1"} rounded-lg cursor-pointer transition-all duration-200 flex-1"
@click=${() => this.handleClick()}
>
${c?.product
${(c?.product ?? c?.priceHard ?? c?.priceSoft)
? html`<cosmetic-info
.artist=${c.artist}
.rarity=${c.rarity}
+32 -6
View File
@@ -152,8 +152,20 @@ export class CosmeticContainer extends LitElement {
@property({ type: Object })
product: Product | null = null;
@property({ type: Number })
priceHard: number | null = null;
@property({ type: Number })
priceSoft: number | null = null;
@property({ type: Function })
onPurchase?: () => void;
onPurchaseDollar?: () => void;
@property({ type: Function })
onPurchaseHard?: () => void;
@property({ type: Function })
onPurchaseSoft?: () => void;
private static _backdrop: HTMLDivElement | null = null;
private static _ensureBackdrop(): HTMLDivElement {
@@ -205,7 +217,11 @@ export class CosmeticContainer extends LitElement {
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" : "";
const hasPurchase =
this.product !== null ||
this.priceHard !== null ||
this.priceSoft !== null;
this.style.cursor = hasPurchase ? "pointer" : "";
if (this.selected) {
this.style.boxShadow = `0 0 18px ${cfg.glow}`;
@@ -311,10 +327,16 @@ export class CosmeticContainer extends LitElement {
if (CosmeticContainer._backdrop) {
CosmeticContainer._backdrop.style.background = "rgba(0,0,0,0)";
}
if (this.product && this.onPurchase && !this._loading) {
// Only auto-fire container click when there's exactly one purchase path
const handlers = [
this.onPurchaseDollar,
this.onPurchaseHard,
this.onPurchaseSoft,
].filter(Boolean);
if (handlers.length === 1 && !this._loading) {
this._loading = true;
this._showLoadingOverlay();
Promise.resolve(this.onPurchase()).catch(() => {
Promise.resolve(handlers[0]!()).finally(() => {
this._hideLoadingOverlay();
});
}
@@ -420,11 +442,15 @@ export class CosmeticContainer extends LitElement {
render() {
return html`
<slot></slot>
${this.product && this.onPurchase
${this.product || this.priceHard !== null || this.priceSoft !== null
? html`<purchase-button
.product=${this.product}
.priceHard=${this.priceHard}
.priceSoft=${this.priceSoft}
.rarity=${this.rarity}
.onPurchase=${this.onPurchase}
.onPurchaseDollar=${this.onPurchaseDollar}
.onPurchaseHard=${this.onPurchaseHard}
.onPurchaseSoft=${this.onPurchaseSoft}
></purchase-button>`
: null}
`;
+100 -14
View File
@@ -1,7 +1,9 @@
import { html, LitElement } from "lit";
import { html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators.js";
import { Product } from "../../core/CosmeticSchemas";
import { translateText } from "../Utils";
import "./CapIcon";
import "./PlutoniumIcon";
const PURCHASE_STYLE_ID = "purchase-button-styles";
if (!document.getElementById(PURCHASE_STYLE_ID)) {
@@ -34,6 +36,18 @@ if (!document.getElementById(PURCHASE_STYLE_ID)) {
color: white;
box-shadow: 0 0 20px rgba(74,222,128,0.6);
}
cosmetic-container:hover .purchase-sparkle-btn-hard {
background: rgb(22,163,74);
border-color: rgb(74,222,128);
color: white;
box-shadow: 0 0 20px rgba(74,222,128,0.6);
}
cosmetic-container:hover .purchase-sparkle-btn-soft {
background: rgb(180,83,9);
border-color: rgb(217,119,6);
color: white;
box-shadow: 0 0 20px rgba(217,119,6,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); }
@@ -45,6 +59,23 @@ if (!document.getElementById(PURCHASE_STYLE_ID)) {
color: white !important;
animation: purchase-pulse 1.2s ease-in-out infinite !important;
}
.purchase-sparkle-btn-hard: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-pulse-soft {
0% { box-shadow: 0 0 15px rgba(217,119,6,0.6), 0 0 30px rgba(180,83,9,0.3); }
50% { box-shadow: 0 0 25px rgba(217,119,6,0.9), 0 0 50px rgba(180,83,9,0.5); }
100% { box-shadow: 0 0 15px rgba(217,119,6,0.6), 0 0 30px rgba(180,83,9,0.3); }
}
.purchase-sparkle-btn-soft:hover {
background: rgb(180,83,9) !important;
border-color: rgb(217,119,6) !important;
color: white !important;
animation: purchase-pulse-soft 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; }
@@ -148,20 +179,33 @@ if (!document.getElementById(PURCHASE_STYLE_ID)) {
@customElement("purchase-button")
export class PurchaseButton extends LitElement {
@property({ type: Object })
product!: Product;
product: Product | null = null;
@property({ type: Number })
priceHard: number | null = null;
@property({ type: Number })
priceSoft: number | null = null;
@property({ type: String })
rarity: string = "common";
@property({ type: Function })
onPurchase?: () => void;
onPurchaseDollar?: () => void;
@property({ type: Function })
onPurchaseHard?: () => void;
@property({ type: Function })
onPurchaseSoft?: () => void;
createRenderRoot() {
return this;
}
private handleClick(e: Event) {
private handleClick(e: Event, handler?: () => void) {
e.stopPropagation();
if (!handler) return;
const container = this.closest("cosmetic-container") as HTMLElement | null;
if (container && !container.querySelector(".cosmetic-loading-overlay")) {
const overlay = document.createElement("div");
@@ -169,12 +213,58 @@ export class PurchaseButton extends LitElement {
overlay.innerHTML = `<div class="cosmetic-loading-spinner"></div>`;
container.appendChild(overlay);
}
Promise.resolve(this.onPurchase?.()).catch(() => {
Promise.resolve(handler()).finally(() => {
container?.querySelector(".cosmetic-loading-overlay")?.remove();
});
}
private renderDollarButton() {
return html`
<button
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=${(e: Event) => this.handleClick(e, this.onPurchaseDollar)}
>
<span class="purchase-sparkle-streak"></span>
${translateText("territory_patterns.purchase")}
<span class="ml-1 text-white/50">(${this.product!.price})</span>
</button>
`;
}
private renderHardButton() {
return html`
<button
class="purchase-sparkle-btn-hard relative overflow-hidden w-full px-2 py-1.5 bg-green-500/20 text-green-400 border border-green-500/30 rounded-lg text-base font-bold cursor-pointer transition-all duration-200 flex items-center justify-center gap-2
hover:bg-green-500 hover:border-green-400 hover:text-white hover:shadow-[0_0_20px_rgba(74,222,128,0.6)]"
@click=${(e: Event) => this.handleClick(e, this.onPurchaseHard)}
>
<plutonium-icon .size=${20} style="margin-top:3px"></plutonium-icon>
${this.priceHard!.toLocaleString()}
</button>
`;
}
private renderSoftButton() {
return html`
<button
class="purchase-sparkle-btn-soft relative overflow-hidden w-full px-2 py-1.5 bg-amber-700/20 text-amber-600 border border-amber-700/30 rounded-lg text-base font-bold cursor-pointer transition-all duration-200 flex items-center justify-center gap-2
hover:bg-amber-700 hover:border-amber-600 hover:text-white hover:shadow-[0_0_20px_rgba(217,119,6,0.6)]"
@click=${(e: Event) => this.handleClick(e, this.onPurchaseSoft)}
>
<cap-icon .size=${22} style="margin-top:3px"></cap-icon>
${this.priceSoft!.toLocaleString()}
</button>
`;
}
render() {
const hasDollar = this.product && this.onPurchaseDollar;
const hasHard = this.priceHard !== null && this.onPurchaseHard;
const hasSoft = this.priceSoft !== null && this.onPurchaseSoft;
if (!hasDollar && !hasHard && !hasSoft) return nothing;
return html`
<div class="no-crazygames w-full mt-2 relative purchase-btn-wrap">
${this.rarity !== "common"
@@ -190,15 +280,11 @@ export class PurchaseButton extends LitElement {
></span>`,
)}`
: null}
<button
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/50">(${this.product.price})</span>
</button>
<div class="flex flex-col gap-1 w-full">
${hasDollar ? this.renderDollarButton() : null}
${hasHard ? this.renderHardButton() : null}
${hasSoft ? this.renderSoftButton() : null}
</div>
</div>
`;
}
+2 -4
View File
@@ -14,9 +14,8 @@ import { getUserMe } from "../../Api";
import "../../components/CosmeticButton";
import {
fetchCosmetics,
handlePurchase,
purchaseCosmetic,
resolveCosmetics,
ResolvedCosmetic,
} from "../../Cosmetics";
import { crazyGamesSDK } from "../../CrazyGamesSDK";
import { Platform } from "../../Platform";
@@ -179,8 +178,7 @@ export class WinModal extends LitElement implements Layer {
(r) => html`
<cosmetic-button
.resolved=${r}
.onPurchase=${(rc: ResolvedCosmetic) =>
handlePurchase(rc.cosmetic!.product!, rc.colorPalette?.name)}
.onPurchase=${purchaseCosmetic}
></cosmetic-button>
`,
)}
+2
View File
@@ -56,6 +56,8 @@ const CosmeticSchema = z.object({
name: CosmeticNameSchema,
affiliateCode: z.string().nullable(),
product: ProductSchema.nullable(),
priceSoft: z.number().optional(),
priceHard: z.number().optional(),
artist: z.string().optional(),
rarity: z
.enum(["common", "uncommon", "rare", "epic", "legendary"])
+51
View File
@@ -17,6 +17,8 @@ describe("cosmeticRelationship", () => {
wildcardFlare: "flag:*",
requiredFlare: "flag:cool",
product,
priceSoft: undefined,
priceHard: undefined,
affiliateCode: null,
itemAffiliateCode: null,
},
@@ -32,6 +34,8 @@ describe("cosmeticRelationship", () => {
wildcardFlare: "flag:*",
requiredFlare: "flag:cool",
product,
priceSoft: undefined,
priceHard: undefined,
affiliateCode: null,
itemAffiliateCode: null,
},
@@ -47,6 +51,8 @@ describe("cosmeticRelationship", () => {
wildcardFlare: "flag:*",
requiredFlare: "flag:cool",
product: null,
priceSoft: undefined,
priceHard: undefined,
affiliateCode: null,
itemAffiliateCode: null,
},
@@ -62,6 +68,8 @@ describe("cosmeticRelationship", () => {
wildcardFlare: "flag:*",
requiredFlare: "flag:cool",
product,
priceSoft: undefined,
priceHard: undefined,
affiliateCode: "storeA",
itemAffiliateCode: "storeB",
},
@@ -77,6 +85,8 @@ describe("cosmeticRelationship", () => {
wildcardFlare: "flag:*",
requiredFlare: "flag:cool",
product,
priceSoft: undefined,
priceHard: undefined,
affiliateCode: null,
itemAffiliateCode: null,
},
@@ -92,6 +102,8 @@ describe("cosmeticRelationship", () => {
wildcardFlare: "pattern:*",
requiredFlare: "pattern:stripes:red",
product,
priceSoft: undefined,
priceHard: undefined,
affiliateCode: "storeA",
itemAffiliateCode: "storeA",
},
@@ -107,6 +119,8 @@ describe("cosmeticRelationship", () => {
wildcardFlare: "flag:*",
requiredFlare: "flag:cool",
product: null,
priceSoft: undefined,
priceHard: undefined,
affiliateCode: null,
itemAffiliateCode: null,
},
@@ -122,6 +136,8 @@ describe("cosmeticRelationship", () => {
wildcardFlare: "flag:*",
requiredFlare: "flag:cool",
product,
priceSoft: undefined,
priceHard: undefined,
affiliateCode: null,
itemAffiliateCode: null,
},
@@ -130,6 +146,39 @@ describe("cosmeticRelationship", () => {
).toBe("purchasable");
});
it("returns purchasable when item has currency price and no product", () => {
expect(
cosmeticRelationship(
{
wildcardFlare: "flag:*",
requiredFlare: "flag:cool",
product: null,
priceSoft: 100,
affiliateCode: null,
itemAffiliateCode: null,
},
makeUserMe([]),
),
).toBe("purchasable");
});
it("returns blocked when item has currency price but affiliate codes do not match", () => {
expect(
cosmeticRelationship(
{
wildcardFlare: "flag:*",
requiredFlare: "flag:cool",
product: null,
priceSoft: 100,
priceHard: 50,
affiliateCode: "storeA",
itemAffiliateCode: "storeB",
},
makeUserMe([]),
),
).toBe("blocked");
});
it("returns owned when user has wildcard flare for patterns", () => {
expect(
cosmeticRelationship(
@@ -137,6 +186,8 @@ describe("cosmeticRelationship", () => {
wildcardFlare: "pattern:*",
requiredFlare: "pattern:stripes:red",
product,
priceSoft: undefined,
priceHard: undefined,
affiliateCode: null,
itemAffiliateCode: null,
},
+2
View File
@@ -44,6 +44,8 @@ const flagCosmetics = {
url: "https://example.com/cool.png",
affiliateCode: null,
product: { productId: "prod_1", priceId: "price_1", price: "$4.99" },
priceSoft: undefined,
priceHard: undefined,
rarity: "common",
},
},
+8
View File
@@ -47,6 +47,8 @@ describe("resolveCosmetics", () => {
pattern: "AAAAAA",
affiliateCode: null,
product,
priceSoft: undefined,
priceHard: undefined,
rarity: "common",
colorPalettes: [
{ name: "red", isArchived: false },
@@ -226,6 +228,8 @@ describe("resolveCosmetics", () => {
url: "https://example.com/cool.png",
affiliateCode: null,
product,
priceSoft: undefined,
priceHard: undefined,
rarity: "rare",
};
@@ -292,6 +296,8 @@ describe("resolveCosmetics", () => {
pattern: "AAAAAA",
affiliateCode: null,
product,
priceSoft: null,
priceHard: null,
rarity: "common",
} as any,
},
@@ -302,6 +308,8 @@ describe("resolveCosmetics", () => {
url: "/flags/heart.svg",
affiliateCode: null,
product,
priceSoft: null,
priceHard: null,
rarity: "common",
} as any,
},