Compare commits

..
6 Commits
Author SHA1 Message Date
zaremate bf813acfa6 fix(gitignore): add app.pid to ignore list and ensure cron_update.log is properly formatted 2026-03-26 23:44:16 +01:00
zaremate 1b867857b8 fix(gitignore): add cron_update.log to ignore list and ensure proper formatting 2026-03-26 23:42:52 +01:00
zaremate 5e2eaff7c4 feat(transfer): implement transfer functionality with user selection and balance validation 2026-03-26 23:31:57 +01:00
zaremate 992c9a2e63 refactor(blackjack): remove Blackjack game components and actions
refactor(home): update sellableData state to use array type
fix(account): improve type safety for user data fetching
fix(cart): streamline item removal and purchase handling
2026-03-26 23:13:54 +01:00
zaremate eef355a791 fix(sellable): update addSellable function to use availableItems and enhance logging 2026-03-26 23:03:58 +01:00
zaremate 8b4eaee510 feat(cart): update CartItem model to use composite key and adjust related logic
fix(cart): ensure cart item updates and deletions are based on cartId and itemId

feat(buy): implement balance checks and updates during purchase process

fix(sellable): ensure cart items are deleted when a sellable is removed

feat(user): add conflict checks for shop creation to prevent ID collisions
2026-03-26 20:33:11 +01:00
22 changed files with 395 additions and 499 deletions
+4 -1
View File
@@ -43,4 +43,7 @@ yarn-error.log*
*.tsbuildinfo *.tsbuildinfo
# idea files # idea files
.idea .idea
cron_update.log
app.pid
File diff suppressed because one or more lines are too long
+8 -2
View File
@@ -12274,15 +12274,16 @@ export namespace Prisma {
} }
export type CartItemWhereUniqueInput = Prisma.AtLeast<{ export type CartItemWhereUniqueInput = Prisma.AtLeast<{
itemId?: string cartId_itemId?: CartItemCartIdItemIdCompoundUniqueInput
AND?: CartItemWhereInput | CartItemWhereInput[] AND?: CartItemWhereInput | CartItemWhereInput[]
OR?: CartItemWhereInput[] OR?: CartItemWhereInput[]
NOT?: CartItemWhereInput | CartItemWhereInput[] NOT?: CartItemWhereInput | CartItemWhereInput[]
itemId?: StringFilter<"CartItem"> | string
quantity?: IntFilter<"CartItem"> | number quantity?: IntFilter<"CartItem"> | number
cartId?: StringFilter<"CartItem"> | string cartId?: StringFilter<"CartItem"> | string
cart?: XOR<CartScalarRelationFilter, CartWhereInput> cart?: XOR<CartScalarRelationFilter, CartWhereInput>
sellable?: XOR<SellableScalarRelationFilter, SellableWhereInput> sellable?: XOR<SellableScalarRelationFilter, SellableWhereInput>
}, "itemId"> }, "cartId_itemId">
export type CartItemOrderByWithAggregationInput = { export type CartItemOrderByWithAggregationInput = {
itemId?: SortOrder itemId?: SortOrder
@@ -13532,6 +13533,11 @@ export namespace Prisma {
search: string search: string
} }
export type CartItemCartIdItemIdCompoundUniqueInput = {
cartId: string
itemId: string
}
export type CartItemCountOrderByAggregateInput = { export type CartItemCountOrderByAggregateInput = {
itemId?: SortOrder itemId?: SortOrder
quantity?: SortOrder quantity?: SortOrder
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -1,5 +1,5 @@
{ {
"name": "prisma-client-60cecad7f5344b2ab216d0c215477a4a9f0fb3ec9e5201c830e0c4ac3caafb77", "name": "prisma-client-7f3601640bc9191b9770c2fe70db074b0364e5d01ec2def94d81cee0c9a18abc",
"main": "index.js", "main": "index.js",
"types": "index.d.ts", "types": "index.d.ts",
"browser": "default.js", "browser": "default.js",
+3 -1
View File
@@ -117,12 +117,14 @@ model Cart {
} }
model CartItem { model CartItem {
itemId String @id itemId String
quantity Int quantity Int
cartId String cartId String
cart Cart @relation(fields: [cartId], references: [userId], onDelete: Cascade, onUpdate: Cascade) cart Cart @relation(fields: [cartId], references: [userId], onDelete: Cascade, onUpdate: Cascade)
sellable Sellable @relation(fields: [itemId], references: [id], onDelete: Cascade, onUpdate: Cascade) sellable Sellable @relation(fields: [itemId], references: [id], onDelete: Cascade, onUpdate: Cascade)
@@id([cartId, itemId])
} }
////////////////////// //////////////////////
+4 -4
View File
@@ -263,7 +263,7 @@ const config = {
"value": "prisma-client-js" "value": "prisma-client-js"
}, },
"output": { "output": {
"value": "/var/home/zaremate/Documents/cc-create-shop/generated/prisma", "value": "/var/mnt/f5a90b51-c040-4601-a32c-9629866f15a2/Documents/cc-create-shop/generated/prisma",
"fromEnvVar": null "fromEnvVar": null
}, },
"config": { "config": {
@@ -277,7 +277,7 @@ const config = {
} }
], ],
"previewFeatures": [], "previewFeatures": [],
"sourceFilePath": "/var/home/zaremate/Documents/cc-create-shop/prisma/schema.prisma", "sourceFilePath": "/var/mnt/f5a90b51-c040-4601-a32c-9629866f15a2/Documents/cc-create-shop/prisma/schema.prisma",
"isCustomOutput": true "isCustomOutput": true
}, },
"relativeEnvPaths": { "relativeEnvPaths": {
@@ -299,8 +299,8 @@ const config = {
} }
} }
}, },
"inlineSchema": "// This is your Prisma schema file,\n// learn more about it in the docs: https://pris.ly/d/prisma-schema\n\ngenerator client {\n provider = \"prisma-client-js\"\n output = \"../generated/prisma\"\n}\n\ndatasource db {\n provider = \"mysql\"\n // NOTE: When using mysql or sqlserver, uncomment the @db.Text annotations in model Account below\n // Further reading:\n // https://next-auth.js.org/adapters/prisma#create-the-prisma-schema\n // https://www.prisma.io/docs/reference/api-reference/prisma-schema-reference#string\n url = env(\"DATABASE_URL\")\n}\n\n// Necessary for Next auth\nmodel Account {\n id String @id @default(cuid())\n userId String\n type String\n provider String\n providerAccountId String\n refresh_token String? @db.Text\n access_token String? // @db.Text\n expires_at Int?\n token_type String?\n scope String?\n id_token String? // @db.Text\n session_state String?\n user User @relation(fields: [userId], references: [id], onDelete: Cascade)\n refresh_token_expires_in Int?\n\n @@unique([provider, providerAccountId])\n}\n\nmodel Session {\n id String @id @default(cuid())\n sessionToken String @unique\n userId String\n expires DateTime\n user User @relation(fields: [userId], references: [id], onDelete: Cascade)\n}\n\nmodel User {\n id String @id @default(cuid())\n name String?\n email String? @unique\n emailVerified DateTime?\n image String?\n balance Float @default(1000)\n\n accounts Account[]\n sessions Session[]\n shops Shop[]\n carts Cart[]\n adresses Adress[]\n}\n\nmodel VerificationToken {\n identifier String\n token String @unique\n expires DateTime\n\n @@unique([identifier, token])\n}\n\n//////////////////////\n// SHOP\n//////////////////////\n\nmodel Shop {\n id Int @id\n userId String\n label String\n\n user User @relation(fields: [userId], references: [id], onDelete: Cascade, onUpdate: Cascade)\n items Item[]\n sellables Sellable[]\n}\n\nmodel Item {\n item_name String\n shopId Int\n stock Int\n\n shop Shop @relation(fields: [shopId], references: [id], onDelete: Cascade, onUpdate: Cascade)\n sellables Sellable[]\n\n @@id([item_name, shopId])\n}\n\nmodel Sellable {\n id String @id @default(cuid())\n item_name String\n shopId Int\n amount Int\n price Float\n enabled Boolean @default(true)\n\n shop Shop @relation(fields: [shopId], references: [id], onDelete: Cascade)\n item Item @relation(fields: [item_name, shopId], references: [item_name, shopId], onDelete: Cascade)\n\n cartItems CartItem[]\n}\n\n//////////////////////\n// CART\n//////////////////////\n\nmodel Cart {\n userId String @id\n\n user User @relation(fields: [userId], references: [id], onDelete: Cascade, onUpdate: Cascade)\n cartItems CartItem[]\n}\n\nmodel CartItem {\n itemId String @id\n quantity Int\n cartId String\n\n cart Cart @relation(fields: [cartId], references: [userId], onDelete: Cascade, onUpdate: Cascade)\n sellable Sellable @relation(fields: [itemId], references: [id], onDelete: Cascade, onUpdate: Cascade)\n}\n\n//////////////////////\n// ADDRESS\n//////////////////////\n\nmodel Adress {\n id String @id @default(cuid())\n userId String\n adress String\n\n user User @relation(fields: [userId], references: [id], onDelete: Cascade, onUpdate: Cascade)\n}\n", "inlineSchema": "// This is your Prisma schema file,\n// learn more about it in the docs: https://pris.ly/d/prisma-schema\n\ngenerator client {\n provider = \"prisma-client-js\"\n output = \"../generated/prisma\"\n}\n\ndatasource db {\n provider = \"mysql\"\n // NOTE: When using mysql or sqlserver, uncomment the @db.Text annotations in model Account below\n // Further reading:\n // https://next-auth.js.org/adapters/prisma#create-the-prisma-schema\n // https://www.prisma.io/docs/reference/api-reference/prisma-schema-reference#string\n url = env(\"DATABASE_URL\")\n}\n\n// Necessary for Next auth\nmodel Account {\n id String @id @default(cuid())\n userId String\n type String\n provider String\n providerAccountId String\n refresh_token String? @db.Text\n access_token String? // @db.Text\n expires_at Int?\n token_type String?\n scope String?\n id_token String? // @db.Text\n session_state String?\n user User @relation(fields: [userId], references: [id], onDelete: Cascade)\n refresh_token_expires_in Int?\n\n @@unique([provider, providerAccountId])\n}\n\nmodel Session {\n id String @id @default(cuid())\n sessionToken String @unique\n userId String\n expires DateTime\n user User @relation(fields: [userId], references: [id], onDelete: Cascade)\n}\n\nmodel User {\n id String @id @default(cuid())\n name String?\n email String? @unique\n emailVerified DateTime?\n image String?\n balance Float @default(1000)\n\n accounts Account[]\n sessions Session[]\n shops Shop[]\n carts Cart[]\n adresses Adress[]\n}\n\nmodel VerificationToken {\n identifier String\n token String @unique\n expires DateTime\n\n @@unique([identifier, token])\n}\n\n//////////////////////\n// SHOP\n//////////////////////\n\nmodel Shop {\n id Int @id\n userId String\n label String\n\n user User @relation(fields: [userId], references: [id], onDelete: Cascade, onUpdate: Cascade)\n items Item[]\n sellables Sellable[]\n}\n\nmodel Item {\n item_name String\n shopId Int\n stock Int\n\n shop Shop @relation(fields: [shopId], references: [id], onDelete: Cascade, onUpdate: Cascade)\n sellables Sellable[]\n\n @@id([item_name, shopId])\n}\n\nmodel Sellable {\n id String @id @default(cuid())\n item_name String\n shopId Int\n amount Int\n price Float\n enabled Boolean @default(true)\n\n shop Shop @relation(fields: [shopId], references: [id], onDelete: Cascade)\n item Item @relation(fields: [item_name, shopId], references: [item_name, shopId], onDelete: Cascade)\n\n cartItems CartItem[]\n}\n\n//////////////////////\n// CART\n//////////////////////\n\nmodel Cart {\n userId String @id\n\n user User @relation(fields: [userId], references: [id], onDelete: Cascade, onUpdate: Cascade)\n cartItems CartItem[]\n}\n\nmodel CartItem {\n itemId String\n quantity Int\n cartId String\n\n cart Cart @relation(fields: [cartId], references: [userId], onDelete: Cascade, onUpdate: Cascade)\n sellable Sellable @relation(fields: [itemId], references: [id], onDelete: Cascade, onUpdate: Cascade)\n\n @@id([cartId, itemId])\n}\n\n//////////////////////\n// ADDRESS\n//////////////////////\n\nmodel Adress {\n id String @id @default(cuid())\n userId String\n adress String\n\n user User @relation(fields: [userId], references: [id], onDelete: Cascade, onUpdate: Cascade)\n}\n",
"inlineSchemaHash": "6067e34ba0a13fe49002e5198964784b3870ee91700e9649c3b9ffe64ee9c688", "inlineSchemaHash": "f1f25ffbae59b74d980dd4ecdf5d9929994691bf2809d9a3468ab2985796fd99",
"copyEngine": true "copyEngine": true
} }
config.dirname = '/' config.dirname = '/'
+3 -1
View File
@@ -117,12 +117,14 @@ model Cart {
} }
model CartItem { model CartItem {
itemId String @id itemId String
quantity Int quantity Int
cartId String cartId String
cart Cart @relation(fields: [cartId], references: [userId], onDelete: Cascade, onUpdate: Cascade) cart Cart @relation(fields: [cartId], references: [userId], onDelete: Cascade, onUpdate: Cascade)
sellable Sellable @relation(fields: [itemId], references: [id], onDelete: Cascade, onUpdate: Cascade) sellable Sellable @relation(fields: [itemId], references: [id], onDelete: Cascade, onUpdate: Cascade)
@@id([cartId, itemId])
} }
////////////////////// //////////////////////
+43 -2
View File
@@ -24,7 +24,10 @@ export async function POST(request: Request) {
// Fetch all cart items with related sellable and item to get shopId // Fetch all cart items with related sellable and item to get shopId
const cartItemsWithShop = await db.cartItem.findMany({ const cartItemsWithShop = await db.cartItem.findMany({
where: { itemId: { in: cart.map((c) => c.id) } }, where: {
cartId: userId,
itemId: { in: cart.map((c) => c.id) },
},
include: { include: {
sellable: { sellable: {
include: { include: {
@@ -49,10 +52,27 @@ export async function POST(request: Request) {
itemsByShop[shopId] ??= []; // initialize if undefined or null itemsByShop[shopId] ??= []; // initialize if undefined or null
itemsByShop[shopId].push({ itemsByShop[shopId].push({
id: ci.sellable.item.item_name, // API expects item_name as id id: ci.sellable.item.item_name, // API expects item_name as id
quantity: ci.quantity, quantity: ci.quantity * ci.sellable.amount, // total quantity based on cart item quantity and sellable amount
}); });
} }
// Check if user has enough balance for all items
const totalCost = cartItemsWithShop.reduce((sum, ci) => {
return sum + ci.quantity * ci.sellable.price;
}, 0);
const user = await db.user.findUnique({
where: { id: userId },
select: { balance: true },
});
if (!user || user.balance < totalCost) {
return NextResponse.json(
{ error: "Insufficient balance" },
{ status: 400 },
);
}
console.log(itemsByShop); console.log(itemsByShop);
// Send requests per shop // Send requests per shop
@@ -79,6 +99,27 @@ export async function POST(request: Request) {
); );
} }
// Deduct the total cost from the user's balance
await db.user.update({
where: { id: userId },
data: { balance: { decrement: totalCost } },
});
// Add balance to shop owner
console.log("Updating shop owner balance for shop:", shopId);
const shop = await db.shop.findUnique({
where: { id: Number(shopId) },
select: { userId: true },
});
console.log("Shop owner userId:", shop?.userId);
console.log("Total cost to add to shop owner:", totalCost);
if (shop) {
await db.user.update({
where: { id: shop.userId },
data: { balance: { increment: totalCost } },
});
}
// Delete successfully sent items from cart // Delete successfully sent items from cart
const itemIds = items.map((i) => i.id); const itemIds = items.map((i) => i.id);
await db.cartItem.deleteMany({ await db.cartItem.deleteMany({
+27 -10
View File
@@ -39,29 +39,46 @@ export async function PATCH(request: Request) {
}); });
// Check if the item already exists in the cart // Check if the item already exists in the cart
const existingCartItem = await db.cartItem.findUnique({ const existingCartItem = await db.cartItem.findFirst({
where: { itemId }, where: {
cartId: userId,
itemId,
},
}); });
if (existingCartItem) { if (existingCartItem) {
if (quantity <= 0 && quantity * -1 >= existingCartItem.quantity) { const nextQuantity = existingCartItem.quantity + quantity;
await db.cartItem.delete({
where: { itemId }, if (nextQuantity <= 0) {
await db.cartItem.deleteMany({
where: {
cartId: userId,
itemId,
},
}); });
} else { } else {
// Update quantity await db.cartItem.updateMany({
await db.cartItem.update({ where: {
where: { itemId }, cartId: userId,
data: { quantity: existingCartItem.quantity + quantity }, itemId,
},
data: { quantity: nextQuantity },
}); });
} }
} else { } else {
if (quantity < 0) {
return NextResponse.json(
{ error: "Cannot remove an item that is not in the cart" },
{ status: 400 },
);
}
// Add new item // Add new item
await db.cartItem.create({ await db.cartItem.create({
data: { data: {
itemId, itemId,
quantity, quantity,
cartId: userId, cartId: cart.userId,
}, },
}); });
} }
+4
View File
@@ -97,6 +97,10 @@ export async function DELETE(request: Request) {
return NextResponse.json({ error: "Item not found" }, { status: 404 }); return NextResponse.json({ error: "Item not found" }, { status: 404 });
} }
await db.cartItem.deleteMany({
where: { itemId: item.id },
});
await db.sellable.delete({ await db.sellable.delete({
where: { where: {
id: item.id, id: item.id,
+75
View File
@@ -0,0 +1,75 @@
import { NextResponse } from "next/server";
import { auth } from "~/server/auth";
import { db } from "~/server/db";
export async function POST(request: Request) {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { toUserId, amount } = (await request.json()) as {
toUserId: string;
amount: number;
};
if (!toUserId || typeof toUserId !== "string") {
return NextResponse.json({ error: "Invalid recipient" }, { status: 400 });
}
if (typeof amount !== "number" || isNaN(amount) || amount <= 0) {
return NextResponse.json(
{ error: "Amount must be a positive number" },
{ status: 400 },
);
}
const fromUserId = session.user.id;
if (fromUserId === toUserId) {
return NextResponse.json(
{ error: "Cannot transfer to yourself" },
{ status: 400 },
);
}
const [sender, recipient] = await Promise.all([
db.user.findUnique({
where: { id: fromUserId },
select: { id: true, balance: true },
}),
db.user.findUnique({
where: { id: toUserId },
select: { id: true, name: true },
}),
]);
if (!sender) {
return NextResponse.json({ error: "Sender not found" }, { status: 404 });
}
if (!recipient) {
return NextResponse.json({ error: "Recipient not found" }, { status: 404 });
}
if (sender.balance < amount) {
return NextResponse.json(
{ error: "Insufficient balance" },
{ status: 400 },
);
}
await db.$transaction([
db.user.update({
where: { id: fromUserId },
data: { balance: { decrement: amount } },
}),
db.user.update({
where: { id: toUserId },
data: { balance: { increment: amount } },
}),
]);
return NextResponse.json({ success: true, amount, to: recipient.name });
}
+16
View File
@@ -141,6 +141,22 @@ export async function POST(request: Request) {
const shopsToCreate = body.shops.filter((s) => !currentShopIds.has(s.id)); const shopsToCreate = body.shops.filter((s) => !currentShopIds.has(s.id));
if (shopsToCreate.length > 0) { if (shopsToCreate.length > 0) {
const conflictingShops = await db.shop.findMany({
where: {
id: { in: shopsToCreate.map((s) => s.id) },
NOT: { userId: session.user.id },
},
select: { id: true },
});
if (conflictingShops.length > 0) {
const conflictIds = conflictingShops.map((s) => s.id).join(", ");
return NextResponse.json(
{ error: `Shop ID(s) already claimed by another user: ${conflictIds}` },
{ status: 409 },
);
}
await db.shop.createMany({ await db.shop.createMany({
data: shopsToCreate.map((s) => ({ data: shopsToCreate.map((s) => ({
id: s.id, id: s.id,
+19
View File
@@ -0,0 +1,19 @@
import { NextResponse } from "next/server";
import { auth } from "~/server/auth";
import { db } from "~/server/db";
export async function GET() {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const users = await db.user.findMany({
where: { id: { not: session.user.id } },
select: { id: true, name: true },
orderBy: { name: "asc" },
});
return NextResponse.json(users);
}
-354
View File
@@ -1,354 +0,0 @@
"use client";
import { useRef, useState, useTransition } from "react";
import { startGame, hit, stand, revealDealer } from "./actions";
import { motion } from "framer-motion";
type Phase = "idle" | "playing" | "revealing" | "finished";
export default function BlackjackClient() {
const [state, setState] = useState<any>(null);
const [gameId, setGameId] = useState<string | null>(null);
const [phase, setPhase] = useState<Phase>("idle");
const [visibleDealerCount, setVisibleDealerCount] = useState(1);
const dealerRevealIndex = useRef(0);
const MAX_DEALER_CARDS = 5;
const CARD_WIDTH = 64; // w-16
const GAP = 8; // space-x-2
const [, startTransition] = useTransition();
// animation snapshots
const lastPlayerLen = useRef(0);
/* ---------------- actions ---------------- */
const start = async () => {
const res: any = await startGame(10);
lastPlayerLen.current = 0;
setVisibleDealerCount(1);
setGameId(res.gameId);
setPhase("playing");
setState(res);
};
const getDealerCards = () => {
// if we don't yet have any dealer info, nothing to render
if (!state?.dealerUpcard && !state?.dealer) return [];
// ALWAYS start with upcard + hole slot; prefer the authoritative dealer state when available
const upcard = state?.dealer?.[0] ?? state.dealerUpcard[0];
const base = [upcard, "__HOLE__"];
// If dealer is revealed, append extra cards ONLY
if (state?.dealer && state.dealer.length > 2) {
return [...base, ...state.dealer.slice(2)];
}
return base;
};
const onHit = async () => {
lastPlayerLen.current = state.player.length;
const res = await hit(gameId!);
setState((s: any) => ({ ...s, ...res }));
if (res.status === "bust") {
const dealerRes = await revealDealer(gameId!);
setState((s: any) => ({
...s,
dealer: dealerRes.dealer,
dealerTotal: dealerRes.dealerTotal,
status: "bust",
}));
setVisibleDealerCount(dealerRes.dealer.length);
setPhase("finished");
}
};
const onStand = async () => {
const res = await stand(gameId!);
setPhase("revealing");
// start with only upcard visible; we'll reveal the hole (i=1) then extra cards (i>=2)
dealerRevealIndex.current = 0;
setVisibleDealerCount(1);
setState((s: any) => ({
...s,
dealer: res.dealer,
dealerTotal: res.dealerTotal,
status: "finished",
result: res.result,
}));
// sequential reveal:
// - i = 1 -> flip the hole card
// - i >= 2 -> reveal each drawn card in order
for (let i = 1; i < res.dealer.length; i++) {
// wait before revealing next slot
await new Promise((r) => setTimeout(r, 1600));
// set which dealer index should be animating (1 for hole, 2..n for drawn)
dealerRevealIndex.current = i;
// make the slot visible (i+1 slots visible: 0..i)
setVisibleDealerCount(i + 1);
// small pause so the flip animation has time to start/finish before next reveal step
await new Promise((r) => setTimeout(r, 600));
}
setPhase("finished");
};
/* ---------------- cards ---------------- */
const CardStatic = ({ c }: any) => (
<div className="flex h-24 w-16 items-center justify-center rounded bg-white text-xl font-bold text-black shadow-xl">
{c.label}
{c.suit}
</div>
);
const CardBack = () => (
<div className="flex h-24 w-16 items-center justify-center rounded bg-zinc-700 shadow-xl">
<span className="text-xs tracking-widest text-zinc-300">RUST</span>
</div>
);
const CardFlip = ({ c }: any) => (
<motion.div
className="perspective h-24 w-16"
initial={{ y: -60, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
transition={{ duration: 1.0 }}
>
<motion.div
className="relative h-full w-full"
initial={{ rotateY: 0 }}
animate={{ rotateY: 180 }}
transition={{
duration: 1.0,
delay: 0.8,
ease: "easeInOut",
}}
style={{ transformStyle: "preserve-3d" }}
>
{/* BACK */}
<div
className="absolute inset-0 flex items-center justify-center rounded bg-zinc-700 shadow-xl"
style={{ backfaceVisibility: "hidden" }}
>
<span className="text-xs tracking-widest text-zinc-300">RUST</span>
</div>
{/* FRONT */}
<div
className="absolute inset-0 flex items-center justify-center rounded bg-white text-xl font-bold text-black shadow-xl"
style={{
backfaceVisibility: "hidden",
transform: "rotateY(180deg)",
}}
>
{c.label}
{c.suit}
</div>
</motion.div>
</motion.div>
);
function isSoftHand(hand: any[], total: number) {
const hasAce = hand.some((c) => c.label === "A");
if (!hasAce) return false;
// if counting all aces as 1 would reduce the total, it's soft
const minTotal = hand.reduce(
(sum, c) => sum + (c.label === "A" ? 1 : c.value),
0,
);
return minTotal !== total;
}
/* Stable key helpers to prevent React remount flashes */
const dealerCardKey = (slot: any, index: number) => {
// slot can be a card object or "__HOLE__"
if (slot === "__HOLE__") {
// When hole is hidden, use a stable hidden key so it doesn't briefly mount/unmount
if (phase === "playing" || state?.status === "bust") {
return "dealer-hole-hidden";
}
// When revealed, key should reflect the actual dealer card identity
const revealedCard = state?.dealer?.[1];
if (revealedCard?.code) return `dealer-${revealedCard.code}`;
if (revealedCard)
return `dealer-${revealedCard.label}${revealedCard.suit}`;
return `dealer-hole-${index}`;
}
// For upcard and other visible cards prefer a unique code if available
if (slot?.code) return `dealer-${slot.code}`;
if (slot?.label && slot?.suit)
return `dealer-${slot.label}${slot.suit}-${index}`;
// fallback stable index
return `dealer-${index}`;
};
/* ---------------- render ---------------- */
return (
<div className="flex min-h-screen items-center justify-center bg-zinc-900 text-white">
<div className="w-[420px] space-y-4 rounded-lg bg-zinc-800 p-4 shadow-2xl">
{/* DEALER */}
<div className="flex min-h-[96px] justify-center space-x-2">
{getDealerCards().map((c, i) => {
// Always render upcard (index 0) regardless of visibleDealerCount
if (i === 0 && c && c !== "__HOLE__") {
const key = dealerCardKey(c, i);
return <CardStatic key={key} c={c} />;
}
// HOLE CARD (index 1)
if (i === 1) {
const key = dealerCardKey(c, i);
// Hidden hole card during play or when player busts
// Also respect visibleDealerCount: hole is visible only when visibleDealerCount >= 2
if (
phase === "playing" ||
state?.status === "bust" ||
visibleDealerCount < 2
) {
return <CardBack key={key} />;
}
// During reveal phase: flip the hole only when dealerRevealIndex === 1
if (phase === "revealing" && state?.dealer) {
if (dealerRevealIndex.current === 1) {
return <CardFlip key={key} c={state.dealer[1]} />;
} else {
return <CardStatic key={key} c={state.dealer[1]} />;
}
}
// Final static revealed card
return <CardStatic key={key} c={state.dealer?.[1]} />;
}
// DRAWN CARDS (index >= 2)
if (i >= 2 && state?.dealer) {
// only render this slot once it's been made visible by visibleDealerCount
if (i >= visibleDealerCount) return null;
const key = dealerCardKey(state.dealer[i], i);
// If we're currently revealing this index, play the flip animation.
// Otherwise show the static face
if (phase === "revealing" && i === dealerRevealIndex.current) {
return <CardFlip key={key} c={state.dealer[i]} />;
}
return <CardStatic key={key} c={state.dealer[i]} />;
}
return null;
})}
</div>
{/* TERMINAL */}
<div className="min-h-[130px] rounded border-2 border-green-500 bg-black p-3 font-mono text-sm text-green-400">
{phase === "idle" && <p>&gt; INSERT SCRAP</p>}
{phase === "playing" && state && (
<>
<p>
&gt; PLAYER TOTAL: {state.playerTotal}
{isSoftHand(state.player, state.playerTotal) && " (SOFT)"}
</p>
<p>&gt; DEALER WAITING...</p>
</>
)}
{phase === "revealing" && (
<motion.p
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 1 }}
>
&gt; DEALER DRAWING...
</motion.p>
)}
{phase === "finished" && state && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 1 }}
>
<p>&gt; PLAYER: {state.playerTotal}</p>
{state.status === "bust" && (
<p className="text-red-500">&gt; PLAYER BUSTED</p>
)}
{/* Always show dealer score once dealer is revealed */}
{state.dealerTotal !== undefined && (
<p>&gt; DEALER: {state.dealerTotal}</p>
)}
{state.result && <p>&gt; RESULT: {state.result.toUpperCase()}</p>}
</motion.div>
)}
</div>
{/* PLAYER */}
<div className="flex min-h-[96px] justify-center space-x-2">
{state?.player?.map((c: any, i: number) =>
phase === "playing" && i >= lastPlayerLen.current ? (
<CardFlip
key={`player-${c?.code ?? c?.label + c?.suit + i}`}
c={c}
/>
) : (
<CardStatic
key={`player-${c?.code ?? c?.label + c?.suit + i}`}
c={c}
/>
),
)}
</div>
{/* CONTROLS */}
<div className="flex justify-center space-x-3">
{phase === "idle" && (
<button
onClick={() => startTransition(start)}
className="rounded bg-green-700 px-4 py-2 font-bold"
>
INSERT SCRAP
</button>
)}
{phase === "playing" && state?.status === "playing" && (
<>
<button
onClick={() => startTransition(onHit)}
className="rounded bg-green-700 px-4 py-2"
>
HIT
</button>
<button
onClick={() => startTransition(onStand)}
className="rounded bg-red-700 px-4 py-2"
>
STAND
</button>
</>
)}
</div>
</div>
</div>
);
}
-84
View File
@@ -1,84 +0,0 @@
"use server";
import crypto from "crypto";
import {
createDeck,
shuffle,
calculateHand,
type Card,
} from "~/lib/blackjack/engine";
// In-memory storage (replace with DB/Redis in production)
const games = new Map<string, any>();
export async function startGame(bet: number) {
const deck = shuffle(createDeck());
const player: Card[] = [deck.pop()!, deck.pop()!];
const dealer: Card[] = [deck.pop()!, deck.pop()!];
const gameId = crypto.randomUUID();
games.set(gameId, { deck, player, dealer, bet, status: "playing" });
return {
gameId,
player,
dealerUpcard: [dealer[0]],
playerTotal: calculateHand(player),
status: "playing",
};
}
export async function hit(gameId: string) {
const game = games.get(gameId);
if (!game) throw new Error("Game not found");
const card = game.deck.pop();
game.player.push(card);
const playerTotal = calculateHand(game.player);
if (playerTotal > 21) game.status = "bust";
return {
player: game.player,
playerTotal,
status: game.status,
};
}
export async function stand(gameId: string) {
const game = games.get(gameId);
if (!game) throw new Error("Game not found");
while (calculateHand(game.dealer) < 17) {
game.dealer.push(game.deck.pop());
}
const playerTotal = calculateHand(game.player);
const dealerTotal = calculateHand(game.dealer);
let result: "win" | "lose" | "push" = "lose";
if (dealerTotal > 21 || playerTotal > dealerTotal) result = "win";
if (playerTotal === dealerTotal) result = "push";
// TODO: update user balance here
games.delete(gameId);
return {
dealer: game.dealer,
dealerTotal,
result,
};
}
export async function revealDealer(gameId: string) {
const game = games.get(gameId);
if (!game) throw new Error("Game not found");
return {
dealer: game.dealer,
dealerTotal: calculateHand(game.dealer),
};
}
-10
View File
@@ -1,10 +0,0 @@
import BlackjackClient from "./BlackjackClient";
export default function BlackjackPage() {
return (
<div className="space-y-4 p-6">
<h1 className="text-3xl font-bold">Blackjack</h1>
<BlackjackClient />
</div>
);
}
+7 -7
View File
@@ -8,6 +8,7 @@ import CartButton from "~/components/cart";
import type { Session } from "next-auth"; import type { Session } from "next-auth";
import AccountButton from "./account"; import AccountButton from "./account";
import SellableItemsButton from "./new_items"; import SellableItemsButton from "./new_items";
import TransferButton from "./transfer";
type UserResponse = { type UserResponse = {
id: string; id: string;
@@ -43,9 +44,7 @@ type Props = {
export default function HomeClient({ session }: Props) { export default function HomeClient({ session }: Props) {
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
const [userData, setUserData] = useState<UserResponse | null>(null); const [userData, setUserData] = useState<UserResponse | null>(null);
const [sellableData, setSellableData] = useState<SellableResponse | null>( const [sellableData, setSellableData] = useState<SellableResponse[]>([]);
null,
);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
// Fetch /api/user once and store globally here // Fetch /api/user once and store globally here
@@ -68,7 +67,7 @@ export default function HomeClient({ session }: Props) {
setLoading(true); setLoading(true);
const res = await fetch("/api/sellable"); const res = await fetch("/api/sellable");
if (!res.ok) throw new Error("Failed to fetch sellable"); if (!res.ok) throw new Error("Failed to fetch sellable");
const data = (await res.json()) as SellableResponse; const data = (await res.json()) as SellableResponse[];
setSellableData(data); setSellableData(data);
} catch (err) { } catch (err) {
console.error(err); console.error(err);
@@ -104,9 +103,10 @@ export default function HomeClient({ session }: Props) {
<main className="flex min-h-screen flex-col items-center justify-center bg-linear-to-b from-[#2e026d] to-[#7f3b3b] text-white"> <main className="flex min-h-screen flex-col items-center justify-center bg-linear-to-b from-[#2e026d] to-[#7f3b3b] text-white">
<div className="absolute top-4 right-4 flex items-center gap-4"> <div className="absolute top-4 right-4 flex items-center gap-4">
{userData && ( {userData && (
<div className="rounded-full bg-white/10 px-4 py-2 font-semibold"> <TransferButton
${userData.balance ?? 0} balance={userData.balance ?? 0}
</div> reloadUser={loadUser}
/>
)} )}
{session?.user && ( {session?.user && (
<> <>
+5 -5
View File
@@ -37,7 +37,7 @@ export default function AccountButton({ loading }: Props) {
return false; return false;
} }
const data = await res.json(); const data = (await res.json()) as UserData;
setUserData({ setUserData({
adresses: data.adresses ?? [], adresses: data.adresses ?? [],
@@ -83,7 +83,7 @@ export default function AccountButton({ loading }: Props) {
}; };
const res = await saveAll(next); const res = await saveAll(next);
if (!res || !res.ok) { if (!res?.ok) {
console.error("Failed to add address"); console.error("Failed to add address");
return; return;
} }
@@ -99,7 +99,7 @@ export default function AccountButton({ loading }: Props) {
}; };
const res = await saveAll(next); const res = await saveAll(next);
if (!res || !res.ok) { if (!res?.ok) {
console.error("Failed to remove address"); console.error("Failed to remove address");
return; return;
} }
@@ -119,7 +119,7 @@ export default function AccountButton({ loading }: Props) {
}; };
const res = await saveAll(next); const res = await saveAll(next);
if (!res || !res.ok) { if (!res?.ok) {
console.error("Failed to add shop"); console.error("Failed to add shop");
return; return;
} }
@@ -136,7 +136,7 @@ export default function AccountButton({ loading }: Props) {
}; };
const res = await saveAll(next); const res = await saveAll(next);
if (!res || !res.ok) { if (!res?.ok) {
console.error("Failed to remove shop"); console.error("Failed to remove shop");
return; return;
} }
+8 -5
View File
@@ -1,7 +1,5 @@
"use client"; "use client";
import type { CartItem } from "generated/prisma";
import { IMAGES_MANIFEST } from "next/dist/shared/lib/constants";
import { useEffect, useState, useCallback } from "react"; import { useEffect, useState, useCallback } from "react";
type Cart = { type Cart = {
@@ -202,12 +200,12 @@ export default function CartButton({
[dragging], [dragging],
); );
const handleMouseRelease = useCallback(async () => { const handleMouseRelease = useCallback(() => {
if (dragging) { if (dragging) {
const { itemId, quantity } = dragging; const { itemId, quantity } = dragging;
setDragging(null); setDragging(null);
setSliderActive(null); setSliderActive(null);
await removeItem(itemId, quantity); void removeItem(itemId, quantity);
} }
}, [dragging]); }, [dragging]);
@@ -370,7 +368,12 @@ export default function CartButton({
cartItems.length === 0 cartItems.length === 0
) )
return; return;
void buyItems(cartItems, selectedAddress); void buyItems(cartItems, selectedAddress).then(async () => {
setCartItems([]);
setTotalQuantity(0);
setIsOpen(false);
await reloadUser();
});
}} }}
disabled={buyDisabled} disabled={buyDisabled}
> >
+9 -2
View File
@@ -109,10 +109,17 @@ export default function SellableItemsButton({
const addSellable = async () => { const addSellable = async () => {
if (selectedIndex === null || price === "" || amount === "") return; if (selectedIndex === null || price === "" || amount === "") return;
const item = items[selectedIndex]; const item = availableItems[selectedIndex];
if (!item) return; if (!item) return;
console.log("Adding sellable", {
shopId: item.shop.id,
itemId: item.item_name,
price,
amount,
});
const res = await fetch("/api/sellable", { const res = await fetch("/api/sellable", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
@@ -272,7 +279,7 @@ export default function SellableItemsButton({
</p> </p>
)} )}
<div className="space-y-4"> <div className="max-h-56 space-y-4 overflow-y-auto rounded-lg pr-1">
{Object.entries(sellablesByShop).map(([shopId, items]) => ( {Object.entries(sellablesByShop).map(([shopId, items]) => (
<div key={shopId} className="rounded-lg bg-white/5 p-3"> <div key={shopId} className="rounded-lg bg-white/5 p-3">
<h4 className="mb-2 font-semibold"> <h4 className="mb-2 font-semibold">
+149
View File
@@ -0,0 +1,149 @@
"use client";
import { useState } from "react";
type User = {
id: string;
name: string | null;
};
type Props = {
balance: number;
reloadUser: () => Promise<void>;
};
export default function TransferButton({ balance, reloadUser }: Props) {
const [isOpen, setIsOpen] = useState(false);
const [users, setUsers] = useState<User[]>([]);
const [toUserId, setToUserId] = useState("");
const [amount, setAmount] = useState("");
const [error, setError] = useState<string | null>(null);
const [sending, setSending] = useState(false);
const openModal = async () => {
setError(null);
setAmount("");
setToUserId("");
try {
const res = await fetch("/api/users");
if (!res.ok) throw new Error("Failed to load users");
const data = (await res.json()) as User[];
setUsers(data);
if (data[0]) setToUserId(data[0].id);
setIsOpen(true);
} catch {
setError("Could not load users");
}
};
const send = async () => {
setError(null);
const parsed = parseFloat(amount);
if (!toUserId) return setError("Select a recipient");
if (isNaN(parsed) || parsed <= 0) return setError("Enter a valid amount");
if (parsed > balance) return setError("Insufficient balance");
setSending(true);
try {
const res = await fetch("/api/transfer", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ toUserId, amount: parsed }),
});
const data = (await res.json()) as { error?: string; to?: string };
if (!res.ok) {
setError(data.error ?? "Transfer failed");
return;
}
setIsOpen(false);
setAmount("");
await reloadUser();
} catch {
setError("Transfer failed");
} finally {
setSending(false);
}
};
return (
<>
<button
onClick={openModal}
className="rounded-full bg-white/10 px-4 py-2 font-semibold transition hover:bg-white/20"
>
${balance}
</button>
{isOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center">
{/* backdrop */}
<div
className="absolute inset-0 bg-black/60"
onClick={() => setIsOpen(false)}
/>
<div className="relative z-10 w-full max-w-sm rounded-xl bg-neutral-900 p-6 text-white shadow-xl">
<h2 className="mb-5 text-xl font-bold">Transfer Balance</h2>
<div className="mb-4">
<label className="mb-1 block text-sm font-semibold text-white/70">
Recipient
</label>
<select
value={toUserId}
onChange={(e) => setToUserId(e.target.value)}
className="w-full rounded bg-white/10 px-3 py-2 text-sm text-white focus:outline-none"
>
{users.map((u) => (
<option key={u.id} value={u.id} className="bg-neutral-800">
{u.name ?? u.id}
</option>
))}
</select>
</div>
<div className="mb-5">
<label className="mb-1 block text-sm font-semibold text-white/70">
Amount{" "}
<span className="text-white/40">
(your balance: ${balance})
</span>
</label>
<input
type="number"
min={1}
step={1}
value={amount}
onChange={(e) => setAmount(e.target.value)}
placeholder="0"
className="w-full rounded bg-white/10 px-3 py-2 text-sm focus:outline-none"
/>
</div>
{error && (
<p className="mb-3 rounded bg-red-500/20 px-3 py-2 text-sm text-red-300">
{error}
</p>
)}
<div className="flex gap-2">
<button
onClick={send}
disabled={sending}
className="flex-1 rounded bg-purple-600 py-2 font-bold transition hover:bg-purple-700 disabled:opacity-50"
>
{sending ? "Sending…" : "Send"}
</button>
<button
onClick={() => setIsOpen(false)}
className="rounded bg-white/10 px-4 py-2 font-semibold transition hover:bg-white/20"
>
Cancel
</button>
</div>
</div>
</div>
)}
</>
);
}