diff --git a/.vscode/settings.json b/.vscode/settings.json index 44891a0..83c40ed 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", @@ -162,6 +164,7 @@ "premdays", "premiumuntil", "previewstate", + "purchasecomplete", "pvptype", "qrcode", "quickloot", @@ -203,6 +206,8 @@ "usecase", "usecases", "usehooks", + "uuid", + "vaul", "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..526e638 100644 --- a/apps/api/prisma/seed/index.ts +++ b/apps/api/prisma/seed/index.ts @@ -61,6 +61,50 @@ async function main() { } }) + console.log("[seed] Seeding default payment options") + if (env.MERCADO_PAGO_ENABLED) { + await prisma.miforge_shop_payment_option.upsert({ + where: { + uniq_shop_payment_option_provider_method: { + method: "PIX", + provider: "MERCADO_PAGO" + } + }, + create: { + method: "PIX", + provider: "MERCADO_PAGO", + label: "Mercado Pago - PIX", + enabled: true, + description: "Pay using Mercado Pago's PIX integration.", + }, + update: {} + }) + } + + + 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..b9d8857 100644 --- a/apps/api/src/application/services/index.ts +++ b/apps/api/src/application/services/index.ts @@ -8,5 +8,7 @@ export * from "./lostAccount"; 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/shopOrder/index.ts b/apps/api/src/application/services/shopOrder/index.ts new file mode 100644 index 0000000..7d11264 --- /dev/null +++ b/apps/api/src/application/services/shopOrder/index.ts @@ -0,0 +1,293 @@ +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, + ShopOrderRepository, + ShopPaymentOptionRepository, + ShopProductRepository, +} 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, + @inject(TOKENS.ShopProductRepository) + private readonly shopProductRepository: ShopProductRepository, + @inject(TOKENS.ShopOrderItemRepository) + 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(); + + 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, + baseUnitQuantity: item.product.baseUnitQuantity, + maxUnits: item.product.maxUnits, + minUnits: item.product.minUnits, + productId: item.product.id, + productSlug: item.product.slug, + category: item.product.category, + description: item.product.description, + title: item.product.title, + }; + }), + payment: { + providers, + selectedProvider, + }, + totals: { + totalizers: totalizers, + }, + updatedAt: order.updatedAt, + createdAt: order.createdAt, + }; + } + + @Catch() + 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(); + } + + @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(); + } + + @Catch() + async removePaymentOption(): Promise { + const orderform = await this.orderForm(); + + if (orderform.status !== ShopOrderStatus.DRAFT) { + throw new ORPCError("BAD_REQUEST", { + message: "Cannot change payment option of a non-draft order.", + }); + } + + await this.shopOrderRepository.setPaymentOption(orderform.id, null); + + return this.orderForm(); + } + + @Catch() + async selectPaymentOption(paymentOptionId: string): Promise { + const orderform = await this.orderForm(); + + if (orderform.status !== ShopOrderStatus.DRAFT) { + throw new ORPCError("BAD_REQUEST", { + message: "Cannot change payment option of a non-draft order.", + }); + } + + const provider = + await this.shopPaymentOptionRepository.findById(paymentOptionId); + + if (!provider) { + throw new ORPCError("BAD_REQUEST", { + message: "Selected payment option is not available.", + }); + } + + if (!provider.enabled) { + throw new ORPCError("BAD_REQUEST", { + message: "Selected payment option is currently disabled.", + }); + } + + if (provider.id === orderform.payment.selectedProvider?.id) { + throw new ORPCError("BAD_REQUEST", { + message: "Selected payment option is already set.", + }); + } + + await this.shopOrderRepository.setPaymentOption( + orderform.id, + paymentOptionId, + ); + + return this.orderForm(); + } +} 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 342be4b..7838db2 100644 --- a/apps/api/src/application/usecases/factory.ts +++ b/apps/api/src/application/usecases/factory.ts @@ -219,4 +219,30 @@ export class UseCasesFactory { update, } as const; } + + get shop() { + const orderForm = this.di.resolve(TOKENS.ShopOrderFormUseCase); + const orderFormAddOrUpdateItem = this.di.resolve( + TOKENS.ShopOrderFormAddOrUpdateItemUseCase, + ); + const orderFormRemoveItem = this.di.resolve( + TOKENS.ShopOrderFormRemoveItemUseCase, + ); + const orderFormSetPaymentOption = this.di.resolve( + TOKENS.ShopOrderFormSetPaymentOptionUseCase, + ); + const orderFormRemovePaymentOption = this.di.resolve( + TOKENS.ShopOrderFormRemovePaymentOptionUseCase, + ); + const listProducts = this.di.resolve(TOKENS.ShopListProductsUseCase); + + return { + orderForm, + orderFormAddOrUpdateItem, + orderFormRemoveItem, + listProducts, + orderFormSetPaymentOption, + orderFormRemovePaymentOption, + } 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..8de7179 --- /dev/null +++ b/apps/api/src/application/usecases/shop/index.ts @@ -0,0 +1,6 @@ +export * from "./listProducts"; +export * from "./orderform"; +export * from "./orderformAddOrUpdateItem"; +export * from "./orderformRemoveItem"; +export * from "./orderformRemovePaymentOption"; +export * from "./orderformSetPaymentOption"; 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/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/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/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/application/usecases/shop/orderformRemovePaymentOption/contract.ts b/apps/api/src/application/usecases/shop/orderformRemovePaymentOption/contract.ts new file mode 100644 index 0000000..4cd1378 --- /dev/null +++ b/apps/api/src/application/usecases/shop/orderformRemovePaymentOption/contract.ts @@ -0,0 +1,14 @@ +import z from "zod"; +import { ShopOrderForm } from "@/shared/schemas/ShopOrderForm"; + +export const ShopOrderFormRemovePaymentOptionContractSchema = { + input: z.undefined(), + output: ShopOrderForm, +}; + +export type ShopOrderFormRemovePaymentOptionContractInput = z.infer< + typeof ShopOrderFormRemovePaymentOptionContractSchema.input +>; +export type ShopOrderFormRemovePaymentOptionContractOutput = z.infer< + typeof ShopOrderFormRemovePaymentOptionContractSchema.output +>; diff --git a/apps/api/src/application/usecases/shop/orderformRemovePaymentOption/index.ts b/apps/api/src/application/usecases/shop/orderformRemovePaymentOption/index.ts new file mode 100644 index 0000000..9b5d055 --- /dev/null +++ b/apps/api/src/application/usecases/shop/orderformRemovePaymentOption/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 { + ShopOrderFormRemovePaymentOptionContractInput, + ShopOrderFormRemovePaymentOptionContractOutput, +} from "./contract"; + +@injectable() +export class ShopOrderFormRemovePaymentOptionUseCase + implements + UseCase< + ShopOrderFormRemovePaymentOptionContractInput, + ShopOrderFormRemovePaymentOptionContractOutput + > +{ + constructor( + @inject(TOKENS.ShopOrderService) + private readonly shopOrderService: ShopOrderService, + ) {} + + execute( + _input: ShopOrderFormRemovePaymentOptionContractInput, + ): Promise { + return this.shopOrderService.removePaymentOption(); + } +} diff --git a/apps/api/src/application/usecases/shop/orderformSetPaymentOption/contract.ts b/apps/api/src/application/usecases/shop/orderformSetPaymentOption/contract.ts new file mode 100644 index 0000000..c362e5c --- /dev/null +++ b/apps/api/src/application/usecases/shop/orderformSetPaymentOption/contract.ts @@ -0,0 +1,16 @@ +import z from "zod"; +import { ShopOrderForm } from "@/shared/schemas/ShopOrderForm"; + +export const ShopOrderFormSetPaymentOptionContractSchema = { + input: z.object({ + paymentOptionId: z.uuid(), + }), + output: ShopOrderForm, +}; + +export type ShopOrderFormSetPaymentOptionContractInput = z.infer< + typeof ShopOrderFormSetPaymentOptionContractSchema.input +>; +export type ShopOrderFormSetPaymentOptionContractOutput = z.infer< + typeof ShopOrderFormSetPaymentOptionContractSchema.output +>; diff --git a/apps/api/src/application/usecases/shop/orderformSetPaymentOption/index.ts b/apps/api/src/application/usecases/shop/orderformSetPaymentOption/index.ts new file mode 100644 index 0000000..21126ab --- /dev/null +++ b/apps/api/src/application/usecases/shop/orderformSetPaymentOption/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 { + ShopOrderFormSetPaymentOptionContractInput, + ShopOrderFormSetPaymentOptionContractOutput, +} from "./contract"; + +@injectable() +export class ShopOrderFormSetPaymentOptionUseCase + implements + UseCase< + ShopOrderFormSetPaymentOptionContractInput, + ShopOrderFormSetPaymentOptionContractOutput + > +{ + constructor( + @inject(TOKENS.ShopOrderService) + private readonly shopOrderService: ShopOrderService, + ) {} + + execute( + input: ShopOrderFormSetPaymentOptionContractInput, + ): Promise { + return this.shopOrderService.selectPaymentOption(input.paymentOptionId); + } +} diff --git a/apps/api/src/domain/modules/cache/keys.ts b/apps/api/src/domain/modules/cache/keys.ts index 5c89ae8..fdd69e5 100644 --- a/apps/api/src/domain/modules/cache/keys.ts +++ b/apps/api/src/domain/modules/cache/keys.ts @@ -20,9 +20,21 @@ export class CacheKeys { }; readonly keys: Record< - "config" | "serverStatus" | "outfit", + "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), + 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..7d60f66 100644 --- a/apps/api/src/domain/repositories/index.ts +++ b/apps/api/src/domain/repositories/index.ts @@ -10,4 +10,8 @@ export * from "./live"; 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/shopOrder/index.ts b/apps/api/src/domain/repositories/shopOrder/index.ts new file mode 100644 index 0000000..fa80d76 --- /dev/null +++ b/apps/api/src/domain/repositories/shopOrder/index.ts @@ -0,0 +1,58 @@ +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, + }, + }); + } + + setPaymentOption(orderId: string, paymentOptionId: string | null) { + return this.database.miforge_shop_order.update({ + where: { + id: orderId, + }, + data: { + paymentOptionId, + }, + }); + } + + 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/shopOrderItem/index.ts b/apps/api/src/domain/repositories/shopOrderItem/index.ts new file mode 100644 index 0000000..f29361c --- /dev/null +++ b/apps/api/src/domain/repositories/shopOrderItem/index.ts @@ -0,0 +1,62 @@ +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, + }, + }); + } + + 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, + 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/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/domain/repositories/shopProduct/index.ts b/apps/api/src/domain/repositories/shopProduct/index.ts new file mode 100644 index 0000000..740bef1 --- /dev/null +++ b/apps/api/src/domain/repositories/shopProduct/index.ts @@ -0,0 +1,78 @@ +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"; +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<{}>; + +@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 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); + + 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/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/repositories.ts b/apps/api/src/infra/di/containers/repositories.ts index a5507b8..5b8a260 100644 --- a/apps/api/src/infra/di/containers/repositories.ts +++ b/apps/api/src/infra/di/containers/repositories.ts @@ -10,6 +10,10 @@ import { OtsServerRepository, PlayersRepository, SessionRepository, + ShopOrderItemRepository, + ShopOrderRepository, + ShopPaymentOptionRepository, + ShopProductRepository, } from "@/domain/repositories"; import { WorldsRepository } from "@/domain/repositories/worlds"; import { TOKENS } from "../tokens"; @@ -68,6 +72,27 @@ export function registerRepositories() { { lifecycle: Lifecycle.ResolutionScoped }, ); + container.register( + TOKENS.ShopOrderRepository, + { useClass: ShopOrderRepository }, + { lifecycle: Lifecycle.ResolutionScoped }, + ); + container.register( + TOKENS.ShopPaymentOptionRepository, + { 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( TOKENS.ConfigLiveRepository, diff --git a/apps/api/src/infra/di/containers/services.ts b/apps/api/src/infra/di/containers/services.ts index 5d79629..ae38d4b 100644 --- a/apps/api/src/infra/di/containers/services.ts +++ b/apps/api/src/infra/di/containers/services.ts @@ -10,6 +10,8 @@ import { PlayersService, RecoveryKeyService, SessionService, + ShopOrderService, + ShopProductsService, TibiaClientService, WorldsService, } from "@/application/services"; @@ -76,4 +78,14 @@ export function registerServices() { { useClass: AccountOauthService }, { lifecycle: Lifecycle.ResolutionScoped }, ); + container.register( + TOKENS.ShopOrderService, + { 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 1b96a30..e647486 100644 --- a/apps/api/src/infra/di/containers/usecases.ts +++ b/apps/api/src/infra/di/containers/usecases.ts @@ -43,6 +43,12 @@ import { SessionCanBeAuthenticatedUseCase, SessionInfoUseCase, SessionNotAuthenticatedUseCase, + ShopListProductsUseCase, + ShopOrderFormAddOrUpdateItemUseCase, + ShopOrderFormRemoveItemUseCase, + ShopOrderFormRemovePaymentOptionUseCase, + ShopOrderFormSetPaymentOptionUseCase, + ShopOrderFormUseCase, TibiaLoginUseCase, WorldsListUseCase, } from "@/application/usecases"; @@ -287,4 +293,35 @@ export function registerUseCases() { { useClass: AccountDiscordOauthUnlinkUseCase }, { lifecycle: Lifecycle.ResolutionScoped }, ); + + container.register( + TOKENS.ShopOrderFormUseCase, + { useClass: ShopOrderFormUseCase }, + { lifecycle: Lifecycle.ResolutionScoped }, + ); + container.register( + TOKENS.ShopOrderFormAddOrUpdateItemUseCase, + { useClass: ShopOrderFormAddOrUpdateItemUseCase }, + { lifecycle: Lifecycle.ResolutionScoped }, + ); + container.register( + TOKENS.ShopOrderFormRemoveItemUseCase, + { useClass: ShopOrderFormRemoveItemUseCase }, + { lifecycle: Lifecycle.ResolutionScoped }, + ); + container.register( + TOKENS.ShopListProductsUseCase, + { useClass: ShopListProductsUseCase }, + { lifecycle: Lifecycle.ResolutionScoped }, + ); + container.register( + TOKENS.ShopOrderFormSetPaymentOptionUseCase, + { useClass: ShopOrderFormSetPaymentOptionUseCase }, + { lifecycle: Lifecycle.ResolutionScoped }, + ); + container.register( + TOKENS.ShopOrderFormRemovePaymentOptionUseCase, + { useClass: ShopOrderFormRemovePaymentOptionUseCase }, + { lifecycle: Lifecycle.ResolutionScoped }, + ); } diff --git a/apps/api/src/infra/di/tokens.ts b/apps/api/src/infra/di/tokens.ts index c0d5d17..1e64461 100644 --- a/apps/api/src/infra/di/tokens.ts +++ b/apps/api/src/infra/di/tokens.ts @@ -11,6 +11,8 @@ import type { PlayersService, RecoveryKeyService, SessionService, + ShopOrderService, + ShopProductsService, TibiaClientService, WorldsService, } from "@/application/services"; @@ -58,6 +60,12 @@ import type { SessionCanBeAuthenticatedUseCase, SessionInfoUseCase, SessionNotAuthenticatedUseCase, + ShopListProductsUseCase, + ShopOrderFormAddOrUpdateItemUseCase, + ShopOrderFormRemoveItemUseCase, + ShopOrderFormRemovePaymentOptionUseCase, + ShopOrderFormSetPaymentOptionUseCase, + ShopOrderFormUseCase, TibiaLoginUseCase, WorldsListUseCase, } from "@/application/usecases"; @@ -114,6 +122,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"; @@ -174,6 +186,14 @@ const REPOSITORIES_TOKENS = { AccountOauthRepository: token( "AccountOauthRepository", ), + ShopOrderRepository: token("ShopOrderRepository"), + ShopPaymentOptionRepository: token( + "ShopPaymentOptionRepository", + ), + ShopProductRepository: token("ShopProductRepository"), + ShopOrderItemRepository: token( + "ShopOrderItemRepository", + ), }; const UTILS_TOKENS = { @@ -321,6 +341,25 @@ const USECASES_TOKENS = { PlayerOutfitUseCase: token("PlayerOutfitUseCase"), PlayerOutfitsUseCase: token("PlayerOutfitsUseCase"), TibiaLoginUseCase: token("TibiaLoginUseCase"), + ShopOrderFormUseCase: token("ShopOrderFormUseCase"), + ShopOrderFormAddOrUpdateItemUseCase: + token( + "ShopOrderFormAddOrUpdateItemUseCase", + ), + ShopOrderFormRemoveItemUseCase: token( + "ShopOrderFormRemoveItemUseCase", + ), + ShopListProductsUseCase: token( + "ShopListProductsUseCase", + ), + ShopOrderFormSetPaymentOptionUseCase: + token( + "ShopOrderFormSetPaymentOptionUseCase", + ), + ShopOrderFormRemovePaymentOptionUseCase: + token( + "ShopOrderFormRemovePaymentOptionUseCase", + ), }; const SERVICES_TOKENS = { @@ -340,6 +379,8 @@ const SERVICES_TOKENS = { LostAccountService: token("LostAccountService"), RecoveryKeyService: token("RecoveryKeyService"), AccountOauthService: token("AccountOauthService"), + ShopOrderService: token("ShopOrderService"), + ShopProductsService: token("ShopProductsService"), }; const QUEUE_AND_WORKERS_TOKENS = { diff --git a/apps/api/src/infra/env/index.ts b/apps/api/src/infra/env/index.ts index bcd79c8..6bb8443 100644 --- a/apps/api/src/infra/env/index.ts +++ b/apps/api/src/infra/env/index.ts @@ -91,6 +91,16 @@ const DISCORD_CONFIG_SCHEMA = z.object({ DISCORD_API_URL: z.url().default("https://discord.com"), }); +const MERCADO_PAGO_CONFIG_SCHEMA = z.object({ + MERCADO_PAGO_ENABLED: z + .string() + .transform((val) => val === "true" || val === "1") + .default(false), + MERCADO_PAGO_ACCESS_TOKEN: z.string().optional(), + MERCADO_PAGO_WEBHOOK_URL: z.url().optional(), + MERCADO_PAGO_WEBHOOK_SECRET: z.string().optional(), +}); + const envSchema = z.object({ ...FRONTEND_CONFIG_SCHEMA.shape, ...SERVER_CONFIG_SCHEMA.shape, @@ -100,6 +110,7 @@ const envSchema = z.object({ ...MAILER_CONFIG_SCHEMA.shape, ...OUTFIT_CONFIG_SCHEMA.shape, ...DISCORD_CONFIG_SCHEMA.shape, + ...MERCADO_PAGO_CONFIG_SCHEMA.shape, LOG_LEVEL: z.enum(["debug", "info", "warn", "error"]).default("info"), SERVICE_NAME: z.string().default("miforge-api"), PORT: z.coerce.number().default(4000), @@ -172,4 +183,22 @@ export const env = envSchema } } }) + .superRefine((env, ctx) => { + if (!env.MERCADO_PAGO_ENABLED) return; + + const requiredFields: (keyof typeof env)[] = [ + "MERCADO_PAGO_ACCESS_TOKEN", + "MERCADO_PAGO_WEBHOOK_URL", + "MERCADO_PAGO_WEBHOOK_SECRET", + ]; + + for (const field of requiredFields) { + if (!env[field]) { + ctx.addIssue({ + code: "custom", + message: `${field} is required when MERCADO_PAGO_ENABLED is true`, + }); + } + } + }) .parse(process.env); 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..f2c0a40 --- /dev/null +++ b/apps/api/src/presentation/v1/routes/shop/index.ts @@ -0,0 +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/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 new file mode 100644 index 0000000..7939349 --- /dev/null +++ b/apps/api/src/presentation/v1/routes/shop/orderform/index.ts @@ -0,0 +1,12 @@ +import { base } from "@/infra/rpc/base"; +import { orderFormAddOrUpdateItemRoute } from "./addOrUpdateItem"; +import { orderFormRoute } from "./mostRecent"; +import { orderFormPaymentsRouter } from "./payments"; +import { orderFormRemoveItemRoute } from "./removeItem"; + +export const orderFormRouter = base.prefix("/orderform").router({ + getMostRecent: orderFormRoute, + addOrUpdateItem: orderFormAddOrUpdateItemRoute, + removeItem: orderFormRemoveItemRoute, + payments: orderFormPaymentsRouter, +}); 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..79139a5 --- /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: "/", + 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/presentation/v1/routes/shop/orderform/payments/index.ts b/apps/api/src/presentation/v1/routes/shop/orderform/payments/index.ts new file mode 100644 index 0000000..a1b303c --- /dev/null +++ b/apps/api/src/presentation/v1/routes/shop/orderform/payments/index.ts @@ -0,0 +1,8 @@ +import { base } from "@/infra/rpc/base"; +import { orderFormRemovePaymentRoute } from "./removePayment"; +import { orderFormSetPaymentRoute } from "./setPayment"; + +export const orderFormPaymentsRouter = base.prefix("/payments").router({ + setPayment: orderFormSetPaymentRoute, + removePayment: orderFormRemovePaymentRoute, +}); diff --git a/apps/api/src/presentation/v1/routes/shop/orderform/payments/removePayment/index.ts b/apps/api/src/presentation/v1/routes/shop/orderform/payments/removePayment/index.ts new file mode 100644 index 0000000..778cc27 --- /dev/null +++ b/apps/api/src/presentation/v1/routes/shop/orderform/payments/removePayment/index.ts @@ -0,0 +1,18 @@ +import { ShopOrderFormRemovePaymentOptionContractSchema } from "@/application/usecases/shop/orderformRemovePaymentOption/contract"; +import { isAuthenticatedProcedure } from "@/presentation/procedures/isAuthenticated"; + +export const orderFormRemovePaymentRoute = isAuthenticatedProcedure + .route({ + method: "DELETE", + path: "/", + summary: "Remove OrderForm Payment", + description: + "Removes the payment information for the order form in the shop.", + }) + .input(ShopOrderFormRemovePaymentOptionContractSchema.input) + .output(ShopOrderFormRemovePaymentOptionContractSchema.output) + .handler(async ({ context }) => { + return context.usecases.shop.orderFormRemovePaymentOption.execute( + undefined, + ); + }); diff --git a/apps/api/src/presentation/v1/routes/shop/orderform/payments/setPayment/index.ts b/apps/api/src/presentation/v1/routes/shop/orderform/payments/setPayment/index.ts new file mode 100644 index 0000000..ac20a0e --- /dev/null +++ b/apps/api/src/presentation/v1/routes/shop/orderform/payments/setPayment/index.ts @@ -0,0 +1,17 @@ +import { ShopOrderFormSetPaymentOptionContractSchema } from "@/application/usecases/shop/orderformSetPaymentOption/contract"; +import { isAuthenticatedProcedure } from "@/presentation/procedures/isAuthenticated"; + +export const orderFormSetPaymentRoute = isAuthenticatedProcedure + .route({ + method: "POST", + path: "/", + summary: "Set OrderForm Payment", + description: "Sets the payment information for the order form in the shop.", + }) + .input(ShopOrderFormSetPaymentOptionContractSchema.input) + .output(ShopOrderFormSetPaymentOptionContractSchema.output) + .handler(async ({ context, input }) => { + return context.usecases.shop.orderFormSetPaymentOption.execute({ + paymentOptionId: input.paymentOptionId, + }); + }); 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); + }); 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/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..34fbe8e --- /dev/null +++ b/apps/api/src/shared/schemas/ShopOrderForm.ts @@ -0,0 +1,61 @@ +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, + maxUnits: true, + minUnits: true, + baseUnitQuantity: 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..996dc25 --- /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(), + description: z.string().nullable(), + + createdAt: z.coerce.date(), + updatedAt: z.coerce.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..044a752 --- /dev/null +++ b/apps/api/src/shared/schemas/ShopProduct.ts @@ -0,0 +1,39 @@ +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(), +}); + +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 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 0000000..e470917 Binary files /dev/null and b/apps/web/public/assets/buttons/button_green_extended.gif differ 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 0000000..0b592e1 Binary files /dev/null and b/apps/web/public/assets/cart/purchasecomplete_idle.png differ diff --git a/apps/web/public/assets/cart/store-purse.png b/apps/web/public/assets/cart/store-purse.png new file mode 100644 index 0000000..659a0d7 Binary files /dev/null and b/apps/web/public/assets/cart/store-purse.png differ 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 0000000..68fb9b3 Binary files /dev/null and b/apps/web/public/assets/coins/coins_1.png differ 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 0000000..b3c1bcb Binary files /dev/null and b/apps/web/public/assets/coins/coins_2.png differ diff --git a/apps/web/public/assets/coins/coins_3.png b/apps/web/public/assets/coins/coins_3.png new file mode 100644 index 0000000..c84246e Binary files /dev/null and b/apps/web/public/assets/coins/coins_3.png differ 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 0000000..793e2ac Binary files /dev/null and b/apps/web/public/assets/coins/coins_4.png differ diff --git a/apps/web/public/assets/coins/coins_5.png b/apps/web/public/assets/coins/coins_5.png new file mode 100644 index 0000000..28c1344 Binary files /dev/null and b/apps/web/public/assets/coins/coins_5.png differ diff --git a/apps/web/public/assets/coins/coins_6.png b/apps/web/public/assets/coins/coins_6.png new file mode 100644 index 0000000..c506981 Binary files /dev/null and b/apps/web/public/assets/coins/coins_6.png differ diff --git a/apps/web/public/assets/coins/non_transferable_coins_1.png b/apps/web/public/assets/coins/non_transferable_coins_1.png new file mode 100644 index 0000000..3d97aa9 Binary files /dev/null and b/apps/web/public/assets/coins/non_transferable_coins_1.png differ diff --git a/apps/web/public/assets/coins/non_transferable_coins_2.png b/apps/web/public/assets/coins/non_transferable_coins_2.png new file mode 100644 index 0000000..3f6c1a7 Binary files /dev/null and b/apps/web/public/assets/coins/non_transferable_coins_2.png differ 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 0000000..1e1a25d Binary files /dev/null and b/apps/web/public/assets/coins/non_transferable_coins_3.png differ diff --git a/apps/web/public/assets/coins/non_transferable_coins_4.png b/apps/web/public/assets/coins/non_transferable_coins_4.png new file mode 100644 index 0000000..b42f3cc Binary files /dev/null and b/apps/web/public/assets/coins/non_transferable_coins_4.png differ 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 0000000..306f096 Binary files /dev/null and b/apps/web/public/assets/coins/non_transferable_coins_5.png differ diff --git a/apps/web/public/assets/coins/non_transferable_coins_6.png b/apps/web/public/assets/coins/non_transferable_coins_6.png new file mode 100644 index 0000000..efe6b84 Binary files /dev/null and b/apps/web/public/assets/coins/non_transferable_coins_6.png differ diff --git a/apps/web/public/assets/icon-hourglass.png b/apps/web/public/assets/icon-hourglass.png new file mode 100644 index 0000000..0c92d20 Binary files /dev/null and b/apps/web/public/assets/icon-hourglass.png differ diff --git a/apps/web/public/assets/icons/32/tibiora_box.gif b/apps/web/public/assets/icons/32/tibiora_box.gif new file mode 100644 index 0000000..2782a2b Binary files /dev/null and b/apps/web/public/assets/icons/32/tibiora_box.gif differ diff --git a/apps/web/public/assets/payments/payment_icon_hover.webp b/apps/web/public/assets/payments/payment_icon_hover.webp new file mode 100644 index 0000000..1183361 Binary files /dev/null and b/apps/web/public/assets/payments/payment_icon_hover.webp differ diff --git a/apps/web/public/assets/payments/payment_icon_normal.webp b/apps/web/public/assets/payments/payment_icon_normal.webp new file mode 100644 index 0000000..f3c9c11 Binary files /dev/null and b/apps/web/public/assets/payments/payment_icon_normal.webp differ diff --git a/apps/web/public/assets/payments/payment_icon_selected.webp b/apps/web/public/assets/payments/payment_icon_selected.webp new file mode 100644 index 0000000..af62df0 Binary files /dev/null and b/apps/web/public/assets/payments/payment_icon_selected.webp differ diff --git a/apps/web/public/assets/payments/pix.webp b/apps/web/public/assets/payments/pix.webp new file mode 100644 index 0000000..dd6cd7e Binary files /dev/null and b/apps/web/public/assets/payments/pix.webp differ diff --git a/apps/web/public/assets/service/service_deactivated.png b/apps/web/public/assets/service/service_deactivated.png new file mode 100644 index 0000000..b91c921 Binary files /dev/null and b/apps/web/public/assets/service/service_deactivated.png differ diff --git a/apps/web/public/assets/service/service_icon_normal.png b/apps/web/public/assets/service/service_icon_normal.png new file mode 100644 index 0000000..4745481 Binary files /dev/null and b/apps/web/public/assets/service/service_icon_normal.png differ 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 0000000..daabf00 Binary files /dev/null and b/apps/web/public/assets/service/service_icon_over.png differ 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 0000000..6e2eb79 Binary files /dev/null and b/apps/web/public/assets/service/service_icon_selected.png differ diff --git a/apps/web/src/assets/index.ts b/apps/web/src/assets/index.ts index e67d5bb..3daef63 100644 --- a/apps/web/src/assets/index.ts +++ b/apps/web/src/assets/index.ts @@ -13,6 +13,7 @@ export const assets = { buttons: { buttonBlue: "/assets/buttons/button_blue.gif", buttonGreen: "/assets/buttons/button_green.gif", + buttonGreenExtended: "/assets/buttons/button_green_extended.gif", buttonRed: "/assets/buttons/button_red.gif", buttonExtend: "/assets/buttons/button-extend.webp", buttonMenu: "/assets/buttons/button-menu.webp", 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/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/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..ad52ab6 --- /dev/null +++ b/apps/web/src/components/Products/Coins/index.tsx @@ -0,0 +1,194 @@ +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; + 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; + 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, + })} *`} + /> + {showSlider && ( + + )} +
+ {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 492f0cf..da31101 100644 --- a/apps/web/src/routeTree.gen.ts +++ b/apps/web/src/routeTree.gen.ts @@ -30,12 +30,16 @@ 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/', )() const Not_authAccountCreateIndexLazyRouteImport = createFileRoute( '/_not_auth/account/create/', )() +const AuthShopCheckoutIndexLazyRouteImport = createFileRoute( + '/_auth/shop/checkout/', +)() const AuthAccountReset_passwordIndexLazyRouteImport = createFileRoute( '/_auth/account/reset_password/', )() @@ -103,6 +107,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/', @@ -138,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/', @@ -345,6 +364,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 @@ -354,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 @@ -377,6 +398,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 @@ -385,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 @@ -412,6 +435,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 @@ -421,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 @@ -446,6 +471,7 @@ export interface FileRouteTypes { | '/account/email' | '/account' | '/worlds' + | '/shop' | '/login' | '/terms' | '/account/lost/$email' @@ -455,6 +481,7 @@ export interface FileRouteTypes { | '/account/details' | '/account/registration' | '/account/reset_password' + | '/shop/checkout' | '/account/create' | '/account/lost' | '/account/email/change/$token' @@ -478,6 +505,7 @@ export interface FileRouteTypes { | '/account/email' | '/account' | '/worlds' + | '/shop' | '/login' | '/terms' | '/account/lost/$token' @@ -486,6 +514,7 @@ export interface FileRouteTypes { | '/account/details' | '/account/registration' | '/account/reset_password' + | '/shop/checkout' | '/account/create' | '/account/lost' | '/account/email/change/$token' @@ -512,6 +541,7 @@ export interface FileRouteTypes { | '/_auth/account/email' | '/_auth/account/' | '/_public/worlds/' + | '/_auth/shop/' | '/_not_auth/login/' | '/_public/terms/' | '/_not_auth/account/lost/$email' @@ -521,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' @@ -590,6 +621,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' @@ -625,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' @@ -816,11 +861,13 @@ 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 AuthAccountRegistrationIndexLazyRoute: typeof AuthAccountRegistrationIndexLazyRoute AuthAccountReset_passwordIndexLazyRoute: typeof AuthAccountReset_passwordIndexLazyRoute + AuthShopCheckoutIndexLazyRoute: typeof AuthShopCheckoutIndexLazyRoute AuthAccount2faLinkIndexRoute: typeof AuthAccount2faLinkIndexRoute AuthAccount2faUnlinkIndexRoute: typeof AuthAccount2faUnlinkIndexRoute AuthAccountPlayerCreateIndexRoute: typeof AuthAccountPlayerCreateIndexRoute @@ -832,6 +879,7 @@ interface AuthRouteRouteChildren { const AuthRouteRouteChildren: AuthRouteRouteChildren = { AuthAccountEmailRouteRoute: AuthAccountEmailRouteRouteWithChildren, AuthAccountIndexRoute: AuthAccountIndexRoute, + AuthShopIndexLazyRoute: AuthShopIndexLazyRoute, AuthAccountAudit_historyIndexLazyRoute: AuthAccountAudit_historyIndexLazyRoute, AuthAccountCoins_historyIndexLazyRoute: @@ -840,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 28823f0..1c941b7 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,23 @@ export const Route = createRootRouteWithContext()({ return ( - - - {env.VITE_SHOW_DEVTOOLS && ( - - - - - )} + + + + {env.VITE_SHOW_DEVTOOLS && ( + + + + + )} + ); diff --git a/apps/web/src/routes/_auth/shop/checkout/index.lazy.tsx b/apps/web/src/routes/_auth/shop/checkout/index.lazy.tsx new file mode 100644 index 0000000..7e9166f --- /dev/null +++ b/apps/web/src/routes/_auth/shop/checkout/index.lazy.tsx @@ -0,0 +1,10 @@ +import { createLazyFileRoute } from "@tanstack/react-router"; +import { ShopCheckoutSection } from "@/sections/shop_checkout"; + +export const Route = createLazyFileRoute("/_auth/shop/checkout/")({ + component: RouteComponent, +}); + +function RouteComponent() { + return ; +} 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 new file mode 100644 index 0000000..920929e --- /dev/null +++ b/apps/web/src/sdk/contexts/orderform.tsx @@ -0,0 +1,58 @@ +import type { ShopOrderForm } from "@miforge/api/shared/schemas/ShopOrderForm"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { createContext, use, useState } from "react"; +import { api } from "../lib/api/factory"; +import { useSession } from "./session"; + +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( + api.query.miforge.shop.orderForm.getMostRecent.queryOptions({ + enabled: !!session, + }), + ); + + return ( + { + await queryClient.invalidateQueries({ + queryKey: api.query.miforge.shop.orderForm.getMostRecent.queryKey(), + }); + }, + }} + > + {children} + + ); +} + +export const useOrderForm = () => { + const context = use(OrderFormContext); + + if (!context) { + throw new Error("useOrderForm must be used within OrderFormProvider"); + } + + return context; +}; 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 new file mode 100644 index 0000000..1000c93 --- /dev/null +++ b/apps/web/src/sdk/hooks/useOrderFormItem.ts @@ -0,0 +1,106 @@ +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"; + +export function useOrderFormItem() { + const { + mutateAsync: addOrUpdateItemMutation, + isPending: addOrUpdateItemLoading, + } = useMutation( + 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( + async (data: { + productId: string; + quantity: number; + mode?: "ADD" | "SET"; + options?: { + toast?: { + successMessage?: string; + }; + }; + }) => { + withORPCErrorHandling( + async () => { + const mode = data.mode ?? "ADD"; + + await addOrUpdateItemMutation({ + productId: data.productId, + 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: () => { + invalidate(); + }, + }, + ); + }, + [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, + 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..71f948e --- /dev/null +++ b/apps/web/src/sections/shop/index.tsx @@ -0,0 +1,92 @@ +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 { 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"; + +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!`, + }, + }, + }); + }} + /> + ); + } + + /** + * 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); + }} + /> + ); + })} + + + ); +} 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 ( +