mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-21 08:30:42 +00:00
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:
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
+73
-10
@@ -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);
|
||||
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("Failed to create checkout session.");
|
||||
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
@@ -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>
|
||||
`,
|
||||
)}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
`;
|
||||
|
||||
@@ -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>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
`,
|
||||
)}
|
||||
|
||||
@@ -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"])
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user