mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-21 08:20:50 +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_flags": "No flags available. Check back later for new items.",
|
||||||
"no_skins": "No skins 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.",
|
"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": {
|
"territory_patterns": {
|
||||||
"title": "Skins",
|
"title": "Skins",
|
||||||
|
|||||||
@@ -92,6 +92,49 @@ export async function getUserMe(): Promise<UserMeResponse | false> {
|
|||||||
return __userMe;
|
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(
|
export async function createCheckoutSession(
|
||||||
priceId: string,
|
priceId: string,
|
||||||
colorPaletteName?: string,
|
colorPaletteName?: string,
|
||||||
|
|||||||
+74
-11
@@ -15,7 +15,13 @@ import {
|
|||||||
PlayerPattern,
|
PlayerPattern,
|
||||||
} from "../core/Schemas";
|
} from "../core/Schemas";
|
||||||
import { UserSettings } from "../core/game/UserSettings";
|
import { UserSettings } from "../core/game/UserSettings";
|
||||||
import { createCheckoutSession, getApiBase, getUserMe } from "./Api";
|
import {
|
||||||
|
createCheckoutSession,
|
||||||
|
getApiBase,
|
||||||
|
getUserMe,
|
||||||
|
invalidateUserMe,
|
||||||
|
purchaseWithCurrency,
|
||||||
|
} from "./Api";
|
||||||
import { translateText } from "./Utils";
|
import { translateText } from "./Utils";
|
||||||
|
|
||||||
export const TEMP_FLARE_OFFSET = 1 * 60 * 1000; // 1 minute
|
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 __cosmetics: Promise<Cosmetics | null> | null = null;
|
||||||
let __cosmeticsHash: string | null = null;
|
let __cosmeticsHash: string | null = null;
|
||||||
|
|
||||||
export async function handlePurchase(
|
export type PaymentMethod = "dollar" | "hard" | "soft";
|
||||||
product: Product,
|
|
||||||
colorPaletteName?: string,
|
export async function purchaseCosmetic(
|
||||||
) {
|
resolved: ResolvedCosmetic,
|
||||||
const url = await createCheckoutSession(product.priceId, colorPaletteName);
|
method: PaymentMethod,
|
||||||
if (url === false) {
|
): Promise<void> {
|
||||||
alert("Failed to create checkout session.");
|
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;
|
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 {
|
function simpleHash(str: string): string {
|
||||||
@@ -102,6 +154,8 @@ export function cosmeticRelationship(
|
|||||||
wildcardFlare: string;
|
wildcardFlare: string;
|
||||||
requiredFlare: string;
|
requiredFlare: string;
|
||||||
product: Product | null;
|
product: Product | null;
|
||||||
|
priceSoft?: number;
|
||||||
|
priceHard?: number;
|
||||||
affiliateCode: string | null;
|
affiliateCode: string | null;
|
||||||
itemAffiliateCode: string | null;
|
itemAffiliateCode: string | null;
|
||||||
},
|
},
|
||||||
@@ -118,11 +172,16 @@ export function cosmeticRelationship(
|
|||||||
return "owned";
|
return "owned";
|
||||||
}
|
}
|
||||||
|
|
||||||
if (opts.product === null) {
|
if (opts.affiliateCode !== opts.itemAffiliateCode) {
|
||||||
return "blocked";
|
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";
|
return "blocked";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -166,6 +225,8 @@ export function patternRelationship(
|
|||||||
wildcardFlare: "pattern:*",
|
wildcardFlare: "pattern:*",
|
||||||
requiredFlare: `pattern:${pattern.name}:${colorPalette.name}`,
|
requiredFlare: `pattern:${pattern.name}:${colorPalette.name}`,
|
||||||
product: pattern.product,
|
product: pattern.product,
|
||||||
|
priceSoft: pattern.priceSoft,
|
||||||
|
priceHard: pattern.priceHard,
|
||||||
affiliateCode,
|
affiliateCode,
|
||||||
itemAffiliateCode: pattern.affiliateCode,
|
itemAffiliateCode: pattern.affiliateCode,
|
||||||
},
|
},
|
||||||
@@ -183,6 +244,8 @@ export function flagRelationship(
|
|||||||
wildcardFlare: "flag:*",
|
wildcardFlare: "flag:*",
|
||||||
requiredFlare: `flag:${flag.name}`,
|
requiredFlare: `flag:${flag.name}`,
|
||||||
product: flag.product,
|
product: flag.product,
|
||||||
|
priceSoft: flag.priceSoft,
|
||||||
|
priceHard: flag.priceHard,
|
||||||
affiliateCode,
|
affiliateCode,
|
||||||
itemAffiliateCode: flag.affiliateCode,
|
itemAffiliateCode: flag.affiliateCode,
|
||||||
},
|
},
|
||||||
|
|||||||
+4
-8
@@ -10,9 +10,8 @@ import "./components/NotLoggedInWarning";
|
|||||||
import { modalHeader } from "./components/ui/ModalHeader";
|
import { modalHeader } from "./components/ui/ModalHeader";
|
||||||
import {
|
import {
|
||||||
fetchCosmetics,
|
fetchCosmetics,
|
||||||
handlePurchase,
|
purchaseCosmetic,
|
||||||
resolveCosmetics,
|
resolveCosmetics,
|
||||||
ResolvedCosmetic,
|
|
||||||
} from "./Cosmetics";
|
} from "./Cosmetics";
|
||||||
import { translateText } from "./Utils";
|
import { translateText } from "./Utils";
|
||||||
|
|
||||||
@@ -109,8 +108,7 @@ export class StoreModal extends BaseModal {
|
|||||||
(r) => html`
|
(r) => html`
|
||||||
<cosmetic-button
|
<cosmetic-button
|
||||||
.resolved=${r}
|
.resolved=${r}
|
||||||
.onPurchase=${(rc: ResolvedCosmetic) =>
|
.onPurchase=${purchaseCosmetic}
|
||||||
handlePurchase(rc.cosmetic!.product!, rc.colorPalette?.name)}
|
|
||||||
></cosmetic-button>
|
></cosmetic-button>
|
||||||
`,
|
`,
|
||||||
)}
|
)}
|
||||||
@@ -148,8 +146,7 @@ export class StoreModal extends BaseModal {
|
|||||||
<cosmetic-button
|
<cosmetic-button
|
||||||
.resolved=${r}
|
.resolved=${r}
|
||||||
.selected=${selectedFlag === r.key}
|
.selected=${selectedFlag === r.key}
|
||||||
.onPurchase=${(rc: ResolvedCosmetic) =>
|
.onPurchase=${purchaseCosmetic}
|
||||||
handlePurchase(rc.cosmetic!.product!)}
|
|
||||||
></cosmetic-button>
|
></cosmetic-button>
|
||||||
`,
|
`,
|
||||||
)}
|
)}
|
||||||
@@ -180,8 +177,7 @@ export class StoreModal extends BaseModal {
|
|||||||
(r) => html`
|
(r) => html`
|
||||||
<cosmetic-button
|
<cosmetic-button
|
||||||
.resolved=${r}
|
.resolved=${r}
|
||||||
.onPurchase=${(rc: ResolvedCosmetic) =>
|
.onPurchase=${purchaseCosmetic}
|
||||||
handlePurchase(rc.cosmetic!.product!)}
|
|
||||||
></cosmetic-button>
|
></cosmetic-button>
|
||||||
`,
|
`,
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -2,7 +2,11 @@ import { html, LitElement, nothing, TemplateResult } from "lit";
|
|||||||
import { customElement, property } from "lit/decorators.js";
|
import { customElement, property } from "lit/decorators.js";
|
||||||
import { Flag, Pack, Pattern } from "../../core/CosmeticSchemas";
|
import { Flag, Pack, Pattern } from "../../core/CosmeticSchemas";
|
||||||
import { PlayerPattern } from "../../core/Schemas";
|
import { PlayerPattern } from "../../core/Schemas";
|
||||||
import { ResolvedCosmetic, translateCosmetic } from "../Cosmetics";
|
import {
|
||||||
|
PaymentMethod,
|
||||||
|
ResolvedCosmetic,
|
||||||
|
translateCosmetic,
|
||||||
|
} from "../Cosmetics";
|
||||||
import { translateText } from "../Utils";
|
import { translateText } from "../Utils";
|
||||||
import "./CapIcon";
|
import "./CapIcon";
|
||||||
import "./CosmeticContainer";
|
import "./CosmeticContainer";
|
||||||
@@ -22,7 +26,7 @@ export class CosmeticButton extends LitElement {
|
|||||||
onSelect?: (resolved: ResolvedCosmetic) => void;
|
onSelect?: (resolved: ResolvedCosmetic) => void;
|
||||||
|
|
||||||
@property({ type: Function })
|
@property({ type: Function })
|
||||||
onPurchase?: (resolved: ResolvedCosmetic) => void;
|
onPurchase?: (resolved: ResolvedCosmetic, method: PaymentMethod) => void;
|
||||||
|
|
||||||
createRenderRoot() {
|
createRenderRoot() {
|
||||||
return this;
|
return this;
|
||||||
@@ -118,7 +122,17 @@ export class CosmeticButton extends LitElement {
|
|||||||
.rarity=${c?.rarity ?? "common"}
|
.rarity=${c?.rarity ?? "common"}
|
||||||
.selected=${this.selected}
|
.selected=${this.selected}
|
||||||
.product=${isPurchasable && c?.product ? c.product : null}
|
.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}
|
.name=${this.displayName}
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
@@ -127,7 +141,7 @@ export class CosmeticButton extends LitElement {
|
|||||||
: "gap-1"} rounded-lg cursor-pointer transition-all duration-200 flex-1"
|
: "gap-1"} rounded-lg cursor-pointer transition-all duration-200 flex-1"
|
||||||
@click=${() => this.handleClick()}
|
@click=${() => this.handleClick()}
|
||||||
>
|
>
|
||||||
${c?.product
|
${(c?.product ?? c?.priceHard ?? c?.priceSoft)
|
||||||
? html`<cosmetic-info
|
? html`<cosmetic-info
|
||||||
.artist=${c.artist}
|
.artist=${c.artist}
|
||||||
.rarity=${c.rarity}
|
.rarity=${c.rarity}
|
||||||
|
|||||||
@@ -152,8 +152,20 @@ export class CosmeticContainer extends LitElement {
|
|||||||
@property({ type: Object })
|
@property({ type: Object })
|
||||||
product: Product | null = null;
|
product: Product | null = null;
|
||||||
|
|
||||||
|
@property({ type: Number })
|
||||||
|
priceHard: number | null = null;
|
||||||
|
|
||||||
|
@property({ type: Number })
|
||||||
|
priceSoft: number | null = null;
|
||||||
|
|
||||||
@property({ type: Function })
|
@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 _backdrop: HTMLDivElement | null = null;
|
||||||
private static _ensureBackdrop(): HTMLDivElement {
|
private static _ensureBackdrop(): HTMLDivElement {
|
||||||
@@ -205,7 +217,11 @@ export class CosmeticContainer extends LitElement {
|
|||||||
this.style.transition =
|
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";
|
"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.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) {
|
if (this.selected) {
|
||||||
this.style.boxShadow = `0 0 18px ${cfg.glow}`;
|
this.style.boxShadow = `0 0 18px ${cfg.glow}`;
|
||||||
@@ -311,10 +327,16 @@ export class CosmeticContainer extends LitElement {
|
|||||||
if (CosmeticContainer._backdrop) {
|
if (CosmeticContainer._backdrop) {
|
||||||
CosmeticContainer._backdrop.style.background = "rgba(0,0,0,0)";
|
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._loading = true;
|
||||||
this._showLoadingOverlay();
|
this._showLoadingOverlay();
|
||||||
Promise.resolve(this.onPurchase()).catch(() => {
|
Promise.resolve(handlers[0]!()).finally(() => {
|
||||||
this._hideLoadingOverlay();
|
this._hideLoadingOverlay();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -420,11 +442,15 @@ export class CosmeticContainer extends LitElement {
|
|||||||
render() {
|
render() {
|
||||||
return html`
|
return html`
|
||||||
<slot></slot>
|
<slot></slot>
|
||||||
${this.product && this.onPurchase
|
${this.product || this.priceHard !== null || this.priceSoft !== null
|
||||||
? html`<purchase-button
|
? html`<purchase-button
|
||||||
.product=${this.product}
|
.product=${this.product}
|
||||||
|
.priceHard=${this.priceHard}
|
||||||
|
.priceSoft=${this.priceSoft}
|
||||||
.rarity=${this.rarity}
|
.rarity=${this.rarity}
|
||||||
.onPurchase=${this.onPurchase}
|
.onPurchaseDollar=${this.onPurchaseDollar}
|
||||||
|
.onPurchaseHard=${this.onPurchaseHard}
|
||||||
|
.onPurchaseSoft=${this.onPurchaseSoft}
|
||||||
></purchase-button>`
|
></purchase-button>`
|
||||||
: null}
|
: null}
|
||||||
`;
|
`;
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { html, LitElement } from "lit";
|
import { html, LitElement, nothing } from "lit";
|
||||||
import { customElement, property } from "lit/decorators.js";
|
import { customElement, property } from "lit/decorators.js";
|
||||||
import { Product } from "../../core/CosmeticSchemas";
|
import { Product } from "../../core/CosmeticSchemas";
|
||||||
import { translateText } from "../Utils";
|
import { translateText } from "../Utils";
|
||||||
|
import "./CapIcon";
|
||||||
|
import "./PlutoniumIcon";
|
||||||
|
|
||||||
const PURCHASE_STYLE_ID = "purchase-button-styles";
|
const PURCHASE_STYLE_ID = "purchase-button-styles";
|
||||||
if (!document.getElementById(PURCHASE_STYLE_ID)) {
|
if (!document.getElementById(PURCHASE_STYLE_ID)) {
|
||||||
@@ -34,6 +36,18 @@ if (!document.getElementById(PURCHASE_STYLE_ID)) {
|
|||||||
color: white;
|
color: white;
|
||||||
box-shadow: 0 0 20px rgba(74,222,128,0.6);
|
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 {
|
@keyframes purchase-pulse {
|
||||||
0% { box-shadow: 0 0 15px rgba(74,222,128,0.6), 0 0 30px rgba(34,197,94,0.3); }
|
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); }
|
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;
|
color: white !important;
|
||||||
animation: purchase-pulse 1.2s ease-in-out infinite !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 {
|
@keyframes purchase-ember-0 {
|
||||||
0% { transform: translateY(0) translateX(0) scale(1); opacity: 0.9; }
|
0% { transform: translateY(0) translateX(0) scale(1); opacity: 0.9; }
|
||||||
100% { transform: translateY(-35px) translateX(5px) scale(0.2); opacity: 0; }
|
100% { transform: translateY(-35px) translateX(5px) scale(0.2); opacity: 0; }
|
||||||
@@ -148,20 +179,33 @@ if (!document.getElementById(PURCHASE_STYLE_ID)) {
|
|||||||
@customElement("purchase-button")
|
@customElement("purchase-button")
|
||||||
export class PurchaseButton extends LitElement {
|
export class PurchaseButton extends LitElement {
|
||||||
@property({ type: Object })
|
@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 })
|
@property({ type: String })
|
||||||
rarity: string = "common";
|
rarity: string = "common";
|
||||||
|
|
||||||
@property({ type: Function })
|
@property({ type: Function })
|
||||||
onPurchase?: () => void;
|
onPurchaseDollar?: () => void;
|
||||||
|
|
||||||
|
@property({ type: Function })
|
||||||
|
onPurchaseHard?: () => void;
|
||||||
|
|
||||||
|
@property({ type: Function })
|
||||||
|
onPurchaseSoft?: () => void;
|
||||||
|
|
||||||
createRenderRoot() {
|
createRenderRoot() {
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
private handleClick(e: Event) {
|
private handleClick(e: Event, handler?: () => void) {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
if (!handler) return;
|
||||||
const container = this.closest("cosmetic-container") as HTMLElement | null;
|
const container = this.closest("cosmetic-container") as HTMLElement | null;
|
||||||
if (container && !container.querySelector(".cosmetic-loading-overlay")) {
|
if (container && !container.querySelector(".cosmetic-loading-overlay")) {
|
||||||
const overlay = document.createElement("div");
|
const overlay = document.createElement("div");
|
||||||
@@ -169,12 +213,58 @@ export class PurchaseButton extends LitElement {
|
|||||||
overlay.innerHTML = `<div class="cosmetic-loading-spinner"></div>`;
|
overlay.innerHTML = `<div class="cosmetic-loading-spinner"></div>`;
|
||||||
container.appendChild(overlay);
|
container.appendChild(overlay);
|
||||||
}
|
}
|
||||||
Promise.resolve(this.onPurchase?.()).catch(() => {
|
Promise.resolve(handler()).finally(() => {
|
||||||
container?.querySelector(".cosmetic-loading-overlay")?.remove();
|
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() {
|
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`
|
return html`
|
||||||
<div class="no-crazygames w-full mt-2 relative purchase-btn-wrap">
|
<div class="no-crazygames w-full mt-2 relative purchase-btn-wrap">
|
||||||
${this.rarity !== "common"
|
${this.rarity !== "common"
|
||||||
@@ -190,15 +280,11 @@ export class PurchaseButton extends LitElement {
|
|||||||
></span>`,
|
></span>`,
|
||||||
)}`
|
)}`
|
||||||
: null}
|
: null}
|
||||||
<button
|
<div class="flex flex-col gap-1 w-full">
|
||||||
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
|
${hasDollar ? this.renderDollarButton() : null}
|
||||||
hover:bg-green-500 hover:border-green-400 hover:text-white hover:shadow-[0_0_20px_rgba(74,222,128,0.6)]"
|
${hasHard ? this.renderHardButton() : null}
|
||||||
@click=${this.handleClick}
|
${hasSoft ? this.renderSoftButton() : null}
|
||||||
>
|
</div>
|
||||||
<span class="purchase-sparkle-streak"></span>
|
|
||||||
${translateText("territory_patterns.purchase")}
|
|
||||||
<span class="ml-1 text-white/50">(${this.product.price})</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,9 +14,8 @@ import { getUserMe } from "../../Api";
|
|||||||
import "../../components/CosmeticButton";
|
import "../../components/CosmeticButton";
|
||||||
import {
|
import {
|
||||||
fetchCosmetics,
|
fetchCosmetics,
|
||||||
handlePurchase,
|
purchaseCosmetic,
|
||||||
resolveCosmetics,
|
resolveCosmetics,
|
||||||
ResolvedCosmetic,
|
|
||||||
} from "../../Cosmetics";
|
} from "../../Cosmetics";
|
||||||
import { crazyGamesSDK } from "../../CrazyGamesSDK";
|
import { crazyGamesSDK } from "../../CrazyGamesSDK";
|
||||||
import { Platform } from "../../Platform";
|
import { Platform } from "../../Platform";
|
||||||
@@ -179,8 +178,7 @@ export class WinModal extends LitElement implements Layer {
|
|||||||
(r) => html`
|
(r) => html`
|
||||||
<cosmetic-button
|
<cosmetic-button
|
||||||
.resolved=${r}
|
.resolved=${r}
|
||||||
.onPurchase=${(rc: ResolvedCosmetic) =>
|
.onPurchase=${purchaseCosmetic}
|
||||||
handlePurchase(rc.cosmetic!.product!, rc.colorPalette?.name)}
|
|
||||||
></cosmetic-button>
|
></cosmetic-button>
|
||||||
`,
|
`,
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -56,6 +56,8 @@ const CosmeticSchema = z.object({
|
|||||||
name: CosmeticNameSchema,
|
name: CosmeticNameSchema,
|
||||||
affiliateCode: z.string().nullable(),
|
affiliateCode: z.string().nullable(),
|
||||||
product: ProductSchema.nullable(),
|
product: ProductSchema.nullable(),
|
||||||
|
priceSoft: z.number().optional(),
|
||||||
|
priceHard: z.number().optional(),
|
||||||
artist: z.string().optional(),
|
artist: z.string().optional(),
|
||||||
rarity: z
|
rarity: z
|
||||||
.enum(["common", "uncommon", "rare", "epic", "legendary"])
|
.enum(["common", "uncommon", "rare", "epic", "legendary"])
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ describe("cosmeticRelationship", () => {
|
|||||||
wildcardFlare: "flag:*",
|
wildcardFlare: "flag:*",
|
||||||
requiredFlare: "flag:cool",
|
requiredFlare: "flag:cool",
|
||||||
product,
|
product,
|
||||||
|
priceSoft: undefined,
|
||||||
|
priceHard: undefined,
|
||||||
affiliateCode: null,
|
affiliateCode: null,
|
||||||
itemAffiliateCode: null,
|
itemAffiliateCode: null,
|
||||||
},
|
},
|
||||||
@@ -32,6 +34,8 @@ describe("cosmeticRelationship", () => {
|
|||||||
wildcardFlare: "flag:*",
|
wildcardFlare: "flag:*",
|
||||||
requiredFlare: "flag:cool",
|
requiredFlare: "flag:cool",
|
||||||
product,
|
product,
|
||||||
|
priceSoft: undefined,
|
||||||
|
priceHard: undefined,
|
||||||
affiliateCode: null,
|
affiliateCode: null,
|
||||||
itemAffiliateCode: null,
|
itemAffiliateCode: null,
|
||||||
},
|
},
|
||||||
@@ -47,6 +51,8 @@ describe("cosmeticRelationship", () => {
|
|||||||
wildcardFlare: "flag:*",
|
wildcardFlare: "flag:*",
|
||||||
requiredFlare: "flag:cool",
|
requiredFlare: "flag:cool",
|
||||||
product: null,
|
product: null,
|
||||||
|
priceSoft: undefined,
|
||||||
|
priceHard: undefined,
|
||||||
affiliateCode: null,
|
affiliateCode: null,
|
||||||
itemAffiliateCode: null,
|
itemAffiliateCode: null,
|
||||||
},
|
},
|
||||||
@@ -62,6 +68,8 @@ describe("cosmeticRelationship", () => {
|
|||||||
wildcardFlare: "flag:*",
|
wildcardFlare: "flag:*",
|
||||||
requiredFlare: "flag:cool",
|
requiredFlare: "flag:cool",
|
||||||
product,
|
product,
|
||||||
|
priceSoft: undefined,
|
||||||
|
priceHard: undefined,
|
||||||
affiliateCode: "storeA",
|
affiliateCode: "storeA",
|
||||||
itemAffiliateCode: "storeB",
|
itemAffiliateCode: "storeB",
|
||||||
},
|
},
|
||||||
@@ -77,6 +85,8 @@ describe("cosmeticRelationship", () => {
|
|||||||
wildcardFlare: "flag:*",
|
wildcardFlare: "flag:*",
|
||||||
requiredFlare: "flag:cool",
|
requiredFlare: "flag:cool",
|
||||||
product,
|
product,
|
||||||
|
priceSoft: undefined,
|
||||||
|
priceHard: undefined,
|
||||||
affiliateCode: null,
|
affiliateCode: null,
|
||||||
itemAffiliateCode: null,
|
itemAffiliateCode: null,
|
||||||
},
|
},
|
||||||
@@ -92,6 +102,8 @@ describe("cosmeticRelationship", () => {
|
|||||||
wildcardFlare: "pattern:*",
|
wildcardFlare: "pattern:*",
|
||||||
requiredFlare: "pattern:stripes:red",
|
requiredFlare: "pattern:stripes:red",
|
||||||
product,
|
product,
|
||||||
|
priceSoft: undefined,
|
||||||
|
priceHard: undefined,
|
||||||
affiliateCode: "storeA",
|
affiliateCode: "storeA",
|
||||||
itemAffiliateCode: "storeA",
|
itemAffiliateCode: "storeA",
|
||||||
},
|
},
|
||||||
@@ -107,6 +119,8 @@ describe("cosmeticRelationship", () => {
|
|||||||
wildcardFlare: "flag:*",
|
wildcardFlare: "flag:*",
|
||||||
requiredFlare: "flag:cool",
|
requiredFlare: "flag:cool",
|
||||||
product: null,
|
product: null,
|
||||||
|
priceSoft: undefined,
|
||||||
|
priceHard: undefined,
|
||||||
affiliateCode: null,
|
affiliateCode: null,
|
||||||
itemAffiliateCode: null,
|
itemAffiliateCode: null,
|
||||||
},
|
},
|
||||||
@@ -122,6 +136,8 @@ describe("cosmeticRelationship", () => {
|
|||||||
wildcardFlare: "flag:*",
|
wildcardFlare: "flag:*",
|
||||||
requiredFlare: "flag:cool",
|
requiredFlare: "flag:cool",
|
||||||
product,
|
product,
|
||||||
|
priceSoft: undefined,
|
||||||
|
priceHard: undefined,
|
||||||
affiliateCode: null,
|
affiliateCode: null,
|
||||||
itemAffiliateCode: null,
|
itemAffiliateCode: null,
|
||||||
},
|
},
|
||||||
@@ -130,6 +146,39 @@ describe("cosmeticRelationship", () => {
|
|||||||
).toBe("purchasable");
|
).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", () => {
|
it("returns owned when user has wildcard flare for patterns", () => {
|
||||||
expect(
|
expect(
|
||||||
cosmeticRelationship(
|
cosmeticRelationship(
|
||||||
@@ -137,6 +186,8 @@ describe("cosmeticRelationship", () => {
|
|||||||
wildcardFlare: "pattern:*",
|
wildcardFlare: "pattern:*",
|
||||||
requiredFlare: "pattern:stripes:red",
|
requiredFlare: "pattern:stripes:red",
|
||||||
product,
|
product,
|
||||||
|
priceSoft: undefined,
|
||||||
|
priceHard: undefined,
|
||||||
affiliateCode: null,
|
affiliateCode: null,
|
||||||
itemAffiliateCode: null,
|
itemAffiliateCode: null,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -44,6 +44,8 @@ const flagCosmetics = {
|
|||||||
url: "https://example.com/cool.png",
|
url: "https://example.com/cool.png",
|
||||||
affiliateCode: null,
|
affiliateCode: null,
|
||||||
product: { productId: "prod_1", priceId: "price_1", price: "$4.99" },
|
product: { productId: "prod_1", priceId: "price_1", price: "$4.99" },
|
||||||
|
priceSoft: undefined,
|
||||||
|
priceHard: undefined,
|
||||||
rarity: "common",
|
rarity: "common",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -47,6 +47,8 @@ describe("resolveCosmetics", () => {
|
|||||||
pattern: "AAAAAA",
|
pattern: "AAAAAA",
|
||||||
affiliateCode: null,
|
affiliateCode: null,
|
||||||
product,
|
product,
|
||||||
|
priceSoft: undefined,
|
||||||
|
priceHard: undefined,
|
||||||
rarity: "common",
|
rarity: "common",
|
||||||
colorPalettes: [
|
colorPalettes: [
|
||||||
{ name: "red", isArchived: false },
|
{ name: "red", isArchived: false },
|
||||||
@@ -226,6 +228,8 @@ describe("resolveCosmetics", () => {
|
|||||||
url: "https://example.com/cool.png",
|
url: "https://example.com/cool.png",
|
||||||
affiliateCode: null,
|
affiliateCode: null,
|
||||||
product,
|
product,
|
||||||
|
priceSoft: undefined,
|
||||||
|
priceHard: undefined,
|
||||||
rarity: "rare",
|
rarity: "rare",
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -292,6 +296,8 @@ describe("resolveCosmetics", () => {
|
|||||||
pattern: "AAAAAA",
|
pattern: "AAAAAA",
|
||||||
affiliateCode: null,
|
affiliateCode: null,
|
||||||
product,
|
product,
|
||||||
|
priceSoft: null,
|
||||||
|
priceHard: null,
|
||||||
rarity: "common",
|
rarity: "common",
|
||||||
} as any,
|
} as any,
|
||||||
},
|
},
|
||||||
@@ -302,6 +308,8 @@ describe("resolveCosmetics", () => {
|
|||||||
url: "/flags/heart.svg",
|
url: "/flags/heart.svg",
|
||||||
affiliateCode: null,
|
affiliateCode: null,
|
||||||
product,
|
product,
|
||||||
|
priceSoft: null,
|
||||||
|
priceHard: null,
|
||||||
rarity: "common",
|
rarity: "common",
|
||||||
} as any,
|
} as any,
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user