From 2ad5516bec76f5ad1a3886b94a832c9736243ac0 Mon Sep 17 00:00:00 2001 From: Guilherme Fontes Date: Mon, 15 Dec 2025 20:42:13 -0400 Subject: [PATCH 1/6] feat: add route to get orderform and add models and schemas --- .vscode/settings.json | 3 + .../migration.sql | 76 ++++++++++++ apps/api/prisma/models/base.prisma | 1 + apps/api/prisma/models/shop.prisma | 115 ++++++++++++++++++ apps/api/prisma/seed/index.ts | 23 ++++ apps/api/src/application/services/index.ts | 1 + .../application/services/shopOrder/index.ts | 89 ++++++++++++++ apps/api/src/application/usecases/factory.ts | 8 ++ apps/api/src/application/usecases/index.ts | 1 + .../src/application/usecases/shop/index.ts | 1 + .../usecases/shop/orderform/contract.ts | 14 +++ .../usecases/shop/orderform/index.ts | 23 ++++ apps/api/src/domain/modules/cache/keys.ts | 8 +- apps/api/src/domain/repositories/index.ts | 2 + .../domain/repositories/shopOrder/index.ts | 47 +++++++ .../repositories/shopPaymentOption/index.ts | 40 ++++++ .../src/infra/di/containers/repositories.ts | 13 ++ apps/api/src/infra/di/containers/services.ts | 6 + apps/api/src/infra/di/containers/usecases.ts | 7 ++ apps/api/src/infra/di/tokens.ts | 10 ++ apps/api/src/presentation/v1/routes/index.ts | 2 + .../src/presentation/v1/routes/shop/index.ts | 6 + .../v1/routes/shop/orderform/index.ts | 15 +++ apps/api/src/shared/schemas/ShopOrder.ts | 13 ++ apps/api/src/shared/schemas/ShopOrderForm.ts | 54 ++++++++ apps/api/src/shared/schemas/ShopOrderItem.ts | 13 ++ .../src/shared/schemas/ShopPaymentOption.ts | 19 +++ apps/api/src/shared/schemas/ShopProduct.ts | 26 ++++ apps/web/src/routes/__root.tsx | 25 ++-- apps/web/src/sdk/contexts/orderform.tsx | 47 +++++++ 30 files changed, 696 insertions(+), 12 deletions(-) create mode 100644 apps/api/prisma/migrations/20251214154651_add_shop_order/migration.sql create mode 100644 apps/api/prisma/models/shop.prisma create mode 100644 apps/api/src/application/services/shopOrder/index.ts create mode 100644 apps/api/src/application/usecases/shop/index.ts create mode 100644 apps/api/src/application/usecases/shop/orderform/contract.ts create mode 100644 apps/api/src/application/usecases/shop/orderform/index.ts create mode 100644 apps/api/src/domain/repositories/shopOrder/index.ts create mode 100644 apps/api/src/domain/repositories/shopPaymentOption/index.ts create mode 100644 apps/api/src/presentation/v1/routes/shop/index.ts create mode 100644 apps/api/src/presentation/v1/routes/shop/orderform/index.ts create mode 100644 apps/api/src/shared/schemas/ShopOrder.ts create mode 100644 apps/api/src/shared/schemas/ShopOrderForm.ts create mode 100644 apps/api/src/shared/schemas/ShopOrderItem.ts create mode 100644 apps/api/src/shared/schemas/ShopPaymentOption.ts create mode 100644 apps/api/src/shared/schemas/ShopProduct.ts create mode 100644 apps/web/src/sdk/contexts/orderform.tsx diff --git a/.vscode/settings.json b/.vscode/settings.json index 44891a0..8637159 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -47,6 +47,7 @@ "creationdata", "criticalhit", "crystalserver", + "cuid", "currentmount", "currenttournamentphase", "dailyreward", @@ -149,6 +150,7 @@ "openapi", "openssl", "optiontracking", + "orderform", "orpc", "otplib", "outfitid", @@ -203,6 +205,7 @@ "usecase", "usecases", "usehooks", + "uuid", "verdana", "vipgroup", "vipgrouplist", diff --git a/apps/api/prisma/migrations/20251214154651_add_shop_order/migration.sql b/apps/api/prisma/migrations/20251214154651_add_shop_order/migration.sql new file mode 100644 index 0000000..5a4a7da --- /dev/null +++ b/apps/api/prisma/migrations/20251214154651_add_shop_order/migration.sql @@ -0,0 +1,76 @@ +-- CreateTable +CREATE TABLE `miforge_shop_orders` ( + `id` VARCHAR(191) NOT NULL, + `account_id` INTEGER UNSIGNED NOT NULL, + `payment_option_id` VARCHAR(191) NULL, + `status` ENUM('DRAFT', 'CANCELLED', 'PAID', 'PENDING_PAYMENT', 'REFUNDED', 'CONTESTED') NOT NULL DEFAULT 'DRAFT', + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- CreateTable +CREATE TABLE `miforge_shop_order_items` ( + `id` VARCHAR(191) NOT NULL, + `quantity` INTEGER NOT NULL DEFAULT 1, + `unit_price_cents` INTEGER UNSIGNED NOT NULL, + `total_price_cents` INTEGER UNSIGNED NOT NULL, + `effective_quantity` INTEGER UNSIGNED NOT NULL, + `order_id` VARCHAR(191) NOT NULL, + `product_id` VARCHAR(191) NOT NULL, + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- CreateTable +CREATE TABLE `miforge_shop_products` ( + `id` VARCHAR(191) NOT NULL, + `category` ENUM('COINS', 'RECOVERY_KEY', 'CHANGE_NAME') NOT NULL, + `slug` VARCHAR(100) NOT NULL, + `title` VARCHAR(255) NOT NULL, + `description` TEXT NULL, + `enabled` BOOLEAN NOT NULL DEFAULT true, + `base_unit_quantity` INTEGER NOT NULL DEFAULT 1, + `quantity_mode` ENUM('FIXED', 'VARIABLE') NOT NULL, + `min_units` INTEGER UNSIGNED NOT NULL DEFAULT 1, + `max_units` INTEGER UNSIGNED NULL, + `unit_step` INTEGER UNSIGNED NOT NULL DEFAULT 1, + `unit_price_cents` INTEGER UNSIGNED NOT NULL, + `display_unit_label` VARCHAR(32) NULL, + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + + UNIQUE INDEX `miforge_shop_products_slug_key`(`slug`), + INDEX `idx_shop_product_slug`(`slug`), + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- CreateTable +CREATE TABLE `miforge_shop_payment_options` ( + `id` VARCHAR(191) NOT NULL, + `provider` ENUM('MERCADO_PAGO') NOT NULL, + `method` ENUM('PIX') NOT NULL, + `enabled` BOOLEAN NOT NULL DEFAULT true, + `label` VARCHAR(100) NOT NULL, + `description` TEXT NULL, + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + + UNIQUE INDEX `miforge_shop_payment_options_provider_method_key`(`provider`, `method`), + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- AddForeignKey +ALTER TABLE `miforge_shop_orders` ADD CONSTRAINT `miforge_shop_orders_account_id_fkey` FOREIGN KEY (`account_id`) REFERENCES `accounts`(`id`) ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE `miforge_shop_orders` ADD CONSTRAINT `miforge_shop_orders_payment_option_id_fkey` FOREIGN KEY (`payment_option_id`) REFERENCES `miforge_shop_payment_options`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE `miforge_shop_order_items` ADD CONSTRAINT `miforge_shop_order_items_order_id_fkey` FOREIGN KEY (`order_id`) REFERENCES `miforge_shop_orders`(`id`) ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE `miforge_shop_order_items` ADD CONSTRAINT `miforge_shop_order_items_product_id_fkey` FOREIGN KEY (`product_id`) REFERENCES `miforge_shop_products`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/apps/api/prisma/models/base.prisma b/apps/api/prisma/models/base.prisma index e1637fe..816cf5a 100644 --- a/apps/api/prisma/models/base.prisma +++ b/apps/api/prisma/models/base.prisma @@ -101,6 +101,7 @@ model accounts { audits miforge_account_audit[] confirmations miforge_account_confirmations[] oauths miforge_account_oauths[] + orders miforge_shop_order[] two_factor_enabled Boolean @default(false) two_factor_secret String? @db.VarChar(64) diff --git a/apps/api/prisma/models/shop.prisma b/apps/api/prisma/models/shop.prisma new file mode 100644 index 0000000..b0cc144 --- /dev/null +++ b/apps/api/prisma/models/shop.prisma @@ -0,0 +1,115 @@ +enum ShopOrderStatus { + DRAFT + CANCELLED + PAID + PENDING_PAYMENT + REFUNDED + CONTESTED +} + +model miforge_shop_order { + id String @id @default(uuid(7)) + + accountId Int @map("account_id") @db.UnsignedInt + account accounts @relation(fields: [accountId], references: [id], onDelete: Cascade) + + paymentOptionId String? @map("payment_option_id") + paymentOption miforge_shop_payment_option? @relation(fields: [paymentOptionId], references: [id], onDelete: Restrict) + + status ShopOrderStatus @default(DRAFT) + + items miforge_shop_order_item[] + + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") + + @@map("miforge_shop_orders") +} + +model miforge_shop_order_item { + id String @id @default(uuid(7)) + + quantity Int @default(1) + + // Snapshot pricing fields to avoid price changes affecting existing orders + unitPriceCents Int @map("unit_price_cents") @db.UnsignedInt + totalPriceCents Int @map("total_price_cents") @db.UnsignedInt + effectiveQuantity Int @map("effective_quantity") @db.UnsignedInt + + orderId String @map("order_id") + order miforge_shop_order @relation(fields: [orderId], references: [id], onDelete: Cascade) + + productId String @map("product_id") + product miforge_shop_product @relation(fields: [productId], references: [id], onDelete: Restrict) + + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") + + @@map("miforge_shop_order_items") +} + +enum ShopProductCategory { + COINS + RECOVERY_KEY + CHANGE_NAME +} + +enum ShopProductQuantityMode { + FIXED // e.g, recovery key, premium time. + VARIABLE // e.g., coins +} + +model miforge_shop_product { + id String @id @default(uuid(7)) + + category ShopProductCategory + slug String @unique @db.VarChar(100) + title String @db.VarChar(255) + description String? @db.Text + enabled Boolean @default(true) + + baseUnitQuantity Int @default(1) @map("base_unit_quantity") + + quantityMode ShopProductQuantityMode @map("quantity_mode") + minUnits Int @default(1) @map("min_units") @db.UnsignedInt + maxUnits Int? @map("max_units") @db.UnsignedInt + unitStep Int @default(1) @map("unit_step") @db.UnsignedInt + + unitPriceCents Int @map("unit_price_cents") @db.UnsignedInt + displayUnitLabel String? @map("display_unit_label") @db.VarChar(32) + + orders miforge_shop_order_item[] + + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") + + @@index([slug], name: "idx_shop_product_slug") + @@map("miforge_shop_products") +} + +enum ShopPaymentProvider { + MERCADO_PAGO +} + +enum ShopPaymentMethod { + PIX +} + +model miforge_shop_payment_option { + id String @id @default(uuid(7)) + + orders miforge_shop_order[] + + provider ShopPaymentProvider + method ShopPaymentMethod + + enabled Boolean @default(true) + label String @db.VarChar(100) + description String? @db.Text + + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") + + @@unique([provider, method], name: "uniq_shop_payment_option_provider_method") + @@map("miforge_shop_payment_options") +} diff --git a/apps/api/prisma/seed/index.ts b/apps/api/prisma/seed/index.ts index a41aa55..31f7908 100644 --- a/apps/api/prisma/seed/index.ts +++ b/apps/api/prisma/seed/index.ts @@ -61,6 +61,29 @@ async function main() { } }) + console.log("[seed] Seeding default product coins") + await prisma.miforge_shop_product.upsert({ + where: { + slug: "default-coins", + }, + create: { + category: "COINS", + quantityMode: 'VARIABLE', + slug: "default-coins", + title: "Coins", + unitPriceCents: 200, // 25 coins for R$2.00 + baseUnitQuantity: 25, + description: "Purchase in-game coins to use on various services.", + displayUnitLabel: "Coins", + enabled: true, + minUnits: 1, + maxUnits: 400, + unitStep: 1, + }, + update: { + } + }) + for (const config of server_configs) { const existing = await prisma.server_config.findUnique({ where: { diff --git a/apps/api/src/application/services/index.ts b/apps/api/src/application/services/index.ts index b5b0038..3c5a1b6 100644 --- a/apps/api/src/application/services/index.ts +++ b/apps/api/src/application/services/index.ts @@ -8,5 +8,6 @@ export * from "./lostAccount"; export * from "./players"; export * from "./recoveryKey"; export * from "./session"; +export * from "./shopOrder"; export * from "./tibiaclient"; export * from "./worlds"; diff --git a/apps/api/src/application/services/shopOrder/index.ts b/apps/api/src/application/services/shopOrder/index.ts new file mode 100644 index 0000000..8b06b4e --- /dev/null +++ b/apps/api/src/application/services/shopOrder/index.ts @@ -0,0 +1,89 @@ +import { inject, injectable } from "tsyringe"; +import type { ExecutionContext } from "@/domain/context"; +import type { + ShopOrderRepository, + ShopPaymentOptionRepository, +} from "@/domain/repositories"; +import { TOKENS } from "@/infra/di/tokens"; +import type { + ShopOrderForm, + ShopOrderFormTotalizer, +} from "@/shared/schemas/ShopOrderForm"; + +@injectable() +export class ShopOrderService { + constructor( + @inject(TOKENS.ShopOrderRepository) + private readonly shopOrderRepository: ShopOrderRepository, + @inject(TOKENS.ShopPaymentOptionRepository) + private readonly shopPaymentOptionRepository: ShopPaymentOptionRepository, + @inject(TOKENS.ExecutionContext) + private readonly executionContext: ExecutionContext, + ) {} + + async orderForm(): Promise { + const session = this.executionContext.session(); + + let order = await this.shopOrderRepository.findRecentOrderByAccountId( + session.id, + ); + + if (!order) { + order = await this.shopOrderRepository.createOrder(session.id); + } + + const providers = await this.shopPaymentOptionRepository.findAll(); + + const selectedProvider = + providers.find((provider) => provider.id === order.paymentOptionId) || + null; + + const itemsTotalCents = order.items.reduce((sum, item) => { + return sum + item.totalPriceCents; + }, 0); + + const discountTotalCents = 0; // Placeholder for discount calculation + + const totalTotalCents = itemsTotalCents - discountTotalCents; + + const totalizers: Array = [ + { + id: "ITEMS", + label: "Items Total", + valueCents: itemsTotalCents, + }, + { + id: "TOTAL", + label: "Total", + valueCents: totalTotalCents, + }, + ]; + + return { + id: order.id, + status: order.status, + account: { + email: session.email, + }, + items: order.items.map((item) => { + return { + ...item, + productId: item.product.id, + productSlug: item.product.slug, + category: item.product.category, + description: item.product.description, + title: item.product.title, + }; + }), + payment: { + providers: providers, + selectedProvider: selectedProvider, + }, + totals: { + totalizers: totalizers, + }, + updatedAt: order.updatedAt, + createdAt: order.createdAt, + }; + } +} diff --git a/apps/api/src/application/usecases/factory.ts b/apps/api/src/application/usecases/factory.ts index 342be4b..8fa3f67 100644 --- a/apps/api/src/application/usecases/factory.ts +++ b/apps/api/src/application/usecases/factory.ts @@ -219,4 +219,12 @@ export class UseCasesFactory { update, } as const; } + + get shop() { + const orderForm = this.di.resolve(TOKENS.ShopOrderFormUseCase); + + return { + orderForm, + } as const; + } } diff --git a/apps/api/src/application/usecases/index.ts b/apps/api/src/application/usecases/index.ts index aa8e4bf..769d69e 100644 --- a/apps/api/src/application/usecases/index.ts +++ b/apps/api/src/application/usecases/index.ts @@ -3,5 +3,6 @@ export * from "./config"; export * from "./lostAccount"; export * from "./players"; export * from "./session"; +export * from "./shop"; export * from "./tibia"; export * from "./worlds"; diff --git a/apps/api/src/application/usecases/shop/index.ts b/apps/api/src/application/usecases/shop/index.ts new file mode 100644 index 0000000..4cb79c4 --- /dev/null +++ b/apps/api/src/application/usecases/shop/index.ts @@ -0,0 +1 @@ +export * from "./orderform"; diff --git a/apps/api/src/application/usecases/shop/orderform/contract.ts b/apps/api/src/application/usecases/shop/orderform/contract.ts new file mode 100644 index 0000000..3961c37 --- /dev/null +++ b/apps/api/src/application/usecases/shop/orderform/contract.ts @@ -0,0 +1,14 @@ +import z from "zod"; +import { ShopOrderForm } from "@/shared/schemas/ShopOrderForm"; + +export const ShopOrderFormContractSchema = { + input: z.unknown(), + output: ShopOrderForm, +}; + +export type ShopOrderFormContractInput = z.infer< + typeof ShopOrderFormContractSchema.input +>; +export type ShopOrderFormContractOutput = z.infer< + typeof ShopOrderFormContractSchema.output +>; diff --git a/apps/api/src/application/usecases/shop/orderform/index.ts b/apps/api/src/application/usecases/shop/orderform/index.ts new file mode 100644 index 0000000..60c4a4f --- /dev/null +++ b/apps/api/src/application/usecases/shop/orderform/index.ts @@ -0,0 +1,23 @@ +import { inject, injectable } from "tsyringe"; +import type { ShopOrderService } from "@/application/services"; +import { TOKENS } from "@/infra/di/tokens"; +import type { UseCase } from "@/shared/interfaces/usecase"; +import type { + ShopOrderFormContractInput, + ShopOrderFormContractOutput, +} from "./contract"; + +@injectable() +export class ShopOrderFormUseCase + implements UseCase +{ + constructor( + @inject(TOKENS.ShopOrderService) + private readonly shopOrderService: ShopOrderService, + ) {} + execute( + _input: ShopOrderFormContractInput, + ): Promise { + return this.shopOrderService.orderForm(); + } +} diff --git a/apps/api/src/domain/modules/cache/keys.ts b/apps/api/src/domain/modules/cache/keys.ts index 5c89ae8..b2915c6 100644 --- a/apps/api/src/domain/modules/cache/keys.ts +++ b/apps/api/src/domain/modules/cache/keys.ts @@ -20,9 +20,15 @@ export class CacheKeys { }; readonly keys: Record< - "config" | "serverStatus" | "outfit", + "config" | "serverStatus" | "outfit" | "shopPaymentOptions", (...parts: KeyPart[]) => { key: string; ttl: number | undefined } > = { + shopPaymentOptions: (...parts: KeyPart[]) => { + return { + key: this.namespace.build("shop-payment-options", ...parts), + ttl: 5 * 60, // 5 Minutes + }; + }, config: () => { return { key: this.namespace.build("config"), diff --git a/apps/api/src/domain/repositories/index.ts b/apps/api/src/domain/repositories/index.ts index 29b2dd4..5f2f84a 100644 --- a/apps/api/src/domain/repositories/index.ts +++ b/apps/api/src/domain/repositories/index.ts @@ -10,4 +10,6 @@ export * from "./live"; export * from "./otsServer"; export * from "./players"; export * from "./session"; +export * from "./shopOrder"; +export * from "./shopPaymentOption"; export * from "./worlds"; diff --git a/apps/api/src/domain/repositories/shopOrder/index.ts b/apps/api/src/domain/repositories/shopOrder/index.ts new file mode 100644 index 0000000..40e99fe --- /dev/null +++ b/apps/api/src/domain/repositories/shopOrder/index.ts @@ -0,0 +1,47 @@ +import { inject, injectable } from "tsyringe"; +import type { Prisma } from "@/domain/clients"; +import { TOKENS } from "@/infra/di/tokens"; + +@injectable() +export class ShopOrderRepository { + constructor(@inject(TOKENS.Prisma) private readonly database: Prisma) {} + + findRecentOrderByAccountId(accountId: number) { + return this.database.miforge_shop_order.findFirst({ + where: { + accountId, + status: { + in: ["DRAFT", "PENDING_PAYMENT"], + }, + }, + orderBy: { + updatedAt: "desc", + }, + include: { + items: { + include: { + product: true, + }, + }, + paymentOption: true, + }, + }); + } + + createOrder(accountId: number) { + return this.database.miforge_shop_order.create({ + data: { + accountId, + status: "DRAFT", + }, + include: { + items: { + include: { + product: true, + }, + }, + paymentOption: true, + }, + }); + } +} diff --git a/apps/api/src/domain/repositories/shopPaymentOption/index.ts b/apps/api/src/domain/repositories/shopPaymentOption/index.ts new file mode 100644 index 0000000..b0ffb88 --- /dev/null +++ b/apps/api/src/domain/repositories/shopPaymentOption/index.ts @@ -0,0 +1,40 @@ +import type { Prisma } from "generated/client"; +import { inject, injectable } from "tsyringe"; +import type { Prisma as Database } from "@/domain/clients"; +import type { Cache, CacheKeys } from "@/domain/modules"; +import { TOKENS } from "@/infra/di/tokens"; + +// biome-ignore lint/complexity/noBannedTypes: +type ShopPaymentOption = Prisma.miforge_shop_payment_optionGetPayload<{}>; + +@injectable() +export class ShopPaymentOptionRepository { + constructor( + @inject(TOKENS.Prisma) private readonly database: Database, + @inject(TOKENS.Cache) private readonly cache: Cache, + @inject(TOKENS.CacheKeys) private readonly cacheKeys: CacheKeys, + ) {} + + async findAll(): Promise { + const { key, ttl } = this.cacheKeys.keys.shopPaymentOptions(); + + const cached = await this.cache.get(key); + + if (cached) { + return cached.data; + } + + const paymentOptions = + await this.database.miforge_shop_payment_option.findMany(); + + await this.cache.save(key, paymentOptions, ttl); + + return paymentOptions; + } + + async findById(id: string) { + return this.database.miforge_shop_payment_option.findUnique({ + where: { id }, + }); + } +} diff --git a/apps/api/src/infra/di/containers/repositories.ts b/apps/api/src/infra/di/containers/repositories.ts index a5507b8..76c23c1 100644 --- a/apps/api/src/infra/di/containers/repositories.ts +++ b/apps/api/src/infra/di/containers/repositories.ts @@ -10,6 +10,8 @@ import { OtsServerRepository, PlayersRepository, SessionRepository, + ShopOrderRepository, + ShopPaymentOptionRepository, } from "@/domain/repositories"; import { WorldsRepository } from "@/domain/repositories/worlds"; import { TOKENS } from "../tokens"; @@ -68,6 +70,17 @@ export function registerRepositories() { { lifecycle: Lifecycle.ResolutionScoped }, ); + container.register( + TOKENS.ShopOrderRepository, + { useClass: ShopOrderRepository }, + { lifecycle: Lifecycle.ResolutionScoped }, + ); + container.register( + TOKENS.ShopPaymentOptionRepository, + { useClass: ShopPaymentOptionRepository }, + { lifecycle: Lifecycle.ResolutionScoped }, + ); + // Repositories with singleton lifecycle container.register( TOKENS.ConfigLiveRepository, diff --git a/apps/api/src/infra/di/containers/services.ts b/apps/api/src/infra/di/containers/services.ts index 5d79629..073f064 100644 --- a/apps/api/src/infra/di/containers/services.ts +++ b/apps/api/src/infra/di/containers/services.ts @@ -10,6 +10,7 @@ import { PlayersService, RecoveryKeyService, SessionService, + ShopOrderService, TibiaClientService, WorldsService, } from "@/application/services"; @@ -76,4 +77,9 @@ export function registerServices() { { useClass: AccountOauthService }, { lifecycle: Lifecycle.ResolutionScoped }, ); + container.register( + TOKENS.ShopOrderService, + { useClass: ShopOrderService }, + { lifecycle: Lifecycle.ResolutionScoped }, + ); } diff --git a/apps/api/src/infra/di/containers/usecases.ts b/apps/api/src/infra/di/containers/usecases.ts index 1b96a30..5dc973d 100644 --- a/apps/api/src/infra/di/containers/usecases.ts +++ b/apps/api/src/infra/di/containers/usecases.ts @@ -43,6 +43,7 @@ import { SessionCanBeAuthenticatedUseCase, SessionInfoUseCase, SessionNotAuthenticatedUseCase, + ShopOrderFormUseCase, TibiaLoginUseCase, WorldsListUseCase, } from "@/application/usecases"; @@ -287,4 +288,10 @@ export function registerUseCases() { { useClass: AccountDiscordOauthUnlinkUseCase }, { lifecycle: Lifecycle.ResolutionScoped }, ); + + container.register( + TOKENS.ShopOrderFormUseCase, + { useClass: ShopOrderFormUseCase }, + { lifecycle: Lifecycle.ResolutionScoped }, + ); } diff --git a/apps/api/src/infra/di/tokens.ts b/apps/api/src/infra/di/tokens.ts index c0d5d17..d35db68 100644 --- a/apps/api/src/infra/di/tokens.ts +++ b/apps/api/src/infra/di/tokens.ts @@ -11,6 +11,7 @@ import type { PlayersService, RecoveryKeyService, SessionService, + ShopOrderService, TibiaClientService, WorldsService, } from "@/application/services"; @@ -58,6 +59,7 @@ import type { SessionCanBeAuthenticatedUseCase, SessionInfoUseCase, SessionNotAuthenticatedUseCase, + ShopOrderFormUseCase, TibiaLoginUseCase, WorldsListUseCase, } from "@/application/usecases"; @@ -114,6 +116,8 @@ import type { OtsServerRepository, PlayersRepository, SessionRepository, + ShopOrderRepository, + ShopPaymentOptionRepository, } from "@/domain/repositories"; import type { WorldsRepository } from "@/domain/repositories/worlds"; import type { EmailQueue } from "@/jobs/queue/email"; @@ -174,6 +178,10 @@ const REPOSITORIES_TOKENS = { AccountOauthRepository: token( "AccountOauthRepository", ), + ShopOrderRepository: token("ShopOrderRepository"), + ShopPaymentOptionRepository: token( + "ShopPaymentOptionRepository", + ), }; const UTILS_TOKENS = { @@ -321,6 +329,7 @@ const USECASES_TOKENS = { PlayerOutfitUseCase: token("PlayerOutfitUseCase"), PlayerOutfitsUseCase: token("PlayerOutfitsUseCase"), TibiaLoginUseCase: token("TibiaLoginUseCase"), + ShopOrderFormUseCase: token("ShopOrderFormUseCase"), }; const SERVICES_TOKENS = { @@ -340,6 +349,7 @@ const SERVICES_TOKENS = { LostAccountService: token("LostAccountService"), RecoveryKeyService: token("RecoveryKeyService"), AccountOauthService: token("AccountOauthService"), + ShopOrderService: token("ShopOrderService"), }; const QUEUE_AND_WORKERS_TOKENS = { diff --git a/apps/api/src/presentation/v1/routes/index.ts b/apps/api/src/presentation/v1/routes/index.ts index e55021c..4b5ac10 100644 --- a/apps/api/src/presentation/v1/routes/index.ts +++ b/apps/api/src/presentation/v1/routes/index.ts @@ -6,6 +6,7 @@ import { lostAccountRouter } from "./lost"; import { outfitRouter } from "./outfit"; import { pingRoute } from "./ping"; import { sessionRouter } from "./session"; +import { shopRouter } from "./shop"; import { worldsRouter } from "./worlds"; export const router = base.router({ @@ -17,4 +18,5 @@ export const router = base.router({ config: configRouter, outfit: outfitRouter, lost: lostAccountRouter, + shop: shopRouter, }); diff --git a/apps/api/src/presentation/v1/routes/shop/index.ts b/apps/api/src/presentation/v1/routes/shop/index.ts new file mode 100644 index 0000000..67d9d13 --- /dev/null +++ b/apps/api/src/presentation/v1/routes/shop/index.ts @@ -0,0 +1,6 @@ +import { base } from "@/infra/rpc/base"; +import { orderFormRoute } from "./orderform"; + +export const shopRouter = base.prefix("/shop").tag("Shop").router({ + orderForm: orderFormRoute, +}); diff --git a/apps/api/src/presentation/v1/routes/shop/orderform/index.ts b/apps/api/src/presentation/v1/routes/shop/orderform/index.ts new file mode 100644 index 0000000..a0b889a --- /dev/null +++ b/apps/api/src/presentation/v1/routes/shop/orderform/index.ts @@ -0,0 +1,15 @@ +import { ShopOrderFormContractSchema } from "@/application/usecases/shop/orderform/contract"; +import { isAuthenticatedProcedure } from "@/presentation/procedures/isAuthenticated"; + +export const orderFormRoute = isAuthenticatedProcedure + .route({ + method: "GET", + path: "/orderform", + summary: "OrderForm", + description: "Retrieves information about the order form for the shop.", + }) + .input(ShopOrderFormContractSchema.input) + .output(ShopOrderFormContractSchema.output) + .handler(async ({ context }) => { + return context.usecases.shop.orderForm.execute({}); + }); diff --git a/apps/api/src/shared/schemas/ShopOrder.ts b/apps/api/src/shared/schemas/ShopOrder.ts new file mode 100644 index 0000000..e7e37ff --- /dev/null +++ b/apps/api/src/shared/schemas/ShopOrder.ts @@ -0,0 +1,13 @@ +import { ShopOrderStatus } from "generated/client"; +import z from "zod"; + +export const ShopOrderStatusEnum = z.enum(ShopOrderStatus); + +export const ShopOrder = z.object({ + id: z.uuid(), + accountId: z.number(), + paymentOptionId: z.string().nullable(), + status: ShopOrderStatusEnum, + createdAt: z.date(), + updatedAt: z.date(), +}); diff --git a/apps/api/src/shared/schemas/ShopOrderForm.ts b/apps/api/src/shared/schemas/ShopOrderForm.ts new file mode 100644 index 0000000..9bd0906 --- /dev/null +++ b/apps/api/src/shared/schemas/ShopOrderForm.ts @@ -0,0 +1,54 @@ +import z from "zod"; +import { ShopOrderStatusEnum } from "./ShopOrder"; +import { ShopOrderItem } from "./ShopOrderItem"; +import { ShopPaymentOption } from "./ShopPaymentOption"; +import { ShopProduct } from "./ShopProduct"; + +export const ShopOrderFormItem = z.object({ + id: z.uuid(), + productId: z.uuid(), + productSlug: z.string(), + ...ShopProduct.pick({ title: true, description: true, category: true }).shape, + ...ShopOrderItem.pick({ + quantity: true, + unitPriceCents: true, + totalPriceCents: true, + effectiveQuantity: true, + }).shape, +}); + +export const ShopOrderFormAccount = z.object({ + email: z.email(), +}); + +export const ShopOrderFormPaymentOption = ShopPaymentOption; + +export const ShopOrderFormPayment = z.object({ + providers: z.array(ShopOrderFormPaymentOption), + selectedProvider: ShopOrderFormPaymentOption.nullable(), +}); + +export const ShopOrderFormTotalizer = z.object({ + id: z.enum(["ITEMS", "DISCOUNT", "TOTAL"]), + label: z.string(), + valueCents: z.number().int(), +}); + +export const ShopOrderFormTotals = z.object({ + totalizers: z.array(ShopOrderFormTotalizer), +}); + +export type ShopOrderFormTotalizer = z.infer; + +export const ShopOrderForm = z.object({ + id: z.uuid(), + status: ShopOrderStatusEnum, + account: ShopOrderFormAccount, + items: z.array(ShopOrderFormItem), + payment: ShopOrderFormPayment, + totals: ShopOrderFormTotals, + createdAt: z.date(), + updatedAt: z.date(), +}); + +export type ShopOrderForm = z.infer; diff --git a/apps/api/src/shared/schemas/ShopOrderItem.ts b/apps/api/src/shared/schemas/ShopOrderItem.ts new file mode 100644 index 0000000..93dfbb5 --- /dev/null +++ b/apps/api/src/shared/schemas/ShopOrderItem.ts @@ -0,0 +1,13 @@ +import z from "zod"; + +export const ShopOrderItem = z.object({ + id: z.uuid(), + quantity: z.number(), + unitPriceCents: z.number(), + totalPriceCents: z.number(), + effectiveQuantity: z.number(), + orderId: z.uuid(), + productId: z.uuid(), + createdAt: z.date(), + updatedAt: z.date(), +}); diff --git a/apps/api/src/shared/schemas/ShopPaymentOption.ts b/apps/api/src/shared/schemas/ShopPaymentOption.ts new file mode 100644 index 0000000..b7f45bd --- /dev/null +++ b/apps/api/src/shared/schemas/ShopPaymentOption.ts @@ -0,0 +1,19 @@ +import { ShopPaymentMethod, ShopPaymentProvider } from "generated/client"; +import z from "zod"; + +export const ShopPaymentOptionProviderEnum = z.enum(ShopPaymentProvider); +export const ShopPaymentOptionMethodEnum = z.enum(ShopPaymentMethod); + +export const ShopPaymentOption = z.object({ + id: z.uuid(), + + provider: ShopPaymentOptionProviderEnum, + method: ShopPaymentOptionMethodEnum, + + enabled: z.boolean(), + label: z.string().max(100), + description: z.string().nullable(), + + createdAt: z.date(), + updatedAt: z.date(), +}); diff --git a/apps/api/src/shared/schemas/ShopProduct.ts b/apps/api/src/shared/schemas/ShopProduct.ts new file mode 100644 index 0000000..e1b310f --- /dev/null +++ b/apps/api/src/shared/schemas/ShopProduct.ts @@ -0,0 +1,26 @@ +import { ShopProductCategory, ShopProductQuantityMode } from "generated/client"; +import z from "zod"; + +export const ShopProductCategoryEnum = z.enum(ShopProductCategory); +export const ShopProductQuantityModeEnum = z.enum(ShopProductQuantityMode); + +export const ShopProduct = z.object({ + id: z.uuid(), + category: ShopProductCategoryEnum, + slug: z.string().max(100), + title: z.string().max(255), + description: z.string().nullable(), + enabled: z.boolean(), + + baseUnitQuantity: z.number(), + quantityMode: ShopProductQuantityModeEnum, + minUnits: z.number(), + maxUnits: z.number().nullable(), + unitStep: z.number(), + + unitPriceCents: z.number(), + displayUnitLabel: z.string().nullable(), + + createdAt: z.date(), + updatedAt: z.date(), +}); diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index 28823f0..ba3963d 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -7,6 +7,7 @@ import { lazy, Suspense } from "react"; import { Layout } from "@/layout"; import type { RouterContext } from "@/router"; import { ConfigProvider } from "@/sdk/contexts/config"; +import { OrderformProvider } from "@/sdk/contexts/orderform"; import { SessionProvider } from "@/sdk/contexts/session"; import { env } from "@/sdk/env"; import { api } from "@/sdk/lib/api/factory"; @@ -54,17 +55,19 @@ export const Route = createRootRouteWithContext()({ return ( - - - {env.VITE_SHOW_DEVTOOLS && ( - - - - - )} + + + + {env.VITE_SHOW_DEVTOOLS && ( + + + + + )} + ); diff --git a/apps/web/src/sdk/contexts/orderform.tsx b/apps/web/src/sdk/contexts/orderform.tsx new file mode 100644 index 0000000..5551ccb --- /dev/null +++ b/apps/web/src/sdk/contexts/orderform.tsx @@ -0,0 +1,47 @@ +import type { ShopOrderForm } from "@miforge/api/shared/schemas/ShopOrderForm"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { createContext, use } from "react"; +import { api } from "../lib/api/factory"; +import { useSession } from "./session"; + +type Context = { + orderForm: ShopOrderForm | null; + invalidate: () => Promise; +}; + +const OrderformContext = createContext(null); + +export function OrderformProvider({ children }: { children: React.ReactNode }) { + const queryClient = useQueryClient(); + const { session } = useSession(); + const { data: orderForm } = useQuery( + api.query.miforge.shop.orderForm.queryOptions({ + enabled: !!session, + }), + ); + + return ( + { + await queryClient.invalidateQueries({ + queryKey: api.query.miforge.shop.orderForm.queryKey(), + }); + }, + }} + > + {children} + + ); +} + +export const useOrderform = () => { + const context = use(OrderformContext); + + if (!context) { + throw new Error("useOrderform must be used within OrderformProvider"); + } + + return context; +}; From 1553770f232d8d611e4f9ea453a7e3cb273a72bf Mon Sep 17 00:00:00 2001 From: Guilherme Fontes Date: Tue, 16 Dec 2025 16:41:15 -0400 Subject: [PATCH 2/6] feat: add route to add or update item in orderform --- .../application/services/shopOrder/index.ts | 121 ++++++++++++++++++ apps/api/src/application/usecases/factory.ts | 4 + .../src/application/usecases/shop/index.ts | 1 + .../shop/orderformAddOrUpdateItem/contract.ts | 18 +++ .../shop/orderformAddOrUpdateItem/index.ts | 32 +++++ apps/api/src/domain/modules/cache/keys.ts | 8 +- apps/api/src/domain/repositories/index.ts | 2 + .../repositories/shopOrderItem/index.ts | 50 ++++++++ .../domain/repositories/shopProduct/index.ts | 44 +++++++ .../src/infra/di/containers/repositories.ts | 12 ++ apps/api/src/infra/di/containers/usecases.ts | 6 + apps/api/src/infra/di/tokens.ts | 11 ++ .../src/presentation/v1/routes/shop/index.ts | 4 +- .../shop/orderform/addOrUpdateItem/index.ts | 15 +++ .../v1/routes/shop/orderform/index.ts | 21 +-- .../routes/shop/orderform/mostRecent/index.ts | 15 +++ apps/web/src/routes/__root.tsx | 6 +- apps/web/src/sdk/contexts/orderform.tsx | 22 ++-- apps/web/src/sdk/hooks/useOrderFormItem.ts | 43 +++++++ 19 files changed, 405 insertions(+), 30 deletions(-) create mode 100644 apps/api/src/application/usecases/shop/orderformAddOrUpdateItem/contract.ts create mode 100644 apps/api/src/application/usecases/shop/orderformAddOrUpdateItem/index.ts create mode 100644 apps/api/src/domain/repositories/shopOrderItem/index.ts create mode 100644 apps/api/src/domain/repositories/shopProduct/index.ts create mode 100644 apps/api/src/presentation/v1/routes/shop/orderform/addOrUpdateItem/index.ts create mode 100644 apps/api/src/presentation/v1/routes/shop/orderform/mostRecent/index.ts create mode 100644 apps/web/src/sdk/hooks/useOrderFormItem.ts diff --git a/apps/api/src/application/services/shopOrder/index.ts b/apps/api/src/application/services/shopOrder/index.ts index 8b06b4e..0beaca4 100644 --- a/apps/api/src/application/services/shopOrder/index.ts +++ b/apps/api/src/application/services/shopOrder/index.ts @@ -1,8 +1,12 @@ +import { ORPCError } from "@orpc/client"; +import { ShopOrderStatus, ShopProductQuantityMode } from "generated/client"; import { inject, injectable } from "tsyringe"; import type { ExecutionContext } from "@/domain/context"; import type { + ShopOrderItemRepository, ShopOrderRepository, ShopPaymentOptionRepository, + ShopProductRepository, } from "@/domain/repositories"; import { TOKENS } from "@/infra/di/tokens"; import type { @@ -19,6 +23,10 @@ export class ShopOrderService { private readonly shopPaymentOptionRepository: ShopPaymentOptionRepository, @inject(TOKENS.ExecutionContext) private readonly executionContext: ExecutionContext, + @inject(TOKENS.ShopProductRepository) + private readonly shopProductRepository: ShopProductRepository, + @inject(TOKENS.ShopOrderItemRepository) + private readonly shopOrderItemRepository: ShopOrderItemRepository, ) {} async orderForm(): Promise { @@ -86,4 +94,117 @@ export class ShopOrderService { createdAt: order.createdAt, }; } + + private verifyUnit( + units: number, + product: { minUnits: number; maxUnits?: number; unitStep: number }, + ) { + if (units < product.minUnits) { + throw new ORPCError("BAD_REQUEST", { + message: `Minimum order quantity for this product is ${product.minUnits}.`, + }); + } + + if (product.maxUnits != null && units > product.maxUnits) { + throw new ORPCError("BAD_REQUEST", { + message: `Maximum order quantity for this product is ${product.maxUnits}.`, + }); + } + + const diff = units - product.minUnits; + if (diff % product.unitStep !== 0) { + throw new ORPCError("BAD_REQUEST", { + message: `Order quantity must be in increments of ${product.unitStep}.`, + }); + } + } + + async addOrUpdateItem(input: { + productId: string; + quantity: number; + mode?: "ADD" | "SET"; + }): Promise { + const orderform = await this.orderForm(); + + if (orderform.status !== ShopOrderStatus.DRAFT) { + throw new ORPCError("BAD_REQUEST", { + message: "Cannot add items to a non-draft order.", + }); + } + + const product = await this.shopProductRepository.findById(input.productId); + + if (!product || !product.enabled) { + throw new ORPCError("BAD_REQUEST", { + message: "Product is not available", + }); + } + + let units = input.quantity; + + if (units <= 0) { + throw new ORPCError("BAD_REQUEST", { + message: "Quantity must be greater than zero.", + }); + } + + if (product.quantityMode === ShopProductQuantityMode.FIXED) { + units = 1; + } + + if (product.quantityMode === ShopProductQuantityMode.VARIABLE) { + this.verifyUnit(units, { + minUnits: product.minUnits, + maxUnits: product.maxUnits ?? undefined, + unitStep: product.unitStep, + }); + } + + const unitPriceCents = product.unitPriceCents; + const effectiveQuantityPerUnit = product.baseUnitQuantity; + const effectiveQuantity = units * effectiveQuantityPerUnit; + const totalPriceCents = unitPriceCents * units; + + const existingItem = orderform.items.find( + (item) => item.productId === product.id, + ); + + if (existingItem) { + let newUnits: number; + const mode = input.mode || "ADD"; + + if (product.quantityMode === ShopProductQuantityMode.FIXED) { + newUnits = 1; + } else { + newUnits = mode === "SET" ? units : existingItem.quantity + units; + + this.verifyUnit(newUnits, { + minUnits: product.minUnits, + maxUnits: product.maxUnits ?? undefined, + unitStep: product.unitStep, + }); + } + + const newEffectiveQuantity = newUnits * effectiveQuantityPerUnit; + const newTotalPriceCents = unitPriceCents * newUnits; + + await this.shopOrderItemRepository.updateItem(existingItem.id, { + quantity: newUnits, + effectiveQuantity: newEffectiveQuantity, + unitPriceCents: unitPriceCents, + totalPriceCents: newTotalPriceCents, + }); + } + + if (!existingItem) { + await this.shopOrderItemRepository.createItem(orderform.id, product.id, { + quantity: units, + effectiveQuantity: effectiveQuantity, + unitPriceCents: unitPriceCents, + totalPriceCents: totalPriceCents, + }); + } + + return this.orderForm(); + } } diff --git a/apps/api/src/application/usecases/factory.ts b/apps/api/src/application/usecases/factory.ts index 8fa3f67..2bf95a3 100644 --- a/apps/api/src/application/usecases/factory.ts +++ b/apps/api/src/application/usecases/factory.ts @@ -222,9 +222,13 @@ export class UseCasesFactory { get shop() { const orderForm = this.di.resolve(TOKENS.ShopOrderFormUseCase); + const orderFormAddOrUpdateItem = this.di.resolve( + TOKENS.ShopOrderFormAddOrUpdateItemUseCase, + ); return { orderForm, + orderFormAddOrUpdateItem, } as const; } } diff --git a/apps/api/src/application/usecases/shop/index.ts b/apps/api/src/application/usecases/shop/index.ts index 4cb79c4..1bbd5a2 100644 --- a/apps/api/src/application/usecases/shop/index.ts +++ b/apps/api/src/application/usecases/shop/index.ts @@ -1 +1,2 @@ export * from "./orderform"; +export * from "./orderformAddOrUpdateItem"; diff --git a/apps/api/src/application/usecases/shop/orderformAddOrUpdateItem/contract.ts b/apps/api/src/application/usecases/shop/orderformAddOrUpdateItem/contract.ts new file mode 100644 index 0000000..9041de0 --- /dev/null +++ b/apps/api/src/application/usecases/shop/orderformAddOrUpdateItem/contract.ts @@ -0,0 +1,18 @@ +import z from "zod"; +import { ShopOrderForm } from "@/shared/schemas/ShopOrderForm"; + +export const ShopOrderFormAddOrUpdateItemContractSchema = { + input: z.object({ + productId: z.uuid(), + quantity: z.number().min(1), + mode: z.enum(["SET", "ADD"]).default("ADD").optional(), + }), + output: ShopOrderForm, +}; + +export type ShopOrderFormAddOrUpdateItemContractInput = z.infer< + typeof ShopOrderFormAddOrUpdateItemContractSchema.input +>; +export type ShopOrderFormAddOrUpdateItemContractOutput = z.infer< + typeof ShopOrderFormAddOrUpdateItemContractSchema.output +>; diff --git a/apps/api/src/application/usecases/shop/orderformAddOrUpdateItem/index.ts b/apps/api/src/application/usecases/shop/orderformAddOrUpdateItem/index.ts new file mode 100644 index 0000000..eb13162 --- /dev/null +++ b/apps/api/src/application/usecases/shop/orderformAddOrUpdateItem/index.ts @@ -0,0 +1,32 @@ +import { inject, injectable } from "tsyringe"; +import type { ShopOrderService } from "@/application/services"; +import { TOKENS } from "@/infra/di/tokens"; +import type { UseCase } from "@/shared/interfaces/usecase"; +import type { + ShopOrderFormAddOrUpdateItemContractInput, + ShopOrderFormAddOrUpdateItemContractOutput, +} from "./contract"; + +@injectable() +export class ShopOrderFormAddOrUpdateItemUseCase + implements + UseCase< + ShopOrderFormAddOrUpdateItemContractInput, + ShopOrderFormAddOrUpdateItemContractOutput + > +{ + constructor( + @inject(TOKENS.ShopOrderService) + private readonly shopOrderService: ShopOrderService, + ) {} + + execute( + input: ShopOrderFormAddOrUpdateItemContractInput, + ): Promise { + return this.shopOrderService.addOrUpdateItem({ + productId: input.productId, + quantity: input.quantity, + mode: input.mode, + }); + } +} diff --git a/apps/api/src/domain/modules/cache/keys.ts b/apps/api/src/domain/modules/cache/keys.ts index b2915c6..fdd69e5 100644 --- a/apps/api/src/domain/modules/cache/keys.ts +++ b/apps/api/src/domain/modules/cache/keys.ts @@ -20,9 +20,15 @@ export class CacheKeys { }; readonly keys: Record< - "config" | "serverStatus" | "outfit" | "shopPaymentOptions", + "config" | "serverStatus" | "outfit" | "shopPaymentOptions" | "shopProduct", (...parts: KeyPart[]) => { key: string; ttl: number | undefined } > = { + shopProduct: (...parts: KeyPart[]) => { + return { + key: this.namespace.build("shop-product", ...parts), + ttl: 24 * 60 * 60, // 24 Hours + }; + }, shopPaymentOptions: (...parts: KeyPart[]) => { return { key: this.namespace.build("shop-payment-options", ...parts), diff --git a/apps/api/src/domain/repositories/index.ts b/apps/api/src/domain/repositories/index.ts index 5f2f84a..7d60f66 100644 --- a/apps/api/src/domain/repositories/index.ts +++ b/apps/api/src/domain/repositories/index.ts @@ -11,5 +11,7 @@ export * from "./otsServer"; export * from "./players"; export * from "./session"; export * from "./shopOrder"; +export * from "./shopOrderItem"; export * from "./shopPaymentOption"; +export * from "./shopProduct"; export * from "./worlds"; diff --git a/apps/api/src/domain/repositories/shopOrderItem/index.ts b/apps/api/src/domain/repositories/shopOrderItem/index.ts new file mode 100644 index 0000000..e51acc2 --- /dev/null +++ b/apps/api/src/domain/repositories/shopOrderItem/index.ts @@ -0,0 +1,50 @@ +import { inject, injectable } from "tsyringe"; +import type { Prisma } from "@/domain/clients"; +import { TOKENS } from "@/infra/di/tokens"; + +@injectable() +export class ShopOrderItemRepository { + constructor(@inject(TOKENS.Prisma) private readonly database: Prisma) {} + + updateItem( + id: string, + data: { + quantity: number; + effectiveQuantity: number; + unitPriceCents: number; + totalPriceCents: number; + }, + ) { + return this.database.miforge_shop_order_item.update({ + where: { id }, + data: { + quantity: data.quantity, + effectiveQuantity: data.effectiveQuantity, + unitPriceCents: data.unitPriceCents, + totalPriceCents: data.totalPriceCents, + }, + }); + } + + createItem( + orderId: string, + productId: string, + data: { + quantity: number; + effectiveQuantity: number; + unitPriceCents: number; + totalPriceCents: number; + }, + ) { + return this.database.miforge_shop_order_item.create({ + data: { + orderId, + productId, + quantity: data.quantity, + effectiveQuantity: data.effectiveQuantity, + unitPriceCents: data.unitPriceCents, + totalPriceCents: data.totalPriceCents, + }, + }); + } +} diff --git a/apps/api/src/domain/repositories/shopProduct/index.ts b/apps/api/src/domain/repositories/shopProduct/index.ts new file mode 100644 index 0000000..6b279b6 --- /dev/null +++ b/apps/api/src/domain/repositories/shopProduct/index.ts @@ -0,0 +1,44 @@ +import type { Prisma } from "generated/client"; +import { inject, injectable } from "tsyringe"; +import type { Prisma as Database } from "@/domain/clients"; +import type { Cache, CacheKeys } from "@/domain/modules"; +import { TOKENS } from "@/infra/di/tokens"; + +// biome-ignore lint/complexity/noBannedTypes: +type ShopProduct = Prisma.miforge_shop_productGetPayload<{}>; + +@injectable() +export class ShopProductRepository { + constructor( + @inject(TOKENS.Prisma) private readonly database: Database, + @inject(TOKENS.Cache) private readonly cache: Cache, + @inject(TOKENS.CacheKeys) private readonly cacheKeys: CacheKeys, + ) {} + + private invalidateCache(productId: string): Promise { + const { key } = this.cacheKeys.keys.shopProduct(productId); + return this.cache.delete(key); + } + + async findById(productId: string): Promise { + const { key, ttl } = this.cacheKeys.keys.shopProduct(productId); + + const cached = await this.cache.get(key); + + if (cached) { + return cached.data; + } + + const product = await this.database.miforge_shop_product.findUnique({ + where: { + id: productId, + }, + }); + + if (product) { + await this.cache.save(key, product, ttl); + } + + return product; + } +} diff --git a/apps/api/src/infra/di/containers/repositories.ts b/apps/api/src/infra/di/containers/repositories.ts index 76c23c1..5b8a260 100644 --- a/apps/api/src/infra/di/containers/repositories.ts +++ b/apps/api/src/infra/di/containers/repositories.ts @@ -10,8 +10,10 @@ import { OtsServerRepository, PlayersRepository, SessionRepository, + ShopOrderItemRepository, ShopOrderRepository, ShopPaymentOptionRepository, + ShopProductRepository, } from "@/domain/repositories"; import { WorldsRepository } from "@/domain/repositories/worlds"; import { TOKENS } from "../tokens"; @@ -80,6 +82,16 @@ export function registerRepositories() { { useClass: ShopPaymentOptionRepository }, { lifecycle: Lifecycle.ResolutionScoped }, ); + container.register( + TOKENS.ShopProductRepository, + { useClass: ShopProductRepository }, + { lifecycle: Lifecycle.ResolutionScoped }, + ); + container.register( + TOKENS.ShopOrderItemRepository, + { useClass: ShopOrderItemRepository }, + { lifecycle: Lifecycle.ResolutionScoped }, + ); // Repositories with singleton lifecycle container.register( diff --git a/apps/api/src/infra/di/containers/usecases.ts b/apps/api/src/infra/di/containers/usecases.ts index 5dc973d..2d0de3b 100644 --- a/apps/api/src/infra/di/containers/usecases.ts +++ b/apps/api/src/infra/di/containers/usecases.ts @@ -43,6 +43,7 @@ import { SessionCanBeAuthenticatedUseCase, SessionInfoUseCase, SessionNotAuthenticatedUseCase, + ShopOrderFormAddOrUpdateItemUseCase, ShopOrderFormUseCase, TibiaLoginUseCase, WorldsListUseCase, @@ -294,4 +295,9 @@ export function registerUseCases() { { useClass: ShopOrderFormUseCase }, { lifecycle: Lifecycle.ResolutionScoped }, ); + container.register( + TOKENS.ShopOrderFormAddOrUpdateItemUseCase, + { useClass: ShopOrderFormAddOrUpdateItemUseCase }, + { lifecycle: Lifecycle.ResolutionScoped }, + ); } diff --git a/apps/api/src/infra/di/tokens.ts b/apps/api/src/infra/di/tokens.ts index d35db68..5d3b44a 100644 --- a/apps/api/src/infra/di/tokens.ts +++ b/apps/api/src/infra/di/tokens.ts @@ -59,6 +59,7 @@ import type { SessionCanBeAuthenticatedUseCase, SessionInfoUseCase, SessionNotAuthenticatedUseCase, + ShopOrderFormAddOrUpdateItemUseCase, ShopOrderFormUseCase, TibiaLoginUseCase, WorldsListUseCase, @@ -116,8 +117,10 @@ import type { OtsServerRepository, PlayersRepository, SessionRepository, + ShopOrderItemRepository, ShopOrderRepository, ShopPaymentOptionRepository, + ShopProductRepository, } from "@/domain/repositories"; import type { WorldsRepository } from "@/domain/repositories/worlds"; import type { EmailQueue } from "@/jobs/queue/email"; @@ -182,6 +185,10 @@ const REPOSITORIES_TOKENS = { ShopPaymentOptionRepository: token( "ShopPaymentOptionRepository", ), + ShopProductRepository: token("ShopProductRepository"), + ShopOrderItemRepository: token( + "ShopOrderItemRepository", + ), }; const UTILS_TOKENS = { @@ -330,6 +337,10 @@ const USECASES_TOKENS = { PlayerOutfitsUseCase: token("PlayerOutfitsUseCase"), TibiaLoginUseCase: token("TibiaLoginUseCase"), ShopOrderFormUseCase: token("ShopOrderFormUseCase"), + ShopOrderFormAddOrUpdateItemUseCase: + token( + "ShopOrderFormAddOrUpdateItemUseCase", + ), }; const SERVICES_TOKENS = { diff --git a/apps/api/src/presentation/v1/routes/shop/index.ts b/apps/api/src/presentation/v1/routes/shop/index.ts index 67d9d13..4d083d8 100644 --- a/apps/api/src/presentation/v1/routes/shop/index.ts +++ b/apps/api/src/presentation/v1/routes/shop/index.ts @@ -1,6 +1,6 @@ import { base } from "@/infra/rpc/base"; -import { orderFormRoute } from "./orderform"; +import { orderFormRouter } from "./orderform"; export const shopRouter = base.prefix("/shop").tag("Shop").router({ - orderForm: orderFormRoute, + orderForm: orderFormRouter, }); diff --git a/apps/api/src/presentation/v1/routes/shop/orderform/addOrUpdateItem/index.ts b/apps/api/src/presentation/v1/routes/shop/orderform/addOrUpdateItem/index.ts new file mode 100644 index 0000000..e2cdd3c --- /dev/null +++ b/apps/api/src/presentation/v1/routes/shop/orderform/addOrUpdateItem/index.ts @@ -0,0 +1,15 @@ +import { ShopOrderFormAddOrUpdateItemContractSchema } from "@/application/usecases/shop/orderformAddOrUpdateItem/contract"; +import { isAuthenticatedProcedure } from "@/presentation/procedures/isAuthenticated"; + +export const orderFormAddOrUpdateItemRoute = isAuthenticatedProcedure + .route({ + method: "POST", + path: "/items", + summary: "Add or Update Item in Order Form", + description: "Adds or updates an item in the order form for the shop.", + }) + .input(ShopOrderFormAddOrUpdateItemContractSchema.input) + .output(ShopOrderFormAddOrUpdateItemContractSchema.output) + .handler(async ({ context, input }) => { + return context.usecases.shop.orderFormAddOrUpdateItem.execute(input); + }); diff --git a/apps/api/src/presentation/v1/routes/shop/orderform/index.ts b/apps/api/src/presentation/v1/routes/shop/orderform/index.ts index a0b889a..53a1190 100644 --- a/apps/api/src/presentation/v1/routes/shop/orderform/index.ts +++ b/apps/api/src/presentation/v1/routes/shop/orderform/index.ts @@ -1,15 +1,8 @@ -import { ShopOrderFormContractSchema } from "@/application/usecases/shop/orderform/contract"; -import { isAuthenticatedProcedure } from "@/presentation/procedures/isAuthenticated"; +import { base } from "@/infra/rpc/base"; +import { orderFormAddOrUpdateItemRoute } from "./addOrUpdateItem"; +import { orderFormRoute } from "./mostRecent"; -export const orderFormRoute = isAuthenticatedProcedure - .route({ - method: "GET", - path: "/orderform", - summary: "OrderForm", - description: "Retrieves information about the order form for the shop.", - }) - .input(ShopOrderFormContractSchema.input) - .output(ShopOrderFormContractSchema.output) - .handler(async ({ context }) => { - return context.usecases.shop.orderForm.execute({}); - }); +export const orderFormRouter = base.prefix("/orderform").router({ + getMostRecent: orderFormRoute, + addOrUpdateItem: orderFormAddOrUpdateItemRoute, +}); diff --git a/apps/api/src/presentation/v1/routes/shop/orderform/mostRecent/index.ts b/apps/api/src/presentation/v1/routes/shop/orderform/mostRecent/index.ts new file mode 100644 index 0000000..a0b889a --- /dev/null +++ b/apps/api/src/presentation/v1/routes/shop/orderform/mostRecent/index.ts @@ -0,0 +1,15 @@ +import { ShopOrderFormContractSchema } from "@/application/usecases/shop/orderform/contract"; +import { isAuthenticatedProcedure } from "@/presentation/procedures/isAuthenticated"; + +export const orderFormRoute = isAuthenticatedProcedure + .route({ + method: "GET", + path: "/orderform", + summary: "OrderForm", + description: "Retrieves information about the order form for the shop.", + }) + .input(ShopOrderFormContractSchema.input) + .output(ShopOrderFormContractSchema.output) + .handler(async ({ context }) => { + return context.usecases.shop.orderForm.execute({}); + }); diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index ba3963d..492ff2d 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -7,7 +7,7 @@ import { lazy, Suspense } from "react"; import { Layout } from "@/layout"; import type { RouterContext } from "@/router"; import { ConfigProvider } from "@/sdk/contexts/config"; -import { OrderformProvider } from "@/sdk/contexts/orderform"; +import { OrderFormProvider } from "@/sdk/contexts/orderform"; import { SessionProvider } from "@/sdk/contexts/session"; import { env } from "@/sdk/env"; import { api } from "@/sdk/lib/api/factory"; @@ -55,7 +55,7 @@ export const Route = createRootRouteWithContext()({ return ( - + {env.VITE_SHOW_DEVTOOLS && ( @@ -67,7 +67,7 @@ export const Route = createRootRouteWithContext()({ )} - + ); diff --git a/apps/web/src/sdk/contexts/orderform.tsx b/apps/web/src/sdk/contexts/orderform.tsx index 5551ccb..9417898 100644 --- a/apps/web/src/sdk/contexts/orderform.tsx +++ b/apps/web/src/sdk/contexts/orderform.tsx @@ -7,40 +7,42 @@ import { useSession } from "./session"; type Context = { orderForm: ShopOrderForm | null; invalidate: () => Promise; + loading: boolean; }; -const OrderformContext = createContext(null); +const OrderFormContext = createContext(null); -export function OrderformProvider({ children }: { children: React.ReactNode }) { +export function OrderFormProvider({ children }: { children: React.ReactNode }) { const queryClient = useQueryClient(); const { session } = useSession(); - const { data: orderForm } = useQuery( - api.query.miforge.shop.orderForm.queryOptions({ + const { data: orderForm, isPending: orderFormLoading } = useQuery( + api.query.miforge.shop.orderForm.getMostRecent.queryOptions({ enabled: !!session, }), ); return ( - { await queryClient.invalidateQueries({ - queryKey: api.query.miforge.shop.orderForm.queryKey(), + queryKey: api.query.miforge.shop.orderForm.getMostRecent.queryKey(), }); }, }} > {children} - + ); } -export const useOrderform = () => { - const context = use(OrderformContext); +export const useOrderForm = () => { + const context = use(OrderFormContext); if (!context) { - throw new Error("useOrderform must be used within OrderformProvider"); + throw new Error("useOrderForm must be used within OrderFormProvider"); } return context; diff --git a/apps/web/src/sdk/hooks/useOrderFormItem.ts b/apps/web/src/sdk/hooks/useOrderFormItem.ts new file mode 100644 index 0000000..b80d21c --- /dev/null +++ b/apps/web/src/sdk/hooks/useOrderFormItem.ts @@ -0,0 +1,43 @@ +import { useMutation } from "@tanstack/react-query"; +import { useCallback } from "react"; +import { useOrderForm } from "../contexts/orderform"; +import { api } from "../lib/api/factory"; +import { withORPCErrorHandling } from "../utils/orpc"; + +export function useOrderFormItem() { + const { + mutateAsync: addOrUpdateItemMutation, + isPending: addOrUpdateItemLoading, + } = useMutation( + api.query.miforge.shop.orderForm.addOrUpdateItem.mutationOptions(), + ); + + const { invalidate } = useOrderForm(); + + const addOrUpdateItem = useCallback( + (data: { productId: string; quantity: number; mode?: "ADD" | "SET" }) => { + withORPCErrorHandling( + async () => { + const mode = data.mode ?? "ADD"; + + await addOrUpdateItemMutation({ + productId: data.productId, + quantity: data.quantity, + mode: mode, + }); + }, + { + onSuccess: () => { + invalidate(); + }, + }, + ); + }, + [addOrUpdateItemMutation, invalidate], + ); + + return { + addOrUpdateItem, + loading: addOrUpdateItemLoading, + }; +} From f168c19d46153012f1428d07ae4238b903234192 Mon Sep 17 00:00:00 2001 From: Guilherme Fontes Date: Tue, 16 Dec 2025 17:03:27 -0400 Subject: [PATCH 3/6] feat: add route to remove item from orderform --- .../application/services/shopOrder/index.ts | 74 +++++++++++++------ apps/api/src/application/usecases/factory.ts | 4 + .../src/application/usecases/shop/index.ts | 1 + .../shop/orderformRemoveItem/contract.ts | 16 ++++ .../shop/orderformRemoveItem/index.ts | 28 +++++++ .../repositories/shopOrderItem/index.ts | 12 +++ apps/api/src/infra/di/containers/usecases.ts | 6 ++ apps/api/src/infra/di/tokens.ts | 4 + .../v1/routes/shop/orderform/index.ts | 2 + .../routes/shop/orderform/mostRecent/index.ts | 2 +- .../routes/shop/orderform/removeItem/index.ts | 15 ++++ 11 files changed, 139 insertions(+), 25 deletions(-) create mode 100644 apps/api/src/application/usecases/shop/orderformRemoveItem/contract.ts create mode 100644 apps/api/src/application/usecases/shop/orderformRemoveItem/index.ts create mode 100644 apps/api/src/presentation/v1/routes/shop/orderform/removeItem/index.ts diff --git a/apps/api/src/application/services/shopOrder/index.ts b/apps/api/src/application/services/shopOrder/index.ts index 0beaca4..1f95c7a 100644 --- a/apps/api/src/application/services/shopOrder/index.ts +++ b/apps/api/src/application/services/shopOrder/index.ts @@ -1,6 +1,7 @@ import { ORPCError } from "@orpc/client"; import { ShopOrderStatus, ShopProductQuantityMode } from "generated/client"; import { inject, injectable } from "tsyringe"; +import { Catch } from "@/application/decorators/Catch"; import type { ExecutionContext } from "@/domain/context"; import type { ShopOrderItemRepository, @@ -29,6 +30,31 @@ export class ShopOrderService { private readonly shopOrderItemRepository: ShopOrderItemRepository, ) {} + private verifyUnit( + units: number, + product: { minUnits: number; maxUnits?: number; unitStep: number }, + ) { + if (units < product.minUnits) { + throw new ORPCError("BAD_REQUEST", { + message: `Minimum order quantity for this product is ${product.minUnits}.`, + }); + } + + if (product.maxUnits != null && units > product.maxUnits) { + throw new ORPCError("BAD_REQUEST", { + message: `Maximum order quantity for this product is ${product.maxUnits}.`, + }); + } + + const diff = units - product.minUnits; + if (diff % product.unitStep !== 0) { + throw new ORPCError("BAD_REQUEST", { + message: `Order quantity must be in increments of ${product.unitStep}.`, + }); + } + } + + @Catch() async orderForm(): Promise { const session = this.executionContext.session(); @@ -95,30 +121,7 @@ export class ShopOrderService { }; } - private verifyUnit( - units: number, - product: { minUnits: number; maxUnits?: number; unitStep: number }, - ) { - if (units < product.minUnits) { - throw new ORPCError("BAD_REQUEST", { - message: `Minimum order quantity for this product is ${product.minUnits}.`, - }); - } - - if (product.maxUnits != null && units > product.maxUnits) { - throw new ORPCError("BAD_REQUEST", { - message: `Maximum order quantity for this product is ${product.maxUnits}.`, - }); - } - - const diff = units - product.minUnits; - if (diff % product.unitStep !== 0) { - throw new ORPCError("BAD_REQUEST", { - message: `Order quantity must be in increments of ${product.unitStep}.`, - }); - } - } - + @Catch() async addOrUpdateItem(input: { productId: string; quantity: number; @@ -207,4 +210,27 @@ export class ShopOrderService { return this.orderForm(); } + + @Catch() + async removeItem(itemId: string): Promise { + const orderform = await this.orderForm(); + + if (orderform.status !== ShopOrderStatus.DRAFT) { + throw new ORPCError("BAD_REQUEST", { + message: "Cannot remove items from a non-draft order.", + }); + } + + const existingItem = orderform.items.find((item) => item.id === itemId); + + if (!existingItem) { + throw new ORPCError("NOT_FOUND", { + message: "Order item not found.", + }); + } + + await this.shopOrderItemRepository.deleteItem(itemId); + + return this.orderForm(); + } } diff --git a/apps/api/src/application/usecases/factory.ts b/apps/api/src/application/usecases/factory.ts index 2bf95a3..54c122b 100644 --- a/apps/api/src/application/usecases/factory.ts +++ b/apps/api/src/application/usecases/factory.ts @@ -225,10 +225,14 @@ export class UseCasesFactory { const orderFormAddOrUpdateItem = this.di.resolve( TOKENS.ShopOrderFormAddOrUpdateItemUseCase, ); + const orderFormRemoveItem = this.di.resolve( + TOKENS.ShopOrderFormRemoveItemUseCase, + ); return { orderForm, orderFormAddOrUpdateItem, + orderFormRemoveItem, } as const; } } diff --git a/apps/api/src/application/usecases/shop/index.ts b/apps/api/src/application/usecases/shop/index.ts index 1bbd5a2..11e4f19 100644 --- a/apps/api/src/application/usecases/shop/index.ts +++ b/apps/api/src/application/usecases/shop/index.ts @@ -1,2 +1,3 @@ export * from "./orderform"; export * from "./orderformAddOrUpdateItem"; +export * from "./orderformRemoveItem"; diff --git a/apps/api/src/application/usecases/shop/orderformRemoveItem/contract.ts b/apps/api/src/application/usecases/shop/orderformRemoveItem/contract.ts new file mode 100644 index 0000000..d9d983d --- /dev/null +++ b/apps/api/src/application/usecases/shop/orderformRemoveItem/contract.ts @@ -0,0 +1,16 @@ +import z from "zod"; +import { ShopOrderForm } from "@/shared/schemas/ShopOrderForm"; + +export const ShopOrderFormRemoveItemContractSchema = { + input: z.object({ + itemId: z.uuid(), + }), + output: ShopOrderForm, +}; + +export type ShopOrderFormRemoveItemContractInput = z.infer< + typeof ShopOrderFormRemoveItemContractSchema.input +>; +export type ShopOrderFormRemoveItemContractOutput = z.infer< + typeof ShopOrderFormRemoveItemContractSchema.output +>; diff --git a/apps/api/src/application/usecases/shop/orderformRemoveItem/index.ts b/apps/api/src/application/usecases/shop/orderformRemoveItem/index.ts new file mode 100644 index 0000000..3a21673 --- /dev/null +++ b/apps/api/src/application/usecases/shop/orderformRemoveItem/index.ts @@ -0,0 +1,28 @@ +import { inject, injectable } from "tsyringe"; +import type { ShopOrderService } from "@/application/services"; +import { TOKENS } from "@/infra/di/tokens"; +import type { UseCase } from "@/shared/interfaces/usecase"; +import type { + ShopOrderFormRemoveItemContractInput, + ShopOrderFormRemoveItemContractOutput, +} from "./contract"; + +@injectable() +export class ShopOrderFormRemoveItemUseCase + implements + UseCase< + ShopOrderFormRemoveItemContractInput, + ShopOrderFormRemoveItemContractOutput + > +{ + constructor( + @inject(TOKENS.ShopOrderService) + private readonly shopOrderService: ShopOrderService, + ) {} + + execute( + input: ShopOrderFormRemoveItemContractInput, + ): Promise { + return this.shopOrderService.removeItem(input.itemId); + } +} diff --git a/apps/api/src/domain/repositories/shopOrderItem/index.ts b/apps/api/src/domain/repositories/shopOrderItem/index.ts index e51acc2..f29361c 100644 --- a/apps/api/src/domain/repositories/shopOrderItem/index.ts +++ b/apps/api/src/domain/repositories/shopOrderItem/index.ts @@ -26,6 +26,18 @@ export class ShopOrderItemRepository { }); } + deleteManyByOrderId(orderId: string) { + return this.database.miforge_shop_order_item.deleteMany({ + where: { orderId }, + }); + } + + deleteItem(id: string) { + return this.database.miforge_shop_order_item.delete({ + where: { id }, + }); + } + createItem( orderId: string, productId: string, diff --git a/apps/api/src/infra/di/containers/usecases.ts b/apps/api/src/infra/di/containers/usecases.ts index 2d0de3b..8a1f551 100644 --- a/apps/api/src/infra/di/containers/usecases.ts +++ b/apps/api/src/infra/di/containers/usecases.ts @@ -44,6 +44,7 @@ import { SessionInfoUseCase, SessionNotAuthenticatedUseCase, ShopOrderFormAddOrUpdateItemUseCase, + ShopOrderFormRemoveItemUseCase, ShopOrderFormUseCase, TibiaLoginUseCase, WorldsListUseCase, @@ -300,4 +301,9 @@ export function registerUseCases() { { useClass: ShopOrderFormAddOrUpdateItemUseCase }, { lifecycle: Lifecycle.ResolutionScoped }, ); + container.register( + TOKENS.ShopOrderFormRemoveItemUseCase, + { useClass: ShopOrderFormRemoveItemUseCase }, + { lifecycle: Lifecycle.ResolutionScoped }, + ); } diff --git a/apps/api/src/infra/di/tokens.ts b/apps/api/src/infra/di/tokens.ts index 5d3b44a..4b32f05 100644 --- a/apps/api/src/infra/di/tokens.ts +++ b/apps/api/src/infra/di/tokens.ts @@ -60,6 +60,7 @@ import type { SessionInfoUseCase, SessionNotAuthenticatedUseCase, ShopOrderFormAddOrUpdateItemUseCase, + ShopOrderFormRemoveItemUseCase, ShopOrderFormUseCase, TibiaLoginUseCase, WorldsListUseCase, @@ -341,6 +342,9 @@ const USECASES_TOKENS = { token( "ShopOrderFormAddOrUpdateItemUseCase", ), + ShopOrderFormRemoveItemUseCase: token( + "ShopOrderFormRemoveItemUseCase", + ), }; const SERVICES_TOKENS = { diff --git a/apps/api/src/presentation/v1/routes/shop/orderform/index.ts b/apps/api/src/presentation/v1/routes/shop/orderform/index.ts index 53a1190..c7f6298 100644 --- a/apps/api/src/presentation/v1/routes/shop/orderform/index.ts +++ b/apps/api/src/presentation/v1/routes/shop/orderform/index.ts @@ -1,8 +1,10 @@ import { base } from "@/infra/rpc/base"; import { orderFormAddOrUpdateItemRoute } from "./addOrUpdateItem"; import { orderFormRoute } from "./mostRecent"; +import { orderFormRemoveItemRoute } from "./removeItem"; export const orderFormRouter = base.prefix("/orderform").router({ getMostRecent: orderFormRoute, addOrUpdateItem: orderFormAddOrUpdateItemRoute, + removeItem: orderFormRemoveItemRoute, }); diff --git a/apps/api/src/presentation/v1/routes/shop/orderform/mostRecent/index.ts b/apps/api/src/presentation/v1/routes/shop/orderform/mostRecent/index.ts index a0b889a..79139a5 100644 --- a/apps/api/src/presentation/v1/routes/shop/orderform/mostRecent/index.ts +++ b/apps/api/src/presentation/v1/routes/shop/orderform/mostRecent/index.ts @@ -4,7 +4,7 @@ import { isAuthenticatedProcedure } from "@/presentation/procedures/isAuthentica export const orderFormRoute = isAuthenticatedProcedure .route({ method: "GET", - path: "/orderform", + path: "/", summary: "OrderForm", description: "Retrieves information about the order form for the shop.", }) diff --git a/apps/api/src/presentation/v1/routes/shop/orderform/removeItem/index.ts b/apps/api/src/presentation/v1/routes/shop/orderform/removeItem/index.ts new file mode 100644 index 0000000..d38a68c --- /dev/null +++ b/apps/api/src/presentation/v1/routes/shop/orderform/removeItem/index.ts @@ -0,0 +1,15 @@ +import { ShopOrderFormRemoveItemContractSchema } from "@/application/usecases/shop/orderformRemoveItem/contract"; +import { isAuthenticatedProcedure } from "@/presentation/procedures/isAuthenticated"; + +export const orderFormRemoveItemRoute = isAuthenticatedProcedure + .route({ + method: "DELETE", + path: "/items/{itemId}", + summary: "Remove Item from Order Form", + description: "Removes an item from the order form for the shop.", + }) + .input(ShopOrderFormRemoveItemContractSchema.input) + .output(ShopOrderFormRemoveItemContractSchema.output) + .handler(async ({ context, input }) => { + return context.usecases.shop.orderFormRemoveItem.execute(input); + }); From df6da5c4caa940ed749b4d6baa2909ff2778c888 Mon Sep 17 00:00:00 2001 From: Guilherme Fontes Date: Wed, 17 Dec 2025 19:45:06 -0400 Subject: [PATCH 4/6] feat: add route to search products with filters --- apps/api/src/application/services/index.ts | 1 + .../services/shopProducts/index.ts | 39 +++ apps/api/src/application/usecases/factory.ts | 2 + .../src/application/usecases/shop/index.ts | 1 + .../usecases/shop/listProducts/contract.ts | 29 +++ .../usecases/shop/listProducts/index.ts | 33 +++ .../domain/repositories/shopProduct/index.ts | 34 +++ .../repositories/shopProduct/whereFacets.ts | 40 +++ apps/api/src/infra/di/containers/services.ts | 6 + apps/api/src/infra/di/containers/usecases.ts | 6 + apps/api/src/infra/di/tokens.ts | 6 + .../src/presentation/v1/routes/shop/index.ts | 2 + .../v1/routes/shop/products/index.ts | 6 + .../v1/routes/shop/products/search/index.ts | 15 ++ apps/api/src/shared/schemas/ShopProduct.ts | 13 + apps/api/src/shared/utils/array.ts | 31 +++ apps/api/src/shared/utils/prisma/facets.ts | 231 ++++++++++++++++++ 17 files changed, 495 insertions(+) create mode 100644 apps/api/src/application/services/shopProducts/index.ts create mode 100644 apps/api/src/application/usecases/shop/listProducts/contract.ts create mode 100644 apps/api/src/application/usecases/shop/listProducts/index.ts create mode 100644 apps/api/src/domain/repositories/shopProduct/whereFacets.ts create mode 100644 apps/api/src/presentation/v1/routes/shop/products/index.ts create mode 100644 apps/api/src/presentation/v1/routes/shop/products/search/index.ts create mode 100644 apps/api/src/shared/utils/array.ts create mode 100644 apps/api/src/shared/utils/prisma/facets.ts diff --git a/apps/api/src/application/services/index.ts b/apps/api/src/application/services/index.ts index 3c5a1b6..b9d8857 100644 --- a/apps/api/src/application/services/index.ts +++ b/apps/api/src/application/services/index.ts @@ -9,5 +9,6 @@ export * from "./players"; export * from "./recoveryKey"; export * from "./session"; export * from "./shopOrder"; +export * from "./shopProducts"; export * from "./tibiaclient"; export * from "./worlds"; diff --git a/apps/api/src/application/services/shopProducts/index.ts b/apps/api/src/application/services/shopProducts/index.ts new file mode 100644 index 0000000..d38c852 --- /dev/null +++ b/apps/api/src/application/services/shopProducts/index.ts @@ -0,0 +1,39 @@ +import { inject, injectable } from "tsyringe"; +import { Catch } from "@/application/decorators/Catch"; +import type { Pagination } from "@/domain/modules"; +import type { ShopProductRepository } from "@/domain/repositories"; +import { TOKENS } from "@/infra/di/tokens"; +import type { ShopProductFacet } from "@/shared/schemas/ShopProduct"; +import type { PaginationInput } from "@/shared/utils/paginate"; + +@injectable() +export class ShopProductsService { + constructor( + @inject(TOKENS.ShopProductRepository) + private readonly shopProductRepository: ShopProductRepository, + @inject(TOKENS.Pagination) private readonly pagination: Pagination, + ) {} + + @Catch() + async list(input: { + pagination: Partial; + filter?: { + facets?: ShopProductFacet[]; + }; + }) { + const page = input.pagination.page ?? 1; + const size = input.pagination.size ?? 10; + const facets = input.filter?.facets ?? []; + + const { products, total } = await this.shopProductRepository.findProducts({ + pagination: { page, size }, + filter: { facets }, + }); + + return this.pagination.paginate(products, { + page, + size, + total, + }); + } +} diff --git a/apps/api/src/application/usecases/factory.ts b/apps/api/src/application/usecases/factory.ts index 54c122b..1955683 100644 --- a/apps/api/src/application/usecases/factory.ts +++ b/apps/api/src/application/usecases/factory.ts @@ -228,11 +228,13 @@ export class UseCasesFactory { const orderFormRemoveItem = this.di.resolve( TOKENS.ShopOrderFormRemoveItemUseCase, ); + const listProducts = this.di.resolve(TOKENS.ShopListProductsUseCase); return { orderForm, orderFormAddOrUpdateItem, orderFormRemoveItem, + listProducts, } as const; } } diff --git a/apps/api/src/application/usecases/shop/index.ts b/apps/api/src/application/usecases/shop/index.ts index 11e4f19..a304dcb 100644 --- a/apps/api/src/application/usecases/shop/index.ts +++ b/apps/api/src/application/usecases/shop/index.ts @@ -1,3 +1,4 @@ +export * from "./listProducts"; export * from "./orderform"; export * from "./orderformAddOrUpdateItem"; export * from "./orderformRemoveItem"; diff --git a/apps/api/src/application/usecases/shop/listProducts/contract.ts b/apps/api/src/application/usecases/shop/listProducts/contract.ts new file mode 100644 index 0000000..a15457e --- /dev/null +++ b/apps/api/src/application/usecases/shop/listProducts/contract.ts @@ -0,0 +1,29 @@ +import z from "zod"; +import { + ShopProduct, + ShopProductFacetSchema, +} from "@/shared/schemas/ShopProduct"; +import { createPaginateSchema, InputPageSchema } from "@/shared/utils/paginate"; + +export const ShopListProductsContractSchema = { + input: InputPageSchema.extend({ + facets: z + .array(ShopProductFacetSchema) + .optional() + .meta({ + description: "Facets to filter products by", + examples: [ + "facets[0][key]=title&facets[0][value][]=coins", + "https://orpc.dev/docs/openapi/bracket-notation", + ], + }), + }), + output: createPaginateSchema(ShopProduct), +}; + +export type ShopListProductsContractInput = z.infer< + typeof ShopListProductsContractSchema.input +>; +export type ShopListProductsContractOutput = z.infer< + typeof ShopListProductsContractSchema.output +>; diff --git a/apps/api/src/application/usecases/shop/listProducts/index.ts b/apps/api/src/application/usecases/shop/listProducts/index.ts new file mode 100644 index 0000000..58d5232 --- /dev/null +++ b/apps/api/src/application/usecases/shop/listProducts/index.ts @@ -0,0 +1,33 @@ +import { inject, injectable } from "tsyringe"; +import type { ShopProductsService } from "@/application/services"; +import { TOKENS } from "@/infra/di/tokens"; +import type { UseCase } from "@/shared/interfaces/usecase"; +import type { + ShopListProductsContractInput, + ShopListProductsContractOutput, +} from "./contract"; + +@injectable() +export class ShopListProductsUseCase + implements + UseCase +{ + constructor( + @inject(TOKENS.ShopProductsService) + private shopProductsService: ShopProductsService, + ) {} + + execute( + input: ShopListProductsContractInput, + ): Promise { + return this.shopProductsService.list({ + pagination: { + page: input.page, + size: input.size, + }, + filter: { + facets: input.facets, + }, + }); + } +} diff --git a/apps/api/src/domain/repositories/shopProduct/index.ts b/apps/api/src/domain/repositories/shopProduct/index.ts index 6b279b6..740bef1 100644 --- a/apps/api/src/domain/repositories/shopProduct/index.ts +++ b/apps/api/src/domain/repositories/shopProduct/index.ts @@ -3,6 +3,9 @@ import { inject, injectable } from "tsyringe"; import type { Prisma as Database } from "@/domain/clients"; import type { Cache, CacheKeys } from "@/domain/modules"; import { TOKENS } from "@/infra/di/tokens"; +import type { ShopProductFacet } from "@/shared/schemas/ShopProduct"; +import type { PaginationInput } from "@/shared/utils/paginate"; +import { whereFromShopProductFacets } from "./whereFacets"; // biome-ignore lint/complexity/noBannedTypes: type ShopProduct = Prisma.miforge_shop_productGetPayload<{}>; @@ -20,6 +23,37 @@ export class ShopProductRepository { return this.cache.delete(key); } + async findProducts(input: { + pagination: Partial; + filter?: { + facets?: ShopProductFacet[]; + }; + }) { + const page = input.pagination.page ?? 1; + const size = input.pagination.size ?? 10; + const facets = input.filter?.facets ?? []; + + const whereFilter: Prisma.miforge_shop_productWhereInput = { + ...whereFromShopProductFacets(facets), + }; + + const [total, results] = await Promise.all([ + this.database.miforge_shop_product.count({ + where: whereFilter, + }), + this.database.miforge_shop_product.findMany({ + where: whereFilter, + skip: (page - 1) * size, + take: size, + }), + ]); + + return { + products: results, + total, + }; + } + async findById(productId: string): Promise { const { key, ttl } = this.cacheKeys.keys.shopProduct(productId); diff --git a/apps/api/src/domain/repositories/shopProduct/whereFacets.ts b/apps/api/src/domain/repositories/shopProduct/whereFacets.ts new file mode 100644 index 0000000..80dd9df --- /dev/null +++ b/apps/api/src/domain/repositories/shopProduct/whereFacets.ts @@ -0,0 +1,40 @@ +import type { Prisma } from "generated/client"; +import { + type ShopProductFacet, + ShopProductFacetSchema, +} from "@/shared/schemas/ShopProduct"; +import { isNonEmpty, normStringArray } from "@/shared/utils/array"; +import { + type FacetHandlersFromUnion, + makeWhereFromFacets, +} from "@/shared/utils/prisma/facets"; + +const shopProductHandlers = { + title(values: string | string[]) { + const vals = normStringArray(values); + if (!isNonEmpty(vals)) return undefined; + + return { + OR: vals.map((title) => ({ + title: { + contains: title, + }, + })), + }; + }, + enabled(value: boolean) { + return { + enabled: value, + }; + }, +} satisfies FacetHandlersFromUnion< + ShopProductFacet, + Prisma.miforge_shop_productWhereInput +>; + +export const whereFromShopProductFacets = makeWhereFromFacets< + ShopProductFacet, + Prisma.miforge_shop_productWhereInput +>(ShopProductFacetSchema, shopProductHandlers, { + arrayMergePolicy: "union", +}); diff --git a/apps/api/src/infra/di/containers/services.ts b/apps/api/src/infra/di/containers/services.ts index 073f064..ae38d4b 100644 --- a/apps/api/src/infra/di/containers/services.ts +++ b/apps/api/src/infra/di/containers/services.ts @@ -11,6 +11,7 @@ import { RecoveryKeyService, SessionService, ShopOrderService, + ShopProductsService, TibiaClientService, WorldsService, } from "@/application/services"; @@ -82,4 +83,9 @@ export function registerServices() { { useClass: ShopOrderService }, { lifecycle: Lifecycle.ResolutionScoped }, ); + container.register( + TOKENS.ShopProductsService, + { useClass: ShopProductsService }, + { lifecycle: Lifecycle.ResolutionScoped }, + ); } diff --git a/apps/api/src/infra/di/containers/usecases.ts b/apps/api/src/infra/di/containers/usecases.ts index 8a1f551..4fd072a 100644 --- a/apps/api/src/infra/di/containers/usecases.ts +++ b/apps/api/src/infra/di/containers/usecases.ts @@ -43,6 +43,7 @@ import { SessionCanBeAuthenticatedUseCase, SessionInfoUseCase, SessionNotAuthenticatedUseCase, + ShopListProductsUseCase, ShopOrderFormAddOrUpdateItemUseCase, ShopOrderFormRemoveItemUseCase, ShopOrderFormUseCase, @@ -306,4 +307,9 @@ export function registerUseCases() { { useClass: ShopOrderFormRemoveItemUseCase }, { lifecycle: Lifecycle.ResolutionScoped }, ); + container.register( + TOKENS.ShopListProductsUseCase, + { useClass: ShopListProductsUseCase }, + { lifecycle: Lifecycle.ResolutionScoped }, + ); } diff --git a/apps/api/src/infra/di/tokens.ts b/apps/api/src/infra/di/tokens.ts index 4b32f05..b48182a 100644 --- a/apps/api/src/infra/di/tokens.ts +++ b/apps/api/src/infra/di/tokens.ts @@ -12,6 +12,7 @@ import type { RecoveryKeyService, SessionService, ShopOrderService, + ShopProductsService, TibiaClientService, WorldsService, } from "@/application/services"; @@ -59,6 +60,7 @@ import type { SessionCanBeAuthenticatedUseCase, SessionInfoUseCase, SessionNotAuthenticatedUseCase, + ShopListProductsUseCase, ShopOrderFormAddOrUpdateItemUseCase, ShopOrderFormRemoveItemUseCase, ShopOrderFormUseCase, @@ -345,6 +347,9 @@ const USECASES_TOKENS = { ShopOrderFormRemoveItemUseCase: token( "ShopOrderFormRemoveItemUseCase", ), + ShopListProductsUseCase: token( + "ShopListProductsUseCase", + ), }; const SERVICES_TOKENS = { @@ -365,6 +370,7 @@ const SERVICES_TOKENS = { RecoveryKeyService: token("RecoveryKeyService"), AccountOauthService: token("AccountOauthService"), ShopOrderService: token("ShopOrderService"), + ShopProductsService: token("ShopProductsService"), }; const QUEUE_AND_WORKERS_TOKENS = { diff --git a/apps/api/src/presentation/v1/routes/shop/index.ts b/apps/api/src/presentation/v1/routes/shop/index.ts index 4d083d8..f2c0a40 100644 --- a/apps/api/src/presentation/v1/routes/shop/index.ts +++ b/apps/api/src/presentation/v1/routes/shop/index.ts @@ -1,6 +1,8 @@ import { base } from "@/infra/rpc/base"; import { orderFormRouter } from "./orderform"; +import { productsRouter } from "./products"; export const shopRouter = base.prefix("/shop").tag("Shop").router({ orderForm: orderFormRouter, + products: productsRouter, }); diff --git a/apps/api/src/presentation/v1/routes/shop/products/index.ts b/apps/api/src/presentation/v1/routes/shop/products/index.ts new file mode 100644 index 0000000..6b99b4f --- /dev/null +++ b/apps/api/src/presentation/v1/routes/shop/products/index.ts @@ -0,0 +1,6 @@ +import { base } from "@/infra/rpc/base"; +import { productsSearchRoute } from "./search"; + +export const productsRouter = base.prefix("/products").router({ + search: productsSearchRoute, +}); diff --git a/apps/api/src/presentation/v1/routes/shop/products/search/index.ts b/apps/api/src/presentation/v1/routes/shop/products/search/index.ts new file mode 100644 index 0000000..1b9d21f --- /dev/null +++ b/apps/api/src/presentation/v1/routes/shop/products/search/index.ts @@ -0,0 +1,15 @@ +import { ShopListProductsContractSchema } from "@/application/usecases/shop/listProducts/contract"; +import { publicProcedure } from "@/presentation/procedures/public"; + +export const productsSearchRoute = publicProcedure + .route({ + method: "GET", + path: "/search", + summary: "Search Products", + description: "Searches for products in the shop.", + }) + .input(ShopListProductsContractSchema.input) + .output(ShopListProductsContractSchema.output) + .handler(async ({ context, input }) => { + return context.usecases.shop.listProducts.execute(input); + }); diff --git a/apps/api/src/shared/schemas/ShopProduct.ts b/apps/api/src/shared/schemas/ShopProduct.ts index e1b310f..044a752 100644 --- a/apps/api/src/shared/schemas/ShopProduct.ts +++ b/apps/api/src/shared/schemas/ShopProduct.ts @@ -24,3 +24,16 @@ export const ShopProduct = z.object({ createdAt: z.date(), updatedAt: z.date(), }); + +export const ShopProductFacetSchema = z.discriminatedUnion("key", [ + z.object({ + key: z.literal("title"), + value: z.array(z.string().trim().min(1)).min(1), + }), + z.object({ + key: z.literal("enabled"), + value: z.coerce.string().transform((val) => val === "true"), + }), +]); + +export type ShopProductFacet = z.infer; diff --git a/apps/api/src/shared/utils/array.ts b/apps/api/src/shared/utils/array.ts new file mode 100644 index 0000000..00f969f --- /dev/null +++ b/apps/api/src/shared/utils/array.ts @@ -0,0 +1,31 @@ +export function isNonEmpty(arr: T[] | undefined | null): arr is T[] { + return !!arr && arr.length > 0; +} + +export function dedupeArray( + arr: T[], + keyFn?: (item: T) => string | number, +): T[] { + // biome-ignore lint/suspicious/noExplicitAny: + if (!keyFn) return Array.from(new Set(arr as any)) as T[]; + + const seen = new Map(); + for (const item of arr) { + seen.set(keyFn(item), item); + } + return Array.from(seen.values()); +} + +export function toArray(v: T | T[]): T[] { + return Array.isArray(v) ? v : [v]; +} + +export function normStringArray(v: string | string[]): string[] { + return [ + ...new Set( + toArray(v) + .map((s) => String(s).trim()) + .filter(Boolean), + ), + ]; +} diff --git a/apps/api/src/shared/utils/prisma/facets.ts b/apps/api/src/shared/utils/prisma/facets.ts new file mode 100644 index 0000000..4f38ea0 --- /dev/null +++ b/apps/api/src/shared/utils/prisma/facets.ts @@ -0,0 +1,231 @@ +import type { ZodType } from "zod"; +import { dedupeArray, isNonEmpty } from "@/shared/utils/array"; + +type FacetKeys = U extends { key: infer K } ? K : never; +type FacetValueOf = + Extract extends { value: infer V } ? V : never; + +type ScalarConflictPolicy = "last" | "first" | "error"; +type ArrayMergePolicy = "concat" | "union" | "error"; +type LimitReaction = "truncate" | "error"; + +type BuilderLimits = { + /** + * Limite de itens facet recebidos no input (antes do parse). + * Ajuda contra payload gigante. + */ + maxFacets?: number; + + /** + * Limite de valores por key após group/merge. + * Ajuda contra OR gigante (ex.: title). + */ + maxValuesPerKey?: number; + + /** + * Limite de clauses finais no AND/OR. + */ + maxClauses?: number; + + /** + * Limite por key (sobrescreve maxValuesPerKey). + * Ex.: { title: 10 } + */ + byKey?: Partial & string, number>>; +}; + +type BuilderOptions = { + // default: "last" + scalarConflictPolicy?: ScalarConflictPolicy; + + // default: "concat" + arrayMergePolicy?: ArrayMergePolicy; + + // quando arrayMergePolicy === "union" e o item não é primitivo, você pode informar como dedupe + arrayDedupeKey?: (item: unknown) => string | number; + + // default: "AND" + combineKey?: "AND" | "OR"; + + // default: true -> se a mesma key aparecer com array e escalar, explode + strictValueKind?: boolean; + + // default: "truncate" + onLimit?: LimitReaction; + + limits?: BuilderLimits; +}; + +export type FacetHandlersFromUnion = { + [K in FacetKeys & string]: ( + value: FacetValueOf, + ) => Where | Where[] | undefined; +}; + +type GroupedFacetValues = Partial<{ + [K in FacetKeys & string]: Array>; +}>; + +export function groupFacetValues( + facets: ReadonlyArray, +): GroupedFacetValues { + const grouped: Partial> = {}; + + for (const f of facets) { + let bucket = grouped[f.key]; + if (bucket === undefined) { + bucket = []; + grouped[f.key] = bucket; + } + bucket.push(f.value); + } + + return grouped as GroupedFacetValues; +} + +function pushClause(out: Where[], clause: Where | Where[] | undefined) { + if (!clause) return; + if (Array.isArray(clause)) out.push(...clause); + else out.push(clause); +} + +function enforceLimit( + label: string, + items: T[], + max: number | undefined, + onLimit: LimitReaction, +): T[] { + if (!max || max <= 0) return items; + if (items.length <= max) return items; + + if (onLimit === "error") { + throw new Error(`${label} exceeded limit (${items.length} > ${max})`); + } + + return items.slice(0, max); +} + +const DEFAULT_LIMITS = { + maxFacets: 30, + maxValuesPerKey: 10, + maxClauses: 30, +} as const; + +export function makeWhereFromFacets< + U extends { key: string; value: unknown }, + Where, +>( + schema: ZodType, + handlers: FacetHandlersFromUnion, + options: BuilderOptions = {}, +) { + const { + arrayMergePolicy = "concat", + scalarConflictPolicy = "last", + arrayDedupeKey, + combineKey = "AND", + strictValueKind = true, + onLimit = "truncate", + limits, + } = options; + + const mergedLimits: BuilderLimits = { + ...DEFAULT_LIMITS, + ...limits, + byKey: { + ...(limits?.byKey ?? {}), + }, + } + + return function whereFromFacets(input: unknown | unknown[]): Where { + let rawItems = Array.isArray(input) ? input : [input]; + + // 1) limite no payload bruto (antes do parse) + rawItems = enforceLimit("facets", rawItems, mergedLimits.maxFacets, onLimit); + + const valid: U[] = []; + for (const item of rawItems) { + const parsed = schema.safeParse(item); + if (parsed.success) valid.push(parsed.data); + } + + if (!isNonEmpty(valid)) return {} as Where; + + const grouped = groupFacetValues(valid); + const clauses: Where[] = []; + + const applyKey = & string>( + key: K, + values: Array>, + ) => { + const handler = handlers[key]; + if (!handler || values.length === 0) return; + + const hasArray = values.some(Array.isArray); + const hasScalar = values.some((v) => !Array.isArray(v)); + + if (strictValueKind && hasArray && hasScalar) { + throw new Error(`Mixed scalar/array facet values for key "${key}"`); + } + + // limite final por key (pós-merge) — mas pra arrays a gente aplica depois do merge + const perKeyLimit = + mergedLimits.byKey?.[key] ?? mergedLimits.maxValuesPerKey; + + if (hasArray) { + if (arrayMergePolicy === "error" && values.length > 1) { + throw new Error(`Array conflict detected for facet key "${key}"`); + } + + const flattened = (values as unknown[][]).flat(); + const merged = + arrayMergePolicy === "union" + ? dedupeArray(flattened, arrayDedupeKey) + : flattened; + + const limited = enforceLimit( + `facet "${key}" values`, + merged, + perKeyLimit, + onLimit, + ); + + pushClause(clauses, handler(limited as FacetValueOf)); + return; + } + + // escalar + if (values.length > 1 && scalarConflictPolicy === "error") { + throw new Error(`Scalar conflict detected for facet key "${key}"`); + } + + // se quiser também limitar quantidade de escalares por key antes da política: + // (na prática você já resolve conflito com last/first/error) + const chosen = + values.length === 1 || scalarConflictPolicy === "last" + ? values[values.length - 1] + : values[0]; + + pushClause(clauses, handler(chosen)); + }; + + for (const [key, values] of Object.entries(grouped) as Array< + [FacetKeys & string, unknown[]] + >) { + // biome-ignore lint/suspicious/noExplicitAny: correlation assumption key->value + applyKey(key as any, values as any); + } + + if (clauses.length === 0) return {} as Where; + + // 3) limite de clauses finais + const limitedClauses = enforceLimit( + `${combineKey} clauses`, + clauses, + mergedLimits.maxClauses, + onLimit, + ); + + return { [combineKey]: limitedClauses } as unknown as Where; + }; +} \ No newline at end of file From 4a405fbd65c360a04a8eba4028b452586b5573b1 Mon Sep 17 00:00:00 2001 From: Guilherme Fontes Date: Fri, 19 Dec 2025 23:26:36 -0400 Subject: [PATCH 5/6] feat: add page shop with drawer to show cart --- .vscode/settings.json | 2 + apps/web/package.json | 3 + .../assets/buttons/button_green_extended.gif | Bin 0 -> 1472 bytes .../assets/cart/purchasecomplete_idle.png | Bin 0 -> 816 bytes apps/web/public/assets/cart/store-purse.png | Bin 0 -> 3247 bytes apps/web/public/assets/coins/coins_1.png | Bin 0 -> 989 bytes apps/web/public/assets/coins/coins_2.png | Bin 0 -> 1126 bytes apps/web/public/assets/coins/coins_3.png | Bin 0 -> 1319 bytes apps/web/public/assets/coins/coins_4.png | Bin 0 -> 1505 bytes apps/web/public/assets/coins/coins_5.png | Bin 0 -> 1665 bytes apps/web/public/assets/coins/coins_6.png | Bin 0 -> 5734 bytes .../assets/coins/non_transferable_coins_1.png | Bin 0 -> 2216 bytes .../assets/coins/non_transferable_coins_2.png | Bin 0 -> 2566 bytes .../assets/coins/non_transferable_coins_3.png | Bin 0 -> 3074 bytes .../assets/coins/non_transferable_coins_4.png | Bin 0 -> 3460 bytes .../assets/coins/non_transferable_coins_5.png | Bin 0 -> 3835 bytes .../assets/coins/non_transferable_coins_6.png | Bin 0 -> 5735 bytes apps/web/public/assets/icon-hourglass.png | Bin 0 -> 2211 bytes .../public/assets/icons/32/tibiora_box.gif | Bin 0 -> 4666 bytes .../assets/service/service_deactivated.png | Bin 0 -> 6964 bytes .../assets/service/service_icon_normal.png | Bin 0 -> 5065 bytes .../assets/service/service_icon_over.png | Bin 0 -> 4572 bytes .../assets/service/service_icon_selected.png | Bin 0 -> 4497 bytes apps/web/src/assets/index.ts | 1 + apps/web/src/components/Cart/Drawer/index.tsx | 145 ++++ apps/web/src/components/Cart/Open/index.tsx | 40 + apps/web/src/components/Menu/Item/index.tsx | 3 +- apps/web/src/components/Menu/index.tsx | 5 + .../src/components/OutfitAnimation/index.tsx | 75 +- .../components/Products/BaseService/index.tsx | 80 ++ .../src/components/Products/Coins/index.tsx | 183 +++++ apps/web/src/routeTree.gen.ts | 23 + apps/web/src/routes/__root.tsx | 10 +- apps/web/src/routes/_auth/shop/index.lazy.tsx | 10 + apps/web/src/routes/_public/index.tsx | 1 - apps/web/src/sdk/contexts/orderform.tsx | 11 +- apps/web/src/sdk/hooks/useMobile.ts | 21 + apps/web/src/sdk/hooks/useMoney.ts | 44 ++ apps/web/src/sdk/hooks/useOrderFormItem.ts | 67 +- apps/web/src/sdk/hooks/useOutfitAnimation.ts | 21 +- apps/web/src/sections/shop/index.tsx | 78 ++ apps/web/src/ui/Badge/index.tsx | 45 ++ apps/web/src/ui/Buttons/ButtonImage/index.tsx | 5 +- apps/web/src/ui/Container/index.tsx | 44 +- apps/web/src/ui/Drawer/index.tsx | 132 ++++ apps/web/src/ui/Sheet/index.tsx | 136 ++++ apps/web/src/ui/Sidebar/index.tsx | 724 ++++++++++++++++++ apps/web/src/ui/Skeleton/index.tsx | 13 + apps/web/src/ui/Slider/index.tsx | 60 ++ pnpm-lock.yaml | 115 ++- 50 files changed, 2057 insertions(+), 40 deletions(-) create mode 100644 apps/web/public/assets/buttons/button_green_extended.gif create mode 100644 apps/web/public/assets/cart/purchasecomplete_idle.png create mode 100644 apps/web/public/assets/cart/store-purse.png create mode 100644 apps/web/public/assets/coins/coins_1.png create mode 100644 apps/web/public/assets/coins/coins_2.png create mode 100644 apps/web/public/assets/coins/coins_3.png create mode 100644 apps/web/public/assets/coins/coins_4.png create mode 100644 apps/web/public/assets/coins/coins_5.png create mode 100644 apps/web/public/assets/coins/coins_6.png create mode 100644 apps/web/public/assets/coins/non_transferable_coins_1.png create mode 100644 apps/web/public/assets/coins/non_transferable_coins_2.png create mode 100644 apps/web/public/assets/coins/non_transferable_coins_3.png create mode 100644 apps/web/public/assets/coins/non_transferable_coins_4.png create mode 100644 apps/web/public/assets/coins/non_transferable_coins_5.png create mode 100644 apps/web/public/assets/coins/non_transferable_coins_6.png create mode 100644 apps/web/public/assets/icon-hourglass.png create mode 100644 apps/web/public/assets/icons/32/tibiora_box.gif create mode 100644 apps/web/public/assets/service/service_deactivated.png create mode 100644 apps/web/public/assets/service/service_icon_normal.png create mode 100644 apps/web/public/assets/service/service_icon_over.png create mode 100644 apps/web/public/assets/service/service_icon_selected.png create mode 100644 apps/web/src/components/Cart/Drawer/index.tsx create mode 100644 apps/web/src/components/Cart/Open/index.tsx create mode 100644 apps/web/src/components/Products/BaseService/index.tsx create mode 100644 apps/web/src/components/Products/Coins/index.tsx create mode 100644 apps/web/src/routes/_auth/shop/index.lazy.tsx create mode 100644 apps/web/src/sdk/hooks/useMobile.ts create mode 100644 apps/web/src/sdk/hooks/useMoney.ts create mode 100644 apps/web/src/sections/shop/index.tsx create mode 100644 apps/web/src/ui/Badge/index.tsx create mode 100644 apps/web/src/ui/Drawer/index.tsx create mode 100644 apps/web/src/ui/Sheet/index.tsx create mode 100644 apps/web/src/ui/Sidebar/index.tsx create mode 100644 apps/web/src/ui/Skeleton/index.tsx create mode 100644 apps/web/src/ui/Slider/index.tsx diff --git a/.vscode/settings.json b/.vscode/settings.json index 8637159..83c40ed 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -164,6 +164,7 @@ "premdays", "premiumuntil", "previewstate", + "purchasecomplete", "pvptype", "qrcode", "quickloot", @@ -206,6 +207,7 @@ "usecases", "usehooks", "uuid", + "vaul", "verdana", "vipgroup", "vipgrouplist", diff --git a/apps/web/package.json b/apps/web/package.json index 50cc61a..2a2d9a4 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -24,6 +24,7 @@ "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-popover": "^1.1.15", "@radix-ui/react-radio-group": "^1.3.8", + "@radix-ui/react-slider": "^1.3.6", "@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-tooltip": "^1.2.8", "@tailwindcss/vite": "^4.1.17", @@ -37,6 +38,7 @@ "cmdk": "^1.1.1", "envalid": "^8.1.1", "input-otp": "^1.4.2", + "lucide-react": "^0.562.0", "react": "catalog:react", "react-dom": "catalog:react", "react-hook-form": "^7.68.0", @@ -46,6 +48,7 @@ "tailwindcss": "^4.1.17", "tw-animate-css": "^1.4.0", "usehooks-ts": "^3.1.1", + "vaul": "^1.1.2", "zod": "catalog:" }, "devDependencies": { diff --git a/apps/web/public/assets/buttons/button_green_extended.gif b/apps/web/public/assets/buttons/button_green_extended.gif new file mode 100644 index 0000000000000000000000000000000000000000..e47091776df53ec9872f1cad27d0ef5d1ee385e5 GIT binary patch literal 1472 zcmV;x1wZ;nNk%v~VU_?H0Oo%He*gdg0001Q00VIX33LYxb_o-D4ys%SuWJmtb_cX@ z4yk1uy?ha~c`U+j2_JzMB!e3+i6S(NCq0oZL6k5=l`}_|HAt8?PMbPYo<3HfKwG0k zW2Z{Jhcm{41j>d3&58oXg9*uo2+fQQ(TxMulLOL{4Ahkm)|U_3lnB_E3d4mJ&XF6~ zniAQY63~YJU{`^Dx@JRghN&E3m{P<4( z{7n1tQT+8&`|?-)`c?e(T>bf4>&<5L+GFU-e(=w8`s8l>^bxmHUaBXqHZf$L2TU${0O-lRx`%TeX zng~%6L`aQ8Hv(K?qk{(z9WY?Lc){X@0yR}6PJD4<1cE9$UPK)E0)d*1EK;V-v7_Y< z9W8UPbg09H3Kb_**u<&A#Lk>1OpF*h)I*4)B94X-!IXi66%3+EW#A#ffjww&%!o0g zYgZRwU7%<|7DfxALxl$A2xEp09W(#h-f%!DVW9<+^6K5ox36D9CJs#rz@hMl!x|DR zUd*_$({Vj%brd9b%hHU zG-&2bH^2|V8!}j!AVETeFh>9zTp&S!1{6TRh$NP1;)y7xsN#w&w&>!EFs@jD0TWJeL4Jh%wm}5| zhB&~FKn5w~kVF<~&RalE#%;KCoq%Z1(BrpMVDHr<-uf8GsLjG=Qg;Tng&w zqmV}0=9>@1IcFY(_;DqV0T$5Yr=W%^>ZqeWDM1BuW#VfaD(j3B zN^pUcRuFdeu4{LeI*v=B&42t0wjDuLJ6?P zF8l1Ut4;z4AKE a3b5a#0tq>#U`Prj8kcYW`F&Lq2mm{G5WkxM literal 0 HcmV?d00001 diff --git a/apps/web/public/assets/cart/purchasecomplete_idle.png b/apps/web/public/assets/cart/purchasecomplete_idle.png new file mode 100644 index 0000000000000000000000000000000000000000..0b592e18120660b38dcbd71f80c36c44f108c845 GIT binary patch literal 816 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 literal 0 HcmV?d00001 diff --git a/apps/web/public/assets/coins/coins_1.png b/apps/web/public/assets/coins/coins_1.png new file mode 100644 index 0000000000000000000000000000000000000000..68fb9b3531b78983c0cae83637b7a5736c39a69f GIT binary patch literal 989 zcmV<310wv1P)004R>004l5008;`004mK004C`008P>0026e000+ooVrmw0000v zP)t-s00000001;XN#B77|GYc@<*s`b4yPy|(l;#nj(z{{?f?Dw|MlVMw_dz&2HBJ@ zk5vY$V+Q~L03a(?k^lez0(4SNQvgkV{_qt50004EOGiWihy@);00009a7bBm000XT z000XT0n*)m`~Uy~^GQTORA}DqnA@77Fc5?dNrVUl=KWuGySfty8g?a&aWAO3(3#Jn zev3W;x7(#lmo8npbm`KiPpetaE_C;UesFefUpv2@&VqJ!f2F8@_HfQ|nIe;}g}PDRjI z`VJn)BM1PfodCG}*nahI;SYin5zr|}rLzDC09I1`{pt_@);@f=kd@ao+k1fFl517D1Xoz9FatH1~i-<<0Q-GvxWRYWNF*xxd^Xw^->l{6X;afC4i7n5K$92-Zh5Fy|*Uctlgl zUnaE)*HGbd%A_N^27!8hOs8u43xeA2tE%=9L1j0_HyFO*{{QO_tYbJzHh~U&00000 LNkvXXu0mjfm}IBD literal 0 HcmV?d00001 diff --git a/apps/web/public/assets/coins/coins_2.png b/apps/web/public/assets/coins/coins_2.png new file mode 100644 index 0000000000000000000000000000000000000000..b3c1bcbaebd9813cf598d52304ea1d4f3fc96164 GIT binary patch literal 1126 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 literal 0 HcmV?d00001 diff --git a/apps/web/public/assets/coins/coins_4.png b/apps/web/public/assets/coins/coins_4.png new file mode 100644 index 0000000000000000000000000000000000000000..793e2ac88cb2e7c4c50294e3b70ce4a41af7112a GIT binary patch literal 1505 zcmV<71s?i|P)004R>004l5008;`004mK004C`008P>0026e000+ooVrmw00015 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 literal 0 HcmV?d00001 diff --git a/apps/web/public/assets/coins/non_transferable_coins_3.png b/apps/web/public/assets/coins/non_transferable_coins_3.png new file mode 100644 index 0000000000000000000000000000000000000000..1e1a25dc29be3898d4fc17b54b4cc7ad874dcd17 GIT binary patch literal 3074 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 literal 0 HcmV?d00001 diff --git a/apps/web/public/assets/coins/non_transferable_coins_5.png b/apps/web/public/assets/coins/non_transferable_coins_5.png new file mode 100644 index 0000000000000000000000000000000000000000..306f096e2caf33fba5b1e1e7d53ead9259612f8c GIT binary patch literal 3835 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 literal 0 HcmV?d00001 diff --git a/apps/web/public/assets/icon-hourglass.png b/apps/web/public/assets/icon-hourglass.png new file mode 100644 index 0000000000000000000000000000000000000000..0c92d2010e3dfe2eb1312ecf83c3222809a98726 GIT binary patch literal 2211 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 literal 0 HcmV?d00001 diff --git a/apps/web/public/assets/service/service_icon_over.png b/apps/web/public/assets/service/service_icon_over.png new file mode 100644 index 0000000000000000000000000000000000000000..daabf00e46973e9f95d7ceec7eac3f5f7167c76c GIT binary patch literal 4572 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 literal 0 HcmV?d00001 diff --git a/apps/web/public/assets/service/service_icon_selected.png b/apps/web/public/assets/service/service_icon_selected.png new file mode 100644 index 0000000000000000000000000000000000000000..6e2eb791af674ab9d3668711c2bbe05bc8f855ba GIT binary patch literal 4497 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 = { + ITEMS: "Subtotal", + DISCOUNT: "Desconto", + TOTAL: "Total", +}; + +export function CartDrawer() { + const navigate = useNavigate(); + const { cart, orderForm } = useOrderForm(); + const { removeItem, removeLoading } = useOrderFormItem(); + + const classChains = cn( + "absolute top-0y h-full w-[7px] bg-[url('/assets/borders/chain.webp')] bg-repeat-y", + ); + + const items = orderForm?.items || []; + + return ( + + +
+ Teste +
+ + + Meu Carrinho + + + + +
+ {items.map((item) => { + return ( +
+ coins +
+ + {item.effectiveQuantity} {item.title} + + + {formatter(item.totalPriceCents, { + cents: true, + })} + +
+ +
+ ); + })} +
+
+ {orderForm?.totals.totalizers.map((total) => { + return ( +
+ + {TOTALIZERS_LABEL[total.id]}:{" "} + + + {formatter(total.valueCents, { + cents: true, + })} + +
+ ); + })} +
+ +
+ { + cart.setOpen(false); + }} + > + Fechar + + { + navigate({ + to: "/shop", + viewTransition: { + types: ["fade"], + }, + }); + cart.setOpen(false); + }} + > + Comprar + +
+
+
+ + + ); +} diff --git a/apps/web/src/components/Cart/Open/index.tsx b/apps/web/src/components/Cart/Open/index.tsx new file mode 100644 index 0000000..b3609b9 --- /dev/null +++ b/apps/web/src/components/Cart/Open/index.tsx @@ -0,0 +1,40 @@ +import { useEffect, useState } from "react"; +import { createPortal } from "react-dom"; +import { useOrderForm } from "@/sdk/contexts/orderform"; +import { cn } from "@/sdk/utils/cn"; +import { Tooltip } from "@/ui/Tooltip"; + +export function CartOpen() { + const { cart } = useOrderForm(); + const [mounted, setMounted] = useState(false); + + useEffect(() => setMounted(true), []); + + if (!mounted) return null; + + return createPortal( +
+ + + +
, + document.body, + ); +} diff --git a/apps/web/src/components/Menu/Item/index.tsx b/apps/web/src/components/Menu/Item/index.tsx index 3ee4908..55d0d37 100644 --- a/apps/web/src/components/Menu/Item/index.tsx +++ b/apps/web/src/components/Menu/Item/index.tsx @@ -6,7 +6,8 @@ const Icons = { news: "/assets/icons/32/news-menu.gif", sphere: "/assets/icons/32/armillary_sphere.gif", munster: "/assets/icons/32/baby_munster.gif", -}; + tibiora_box: "/assets/icons/32/tibiora_box.gif", +} as const; type Props = { label: string; diff --git a/apps/web/src/components/Menu/index.tsx b/apps/web/src/components/Menu/index.tsx index ea93c61..626333a 100644 --- a/apps/web/src/components/Menu/index.tsx +++ b/apps/web/src/components/Menu/index.tsx @@ -15,6 +15,11 @@ export const Menu = () => { icon="sphere" menus={[{ label: "Updates", to: "/", hot: true }]} /> +
); diff --git a/apps/web/src/components/OutfitAnimation/index.tsx b/apps/web/src/components/OutfitAnimation/index.tsx index f2fdb43..fa8979d 100644 --- a/apps/web/src/components/OutfitAnimation/index.tsx +++ b/apps/web/src/components/OutfitAnimation/index.tsx @@ -2,10 +2,7 @@ import { useEffect, useRef } from "react"; import { useOutfitAnimation } from "@/sdk/hooks/useOutfitAnimation"; import { cn } from "@/sdk/utils/cn"; -type Frame = { - image: string; - duration: number; -}; +type Frame = { image: string; duration: number }; type Props = { frames: Frame[]; @@ -16,6 +13,41 @@ type Props = { loading?: boolean; }; +const bitmapCache = new Map>(); + +function dataUrlToBlob(dataUrl: string): Blob { + const [meta, b64] = dataUrl.split(",", 2); + const mimeMatch = meta.match(/data:(.*?);base64/); + const mime = mimeMatch?.[1] ?? "application/octet-stream"; + + const binary = atob(b64); + const len = binary.length; + const bytes = new Uint8Array(len); + for (let i = 0; i < len; i++) bytes[i] = binary.charCodeAt(i); + + return new Blob([bytes], { type: mime }); +} + +function getBitmap(src: string) { + const cached = bitmapCache.get(src); + if (cached) return cached; + + const p = (async () => { + const blob = dataUrlToBlob(src); + return await createImageBitmap(blob); + })(); + + bitmapCache.set(src, p); + + // opcional: limite pra não crescer infinito + if (bitmapCache.size > 300) { + const firstKey = bitmapCache.keys().next().value as string | undefined; + if (firstKey) bitmapCache.delete(firstKey); + } + + return p; +} + export const OutfitAnimation = ({ frames, width = 64, @@ -28,30 +60,31 @@ export const OutfitAnimation = ({ const canvasRef = useRef(null); useEffect(() => { - if (!frame) return; - if (!canvasRef.current) return; + const src = frame?.image; + const canvas = canvasRef.current; + if (!src || !canvas) return; - const ctx = canvasRef.current.getContext("2d"); + const ctx = canvas.getContext("2d"); if (!ctx) return; - const img = new Image(); - img.src = frame.image; // dataURL vindo do backend + let cancelled = false; + + (async () => { + const bmp = await getBitmap(src); + if (cancelled) return; - img.onload = () => { - // limpa o canvas ctx.clearRect(0, 0, width, height); - // desenha a imagem ajustada ao tamanho - ctx.drawImage(img, 0, 0, width, height); - }; - }, [frame, width, height]); + ctx.imageSmoothingEnabled = false; // pixel art + ctx.drawImage(bmp, 0, 0, width, height); + })(); - if (loading) { - return null; - } + return () => { + cancelled = true; + }; + }, [frame?.image, width, height]); - if (showNotFoundImage === false && !frames.length) { - return null; - } + if (loading) return null; + if (showNotFoundImage === false && !frames.length) return null; if (!frames.length || !frame) { return ( diff --git a/apps/web/src/components/Products/BaseService/index.tsx b/apps/web/src/components/Products/BaseService/index.tsx new file mode 100644 index 0000000..e9d6857 --- /dev/null +++ b/apps/web/src/components/Products/BaseService/index.tsx @@ -0,0 +1,80 @@ +import { cn } from "@/sdk/utils/cn"; + +export type BaseProductServiceCardProps = { + disabled?: boolean; + selected?: boolean; + icon: string | React.ReactNode; + title: string; + description: string; +}; + +export function BaseProductServiceCard(props: BaseProductServiceCardProps) { + const selected = props.selected ?? false; + const disabled = props.disabled ?? false; + const icon = props.icon; + const title = props.title; + const description = props.description; + + return ( +
+ Selected Payment Method + Selected Payment Method + Payment Method Hover +
+ {typeof icon === "string" && ( +
+ )} + {typeof icon !== "string" && icon} +
+

+ {title} +

+

+ {description} +

+
+ ); +} diff --git a/apps/web/src/components/Products/Coins/index.tsx b/apps/web/src/components/Products/Coins/index.tsx new file mode 100644 index 0000000..0691ab6 --- /dev/null +++ b/apps/web/src/components/Products/Coins/index.tsx @@ -0,0 +1,183 @@ +import { useEffect, useMemo, useState } from "react"; +import { formatter } from "@/sdk/hooks/useMoney"; +import { cn } from "@/sdk/utils/cn"; +import { ButtonImage } from "@/ui/Buttons/ButtonImage"; +import { Slider } from "@/ui/Slider"; +import { + BaseProductServiceCard, + type BaseProductServiceCardProps, +} from "../BaseService"; + +const COINS_IMAGE = { + 1: "/assets/coins/coins_1.png", + 2: "/assets/coins/coins_2.png", + 3: "/assets/coins/coins_3.png", + 4: "/assets/coins/coins_4.png", + 5: "/assets/coins/coins_5.png", + 6: "/assets/coins/coins_6.png", +}; + +const COINS_NON_TRANSFERABLE_IMAGE = { + 1: "/assets/coins/non_transferable_coins_1.png", + 2: "/assets/coins/non_transferable_coins_2.png", + 3: "/assets/coins/non_transferable_coins_3.png", + 4: "/assets/coins/non_transferable_coins_4.png", + 5: "/assets/coins/non_transferable_coins_5.png", + 6: "/assets/coins/non_transferable_coins_6.png", +}; + +type Props = Pick & { + baseUnitQuantity: number; + minUnit: number; + maxUnit: number; + unitPriceCents: number; + coinType?: "transferable" | "non-transferable"; + loading?: boolean; + onSelect: (quantity: number, effectiveQuantity: number) => void; + initialUnit?: number; +}; + +export function ProductCoinsCard(props: Props) { + const type = props.coinType || "transferable"; + const [value, setValue] = useState(() => { + const base = props.baseUnitQuantity; + const min = base * props.minUnit; + const max = base * props.maxUnit; + const initial = props.initialUnit != null ? props.initialUnit * base : base; + return [Math.min(max, Math.max(min, initial))]; + }); + + const images = + type === "transferable" ? COINS_IMAGE : COINS_NON_TRANSFERABLE_IMAGE; + + const { maxValue, minValue } = useMemo(() => { + return { + maxValue: props.baseUnitQuantity * props.maxUnit, + minValue: props.baseUnitQuantity * props.minUnit, + }; + }, [props.baseUnitQuantity, props.maxUnit, props.minUnit]); + + useEffect(() => { + if (props.initialUnit == null) return; + const base = props.baseUnitQuantity; + const min = base * props.minUnit; + const max = base * props.maxUnit; + const next = props.initialUnit * base; + setValue([Math.min(max, Math.max(min, next))]); + }, [props.initialUnit, props.baseUnitQuantity, props.minUnit, props.maxUnit]); + + const percentage = useMemo(() => { + const val = ((value[0] - minValue) / (maxValue - minValue)) * 100; + return Number(val.toFixed(0)); + }, [value, minValue, maxValue]); + + const priceInCents = useMemo(() => { + const units = value[0] / props.baseUnitQuantity; + const totalCents = units * props.unitPriceCents; + return totalCents; + }, [props.baseUnitQuantity, props.unitPriceCents, value]); + + const quantity = useMemo(() => { + return value[0] / props.baseUnitQuantity; + }, [props.baseUnitQuantity, value]); + + return ( +
+
+ +
+ Coins= 80, + }, + )} + /> + Coins= 75 && percentage < 80, + }, + )} + /> + Coins= 50 && percentage < 75, + }, + )} + /> + Coins= 30 && percentage < 50, + }, + )} + /> + Coins= 10 && percentage < 30, + }, + )} + /> + Coins= 0 && percentage < 10, + }, + )} + /> +
+
+ } + title={`${value} Coins`} + description={`${formatter(priceInCents, { + cents: true, + })} *`} + /> + +
+ props.onSelect(quantity, value[0])} + > + {props.selected ? "Atualizar" : "Adicionar"} + +
+ ); +} diff --git a/apps/web/src/routeTree.gen.ts b/apps/web/src/routeTree.gen.ts index 492f0cf..f1699aa 100644 --- a/apps/web/src/routeTree.gen.ts +++ b/apps/web/src/routeTree.gen.ts @@ -30,6 +30,7 @@ import { Route as AuthAccountPlayerNameDeleteIndexRouteImport } from './routes/_ const PublicTermsIndexLazyRouteImport = createFileRoute('/_public/terms/')() const Not_authLoginIndexLazyRouteImport = createFileRoute('/_not_auth/login/')() +const AuthShopIndexLazyRouteImport = createFileRoute('/_auth/shop/')() const Not_authAccountLostIndexLazyRouteImport = createFileRoute( '/_not_auth/account/lost/', )() @@ -103,6 +104,13 @@ const Not_authLoginIndexLazyRoute = Not_authLoginIndexLazyRouteImport.update({ } as any).lazy(() => import('./routes/_not_auth/login/index.lazy').then((d) => d.Route), ) +const AuthShopIndexLazyRoute = AuthShopIndexLazyRouteImport.update({ + id: '/shop/', + path: '/shop/', + getParentRoute: () => AuthRouteRoute, +} as any).lazy(() => + import('./routes/_auth/shop/index.lazy').then((d) => d.Route), +) const PublicWorldsIndexRoute = PublicWorldsIndexRouteImport.update({ id: '/worlds/', path: '/worlds/', @@ -345,6 +353,7 @@ export interface FileRoutesByFullPath { '/account/email': typeof AuthAccountEmailRouteRouteWithChildren '/account': typeof AuthAccountIndexRoute '/worlds': typeof PublicWorldsIndexRoute + '/shop': typeof AuthShopIndexLazyRoute '/login': typeof Not_authLoginIndexLazyRoute '/terms': typeof PublicTermsIndexLazyRoute '/account/lost/$email': typeof Not_authAccountLostEmailRouteRouteWithChildren @@ -377,6 +386,7 @@ export interface FileRoutesByTo { '/account/email': typeof AuthAccountEmailRouteRouteWithChildren '/account': typeof AuthAccountIndexRoute '/worlds': typeof PublicWorldsIndexRoute + '/shop': typeof AuthShopIndexLazyRoute '/login': typeof Not_authLoginIndexLazyRoute '/terms': typeof PublicTermsIndexLazyRoute '/account/lost/$token': typeof Not_authAccountLostTokenRouteRouteWithChildren @@ -412,6 +422,7 @@ export interface FileRoutesById { '/_auth/account/email': typeof AuthAccountEmailRouteRouteWithChildren '/_auth/account/': typeof AuthAccountIndexRoute '/_public/worlds/': typeof PublicWorldsIndexRoute + '/_auth/shop/': typeof AuthShopIndexLazyRoute '/_not_auth/login/': typeof Not_authLoginIndexLazyRoute '/_public/terms/': typeof PublicTermsIndexLazyRoute '/_not_auth/account/lost/$email': typeof Not_authAccountLostEmailRouteRouteWithChildren @@ -446,6 +457,7 @@ export interface FileRouteTypes { | '/account/email' | '/account' | '/worlds' + | '/shop' | '/login' | '/terms' | '/account/lost/$email' @@ -478,6 +490,7 @@ export interface FileRouteTypes { | '/account/email' | '/account' | '/worlds' + | '/shop' | '/login' | '/terms' | '/account/lost/$token' @@ -512,6 +525,7 @@ export interface FileRouteTypes { | '/_auth/account/email' | '/_auth/account/' | '/_public/worlds/' + | '/_auth/shop/' | '/_not_auth/login/' | '/_public/terms/' | '/_not_auth/account/lost/$email' @@ -590,6 +604,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof Not_authLoginIndexLazyRouteImport parentRoute: typeof Not_authRouteRoute } + '/_auth/shop/': { + id: '/_auth/shop/' + path: '/shop' + fullPath: '/shop' + preLoaderRoute: typeof AuthShopIndexLazyRouteImport + parentRoute: typeof AuthRouteRoute + } '/_public/worlds/': { id: '/_public/worlds/' path: '/worlds' @@ -816,6 +837,7 @@ const AuthAccountEmailRouteRouteWithChildren = interface AuthRouteRouteChildren { AuthAccountEmailRouteRoute: typeof AuthAccountEmailRouteRouteWithChildren AuthAccountIndexRoute: typeof AuthAccountIndexRoute + AuthShopIndexLazyRoute: typeof AuthShopIndexLazyRoute AuthAccountAudit_historyIndexLazyRoute: typeof AuthAccountAudit_historyIndexLazyRoute AuthAccountCoins_historyIndexLazyRoute: typeof AuthAccountCoins_historyIndexLazyRoute AuthAccountDetailsIndexLazyRoute: typeof AuthAccountDetailsIndexLazyRoute @@ -832,6 +854,7 @@ interface AuthRouteRouteChildren { const AuthRouteRouteChildren: AuthRouteRouteChildren = { AuthAccountEmailRouteRoute: AuthAccountEmailRouteRouteWithChildren, AuthAccountIndexRoute: AuthAccountIndexRoute, + AuthShopIndexLazyRoute: AuthShopIndexLazyRoute, AuthAccountAudit_historyIndexLazyRoute: AuthAccountAudit_historyIndexLazyRoute, AuthAccountCoins_historyIndexLazyRoute: diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index 492ff2d..1648189 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -4,6 +4,8 @@ import { Outlet, } from "@tanstack/react-router"; import { lazy, Suspense } from "react"; +import { CartDrawer } from "@/components/Cart/Drawer"; +import { CartOpen } from "@/components/Cart/Open"; import { Layout } from "@/layout"; import type { RouterContext } from "@/router"; import { ConfigProvider } from "@/sdk/contexts/config"; @@ -58,13 +60,19 @@ export const Route = createRootRouteWithContext()({ + + {env.VITE_SHOW_DEVTOOLS && ( - + )} diff --git a/apps/web/src/routes/_auth/shop/index.lazy.tsx b/apps/web/src/routes/_auth/shop/index.lazy.tsx new file mode 100644 index 0000000..049c8f3 --- /dev/null +++ b/apps/web/src/routes/_auth/shop/index.lazy.tsx @@ -0,0 +1,10 @@ +import { createLazyFileRoute } from "@tanstack/react-router"; +import { ShopSection } from "@/sections/shop"; + +export const Route = createLazyFileRoute("/_auth/shop/")({ + component: RouteComponent, +}); + +function RouteComponent() { + return ; +} diff --git a/apps/web/src/routes/_public/index.tsx b/apps/web/src/routes/_public/index.tsx index f34334b..ab22e70 100644 --- a/apps/web/src/routes/_public/index.tsx +++ b/apps/web/src/routes/_public/index.tsx @@ -1,5 +1,4 @@ import { createFileRoute } from "@tanstack/react-router"; - import { FeaturedSection } from "@/sections/featured"; import { NewsSection } from "@/sections/news"; import { NewstickerSection } from "@/sections/newsticker"; diff --git a/apps/web/src/sdk/contexts/orderform.tsx b/apps/web/src/sdk/contexts/orderform.tsx index 9417898..920929e 100644 --- a/apps/web/src/sdk/contexts/orderform.tsx +++ b/apps/web/src/sdk/contexts/orderform.tsx @@ -1,6 +1,6 @@ import type { ShopOrderForm } from "@miforge/api/shared/schemas/ShopOrderForm"; import { useQuery, useQueryClient } from "@tanstack/react-query"; -import { createContext, use } from "react"; +import { createContext, use, useState } from "react"; import { api } from "../lib/api/factory"; import { useSession } from "./session"; @@ -8,11 +8,16 @@ type Context = { orderForm: ShopOrderForm | null; invalidate: () => Promise; loading: boolean; + cart: { + open: boolean; + setOpen: (open: boolean) => void; + }; }; const OrderFormContext = createContext(null); export function OrderFormProvider({ children }: { children: React.ReactNode }) { + const [cartOpen, setCartOpen] = useState(false); const queryClient = useQueryClient(); const { session } = useSession(); const { data: orderForm, isPending: orderFormLoading } = useQuery( @@ -25,6 +30,10 @@ export function OrderFormProvider({ children }: { children: React.ReactNode }) { { await queryClient.invalidateQueries({ diff --git a/apps/web/src/sdk/hooks/useMobile.ts b/apps/web/src/sdk/hooks/useMobile.ts new file mode 100644 index 0000000..0a89231 --- /dev/null +++ b/apps/web/src/sdk/hooks/useMobile.ts @@ -0,0 +1,21 @@ +import * as React from "react"; + +const MOBILE_BREAKPOINT = 768; + +export function useIsMobile() { + const [isMobile, setIsMobile] = React.useState( + undefined, + ); + + React.useEffect(() => { + const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`); + const onChange = () => { + setIsMobile(window.innerWidth < MOBILE_BREAKPOINT); + }; + mql.addEventListener("change", onChange); + setIsMobile(window.innerWidth < MOBILE_BREAKPOINT); + return () => mql.removeEventListener("change", onChange); + }, []); + + return !!isMobile; +} diff --git a/apps/web/src/sdk/hooks/useMoney.ts b/apps/web/src/sdk/hooks/useMoney.ts new file mode 100644 index 0000000..6763d86 --- /dev/null +++ b/apps/web/src/sdk/hooks/useMoney.ts @@ -0,0 +1,44 @@ +import { useCallback, useMemo, useState } from "react"; + +export type FormatterOptions = { + locale?: string; + currency?: string; + cents?: boolean; +}; + +export const formatter = ( + value: number, + options: FormatterOptions = {}, +): string => { + const { locale = "pt-BR", currency = "BRL" } = options; + + const finalValue = options.cents ? value / 100 : value; + + return new Intl.NumberFormat(locale, { + style: "currency", + currency, + minimumFractionDigits: 2, + }).format(finalValue); +}; + +export const useFormatter = ( + initialValue = 0, + options: FormatterOptions = {}, +) => { + const [value, setValue] = useState(initialValue); + + const formatted = useMemo(() => formatter(value, options), [value, options]); + + const onChange = useCallback((raw: string) => { + const onlyNumbers = raw.replace(/[^\d]/g, ""); + const num = Number(onlyNumbers) / 100; + setValue(num); + }, []); + + return { + value, + formatted, + onChange, + setValue, + }; +}; diff --git a/apps/web/src/sdk/hooks/useOrderFormItem.ts b/apps/web/src/sdk/hooks/useOrderFormItem.ts index b80d21c..1000c93 100644 --- a/apps/web/src/sdk/hooks/useOrderFormItem.ts +++ b/apps/web/src/sdk/hooks/useOrderFormItem.ts @@ -1,5 +1,6 @@ import { useMutation } from "@tanstack/react-query"; import { useCallback } from "react"; +import { toast } from "sonner"; import { useOrderForm } from "../contexts/orderform"; import { api } from "../lib/api/factory"; import { withORPCErrorHandling } from "../utils/orpc"; @@ -12,10 +13,22 @@ export function useOrderFormItem() { api.query.miforge.shop.orderForm.addOrUpdateItem.mutationOptions(), ); + const { mutateAsync: removeItemMutation, isPending: removeItemLoading } = + useMutation(api.query.miforge.shop.orderForm.removeItem.mutationOptions()); + const { invalidate } = useOrderForm(); const addOrUpdateItem = useCallback( - (data: { productId: string; quantity: number; mode?: "ADD" | "SET" }) => { + async (data: { + productId: string; + quantity: number; + mode?: "ADD" | "SET"; + options?: { + toast?: { + successMessage?: string; + }; + }; + }) => { withORPCErrorHandling( async () => { const mode = data.mode ?? "ADD"; @@ -25,6 +38,19 @@ export function useOrderFormItem() { quantity: data.quantity, mode: mode, }); + + let successMessage = "Item adicionado ao carrinho."; + if (mode === "SET") { + successMessage = "Item atualizado no carrinho."; + } + + if (data.options?.toast?.successMessage) { + successMessage = data.options.toast.successMessage; + } + + toast.success(successMessage, { + position: "bottom-left", + }); }, { onSuccess: () => { @@ -36,8 +62,45 @@ export function useOrderFormItem() { [addOrUpdateItemMutation, invalidate], ); + const removeItem = useCallback( + async (data: { + productId: string; + options?: { + toast?: { + successMessage?: string; + }; + }; + }) => { + withORPCErrorHandling( + async () => { + await removeItemMutation({ + itemId: data.productId, + }); + + let successMessage = "Item removido do carrinho."; + + if (data.options?.toast?.successMessage) { + successMessage = data.options.toast.successMessage; + } + + toast.success(successMessage, { + position: "bottom-left", + }); + }, + { + onSuccess: () => { + invalidate(); + }, + }, + ); + }, + [removeItemMutation, invalidate], + ); + return { addOrUpdateItem, - loading: addOrUpdateItemLoading, + removeItem, + addOrUpdateLoading: addOrUpdateItemLoading, + removeLoading: removeItemLoading, }; } diff --git a/apps/web/src/sdk/hooks/useOutfitAnimation.ts b/apps/web/src/sdk/hooks/useOutfitAnimation.ts index ed7fb81..0fbf845 100644 --- a/apps/web/src/sdk/hooks/useOutfitAnimation.ts +++ b/apps/web/src/sdk/hooks/useOutfitAnimation.ts @@ -1,4 +1,4 @@ -import { useEffect, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; export function useOutfitAnimation( frames: Array<{ image: string; duration: number }>, @@ -6,26 +6,37 @@ export function useOutfitAnimation( ) { const [currentFrame, setCurrentFrame] = useState(0); - // biome-ignore lint/correctness/useExhaustiveDependencies: + const framesKey = useMemo(() => { + if (frames.length === 0) return "empty"; + const first = frames[0]?.image ?? ""; + const last = frames[frames.length - 1]?.image ?? ""; + let total = 0; + for (const f of frames) total += f.duration || 0; + return `${frames.length}|${first}|${last}|${total}`; + }, [frames]); + + // biome-ignore lint/correctness/useExhaustiveDependencies: useEffect(() => { setCurrentFrame(0); - }, [frames]); + }, [framesKey]); + // biome-ignore lint/correctness/useExhaustiveDependencies: useEffect(() => { if (!autoPlay || frames.length === 0) return; const current = frames[currentFrame]; + if (!current) return; const timeout = setTimeout(() => { setCurrentFrame((prev) => { const next = prev + 1; if (next < frames.length) return next; - return loop ? 0 : prev; // se não for loop, fica no último + return loop ? 0 : prev; }); }, current.duration); return () => clearTimeout(timeout); - }, [autoPlay, loop, frames, currentFrame]); + }, [autoPlay, loop, framesKey, frames, currentFrame]); return { currentFrame, diff --git a/apps/web/src/sections/shop/index.tsx b/apps/web/src/sections/shop/index.tsx new file mode 100644 index 0000000..7a8be9f --- /dev/null +++ b/apps/web/src/sections/shop/index.tsx @@ -0,0 +1,78 @@ +import { useQuery } from "@tanstack/react-query"; +import { ProductCoinsCard } from "@/components/Products/Coins"; +import { useOrderForm } from "@/sdk/contexts/orderform"; +import { useOrderFormItem } from "@/sdk/hooks/useOrderFormItem"; +import { api } from "@/sdk/lib/api/factory"; +import { cn } from "@/sdk/utils/cn"; +import { Container } from "@/ui/Container"; +import { Section } from "@/ui/Section"; +import { SectionHeader } from "@/ui/Section/Header"; +import { InnerSection } from "@/ui/Section/Inner"; + +export const ShopSection = () => { + const { orderForm } = useOrderForm(); + const { addOrUpdateItem, addOrUpdateLoading } = useOrderFormItem(); + + const { data, isPending: productsPending } = useQuery( + api.query.miforge.shop.products.search.queryOptions({ + input: { + page: 1, + size: 100, + }, + }), + ); + + const products = data?.results ?? []; + + return ( +
+ +

