mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-21 09:10:42 +00:00
support for purchasing currency packs (#3629)
## Description: Adds a currency pack system to the store. Players can purchase packs of in-game currency (Plutonium and Caps) via Stripe checkout. What's new: * Pack schema (PackSchema) — new cosmetic type with currency (hard/soft), amount, and displayName * "Packs" tab in the Store — renders purchasable currency packs using existing CosmeticButton infrastructure * Stripe checkout flow — new createCurrencyPackCheckout API call and handlePackPurchase handler * Currency display in Account modal — shows Plutonium and Caps balances when logged in I* con components — <plutonium-icon> (animated green glow + rotate) and <cap-icon> with new SVG assets * Currency in UserMeResponse — player.currency.hard / player.currency.soft added to the API schema ## 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:
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="1200pt" height="1200pt" version="1.1" viewBox="0 0 1200 1200" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill="#c2610c" d="m1002 489.61c0-58.828-165.61-123.61-402-123.61-237.61 0-402 64.781-402 123.61 0 57.609 164.39 122.39 402 122.39 236.39 0 402-64.781 402-122.39zm-402 152.39c-178.78 0-366-38.391-417.61-111.61l-62.391 159.61c81.609-14.391 164.39 20.391 211.22 90l43.219-88.781c3.5625-7.2188 11.953-10.828 20.344-7.2188 7.2188 3.6094 10.828 12 7.2188 20.391l-39.609 80.391c78-18 160.78 0 224.39 49.219v-100.78c0-8.3906 7.2188-14.391 14.391-14.391 7.2188 0 14.391 7.2188 14.391 14.391l0.046875 100.78c63.609-49.219 146.39-67.219 224.39-49.219l-39.609-81.609c-3.6094-7.2188-1.2188-15.609 7.2188-20.391 7.2188-3.6094 15.609-1.2188 20.391 7.2188l43.219 87.609c46.781-68.391 128.39-103.22 210-88.781l-62.391-159.61c-52.828 74.391-241.22 112.78-418.82 112.78z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 918 B |
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 6.4 KiB |
@@ -942,8 +942,11 @@
|
||||
"title": "Store",
|
||||
"patterns": "Skins",
|
||||
"flags": "Flags",
|
||||
"packs": "Packs",
|
||||
"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.",
|
||||
"currency_pack_purchase_success": "Currency pack purchase successful!"
|
||||
},
|
||||
"territory_patterns": {
|
||||
"title": "Skins",
|
||||
@@ -963,7 +966,9 @@
|
||||
"rare": "Rare",
|
||||
"epic": "Epic",
|
||||
"legendary": "Legendary",
|
||||
"adfree": "ad-free for life!"
|
||||
"adfree": "ad-free for life!",
|
||||
"hard": "Plutonium",
|
||||
"soft": "Caps"
|
||||
},
|
||||
"flag_input": {
|
||||
"title": "Select Flag",
|
||||
|
||||
@@ -15,6 +15,7 @@ import "./components/baseComponents/stats/PlayerStatsTable";
|
||||
import "./components/baseComponents/stats/PlayerStatsTree";
|
||||
import { BaseModal } from "./components/BaseModal";
|
||||
import "./components/CopyButton";
|
||||
import "./components/CurrencyDisplay";
|
||||
import "./components/Difficulties";
|
||||
import { modalHeader } from "./components/ui/ModalHeader";
|
||||
import { translateText } from "./Utils";
|
||||
@@ -191,12 +192,24 @@ export class AccountModal extends BaseModal {
|
||||
`;
|
||||
}
|
||||
|
||||
private renderCurrency(): TemplateResult {
|
||||
const currency = this.userMeResponse?.player?.currency;
|
||||
if (!currency) return html``;
|
||||
|
||||
return html`
|
||||
<currency-display
|
||||
.hard=${currency.hard}
|
||||
.soft=${currency.soft}
|
||||
></currency-display>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderLoggedInAs(): TemplateResult {
|
||||
const me = this.userMeResponse?.user;
|
||||
if (me?.discord) {
|
||||
return html`
|
||||
<div class="flex flex-col items-center gap-3 w-full">
|
||||
${this.renderLogoutButton()}
|
||||
${this.renderCurrency()} ${this.renderLogoutButton()}
|
||||
</div>
|
||||
`;
|
||||
} else if (me?.email) {
|
||||
@@ -207,7 +220,7 @@ export class AccountModal extends BaseModal {
|
||||
account_name: me.email,
|
||||
})}
|
||||
</div>
|
||||
${this.renderLogoutButton()}
|
||||
${this.renderCurrency()} ${this.renderLogoutButton()}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@@ -265,6 +278,7 @@ export class AccountModal extends BaseModal {
|
||||
<p class="text-white/50 text-sm font-medium">
|
||||
${translateText("account_modal.sign_in_desc")}
|
||||
</p>
|
||||
${this.renderCurrency()}
|
||||
</div>
|
||||
|
||||
<div class="space-y-6">
|
||||
|
||||
+15
-2
@@ -5,6 +5,7 @@ import {
|
||||
Cosmetics,
|
||||
CosmeticsSchema,
|
||||
Flag,
|
||||
Pack,
|
||||
Pattern,
|
||||
Product,
|
||||
} from "../core/CosmeticSchemas";
|
||||
@@ -190,8 +191,8 @@ export function flagRelationship(
|
||||
}
|
||||
|
||||
export type ResolvedCosmetic = {
|
||||
type: "pattern" | "flag";
|
||||
cosmetic: Pattern | Flag | null;
|
||||
type: "pattern" | "flag" | "pack";
|
||||
cosmetic: Pattern | Flag | Pack | null;
|
||||
colorPalette: ColorPalette | null;
|
||||
relationship: "owned" | "purchasable" | "blocked";
|
||||
/** Unique key for selection/identity, e.g. "pattern:hearts:red" or "flag:cool_flag" */
|
||||
@@ -257,6 +258,18 @@ export function resolveCosmetics(
|
||||
});
|
||||
}
|
||||
|
||||
// Packs
|
||||
for (const [packKey, pack] of Object.entries(cosmetics.currencyPacks ?? {})) {
|
||||
const rel = pack.product ? "purchasable" : "blocked";
|
||||
result.push({
|
||||
type: "pack",
|
||||
cosmetic: pack,
|
||||
colorPalette: null,
|
||||
relationship: rel,
|
||||
key: `pack:${packKey}`,
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
@@ -636,6 +636,12 @@ class Client {
|
||||
return;
|
||||
}
|
||||
|
||||
const type = params.get("type");
|
||||
if (type === "currency_pack") {
|
||||
alertAndStrip(translateText("store.currency_pack_purchase_success"));
|
||||
return;
|
||||
}
|
||||
|
||||
const cosmeticName = params.get("cosmetic");
|
||||
if (!cosmeticName) {
|
||||
alert("Something went wrong. Please contact support.");
|
||||
|
||||
+45
-3
@@ -18,10 +18,9 @@ import { translateText } from "./Utils";
|
||||
|
||||
@customElement("store-modal")
|
||||
export class StoreModal extends BaseModal {
|
||||
@state() private activeTab: "patterns" | "flags" = "patterns";
|
||||
@state() private activeTab: "patterns" | "flags" | "packs" = "patterns";
|
||||
|
||||
private cosmetics: Cosmetics | null = null;
|
||||
private userSettings: UserSettings = new UserSettings();
|
||||
private isActive = false;
|
||||
private affiliateCode: string | null = null;
|
||||
private userMeResponse: UserMeResponse | false = false;
|
||||
@@ -51,6 +50,15 @@ export class StoreModal extends BaseModal {
|
||||
rightContent: html`<not-logged-in-warning></not-logged-in-warning>`,
|
||||
})}
|
||||
<div class="flex items-center gap-2 justify-center pt-2">
|
||||
<button
|
||||
class="px-6 py-2 text-xs font-bold transition-all duration-200 rounded-lg uppercase tracking-widest ${this
|
||||
.activeTab === "packs"
|
||||
? "bg-blue-500/20 text-blue-400 border border-blue-500/30 shadow-[0_0_15px_rgba(59,130,246,0.2)]"
|
||||
: "text-white/40 hover:text-white hover:bg-white/5 border border-transparent"}"
|
||||
@click=${() => (this.activeTab = "packs")}
|
||||
>
|
||||
${translateText("store.packs")}
|
||||
</button>
|
||||
<button
|
||||
class="px-6 py-2 text-xs font-bold transition-all duration-200 rounded-lg uppercase tracking-widest ${this
|
||||
.activeTab === "patterns"
|
||||
@@ -149,6 +157,38 @@ export class StoreModal extends BaseModal {
|
||||
`;
|
||||
}
|
||||
|
||||
private renderPackGrid(): TemplateResult {
|
||||
const items = resolveCosmetics(
|
||||
this.cosmetics,
|
||||
this.userMeResponse,
|
||||
this.affiliateCode,
|
||||
).filter((r) => r.type === "pack" && r.relationship === "purchasable");
|
||||
|
||||
if (items.length === 0) {
|
||||
return html`<div
|
||||
class="text-white/40 text-sm font-bold uppercase tracking-wider text-center py-8"
|
||||
>
|
||||
${translateText("store.no_packs")}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
return html`
|
||||
<div
|
||||
class="flex flex-wrap gap-4 p-8 justify-center items-stretch content-start"
|
||||
>
|
||||
${items.map(
|
||||
(r) => html`
|
||||
<cosmetic-button
|
||||
.resolved=${r}
|
||||
.onPurchase=${(rc: ResolvedCosmetic) =>
|
||||
handlePurchase(rc.cosmetic!.product!)}
|
||||
></cosmetic-button>
|
||||
`,
|
||||
)}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
render() {
|
||||
if (!this.isActive && !this.inline) return html``;
|
||||
|
||||
@@ -158,7 +198,9 @@ export class StoreModal extends BaseModal {
|
||||
<div class="overflow-y-auto pr-2 custom-scrollbar mr-1">
|
||||
${this.activeTab === "patterns"
|
||||
? this.renderPatternGrid()
|
||||
: this.renderFlagGrid()}
|
||||
: this.activeTab === "flags"
|
||||
? this.renderFlagGrid()
|
||||
: this.renderPackGrid()}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { html, LitElement } from "lit";
|
||||
import { customElement, property } from "lit/decorators.js";
|
||||
import { assetUrl } from "../../core/AssetUrls";
|
||||
|
||||
@customElement("cap-icon")
|
||||
export class CapIcon extends LitElement {
|
||||
@property({ type: Number })
|
||||
size: number = 48;
|
||||
|
||||
createRenderRoot() {
|
||||
return this;
|
||||
}
|
||||
|
||||
render() {
|
||||
return html`
|
||||
<div
|
||||
class="inline-flex items-center justify-center"
|
||||
style="width:${this.size}px; height:${this.size}px;"
|
||||
>
|
||||
<img
|
||||
src=${assetUrl("images/BottleCapIcon.svg")}
|
||||
alt="Caps"
|
||||
style="width:${this.size}px; height:${this.size}px;"
|
||||
draggable="false"
|
||||
/>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,14 @@
|
||||
import { html, LitElement, nothing, TemplateResult } from "lit";
|
||||
import { customElement, property } from "lit/decorators.js";
|
||||
import { Flag, Pattern } from "../../core/CosmeticSchemas";
|
||||
import { Flag, Pack, Pattern } from "../../core/CosmeticSchemas";
|
||||
import { PlayerPattern } from "../../core/Schemas";
|
||||
import { ResolvedCosmetic, translateCosmetic } from "../Cosmetics";
|
||||
import { translateText } from "../Utils";
|
||||
import "./CapIcon";
|
||||
import "./CosmeticContainer";
|
||||
import "./CosmeticInfo";
|
||||
import { renderPatternPreview } from "./PatternPreview";
|
||||
import "./PlutoniumIcon";
|
||||
|
||||
@customElement("cosmetic-button")
|
||||
export class CosmeticButton extends LitElement {
|
||||
@@ -38,6 +40,9 @@ export class CosmeticButton extends LitElement {
|
||||
if (this.resolved.type === "pattern") {
|
||||
return translateCosmetic("territory_patterns.pattern", c.name);
|
||||
}
|
||||
if (this.resolved.type === "pack") {
|
||||
return (c as Pack).displayName;
|
||||
}
|
||||
return translateCosmetic("flags", c.name);
|
||||
}
|
||||
|
||||
@@ -55,6 +60,33 @@ export class CosmeticButton extends LitElement {
|
||||
return renderPatternPreview(playerPattern, 150, 150);
|
||||
}
|
||||
|
||||
if (this.resolved.type === "pack") {
|
||||
const pack = this.resolved.cosmetic as Pack;
|
||||
const isHard = pack.currency === "hard";
|
||||
const icon = isHard
|
||||
? html`<plutonium-icon
|
||||
class="flex-1 flex items-center"
|
||||
.size=${100}
|
||||
></plutonium-icon>`
|
||||
: html`<cap-icon
|
||||
class="flex-1 flex items-center"
|
||||
.size=${100}
|
||||
></cap-icon>`;
|
||||
const colorClass = isHard ? "text-green-400" : "text-amber-700";
|
||||
const currencyKey = isHard ? "cosmetics.hard" : "cosmetics.soft";
|
||||
return html`<div
|
||||
class="flex flex-col items-center justify-end h-full w-full text-center gap-1 pb-1"
|
||||
>
|
||||
${icon}
|
||||
<span class="text-lg font-black ${colorClass}"
|
||||
>${pack.amount.toLocaleString()}</span
|
||||
>
|
||||
<span class="text-[10px] font-bold text-white/50 uppercase"
|
||||
>${translateText(currencyKey)}</span
|
||||
>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
const c = this.resolved.cosmetic as Flag;
|
||||
return html`<img
|
||||
src=${c.url}
|
||||
@@ -75,8 +107,9 @@ export class CosmeticButton extends LitElement {
|
||||
render() {
|
||||
const c = this.resolved.cosmetic;
|
||||
const isPurchasable = this.resolved.relationship === "purchasable";
|
||||
const isPattern = this.resolved.type === "pattern";
|
||||
const sizeClass = isPattern ? "gap-2 p-3 w-48" : "gap-1 p-1.5 w-36";
|
||||
const type = this.resolved.type;
|
||||
const isPattern = type === "pattern";
|
||||
const sizeClass = type === "flag" ? "gap-1 p-1.5 w-36" : "gap-2 p-3 w-48";
|
||||
const crazygamesClass = isPattern ? "no-crazygames " : "";
|
||||
|
||||
return html`
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { html, LitElement } from "lit";
|
||||
import { customElement, property } from "lit/decorators.js";
|
||||
import { translateText } from "../Utils";
|
||||
import "./CapIcon";
|
||||
import "./PlutoniumIcon";
|
||||
|
||||
@customElement("currency-display")
|
||||
export class CurrencyDisplay extends LitElement {
|
||||
@property({ type: Number })
|
||||
hard: number = 0;
|
||||
|
||||
@property({ type: Number })
|
||||
soft: number = 0;
|
||||
|
||||
createRenderRoot() {
|
||||
return this;
|
||||
}
|
||||
|
||||
render() {
|
||||
return html`
|
||||
<div class="flex gap-3 justify-center">
|
||||
<div
|
||||
class="flex items-center gap-1.5"
|
||||
title=${translateText("cosmetics.hard")}
|
||||
>
|
||||
<plutonium-icon .size=${16}></plutonium-icon>
|
||||
<span class="text-sm font-bold text-green-400"
|
||||
>${this.hard.toLocaleString()}</span
|
||||
>
|
||||
</div>
|
||||
<div
|
||||
class="flex items-center gap-1.5"
|
||||
title=${translateText("cosmetics.soft")}
|
||||
>
|
||||
<cap-icon .size=${20} style="margin-top:3px"></cap-icon>
|
||||
<span class="text-sm font-bold text-amber-700"
|
||||
>${this.soft.toLocaleString()}</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { html, LitElement } from "lit";
|
||||
import { customElement, property } from "lit/decorators.js";
|
||||
import { assetUrl } from "../../core/AssetUrls";
|
||||
|
||||
const STYLE_ID = "plutonium-icon-styles";
|
||||
if (!document.getElementById(STYLE_ID)) {
|
||||
const style = document.createElement("style");
|
||||
style.id = STYLE_ID;
|
||||
style.textContent = `
|
||||
@keyframes plutonium-pulse {
|
||||
0% { filter: drop-shadow(0 0 4px rgba(34,197,94,0.6)) drop-shadow(0 0 8px rgba(34,197,94,0.3)); scale: 1; }
|
||||
50% { filter: drop-shadow(0 0 10px rgba(34,197,94,0.9)) drop-shadow(0 0 20px rgba(34,197,94,0.5)) drop-shadow(0 0 30px rgba(34,197,94,0.2)); scale: 1.04; }
|
||||
100% { filter: drop-shadow(0 0 4px rgba(34,197,94,0.6)) drop-shadow(0 0 8px rgba(34,197,94,0.3)); scale: 1; }
|
||||
}
|
||||
@keyframes plutonium-rotate {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
@keyframes plutonium-jiggle {
|
||||
0%, 100% { translate: 0 0; }
|
||||
25% { translate: -0.4px 0.3px; }
|
||||
50% { translate: 0.3px -0.4px; }
|
||||
75% { translate: -0.3px -0.3px; }
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
|
||||
@customElement("plutonium-icon")
|
||||
export class PlutoniumIcon extends LitElement {
|
||||
@property({ type: Number })
|
||||
size: number = 48;
|
||||
|
||||
createRenderRoot() {
|
||||
return this;
|
||||
}
|
||||
|
||||
render() {
|
||||
return html`
|
||||
<div
|
||||
class="inline-flex items-center justify-center"
|
||||
style="width:${this.size}px; height:${this
|
||||
.size}px; animation: plutonium-pulse 2s ease-in-out infinite, plutonium-jiggle 0.15s linear infinite;"
|
||||
>
|
||||
<img
|
||||
src=${assetUrl("images/PlutoniumIcon.svg")}
|
||||
alt="Plutonium"
|
||||
style="width:${this.size}px; height:${this
|
||||
.size}px; animation: plutonium-rotate 7s linear infinite;"
|
||||
draggable="false"
|
||||
/>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -81,6 +81,12 @@ export const UserMeResponseSchema = z.object({
|
||||
.optional(),
|
||||
})
|
||||
.optional(),
|
||||
currency: z
|
||||
.object({
|
||||
soft: z.coerce.number(),
|
||||
hard: z.coerce.number(),
|
||||
})
|
||||
.optional(),
|
||||
}),
|
||||
});
|
||||
export type UserMeResponse = z.infer<typeof UserMeResponseSchema>;
|
||||
|
||||
@@ -6,6 +6,7 @@ import { PlayerPattern } from "./Schemas";
|
||||
export type Cosmetics = z.infer<typeof CosmeticsSchema>;
|
||||
export type Pattern = z.infer<typeof PatternSchema>;
|
||||
export type Flag = z.infer<typeof FlagSchema>;
|
||||
export type Pack = z.infer<typeof PackSchema>;
|
||||
export type PatternName = z.infer<typeof CosmeticNameSchema>;
|
||||
export type Product = z.infer<typeof ProductSchema>;
|
||||
export type ColorPalette = z.infer<typeof ColorPaletteSchema>;
|
||||
@@ -76,11 +77,18 @@ export const FlagSchema = CosmeticSchema.extend({
|
||||
url: z.string(),
|
||||
});
|
||||
|
||||
export const PackSchema = CosmeticSchema.extend({
|
||||
displayName: z.string(),
|
||||
currency: z.enum(["hard", "soft"]),
|
||||
amount: z.number().int().positive(),
|
||||
});
|
||||
|
||||
// Schema for resources/cosmetics/cosmetics.json
|
||||
export const CosmeticsSchema = z.object({
|
||||
colorPalettes: z.record(z.string(), ColorPaletteSchema).optional(),
|
||||
patterns: z.record(z.string(), PatternSchema),
|
||||
flags: z.record(z.string(), FlagSchema),
|
||||
currencyPacks: z.record(z.string(), PackSchema).optional(),
|
||||
});
|
||||
|
||||
export const DefaultPattern = {
|
||||
|
||||
Reference in New Issue
Block a user