From ebe3478e100db49e3c8766553c300e00d71e92e3 Mon Sep 17 00:00:00 2001 From: JP Date: Tue, 25 Nov 2025 15:15:34 +0000 Subject: [PATCH] Add coin transactions, store, and block quick sells --- app/components/IsometricGarden.tsx | 24 +- app/components/MainSlideover.tsx | 383 +++++++++++++++++++++++++++++ app/constants/blocks.ts | 17 +- app/contexts/auth-context.tsx | 47 +++- instant.schema.ts | 13 + 5 files changed, 468 insertions(+), 16 deletions(-) diff --git a/app/components/IsometricGarden.tsx b/app/components/IsometricGarden.tsx index 4992e92..77612cb 100644 --- a/app/components/IsometricGarden.tsx +++ b/app/components/IsometricGarden.tsx @@ -107,6 +107,7 @@ const IsometricGarden: React.FC = () => { $: { where: { "user.id": user.id, + removedAt: { $isNull: true }, }, }, }, @@ -116,6 +117,7 @@ const IsometricGarden: React.FC = () => { $: { where: { sessionId: sessionId, + removedAt: { $isNull: true }, }, }, }, @@ -163,9 +165,11 @@ const IsometricGarden: React.FC = () => { where: user ? { "user.id": user.id, + removedAt: { $isNull: true }, } : { sessionId: effectiveSessionId, + removedAt: { $isNull: true }, }, }, }, @@ -1050,11 +1054,13 @@ const IsometricGarden: React.FC = () => { "user.id": user.id, type: selectedInventoryBlock, x: { $isNull: true }, + removedAt: { $isNull: true }, } : { sessionId: effectiveSessionId, type: selectedInventoryBlock, x: { $isNull: true }, + removedAt: { $isNull: true }, }, limit: 1, }, @@ -1132,11 +1138,19 @@ const IsometricGarden: React.FC = () => { const { data: checkData } = await db.queryOnce({ blocks: { $: { - where: { - sessionId: effectiveSessionId, - type: selectedInventoryBlock, - x: { $isNull: true }, - }, + where: user + ? { + "user.id": user.id, + type: selectedInventoryBlock, + x: { $isNull: true }, + removedAt: { $isNull: true }, + } + : { + sessionId: effectiveSessionId, + type: selectedInventoryBlock, + x: { $isNull: true }, + removedAt: { $isNull: true }, + }, limit: 1, }, }, diff --git a/app/components/MainSlideover.tsx b/app/components/MainSlideover.tsx index b84c7fe..bc3a20e 100644 --- a/app/components/MainSlideover.tsx +++ b/app/components/MainSlideover.tsx @@ -15,6 +15,9 @@ import { SealCheckIcon, SparkleIcon, CircleNotchIcon, + CoinsIcon, + ShoppingCartSimpleIcon, + StorefrontIcon, } from "@phosphor-icons/react"; import { motion, AnimatePresence } from "motion/react"; import { useRef, useState, useEffect, useMemo, useCallback, memo } from "react"; @@ -24,6 +27,8 @@ import { BLOCK_TYPES, BlockTypeId, getBlockDisplayImage, + getBlockPurchaseCost, + getBlockSellValue, } from "../constants/blocks"; import PackOpeningModal from "./PackOpeningModal"; import NumberFlow, { NumberFlowGroup } from "@number-flow/react"; @@ -69,6 +74,16 @@ interface Block { z?: number; type: string; sessionId?: string; + removedAt?: string | null; +} + +interface Transaction { + id: string; + amount: number; + type: "credit" | "debit"; + reason?: string; + blockType?: string; + createdAt: string | number; } // Get or create a browser session ID @@ -363,6 +378,7 @@ type TabType = | "timer" | "sessions" | "blocks" + | "store" | "packs" | "help" | "supporter" @@ -477,6 +493,15 @@ const Tabs = memo( }} showIndicator={hasBlocks} /> + { + setActiveTab("store"); + setIsExpanded(true); + }} + /> (null); const [pausedAt, setPausedAt] = useState(null); + const [sellingBlockType, setSellingBlockType] = useState(null); + const [purchasingBlockType, setPurchasingBlockType] = + useState(null); const { user, profile, sessionId } = useAuth(); const effectiveSessionId = user?.id || sessionId || browserSessionId; @@ -650,10 +679,23 @@ const MainSlideover = memo(function MainSlideover({ ? { "user.id": user.id, x: { $isNull: true }, + removedAt: { $isNull: true }, } : { sessionId: effectiveSessionId, x: { $isNull: true }, + removedAt: { $isNull: true }, + }, + }, + }, + transactions: { + $: { + where: user + ? { + "user.id": user.id, + } + : { + sessionId: effectiveSessionId, }, }, }, @@ -663,6 +705,7 @@ const MainSlideover = memo(function MainSlideover({ const sessions = data?.sessions || []; const unplacedBlocks = data?.blocks || []; + const transactions = (data?.transactions || []) as Transaction[]; // Memoize expensive calculations const sessionsWithUnclaimedRewards = useMemo(() => { @@ -692,6 +735,25 @@ const MainSlideover = memo(function MainSlideover({ }, {} as Record); }, [unplacedBlocks]); + const coinBalance = useMemo(() => { + return transactions.reduce((acc, transaction) => acc + transaction.amount, 0); + }, [transactions]); + + const sortedTransactions = useMemo(() => { + return [...transactions].sort( + (a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime() + ); + }, [transactions]); + + const storeBlocks = useMemo(() => { + return Object.values(BLOCK_TYPES).sort((a, b) => { + if (a.category === b.category) { + return getBlockPurchaseCost(a) - getBlockPurchaseCost(b); + } + return a.category.localeCompare(b.category); + }); + }, []); + // Load the latest active timer on mount or when sessions change useEffect(() => { if (!sessions.length || activeSession) return; @@ -1043,6 +1105,151 @@ const MainSlideover = memo(function MainSlideover({ return `${mins} min`; }, []); + const createCoinTransaction = useCallback( + ( + amount: number, + type: "credit" | "debit", + reason: string, + blockType?: string + ) => { + const transactionId = id(); + const baseData: any = { + amount, + type, + reason, + blockType, + createdAt: new Date().toISOString(), + }; + + if (user) { + return db.tx.transactions[transactionId] + .update(baseData) + .link({ user: user.id }); + } + + return db.tx.transactions[transactionId].update({ + ...baseData, + sessionId: effectiveSessionId, + }); + }, + [effectiveSessionId, user] + ); + + const handleQuickSell = useCallback( + async (blockTypeId: string) => { + const blockType = BLOCK_TYPES[blockTypeId as BlockTypeId]; + if (!blockType || !effectiveSessionId) return; + + setSellingBlockType(blockTypeId); + try { + const { data } = await db.queryOnce({ + blocks: { + $: { + where: user + ? { + "user.id": user.id, + type: blockTypeId, + x: { $isNull: true }, + removedAt: { $isNull: true }, + } + : { + sessionId: effectiveSessionId, + type: blockTypeId, + x: { $isNull: true }, + removedAt: { $isNull: true }, + }, + limit: 1, + }, + }, + }); + + const blockToSell = data?.blocks?.[0]; + + if (!blockToSell) { + alert("No available blocks of this type to sell."); + return; + } + + const sellValue = getBlockSellValue(blockType); + await db.transact([ + db.tx.blocks[blockToSell.id].update({ + removedAt: new Date().toISOString(), + }), + createCoinTransaction( + sellValue, + "credit", + `Sold ${blockType.name}`, + blockTypeId + ), + ]); + } catch (error) { + console.error("Failed to sell block", error); + alert("Could not sell this block. Please try again."); + } finally { + setSellingBlockType(null); + } + }, + [createCoinTransaction, effectiveSessionId, user] + ); + + const handlePurchaseBlock = useCallback( + async (blockTypeId: string) => { + const blockType = BLOCK_TYPES[blockTypeId as BlockTypeId]; + if (!blockType || !effectiveSessionId) return; + + if (blockType.supporterOnly && !profile?.supporter) { + alert("This block is reserved for supporters."); + return; + } + + const cost = getBlockPurchaseCost(blockType); + if (coinBalance < cost) { + alert("Not enough coins to purchase this block."); + return; + } + + setPurchasingBlockType(blockTypeId); + const blockId = id(); + + try { + const purchaseTransaction = createCoinTransaction( + -cost, + "debit", + `Purchased ${blockType.name}`, + blockTypeId + ); + + const blockUpdate = user + ? db.tx.blocks[blockId].update({ + type: blockTypeId, + }).link({ + user: user.id, + }) + : db.tx.blocks[blockId].update({ + type: blockTypeId, + sessionId: effectiveSessionId, + }); + + await db.transact([purchaseTransaction, blockUpdate]); + onSelectBlockType?.(blockTypeId); + setActiveTab("blocks"); + } catch (error) { + console.error("Failed to purchase block", error); + alert("Could not purchase this block right now."); + } finally { + setPurchasingBlockType(null); + } + }, + [ + coinBalance, + createCoinTransaction, + effectiveSessionId, + onSelectBlockType, + profile?.supporter, + user, + ] + ); + // Update time/date less frequently const [currentTime, setCurrentTime] = useState(() => new Date().toLocaleTimeString("en-US", { @@ -1153,6 +1360,20 @@ const MainSlideover = memo(function MainSlideover({ )} */} +
+ +
+ Sell blocks or shop in the Store +
+
{ const blockType = BLOCK_TYPES[type as BlockTypeId]; if (!blockType) return null; + const sellValue = getBlockSellValue(blockType); return (
Click to place
+
); })} @@ -1933,6 +2168,154 @@ const MainSlideover = memo(function MainSlideover({ )} + {activeTab === "store" && ( +
+
+
+
+

+ Coin Balance +

+
+ + + {coinBalance.toLocaleString()} + +
+
+
+ Transaction-based balance +

+ Credits add coins, debits spend them +

+
+
+
+ +
+
+

+ Buy Blocks +

+
+ + Supporter blocks require an active badge +
+
+ +
+ {storeBlocks.map((block) => { + const cost = getBlockPurchaseCost(block); + const locked = block.supporterOnly && !profile?.supporter; + + return ( +
+
+
+

+ {block.name} +

+

+ {block.category} +

+
+ {block.supporterOnly && ( + + Supporter + + )} +
+ +
+ {block.name} +
+ +
+
+ + {cost} +
+ + {block.rarity} + +
+ + +
+ ); + })} +
+
+ +
+
+ +

+ Transactions +

+
+ + {sortedTransactions.length === 0 ? ( +

+ No transactions yet. Sell a block or make a purchase to get started. +

+ ) : ( +
+ {sortedTransactions.slice(0, 12).map((transaction) => { + const isCredit = transaction.amount >= 0; + return ( +
+
+ + {transaction.reason || "Transaction"} + + + {new Date(transaction.createdAt).toLocaleString()} + +
+
+ {isCredit ? "+" : ""} + {transaction.amount} +
+
+ ); + })} +
+ )} +
+
+ )} + {activeTab === "updates" && (
diff --git a/app/constants/blocks.ts b/app/constants/blocks.ts index 21f27cb..4ef6f3e 100644 --- a/app/constants/blocks.ts +++ b/app/constants/blocks.ts @@ -28,6 +28,15 @@ export interface BlockType { supporterOnly?: boolean; // Optional flag to indicate if the block is only available to supporters } +export const RARITY_PRICING = { + common: { buy: 30, sell: 10 }, + uncommon: { buy: 60, sell: 20 }, + rare: { buy: 150, sell: 50 }, + legendary: { buy: 300, sell: 120 }, +} as const; + +export type RarityPricingKey = keyof typeof RARITY_PRICING; + export const BLOCK_TYPES: Record = { 'dirt': { id: 'dirt', @@ -427,4 +436,10 @@ export function getBlockDisplayImage(blockType: BlockType): string | undefined { } // Extract block IDs for type safety -export type BlockTypeId = keyof typeof BLOCK_TYPES; \ No newline at end of file +export type BlockTypeId = keyof typeof BLOCK_TYPES; + +export const getBlockPurchaseCost = (blockType: BlockType) => + RARITY_PRICING[blockType.rarity].buy; + +export const getBlockSellValue = (blockType: BlockType) => + RARITY_PRICING[blockType.rarity].sell; diff --git a/app/contexts/auth-context.tsx b/app/contexts/auth-context.tsx index 11a5b58..b500cd1 100644 --- a/app/contexts/auth-context.tsx +++ b/app/contexts/auth-context.tsx @@ -93,7 +93,22 @@ function AuthContextProviderInner({ children }: { children: React.ReactNode }) { } }); - if (data?.blocks && data.blocks.length > 0) { + const { data: sessionTransactions } = await db.queryOnce({ + transactions: { + $: { + where: { + sessionId: sessionId + } + } + } + }); + + if ( + ((data?.blocks && data.blocks.length > 0) || + (sessionTransactions?.transactions && + sessionTransactions.transactions.length > 0)) && + user + ) { // get all the user blocks @@ -109,18 +124,30 @@ function AuthContextProviderInner({ children }: { children: React.ReactNode }) { // Create transactions to update each block to belong to the user as long as a user block doesnt already exist in x, y, z - const transactions = data.blocks.map(block => { - const userBlock = userBlocks.blocks.find((b: any) => b.x === block.x && b.y === block.y && b.z === block.z); - if (!userBlock) { - return db.tx.blocks[block.id].link({ - user: user.id - }); - } - }); + const transactions = [ + ...(data?.blocks || []).map(block => { + const userBlock = userBlocks.blocks.find((b: any) => b.x === block.x && b.y === block.y && b.z === block.z); + if (!userBlock) { + return db.tx.blocks[block.id].link({ + user: user.id + }); + } + return null; + }), + ...((sessionTransactions?.transactions || []).map((transaction: any) => + db.tx.transactions[transaction.id] + .update({ + sessionId: null + }) + .link({ + user: user.id + }) + ) || []) + ].filter(Boolean); // Execute all updates in a single transaction await db.transact(transactions as any); - console.log(`Transferred ${data.blocks.length} blocks from session to user ${user.id}`); + console.log(`Transferred ${data?.blocks?.length || 0} blocks and ${sessionTransactions?.transactions?.length || 0} transactions from session to user ${user.id}`); } } catch (error) { console.error('Failed to transfer session blocks:', error); diff --git a/instant.schema.ts b/instant.schema.ts index ac9c77c..556c44e 100644 --- a/instant.schema.ts +++ b/instant.schema.ts @@ -27,6 +27,7 @@ const _schema = i.schema({ type: i.string(), sessionId: i.string().optional().indexed(), plantedAt: i.date().optional(), + removedAt: i.date().optional(), }), sessions: i.entity({ sessionId: i.string().optional().indexed(), @@ -39,6 +40,14 @@ const _schema = i.schema({ cancelledAt: i.date().optional().indexed(), type: i.string().optional().indexed(), // 'focus' or 'break' }), + transactions: i.entity({ + amount: i.number(), + type: i.string(), // credit or debit + reason: i.string().optional(), + blockType: i.string().optional(), + sessionId: i.string().optional().indexed(), + createdAt: i.date(), + }), }, links: { userProfile: { @@ -53,6 +62,10 @@ const _schema = i.schema({ forward: { on: 'blocks', has: 'one', label: 'user' }, reverse: { on: '$users', has: 'many', label: 'blocks' } }, + userTransactions: { + forward: { on: 'transactions', has: 'one', label: 'user' }, + reverse: { on: '$users', has: 'many', label: 'transactions' } + }, }, rooms: {}, });