Shop

+
+ + +
+ {products.map((product) => { + const orderFormItem = orderForm?.items.find( + (item) => item.productId === product.id, + ); + + if (product.category === "COINS") { + return ( + { + await addOrUpdateItem({ + productId: product.id, + quantity: quantity, + mode: "SET", + options: { + toast: { + successMessage: `${effectiveQuantity} ${product.title} foram adicionados ao carrinho!`, + }, + }, + }); + }} + /> + ); + } + + return null; + })} +
+
+
+
+ ); +}; diff --git a/apps/web/src/ui/Badge/index.tsx b/apps/web/src/ui/Badge/index.tsx new file mode 100644 index 0000000..a7f68ba --- /dev/null +++ b/apps/web/src/ui/Badge/index.tsx @@ -0,0 +1,45 @@ +import { Slot } from "@radix-ui/react-slot"; +import { cva, type VariantProps } from "class-variance-authority"; +import type * as React from "react"; +import { cn } from "@/sdk/utils/cn"; + +const badgeVariants = cva( + "inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden whitespace-nowrap rounded-md border px-2 py-0.5 font-medium text-xs transition-[color,box-shadow] focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3", + { + variants: { + variant: { + default: + "border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90", + secondary: + "border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90", + destructive: + "border-transparent bg-destructive text-white focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40 [a&]:hover:bg-destructive/90", + outline: + "text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground", + }, + }, + defaultVariants: { + variant: "default", + }, + }, +); + +function Badge({ + className, + variant, + asChild = false, + ...props +}: React.ComponentProps<"span"> & + VariantProps & { asChild?: boolean }) { + const Comp = asChild ? Slot : "span"; + + return ( + + ); +} + +export { Badge, badgeVariants }; diff --git a/apps/web/src/ui/Buttons/ButtonImage/index.tsx b/apps/web/src/ui/Buttons/ButtonImage/index.tsx index b411b9c..48b8ebc 100644 --- a/apps/web/src/ui/Buttons/ButtonImage/index.tsx +++ b/apps/web/src/ui/Buttons/ButtonImage/index.tsx @@ -4,7 +4,7 @@ import { cn } from "@/sdk/utils/cn"; type Props = React.ButtonHTMLAttributes & { loading?: boolean; - variant?: "regular" | "large" | "info" | "green" | "red"; + variant?: "regular" | "large" | "info" | "green" | "red" | "greenExtended"; }; export const ButtonImage = forwardRef( @@ -24,6 +24,8 @@ export const ButtonImage = forwardRef( return assets.buttons.buttonRed; case "green": return assets.buttons.buttonGreen; + case "greenExtended": + return assets.buttons.buttonGreenExtended; case "info": return assets.buttons.buttonBlue; case "large": @@ -46,6 +48,7 @@ export const ButtonImage = forwardRef( { "h-[25px] w-[135px] text-sm": variant === "red" || variant === "info" || variant === "green", + "h-[25px] w-[150px] text-sm": variant === "greenExtended", "h-[34px] w-[142px] px-2 text-base": variant === "regular", "h-[34px] w-[150px] px-2 text-base": variant === "large", }, diff --git a/apps/web/src/ui/Container/index.tsx b/apps/web/src/ui/Container/index.tsx index 6e117fb..4b868a0 100644 --- a/apps/web/src/ui/Container/index.tsx +++ b/apps/web/src/ui/Container/index.tsx @@ -6,10 +6,22 @@ type Props = React.HTMLAttributes & { title: string; innerContainer?: boolean; actions?: React.ReactNode; + loading?: boolean; }; export const Container = forwardRef( - ({ children, className, innerContainer, title, actions, ...props }, ref) => { + ( + { + children, + className, + innerContainer, + title, + actions, + loading = false, + ...props + }, + ref, + ) => { const anchorId = title.toLowerCase().replace(/\s+/g, "-"); const corner = cn( @@ -42,14 +54,34 @@ export const Container = forwardRef( - {innerContainer ? ( + {loading ? (
- {children} + +
+ Loading +

+ Carregando +

+
+
) : ( -
- {children} -
+ <> + {innerContainer ? ( +
+ {children} +
+ ) : ( +
+ {children} +
+ )} + )} ); diff --git a/apps/web/src/ui/Drawer/index.tsx b/apps/web/src/ui/Drawer/index.tsx new file mode 100644 index 0000000..3332841 --- /dev/null +++ b/apps/web/src/ui/Drawer/index.tsx @@ -0,0 +1,132 @@ +import type * as React from "react"; +import { Drawer as DrawerPrimitive } from "vaul"; +import { cn } from "@/sdk/utils/cn"; + +function Drawer({ + ...props +}: React.ComponentProps) { + return ; +} + +function DrawerTrigger({ + ...props +}: React.ComponentProps) { + return ; +} + +function DrawerPortal({ + ...props +}: React.ComponentProps) { + return ; +} + +function DrawerClose({ + ...props +}: React.ComponentProps) { + return ; +} + +function DrawerOverlay({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function DrawerContent({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + + + +
+ {children} + + + ); +} + +function DrawerHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function DrawerFooter({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function DrawerTitle({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function DrawerDescription({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +export { + Drawer, + DrawerPortal, + DrawerOverlay, + DrawerTrigger, + DrawerClose, + DrawerContent, + DrawerHeader, + DrawerFooter, + DrawerTitle, + DrawerDescription, +}; diff --git a/apps/web/src/ui/Sheet/index.tsx b/apps/web/src/ui/Sheet/index.tsx new file mode 100644 index 0000000..cc8412a --- /dev/null +++ b/apps/web/src/ui/Sheet/index.tsx @@ -0,0 +1,136 @@ +import * as SheetPrimitive from "@radix-ui/react-dialog"; +import { XIcon } from "lucide-react"; +import type * as React from "react"; +import { cn } from "@/sdk/utils/cn"; + +function Sheet({ ...props }: React.ComponentProps) { + return ; +} + +function SheetTrigger({ + ...props +}: React.ComponentProps) { + return ; +} + +function SheetClose({ + ...props +}: React.ComponentProps) { + return ; +} + +function SheetPortal({ + ...props +}: React.ComponentProps) { + return ; +} + +function SheetOverlay({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function SheetContent({ + className, + children, + side = "right", + ...props +}: React.ComponentProps & { + side?: "top" | "right" | "bottom" | "left"; +}) { + return ( + + + + {children} + + + Close + + + + ); +} + +function SheetHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function SheetFooter({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function SheetTitle({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function SheetDescription({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +export { + Sheet, + SheetTrigger, + SheetClose, + SheetContent, + SheetHeader, + SheetFooter, + SheetTitle, + SheetDescription, +}; diff --git a/apps/web/src/ui/Sidebar/index.tsx b/apps/web/src/ui/Sidebar/index.tsx new file mode 100644 index 0000000..311ea0b --- /dev/null +++ b/apps/web/src/ui/Sidebar/index.tsx @@ -0,0 +1,724 @@ +import { Slot } from "@radix-ui/react-slot"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@radix-ui/react-tooltip"; +import { cva, type VariantProps } from "class-variance-authority"; +import { PanelLeftIcon } from "lucide-react"; +import * as React from "react"; +import { useIsMobile } from "@/sdk/hooks/useMobile"; +import { cn } from "@/sdk/utils/cn"; +import { Button } from "@/ui/Buttons/Button"; +import { Input } from "@/ui/Input"; +import { Separator } from "@/ui/Separator"; +import { + Sheet, + SheetContent, + SheetDescription, + SheetHeader, + SheetTitle, +} from "../Sheet"; +import { Skeleton } from "../Skeleton"; + +const SIDEBAR_COOKIE_NAME = "sidebar_state"; +const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7; +const SIDEBAR_WIDTH = "16rem"; +const SIDEBAR_WIDTH_MOBILE = "18rem"; +const SIDEBAR_WIDTH_ICON = "3rem"; +const SIDEBAR_KEYBOARD_SHORTCUT = "b"; + +type SidebarContextProps = { + state: "expanded" | "collapsed"; + open: boolean; + setOpen: (open: boolean) => void; + openMobile: boolean; + setOpenMobile: (open: boolean) => void; + isMobile: boolean; + toggleSidebar: () => void; +}; + +const SidebarContext = React.createContext(null); + +function useSidebar() { + const context = React.useContext(SidebarContext); + if (!context) { + throw new Error("useSidebar must be used within a SidebarProvider."); + } + + return context; +} + +function SidebarProvider({ + defaultOpen = true, + open: openProp, + onOpenChange: setOpenProp, + className, + style, + children, + ...props +}: React.ComponentProps<"div"> & { + defaultOpen?: boolean; + open?: boolean; + onOpenChange?: (open: boolean) => void; +}) { + const isMobile = useIsMobile(); + const [openMobile, setOpenMobile] = React.useState(false); + + // This is the internal state of the sidebar. + // We use openProp and setOpenProp for control from outside the component. + const [_open, _setOpen] = React.useState(defaultOpen); + const open = openProp ?? _open; + const setOpen = React.useCallback( + (value: boolean | ((value: boolean) => boolean)) => { + const openState = typeof value === "function" ? value(open) : value; + if (setOpenProp) { + setOpenProp(openState); + } else { + _setOpen(openState); + } + + // This sets the cookie to keep the sidebar state. + // biome-ignore lint/suspicious/noDocumentCookie: + document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`; + }, + [setOpenProp, open], + ); + + // Helper to toggle the sidebar. + const toggleSidebar = React.useCallback(() => { + return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open); + }, [isMobile, setOpen]); + + // Adds a keyboard shortcut to toggle the sidebar. + React.useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + if ( + event.key === SIDEBAR_KEYBOARD_SHORTCUT && + (event.metaKey || event.ctrlKey) + ) { + event.preventDefault(); + toggleSidebar(); + } + }; + + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [toggleSidebar]); + + // We add a state so that we can do data-state="expanded" or "collapsed". + // This makes it easier to style the sidebar with Tailwind classes. + const state = open ? "expanded" : "collapsed"; + + const contextValue = React.useMemo( + () => ({ + state, + open, + setOpen, + isMobile, + openMobile, + setOpenMobile, + toggleSidebar, + }), + [state, open, setOpen, isMobile, openMobile, toggleSidebar], + ); + + return ( + + +
+ {children} +
+
+
+ ); +} + +function Sidebar({ + side = "left", + variant = "sidebar", + collapsible = "offcanvas", + className, + children, + ...props +}: React.ComponentProps<"div"> & { + side?: "left" | "right"; + variant?: "sidebar" | "floating" | "inset"; + collapsible?: "offcanvas" | "icon" | "none"; +}) { + const { isMobile, state, openMobile, setOpenMobile } = useSidebar(); + + if (collapsible === "none") { + return ( +
+ {children} +
+ ); + } + + if (isMobile) { + return ( + + + + Sidebar + Displays the mobile sidebar. + +
{children}
+
+
+ ); + } + + return ( +
+ {/* This is what handles the sidebar gap on desktop */} +
+ +
+ ); +} + +function SidebarTrigger({ + className, + onClick, + ...props +}: React.ComponentProps) { + const { toggleSidebar } = useSidebar(); + + return ( + + ); +} + +function SidebarRail({ className, ...props }: React.ComponentProps<"button">) { + const { toggleSidebar } = useSidebar(); + + return ( + - - -
- {items.map((item) => { - return ( -
- coins -
- - {item.effectiveQuantity} {item.title} - - - {formatter(item.totalPriceCents, { - cents: true, - })} - -
- -
- ); - })} -
-
- {orderForm?.totals.totalizers.map((total) => { - return ( -
- - {TOTALIZERS_LABEL[total.id]}:{" "} - - - {formatter(total.valueCents, { - cents: true, - })} - -
- ); - })} -
- -
- { - cart.setOpen(false); - }} - > - Fechar - - { - navigate({ - to: "/shop", - viewTransition: { - types: ["fade"], - }, - }); - cart.setOpen(false); - }} - > - Comprar - -
-
-
- - - ); -} diff --git a/apps/web/src/components/Cart/Open/index.tsx b/apps/web/src/components/Cart/Open/index.tsx deleted file mode 100644 index b3609b9..0000000 --- a/apps/web/src/components/Cart/Open/index.tsx +++ /dev/null @@ -1,40 +0,0 @@ -import { useEffect, useState } from "react"; -import { createPortal } from "react-dom"; -import { useOrderForm } from "@/sdk/contexts/orderform"; -import { cn } from "@/sdk/utils/cn"; -import { Tooltip } from "@/ui/Tooltip"; - -export function CartOpen() { - const { cart } = useOrderForm(); - const [mounted, setMounted] = useState(false); - - useEffect(() => setMounted(true), []); - - if (!mounted) return null; - - return createPortal( -
- - - -
, - document.body, - ); -} diff --git a/apps/web/src/components/Payments/Method/index.tsx b/apps/web/src/components/Payments/Method/index.tsx new file mode 100644 index 0000000..eaa6736 --- /dev/null +++ b/apps/web/src/components/Payments/Method/index.tsx @@ -0,0 +1,87 @@ +import { cn } from "@/sdk/utils/cn"; + +type Method = "PIX"; + +const METHOD_META: Record = { + PIX: { + icon: "/assets/payments/pix.webp", + className: "mx-auto rounded-md w-[80px]", + }, +}; + +type Props = { + selected: boolean; + onClick: () => void; + title: string; + speed?: "instant" | "medium" | "slow"; + disabled?: boolean; + method: Method; +}; + +export function PaymentMethod({ + selected, + onClick, + title, + speed, + disabled = false, + method, +}: Props) { + return ( + + ); +} diff --git a/apps/web/src/components/Products/Coins/index.tsx b/apps/web/src/components/Products/Coins/index.tsx index 0691ab6..ad52ab6 100644 --- a/apps/web/src/components/Products/Coins/index.tsx +++ b/apps/web/src/components/Products/Coins/index.tsx @@ -35,9 +35,16 @@ type Props = Pick & { loading?: boolean; onSelect: (quantity: number, effectiveQuantity: number) => void; initialUnit?: number; + options?: { + showSlider?: boolean; + showButton?: boolean; + }; }; export function ProductCoinsCard(props: Props) { + const showSlider = props.options?.showSlider ?? true; + const showButton = props.options?.showButton ?? true; + const type = props.coinType || "transferable"; const [value, setValue] = useState(() => { const base = props.baseUnitQuantity; @@ -158,26 +165,30 @@ export function ProductCoinsCard(props: Props) { cents: true, })} *`} /> - + {showSlider && ( + + )}
- props.onSelect(quantity, value[0])} - > - {props.selected ? "Atualizar" : "Adicionar"} - + {showButton && ( + props.onSelect(quantity, value[0])} + > + {props.selected ? "Atualizar" : "Adicionar"} + + )}
); } diff --git a/apps/web/src/routeTree.gen.ts b/apps/web/src/routeTree.gen.ts index f1699aa..da31101 100644 --- a/apps/web/src/routeTree.gen.ts +++ b/apps/web/src/routeTree.gen.ts @@ -37,6 +37,9 @@ const Not_authAccountLostIndexLazyRouteImport = createFileRoute( const Not_authAccountCreateIndexLazyRouteImport = createFileRoute( '/_not_auth/account/create/', )() +const AuthShopCheckoutIndexLazyRouteImport = createFileRoute( + '/_auth/shop/checkout/', +)() const AuthAccountReset_passwordIndexLazyRouteImport = createFileRoute( '/_auth/account/reset_password/', )() @@ -146,6 +149,14 @@ const Not_authAccountCreateIndexLazyRoute = } as any).lazy(() => import('./routes/_not_auth/account/create/index.lazy').then((d) => d.Route), ) +const AuthShopCheckoutIndexLazyRoute = + AuthShopCheckoutIndexLazyRouteImport.update({ + id: '/shop/checkout/', + path: '/shop/checkout/', + getParentRoute: () => AuthRouteRoute, + } as any).lazy(() => + import('./routes/_auth/shop/checkout/index.lazy').then((d) => d.Route), + ) const AuthAccountReset_passwordIndexLazyRoute = AuthAccountReset_passwordIndexLazyRouteImport.update({ id: '/account/reset_password/', @@ -363,6 +374,7 @@ export interface FileRoutesByFullPath { '/account/details': typeof AuthAccountDetailsIndexLazyRoute '/account/registration': typeof AuthAccountRegistrationIndexLazyRoute '/account/reset_password': typeof AuthAccountReset_passwordIndexLazyRoute + '/shop/checkout': typeof AuthShopCheckoutIndexLazyRoute '/account/create': typeof Not_authAccountCreateIndexLazyRoute '/account/lost': typeof Not_authAccountLostIndexLazyRoute '/account/email/change/$token': typeof AuthAccountEmailChangeTokenRouteRouteWithChildren @@ -395,6 +407,7 @@ export interface FileRoutesByTo { '/account/details': typeof AuthAccountDetailsIndexLazyRoute '/account/registration': typeof AuthAccountRegistrationIndexLazyRoute '/account/reset_password': typeof AuthAccountReset_passwordIndexLazyRoute + '/shop/checkout': typeof AuthShopCheckoutIndexLazyRoute '/account/create': typeof Not_authAccountCreateIndexLazyRoute '/account/lost': typeof Not_authAccountLostIndexLazyRoute '/account/email/change/$token': typeof AuthAccountEmailChangeTokenRouteRouteWithChildren @@ -432,6 +445,7 @@ export interface FileRoutesById { '/_auth/account/details/': typeof AuthAccountDetailsIndexLazyRoute '/_auth/account/registration/': typeof AuthAccountRegistrationIndexLazyRoute '/_auth/account/reset_password/': typeof AuthAccountReset_passwordIndexLazyRoute + '/_auth/shop/checkout/': typeof AuthShopCheckoutIndexLazyRoute '/_not_auth/account/create/': typeof Not_authAccountCreateIndexLazyRoute '/_not_auth/account/lost/': typeof Not_authAccountLostIndexLazyRoute '/_auth/account/email/change/$token': typeof AuthAccountEmailChangeTokenRouteRouteWithChildren @@ -467,6 +481,7 @@ export interface FileRouteTypes { | '/account/details' | '/account/registration' | '/account/reset_password' + | '/shop/checkout' | '/account/create' | '/account/lost' | '/account/email/change/$token' @@ -499,6 +514,7 @@ export interface FileRouteTypes { | '/account/details' | '/account/registration' | '/account/reset_password' + | '/shop/checkout' | '/account/create' | '/account/lost' | '/account/email/change/$token' @@ -535,6 +551,7 @@ export interface FileRouteTypes { | '/_auth/account/details/' | '/_auth/account/registration/' | '/_auth/account/reset_password/' + | '/_auth/shop/checkout/' | '/_not_auth/account/create/' | '/_not_auth/account/lost/' | '/_auth/account/email/change/$token' @@ -646,6 +663,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof Not_authAccountCreateIndexLazyRouteImport parentRoute: typeof Not_authRouteRoute } + '/_auth/shop/checkout/': { + id: '/_auth/shop/checkout/' + path: '/shop/checkout' + fullPath: '/shop/checkout' + preLoaderRoute: typeof AuthShopCheckoutIndexLazyRouteImport + parentRoute: typeof AuthRouteRoute + } '/_auth/account/reset_password/': { id: '/_auth/account/reset_password/' path: '/account/reset_password' @@ -843,6 +867,7 @@ interface AuthRouteRouteChildren { AuthAccountDetailsIndexLazyRoute: typeof AuthAccountDetailsIndexLazyRoute AuthAccountRegistrationIndexLazyRoute: typeof AuthAccountRegistrationIndexLazyRoute AuthAccountReset_passwordIndexLazyRoute: typeof AuthAccountReset_passwordIndexLazyRoute + AuthShopCheckoutIndexLazyRoute: typeof AuthShopCheckoutIndexLazyRoute AuthAccount2faLinkIndexRoute: typeof AuthAccount2faLinkIndexRoute AuthAccount2faUnlinkIndexRoute: typeof AuthAccount2faUnlinkIndexRoute AuthAccountPlayerCreateIndexRoute: typeof AuthAccountPlayerCreateIndexRoute @@ -863,6 +888,7 @@ const AuthRouteRouteChildren: AuthRouteRouteChildren = { AuthAccountRegistrationIndexLazyRoute: AuthAccountRegistrationIndexLazyRoute, AuthAccountReset_passwordIndexLazyRoute: AuthAccountReset_passwordIndexLazyRoute, + AuthShopCheckoutIndexLazyRoute: AuthShopCheckoutIndexLazyRoute, AuthAccount2faLinkIndexRoute: AuthAccount2faLinkIndexRoute, AuthAccount2faUnlinkIndexRoute: AuthAccount2faUnlinkIndexRoute, AuthAccountPlayerCreateIndexRoute: AuthAccountPlayerCreateIndexRoute, diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index 1648189..1c941b7 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -4,8 +4,6 @@ import { Outlet, } from "@tanstack/react-router"; import { lazy, Suspense } from "react"; -import { CartDrawer } from "@/components/Cart/Drawer"; -import { CartOpen } from "@/components/Cart/Open"; import { Layout } from "@/layout"; import type { RouterContext } from "@/router"; import { ConfigProvider } from "@/sdk/contexts/config"; @@ -60,8 +58,6 @@ export const Route = createRootRouteWithContext()({ - - {env.VITE_SHOW_DEVTOOLS && ( ; +} diff --git a/apps/web/src/sections/shop/index.tsx b/apps/web/src/sections/shop/index.tsx index 7a8be9f..71f948e 100644 --- a/apps/web/src/sections/shop/index.tsx +++ b/apps/web/src/sections/shop/index.tsx @@ -4,7 +4,9 @@ import { useOrderForm } from "@/sdk/contexts/orderform"; import { useOrderFormItem } from "@/sdk/hooks/useOrderFormItem"; import { api } from "@/sdk/lib/api/factory"; import { cn } from "@/sdk/utils/cn"; +import { ButtonImageLink } from "@/ui/Buttons/ButtonImageLink"; import { Container } from "@/ui/Container"; +import { InnerContainer } from "@/ui/Container/Inner"; import { Section } from "@/ui/Section"; import { SectionHeader } from "@/ui/Section/Header"; import { InnerSection } from "@/ui/Section/Inner"; @@ -31,46 +33,58 @@ export const ShopSection = () => { -
- {products.map((product) => { - const orderFormItem = orderForm?.items.find( - (item) => item.productId === product.id, - ); + +
+ {products.map((product) => { + const orderFormItem = orderForm?.items.find( + (item) => item.productId === product.id, + ); - if (product.category === "COINS") { - return ( - { - await addOrUpdateItem({ - productId: product.id, - quantity: quantity, - mode: "SET", - options: { - toast: { - successMessage: `${effectiveQuantity} ${product.title} foram adicionados ao carrinho!`, + if (product.category === "COINS") { + return ( + { + await addOrUpdateItem({ + productId: product.id, + quantity: quantity, + mode: "SET", + options: { + toast: { + successMessage: `${effectiveQuantity} ${product.title} foram adicionados ao carrinho!`, + }, }, - }, - }); - }} - /> - ); - } + }); + }} + /> + ); + } - return null; - })} -
+ /** + * TODO: Handle other item categories + */ + return null; + })} +
+ + +
+ + Pagamento + +
+
diff --git a/apps/web/src/sections/shop_checkout/index.tsx b/apps/web/src/sections/shop_checkout/index.tsx new file mode 100644 index 0000000..6dda8b4 --- /dev/null +++ b/apps/web/src/sections/shop_checkout/index.tsx @@ -0,0 +1,19 @@ +import { Section } from "@/ui/Section"; +import { SectionHeader } from "@/ui/Section/Header"; +import { InnerSection } from "@/ui/Section/Inner"; +import { ShopCheckoutItems } from "./items"; +import { ShopCheckoutPayments } from "./payments"; + +export const ShopCheckoutSection = () => { + return ( +
+ +

Checkout

+
+ + + + +
+ ); +}; diff --git a/apps/web/src/sections/shop_checkout/items/index.tsx b/apps/web/src/sections/shop_checkout/items/index.tsx new file mode 100644 index 0000000..a70db14 --- /dev/null +++ b/apps/web/src/sections/shop_checkout/items/index.tsx @@ -0,0 +1,39 @@ +import { ProductCoinsCard } from "@/components/Products/Coins"; +import { useOrderForm } from "@/sdk/contexts/orderform"; +import { Container } from "@/ui/Container"; +import { InnerContainer } from "@/ui/Container/Inner"; + +export function ShopCheckoutItems() { + const { orderForm } = useOrderForm(); + + return ( + + + {orderForm?.items.map((item) => { + if (item.category === "COINS") { + return ( + null} + options={{ + showSlider: false, + showButton: false, + }} + /> + ); + } + + /** + * TODO: Handle other item categories + */ + return null; + })} + + + ); +} diff --git a/apps/web/src/sections/shop_checkout/payments/index.tsx b/apps/web/src/sections/shop_checkout/payments/index.tsx new file mode 100644 index 0000000..3e3dd72 --- /dev/null +++ b/apps/web/src/sections/shop_checkout/payments/index.tsx @@ -0,0 +1,66 @@ +import { useMutation } from "@tanstack/react-query"; +import { useCallback } from "react"; +import { toast } from "sonner"; +import { PaymentMethod } from "@/components/Payments/Method"; +import { useOrderForm } from "@/sdk/contexts/orderform"; +import { api } from "@/sdk/lib/api/factory"; +import { withORPCErrorHandling } from "@/sdk/utils/orpc"; +import { Container } from "@/ui/Container"; +import { InnerContainer } from "@/ui/Container/Inner"; + +export function ShopCheckoutPayments() { + const { orderForm, invalidate: invalidateOrderForm } = useOrderForm(); + const { mutateAsync: setPaymentOption, isPending: settingPaymentOption } = + useMutation( + api.query.miforge.shop.orderForm.payments.setPayment.mutationOptions(), + ); + + const handleSelectPaymentOption = useCallback( + async (paymentId: string) => { + withORPCErrorHandling( + async () => { + await setPaymentOption({ + paymentOptionId: paymentId, + }); + }, + { + onSuccess: () => { + invalidateOrderForm(); + toast.success("Payment method selected successfully"); + }, + }, + ); + }, + [invalidateOrderForm, setPaymentOption], + ); + + return ( + + + {orderForm?.payment.providers.map((provider) => { + const selected = + orderForm.payment.selectedProvider?.id === provider.id; + + return ( + { + if (selected) { + toast.info("This payment method is already selected"); + return; + } + + handleSelectPaymentOption(provider.id); + }} + /> + ); + })} + + + ); +}