From 568e856090b06032482601f3bf9492a8dab5ef01 Mon Sep 17 00:00:00 2001 From: Daniel Castillejo Date: Sat, 20 Jun 2026 20:27:51 -0500 Subject: [PATCH 01/18] feat: add sales capture workflows and import safeguards Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../migration.sql | 48 ++ prisma/models/auth.prisma | 1 + prisma/schema.prisma | 46 ++ .../dashboard/menu-items/import-options.tsx | 251 ++++-- src/app/dashboard/sales/closing/page.tsx | 52 ++ src/app/dashboard/sales/loading.tsx | 28 + src/app/dashboard/sales/new/page.tsx | 48 ++ src/app/dashboard/sales/page.tsx | 56 ++ src/components/dashboard/app-sidebar.tsx | 11 + src/components/sales/quick-sale-screen.tsx | 759 ++++++++++++++++++ src/components/sales/sales-closing-export.tsx | 82 ++ src/components/sales/sales-closing.tsx | 180 +++++ src/components/sales/sales-dashboard.tsx | 230 ++++++ src/lib/auth.ts | 171 ++-- src/lib/types/sales.ts | 111 +++ src/server/actions/sales/mutations.ts | 194 +++++ src/server/actions/sales/queries.ts | 505 ++++++++++++ src/server/actions/subscriptions/mutations.ts | 10 +- src/server/actions/subscriptions/queries.ts | 41 +- src/server/actions/user/queries.ts | 33 +- 20 files changed, 2730 insertions(+), 127 deletions(-) create mode 100644 prisma/migrations/20260620000000_add_sales_capture/migration.sql create mode 100644 src/app/dashboard/sales/closing/page.tsx create mode 100644 src/app/dashboard/sales/loading.tsx create mode 100644 src/app/dashboard/sales/new/page.tsx create mode 100644 src/app/dashboard/sales/page.tsx create mode 100644 src/components/sales/quick-sale-screen.tsx create mode 100644 src/components/sales/sales-closing-export.tsx create mode 100644 src/components/sales/sales-closing.tsx create mode 100644 src/components/sales/sales-dashboard.tsx create mode 100644 src/lib/types/sales.ts create mode 100644 src/server/actions/sales/mutations.ts create mode 100644 src/server/actions/sales/queries.ts diff --git a/prisma/migrations/20260620000000_add_sales_capture/migration.sql b/prisma/migrations/20260620000000_add_sales_capture/migration.sql new file mode 100644 index 00000000..2c25ae26 --- /dev/null +++ b/prisma/migrations/20260620000000_add_sales_capture/migration.sql @@ -0,0 +1,48 @@ +-- CreateTable +CREATE TABLE "Sale" ( + "id" TEXT NOT NULL PRIMARY KEY, + "orderType" TEXT NOT NULL DEFAULT 'DINE_IN', + "currency" TEXT NOT NULL DEFAULT 'MXN', + "subtotal" REAL NOT NULL, + "total" REAL NOT NULL, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL, + "organizationId" TEXT NOT NULL, + CONSTRAINT "Sale_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +-- CreateTable +CREATE TABLE "SaleItem" ( + "id" TEXT NOT NULL PRIMARY KEY, + "saleId" TEXT NOT NULL, + "menuItemId" TEXT, + "variantId" TEXT, + "productName" TEXT NOT NULL, + "variantName" TEXT, + "unitPrice" REAL NOT NULL, + "quantity" INTEGER NOT NULL, + "lineTotal" REAL NOT NULL, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL, + CONSTRAINT "SaleItem_saleId_fkey" FOREIGN KEY ("saleId") REFERENCES "Sale" ("id") ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT "SaleItem_menuItemId_fkey" FOREIGN KEY ("menuItemId") REFERENCES "MenuItem" ("id") ON DELETE SET NULL ON UPDATE CASCADE, + CONSTRAINT "SaleItem_variantId_fkey" FOREIGN KEY ("variantId") REFERENCES "Variant" ("id") ON DELETE SET NULL ON UPDATE CASCADE +); + +-- CreateIndex +CREATE INDEX "Sale_organizationId_createdAt_idx" ON "Sale"("organizationId", "createdAt"); + +-- CreateIndex +CREATE INDEX "Sale_organizationId_orderType_createdAt_idx" ON "Sale"("organizationId", "orderType", "createdAt"); + +-- CreateIndex +CREATE INDEX "SaleItem_saleId_idx" ON "SaleItem"("saleId"); + +-- CreateIndex +CREATE INDEX "SaleItem_menuItemId_idx" ON "SaleItem"("menuItemId"); + +-- CreateIndex +CREATE INDEX "SaleItem_variantId_idx" ON "SaleItem"("variantId"); + +-- CreateIndex +CREATE INDEX "SaleItem_productName_idx" ON "SaleItem"("productName"); diff --git a/prisma/models/auth.prisma b/prisma/models/auth.prisma index f939b137..efbd9418 100644 --- a/prisma/models/auth.prisma +++ b/prisma/models/auth.prisma @@ -95,6 +95,7 @@ model Organization { location Location[] menus Menu[] menuItems MenuItem[] + sales Sale[] themes Theme[] mediaAssets MediaAsset[] diff --git a/prisma/schema.prisma b/prisma/schema.prisma index ecf36203..5a64af83 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -13,6 +13,12 @@ enum Currency { USD } +enum OrderType { + DINE_IN + TAKEOUT + DELIVERY +} + enum MediaAssetType { IMAGE VIDEO @@ -128,6 +134,7 @@ model MenuItem { allergens String? currency Currency @default(MXN) translations MenuItemTranslation[] + saleItems SaleItem[] @@unique([organizationId, name]) } @@ -153,6 +160,7 @@ model Variant { menuItemId String menuItem MenuItem @relation(fields: [menuItemId], references: [id], onDelete: Cascade) translations VariantTranslation[] + saleItems SaleItem[] } model MenuItemTranslation { @@ -199,6 +207,44 @@ model CategoryTranslation { @@index([locale]) } +model Sale { + id String @id @default(cuid()) + orderType OrderType @default(DINE_IN) + currency Currency @default(MXN) + subtotal Float + total Float + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + organizationId String + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) + items SaleItem[] + + @@index([organizationId, createdAt]) + @@index([organizationId, orderType, createdAt]) +} + +model SaleItem { + id String @id @default(cuid()) + saleId String + sale Sale @relation(fields: [saleId], references: [id], onDelete: Cascade) + menuItemId String? + menuItem MenuItem? @relation(fields: [menuItemId], references: [id], onDelete: SetNull) + variantId String? + variant Variant? @relation(fields: [variantId], references: [id], onDelete: SetNull) + productName String + variantName String? + unitPrice Float + quantity Int + lineTotal Float + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([saleId]) + @@index([menuItemId]) + @@index([variantId]) + @@index([productName]) +} + // Media asset management models for tracking R2 storage model MediaAsset { diff --git a/src/app/dashboard/menu-items/import-options.tsx b/src/app/dashboard/menu-items/import-options.tsx index 99b3ee5d..7ec7d682 100644 --- a/src/app/dashboard/menu-items/import-options.tsx +++ b/src/app/dashboard/menu-items/import-options.tsx @@ -15,10 +15,10 @@ import { bulkCreateItems } from "@/server/actions/item/mutations" import { MenuItemStatus, type BulkMenuItem } from "@/lib/types/menu-item" type CSVRow = { - nombre: string + nombre?: string variante?: string descripcion?: string - precio: string + precio?: string categoria?: string moneda?: string } @@ -28,6 +28,130 @@ type ImportError = { errors: string[] } +type CanonicalHeader = keyof CSVRow + +const HEADER_ALIASES: Record = { + nombre: ["nombre", "name", "producto", "product", "productname", "item"], + variante: ["variante", "variant", "variantname", "size"], + descripcion: ["descripcion", "description", "desc", "detalle", "detalles"], + precio: ["precio", "price", "unitprice", "pricevalue", "unitpricevalue"], + categoria: ["categoria", "category", "group", "section", "family"], + moneda: ["moneda", "currency", "currencycode", "curr"] +} + +function normalizeHeaderValue(header: string) { + return header + .trim() + .replace(/^\uFEFF/, "") + .toLowerCase() + .normalize("NFKD") + .replace(/[\u0300-\u036f]/g, "") + .replace(/[^a-z0-9]+/g, "") +} + +function normalizeHeader(header: string) { + const normalized = normalizeHeaderValue(header) + + for (const [canonical, aliases] of Object.entries(HEADER_ALIASES)) { + if (aliases.some(alias => normalizeHeaderValue(alias) === normalized)) { + return canonical + } + } + + return normalized +} + +function parsePriceValue(value: string | undefined) { + if (!value) { + return undefined + } + + const normalized = value.trim().replace(/\s/g, "") + if (!normalized) { + return undefined + } + + const numericValue = normalized.replace(/[^\d,.-]/g, "") + if (!numericValue) { + return undefined + } + + const hasComma = numericValue.includes(",") + const hasDot = numericValue.includes(".") + + if (hasComma && hasDot) { + const lastComma = numericValue.lastIndexOf(",") + const lastDot = numericValue.lastIndexOf(".") + const decimalSeparator = lastComma > lastDot ? "," : "." + const sanitized = numericValue.replace(/[,.]/g, char => + char === decimalSeparator ? "." : "" + ) + return Number.parseFloat(sanitized) + } + + if (hasComma) { + return Number.parseFloat(numericValue.replace(",", ".")) + } + + return Number.parseFloat(numericValue) +} + +function detectDelimiter(text: string) { + const sample = text.slice(0, 5000) + const counts = [",", ";", "\t"].map(delimiter => ({ + delimiter, + count: (sample.match(new RegExp(`\\${delimiter}`, "g")) ?? []).length + })) + + return ( + counts.sort((left, right) => right.count - left.count)[0]?.delimiter ?? "," + ) +} + +function parseCsvRows(text: string) { + const delimiter = detectDelimiter(text) + const results = Papa.parse>(text, { + header: true, + skipEmptyLines: true, + delimiter, + transformHeader: header => normalizeHeader(header), + dynamicTyping: false + }) + + if (results.errors.length > 0) { + const firstError = results.errors[0] + return { + error: firstError?.message + ? `No pudimos procesar el archivo CSV: ${firstError.message}` + : "No pudimos procesar el archivo CSV" + } + } + + const fields = results.meta.fields ?? [] + const hasRequiredColumns = + fields.includes("nombre") && fields.includes("precio") + + if (!hasRequiredColumns) { + return { + error: + "El archivo debe incluir columnas de nombre y precio. Usa la plantilla descargable para garantizar el formato correcto." + } + } + + const rows = (results.data as Array>).map( + row => ({ + nombre: row.nombre?.trim(), + variante: row.variante?.trim(), + descripcion: row.descripcion?.trim(), + precio: row.precio?.trim(), + categoria: row.categoria?.trim(), + moneda: row.moneda?.trim() + }) + ) + + return { rows } +} + function downloadCsvFile(rows: CSVRow[], fileName: string) { const csv = Papa.unparse(rows) const blob = new Blob([csv], { type: "text/csv;charset=utf-8;" }) @@ -74,8 +198,8 @@ function validateRow(row: CSVRow): string[] { if (!row.precio) { errors.push("El precio es requerido") } else { - const price = parseFloat(row.precio) - if (isNaN(price) || price < 0) { + const price = parsePriceValue(row.precio) + if (typeof price !== "number" || Number.isNaN(price) || price < 0) { errors.push("El precio debe ser un número positivo") } } @@ -123,7 +247,9 @@ export default function MenuImportOptions({ } }) - const handleFileUpload = (event: React.ChangeEvent) => { + const handleFileUpload = async ( + event: React.ChangeEvent + ) => { const file = event.target.files?.[0] if (!file) { @@ -132,69 +258,82 @@ export default function MenuImportOptions({ setErrors([]) - Papa.parse(file, { - header: true, - skipEmptyLines: true, - encoding: "utf-8", - complete: results => { - if (results.data.length === 0) { - setErrors([{ row: 0, errors: ["El archivo está vacío"] }]) - return - } + try { + const text = await file.text() + const parsed = parseCsvRows(text) - if (results.data.length > 200) { - setErrors([ - { - row: 0, - errors: ["No puedes importar más de 200 productos a la vez"] - } - ]) - return - } + if ("error" in parsed && parsed.error) { + setErrors([{ row: 0, errors: [parsed.error] }]) + event.target.value = "" + return + } - const foundErrors: ImportError[] = [] - const validItems: BulkMenuItem[] = [] + const rows = parsed.rows ?? [] - results.data.forEach((row, index) => { - const rowErrors = validateRow(row) + if (rows.length === 0) { + setErrors([{ row: 0, errors: ["El archivo está vacío"] }]) + event.target.value = "" + return + } - if (rowErrors.length > 0) { - foundErrors.push({ - row: index + 1, - errors: rowErrors - }) - return + if (rows.length > 200) { + setErrors([ + { + row: 0, + errors: ["No puedes importar más de 200 productos a la vez"] } + ]) + event.target.value = "" + return + } - const currency = (row.moneda ?? "MXN").trim().toUpperCase() + const foundErrors: ImportError[] = [] + const validItems: BulkMenuItem[] = [] - validItems.push({ - name: row.nombre, - variantName: row.variante?.trim() || undefined, - description: row.descripcion, - price: parseFloat(row.precio), - status: MenuItemStatus.ACTIVE, - category: row.categoria, - currency: currency === "USD" ? "USD" : "MXN" - }) - }) + rows.forEach((row, index) => { + const rowErrors = validateRow(row) - if (foundErrors.length > 0) { - setErrors(foundErrors) + if (rowErrors.length > 0) { + foundErrors.push({ + row: index + 1, + errors: rowErrors + }) return } - execute(validItems) - }, - error: error => { - setErrors([ - { - row: 0, - errors: [`No pudimos procesar el archivo CSV: ${error.message}`] - } - ]) + const price = parsePriceValue(row.precio) + const currency = (row.moneda ?? "MXN").trim().toUpperCase() + + validItems.push({ + name: row.nombre ?? "", + variantName: row.variante?.trim() || undefined, + description: row.descripcion, + price: typeof price === "number" ? price : 0, + status: MenuItemStatus.ACTIVE, + category: row.categoria, + currency: currency === "USD" ? "USD" : "MXN" + }) + }) + + if (foundErrors.length > 0) { + setErrors(foundErrors) + event.target.value = "" + return } - }) + + execute(validItems) + } catch (error) { + setErrors([ + { + row: 0, + errors: [ + `No pudimos procesar el archivo CSV: ${error instanceof Error ? error.message : "Error desconocido"}` + ] + } + ]) + } finally { + event.target.value = "" + } } return ( diff --git a/src/app/dashboard/sales/closing/page.tsx b/src/app/dashboard/sales/closing/page.tsx new file mode 100644 index 00000000..a0f7a1b6 --- /dev/null +++ b/src/app/dashboard/sales/closing/page.tsx @@ -0,0 +1,52 @@ +import { ArrowLeft, ReceiptText } from "lucide-react" +import type { Metadata } from "next" +import Link from "next/link" +import { notFound } from "next/navigation" + +import PageSubtitle from "@/components/dashboard/page-subtitle" +import { SalesClosingReport } from "@/components/sales/sales-closing" +import { SalesClosingExportButton } from "@/components/sales/sales-closing-export" +import { Button } from "@/components/ui/button" +import { getSalesClosingData } from "@/server/actions/sales/queries" +import { getCurrentOrganization } from "@/server/actions/user/queries" + +export const metadata: Metadata = { + title: "Cierre diario" +} + +export default async function SalesClosingPage() { + const currentOrg = await getCurrentOrganization() + + if (!currentOrg) { + notFound() + } + + const data = await getSalesClosingData(currentOrg.id) + + return ( +
+ + + Cierre diario + + Reporte de fin de jornada para restaurante + + +
+ + +
+
+
+ + +
+ ) +} diff --git a/src/app/dashboard/sales/loading.tsx b/src/app/dashboard/sales/loading.tsx new file mode 100644 index 00000000..4001b5c7 --- /dev/null +++ b/src/app/dashboard/sales/loading.tsx @@ -0,0 +1,28 @@ +import { Skeleton } from "@/components/ui/skeleton" + +export default function Loading() { + return ( +
+
+
+ + +
+ +
+ +
+ {Array.from({ length: 4 }).map((_, index) => ( + + ))} +
+ +
+ + +
+
+ ) +} diff --git a/src/app/dashboard/sales/new/page.tsx b/src/app/dashboard/sales/new/page.tsx new file mode 100644 index 00000000..94e1eed7 --- /dev/null +++ b/src/app/dashboard/sales/new/page.tsx @@ -0,0 +1,48 @@ +import { ArrowLeft, ShoppingBag } from "lucide-react" +import type { Metadata } from "next" +import Link from "next/link" +import { notFound } from "next/navigation" + +import PageSubtitle from "@/components/dashboard/page-subtitle" +import { QuickSaleScreen } from "@/components/sales/quick-sale-screen" +import { Button } from "@/components/ui/button" +import { getSalesCatalog } from "@/server/actions/sales/queries" +import { getCurrentOrganization } from "@/server/actions/user/queries" + +export const metadata: Metadata = { + title: "Nueva venta" +} + +export default async function NewSalePage() { + const currentOrg = await getCurrentOrganization() + + if (!currentOrg) { + notFound() + } + + const catalog = await getSalesCatalog(currentOrg.id) + + return ( +
+ + + Nueva venta + + Captura rápida para mostrador e iPad + + + + + + + +
+ ) +} diff --git a/src/app/dashboard/sales/page.tsx b/src/app/dashboard/sales/page.tsx new file mode 100644 index 00000000..3c1de16d --- /dev/null +++ b/src/app/dashboard/sales/page.tsx @@ -0,0 +1,56 @@ +import { Banknote, Plus, ReceiptText } from "lucide-react" +import type { Metadata } from "next" +import Link from "next/link" +import { notFound } from "next/navigation" + +import PageSubtitle from "@/components/dashboard/page-subtitle" +import { SalesDashboard } from "@/components/sales/sales-dashboard" +import { Button } from "@/components/ui/button" +import { getSalesDashboardData } from "@/server/actions/sales/queries" +import { getCurrentOrganization } from "@/server/actions/user/queries" + +export const metadata: Metadata = { + title: "Ventas" +} + +export default async function SalesPage() { + const currentOrg = await getCurrentOrganization() + + if (!currentOrg) { + notFound() + } + + const data = await getSalesDashboardData(currentOrg.id) + + return ( +
+ + + Ventas + + Captura rápida y métricas de ventas + + +
+ + +
+
+
+ + +
+ ) +} diff --git a/src/components/dashboard/app-sidebar.tsx b/src/components/dashboard/app-sidebar.tsx index b74c0442..03a35787 100644 --- a/src/components/dashboard/app-sidebar.tsx +++ b/src/components/dashboard/app-sidebar.tsx @@ -13,6 +13,7 @@ import * as Sentry from "@sentry/nextjs" import { type feedbackIntegration } from "@sentry/nextjs" import { useQuery, useQueryClient } from "@tanstack/react-query" import { + Banknote, ChevronRight, ChevronsUpDown, Crown, @@ -84,6 +85,16 @@ type NavigationItem = { const navigation: NavigationItem[] = [ { title: "Menús", url: "/dashboard", icon: LayoutTemplate }, + { + title: "Ventas", + url: "/dashboard/sales", + icon: Banknote, + items: [ + { title: "Resumen", url: "/dashboard/sales" }, + { title: "Nueva venta", url: "/dashboard/sales/new" }, + { title: "Cierre diario", url: "/dashboard/sales/closing" } + ] + }, { title: "Catálogo", url: "/dashboard/menu-items", diff --git a/src/components/sales/quick-sale-screen.tsx b/src/components/sales/quick-sale-screen.tsx new file mode 100644 index 00000000..a97a4dcf --- /dev/null +++ b/src/components/sales/quick-sale-screen.tsx @@ -0,0 +1,759 @@ +"use client" + +import { useDeferredValue, useMemo, useState } from "react" +import toast from "react-hot-toast" +import { + Loader2, + Minus, + Plus, + Search, + ShoppingBag, + Trash2, + X +} from "lucide-react" +import { useAction } from "next-safe-action/hooks" +import Image from "next/image" +import { useRouter } from "next/navigation" +import { TextMorph } from "torph/react" + +import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" +import { + Empty, + EmptyDescription, + EmptyHeader, + EmptyMedia, + EmptyTitle +} from "@/components/ui/empty" +import { Input } from "@/components/ui/input" +import { Separator } from "@/components/ui/separator" +import { + Sheet, + SheetContent, + SheetDescription, + SheetHeader, + SheetTitle +} from "@/components/ui/sheet" +import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group" +import { completeSale } from "@/server/actions/sales/mutations" +import { formatPrice } from "@/lib/currency" +import { + salesOrderTypeOptions, + type SaleCartItemInput, + type SalesCatalogData, + type SalesCatalogProduct, + type SalesOrderType +} from "@/lib/types/sales" +import { cn } from "@/lib/utils" + +function roundMoney(value: number) { + return Math.round((value + Number.EPSILON) * 100) / 100 +} + +type CartLine = SaleCartItemInput & { + key: string + productName: string + variantName: string | null + unitPrice: number + lineTotal: number + image: string | null + currency: "MXN" | "USD" +} + +export function QuickSaleScreen({ catalog }: { catalog: SalesCatalogData }) { + const router = useRouter() + const [search, setSearch] = useState("") + const deferredSearch = useDeferredValue(search) + const [selectedCategory, setSelectedCategory] = useState("all") + const [orderType, setOrderType] = useState("DINE_IN") + const [cart, setCart] = useState([]) + const [selectedProduct, setSelectedProduct] = + useState(null) + + const currency = cart[0]?.currency ?? catalog.products[0]?.currency ?? "MXN" + + const { execute, status, reset } = useAction(completeSale, { + onSuccess: ({ data }) => { + if (data?.failure?.reason) { + toast.error(data.failure.reason) + reset() + return + } + + if (data?.success) { + toast.success( + `Venta completada · ${formatPrice(data.success.total, currency)}` + ) + } + + setCart([]) + setSearch("") + setSelectedCategory("all") + setOrderType("DINE_IN") + setSelectedProduct(null) + router.refresh() + reset() + }, + onError: () => { + toast.error("No se pudo completar la venta") + reset() + } + }) + + const categoryOptions = useMemo( + () => [ + { value: "all", label: "Todos" }, + ...catalog.categories.map(category => ({ + value: category.id, + label: category.name + })), + ...(catalog.uncategorizedCount > 0 + ? [{ value: "uncategorized", label: "Sin categoría" }] + : []) + ], + [catalog.categories, catalog.uncategorizedCount] + ) + + const filteredProducts = useMemo(() => { + const query = deferredSearch.trim().toLowerCase() + + return catalog.products.filter(product => { + const matchesCategory = + selectedCategory === "all" + ? true + : selectedCategory === "uncategorized" + ? product.categoryId === null + : product.categoryId === selectedCategory + + const matchesSearch = + query.length === 0 + ? true + : [ + product.name, + product.description ?? "", + product.categoryName ?? "" + ].some(value => value.toLowerCase().includes(query)) + + return matchesCategory && matchesSearch + }) + }, [catalog.products, deferredSearch, selectedCategory]) + + const subtotal = useMemo( + () => roundMoney(cart.reduce((sum, item) => sum + item.lineTotal, 0)), + [cart] + ) + + const totalItems = useMemo( + () => cart.reduce((sum, item) => sum + item.quantity, 0), + [cart] + ) + + const addProduct = (product: SalesCatalogProduct, variantId?: string) => { + const selectedVariant = variantId + ? product.variants.find(variant => variant.id === variantId) + : product.variants[0] + + if (!selectedVariant) { + toast.error("Selecciona una variante") + return + } + + const key = `${product.id}:${selectedVariant.id}` + const lineTotal = Number( + (selectedVariant.price + Number.EPSILON).toFixed(2) + ) + + setCart(current => { + const existing = current.find(item => item.key === key) + if (existing) { + return current.map(item => + item.key === key + ? { + ...item, + quantity: item.quantity + 1, + lineTotal: Number( + ((item.quantity + 1) * item.unitPrice).toFixed(2) + ) + } + : item + ) + } + + return [ + ...current, + { + key, + menuItemId: product.id, + variantId: selectedVariant.id, + quantity: 1, + productName: product.name, + variantName: selectedVariant.name, + unitPrice: selectedVariant.price, + lineTotal, + image: product.image, + currency: product.currency + } + ] + }) + } + + const handleProductSelect = (product: SalesCatalogProduct) => { + if (product.variantCount === 0) { + toast.error("Este producto no tiene variantes disponibles") + return + } + + if (product.variantCount === 1) { + addProduct(product) + return + } + + setSelectedProduct(product) + } + + const updateQuantity = (key: string, nextQuantity: number) => { + setCart(current => + current + .map(item => + item.key === key + ? { + ...item, + quantity: nextQuantity, + lineTotal: Number((nextQuantity * item.unitPrice).toFixed(2)) + } + : item + ) + .filter(item => item.quantity > 0) + ) + } + + const handleCompleteSale = () => { + if (cart.length === 0) { + toast.error("Agrega al menos un producto") + return + } + + execute({ + orderType, + items: cart.map(item => ({ + menuItemId: item.menuItemId, + variantId: item.variantId, + quantity: item.quantity + })) + }) + } + + const clearCart = () => { + setCart([]) + setSelectedProduct(null) + } + + return ( + <> +
+
+ + +
+
+ + setSearch(event.target.value)} + placeholder="Buscar producto" + className="h-11 pl-10 text-base" + /> +
+
+ + {filteredProducts.length} productos + + {cart.length} líneas +
+
+ + +
+
+ + +
+ + +
+ + { + if (!open) setSelectedProduct(null) + }} + > + + + + {selectedProduct?.name} + + + Elige una variante para agregarla a la venta + + + + {selectedProduct && ( +
+
+
+

+ {selectedProduct.name} +

+

+ {selectedProduct.categoryName ?? "Sin categoría"} +

+
+ + {selectedProduct.variantCount} opciones + +
+ +
+ {selectedProduct.variants.map(variant => ( + + ))} +
+
+ )} +
+
+ + ) +} + +function CategoryFilter({ + options, + value, + onValueChange +}: { + options: Array<{ value: string; label: string }> + value: string + onValueChange: (value: string) => void +}) { + return ( +
+

Categorías

+ { + if (next) onValueChange(next) + }} + className="flex w-full flex-wrap justify-start gap-2" + variant="outline" + size="sm" + > + {options.map(option => ( + + {option.label} + + ))} + +
+ ) +} + +function ProductGrid({ + products, + onSelect +}: { + products: SalesCatalogProduct[] + onSelect: (product: SalesCatalogProduct) => void +}) { + if (products.length === 0) { + return ( + + + + + + + + No hay productos + + Ajusta el filtro o la búsqueda para encontrar productos + disponibles. + + + + + + ) + } + + return ( +
+ {products.map(product => ( + + ))} +
+ ) +} + +function ProductCard({ + product, + onSelect +}: { + product: SalesCatalogProduct + onSelect: (product: SalesCatalogProduct) => void +}) { + return ( + + ) +} + +function OrderTypeSelector({ + value, + onValueChange +}: { + value: SalesOrderType + onValueChange: (value: SalesOrderType) => void +}) { + return ( +
+

Tipo de orden

+ { + if (next) onValueChange(next as SalesOrderType) + }} + className="grid grid-cols-3 gap-2" + variant="outline" + size="sm" + > + {salesOrderTypeOptions.map(option => ( + + {option.label} + + ))} + +
+ ) +} + +function SaleCart({ + cart, + currency, + onIncrease, + onDecrease, + onRemove +}: { + cart: CartLine[] + currency: "MXN" | "USD" + onIncrease: (key: string) => void + onDecrease: (key: string) => void + onRemove: (key: string) => void +}) { + if (cart.length === 0) { + return ( +
+ + + + + + Sin productos + + Toca un producto para agregarlo a la venta. + + + +
+ ) + } + + return ( +
+ {cart.map(item => ( +
+
+
+

{item.productName}

+ {item.variantName && ( +

+ {item.variantName} +

+ )} +

+ {formatPrice(item.unitPrice, currency)} c/u +

+
+
+

+ {formatPrice(item.lineTotal, currency)} +

+ +
+
+ +
+
+ +
+ {item.quantity} +
+ +
+

+ {formatPrice(item.lineTotal, currency)} +

+
+
+ ))} +
+ ) +} + +function SaleSummary({ + subtotal, + total, + currency, + itemCount +}: { + subtotal: number + total: number + currency: "MXN" | "USD" + itemCount: number +}) { + return ( +
+
+ Subtotal + {formatPrice(subtotal, currency)} +
+
+ Items + {itemCount} +
+ +
+
+

Total

+

+ Sin impuestos, propinas ni descuentos +

+
+

{formatPrice(total, currency)}

+
+
+ ) +} diff --git a/src/components/sales/sales-closing-export.tsx b/src/components/sales/sales-closing-export.tsx new file mode 100644 index 00000000..c35c4236 --- /dev/null +++ b/src/components/sales/sales-closing-export.tsx @@ -0,0 +1,82 @@ +"use client" + +import { Download } from "lucide-react" +import Papa from "papaparse" + +import { Button } from "@/components/ui/button" +import { salesOrderTypeLabels, type SalesClosingData } from "@/lib/types/sales" + +type ClosingCsvRow = { + section: string + label: string + orderType: string + quantity: number | "" + orders: number | "" + revenue: number | "" + currency: string +} + +function buildRows(data: SalesClosingData): ClosingCsvRow[] { + const rows: ClosingCsvRow[] = [ + { + section: "summary", + label: "Ingresos de hoy", + orderType: "", + quantity: "", + orders: "", + revenue: data.todayRevenue, + currency: data.currency + }, + { + section: "summary", + label: "Órdenes de hoy", + orderType: "", + quantity: "", + orders: data.todayOrders, + revenue: "", + currency: data.currency + }, + ...data.bestSellers.map(item => ({ + section: "best_sellers", + label: item.productName, + orderType: "", + quantity: item.quantity, + orders: "", + revenue: item.revenue, + currency: data.currency + })), + ...data.revenueByOrderType.map(item => ({ + section: "order_type", + label: salesOrderTypeLabels[item.orderType], + orderType: item.orderType, + quantity: "", + orders: item.orders, + revenue: item.revenue, + currency: data.currency + })) + ] + + return rows +} + +function downloadCsv(rows: ClosingCsvRow[]) { + const csv = Papa.unparse(rows) + const blob = new Blob([csv], { type: "text/csv;charset=utf-8;" }) + const url = URL.createObjectURL(blob) + const link = document.createElement("a") + link.href = url + link.download = `cierre-ventas-${new Date().toISOString().slice(0, 10)}.csv` + document.body.appendChild(link) + link.click() + document.body.removeChild(link) + URL.revokeObjectURL(url) +} + +export function SalesClosingExportButton({ data }: { data: SalesClosingData }) { + return ( + + ) +} diff --git a/src/components/sales/sales-closing.tsx b/src/components/sales/sales-closing.tsx new file mode 100644 index 00000000..ad2fabc8 --- /dev/null +++ b/src/components/sales/sales-closing.tsx @@ -0,0 +1,180 @@ +import { Banknote, ShoppingCart } from "lucide-react" + +import { Badge } from "@/components/ui/badge" +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle +} from "@/components/ui/card" +import { + Empty, + EmptyDescription, + EmptyHeader, + EmptyMedia, + EmptyTitle +} from "@/components/ui/empty" +import { Separator } from "@/components/ui/separator" +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow +} from "@/components/ui/table" +import { formatPrice } from "@/lib/currency" +import { + salesOrderTypeBadgeVariants, + salesOrderTypeLabels, + type SalesClosingData +} from "@/lib/types/sales" + +function getSummaryItems(data: SalesClosingData) { + return [ + { + title: "Ingresos de hoy", + value: formatPrice(data.todayRevenue, data.currency), + description: "Ingresos totales del día", + icon: Banknote + }, + { + title: "Órdenes de hoy", + value: data.todayOrders.toString(), + description: "Ventas completadas hoy", + icon: ShoppingCart + } + ] +} + +export function SalesClosingReport({ data }: { data: SalesClosingData }) { + return ( +
+
+ {getSummaryItems(data).map(item => ( + + +
+ {item.title} + {item.value} +
+
+ +
+
+ +

+ {item.description} +

+
+
+ ))} +
+ +
+ + + Productos más vendidos + Productos más vendidos del día + + + {data.bestSellers.length === 0 ? ( +
+ + + + + + Sin ventas hoy + + No hay productos vendidos para el cierre de hoy. + + + +
+ ) : ( +
+ {data.bestSellers.map((item, index) => ( +
+
+
+ + #{index + 1} + +
+

+ {item.productName} +

+

+ {item.quantity} unidades +

+
+
+

+ {formatPrice(item.revenue, data.currency)} +

+
+ {index < data.bestSellers.length - 1 && } +
+ ))} +
+ )} +
+
+ + + + Ingresos por tipo de orden + + Ventas agrupadas por tipo de orden + + + + + + + Tipo de orden + Órdenes + Ingresos + + + + {data.revenueByOrderType.map(item => ( + + + + {salesOrderTypeLabels[item.orderType]} + + + {item.orders} + + {formatPrice(item.revenue, data.currency)} + + + ))} + +
+
+
+
+
+ ) +} diff --git a/src/components/sales/sales-dashboard.tsx b/src/components/sales/sales-dashboard.tsx new file mode 100644 index 00000000..4a8dd900 --- /dev/null +++ b/src/components/sales/sales-dashboard.tsx @@ -0,0 +1,230 @@ +import { Banknote, ShoppingCart, TrendingUp, WalletCards } from "lucide-react" + +import { Badge } from "@/components/ui/badge" +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle +} from "@/components/ui/card" +import { + Empty, + EmptyDescription, + EmptyHeader, + EmptyMedia, + EmptyTitle +} from "@/components/ui/empty" +import { Separator } from "@/components/ui/separator" +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow +} from "@/components/ui/table" +import { formatPrice } from "@/lib/currency" +import { + salesOrderTypeBadgeVariants, + salesOrderTypeLabels, + type SalesDashboardData +} from "@/lib/types/sales" + +function formatDateTime(value: string) { + return new Intl.DateTimeFormat("es-MX", { + dateStyle: "medium", + timeStyle: "short" + }).format(new Date(value)) +} + +function getKpiItems(data: SalesDashboardData) { + return [ + { + title: "Ingresos de hoy", + value: formatPrice(data.todayRevenue, data.currency), + description: "Ingresos de hoy", + icon: Banknote + }, + { + title: "Órdenes de hoy", + value: data.todayOrders.toString(), + description: "Órdenes completadas hoy", + icon: ShoppingCart + }, + { + title: "Ingresos del mes", + value: formatPrice(data.monthRevenue, data.currency), + description: "Ingresos del mes", + icon: TrendingUp + }, + { + title: "Ticket promedio", + value: formatPrice(data.averageTicket, data.currency), + description: "Promedio por venta hoy", + icon: WalletCards + } + ] +} + +export function SalesDashboard({ data }: { data: SalesDashboardData }) { + return ( +
+
+ {getKpiItems(data).map(item => ( + + +
+ {item.title} + {item.value} +
+
+ +
+
+ +

+ {item.description} +

+
+
+ ))} +
+ +
+ + +
+ Ventas recientes + Últimas 25 ventas completadas +
+ {data.recentSales.length} +
+ + {data.recentSales.length === 0 ? ( +
+ + + + + + No hay ventas aún + + Cuando completes la primera venta, aparecerá aquí. + + + +
+ ) : ( + + + + Fecha + Tipo de orden + Artículos + Total + + + + {data.recentSales.map(sale => ( + + + {formatDateTime(sale.createdAt)} + + + + {salesOrderTypeLabels[sale.orderType]} + + + {sale.items} + + {formatPrice(sale.total, data.currency)} + + + ))} + +
+ )} +
+
+ + + + Más vendidos + + Top 10 productos por cantidad vendida + + + + {data.bestSellers.length === 0 ? ( +
+ + + + + + Sin ventas para analizar + + Tu ranking de productos aparecerá después de capturar + algunas ventas. + + + +
+ ) : ( +
+ {data.bestSellers.map((item, index) => ( +
+
+
+ + #{index + 1} + +
+

+ {item.productName} +

+

+ {item.quantity} vendidos +

+
+
+
+

+ {formatPrice(item.revenue, data.currency)} +

+

+ {item.quantity} qty +

+
+
+ {index < data.bestSellers.length - 1 && } +
+ ))} +
+ )} +
+
+
+
+ ) +} diff --git a/src/lib/auth.ts b/src/lib/auth.ts index 4960ef99..08b5ff25 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -1,4 +1,7 @@ -import { stripe } from "@better-auth/stripe" +import { + stripe, + type Subscription as StripeSubscription +} from "@better-auth/stripe" import { prismaAdapter } from "better-auth/adapters/prisma" import { createAuthMiddleware } from "better-auth/api" import { betterAuth } from "better-auth/minimal" @@ -54,10 +57,34 @@ function extractEmailFromContext(ctx: Record | undefined) { return found ? String(found).trim().toLowerCase() : null } -// skipcq: JS-0339 -const stripeClient = new Stripe(process.env.STRIPE_SECRET_KEY!, { - apiVersion: "2026-04-22.dahlia" -}) +const stripeSecretKey = process.env.STRIPE_SECRET_KEY +const stripeWebhookSecret = process.env.STRIPE_WEBHOOK_SECRET +const stripeBasicPriceId = process.env.NEXT_PUBLIC_STRIPE_PRICE_BASIC +const stripeProMonthlyPriceId = process.env.NEXT_PUBLIC_STRIPE_PRICE_PRO_MONTHLY +const stripeProYearlyPriceId = process.env.NEXT_PUBLIC_STRIPE_PRICE_PRO_YEARLY + +const stripeClient = stripeSecretKey + ? new Stripe(stripeSecretKey, { + apiVersion: "2026-04-22.dahlia" + }) + : null + +const stripeBillingConfig = + stripeClient && + stripeWebhookSecret && + stripeBasicPriceId && + stripeProMonthlyPriceId && + stripeProYearlyPriceId + ? { + stripeClient, + stripeWebhookSecret, + stripeBasicPriceId, + stripeProMonthlyPriceId, + stripeProYearlyPriceId + } + : null + +export const isStripeBillingConfigured = stripeBillingConfig !== null export const auth = betterAuth({ // Adjust trusted origins for your deployment @@ -236,57 +263,95 @@ export const auth = betterAuth({ }) } }), - // Stripe billing integration via Better Auth plugin (server-side) - stripe({ - // Pass an initialized Stripe client (recommended by the plugin docs) - stripeClient, - // Webhook signing secret for verifying Stripe webhook payloads - // skipcq: JS-0339 - stripeWebhookSecret: process.env.STRIPE_WEBHOOK_SECRET!, - // Create a Stripe customer automatically when users sign up - createCustomerOnSignUp: true, - subscription: { - enabled: true, - plans: [ - { - name: "BASIC", - // skipcq: JS-0339 - priceId: process.env.NEXT_PUBLIC_STRIPE_PRICE_BASIC!, - limits: { - menus: appConfig.menuLimit, - products: appConfig.itemLimit - } - }, - { - name: "PRO", - // skipcq: JS-0339 - priceId: process.env.NEXT_PUBLIC_STRIPE_PRICE_PRO_MONTHLY!, - // skipcq: JS-0339 - annualDiscountPriceId: - process.env.NEXT_PUBLIC_STRIPE_PRICE_PRO_YEARLY!, - limits: { - menus: 100, - products: 1000 - }, - freeTrial: { - days: 30 - } - } - ], - authorizeReference: async ({ user, referenceId }) => { - // Ensure the user is authorized to manage the organization - const member = await prisma.member.findFirst({ - where: { - userId: user.id, - organizationId: referenceId + ...(stripeBillingConfig + ? [ + stripe({ + // Pass an initialized Stripe client (recommended by the plugin docs) + stripeClient: stripeBillingConfig.stripeClient, + // Webhook signing secret for verifying Stripe webhook payloads + stripeWebhookSecret: stripeBillingConfig.stripeWebhookSecret, + // Create a Stripe customer automatically when users sign up + createCustomerOnSignUp: true, + subscription: { + enabled: true, + plans: [ + { + name: "BASIC", + priceId: stripeBillingConfig.stripeBasicPriceId, + limits: { + menus: appConfig.menuLimit, + products: appConfig.itemLimit + } + }, + { + name: "PRO", + priceId: stripeBillingConfig.stripeProMonthlyPriceId, + annualDiscountPriceId: + stripeBillingConfig.stripeProYearlyPriceId, + limits: { + menus: 100, + products: 1000 + }, + freeTrial: { + days: 30 + } + } + ], + authorizeReference: async ({ user, referenceId }) => { + // Ensure the user is authorized to manage the organization + const member = await prisma.member.findFirst({ + where: { + userId: user.id, + organizationId: referenceId + } + }) + + return member?.role === "owner" || member?.role === "admin" + } } }) - - return member?.role === "owner" || member?.role === "admin" - } - } - }) + ] + : []) ] }) +type StripeBillingPortalResponse = { + url: string +} + +type StripeBillingApi = { + listActiveSubscriptions: (options: { + query: { referenceId: string } + headers: HeadersInit + }) => Promise + createBillingPortal: (options: { + body: { referenceId: string; returnUrl: string } + headers: HeadersInit + }) => Promise +} + +export function getStripeBillingApi(): StripeBillingApi | null { + if (!isStripeBillingConfigured) { + return null + } + + const api = auth.api as Record + const listActiveSubscriptions = api.listActiveSubscriptions + const createBillingPortal = api.createBillingPortal + + if ( + typeof listActiveSubscriptions !== "function" || + typeof createBillingPortal !== "function" + ) { + return null + } + + return { + listActiveSubscriptions: + listActiveSubscriptions as StripeBillingApi["listActiveSubscriptions"], + createBillingPortal: + createBillingPortal as StripeBillingApi["createBillingPortal"] + } +} + export type AuthMember = typeof auth.$Infer.Member diff --git a/src/lib/types/sales.ts b/src/lib/types/sales.ts new file mode 100644 index 00000000..b5974c58 --- /dev/null +++ b/src/lib/types/sales.ts @@ -0,0 +1,111 @@ +import { z } from "zod/v4" + +import type { Currency } from "@/lib/currency" + +export const salesOrderTypeValues = ["DINE_IN", "TAKEOUT", "DELIVERY"] as const + +export const salesOrderTypeSchema = z.enum(salesOrderTypeValues) + +export type SalesOrderType = z.infer + +export const salesOrderTypeLabels = { + DINE_IN: "Para aquí", + TAKEOUT: "Para llevar", + DELIVERY: "Entrega" +} as const satisfies Record + +export const salesOrderTypeBadgeVariants = { + DINE_IN: "blue", + TAKEOUT: "indigo", + DELIVERY: "yellow" +} as const satisfies Record + +export const salesOrderTypeOptions = salesOrderTypeValues.map(value => ({ + value, + label: salesOrderTypeLabels[value] +})) + +export const saleCartItemSchema = z.object({ + menuItemId: z.string().min(1), + variantId: z.string().optional(), + quantity: z.number().int().min(1) +}) + +export const completeSaleSchema = z.object({ + orderType: salesOrderTypeSchema.default("DINE_IN"), + items: z.array(saleCartItemSchema).min(1, "Agrega al menos un producto") +}) + +export type SaleCartItemInput = z.infer +export type CompleteSaleInput = z.infer + +export type SalesCatalogVariant = { + id: string + name: string + description: string | null + price: number +} + +export type SalesCatalogProduct = { + id: string + name: string + description: string | null + image: string | null + categoryId: string | null + categoryName: string | null + currency: Currency + price: number + priceLabel: string + variantCount: number + variants: SalesCatalogVariant[] +} + +export type SalesCatalogCategory = { + id: string + name: string + itemCount: number +} + +export type SalesCatalogData = { + categories: SalesCatalogCategory[] + products: SalesCatalogProduct[] + uncategorizedCount: number +} + +export type SalesBestSeller = { + productName: string + quantity: number + revenue: number +} + +export type SalesRecentSale = { + id: string + createdAt: string + orderType: SalesOrderType + items: number + total: number +} + +export type SalesRevenueByOrderType = { + orderType: SalesOrderType + revenue: number + orders: number +} + +export type SalesDashboardData = { + currency: Currency + todayRevenue: number + todayOrders: number + monthRevenue: number + averageTicket: number + bestSellers: SalesBestSeller[] + recentSales: SalesRecentSale[] +} + +export type SalesClosingData = { + currency: Currency + todayRevenue: number + todayOrders: number + bestSellers: SalesBestSeller[] + revenueByOrderType: SalesRevenueByOrderType[] +} diff --git a/src/server/actions/sales/mutations.ts b/src/server/actions/sales/mutations.ts new file mode 100644 index 00000000..ded944fd --- /dev/null +++ b/src/server/actions/sales/mutations.ts @@ -0,0 +1,194 @@ +"use server" + +import { updateTag } from "next/cache" + +import { type Currency } from "@/lib/currency" +import prisma from "@/lib/prisma" +import { authMemberActionClient } from "@/lib/safe-actions" +import { completeSaleSchema } from "@/lib/types/sales" + +function roundMoney(value: number) { + return Math.round((value + Number.EPSILON) * 100) / 100 +} + +export const completeSale = authMemberActionClient + .inputSchema(completeSaleSchema) + .action(async ({ parsedInput, ctx: { member } }) => { + const organizationId = member.organizationId + + if (!organizationId) { + return { + failure: { + reason: "No se pudo obtener la organización actual" + } + } + } + + const itemsByKey = new Map< + string, + { + menuItemId: string + variantId?: string + quantity: number + } + >() + + for (const item of parsedInput.items) { + const key = `${item.menuItemId}:${item.variantId ?? ""}` + const existing = itemsByKey.get(key) + + if (existing) { + existing.quantity += item.quantity + } else { + itemsByKey.set(key, { ...item }) + } + } + + const cartItems = [...itemsByKey.values()] + + if (cartItems.length === 0) { + return { + failure: { + reason: "Agrega al menos un producto" + } + } + } + + const menuItemIds = [...new Set(cartItems.map(item => item.menuItemId))] + + const menuItems = await prisma.menuItem.findMany({ + where: { + id: { + in: menuItemIds + }, + organizationId, + status: "ACTIVE" + }, + include: { + variants: { + orderBy: { + price: "asc" + } + } + } + }) + + if (menuItems.length !== menuItemIds.length) { + return { + failure: { + reason: "Uno o más productos ya no están disponibles" + } + } + } + + const menuItemsById = new Map(menuItems.map(item => [item.id, item])) + + const resolvedItems = cartItems.map(line => { + const menuItem = menuItemsById.get(line.menuItemId) + + if (!menuItem) { + return null + } + + const variants = menuItem.variants + const selectedVariant = line.variantId + ? variants.find(variant => variant.id === line.variantId) + : variants.length === 1 + ? variants[0] + : null + + if (!selectedVariant) { + return null + } + + return { + menuItemId: menuItem.id, + variantId: selectedVariant.id, + productName: menuItem.name, + variantName: selectedVariant.name, + unitPrice: selectedVariant.price, + quantity: line.quantity, + lineTotal: roundMoney(selectedVariant.price * line.quantity), + currency: menuItem.currency as Currency + } + }) + + if (resolvedItems.some(item => !item)) { + return { + failure: { + reason: "No se pudo preparar uno o más productos" + } + } + } + + const salesItems = resolvedItems as Array<{ + menuItemId: string + variantId: string + productName: string + variantName: string + unitPrice: number + quantity: number + lineTotal: number + currency: Currency + }> + + const saleCurrency = salesItems[0]?.currency + if (!saleCurrency) { + return { + failure: { + reason: "No se pudo determinar la moneda de la venta" + } + } + } + + if (salesItems.some(item => item.currency !== saleCurrency)) { + return { + failure: { + reason: "No se pueden mezclar monedas en una sola venta" + } + } + } + + const subtotal = roundMoney( + salesItems.reduce((sum, item) => sum + item.lineTotal, 0) + ) + const total = subtotal + const itemCount = salesItems.reduce((sum, item) => sum + item.quantity, 0) + + const sale = await prisma.$transaction(async tx => { + return await tx.sale.create({ + data: { + organizationId, + orderType: parsedInput.orderType, + currency: saleCurrency, + subtotal, + total, + items: { + create: salesItems.map(item => ({ + menuItemId: item.menuItemId, + variantId: item.variantId, + productName: item.productName, + variantName: item.variantName, + unitPrice: item.unitPrice, + quantity: item.quantity, + lineTotal: item.lineTotal + })) + } + }, + include: { + items: true + } + }) + }) + + updateTag(`sales-${organizationId}`) + + return { + success: { + id: sale.id, + orderType: sale.orderType, + total: sale.total, + itemCount + } + } + }) diff --git a/src/server/actions/sales/queries.ts b/src/server/actions/sales/queries.ts new file mode 100644 index 00000000..d82b4acc --- /dev/null +++ b/src/server/actions/sales/queries.ts @@ -0,0 +1,505 @@ +"use server" + +import { cacheLife, cacheTag } from "next/cache" + +import { formatPriceRange, type Currency } from "@/lib/currency" +import prisma from "@/lib/prisma" +import { + salesOrderTypeValues, + type SalesBestSeller, + type SalesCatalogCategory, + type SalesCatalogData, + type SalesCatalogProduct, + type SalesClosingData, + type SalesDashboardData, + type SalesOrderType, + type SalesRecentSale, + type SalesRevenueByOrderType +} from "@/lib/types/sales" +import { getCacheBustedImageUrl } from "@/lib/utils" + +function roundMoney(value: number) { + return Math.round((value + Number.EPSILON) * 100) / 100 +} + +function startOfDay(date = new Date()) { + const next = new Date(date) + next.setHours(0, 0, 0, 0) + return next +} + +function startOfNextDay(date = new Date()) { + const next = startOfDay(date) + next.setDate(next.getDate() + 1) + return next +} + +function startOfMonth(date = new Date()) { + const next = new Date(date) + next.setDate(1) + next.setHours(0, 0, 0, 0) + return next +} + +function startOfNextMonth(date = new Date()) { + const next = startOfMonth(date) + next.setMonth(next.getMonth() + 1) + return next +} + +function mapCatalogProduct({ + item, + categoryName +}: { + item: { + id: string + name: string + description: string | null + image: string | null + currency: Currency + categoryId: string | null + updatedAt: Date + variants: Array<{ + id: string + name: string + description: string | null + price: number + }> + } + categoryName: string | null +}): SalesCatalogProduct { + const variants = [...item.variants].sort((a, b) => a.price - b.price) + const minPrice = variants[0]?.price ?? 0 + const maxPrice = variants[variants.length - 1]?.price ?? minPrice + + return { + id: item.id, + name: item.name, + description: item.description, + image: item.image + ? getCacheBustedImageUrl(item.image, item.updatedAt) + : null, + categoryId: item.categoryId, + categoryName, + currency: item.currency, + price: minPrice, + priceLabel: + variants.length > 0 + ? formatPriceRange(minPrice, maxPrice, item.currency) + : "Sin precio", + variantCount: variants.length, + variants: variants.map(variant => ({ + id: variant.id, + name: variant.name, + description: variant.description, + price: variant.price + })) + } +} + +async function getOrganizationCurrency( + organizationId: string +): Promise { + const latestSale = await prisma.sale.findFirst({ + where: { + organizationId + }, + orderBy: { + createdAt: "desc" + }, + select: { + currency: true + } + }) + + if (latestSale?.currency) { + return latestSale.currency + } + + const defaultLocation = await prisma.location.findFirst({ + where: { + organizationId + }, + orderBy: { + createdAt: "asc" + }, + select: { + currency: true + } + }) + + return defaultLocation?.currency ?? "MXN" +} + +async function getSalesTotals( + organizationId: string, + startDate: Date, + endDate: Date +) { + const [totals, saleCount] = await Promise.all([ + prisma.sale.aggregate({ + where: { + organizationId, + createdAt: { + gte: startDate, + lt: endDate + } + }, + _sum: { + total: true + } + }), + prisma.sale.count({ + where: { + organizationId, + createdAt: { + gte: startDate, + lt: endDate + } + } + }) + ]) + + return { + revenue: roundMoney(totals._sum.total ?? 0), + orders: saleCount + } +} + +async function getBestSellers( + organizationId: string, + startDate: Date, + endDate: Date +): Promise { + const rows = await prisma.saleItem.findMany({ + where: { + sale: { + organizationId, + createdAt: { + gte: startDate, + lt: endDate + } + } + }, + select: { + productName: true, + quantity: true, + lineTotal: true + } + }) + + const products = new Map< + string, + { + productName: string + quantity: number + revenue: number + } + >() + + for (const row of rows) { + const current = products.get(row.productName) ?? { + productName: row.productName, + quantity: 0, + revenue: 0 + } + + current.quantity += row.quantity + current.revenue += row.lineTotal + products.set(row.productName, current) + } + + return [...products.values()] + .sort((a, b) => { + if (b.quantity !== a.quantity) return b.quantity - a.quantity + if (b.revenue !== a.revenue) return b.revenue - a.revenue + return a.productName.localeCompare(b.productName) + }) + .slice(0, 10) + .map(product => ({ + productName: product.productName, + quantity: product.quantity, + revenue: roundMoney(product.revenue) + })) +} + +async function getRecentSales( + organizationId: string +): Promise { + const sales = await prisma.sale.findMany({ + where: { + organizationId + }, + orderBy: { + createdAt: "desc" + }, + take: 25, + select: { + id: true, + createdAt: true, + orderType: true, + total: true, + items: { + select: { + quantity: true + } + } + } + }) + + return sales.map(sale => ({ + id: sale.id, + createdAt: sale.createdAt.toISOString(), + orderType: sale.orderType as SalesOrderType, + total: roundMoney(sale.total), + items: sale.items.reduce((count, item) => count + item.quantity, 0) + })) +} + +async function getRevenueByOrderType( + organizationId: string, + startDate: Date, + endDate: Date +): Promise { + const rows = await prisma.sale.groupBy({ + by: ["orderType"], + where: { + organizationId, + createdAt: { + gte: startDate, + lt: endDate + } + }, + _sum: { + total: true + }, + _count: { + id: true + } + }) + + return salesOrderTypeValues.map(orderType => { + const row = rows.find(entry => entry.orderType === orderType) + + return { + orderType, + revenue: roundMoney(row?._sum.total ?? 0), + orders: row?._count.id ?? 0 + } + }) +} + +export async function getSalesCatalog( + organizationId: string +): Promise { + "use cache: private" + cacheLife({ stale: 60 }) + + if (!organizationId) { + return { + categories: [], + products: [], + uncategorizedCount: 0 + } + } + + cacheTag(`menu-items-${organizationId}`) + cacheTag(`categories-${organizationId}`) + + const [categories, uncategorized] = await Promise.all([ + prisma.category.findMany({ + where: { + organizationId, + menuItems: { + some: { + status: "ACTIVE" + } + } + }, + orderBy: { + name: "asc" + }, + include: { + menuItems: { + where: { + status: "ACTIVE" + }, + orderBy: { + name: "asc" + }, + include: { + variants: { + orderBy: { + price: "asc" + } + } + } + } + } + }), + prisma.menuItem.findMany({ + where: { + organizationId, + categoryId: null, + status: "ACTIVE" + }, + orderBy: { + name: "asc" + }, + include: { + variants: { + orderBy: { + price: "asc" + } + } + } + }) + ]) + + const catalogCategories: SalesCatalogCategory[] = categories.map( + category => ({ + id: category.id, + name: category.name, + itemCount: category.menuItems.length + }) + ) + + const products = [ + ...categories.flatMap(category => + category.menuItems.map(item => + mapCatalogProduct({ + item: { + id: item.id, + name: item.name, + description: item.description ?? null, + image: item.image ?? null, + currency: item.currency, + categoryId: item.categoryId ?? null, + updatedAt: item.updatedAt, + variants: item.variants.map(variant => ({ + id: variant.id, + name: variant.name, + description: variant.description ?? null, + price: variant.price + })) + }, + categoryName: category.name + }) + ) + ), + ...uncategorized.map(item => + mapCatalogProduct({ + item: { + id: item.id, + name: item.name, + description: item.description ?? null, + image: item.image ?? null, + currency: item.currency, + categoryId: item.categoryId ?? null, + updatedAt: item.updatedAt, + variants: item.variants.map(variant => ({ + id: variant.id, + name: variant.name, + description: variant.description ?? null, + price: variant.price + })) + }, + categoryName: null + }) + ) + ] + + return { + categories: catalogCategories, + products, + uncategorizedCount: uncategorized.length + } +} + +export async function getSalesDashboardData( + organizationId: string +): Promise { + "use cache: private" + cacheLife({ stale: 30 }) + + if (!organizationId) { + return { + currency: "MXN", + todayRevenue: 0, + todayOrders: 0, + monthRevenue: 0, + averageTicket: 0, + bestSellers: [], + recentSales: [] + } + } + + cacheTag(`sales-${organizationId}`) + + const now = new Date() + const todayStart = startOfDay(now) + const tomorrowStart = startOfNextDay(now) + const monthStart = startOfMonth(now) + const nextMonthStart = startOfNextMonth(now) + + const [currency, todayTotals, monthTotals, bestSellers, recentSales] = + await Promise.all([ + getOrganizationCurrency(organizationId), + getSalesTotals(organizationId, todayStart, tomorrowStart), + getSalesTotals(organizationId, monthStart, nextMonthStart), + getBestSellers(organizationId, monthStart, nextMonthStart), + getRecentSales(organizationId) + ]) + + return { + currency, + todayRevenue: todayTotals.revenue, + todayOrders: todayTotals.orders, + monthRevenue: monthTotals.revenue, + averageTicket: + todayTotals.orders > 0 + ? roundMoney(todayTotals.revenue / todayTotals.orders) + : 0, + bestSellers, + recentSales + } +} + +export async function getSalesClosingData( + organizationId: string +): Promise { + "use cache: private" + cacheLife({ stale: 30 }) + + if (!organizationId) { + return { + currency: "MXN", + todayRevenue: 0, + todayOrders: 0, + bestSellers: [], + revenueByOrderType: [] + } + } + + cacheTag(`sales-${organizationId}`) + + const now = new Date() + const todayStart = startOfDay(now) + const tomorrowStart = startOfNextDay(now) + + const [currency, todayTotals, bestSellers, revenueByOrderType] = + await Promise.all([ + getOrganizationCurrency(organizationId), + getSalesTotals(organizationId, todayStart, tomorrowStart), + getBestSellers(organizationId, todayStart, tomorrowStart), + getRevenueByOrderType(organizationId, todayStart, tomorrowStart) + ]) + + return { + currency, + todayRevenue: todayTotals.revenue, + todayOrders: todayTotals.orders, + bestSellers, + revenueByOrderType + } +} diff --git a/src/server/actions/subscriptions/mutations.ts b/src/server/actions/subscriptions/mutations.ts index 4caab4eb..a3c037df 100644 --- a/src/server/actions/subscriptions/mutations.ts +++ b/src/server/actions/subscriptions/mutations.ts @@ -4,12 +4,18 @@ import * as Sentry from "@sentry/nextjs" import { updateTag } from "next/cache" import { headers } from "next/headers" -import { auth } from "@/lib/auth" +import { getStripeBillingApi } from "@/lib/auth" import { getBaseUrl } from "@/lib/utils" export const createStripePortal = async (referenceId: string) => { try { - const data = await auth.api.createBillingPortal({ + const stripeBillingApi = getStripeBillingApi() + + if (!stripeBillingApi) { + return null + } + + const data = await stripeBillingApi.createBillingPortal({ body: { referenceId, returnUrl: `${getBaseUrl()}/dashboard/settings/billing` diff --git a/src/server/actions/subscriptions/queries.ts b/src/server/actions/subscriptions/queries.ts index 21cdbd2d..8820717d 100644 --- a/src/server/actions/subscriptions/queries.ts +++ b/src/server/actions/subscriptions/queries.ts @@ -1,7 +1,8 @@ +import * as Sentry from "@sentry/nextjs" import { cacheLife, cacheTag } from "next/cache" import { headers } from "next/headers" -import { auth } from "@/lib/auth" +import { getStripeBillingApi } from "@/lib/auth" // Get the current active subscription in the organization export const getCurrentSubscription = async (organizationId: string) => { @@ -9,16 +10,34 @@ export const getCurrentSubscription = async (organizationId: string) => { cacheTag(`organization-${organizationId}-subscription`) cacheLife({ stale: 60 }) - const subscriptions = await auth.api.listActiveSubscriptions({ - query: { - referenceId: organizationId - }, - headers: await headers() - }) + const stripeBillingApi = getStripeBillingApi() - const activeSubscription = subscriptions.find( - sub => sub.status === "active" || sub.status === "trialing" - ) + if (!stripeBillingApi) { + return null + } - return activeSubscription + try { + const subscriptions = await stripeBillingApi.listActiveSubscriptions({ + query: { + referenceId: organizationId + }, + headers: await headers() + }) + + const activeSubscription = subscriptions.find( + sub => sub.status === "active" || sub.status === "trialing" + ) + + return activeSubscription + } catch (error) { + console.error("Failed to get current subscription", error) + Sentry.captureException(error, { + tags: { + section: "subscription-queries", + operation: "getCurrentSubscription" + }, + extra: { organizationId } + }) + return null + } } diff --git a/src/server/actions/user/queries.ts b/src/server/actions/user/queries.ts index 34151e34..67318de1 100644 --- a/src/server/actions/user/queries.ts +++ b/src/server/actions/user/queries.ts @@ -5,7 +5,7 @@ import * as Sentry from "@sentry/nextjs" import { cacheLife, cacheTag } from "next/cache" import { headers } from "next/headers" -import { auth } from "@/lib/auth" +import { auth, getStripeBillingApi } from "@/lib/auth" import prisma from "@/lib/prisma" import { SubscriptionStatus } from "@/lib/types/billing" import { getCacheBustedImageUrl } from "@/lib/utils" @@ -199,10 +199,33 @@ export async function isProMember() { cacheTag(`organization-${org.id}-subscription`) - const subscriptions = await auth.api.listActiveSubscriptions({ - query: { referenceId: org?.id }, - headers: await headers() - }) + const hasStoredProAccess = + org.status === SubscriptionStatus.SPONSORED || + (org.plan?.toUpperCase() === "PRO" && + (org.status === SubscriptionStatus.ACTIVE || + org.status === SubscriptionStatus.TRIALING)) + + const stripeBillingApi = getStripeBillingApi() + + if (!stripeBillingApi) { + return hasStoredProAccess + } + + let subscriptions + + try { + subscriptions = await stripeBillingApi.listActiveSubscriptions({ + query: { referenceId: org.id }, + headers: await headers() + }) + } catch (error) { + console.error("Failed to list active subscriptions", error) + Sentry.captureException(error, { + tags: { section: "user-queries", operation: "listActiveSubscriptions" }, + extra: { organizationId: org.id } + }) + return hasStoredProAccess + } const activeSubscription = subscriptions.find( sub => sub.status === "active" || sub.status === "trialing" From 7a65355746559c86b85946574a6f1f405a7f0530 Mon Sep 17 00:00:00 2001 From: Daniel Castillejo Date: Sat, 20 Jun 2026 22:52:10 -0500 Subject: [PATCH 02/18] feat: enhance QuickSaleScreen with improved cart handling and UI updates --- .gitignore | 3 + src/app/dashboard/sales/new/page.tsx | 20 -- src/components/sales/quick-sale-screen.tsx | 358 +++++++++++---------- 3 files changed, 197 insertions(+), 184 deletions(-) diff --git a/.gitignore b/.gitignore index e1dbf397..2cbb52f2 100644 --- a/.gitignore +++ b/.gitignore @@ -55,3 +55,6 @@ yarn-error.log* # prisma generated client generated + +# playwright +.playwright-mcp diff --git a/src/app/dashboard/sales/new/page.tsx b/src/app/dashboard/sales/new/page.tsx index 94e1eed7..4e096144 100644 --- a/src/app/dashboard/sales/new/page.tsx +++ b/src/app/dashboard/sales/new/page.tsx @@ -1,11 +1,7 @@ -import { ArrowLeft, ShoppingBag } from "lucide-react" import type { Metadata } from "next" -import Link from "next/link" import { notFound } from "next/navigation" -import PageSubtitle from "@/components/dashboard/page-subtitle" import { QuickSaleScreen } from "@/components/sales/quick-sale-screen" -import { Button } from "@/components/ui/button" import { getSalesCatalog } from "@/server/actions/sales/queries" import { getCurrentOrganization } from "@/server/actions/user/queries" @@ -26,22 +22,6 @@ export default async function NewSalePage() {
- - - Nueva venta - - Captura rápida para mostrador e iPad - - - - - -
) diff --git a/src/components/sales/quick-sale-screen.tsx b/src/components/sales/quick-sale-screen.tsx index a97a4dcf..07731873 100644 --- a/src/components/sales/quick-sale-screen.tsx +++ b/src/components/sales/quick-sale-screen.tsx @@ -1,16 +1,15 @@ "use client" -import { useDeferredValue, useMemo, useState } from "react" -import toast from "react-hot-toast" import { - Loader2, - Minus, - Plus, - Search, - ShoppingBag, - Trash2, - X -} from "lucide-react" + useDeferredValue, + useLayoutEffect, + useMemo, + useRef, + useState +} from "react" +import toast from "react-hot-toast" +import { Loader2, Minus, Plus, Search, ShoppingBag, Trash2 } from "lucide-react" +import { AnimatePresence, motion } from "motion/react" import { useAction } from "next-safe-action/hooks" import Image from "next/image" import { useRouter } from "next/navigation" @@ -68,6 +67,7 @@ export function QuickSaleScreen({ catalog }: { catalog: SalesCatalogData }) { const [selectedCategory, setSelectedCategory] = useState("all") const [orderType, setOrderType] = useState("DINE_IN") const [cart, setCart] = useState([]) + const [cartScrollSignal, setCartScrollSignal] = useState(0) const [selectedProduct, setSelectedProduct] = useState(null) @@ -196,6 +196,8 @@ export function QuickSaleScreen({ catalog }: { catalog: SalesCatalogData }) { } ] }) + + setCartScrollSignal(signal => signal + 1) } const handleProductSelect = (product: SalesCatalogProduct) => { @@ -253,43 +255,41 @@ export function QuickSaleScreen({ catalog }: { catalog: SalesCatalogData }) { <>
- - -
-
- - setSearch(event.target.value)} - placeholder="Buscar producto" - className="h-11 pl-10 text-base" - /> -
-
- - {filteredProducts.length} productos - - {cart.length} líneas -
+
+
+
+ + setSearch(event.target.value)} + placeholder="Buscar producto" + className="h-11 pl-10 text-base" + /> +
+
+ + {filteredProducts.length} productos + + {cart.length} líneas
+
- - - + +
-
- Venta actual -

- {cart.length === 0 - ? "Lista para empezar" - : `${cart.length} líneas · ${totalItems} artículos`} -

-
+ Venta actual + + +

+ {product.name} +

+

+ {product.priceLabel} +

+
+ ) } @@ -599,15 +588,15 @@ function OrderTypeSelector({ onValueChange: (value: SalesOrderType) => void }) { return ( -
-

Tipo de orden

+
+

Tipo de orden

{ if (next) onValueChange(next as SalesOrderType) }} - className="grid grid-cols-3 gap-2" + className="flex gap-1.5" variant="outline" size="sm" > @@ -615,7 +604,7 @@ function OrderTypeSelector({ {option.label} @@ -628,19 +617,58 @@ function OrderTypeSelector({ function SaleCart({ cart, currency, + scrollToBottomSignal, onIncrease, - onDecrease, - onRemove + onDecrease }: { cart: CartLine[] currency: "MXN" | "USD" + scrollToBottomSignal: number onIncrease: (key: string) => void onDecrease: (key: string) => void - onRemove: (key: string) => void }) { + const scrollContainerRef = useRef(null) + const scrollResetTimeoutRef = useRef(null) + + useLayoutEffect(() => { + const scrollContainer = scrollContainerRef.current + if (!scrollContainer) return + + if (scrollContainer.scrollHeight <= scrollContainer.clientHeight) return + + const scrollToBottom = () => { + scrollContainer.scrollTop = scrollContainer.scrollHeight + } + + scrollToBottom() + + if (scrollResetTimeoutRef.current !== null) { + window.clearTimeout(scrollResetTimeoutRef.current) + } + + scrollResetTimeoutRef.current = window.setTimeout(scrollToBottom, 220) + + return () => { + if (scrollResetTimeoutRef.current === null) return + + window.clearTimeout(scrollResetTimeoutRef.current) + scrollResetTimeoutRef.current = null + } + }, [scrollToBottomSignal]) + if (cart.length === 0) { return ( -
+ @@ -652,73 +680,76 @@ function SaleCart({ -
+ ) } return ( -
- {cart.map(item => ( -
-
-
-

{item.productName}

- {item.variantName && ( -

- {item.variantName} +

+ + {cart.map(item => ( + +
+
+

+ {item.productName}

- )} -

- {formatPrice(item.unitPrice, currency)} c/u -

-
-
-

- {formatPrice(item.lineTotal, currency)} -

- -
-
+ {item.variantName && ( +

+ {item.variantName} +

+ )} +
-
-
- -
- {item.quantity} +
+
+ +
+ {item.quantity} +
+ +
+

+ {formatPrice(item.lineTotal, currency)} +

-
-

- {formatPrice(item.lineTotal, currency)} -

-
-
- ))} + + ))} +
) } @@ -741,18 +772,17 @@ function SaleSummary({ {formatPrice(subtotal, currency)}
- Items + Productos {itemCount}

Total

-

- Sin impuestos, propinas ni descuentos -

-

{formatPrice(total, currency)}

+

+ {formatPrice(total, currency)} +

) From 3afdbbc528ad34948fea01c0834d4b8449181b9b Mon Sep 17 00:00:00 2001 From: Daniel Castillejo Date: Sun, 21 Jun 2026 01:29:09 -0500 Subject: [PATCH 03/18] feat: update navigation titles and enhance QuickSaleScreen with improved input handling and cart integration --- src/components/dashboard/app-sidebar.tsx | 4 +- src/components/sales/quick-sale-screen.tsx | 143 ++++++++++++--------- 2 files changed, 84 insertions(+), 63 deletions(-) diff --git a/src/components/dashboard/app-sidebar.tsx b/src/components/dashboard/app-sidebar.tsx index 03a35787..0056d862 100644 --- a/src/components/dashboard/app-sidebar.tsx +++ b/src/components/dashboard/app-sidebar.tsx @@ -90,8 +90,8 @@ const navigation: NavigationItem[] = [ url: "/dashboard/sales", icon: Banknote, items: [ - { title: "Resumen", url: "/dashboard/sales" }, - { title: "Nueva venta", url: "/dashboard/sales/new" }, + { title: "Punto de venta", url: "/dashboard/sales/new" }, + { title: "Analítica", url: "/dashboard/sales" }, { title: "Cierre diario", url: "/dashboard/sales/closing" } ] }, diff --git a/src/components/sales/quick-sale-screen.tsx b/src/components/sales/quick-sale-screen.tsx index 07731873..3aae508c 100644 --- a/src/components/sales/quick-sale-screen.tsx +++ b/src/components/sales/quick-sale-screen.tsx @@ -25,7 +25,11 @@ import { EmptyMedia, EmptyTitle } from "@/components/ui/empty" -import { Input } from "@/components/ui/input" +import { + InputGroup, + InputGroupAddon, + InputGroupInput +} from "@/components/ui/input-group" import { Separator } from "@/components/ui/separator" import { Sheet, @@ -149,6 +153,11 @@ export function QuickSaleScreen({ catalog }: { catalog: SalesCatalogData }) { [cart] ) + const cartProductIds = useMemo( + () => new Set(cart.map(item => item.menuItemId)), + [cart] + ) + const addProduct = (product: SalesCatalogProduct, variantId?: string) => { const selectedVariant = variantId ? product.variants.find(variant => variant.id === variantId) @@ -260,27 +269,27 @@ export function QuickSaleScreen({ catalog }: { catalog: SalesCatalogData }) {
-
- - + setSearch(event.target.value)} placeholder="Buscar producto" - className="h-11 pl-10 text-base" + inputMode="search" + enterKeyHint="search" + className="h-11 text-base" /> -
+ + + +
- + {filteredProducts.length} productos - {cart.length} líneas + {cart.length} líneas
@@ -293,6 +302,7 @@ export function QuickSaleScreen({ catalog }: { catalog: SalesCatalogData }) {
@@ -469,7 +479,10 @@ function CategoryFilter({ {option.label} @@ -481,9 +494,11 @@ function CategoryFilter({ function ProductGrid({ products, + cartProductIds, onSelect }: { products: SalesCatalogProduct[] + cartProductIds: Set onSelect: (product: SalesCatalogProduct) => void }) { if (products.length === 0) { @@ -510,7 +525,12 @@ function ProductGrid({ return (
{products.map(product => ( - + ))}
) @@ -518,65 +538,66 @@ function ProductGrid({ function ProductCard({ product, + isInCart, onSelect }: { product: SalesCatalogProduct + isInCart: boolean onSelect: (product: SalesCatalogProduct) => void }) { return ( - onSelect(product)} - onKeyDown={event => { - if (event.key === "Enter" || event.key === " ") { - event.preventDefault() - onSelect(product) - } - }} className={cn( - "group cursor-pointer overflow-hidden text-left", - `hover:border-foreground/20 hover:bg-accent/40 transition-colors - focus-visible:outline-none`, - `focus-visible:ring-ring focus-visible:ring-2 - focus-visible:ring-offset-2` + "group block w-full cursor-pointer text-left focus-visible:outline-none" )} > -
- {product.image ? ( - {product.name} - ) : ( -
- -
- )} - {product.variantCount > 1 && ( -
- - {product.variantCount} opciones - -
+ + > +
+ {product.image ? ( + {product.name} + ) : ( +
+ +
+ )} + {product.variantCount > 1 && ( +
+ + {product.variantCount} opciones + +
+ )} +
- -

- {product.name} -

-

- {product.priceLabel} -

-
-
+ +

+ {product.name} +

+

+ {product.priceLabel} +

+
+ + ) } From b771770e8f4afa8d207da26215d317ede16785af Mon Sep 17 00:00:00 2001 From: Daniel Castillejo Date: Sun, 21 Jun 2026 02:51:53 -0500 Subject: [PATCH 04/18] feat: add new number input component and integrate into quick sale screen --- bun.lock | 170 +++++++++++++++++- components.json | 3 +- package.json | 4 +- .../menu-items/[action]/[id]/variant-form.tsx | 4 +- src/components/sales/quick-sale-screen.tsx | 163 ++++++++++++----- src/components/ui/card.tsx | 3 +- src/components/ui/number-input.tsx | 165 +++++++++++++++++ styles/globals.css | 18 ++ 8 files changed, 479 insertions(+), 51 deletions(-) create mode 100644 src/components/ui/number-input.tsx diff --git a/bun.lock b/bun.lock index d41468e6..5494b086 100644 --- a/bun.lock +++ b/bun.lock @@ -5,6 +5,7 @@ "": { "name": "biztro", "dependencies": { + "@ark-ui/react": "^5.37.2", "@aws-sdk/client-s3": "^3.1071.0", "@aws-sdk/s3-request-presigner": "^3.1071.0", "@better-auth/stripe": "1.6.9", @@ -76,7 +77,7 @@ "lodash.isequal": "^4.5.0", "lodash.isobject": "^3.0.2", "lodash.transform": "^4.6.0", - "lucide-react": "^1.20.0", + "lucide-react": "^1.21.0", "lzutf8": "^0.6.3", "motion": "^12.40.0", "nanoid": "^5.1.11", @@ -112,6 +113,7 @@ "stripe": "^22.1.0", "styled-components": "^6.4.2", "tailwind-merge": "^3.6.0", + "tailwind-variants": "^3.2.2", "tailwindcss-animate": "^1.0.7", "torph": "^0.0.9", "vaul": "^1.1.2", @@ -179,6 +181,8 @@ "@alloc/quick-lru": ["@alloc/quick-lru@5.2.0", "", {}, "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw=="], + "@ark-ui/react": ["@ark-ui/react@5.37.2", "", { "dependencies": { "@internationalized/date": "3.12.2", "@zag-js/accordion": "1.41.2", "@zag-js/anatomy": "1.41.2", "@zag-js/angle-slider": "1.41.2", "@zag-js/async-list": "1.41.2", "@zag-js/auto-resize": "1.41.2", "@zag-js/avatar": "1.41.2", "@zag-js/carousel": "1.41.2", "@zag-js/cascade-select": "1.41.2", "@zag-js/checkbox": "1.41.2", "@zag-js/clipboard": "1.41.2", "@zag-js/collapsible": "1.41.2", "@zag-js/collection": "1.41.2", "@zag-js/color-picker": "1.41.2", "@zag-js/color-utils": "1.41.2", "@zag-js/combobox": "1.41.2", "@zag-js/core": "1.41.2", "@zag-js/date-input": "1.41.2", "@zag-js/date-picker": "1.41.2", "@zag-js/date-utils": "1.41.2", "@zag-js/dialog": "1.41.2", "@zag-js/dom-query": "1.41.2", "@zag-js/drawer": "1.41.2", "@zag-js/editable": "1.41.2", "@zag-js/file-upload": "1.41.2", "@zag-js/file-utils": "1.41.2", "@zag-js/floating-panel": "1.41.2", "@zag-js/focus-trap": "1.41.2", "@zag-js/focus-visible": "1.41.2", "@zag-js/highlight-word": "1.41.2", "@zag-js/hover-card": "1.41.2", "@zag-js/i18n-utils": "1.41.2", "@zag-js/image-cropper": "1.41.2", "@zag-js/json-tree-utils": "1.41.2", "@zag-js/listbox": "1.41.2", "@zag-js/marquee": "1.41.2", "@zag-js/menu": "1.41.2", "@zag-js/navigation-menu": "1.41.2", "@zag-js/number-input": "1.41.2", "@zag-js/pagination": "1.41.2", "@zag-js/password-input": "1.41.2", "@zag-js/pin-input": "1.41.2", "@zag-js/popover": "1.41.2", "@zag-js/presence": "1.41.2", "@zag-js/progress": "1.41.2", "@zag-js/qr-code": "1.41.2", "@zag-js/radio-group": "1.41.2", "@zag-js/rating-group": "1.41.2", "@zag-js/react": "1.41.2", "@zag-js/scroll-area": "1.41.2", "@zag-js/select": "1.41.2", "@zag-js/signature-pad": "1.41.2", "@zag-js/slider": "1.41.2", "@zag-js/splitter": "1.41.2", "@zag-js/steps": "1.41.2", "@zag-js/switch": "1.41.2", "@zag-js/tabs": "1.41.2", "@zag-js/tags-input": "1.41.2", "@zag-js/timer": "1.41.2", "@zag-js/toast": "1.41.2", "@zag-js/toggle": "1.41.2", "@zag-js/toggle-group": "1.41.2", "@zag-js/tooltip": "1.41.2", "@zag-js/tour": "1.41.2", "@zag-js/tree-view": "1.41.2", "@zag-js/types": "1.41.2", "@zag-js/utils": "1.41.2" }, "peerDependencies": { "react": ">=18.0.0", "react-dom": ">=18.0.0" } }, "sha512-Q0R2Ah50kUhup0Ljxg65zGJq5yBV52BLm1coRkjHHid40d1yclaDGfhPL48kcF/xtjAFlGLkL6SiENkGvfh+mw=="], + "@aws-crypto/crc32": ["@aws-crypto/crc32@5.2.0", "", { "dependencies": { "@aws-crypto/util": "^5.2.0", "@aws-sdk/types": "^3.222.0", "tslib": "^2.6.2" } }, "sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg=="], "@aws-crypto/crc32c": ["@aws-crypto/crc32c@5.2.0", "", { "dependencies": { "@aws-crypto/util": "^5.2.0", "@aws-sdk/types": "^3.222.0", "tslib": "^2.6.2" } }, "sha512-+iWb8qaHLYKrNvGRbiYRHSdKRWhto5XlZUEBwDjYNf+ly5SVYG6zEoYIdxvf5R3zyeP16w4PLBn3rH1xc74Rag=="], @@ -1423,6 +1427,156 @@ "@xtuc/long": ["@xtuc/long@4.2.2", "", {}, "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ=="], + "@zag-js/accordion": ["@zag-js/accordion@1.41.2", "", { "dependencies": { "@zag-js/anatomy": "1.41.2", "@zag-js/core": "1.41.2", "@zag-js/dom-query": "1.41.2", "@zag-js/types": "1.41.2", "@zag-js/utils": "1.41.2" } }, "sha512-7G//V7svGGT8k5avw7bbQvbRC0Q/9QtX51b4iyAB1alR9E5mFd6Ch8q4njwcXClMQ7xePS3jUfVnzVGiRInEiQ=="], + + "@zag-js/anatomy": ["@zag-js/anatomy@1.41.2", "", {}, "sha512-Fm9hqdrvaCzCsdcf19G8WZxYtHElKltkGHdhqMEt4XU+ULTr1DK7KbOtDDv9J27CuzqSLALUz5QfRjPftoKHwg=="], + + "@zag-js/angle-slider": ["@zag-js/angle-slider@1.41.2", "", { "dependencies": { "@zag-js/anatomy": "1.41.2", "@zag-js/core": "1.41.2", "@zag-js/dom-query": "1.41.2", "@zag-js/rect-utils": "1.41.2", "@zag-js/types": "1.41.2", "@zag-js/utils": "1.41.2" } }, "sha512-+7bZHAZx0MEbjTMr2tD+meFJ0EJwFfUEcTqmdLzFGr/ySAMCWlcadDBz+ZmrSn03aKLps8FxliVLzsFJNgUqIQ=="], + + "@zag-js/aria-hidden": ["@zag-js/aria-hidden@1.41.2", "", { "dependencies": { "@zag-js/dom-query": "1.41.2" } }, "sha512-qEcYmwlQr3qjA0T/IZ5a/o7fRUxfQ14tXjAFhR3GXCtxBKaqS+wnq/LN09Xw4bin3QWTINU+Z0oXFs9spWtNwA=="], + + "@zag-js/async-list": ["@zag-js/async-list@1.41.2", "", { "dependencies": { "@zag-js/core": "1.41.2", "@zag-js/utils": "1.41.2" } }, "sha512-NZZEIGFdeDp2uHjsLVegLAGJOYGwI9HPJI1V2c/P1TQmfmrfWyWELAvnnW4kWYVUKYD9TxKQkm6LvqpHrQzgfQ=="], + + "@zag-js/auto-resize": ["@zag-js/auto-resize@1.41.2", "", { "dependencies": { "@zag-js/dom-query": "1.41.2" } }, "sha512-CYq+JQ1TTkEiK7OcNcMTS0f4wFvtmManvUltije5o50gDQ7vFYA81oQh4A9pAB1keUi9Zv06CNefFARaTcne5w=="], + + "@zag-js/avatar": ["@zag-js/avatar@1.41.2", "", { "dependencies": { "@zag-js/anatomy": "1.41.2", "@zag-js/core": "1.41.2", "@zag-js/dom-query": "1.41.2", "@zag-js/types": "1.41.2", "@zag-js/utils": "1.41.2" } }, "sha512-+4K0tRtIQysMGuERh5JRmn46uq7gJ6IjJd5DKj74VBtVVY8T6HGk0D0DZxmOIgADHP1qSvowLDoOX8kUyBHarQ=="], + + "@zag-js/carousel": ["@zag-js/carousel@1.41.2", "", { "dependencies": { "@zag-js/anatomy": "1.41.2", "@zag-js/core": "1.41.2", "@zag-js/dom-query": "1.41.2", "@zag-js/scroll-snap": "1.41.2", "@zag-js/types": "1.41.2", "@zag-js/utils": "1.41.2" } }, "sha512-H6sDxyWIzkvtHN4M/BYvFy7pi8j+kLEtfPZvgNl2+gRnfAHVK9/XqpoUsjw1DAo5Fh25pPuaTnh52Kml9OK0iA=="], + + "@zag-js/cascade-select": ["@zag-js/cascade-select@1.41.2", "", { "dependencies": { "@zag-js/anatomy": "1.41.2", "@zag-js/collection": "1.41.2", "@zag-js/core": "1.41.2", "@zag-js/dismissable": "1.41.2", "@zag-js/dom-query": "1.41.2", "@zag-js/focus-visible": "1.41.2", "@zag-js/popper": "1.41.2", "@zag-js/rect-utils": "1.41.2", "@zag-js/types": "1.41.2", "@zag-js/utils": "1.41.2" } }, "sha512-dBByZmIAJU/B+YIAzczng05lCJXEwEm4df6GmUZtioKHZdeN+WEKUP4qzFDFZdytXympGfJCIBvtgf87gACYVQ=="], + + "@zag-js/checkbox": ["@zag-js/checkbox@1.41.2", "", { "dependencies": { "@zag-js/anatomy": "1.41.2", "@zag-js/core": "1.41.2", "@zag-js/dom-query": "1.41.2", "@zag-js/focus-visible": "1.41.2", "@zag-js/types": "1.41.2", "@zag-js/utils": "1.41.2" } }, "sha512-aQ5dZWHUHRIw4cbLtzrR+dc8M0N6wSiiCml/zbU3ciHOTBXSrs2rKnp/L99xhi97Lj2uCEhpIZ704Ou/MjGuCg=="], + + "@zag-js/clipboard": ["@zag-js/clipboard@1.41.2", "", { "dependencies": { "@zag-js/anatomy": "1.41.2", "@zag-js/core": "1.41.2", "@zag-js/dom-query": "1.41.2", "@zag-js/types": "1.41.2", "@zag-js/utils": "1.41.2" } }, "sha512-FM+PNGEeY+YpF1dVr9d8kg3DQM66LRnkR4Mva1oprqnrCXTYWj+k7uig893ubSG2xw1GO5b/WJd27kSmNoKnmw=="], + + "@zag-js/collapsible": ["@zag-js/collapsible@1.41.2", "", { "dependencies": { "@zag-js/anatomy": "1.41.2", "@zag-js/core": "1.41.2", "@zag-js/dom-query": "1.41.2", "@zag-js/types": "1.41.2", "@zag-js/utils": "1.41.2" } }, "sha512-uMIp4rqd3iI6VFPMAOcbu9dh9WV4l85nNPYQiBtMKRHgbkfaZdfzF+E+NX3KquIeHW6JiBFUiIyCU0Sf2Cscdw=="], + + "@zag-js/collection": ["@zag-js/collection@1.41.2", "", { "dependencies": { "@zag-js/utils": "1.41.2" } }, "sha512-ZZWuvfPZI8ccWd4aLpuU47k1jSc9eO+F3FM3iBJuvgegCH6g3+HDEwGN6wdePHnYfv7zyIKCGKr816zXcIWQbw=="], + + "@zag-js/color-picker": ["@zag-js/color-picker@1.41.2", "", { "dependencies": { "@zag-js/anatomy": "1.41.2", "@zag-js/color-utils": "1.41.2", "@zag-js/core": "1.41.2", "@zag-js/dismissable": "1.41.2", "@zag-js/dom-query": "1.41.2", "@zag-js/popper": "1.41.2", "@zag-js/types": "1.41.2", "@zag-js/utils": "1.41.2" } }, "sha512-UynyJ/bTBeSlq6ziHr9GVrFycDjVxfLFIb8Aivu/XvihrAAvkhXUxwy+1ax+hj7peGlTY590xoQAvQixV+McBA=="], + + "@zag-js/color-utils": ["@zag-js/color-utils@1.41.2", "", { "dependencies": { "@zag-js/utils": "1.41.2" } }, "sha512-Lsi5c2ztGZqud0oeDtAn3xFhgrYMaeaz1zi4p+mr0zXOuQNuZzfsrJ0stQ45s8L/xT8UJtGhYyEVy1+xjE4ARA=="], + + "@zag-js/combobox": ["@zag-js/combobox@1.41.2", "", { "dependencies": { "@zag-js/anatomy": "1.41.2", "@zag-js/collection": "1.41.2", "@zag-js/core": "1.41.2", "@zag-js/dismissable": "1.41.2", "@zag-js/dom-query": "1.41.2", "@zag-js/focus-visible": "1.41.2", "@zag-js/live-region": "1.41.2", "@zag-js/popper": "1.41.2", "@zag-js/types": "1.41.2", "@zag-js/utils": "1.41.2" } }, "sha512-V9jQteyQHs8ZNQx/FcA98P1NVZas/yjiW1V7s4L+h8MnRS3AK97+FAHAT83KCiZ34/vkpLF6ZEHI2zkcQpNXcg=="], + + "@zag-js/core": ["@zag-js/core@1.41.2", "", { "dependencies": { "@zag-js/dom-query": "1.41.2", "@zag-js/utils": "1.41.2" } }, "sha512-xXTN3zKwOtMI4+5dG2cG+T1B4WR3X9alXiYaPJKaGd4N2eYRj9JEPte3Hv9gtFm+RM0b9VIwksHEg25rqE4apw=="], + + "@zag-js/date-input": ["@zag-js/date-input@1.41.2", "", { "dependencies": { "@zag-js/anatomy": "1.41.2", "@zag-js/core": "1.41.2", "@zag-js/date-utils": "1.41.2", "@zag-js/dom-query": "1.41.2", "@zag-js/live-region": "1.41.2", "@zag-js/types": "1.41.2", "@zag-js/utils": "1.41.2" }, "peerDependencies": { "@internationalized/date": ">=3.0.0" } }, "sha512-J8PdpEpM9TBxZBEE8/Nxi39LNJOZY7lilTbgcDABR5uiviZUk5++5GSdUTspcjFUIXx5SvqmdCk2RF7hsUEHVQ=="], + + "@zag-js/date-picker": ["@zag-js/date-picker@1.41.2", "", { "dependencies": { "@zag-js/anatomy": "1.41.2", "@zag-js/core": "1.41.2", "@zag-js/date-utils": "1.41.2", "@zag-js/dismissable": "1.41.2", "@zag-js/dom-query": "1.41.2", "@zag-js/live-region": "1.41.2", "@zag-js/popper": "1.41.2", "@zag-js/types": "1.41.2", "@zag-js/utils": "1.41.2" }, "peerDependencies": { "@internationalized/date": ">=3.0.0" } }, "sha512-j9LaznCV4QCfrq/y/mNAN9XgIRFFg+414TxGT4TK8mfWouIaFvxEoKdGP0l/LL4DFv1NRDaRdSBBcw3eXtMVnA=="], + + "@zag-js/date-utils": ["@zag-js/date-utils@1.41.2", "", { "peerDependencies": { "@internationalized/date": ">=3.0.0" } }, "sha512-jdcWLa5fYLTsvGWJkx4v/9Hzqf6UHdvJDeP6NxHpwoEmzYy7+AghYKBNSnRrJIBCQJgl1vM9OkpMvYrLNHr9jw=="], + + "@zag-js/dialog": ["@zag-js/dialog@1.41.2", "", { "dependencies": { "@zag-js/anatomy": "1.41.2", "@zag-js/aria-hidden": "1.41.2", "@zag-js/core": "1.41.2", "@zag-js/dismissable": "1.41.2", "@zag-js/dom-query": "1.41.2", "@zag-js/focus-trap": "1.41.2", "@zag-js/remove-scroll": "1.41.2", "@zag-js/types": "1.41.2", "@zag-js/utils": "1.41.2" } }, "sha512-t1N72snpFGiKj7cg2PivPdsy+X1YdgXPv7bzovpqjMLy0kN+gYTPoV1Iy/hQA+g021dz9XGgXzkDuU45sJqsFA=="], + + "@zag-js/dismissable": ["@zag-js/dismissable@1.41.2", "", { "dependencies": { "@zag-js/dom-query": "1.41.2", "@zag-js/interact-outside": "1.41.2", "@zag-js/utils": "1.41.2" } }, "sha512-hO/tFhRZ7S+LOOljGOQJIubbc3MXg41+iWR1yUXKl76cAenbxaCit1LZmUCwQPvRN0GndK6bDQo5ETjHZz/k7A=="], + + "@zag-js/dom-query": ["@zag-js/dom-query@1.41.2", "", { "dependencies": { "@zag-js/types": "1.41.2" } }, "sha512-+eBk1nlJA312mNmY/GSThLRwcCRqMIL+A1pLsWvTlQLQjmH1/UxoAuv6l2yvRCT33XmC8FBlBIKnXhOCpDvIZA=="], + + "@zag-js/drawer": ["@zag-js/drawer@1.41.2", "", { "dependencies": { "@zag-js/anatomy": "1.41.2", "@zag-js/aria-hidden": "1.41.2", "@zag-js/core": "1.41.2", "@zag-js/dismissable": "1.41.2", "@zag-js/dom-query": "1.41.2", "@zag-js/focus-trap": "1.41.2", "@zag-js/remove-scroll": "1.41.2", "@zag-js/types": "1.41.2", "@zag-js/utils": "1.41.2" } }, "sha512-aJql6L0cfHd1wXbemfLcNnjqTA/CbwVgQdWEVn5Qci6zhy4UJXPQeBBfxfgPgEOAbW60gL/gaaXG3d9Vx6+6oA=="], + + "@zag-js/editable": ["@zag-js/editable@1.41.2", "", { "dependencies": { "@zag-js/anatomy": "1.41.2", "@zag-js/core": "1.41.2", "@zag-js/dom-query": "1.41.2", "@zag-js/interact-outside": "1.41.2", "@zag-js/types": "1.41.2", "@zag-js/utils": "1.41.2" } }, "sha512-loTM1lrHBBqYfR8SJZrYayVdWHMpXCLVir+aDTQ8/d4bb/Gfl/L8CJNs3BRj3yt9zvLoWTLO+4LOY3hLyQSR2w=="], + + "@zag-js/file-upload": ["@zag-js/file-upload@1.41.2", "", { "dependencies": { "@zag-js/anatomy": "1.41.2", "@zag-js/core": "1.41.2", "@zag-js/dom-query": "1.41.2", "@zag-js/file-utils": "1.41.2", "@zag-js/i18n-utils": "1.41.2", "@zag-js/types": "1.41.2", "@zag-js/utils": "1.41.2" } }, "sha512-WFwIaKvHpCUjyYMvp42VoydT1WIP5DhDlpmG/nrF4i0ro7pLGb1A4dvyBrAfGcozCB88yR1y1iZKPDY1I9/uUw=="], + + "@zag-js/file-utils": ["@zag-js/file-utils@1.41.2", "", { "dependencies": { "@zag-js/i18n-utils": "1.41.2" } }, "sha512-Ih+8ULbId0M+CFR4IsqG5y/0VLCk2l+1rgPH+21L40dlSB6z6qKSP2tG7W69Cj2/3vryZsn67ibn26iCPG/vOw=="], + + "@zag-js/floating-panel": ["@zag-js/floating-panel@1.41.2", "", { "dependencies": { "@zag-js/anatomy": "1.41.2", "@zag-js/core": "1.41.2", "@zag-js/dom-query": "1.41.2", "@zag-js/popper": "1.41.2", "@zag-js/rect-utils": "1.41.2", "@zag-js/store": "1.41.2", "@zag-js/types": "1.41.2", "@zag-js/utils": "1.41.2" } }, "sha512-nJP3oZ4YrJh+7H5YdwNccSzPGXFqjQN1ujZ/xGDhegjz4XtL48QMFnAasxlRI8VGjse9Tj8VXlQZxXPrASGzlA=="], + + "@zag-js/focus-trap": ["@zag-js/focus-trap@1.41.2", "", { "dependencies": { "@zag-js/dom-query": "1.41.2" } }, "sha512-3QTtGUjFU2OLbyrDlyoYWvKZecCmtn/+bsfsHW159jJHiEGHVYK6CY8AI1ePsMk9gVay48bXh008j+lVli7gAw=="], + + "@zag-js/focus-visible": ["@zag-js/focus-visible@1.41.2", "", { "dependencies": { "@zag-js/dom-query": "1.41.2" } }, "sha512-oRjwtgafUdGVwLJUN6mKsnBQbez/CHYAPkPg1FxOnr5GFpEpr8oMTOZJ3wdPM2U1ynS9QnUUu/sXc3KQv/jX0Q=="], + + "@zag-js/highlight-word": ["@zag-js/highlight-word@1.41.2", "", {}, "sha512-95zcZKqNrL7JlqAckfzHa+LRbnfoz1lj6skUhq/uHSnni1vH6+8fNWT8ruo9G7vpGopbyRmmcie5p41SbAwQjQ=="], + + "@zag-js/hover-card": ["@zag-js/hover-card@1.41.2", "", { "dependencies": { "@zag-js/anatomy": "1.41.2", "@zag-js/core": "1.41.2", "@zag-js/dismissable": "1.41.2", "@zag-js/dom-query": "1.41.2", "@zag-js/popper": "1.41.2", "@zag-js/types": "1.41.2", "@zag-js/utils": "1.41.2" } }, "sha512-Xn9RVzgTkKaVzyJTDdBJiXmCleNi1/hmW8z73tC0vOiQvSSvPejg/JkzqTOLFODvQHK3NOw54QHZvmjYp9Mubw=="], + + "@zag-js/i18n-utils": ["@zag-js/i18n-utils@1.41.2", "", { "dependencies": { "@zag-js/dom-query": "1.41.2" } }, "sha512-f1xqaEY79awBxgUyjFso0UEpIoEHZq+zRvB0nUVFHJRW7Ds/QhaFHKSRnf2QBTlP/ObvCT225R+piNAAc17Aaw=="], + + "@zag-js/image-cropper": ["@zag-js/image-cropper@1.41.2", "", { "dependencies": { "@zag-js/anatomy": "1.41.2", "@zag-js/core": "1.41.2", "@zag-js/dom-query": "1.41.2", "@zag-js/types": "1.41.2", "@zag-js/utils": "1.41.2" } }, "sha512-750aT4U+J/TJw/Z1QVoLU9JG0luCtf9CyvShtJFIxeS+i25aUoBI9pOKgSFABsOutIqNJTPq4gYpqtuxFjSxRw=="], + + "@zag-js/interact-outside": ["@zag-js/interact-outside@1.41.2", "", { "dependencies": { "@zag-js/dom-query": "1.41.2", "@zag-js/utils": "1.41.2" } }, "sha512-dM4Fn9iyqQeqkCMRYZP+bAgWEPKRVQRqMmcPsN0OXBhhFKC31Em15LTIZXaOtVKAjH+iwx+UvSYFRiWwEjkOEA=="], + + "@zag-js/json-tree-utils": ["@zag-js/json-tree-utils@1.41.2", "", {}, "sha512-gNaOzsbCwmTd2HM3/u0xQdWX5UDBfl8tCXFavzbamkFH0iYQOXJb7cqUXBVuI4KScIbHPCKwrzZjqA5Sg9qzAQ=="], + + "@zag-js/listbox": ["@zag-js/listbox@1.41.2", "", { "dependencies": { "@zag-js/anatomy": "1.41.2", "@zag-js/collection": "1.41.2", "@zag-js/core": "1.41.2", "@zag-js/dom-query": "1.41.2", "@zag-js/focus-visible": "1.41.2", "@zag-js/types": "1.41.2", "@zag-js/utils": "1.41.2" } }, "sha512-iDGrZleP3ui2Q6Jgmr9RYlbd7njdJHs8Qb3IJrSIZBIeYyYmFvVUfAFjk0g/z/amjTx6uYxRASWSPy/RETd4ug=="], + + "@zag-js/live-region": ["@zag-js/live-region@1.41.2", "", {}, "sha512-7ubIW5AQt1wx9S/gFN+rU4TyvuFWJrL/DhnDWPNlH5g3luDVHSNeYGeeqf4d4tkcibtpYZa0pg9CbXxRxrfwVA=="], + + "@zag-js/marquee": ["@zag-js/marquee@1.41.2", "", { "dependencies": { "@zag-js/anatomy": "1.41.2", "@zag-js/core": "1.41.2", "@zag-js/dom-query": "1.41.2", "@zag-js/types": "1.41.2", "@zag-js/utils": "1.41.2" } }, "sha512-cT77aMhrtAK3oe2O6+X3TLtQs8wupdPUtZgHMWVxCcQOwagrk7RUBEQwU3Cx7cTswpBdTKSuDYgUggfgCs96wg=="], + + "@zag-js/menu": ["@zag-js/menu@1.41.2", "", { "dependencies": { "@zag-js/anatomy": "1.41.2", "@zag-js/core": "1.41.2", "@zag-js/dismissable": "1.41.2", "@zag-js/dom-query": "1.41.2", "@zag-js/focus-visible": "1.41.2", "@zag-js/popper": "1.41.2", "@zag-js/rect-utils": "1.41.2", "@zag-js/types": "1.41.2", "@zag-js/utils": "1.41.2" } }, "sha512-wwix8hcAUSi0scpWXCiDppfdZV01Za8nN0gqLt9GdhCiVSlr0rs9pK1ROgPKJTyc43UZfyFPqtTWVHvEHMM70w=="], + + "@zag-js/navigation-menu": ["@zag-js/navigation-menu@1.41.2", "", { "dependencies": { "@zag-js/anatomy": "1.41.2", "@zag-js/core": "1.41.2", "@zag-js/dismissable": "1.41.2", "@zag-js/dom-query": "1.41.2", "@zag-js/types": "1.41.2", "@zag-js/utils": "1.41.2" } }, "sha512-aROB9CHzskZpnoFuGFkp7dbkZdsXvp2nxQgsgld02I1sDqiwcQd+YMdB0/6Ik0oz6X8I70RKdUuQM9WQFQfDnA=="], + + "@zag-js/number-input": ["@zag-js/number-input@1.41.2", "", { "dependencies": { "@internationalized/number": "3.6.6", "@zag-js/anatomy": "1.41.2", "@zag-js/core": "1.41.2", "@zag-js/dom-query": "1.41.2", "@zag-js/types": "1.41.2", "@zag-js/utils": "1.41.2" } }, "sha512-QoQWCiVHO+ciUbq8uL8Kxhtk4o3UpwzYpJkfsOfitzsZoYpPc7V/A6+n5yABV2SOwpqBODwNASZdxiEa60kfow=="], + + "@zag-js/pagination": ["@zag-js/pagination@1.41.2", "", { "dependencies": { "@zag-js/anatomy": "1.41.2", "@zag-js/core": "1.41.2", "@zag-js/dom-query": "1.41.2", "@zag-js/types": "1.41.2", "@zag-js/utils": "1.41.2" } }, "sha512-vZTxz4DrIDfedMcTjDeEkOc1iXD9wkP8eKuEcDieHOodnjSnNNwtmoFw5DCWv+yEa/TByIamXBZ8ZxHY144JgA=="], + + "@zag-js/password-input": ["@zag-js/password-input@1.41.2", "", { "dependencies": { "@zag-js/anatomy": "1.41.2", "@zag-js/core": "1.41.2", "@zag-js/dom-query": "1.41.2", "@zag-js/types": "1.41.2", "@zag-js/utils": "1.41.2" } }, "sha512-OWKFl0S12Qnlf4R4WbMCJ/YU0kGfezm5tP0UiyubMO/Fixv6H0twDFaJSPg2F6POv5uomCcGubc1H7gO+fIhsQ=="], + + "@zag-js/pin-input": ["@zag-js/pin-input@1.41.2", "", { "dependencies": { "@zag-js/anatomy": "1.41.2", "@zag-js/core": "1.41.2", "@zag-js/dom-query": "1.41.2", "@zag-js/types": "1.41.2", "@zag-js/utils": "1.41.2" } }, "sha512-Wrn3YDbmWL3qvUIzN4QyLO7PzEhunX7z8DwBpmrK04p7HxR9pniTLVIPk27xk2MzyAPYcl4mwd9/Mc88tBxHsg=="], + + "@zag-js/popover": ["@zag-js/popover@1.41.2", "", { "dependencies": { "@zag-js/anatomy": "1.41.2", "@zag-js/aria-hidden": "1.41.2", "@zag-js/core": "1.41.2", "@zag-js/dismissable": "1.41.2", "@zag-js/dom-query": "1.41.2", "@zag-js/focus-trap": "1.41.2", "@zag-js/popper": "1.41.2", "@zag-js/remove-scroll": "1.41.2", "@zag-js/types": "1.41.2", "@zag-js/utils": "1.41.2" } }, "sha512-h/LlVMIERM+NWzYV0ZHURlJuaqkT8XxwyEV96XfVzjknDRFNoPSl5IttVYtoaPoUqC0p/Y4oTSiee4mUZKOLHw=="], + + "@zag-js/popper": ["@zag-js/popper@1.41.2", "", { "dependencies": { "@floating-ui/dom": "^1.7.6", "@zag-js/dom-query": "1.41.2", "@zag-js/utils": "1.41.2" } }, "sha512-Iz4D5YAIiIPn4IHGjhX3QatR/RyGaDt43lBSZv0RYcPQYtFg9sUuek3wizjW9qXgdvItevvNMqRdpl7f3es09g=="], + + "@zag-js/presence": ["@zag-js/presence@1.41.2", "", { "dependencies": { "@zag-js/core": "1.41.2", "@zag-js/dom-query": "1.41.2", "@zag-js/types": "1.41.2" } }, "sha512-OhOLPAf/DYPmgoEntrlrf3LOrkwA+Y0J3K03NXHXPnkRB7h8jyIbHqzHS0jRTb1pvsO2P/yowRyoYtptY2Zmog=="], + + "@zag-js/progress": ["@zag-js/progress@1.41.2", "", { "dependencies": { "@zag-js/anatomy": "1.41.2", "@zag-js/core": "1.41.2", "@zag-js/dom-query": "1.41.2", "@zag-js/types": "1.41.2", "@zag-js/utils": "1.41.2" } }, "sha512-SnzrqN+Z568NoO4rrgjrIc/S4EXMEne5CDgWwt2kQ8yq9VysdH8TtHAjyciFRIio7cdgEZNHKw9jSccpMWgRwA=="], + + "@zag-js/qr-code": ["@zag-js/qr-code@1.41.2", "", { "dependencies": { "@zag-js/anatomy": "1.41.2", "@zag-js/core": "1.41.2", "@zag-js/dom-query": "1.41.2", "@zag-js/types": "1.41.2", "@zag-js/utils": "1.41.2", "proxy-memoize": "3.0.1", "uqr": "0.1.3" } }, "sha512-+JLswCNnzf58aQTaX0SMUA9wRC8Hb/a/ZveJIXTz6853Siemay2HqOqB2WQIeF33HNldUFD/a4+4Q8ugg93ubA=="], + + "@zag-js/radio-group": ["@zag-js/radio-group@1.41.2", "", { "dependencies": { "@zag-js/anatomy": "1.41.2", "@zag-js/core": "1.41.2", "@zag-js/dom-query": "1.41.2", "@zag-js/focus-visible": "1.41.2", "@zag-js/types": "1.41.2", "@zag-js/utils": "1.41.2" } }, "sha512-EZjos61jKHZlNw8ez80vG2T9gUwpooxcVpYOKS2hyhuEXn3wEoefS5v1WFbmpoA/8TUpUQnYxisAeNuQfEQCuQ=="], + + "@zag-js/rating-group": ["@zag-js/rating-group@1.41.2", "", { "dependencies": { "@zag-js/anatomy": "1.41.2", "@zag-js/core": "1.41.2", "@zag-js/dom-query": "1.41.2", "@zag-js/types": "1.41.2", "@zag-js/utils": "1.41.2" } }, "sha512-SbJP4HiK5XRy/oC3xRowjZOCThq1n/QA2Z/XAkvKLJArVQCYjrH3MoWwWvMVNfNC5+ZJluborR2AGF7tlVLzxA=="], + + "@zag-js/react": ["@zag-js/react@1.41.2", "", { "dependencies": { "@zag-js/core": "1.41.2", "@zag-js/store": "1.41.2", "@zag-js/types": "1.41.2", "@zag-js/utils": "1.41.2" }, "peerDependencies": { "react": ">=18.0.0", "react-dom": ">=18.0.0" } }, "sha512-5Bx7mQAron4LFWI8Hhs/uw5kwQ19s2Tn30HhctozLqmCu4nnJSTSh7GRvX+uwRZnztGXBXoOrgBWIepU4RXFGg=="], + + "@zag-js/rect-utils": ["@zag-js/rect-utils@1.41.2", "", {}, "sha512-GWBTamaMLMG1p7Fe6V0dsXeTEmk+tqG3ciovzmjxURCJ3Yq2EkAMRhS0v5DG0oo+PyrPEIzEWukGBQkh/XRgYQ=="], + + "@zag-js/remove-scroll": ["@zag-js/remove-scroll@1.41.2", "", { "dependencies": { "@zag-js/dom-query": "1.41.2" } }, "sha512-ieIrOgPKlCikAGEBIboQJoU7oxrL5BEY670tDOu7Eg7rNOdAwGXLEKPX2A/q+lREhOiVYjx0D3//vHTvdte80Q=="], + + "@zag-js/scroll-area": ["@zag-js/scroll-area@1.41.2", "", { "dependencies": { "@zag-js/anatomy": "1.41.2", "@zag-js/core": "1.41.2", "@zag-js/dom-query": "1.41.2", "@zag-js/types": "1.41.2", "@zag-js/utils": "1.41.2" } }, "sha512-hJFAwfIFuS7XmNsS3nB5rPm9OWXfB4d98sID7fjurcaZtDH5LqFriqfXhceNvs7rz7K4f/u8rufXrb6tcvATIA=="], + + "@zag-js/scroll-snap": ["@zag-js/scroll-snap@1.41.2", "", { "dependencies": { "@zag-js/dom-query": "1.41.2" } }, "sha512-+70Al6LSASyEZtFyyffUJlDbE88KgYJDud05z8oZTqyEOLlTqnlSNkXq/P3siO7r3sNykg9H+TmAKn+/dVSKuw=="], + + "@zag-js/select": ["@zag-js/select@1.41.2", "", { "dependencies": { "@zag-js/anatomy": "1.41.2", "@zag-js/collection": "1.41.2", "@zag-js/core": "1.41.2", "@zag-js/dismissable": "1.41.2", "@zag-js/dom-query": "1.41.2", "@zag-js/focus-visible": "1.41.2", "@zag-js/popper": "1.41.2", "@zag-js/types": "1.41.2", "@zag-js/utils": "1.41.2" } }, "sha512-3wGaKABILexoNBJ1bJiHqLLTctR/VMZaNA4cyKiqZeBEWtAkdMhgyY2xKobrP6KtUTqAeUFNVSTu4yCDrAQnUw=="], + + "@zag-js/signature-pad": ["@zag-js/signature-pad@1.41.2", "", { "dependencies": { "@zag-js/anatomy": "1.41.2", "@zag-js/core": "1.41.2", "@zag-js/dom-query": "1.41.2", "@zag-js/types": "1.41.2", "@zag-js/utils": "1.41.2", "perfect-freehand": "^1.2.3" } }, "sha512-iNrOxY4gtqhsZdXYvlhF7s+LiOvwV7/kBpNnq8tJ1oYhgTs8wvKtJHrP86/CR05irEXQTaLfmeAJZuBEys9iRA=="], + + "@zag-js/slider": ["@zag-js/slider@1.41.2", "", { "dependencies": { "@zag-js/anatomy": "1.41.2", "@zag-js/core": "1.41.2", "@zag-js/dom-query": "1.41.2", "@zag-js/types": "1.41.2", "@zag-js/utils": "1.41.2" } }, "sha512-mKK2BwoDbIGxAdkdKkPZJA1SHtEQt3lS9hJ6WghefYU2vyd0BXoIKvcDV3xJOzly5LXYhH5cJITn6JtGK8353A=="], + + "@zag-js/splitter": ["@zag-js/splitter@1.41.2", "", { "dependencies": { "@zag-js/anatomy": "1.41.2", "@zag-js/core": "1.41.2", "@zag-js/dom-query": "1.41.2", "@zag-js/types": "1.41.2", "@zag-js/utils": "1.41.2" } }, "sha512-Ubp4hkmzvVysU31jCINYbBXVqruu7rEPGqugWMzeXC5Hwda648aHpbg4Jix/wRtaGLjsyh6KOVEwAmoJU9NwhQ=="], + + "@zag-js/steps": ["@zag-js/steps@1.41.2", "", { "dependencies": { "@zag-js/anatomy": "1.41.2", "@zag-js/core": "1.41.2", "@zag-js/dom-query": "1.41.2", "@zag-js/types": "1.41.2", "@zag-js/utils": "1.41.2" } }, "sha512-m0t2r8+FWwa2b2aU5JiNrHVdYHyNZYHK0G3Tq9lCOSQoDeoJIkyta+sIVehLVSY+0Ba9kOlkRUmYLbsnfXaW+Q=="], + + "@zag-js/store": ["@zag-js/store@1.41.2", "", { "dependencies": { "proxy-compare": "3.0.1" } }, "sha512-dVZF7E1ezXzynrKhMH3rfSr2rBbCfvTjvXbXz7//1PNULuq58UU5dG93V+9l834npCZxI2+PrpY45wZLJPTsIA=="], + + "@zag-js/switch": ["@zag-js/switch@1.41.2", "", { "dependencies": { "@zag-js/anatomy": "1.41.2", "@zag-js/core": "1.41.2", "@zag-js/dom-query": "1.41.2", "@zag-js/focus-visible": "1.41.2", "@zag-js/types": "1.41.2", "@zag-js/utils": "1.41.2" } }, "sha512-qHbQK95UUHN0tj+bf9LLphLcMo/uTg2Pvs0c1Gs03Zh4g3NtHf0uYIhMZKYlCH0hNVlKrmdzLKWgeoDxv9gySw=="], + + "@zag-js/tabs": ["@zag-js/tabs@1.41.2", "", { "dependencies": { "@zag-js/anatomy": "1.41.2", "@zag-js/core": "1.41.2", "@zag-js/dom-query": "1.41.2", "@zag-js/types": "1.41.2", "@zag-js/utils": "1.41.2" } }, "sha512-7YVj2mCcxRbn1wMXP9anaTOVf+J0fa5uaPScr8e4+e2xc+/1WKzqN6V8IDeKS5wV/xzi1r3Ny307sX7Xz4ZJVQ=="], + + "@zag-js/tags-input": ["@zag-js/tags-input@1.41.2", "", { "dependencies": { "@zag-js/anatomy": "1.41.2", "@zag-js/auto-resize": "1.41.2", "@zag-js/core": "1.41.2", "@zag-js/dom-query": "1.41.2", "@zag-js/interact-outside": "1.41.2", "@zag-js/live-region": "1.41.2", "@zag-js/types": "1.41.2", "@zag-js/utils": "1.41.2" } }, "sha512-aIPEndSO+9LHxyoXLUr0Uttxw2cYMyDuii5w50wn/N7lFJD49U3Sj+6XaB/oJSbDAwn10WrsRDtb7Gs2kmU+Qw=="], + + "@zag-js/timer": ["@zag-js/timer@1.41.2", "", { "dependencies": { "@zag-js/anatomy": "1.41.2", "@zag-js/core": "1.41.2", "@zag-js/dom-query": "1.41.2", "@zag-js/types": "1.41.2", "@zag-js/utils": "1.41.2" } }, "sha512-PRYLWaip0+1FeVGEMNk5wMGAAIYgBIWwulZ4U5I+2Ayjohzp2NUAfwJ3sqoYvRrfjNYUNAPdU4eGu1zetC+oVQ=="], + + "@zag-js/toast": ["@zag-js/toast@1.41.2", "", { "dependencies": { "@zag-js/anatomy": "1.41.2", "@zag-js/core": "1.41.2", "@zag-js/dismissable": "1.41.2", "@zag-js/dom-query": "1.41.2", "@zag-js/types": "1.41.2", "@zag-js/utils": "1.41.2" } }, "sha512-+F3PsAo6EIz4rh73IOMCb/+FOUp7e3VjUY2q5sdU4IbfOzJBIbVSJxn0PQmHkuxkzWdCom0Lv0qSPFg/UTplnQ=="], + + "@zag-js/toggle": ["@zag-js/toggle@1.41.2", "", { "dependencies": { "@zag-js/anatomy": "1.41.2", "@zag-js/core": "1.41.2", "@zag-js/dom-query": "1.41.2", "@zag-js/types": "1.41.2", "@zag-js/utils": "1.41.2" } }, "sha512-EFB9pb3pEtwXt7RSivVLWXV64dKUF8gsn75tt8TbYBgfy+zW85MsRLu8U48TXZN5teQWAKKNujr9bxQsW/CWvQ=="], + + "@zag-js/toggle-group": ["@zag-js/toggle-group@1.41.2", "", { "dependencies": { "@zag-js/anatomy": "1.41.2", "@zag-js/core": "1.41.2", "@zag-js/dom-query": "1.41.2", "@zag-js/types": "1.41.2", "@zag-js/utils": "1.41.2" } }, "sha512-C6wn3A89h24hTs0BN9ryEuKatfR493u7QqxS06TeK9oI/KZBvm5Rwm8FPHSUJvsUTkAkow4PsXC0Ra19duzEdQ=="], + + "@zag-js/tooltip": ["@zag-js/tooltip@1.41.2", "", { "dependencies": { "@zag-js/anatomy": "1.41.2", "@zag-js/core": "1.41.2", "@zag-js/dom-query": "1.41.2", "@zag-js/focus-visible": "1.41.2", "@zag-js/popper": "1.41.2", "@zag-js/types": "1.41.2", "@zag-js/utils": "1.41.2" } }, "sha512-68okWJCFXfW8r0h97kEcU2yKPkq6e0S6QkiYh09ifMXoYjrQw/shPol8PgrS1poqKJigUWtsKm+bw73abhMn3w=="], + + "@zag-js/tour": ["@zag-js/tour@1.41.2", "", { "dependencies": { "@zag-js/anatomy": "1.41.2", "@zag-js/core": "1.41.2", "@zag-js/dismissable": "1.41.2", "@zag-js/dom-query": "1.41.2", "@zag-js/focus-trap": "1.41.2", "@zag-js/interact-outside": "1.41.2", "@zag-js/popper": "1.41.2", "@zag-js/types": "1.41.2", "@zag-js/utils": "1.41.2" } }, "sha512-Q7UsvuHYYBo1Cs4b4OS0e/D8lxd2GpSRII93s5BQPi5HTcBjaWhVysAykmCFbat6Z56z0NBmFHNdhZ8oVPls1A=="], + + "@zag-js/tree-view": ["@zag-js/tree-view@1.41.2", "", { "dependencies": { "@zag-js/anatomy": "1.41.2", "@zag-js/collection": "1.41.2", "@zag-js/core": "1.41.2", "@zag-js/dom-query": "1.41.2", "@zag-js/types": "1.41.2", "@zag-js/utils": "1.41.2" } }, "sha512-QNi0VpV+RyzF4NP72+kSleUpauF9SMAzOAe59nxvs8jAUHqV5diDCInnbQos4+cyFDXFzGq3lElot26A1yI+7Q=="], + + "@zag-js/types": ["@zag-js/types@1.41.2", "", { "dependencies": { "csstype": "3.2.3" } }, "sha512-L6CNvK06lIVpy0X8eG3kbDIx8Uuv+3KHElxXYSzRXSJ7/OLCv1sTRgEvnxNtdIWOrksGgxF4JtT7PXtoClGqNQ=="], + + "@zag-js/utils": ["@zag-js/utils@1.41.2", "", {}, "sha512-Yj8FSrR7vGA6ahUhjrThfHAF+PM2Y1Yv2lkXkqZZd60mPBhixcot1+SHOfEMV63JimQcWrmQ8QbeYYMmF+ZpLQ=="], + "abort-controller": ["abort-controller@3.0.0", "", { "dependencies": { "event-target-shim": "^5.0.0" } }, "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg=="], "accepts": ["accepts@1.3.8", "", { "dependencies": { "mime-types": "~2.1.34", "negotiator": "0.6.3" } }, "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw=="], @@ -2369,7 +2523,7 @@ "lru.min": ["lru.min@1.1.4", "", {}, "sha512-DqC6n3QQ77zdFpCMASA1a3Jlb64Hv2N2DciFGkO/4L9+q/IpIAuRlKOvCXabtRW6cQf8usbmM6BE/TOPysCdIA=="], - "lucide-react": ["lucide-react@1.20.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-jhXLeC/7m0/tjL1nzMdKk6x256zWA6AtbhTVreHOiKPoeX2d6MK4FbyIQPpVq0E6iPWBisyy1TW+pEge/uMEuQ=="], + "lucide-react": ["lucide-react@1.21.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-reEZMXq8Qdd5jg5XYkQ5TR1fB/GiQ7ih4vcrthYDtgjSDwh0i6/YLiGjsWsIwgN49gpAnd4J2elSNzncMEEUUQ=="], "lzutf8": ["lzutf8@0.6.3", "", { "dependencies": { "readable-stream": "^4.0.0" } }, "sha512-CAkF9HKrM+XpB0f3DepQ2to2iUEo0zrbh+XgBqgNBc1+k8HMM3u/YSfHI3Dr4GmoTIez2Pr/If1XFl3rU26AwA=="], @@ -2669,6 +2823,8 @@ "perfect-debounce": ["perfect-debounce@2.1.0", "", {}, "sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g=="], + "perfect-freehand": ["perfect-freehand@1.2.3", "", {}, "sha512-bHZSfqDHGNlPpgH2yxXgPHlQSPpEbo+qg7li0M78J9vNAi2yjwLeA4x79BEQhX44lEWpCLSFCeRZwpw0niiXPA=="], + "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], "picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="], @@ -2737,8 +2893,12 @@ "proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="], + "proxy-compare": ["proxy-compare@3.0.1", "", {}, "sha512-V9plBAt3qjMlS1+nC8771KNf6oJ12gExvaxnNzN/9yVRLdTv/lc+oJlnSzrdYDAvBfTStPCoiaCOTmTs0adv7Q=="], + "proxy-from-env": ["proxy-from-env@1.1.0", "", {}, "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="], + "proxy-memoize": ["proxy-memoize@3.0.1", "", { "dependencies": { "proxy-compare": "^3.0.0" } }, "sha512-VDdG/VYtOgdGkWJx7y0o7p+zArSf2383Isci8C+BP3YXgMYDoPd3cCBjw0JdWb6YBb9sFiOPbAADDVTPJnh+9g=="], + "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], "pure-rand": ["pure-rand@6.1.0", "", {}, "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA=="], @@ -3051,6 +3211,8 @@ "tailwind-merge": ["tailwind-merge@3.6.0", "", {}, "sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w=="], + "tailwind-variants": ["tailwind-variants@3.2.2", "", { "peerDependencies": { "tailwind-merge": ">=3.0.0", "tailwindcss": "*" }, "optionalPeers": ["tailwind-merge"] }, "sha512-Mi4kHeMTLvKlM98XPnK+7HoBPmf4gygdFmqQPaDivc3DpYS6aIY6KiG/PgThrGvii5YZJqRsPz0aPyhoFzmZgg=="], + "tailwindcss": ["tailwindcss@4.3.1", "", {}, "sha512-hk+TB1m+K8CYNrP6rjQaq/Y+4Zylwpa87mLYBKCunwnnQ9p+fHb7kmSfGqyEJoxF/O6CDyABWVFEafNSYKll+Q=="], "tailwindcss-animate": ["tailwindcss-animate@1.0.7", "", { "peerDependencies": { "tailwindcss": ">=3.0.0 || insiders" } }, "sha512-bl6mpH3T7I3UFxuvDEXLxy/VuFxBk5bbzplh7tXI68mwMokNYd1t9qPBHlnyTwfa4JGC4zP516I1hYYtQ/vspA=="], @@ -3141,6 +3303,8 @@ "update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="], + "uqr": ["uqr@0.1.3", "", {}, "sha512-0rjE8iEJe4YmT9TOhwsZtqCMRLc5DXZUI2UEYUUg63ikBkqqE5EYWaI0etFe/5KUcmcYwLih2RND1kq+hrUJXA=="], + "uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="], "use-callback-ref": ["use-callback-ref@1.3.3", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg=="], @@ -3421,6 +3585,8 @@ "@vercel/toolbar/execa": ["execa@5.1.1", "", { "dependencies": { "cross-spawn": "^7.0.3", "get-stream": "^6.0.0", "human-signals": "^2.1.0", "is-stream": "^2.0.0", "merge-stream": "^2.0.0", "npm-run-path": "^4.0.1", "onetime": "^5.1.2", "signal-exit": "^3.0.3", "strip-final-newline": "^2.0.0" } }, "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg=="], + "@zag-js/number-input/@internationalized/number": ["@internationalized/number@3.6.6", "", { "dependencies": { "@swc/helpers": "^0.5.0" } }, "sha512-iFgmQaXHE0vytNfpLZWOC2mEJCBRzcUxt53Xf/yCXG93lRvqas237i3r7X4RKMwO3txiyZD4mQjKAByFv6UGSQ=="], + "accepts/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], "ajv-formats/ajv": ["ajv@8.18.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A=="], diff --git a/components.json b/components.json index c1d215d7..0526bd30 100644 --- a/components.json +++ b/components.json @@ -22,6 +22,7 @@ "@magicui": "https://magicui.design/r/{name}.json", "@kibo-ui": "https://www.kibo-ui.com/r/{name}.json", "@diceui": "https://diceui.com/r/{name}.json", - "@ncdai": "https://chanhdai.com/r/{name}.json" + "@ncdai": "https://chanhdai.com/r/{name}.json", + "@shark": "https://shark.vini.one/r/{name}.json" } } diff --git a/package.json b/package.json index f4397633..68dbd7e7 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "stripe:listen": "stripe listen --forward-to localhost:3000/api/auth/stripe/webhook" }, "dependencies": { + "@ark-ui/react": "^5.37.2", "@aws-sdk/client-s3": "^3.1071.0", "@aws-sdk/s3-request-presigner": "^3.1071.0", "@better-auth/stripe": "1.6.9", @@ -93,7 +94,7 @@ "lodash.isequal": "^4.5.0", "lodash.isobject": "^3.0.2", "lodash.transform": "^4.6.0", - "lucide-react": "^1.20.0", + "lucide-react": "^1.21.0", "lzutf8": "^0.6.3", "motion": "^12.40.0", "nanoid": "^5.1.11", @@ -129,6 +130,7 @@ "stripe": "^22.1.0", "styled-components": "^6.4.2", "tailwind-merge": "^3.6.0", + "tailwind-variants": "^3.2.2", "tailwindcss-animate": "^1.0.7", "torph": "^0.0.9", "vaul": "^1.1.2", diff --git a/src/app/dashboard/menu-items/[action]/[id]/variant-form.tsx b/src/app/dashboard/menu-items/[action]/[id]/variant-form.tsx index 10d20c45..b0bcc7f6 100644 --- a/src/app/dashboard/menu-items/[action]/[id]/variant-form.tsx +++ b/src/app/dashboard/menu-items/[action]/[id]/variant-form.tsx @@ -8,7 +8,7 @@ import { type UseFormReturn } from "react-hook-form" import { Trash } from "lucide-react" -import { z } from "zod/v4" +import { type z } from "zod/v4" import { Button } from "@/components/ui/button" import { Field, FieldError, FieldLabel } from "@/components/ui/field" @@ -22,7 +22,7 @@ import { TableRow } from "@/components/ui/table" import VariantDelete from "@/app/dashboard/menu-items/[action]/[id]/variant-delete" -import { menuItemFormSchema } from "@/lib/types/menu-item" +import { type menuItemFormSchema } from "@/lib/types/menu-item" type VariantFormValues = z.infer diff --git a/src/components/sales/quick-sale-screen.tsx b/src/components/sales/quick-sale-screen.tsx index 3aae508c..045a4345 100644 --- a/src/components/sales/quick-sale-screen.tsx +++ b/src/components/sales/quick-sale-screen.tsx @@ -8,7 +8,7 @@ import { useState } from "react" import toast from "react-hot-toast" -import { Loader2, Minus, Plus, Search, ShoppingBag, Trash2 } from "lucide-react" +import { Loader2, Search, ShoppingBag, Trash2 } from "lucide-react" import { AnimatePresence, motion } from "motion/react" import { useAction } from "next-safe-action/hooks" import Image from "next/image" @@ -30,6 +30,13 @@ import { InputGroupAddon, InputGroupInput } from "@/components/ui/input-group" +import { + NumberInput, + NumberInputDecrement, + NumberInputGroup, + NumberInputIncrement, + NumberInputInput +} from "@/components/ui/number-input" import { Separator } from "@/components/ui/separator" import { Sheet, @@ -54,6 +61,74 @@ function roundMoney(value: number) { return Math.round((value + Number.EPSILON) * 100) / 100 } +const productPlaceholderColors = [ + "bg-amber-500 dark:bg-amber-400 text-amber-100", + "bg-blue-500 dark:bg-blue-400 text-blue-100", + "bg-cyan-500 dark:bg-cyan-400 text-cyan-100", + "bg-emerald-500 dark:bg-emerald-400 text-emerald-100", + "bg-fuchsia-500 dark:bg-fuchsia-400 text-fuchsia-100", + "bg-gray-500 dark:bg-gray-400 text-gray-100", + "bg-green-500 dark:bg-green-400 text-green-100", + "bg-indigo-500 dark:bg-indigo-400 text-indigo-100", + "bg-lime-500 dark:bg-lime-400 text-lime-100", + "bg-orange-500 dark:bg-orange-400 text-orange-100", + "bg-pink-500 dark:bg-pink-400 text-pink-100", + "bg-purple-500 dark:bg-purple-400 text-purple-100", + "bg-red-500 dark:bg-red-400 text-red-100", + "bg-rose-500 dark:bg-rose-400 text-rose-100", + "bg-sky-500 dark:bg-sky-400 text-sky-100", + "bg-teal-500 dark:bg-teal-400 text-teal-100", + "bg-violet-500 dark:bg-violet-400 text-violet-100", + "bg-yellow-500 dark:bg-yellow-400 text-yellow-100" +] + +function getProductPlaceholderColor(name: string) { + const hash = name.split("").reduce((accumulator, char) => { + return accumulator + char.charCodeAt(0) + }, 0) + + return productPlaceholderColors[hash % productPlaceholderColors.length] +} + +const productAbbreviationStopWords = new Set([ + "a", + "al", + "con", + "de", + "del", + "e", + "el", + "en", + "la", + "las", + "los", + "o", + "u", + "y" +]) + +function getProductAbbreviation(name: string) { + const words = name + .trim() + .split(/\s+/) + .filter(Boolean) + .filter(word => !productAbbreviationStopWords.has(word.toLowerCase())) + + const fallbackWords = name.trim().split(/\s+/).filter(Boolean) + + if (words.length === 0) return "?" + + if (words.length === 1) { + return (words[0] ?? fallbackWords[0] ?? "?").slice(0, 2).toUpperCase() + } + + return words + .slice(0, 2) + .map(word => word[0]) + .join("") + .toUpperCase() +} + type CartLine = SaleCartItemInput & { key: string productName: string @@ -335,16 +410,7 @@ export function QuickSaleScreen({ catalog }: { catalog: SalesCatalogData }) { cart={cart} currency={currency} scrollToBottomSignal={cartScrollSignal} - onDecrease={key => { - const line = cart.find(item => item.key === key) - if (!line) return - updateQuantity(key, line.quantity - 1) - }} - onIncrease={key => { - const line = cart.find(item => item.key === key) - if (!line) return - updateQuantity(key, line.quantity + 1) - }} + onQuantityChange={updateQuantity} />
@@ -573,15 +639,22 @@ function ProductCard({ /> ) : (
- + + {getProductAbbreviation(product.name)} +
)} {product.variantCount > 1 && (
- + {product.variantCount} opciones
@@ -639,14 +712,12 @@ function SaleCart({ cart, currency, scrollToBottomSignal, - onIncrease, - onDecrease + onQuantityChange }: { cart: CartLine[] currency: "MXN" | "USD" scrollToBottomSignal: number - onIncrease: (key: string) => void - onDecrease: (key: string) => void + onQuantityChange: (key: string, nextQuantity: number) => void }) { const scrollContainerRef = useRef(null) const scrollResetTimeoutRef = useRef(null) @@ -740,29 +811,33 @@ function SaleCart({
-
- -
- {item.quantity} -
- -
+ { + const nextQuantity = details.valueAsNumber + + if (!Number.isFinite(nextQuantity)) return + + onQuantityChange(item.key, nextQuantity) + }} + className="w-40 shrink-0" + > + + + + + +

{formatPrice(item.lineTotal, currency)}

diff --git a/src/components/ui/card.tsx b/src/components/ui/card.tsx index c6f5a946..14c10e4a 100644 --- a/src/components/ui/card.tsx +++ b/src/components/ui/card.tsx @@ -9,7 +9,8 @@ const Card = React.forwardRef<
, + Pick {} + +export const NumberInput = (props: NumberInputProps) => { + const { size = "md", className, ...rest } = props + + return ( + + ) +} + +export const NumberInputGroup = ( + props: React.ComponentProps +) => { + const { className, ...rest } = props + + return ( + + ) +} + +export const NumberInputDecrement = ( + props: React.ComponentProps +) => { + const { className, ...rest } = props + + return ( + + + + ) +} + +export const NumberInputIncrement = ( + props: React.ComponentProps +) => { + const { className, ...rest } = props + + return ( + + + + ) +} + +export const NumberInputInput = (props: React.ComponentProps) => { + const { _size, className, ...rest } = props + + return ( + + + + ) +} + +export const NumberInputScrubber = ( + props: React.ComponentProps +) => { + const { className, children, ...rest } = props + + return ( + + + {children} + + + ) +} diff --git a/styles/globals.css b/styles/globals.css index 241f0522..44f8f0ea 100644 --- a/styles/globals.css +++ b/styles/globals.css @@ -397,6 +397,12 @@ --color-3: oklch(69.6% 0.165 251); --color-4: oklch(80.2% 0.134 225); --color-5: oklch(90.7% 0.231 133); + --info: var(--color-blue-500); + --info-foreground: var(--color-blue-700); + --success: var(--color-emerald-500); + --success-foreground: var(--color-emerald-700); + --warning: var(--color-amber-500); + --warning-foreground: var(--color-amber-700); } .dark { @@ -437,6 +443,12 @@ --color-3: oklch(69.6% 0.165 251); --color-4: oklch(80.2% 0.134 225); --color-5: oklch(90.7% 0.231 133); + --info: var(--color-blue-500); + --info-foreground: var(--color-blue-400); + --success: var(--color-emerald-500); + --success-foreground: var(--color-emerald-400); + --warning: var(--color-amber-500); + --warning-foreground: var(--color-amber-400); } /* @@ -505,6 +517,12 @@ } --animate-ripple: ripple var(--duration, 10s) ease calc(var(--i, 0) * 0.2s) infinite; + --color-warning-foreground: var(--warning-foreground); + --color-warning: var(--warning); + --color-success-foreground: var(--success-foreground); + --color-success: var(--success); + --color-info-foreground: var(--info-foreground); + --color-info: var(--info); @keyframes ripple { 0%, 100% { From d4daa685ecf902a77337347aae837db0a07ccc2b Mon Sep 17 00:00:00 2001 From: Daniel Castillejo Date: Mon, 22 Jun 2026 00:05:13 -0500 Subject: [PATCH 05/18] feat: update styles and refactor number input component for improved usability --- src/components/sales/quick-sale-screen.tsx | 4 ++-- src/components/ui/input.tsx | 4 +++- src/components/ui/number-input.tsx | 16 ++++++++++------ 3 files changed, 15 insertions(+), 9 deletions(-) diff --git a/src/components/sales/quick-sale-screen.tsx b/src/components/sales/quick-sale-screen.tsx index 045a4345..fa753603 100644 --- a/src/components/sales/quick-sale-screen.tsx +++ b/src/components/sales/quick-sale-screen.tsx @@ -624,7 +624,7 @@ function ProductCard({ className={cn( "h-full overflow-hidden text-left transition-colors", isInCart - ? "inset-ring-primary/60 bg-primary/5 shadow-sm" + ? "inset-ring-primary/60 bg-primary/15 shadow-sm" : "hover:inset-ring-foreground/20 hover:bg-accent/40" )} > @@ -813,7 +813,7 @@ function SaleCart({
) { +export type InputProps = React.ComponentProps<"input"> + +function Input({ className, type, ...props }: InputProps) { return ( , - Pick {} +type NumberInputSize = "sm" | "md" | "lg" + +interface NumberInputProps extends Omit< + React.ComponentProps, + "size" +> { + size?: NumberInputSize +} export const NumberInput = (props: NumberInputProps) => { const { size = "md", className, ...rest } = props @@ -126,7 +130,7 @@ export const NumberInputIncrement = ( } export const NumberInputInput = (props: React.ComponentProps) => { - const { _size, className, ...rest } = props + const { className, ...rest } = props return ( From 7f7d6fefb748cbc910e05c113b4d7da8e5a80192 Mon Sep 17 00:00:00 2001 From: Daniel Castillejo Date: Mon, 22 Jun 2026 14:12:44 -0500 Subject: [PATCH 06/18] feat(charts): implement chart interaction hooks and utilities --- bun.lock | 91 +++ components.json | 1 + package.json | 9 + src/app/dashboard/error.tsx | 2 +- src/app/dashboard/sales/page.tsx | 53 +- src/components/charts/animation.ts | 36 + src/components/charts/bar-chart-loading.tsx | 38 + src/components/charts/bar-chart.tsx | 705 ++++++++++++++++++ src/components/charts/bar-depth-geometry.ts | 34 + src/components/charts/bar-x-axis.tsx | 155 ++++ src/components/charts/bar-y-axis.tsx | 144 ++++ src/components/charts/bar.tsx | 467 ++++++++++++ .../charts/chart-child-passthrough.ts | 117 +++ .../charts/chart-config-context.tsx | 92 +++ src/components/charts/chart-context.tsx | 426 +++++++++++ src/components/charts/chart-defs.ts | 72 ++ src/components/charts/chart-formatters.ts | 20 + src/components/charts/chart-legend-hover.tsx | 44 ++ src/components/charts/chart-phase.ts | 52 ++ src/components/charts/chart-reveal-clip.tsx | 93 +++ src/components/charts/decimate-time-series.ts | 136 ++++ .../charts/filter-data-by-x-domain.ts | 59 ++ .../charts/generate-chart-skeleton-data.ts | 42 ++ src/components/charts/grid.tsx | 318 ++++++++ src/components/charts/indicator-fade.ts | 64 ++ src/components/charts/line-loading-timing.ts | 12 + src/components/charts/loading-sweep.tsx | 481 ++++++++++++ src/components/charts/motion-utils.ts | 59 ++ .../charts/reference-area-config.ts | 69 ++ .../charts/static-chart-preview-context.tsx | 22 + .../charts/tooltip/chart-tooltip.tsx | 398 ++++++++++ src/components/charts/tooltip/date-ticker.tsx | 161 ++++ src/components/charts/tooltip/index.ts | 14 + src/components/charts/tooltip/tooltip-box.tsx | 221 ++++++ .../charts/tooltip/tooltip-content.tsx | 69 ++ src/components/charts/tooltip/tooltip-dot.tsx | 74 ++ .../charts/tooltip/tooltip-indicator.tsx | 212 ++++++ .../charts/use-animated-y-domains.ts | 254 +++++++ .../charts/use-chart-interaction.ts | 353 +++++++++ .../charts/use-chart-phase-orchestrator.ts | 187 +++++ src/components/charts/use-enter-complete.ts | 28 + src/components/charts/use-grid-shimmer.ts | 111 +++ src/components/charts/use-mount-progress.ts | 33 + .../charts/use-scheduled-tooltip.ts | 97 +++ src/components/charts/y-axis-scales.ts | 116 +++ src/components/charts/y-axis-ticks.ts | 25 + src/components/charts/y-domain-utils.ts | 128 ++++ .../sales/sales-dashboard-period-filter.tsx | 82 ++ src/components/sales/sales-dashboard.tsx | 349 +++++---- src/components/sales/sales-revenue-chart.tsx | 112 +++ src/components/ui/card.tsx | 2 +- src/lib/sales-dashboard-period.ts | 40 + src/lib/types/sales.ts | 14 +- src/server/actions/sales/queries.ts | 187 ++++- styles/globals.css | 43 ++ 55 files changed, 7021 insertions(+), 202 deletions(-) create mode 100644 src/components/charts/animation.ts create mode 100644 src/components/charts/bar-chart-loading.tsx create mode 100644 src/components/charts/bar-chart.tsx create mode 100644 src/components/charts/bar-depth-geometry.ts create mode 100644 src/components/charts/bar-x-axis.tsx create mode 100644 src/components/charts/bar-y-axis.tsx create mode 100644 src/components/charts/bar.tsx create mode 100644 src/components/charts/chart-child-passthrough.ts create mode 100644 src/components/charts/chart-config-context.tsx create mode 100644 src/components/charts/chart-context.tsx create mode 100644 src/components/charts/chart-defs.ts create mode 100644 src/components/charts/chart-formatters.ts create mode 100644 src/components/charts/chart-legend-hover.tsx create mode 100644 src/components/charts/chart-phase.ts create mode 100644 src/components/charts/chart-reveal-clip.tsx create mode 100644 src/components/charts/decimate-time-series.ts create mode 100644 src/components/charts/filter-data-by-x-domain.ts create mode 100644 src/components/charts/generate-chart-skeleton-data.ts create mode 100644 src/components/charts/grid.tsx create mode 100644 src/components/charts/indicator-fade.ts create mode 100644 src/components/charts/line-loading-timing.ts create mode 100644 src/components/charts/loading-sweep.tsx create mode 100644 src/components/charts/motion-utils.ts create mode 100644 src/components/charts/reference-area-config.ts create mode 100644 src/components/charts/static-chart-preview-context.tsx create mode 100644 src/components/charts/tooltip/chart-tooltip.tsx create mode 100644 src/components/charts/tooltip/date-ticker.tsx create mode 100644 src/components/charts/tooltip/index.ts create mode 100644 src/components/charts/tooltip/tooltip-box.tsx create mode 100644 src/components/charts/tooltip/tooltip-content.tsx create mode 100644 src/components/charts/tooltip/tooltip-dot.tsx create mode 100644 src/components/charts/tooltip/tooltip-indicator.tsx create mode 100644 src/components/charts/use-animated-y-domains.ts create mode 100644 src/components/charts/use-chart-interaction.ts create mode 100644 src/components/charts/use-chart-phase-orchestrator.ts create mode 100644 src/components/charts/use-enter-complete.ts create mode 100644 src/components/charts/use-grid-shimmer.ts create mode 100644 src/components/charts/use-mount-progress.ts create mode 100644 src/components/charts/use-scheduled-tooltip.ts create mode 100644 src/components/charts/y-axis-scales.ts create mode 100644 src/components/charts/y-axis-ticks.ts create mode 100644 src/components/charts/y-domain-utils.ts create mode 100644 src/components/sales/sales-dashboard-period-filter.tsx create mode 100644 src/components/sales/sales-revenue-chart.tsx create mode 100644 src/lib/sales-dashboard-period.ts diff --git a/bun.lock b/bun.lock index 5494b086..fc63fb84 100644 --- a/bun.lock +++ b/bun.lock @@ -18,6 +18,7 @@ "@hookform/resolvers": "^5.4.0", "@internationalized/date": "^3.12.2", "@next/bundle-analyzer": "16.2.9", + "@number-flow/react": "^0.6.0", "@prisma/adapter-libsql": "7.8.0", "@prisma/client": "7.8.0", "@radix-ui/react-aspect-ratio": "^1.1.10", @@ -55,6 +56,13 @@ "@uppy/progress-bar": "^4.3.2", "@uppy/react": "^4.5.2", "@vercel/toolbar": "^0.2.6", + "@visx/event": "4.0.1-alpha.0", + "@visx/gradient": "4.0.1-alpha.0", + "@visx/grid": "4.0.1-alpha.0", + "@visx/pattern": "4.0.1-alpha.0", + "@visx/responsive": "4.0.1-alpha.0", + "@visx/scale": "4.0.1-alpha.0", + "@visx/shape": "4.0.1-alpha.0", "ai": "^6.0.208", "better-auth": "1.6.9", "bloom-menu": "^0.1.0", @@ -63,6 +71,7 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", + "d3-array": "^3.2.4", "date-fns": "^4.4.0", "embla-carousel-autoplay": "^8.6.0", "embla-carousel-react": "^8.6.0", @@ -679,6 +688,8 @@ "@nolyfill/is-core-module": ["@nolyfill/is-core-module@1.0.39", "", {}, "sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA=="], + "@number-flow/react": ["@number-flow/react@0.6.0", "", { "dependencies": { "esm-env": "^1.1.4", "number-flow": "0.6.0" }, "peerDependencies": { "react": "^18 || ^19", "react-dom": "^18 || ^19" } }, "sha512-77Yfc9+zkV2UDSP8phhZzxJGuwxi/Tt1TikmipL+1r3e9GFKEYDZ1XwInj67NoSt3OnOB0KLvvcl3lfPZgBHVQ=="], + "@opentelemetry/api": ["@opentelemetry/api@1.9.1", "", {}, "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q=="], "@opentelemetry/api-logs": ["@opentelemetry/api-logs@0.214.0", "", { "dependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-40lSJeqYO8Uz2Yj7u94/SJWE/wONa7rmMKjI1ZcIjgf3MHNHv1OZUCrCETGuaRF62d5pQD1wKIW+L4lmSMTzZA=="], @@ -1159,6 +1170,28 @@ "@types/cors": ["@types/cors@2.8.19", "", { "dependencies": { "@types/node": "*" } }, "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg=="], + "@types/d3-array": ["@types/d3-array@3.0.3", "", {}, "sha512-Reoy+pKnvsksN0lQUlcH6dOGjRZ/3WRwXR//m+/8lt1BXeI4xyaUZoqULNjyXXRuh0Mj4LNpkCvhUpQlY3X5xQ=="], + + "@types/d3-color": ["@types/d3-color@3.1.0", "", {}, "sha512-HKuicPHJuvPgCD+np6Se9MQvS6OCbJmOjGvylzMJRlDwUXjKTTXs6Pwgk79O09Vj/ho3u1ofXnhFOaEWWPrlwA=="], + + "@types/d3-delaunay": ["@types/d3-delaunay@6.0.1", "", {}, "sha512-tLxQ2sfT0p6sxdG75c6f/ekqxjyYR0+LwPrsO1mbC9YDBzPJhs2HbJJRrn8Ez1DBoHRo2yx7YEATI+8V1nGMnQ=="], + + "@types/d3-format": ["@types/d3-format@3.0.1", "", {}, "sha512-5KY70ifCCzorkLuIkDe0Z9YTf9RR2CjBX1iaJG+rgM/cPP+sO+q9YdQ9WdhQcgPj1EQiJ2/0+yUkkziTG6Lubg=="], + + "@types/d3-geo": ["@types/d3-geo@3.1.0", "", { "dependencies": { "@types/geojson": "*" } }, "sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ=="], + + "@types/d3-interpolate": ["@types/d3-interpolate@3.0.1", "", { "dependencies": { "@types/d3-color": "*" } }, "sha512-jx5leotSeac3jr0RePOH1KdR9rISG91QIE4Q2PYTu4OymLTZfA3SrnURSLzKH48HmXVUru50b8nje4E79oQSQw=="], + + "@types/d3-path": ["@types/d3-path@3.1.1", "", {}, "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg=="], + + "@types/d3-scale": ["@types/d3-scale@4.0.2", "", { "dependencies": { "@types/d3-time": "*" } }, "sha512-Yk4htunhPAwN0XGlIwArRomOjdoBFXC3+kCxK2Ubg7I9shQlVSJy/pG/Ht5ASN+gdMIalpk8TJ5xV74jFsetLA=="], + + "@types/d3-shape": ["@types/d3-shape@3.1.7", "", { "dependencies": { "@types/d3-path": "*" } }, "sha512-VLvUQ33C+3J+8p+Daf+nYSOsjB4GXp19/S/aGo60m9h1v6XaxjiT82lKVWJCfzhtuZ3yD7i/TPeC/fuKLLOSmg=="], + + "@types/d3-time": ["@types/d3-time@3.0.0", "", {}, "sha512-sZLCdHvBUcNby1cB6Fd3ZBrABbjz3v1Vm90nysCQ6Vt7vd6e/h9Lt7SiJUoEX0l4Dzc7P5llKyhqSi1ycSf1Hg=="], + + "@types/d3-time-format": ["@types/d3-time-format@2.1.0", "", {}, "sha512-/myT3I7EwlukNOX2xVdMzb8FRgNzRMpsZddwst9Ld/VFe6LyJyRp0s32l/V9XoUzk+Gqu56F/oGk6507+8BxrA=="], + "@types/debug": ["@types/debug@4.1.12", "", { "dependencies": { "@types/ms": "*" } }, "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ=="], "@types/eslint": ["@types/eslint@9.6.1", "", { "dependencies": { "@types/estree": "*", "@types/json-schema": "*" } }, "sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag=="], @@ -1169,6 +1202,8 @@ "@types/estree-jsx": ["@types/estree-jsx@1.0.5", "", { "dependencies": { "@types/estree": "*" } }, "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg=="], + "@types/geojson": ["@types/geojson@7946.0.16", "", {}, "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg=="], + "@types/hast": ["@types/hast@3.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ=="], "@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="], @@ -1393,6 +1428,28 @@ "@vercel/toolbar": ["@vercel/toolbar@0.2.6", "", { "dependencies": { "@tinyhttp/app": "1.3.0", "@vercel/microfrontends": "^2.3.1", "chokidar": "^3.5.3", "execa": "5.1.1", "find-up": "5.0.0", "get-port": "5.1.1", "strip-ansi": "6.0.1" }, "peerDependencies": { "next": ">=11.0.0", "nuxt": ">=3.0.0", "react": ">=17", "vite": ">=5" }, "optionalPeers": ["next", "nuxt", "react", "vite"] }, "sha512-islPzlNZ7yBc9l/V5rUpBsBaF8+0UaO+iC8TDk4BF0YKiCQkQKItI6ik/k05pDvpoPWzK6/TvfPwb+It98kjsw=="], + "@visx/curve": ["@visx/curve@4.0.1-alpha.0", "", { "dependencies": { "@visx/vendor": "4.0.0-alpha.0" } }, "sha512-jRu61Uz274pV1zyioXmboyrLutYbnKsgjj4njSGCnhdXj5GkZvZbg+ThDb6oOzoAnJOBRLz4rzPlWvNJOzuVMg=="], + + "@visx/event": ["@visx/event@4.0.1-alpha.0", "", { "dependencies": { "@types/react": "*", "@visx/point": "4.0.1-alpha.0" } }, "sha512-EQqCMSv/s8NbFjo+hz3FKsvvYfP+2QslsFJ/24/O5l/W+7UC6J6aAvO0ujVwrTwdYbuQ+vhxKi1xdPdKR/qj1g=="], + + "@visx/gradient": ["@visx/gradient@4.0.1-alpha.0", "", { "dependencies": { "@types/react": "*" }, "peerDependencies": { "react": "^16.14.0 || ^17.0.0-0 || ^18.0.0-0 || ^19.0.0-0" } }, "sha512-HxkxLdThV/gPaulw+t7/ESo7AtqIBq9IlHIkHi7lQqXe0Yl+x8rpM3KYlYqEZjtihD9CeEaVJjBbdY7fQb54IA=="], + + "@visx/grid": ["@visx/grid@4.0.1-alpha.0", "", { "dependencies": { "@types/react": "*", "@visx/curve": "4.0.1-alpha.0", "@visx/group": "4.0.1-alpha.0", "@visx/point": "4.0.1-alpha.0", "@visx/scale": "4.0.1-alpha.0", "@visx/shape": "4.0.1-alpha.0", "classnames": "^2.3.1" }, "peerDependencies": { "react": "^16.14.0 || ^17.0.0-0 || ^18.0.0-0 || ^19.0.0-0" } }, "sha512-rycutGmTHO+znNdPumheWMglm7YfpffvRwUkVy5zy4WoORIuKTMkDxwnOzHG2xMxU3EE/YCd37xFV5AxA30yeg=="], + + "@visx/group": ["@visx/group@4.0.1-alpha.0", "", { "dependencies": { "@types/react": "*", "classnames": "^2.3.1" }, "peerDependencies": { "react": "^16.14.0 || ^17.0.0-0 || ^18.0.0-0 || ^19.0.0-0" } }, "sha512-V19l7iQ7jccBv8kao/EByuI6o4xtxzzLV9nqVI1hRvmdzTVsuLpqlwzYCZUXJaTVvUWf8s4D2SQFjGkj/Nw+0w=="], + + "@visx/pattern": ["@visx/pattern@4.0.1-alpha.0", "", { "dependencies": { "@types/react": "*", "classnames": "^2.3.1" }, "peerDependencies": { "react": "^16.14.0 || ^17.0.0-0 || ^18.0.0-0 || ^19.0.0-0" } }, "sha512-KCqHpjtHdu95uqamc5PIYG55Xp0w+7/Zz63k4eeZErwK1o0PN2rZiBiuodNtENL8NqIU2b5AP+XI/xXaUH0M4Q=="], + + "@visx/point": ["@visx/point@4.0.1-alpha.0", "", {}, "sha512-ijTfr/Nx09f03vIj9nyTr3z4Xth4Y75427UaogJh6dnIRLMEFHQOwNu791sbfiNj0a+ZXuaE32h0vKrFe4/8Qg=="], + + "@visx/responsive": ["@visx/responsive@4.0.1-alpha.0", "", { "dependencies": { "@types/lodash": "^4.17.13", "@types/react": "*", "lodash": "^4.17.21" }, "peerDependencies": { "react": "^16.14.0 || ^17.0.0-0 || ^18.0.0-0 || ^19.0.0-0" } }, "sha512-o+1zGywQZY0+yOx3Iw87wc4bbPJRr/HnIukTwfOz4UVyj9pB1OQNVHB7OORO1+LBHJceWpB31co/ZV9KHncKrA=="], + + "@visx/scale": ["@visx/scale@4.0.1-alpha.0", "", { "dependencies": { "@visx/vendor": "4.0.0-alpha.0" } }, "sha512-nzjeE87vFSAXGWFiiNfBpNLAf0Q8Qmf6syvKLjqNi4kGZkdhbUll3E/59YsgWXmjM8+llPLWzGsP+JPvo5eq1A=="], + + "@visx/shape": ["@visx/shape@4.0.1-alpha.0", "", { "dependencies": { "@types/lodash": "^4.17.13", "@types/react": "*", "@visx/curve": "4.0.1-alpha.0", "@visx/group": "4.0.1-alpha.0", "@visx/scale": "4.0.1-alpha.0", "@visx/vendor": "4.0.0-alpha.0", "classnames": "^2.3.1", "lodash": "^4.17.21" }, "peerDependencies": { "react": "^16.14.0 || ^17.0.0-0 || ^18.0.0-0 || ^19.0.0-0" } }, "sha512-62QeiVNmPlterQGwhkEDcbq7M0MqY0lBsK5QKXtM9ZoPZWkuGV3aykA3+Xu20B2FAvyJq4LqJzBc7Sxr+EAdbA=="], + + "@visx/vendor": ["@visx/vendor@4.0.0-alpha.0", "", { "dependencies": { "@types/d3-array": "3.0.3", "@types/d3-color": "3.1.0", "@types/d3-delaunay": "6.0.1", "@types/d3-format": "3.0.1", "@types/d3-geo": "3.1.0", "@types/d3-interpolate": "3.0.1", "@types/d3-path": "3.1.1", "@types/d3-scale": "4.0.2", "@types/d3-shape": "3.1.7", "@types/d3-time": "3.0.0", "@types/d3-time-format": "2.1.0", "d3-array": "3.2.1", "d3-color": "3.1.0", "d3-delaunay": "6.0.2", "d3-format": "3.1.0", "d3-geo": "3.1.0", "d3-interpolate": "3.0.1", "d3-path": "3.1.0", "d3-scale": "4.0.2", "d3-shape": "3.2.0", "d3-time": "3.1.0", "d3-time-format": "4.1.0", "internmap": "2.0.3" } }, "sha512-6I+MuqXBcv9jnlcVowHoHKSdk9gXTWkHLKyqBwRWg7LY6A3Ei8SHfubpqGV5rBUSppxMq2RszPJUS6w+H0YgmQ=="], + "@webassemblyjs/ast": ["@webassemblyjs/ast@1.14.1", "", { "dependencies": { "@webassemblyjs/helper-numbers": "1.13.2", "@webassemblyjs/helper-wasm-bytecode": "1.13.2" } }, "sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ=="], "@webassemblyjs/floating-point-hex-parser": ["@webassemblyjs/floating-point-hex-parser@1.13.2", "", {}, "sha512-6oXyTOzbKxGH4steLbLNOu71Oj+C8Lg34n6CqRvqfS2O71BxY6ByfMDRhBytzknj9yGUPVJ1qIKhRlAwO1AovA=="], @@ -1835,6 +1892,28 @@ "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], + "d3-array": ["d3-array@3.2.4", "", { "dependencies": { "internmap": "1 - 2" } }, "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg=="], + + "d3-color": ["d3-color@3.1.0", "", {}, "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA=="], + + "d3-delaunay": ["d3-delaunay@6.0.2", "", { "dependencies": { "delaunator": "5" } }, "sha512-IMLNldruDQScrcfT+MWnazhHbDJhcRJyOEBAJfwQnHle1RPh6WDuLvxNArUju2VSMSUuKlY5BGHRJ2cYyoFLQQ=="], + + "d3-format": ["d3-format@3.1.0", "", {}, "sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA=="], + + "d3-geo": ["d3-geo@3.1.0", "", { "dependencies": { "d3-array": "2.5.0 - 3" } }, "sha512-JEo5HxXDdDYXCaWdwLRt79y7giK8SbhZJbFWXqbRTolCHFI5jRqteLzCsq51NKbUoX0PjBVSohxrx+NoOUujYA=="], + + "d3-interpolate": ["d3-interpolate@3.0.1", "", { "dependencies": { "d3-color": "1 - 3" } }, "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g=="], + + "d3-path": ["d3-path@3.1.0", "", {}, "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ=="], + + "d3-scale": ["d3-scale@4.0.2", "", { "dependencies": { "d3-array": "2.10.0 - 3", "d3-format": "1 - 3", "d3-interpolate": "1.2.0 - 3", "d3-time": "2.1.1 - 3", "d3-time-format": "2 - 4" } }, "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ=="], + + "d3-shape": ["d3-shape@3.2.0", "", { "dependencies": { "d3-path": "^3.1.0" } }, "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA=="], + + "d3-time": ["d3-time@3.1.0", "", { "dependencies": { "d3-array": "2 - 3" } }, "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q=="], + + "d3-time-format": ["d3-time-format@4.1.0", "", { "dependencies": { "d3-time": "1 - 3" } }, "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg=="], + "damerau-levenshtein": ["damerau-levenshtein@1.0.8", "", {}, "sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA=="], "data-uri-to-buffer": ["data-uri-to-buffer@4.0.1", "", {}, "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A=="], @@ -1877,6 +1956,8 @@ "defu": ["defu@6.1.7", "", {}, "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ=="], + "delaunator": ["delaunator@5.1.0", "", { "dependencies": { "robust-predicates": "^3.0.2" } }, "sha512-AGrQ4QSgssa1NGmWmLPqN5NY2KajF5MqxetNEO+o0n3ZwZZeTmt7bBnvzHWrmkZFxGgr4HdyFgelzgi06otLuQ=="], + "denque": ["denque@2.1.0", "", {}, "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw=="], "depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="], @@ -2011,6 +2092,8 @@ "eslint-visitor-keys": ["eslint-visitor-keys@4.2.1", "", {}, "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ=="], + "esm-env": ["esm-env@1.2.2", "", {}, "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA=="], + "espree": ["espree@10.4.0", "", { "dependencies": { "acorn": "^8.15.0", "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^4.2.1" } }, "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ=="], "esprima": ["esprima@4.0.1", "", { "bin": { "esparse": "./bin/esparse.js", "esvalidate": "./bin/esvalidate.js" } }, "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A=="], @@ -2291,6 +2374,8 @@ "internal-slot": ["internal-slot@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "hasown": "^2.0.2", "side-channel": "^1.1.0" } }, "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw=="], + "internmap": ["internmap@2.0.3", "", {}, "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg=="], + "ip-address": ["ip-address@10.1.0", "", {}, "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q=="], "ipaddr.js": ["ipaddr.js@2.3.0", "", {}, "sha512-Zv/pA+ciVFbCSBBjGfaKUya/CcGmUHzTydLMaTwrUUEM2DIEO3iZvueGxmacvmN50fGpGVKeTXpb2LcYQxeVdg=="], @@ -2727,6 +2812,8 @@ "npm-run-path": ["npm-run-path@6.0.0", "", { "dependencies": { "path-key": "^4.0.0", "unicorn-magic": "^0.3.0" } }, "sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA=="], + "number-flow": ["number-flow@0.6.0", "", { "dependencies": { "esm-env": "^1.1.4" } }, "sha512-K8flNq2Wqus53vjp/btVo3qXFkagF8dIdYavreBfE7hlvFFG/b1HMGEH6nZL+mlrJ+4lbLP9OmPv3t2rmRkpSQ=="], + "nuqs": ["nuqs@2.8.9", "", { "dependencies": { "@standard-schema/spec": "1.0.0" }, "peerDependencies": { "@remix-run/react": ">=2", "@tanstack/react-router": "^1", "next": ">=14.2.0", "react": ">=18.2.0 || ^19.0.0-0", "react-router": "^5 || ^6 || ^7", "react-router-dom": "^5 || ^6 || ^7" }, "optionalPeers": ["@remix-run/react", "@tanstack/react-router", "next", "react-router", "react-router-dom"] }, "sha512-8ou6AEwsxMWSYo2qkfZtYFVzngwbKmg4c00HVxC1fF6CEJv3Fwm6eoZmfVPALB+vw8Udo7KL5uy96PFcYe1BIQ=="], "nypm": ["nypm@0.6.6", "", { "dependencies": { "citty": "^0.2.2", "pathe": "^2.0.3", "tinyexec": "^1.1.1" }, "bin": { "nypm": "dist/cli.mjs" } }, "sha512-vRyr0r4cbBapw07Xw8xrj9Teq3o7MUD35rSaTcanDbW+aK2XHDgJFiU6ZTj2GBw7Q12ysdsyFss+Vdz4hQ0Y6Q=="], @@ -3037,6 +3124,8 @@ "rfdc": ["rfdc@1.4.1", "", {}, "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA=="], + "robust-predicates": ["robust-predicates@3.0.3", "", {}, "sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA=="], + "rollup": ["rollup@4.62.0", "", { "dependencies": { "@types/estree": "1.0.9" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.62.0", "@rollup/rollup-android-arm64": "4.62.0", "@rollup/rollup-darwin-arm64": "4.62.0", "@rollup/rollup-darwin-x64": "4.62.0", "@rollup/rollup-freebsd-arm64": "4.62.0", "@rollup/rollup-freebsd-x64": "4.62.0", "@rollup/rollup-linux-arm-gnueabihf": "4.62.0", "@rollup/rollup-linux-arm-musleabihf": "4.62.0", "@rollup/rollup-linux-arm64-gnu": "4.62.0", "@rollup/rollup-linux-arm64-musl": "4.62.0", "@rollup/rollup-linux-loong64-gnu": "4.62.0", "@rollup/rollup-linux-loong64-musl": "4.62.0", "@rollup/rollup-linux-ppc64-gnu": "4.62.0", "@rollup/rollup-linux-ppc64-musl": "4.62.0", "@rollup/rollup-linux-riscv64-gnu": "4.62.0", "@rollup/rollup-linux-riscv64-musl": "4.62.0", "@rollup/rollup-linux-s390x-gnu": "4.62.0", "@rollup/rollup-linux-x64-gnu": "4.62.0", "@rollup/rollup-linux-x64-musl": "4.62.0", "@rollup/rollup-openbsd-x64": "4.62.0", "@rollup/rollup-openharmony-arm64": "4.62.0", "@rollup/rollup-win32-arm64-msvc": "4.62.0", "@rollup/rollup-win32-ia32-msvc": "4.62.0", "@rollup/rollup-win32-x64-gnu": "4.62.0", "@rollup/rollup-win32-x64-msvc": "4.62.0", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-nc72Wgq62I7rtDV4izT5/aaS0zxy3kttkinf9586ApknY3jZO9NYsmtc24fUckA0X7Q2v+ML4a15pdUlV5V/jA=="], "rou3": ["rou3@0.7.12", "", {}, "sha512-iFE4hLDuloSWcD7mjdCDhx2bKcIsYbtOTpfH5MHHLSKMOUyjqQXTeZVa289uuwEGEKFoE/BAPbhaU4B774nceg=="], @@ -3585,6 +3674,8 @@ "@vercel/toolbar/execa": ["execa@5.1.1", "", { "dependencies": { "cross-spawn": "^7.0.3", "get-stream": "^6.0.0", "human-signals": "^2.1.0", "is-stream": "^2.0.0", "merge-stream": "^2.0.0", "npm-run-path": "^4.0.1", "onetime": "^5.1.2", "signal-exit": "^3.0.3", "strip-final-newline": "^2.0.0" } }, "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg=="], + "@visx/vendor/d3-array": ["d3-array@3.2.1", "", { "dependencies": { "internmap": "1 - 2" } }, "sha512-gUY/qeHq/yNqqoCKNq4vtpFLdoCdvyNpWoC/KNjhGbhDuQpAM9sIQQKkXSNpXa9h5KySs/gzm7R88WkUutgwWQ=="], + "@zag-js/number-input/@internationalized/number": ["@internationalized/number@3.6.6", "", { "dependencies": { "@swc/helpers": "^0.5.0" } }, "sha512-iFgmQaXHE0vytNfpLZWOC2mEJCBRzcUxt53Xf/yCXG93lRvqas237i3r7X4RKMwO3txiyZD4mQjKAByFv6UGSQ=="], "accepts/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], diff --git a/components.json b/components.json index 0526bd30..789031eb 100644 --- a/components.json +++ b/components.json @@ -19,6 +19,7 @@ "hooks": "@/hooks" }, "registries": { + "@bklit": "https://ui.bklit.com/r/{name}.json", "@magicui": "https://magicui.design/r/{name}.json", "@kibo-ui": "https://www.kibo-ui.com/r/{name}.json", "@diceui": "https://diceui.com/r/{name}.json", diff --git a/package.json b/package.json index 68dbd7e7..e132bb37 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,7 @@ "@hookform/resolvers": "^5.4.0", "@internationalized/date": "^3.12.2", "@next/bundle-analyzer": "16.2.9", + "@number-flow/react": "^0.6.0", "@prisma/adapter-libsql": "7.8.0", "@prisma/client": "7.8.0", "@radix-ui/react-aspect-ratio": "^1.1.10", @@ -72,6 +73,13 @@ "@uppy/progress-bar": "^4.3.2", "@uppy/react": "^4.5.2", "@vercel/toolbar": "^0.2.6", + "@visx/event": "4.0.1-alpha.0", + "@visx/gradient": "4.0.1-alpha.0", + "@visx/grid": "4.0.1-alpha.0", + "@visx/pattern": "4.0.1-alpha.0", + "@visx/responsive": "4.0.1-alpha.0", + "@visx/scale": "4.0.1-alpha.0", + "@visx/shape": "4.0.1-alpha.0", "ai": "^6.0.208", "better-auth": "1.6.9", "bloom-menu": "^0.1.0", @@ -80,6 +88,7 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", + "d3-array": "^3.2.4", "date-fns": "^4.4.0", "embla-carousel-autoplay": "^8.6.0", "embla-carousel-react": "^8.6.0", diff --git a/src/app/dashboard/error.tsx b/src/app/dashboard/error.tsx index ca17d118..096690c3 100644 --- a/src/app/dashboard/error.tsx +++ b/src/app/dashboard/error.tsx @@ -21,7 +21,7 @@ export default function DashboardError({

- Ocurrió un error en el dashboard + Ocurrió un error en la aplicación

Intenta nuevamente o vuelve al inicio. Si el problema persiste, diff --git a/src/app/dashboard/sales/page.tsx b/src/app/dashboard/sales/page.tsx index 3c1de16d..59ac5c54 100644 --- a/src/app/dashboard/sales/page.tsx +++ b/src/app/dashboard/sales/page.tsx @@ -1,52 +1,59 @@ -import { Banknote, Plus, ReceiptText } from "lucide-react" +import { Banknote } from "lucide-react" import type { Metadata } from "next" -import Link from "next/link" import { notFound } from "next/navigation" +import { createLoader, parseAsStringEnum } from "nuqs/server" import PageSubtitle from "@/components/dashboard/page-subtitle" import { SalesDashboard } from "@/components/sales/sales-dashboard" -import { Button } from "@/components/ui/button" +import { SalesDashboardPeriodFilter } from "@/components/sales/sales-dashboard-period-filter" import { getSalesDashboardData } from "@/server/actions/sales/queries" import { getCurrentOrganization } from "@/server/actions/user/queries" +import { + defaultSalesDashboardPeriod, + salesDashboardPeriodValues +} from "@/lib/sales-dashboard-period" export const metadata: Metadata = { title: "Ventas" } -export default async function SalesPage() { - const currentOrg = await getCurrentOrganization() +const loadSalesDashboardSearchParams = createLoader({ + period: parseAsStringEnum([...salesDashboardPeriodValues]).withDefault( + defaultSalesDashboardPeriod + ) +}) + +export default async function SalesPage(props: { + searchParams: Promise<{ [key: string]: string | string[] | undefined }> +}) { + const [{ period }, currentOrg] = await Promise.all([ + loadSalesDashboardSearchParams(props.searchParams), + getCurrentOrganization() + ]) if (!currentOrg) { notFound() } - const data = await getSalesDashboardData(currentOrg.id) + const data = await getSalesDashboardData(currentOrg.id, period) return (

- + Ventas Captura rápida y métricas de ventas - -
- - -
+ +
diff --git a/src/components/charts/animation.ts b/src/components/charts/animation.ts new file mode 100644 index 00000000..7286c7c4 --- /dev/null +++ b/src/components/charts/animation.ts @@ -0,0 +1,36 @@ +import type { Transition } from "motion/react" + +/** Default clip-reveal easing for cartesian charts. */ +export const DEFAULT_ANIMATION_EASING = "cubic-bezier(0.85, 0, 0.15, 1)" + +export const DEFAULT_ANIMATION_DURATION_MS = 1100 + +/** Default enter transition — matches the original line chart reveal. */ +export const DEFAULT_CHART_ENTER_TRANSITION: Transition = { + type: "tween", + duration: DEFAULT_ANIMATION_DURATION_MS / 1000, + ease: [0.85, 0, 0.15, 1] +} + +/** + * Clip-path width reveal must use tween — spring does not reliably animate SVG width. + */ +export function clipRevealTransition(enterTransition?: Transition): Transition { + if (enterTransition?.type === "tween") { + return { + ...enterTransition, + ease: enterTransition.ease ?? DEFAULT_CHART_ENTER_TRANSITION.ease + } + } + + const duration = + typeof enterTransition?.duration === "number" + ? enterTransition.duration + : DEFAULT_ANIMATION_DURATION_MS / 1000 + + return { + type: "tween", + duration, + ease: DEFAULT_CHART_ENTER_TRANSITION.ease + } +} diff --git a/src/components/charts/bar-chart-loading.tsx b/src/components/charts/bar-chart-loading.tsx new file mode 100644 index 00000000..e521a084 --- /dev/null +++ b/src/components/charts/bar-chart-loading.tsx @@ -0,0 +1,38 @@ +"use client" + +import { BarChart } from "./bar-chart" +import type { Margin } from "./chart-context" + +const EMPTY_DATA: Record[] = [] + +export interface BarChartLoadingProps { + /** Chart margins. */ + margin?: Partial + /** Aspect ratio as "width / height". Default: "2 / 1" */ + aspectRatio?: string + /** Additional class name for the container. */ + className?: string +} + +/** + * Turnkey loading skeleton for bar charts, a thin shortcut for + * ``. Renders shimmer-swept placeholder bars while + * data is fetching; swap in a real `` once it resolves. + */ +export function BarChartLoading({ + margin, + aspectRatio = "2 / 1", + className = "" +}: BarChartLoadingProps) { + return ( + + ) +} + +export default BarChartLoading diff --git a/src/components/charts/bar-chart.tsx b/src/components/charts/bar-chart.tsx new file mode 100644 index 00000000..fa6bdb9a --- /dev/null +++ b/src/components/charts/bar-chart.tsx @@ -0,0 +1,705 @@ +"use client" + +import { + memo, + useCallback, + useEffect, + useMemo, + useRef, + useState, + type ReactElement, + type ReactNode +} from "react" +import { localPoint } from "@visx/event" +import { ParentSize } from "@visx/responsive" +import { scaleBand, scaleLinear } from "@visx/scale" +import type { Transition } from "motion/react" + +import { cn } from "@/lib/utils" +import { DEFAULT_ANIMATION_EASING } from "./animation" +import type { BarProps } from "./bar" +import { + forEachChartChild, + isChartClipPassthrough, + isClipExcludedComponent, + isPostOverlayComponent, + isUnderlayComponent, + renderKeyedChartLayers, + resolveChartChildElement +} from "./chart-child-passthrough" +import { + ChartProvider, + type ChartContextValue, + type LineConfig, + type Margin, + type TooltipData +} from "./chart-context" +import { isGradientDefComponent, isPatternDefComponent } from "./chart-defs" +import { shortDateFmt } from "./chart-formatters" +import { + DEFAULT_CHART_LIFECYCLE, + resolveRestingChartPhase, + type ChartPhase, + type ChartStatus +} from "./chart-phase" +import { BarLoadingSkeleton } from "./loading-sweep" +import { extractReferenceAreaConfigs } from "./reference-area-config" +import { useScheduledTooltip } from "./use-scheduled-tooltip" +import { + buildYScalesForLines, + getPrimaryYScale, + normalizeYAxisId, + wrapSingleYScale +} from "./y-axis-scales" + +/** Skeleton bars to show when `status="loading"` and `data` is empty. */ +const FALLBACK_LOADING_BARS = 12 + +export type BarOrientation = "vertical" | "horizontal" + +export interface BarChartProps { + /** Data array - each item should have an x-axis key and numeric values */ + data: Record[] + /** Key in data for the categorical axis. Default: "name" */ + xDataKey?: string + /** Chart margins */ + margin?: Partial + /** Animation duration in milliseconds. Default: 1100 */ + animationDuration?: number + /** CSS easing for bar grow transitions. */ + animationEasing?: string + /** Motion enter transition (spring or cubic-bezier tween). */ + enterTransition?: Transition + /** Signature of motion URL state — triggers enter replay when it changes. */ + revealSignature?: string + /** Aspect ratio as "width / height". Default: "2 / 1" */ + aspectRatio?: string + /** Additional class name for the container */ + className?: string + /** Gap between bar groups as a fraction of band width (0-1). Default: 0.2 */ + barGap?: number + /** Fixed bar width in pixels. If not set, bars auto-size to fill the band. */ + barWidth?: number + /** Bar chart orientation. Default: "vertical" */ + orientation?: BarOrientation + /** Whether to stack bars instead of grouping them. Default: false */ + stacked?: boolean + /** Gap between stacked bar segments in pixels. Default: 0 */ + stackGap?: number + /** Child components (Bar, Grid, ChartTooltip, etc.). Optional — omit for a + * pure `status="loading"` skeleton. */ + children?: ReactNode + /** Reports reveal lifecycle for OG screenshots and loading orchestration. */ + onPhaseChange?: (phase: ChartPhase) => void + /** Fetch / display status. When `"loading"`, a shimmer skeleton replaces the + * bars (no chart data required). Default: `"ready"`. */ + status?: ChartStatus +} + +const DEFAULT_MARGIN: Margin = { top: 40, right: 40, bottom: 40, left: 40 } + +// Extract bar configs from children synchronously +function extractBarConfigs(children: ReactNode): LineConfig[] { + const configs: LineConfig[] = [] + + forEachChartChild(children, child => { + const childType = child.type as { + displayName?: string + name?: string + __isBarDepthLayer?: boolean + } + // Bar-depth surface layers (BarDepthBack/Front, BarPulse) carry a + // `dataKey` to pair with a Bar but are not series themselves — skip them + // so they don't inflate the series count and shrink the real bars. + if (childType.__isBarDepthLayer) { + return + } + const componentName = + typeof child.type === "function" + ? childType.displayName || childType.name || "" + : "" + + const props = child.props as BarProps | undefined + const isBarComponent = + componentName === "Bar" || + (props && typeof props.dataKey === "string" && props.dataKey.length > 0) + + if (isBarComponent && props?.dataKey) { + // Use stroke for tooltip dot color if provided, otherwise fall back to fill + // This allows gradient/pattern fills to have a solid dot color + const dotColor = props.stroke || props.fill || "var(--chart-line-primary)" + configs.push({ + dataKey: props.dataKey, + stroke: dotColor, + strokeWidth: 0, + yAxisId: props.yAxisId + }) + } + }) + + return configs +} + +interface ChartInnerProps { + width: number + height: number + data: Record[] + xDataKey: string + margin: Margin + animationDuration: number + animationEasing: string + enterTransition?: Transition + revealSignature?: string + barGap: number + barWidthProp?: number + orientation: BarOrientation + stacked: boolean + stackGap: number + children: ReactNode + containerRef: React.RefObject + onPhaseChange?: (phase: ChartPhase) => void + status: ChartStatus +} + +function ChartInner(props: ChartInnerProps) { + const { width, height } = props + if (width < 10 || height < 10) { + return null + } + return +} + +const ChartCore = memo(function ChartCore({ + width, + height, + data, + xDataKey, + margin, + animationDuration, + animationEasing, + enterTransition, + revealSignature = "", + barGap, + barWidthProp, + orientation, + stacked, + stackGap, + children, + containerRef, + onPhaseChange, + status +}: ChartInnerProps) { + const { tooltipData, setTooltipData, scheduleTooltip, clearTooltip } = + useScheduledTooltip() + const [isLoaded, setIsLoaded] = useState(false) + const [revealEpoch, setRevealEpoch] = useState(0) + const hoveredBarIndex = tooltipData?.index ?? null + + const isHorizontal = orientation === "horizontal" + + // Extract bar configs synchronously from children + const lines = useMemo(() => extractBarConfigs(children), [children]) + + const innerWidth = width - margin.left - margin.right + const innerHeight = height - margin.top - margin.bottom + + // Category accessor function - returns string for categorical scale + const categoryAccessor = useCallback( + (d: Record): string => { + const value = d[xDataKey] + if (value instanceof Date) { + return shortDateFmt.format(value) + } + return String(value ?? "") + }, + [xDataKey] + ) + + // For compatibility with ChartContext, provide a Date-based xAccessor + const xAccessorDate = useCallback( + (d: Record): Date => { + const value = d[xDataKey] + if (value instanceof Date) { + return value + } + return new Date() + }, + [xDataKey] + ) + + // Category scale (band) - for the categorical axis + const categoryScale = useMemo(() => { + const domain = data.map(d => categoryAccessor(d)) + const range: [number, number] = isHorizontal + ? [0, innerHeight] + : [0, innerWidth] + return scaleBand({ + range, + domain, + padding: barGap + }) + }, [innerWidth, innerHeight, data, categoryAccessor, barGap, isHorizontal]) + + // Band width for bars - use prop if provided, otherwise use scale's bandwidth + const bandWidth = barWidthProp ?? categoryScale.bandwidth() + + // Compute max value considering stacking + const maxValue = useMemo(() => { + if (stacked) { + // For stacked bars, sum all values at each data point + let max = 0 + for (const d of data) { + let sum = 0 + for (const line of lines) { + const value = d[line.dataKey] + if (typeof value === "number") { + sum += value + } + } + if (sum > max) { + max = sum + } + } + return max || 100 + } + // For grouped bars, find max single value + let max = 0 + for (const line of lines) { + for (const d of data) { + const value = d[line.dataKey] + if (typeof value === "number" && value > max) { + max = value + } + } + } + return max || 100 + }, [data, lines, stacked]) + + // Value scale (linear) - for the value axis + const valueScale = useMemo(() => { + const range = isHorizontal ? [0, innerWidth] : [innerHeight, 0] + return scaleLinear({ + range, + domain: [0, maxValue * 1.1], + nice: true + }) + }, [innerWidth, innerHeight, maxValue, isHorizontal]) + + const yScales = useMemo(() => { + if (isHorizontal) { + return wrapSingleYScale(valueScale) + } + return buildYScalesForLines({ + lines, + data, + innerHeight, + resolveDomain: dataKeys => { + let max = 0 + for (const d of data) { + for (const key of dataKeys) { + const value = d[key] + if (typeof value === "number" && value > max) { + max = value + } + } + } + return [0, (max || 100) * 1.1] + } + }) + }, [data, innerHeight, isHorizontal, lines, valueScale]) + + const primaryYScale = getPrimaryYScale(yScales, valueScale) + + // Compute stack offsets for stacked bars + const stackOffsets = useMemo(() => { + if (!stacked) { + return undefined + } + const offsets = new Map>() + for (let i = 0; i < data.length; i++) { + const d = data[i] + if (!d) { + continue + } + const pointOffsets = new Map() + let cumulative = 0 + for (const line of lines) { + pointOffsets.set(line.dataKey, cumulative) + const value = d[line.dataKey] + if (typeof value === "number") { + cumulative += value + } + } + offsets.set(i, pointOffsets) + } + return offsets + }, [data, lines, stacked]) + + // Column width for tooltip indicator + const columnWidth = useMemo(() => { + if (data.length < 1) { + return 0 + } + return isHorizontal ? innerHeight / data.length : innerWidth / data.length + }, [innerWidth, innerHeight, data.length, isHorizontal]) + + // Pre-compute labels for ticker animation + const dateLabels = useMemo( + () => data.map(d => categoryAccessor(d)), + [data, categoryAccessor] + ) + + // Create a fake time scale for compatibility with ChartContext + const fakeTimeScale = useMemo(() => { + const now = Date.now() + const start = now - data.length * 24 * 60 * 60 * 1000 + const scale = { + ...categoryScale, + domain: () => [new Date(start), new Date(now)], + range: () => [0, innerWidth] as [number, number], + invert: (x: number) => new Date(start + (x / innerWidth) * (now - start)), + copy: () => scale + } + return scale + }, [categoryScale, innerWidth, data.length]) + + // Animation timing — replay when motion settings change + // biome-ignore lint/correctness/useExhaustiveDependencies: revealSignature + useEffect(() => { + setRevealEpoch(n => n + 1) + setIsLoaded(false) + // While loading, hold the skeleton (no reveal, no interaction). When + // status flips to "ready" this effect re-runs and plays the grow reveal. + if (status === "loading") { + return + } + const timer = setTimeout(() => { + setIsLoaded(true) + }, animationDuration) + return () => clearTimeout(timer) + }, [animationDuration, revealSignature, status]) + + useEffect(() => { + onPhaseChange?.(isLoaded ? "ready" : "revealing") + }, [isLoaded, onPhaseChange]) + + // Mouse move handler + const handleMouseMove = useCallback( + (event: React.MouseEvent) => { + const point = localPoint(event) + if (!point) { + return + } + + const pos = isHorizontal ? point.y - margin.top : point.x - margin.left + + // Find which band the mouse is over + const bandIndex = Math.floor(pos / columnWidth) + const clampedIndex = Math.max(0, Math.min(data.length - 1, bandIndex)) + const d = data[clampedIndex] + + if (!d) { + return + } + + // Calculate positions for each bar + const yPositions: Record = {} + const xPositions: Record = {} + const barPos = categoryScale(categoryAccessor(d)) ?? 0 + + if (isHorizontal) { + // Horizontal bars: dots at end of bar (x = value), centered vertically in band + const seriesCount = lines.length + const groupGap = seriesCount > 1 ? 4 : 0 + const individualBarHeight = + seriesCount > 0 + ? (bandWidth - groupGap * (seriesCount - 1)) / seriesCount + : bandWidth + + if (stacked) { + // Stacked horizontal: all bars same y, x at cumulative end + let cumulative = 0 + for (const line of lines) { + const value = d[line.dataKey] + if (typeof value === "number") { + cumulative += value + const axisScale = + yScales[normalizeYAxisId(line.yAxisId)] ?? valueScale + xPositions[line.dataKey] = axisScale(cumulative) ?? 0 + yPositions[line.dataKey] = barPos + bandWidth / 2 + } + } + } else { + // Grouped horizontal: each bar at its own y position + lines.forEach((line, idx) => { + const value = d[line.dataKey] + if (typeof value === "number") { + const axisScale = + yScales[normalizeYAxisId(line.yAxisId)] ?? valueScale + xPositions[line.dataKey] = axisScale(value) ?? 0 + yPositions[line.dataKey] = + barPos + + idx * (individualBarHeight + groupGap) + + individualBarHeight / 2 + } + }) + } + } else if (stacked) { + // Vertical stacked bars + let cumulative = 0 + let seriesIdx = 0 + for (const line of lines) { + const value = d[line.dataKey] + if (typeof value === "number") { + cumulative += value + const axisScale = + yScales[normalizeYAxisId(line.yAxisId)] ?? primaryYScale + const gapOffset = seriesIdx * stackGap + yPositions[line.dataKey] = (axisScale(cumulative) ?? 0) - gapOffset + seriesIdx++ + } + } + } else { + // Vertical grouped bars + const seriesCount = lines.length + const groupGap = seriesCount > 1 ? 4 : 0 + const individualBarWidth = + seriesCount > 0 + ? (bandWidth - groupGap * (seriesCount - 1)) / seriesCount + : bandWidth + + lines.forEach((line, idx) => { + const value = d[line.dataKey] + if (typeof value === "number") { + const axisScale = + yScales[normalizeYAxisId(line.yAxisId)] ?? primaryYScale + yPositions[line.dataKey] = axisScale(value) ?? 0 + xPositions[line.dataKey] = + barPos + + idx * (individualBarWidth + groupGap) + + individualBarWidth / 2 + } + }) + } + + // Tooltip position: for horizontal, position at max bar end; for vertical, center of band + let tooltipX: number + if (isHorizontal) { + // Position tooltip at the end of the longest bar + const maxX = Math.max(...Object.values(xPositions), 0) + tooltipX = maxX + } else { + tooltipX = barPos + bandWidth / 2 + } + + scheduleTooltip({ + point: d, + index: clampedIndex, + x: tooltipX, + yPositions, + xPositions: Object.keys(xPositions).length > 0 ? xPositions : undefined + }) + }, + [ + categoryScale, + valueScale, + data, + lines, + margin.left, + margin.top, + categoryAccessor, + columnWidth, + bandWidth, + isHorizontal, + stacked, + stackGap, + scheduleTooltip, + yScales, + primaryYScale + ] + ) + + const handleMouseLeave = useCallback(() => { + clearTooltip() + }, [clearTooltip]) + + const canInteract = isLoaded + + // Separate children into defs, pre-overlay, and post-overlay + const defsChildren: ReactElement[] = [] + const clipExcludedChildren: ReactElement[] = [] + const underlayChildren: ReactElement[] = [] + const preOverlayChildren: ReactElement[] = [] + const postOverlayChildren: ReactElement[] = [] + + forEachChartChild(children, child => { + const resolvedChild = resolveChartChildElement(child) + + if (isGradientDefComponent(child)) { + defsChildren.push(child) + } else if (isPatternDefComponent(child)) { + preOverlayChildren.push(child) + } else if (isPostOverlayComponent(resolvedChild)) { + postOverlayChildren.push(resolvedChild) + } else if (isClipExcludedComponent(resolvedChild)) { + clipExcludedChildren.push( + isChartClipPassthrough(child.type) ? resolvedChild : child + ) + } else if (isUnderlayComponent(resolvedChild)) { + underlayChildren.push(resolvedChild) + } else { + preOverlayChildren.push(child) + } + }) + + const referenceAreas = useMemo( + () => extractReferenceAreaConfigs(children), + [children] + ) + + const contextValue = { + ...DEFAULT_CHART_LIFECYCLE, + chartPhase: resolveRestingChartPhase(status), + chartStatus: status, + data, + renderData: data, + xScale: fakeTimeScale as unknown as ChartContextValue["xScale"], + yScale: isHorizontal ? valueScale : primaryYScale, + yScales, + width, + height, + innerWidth, + innerHeight, + margin, + columnWidth, + tooltipData, + setTooltipData, + containerRef, + lines, + referenceAreas, + isLoaded, + animationDuration, + animationEasing, + enterTransition, + revealEpoch, + xAccessor: xAccessorDate, + dateLabels, + // Bar-specific properties + barScale: categoryScale, + bandWidth, + hoveredBarIndex, + barXAccessor: categoryAccessor, + orientation, + stacked, + stackOffsets + } + + return ( + + + + ) +}) + +export function BarChart({ + data, + xDataKey = "name", + margin: marginProp, + animationDuration = 1100, + animationEasing = DEFAULT_ANIMATION_EASING, + enterTransition, + revealSignature, + aspectRatio = "2 / 1", + className = "", + barGap = 0.2, + barWidth, + orientation = "vertical", + stacked = false, + stackGap = 0, + children, + onPhaseChange, + status = "ready" +}: BarChartProps) { + const containerRef = useRef(null) + const margin = { ...DEFAULT_MARGIN, ...marginProp } + + return ( +
+ + {({ width, height }) => ( + + {children} + + )} + +
+ ) +} + +BarChart.displayName = "BarChart" + +export default BarChart diff --git a/src/components/charts/bar-depth-geometry.ts b/src/components/charts/bar-depth-geometry.ts new file mode 100644 index 00000000..f139eb1c --- /dev/null +++ b/src/components/charts/bar-depth-geometry.ts @@ -0,0 +1,34 @@ +export const BAR_DEPTH_MAX_PX = 7 +/** The side parallelogram's back edge lifts by `depth * this ratio`, giving a + * subtle head-on perspective slope. */ +export const BAR_DEPTH_PERSPECTIVE_RATIO = 0.45 + +/** + * Maximum side-face depth in px for a chart, clamped so depth never spills past + * the gap between bars. `stepWidth` is d3-scaleBand's `step()` (bandwidth + + * gap); `bandWidth` is a single bar's width. Returns 0 for gapless/dense charts. + */ +export function barDepthMaxDepth(stepWidth: number, bandWidth: number): number { + const gap = Math.max(0, stepWidth - bandWidth) + return Math.min(bandWidth * 0.22, Math.max(0, gap - 1), BAR_DEPTH_MAX_PX) +} + +/** + * Per-bar side-face depth + perspective rise. + * + * - `absOffset` ∈ [0, 1]: the bar's normalized distance from the chart's + * horizontal center. 0 = dead center (no depth); 1 = chart edge (full depth). + * - `naturalHeight`: the bar's pixel height. Depth is capped by it so a short + * bar's side never reads wider than the bar is tall. + * - `maxDepth`: from `barDepthMaxDepth`. + */ +export function barDepthAndRise( + absOffset: number, + naturalHeight: number, + maxDepth: number +): { depth: number; perspectiveRise: number } { + const offset = Math.min(1, Math.max(0, absOffset)) + const cappedMaxDepth = Math.min(maxDepth, Math.max(0, naturalHeight)) + const depth = offset * cappedMaxDepth + return { depth, perspectiveRise: depth * BAR_DEPTH_PERSPECTIVE_RATIO } +} diff --git a/src/components/charts/bar-x-axis.tsx b/src/components/charts/bar-x-axis.tsx new file mode 100644 index 00000000..b13c590c --- /dev/null +++ b/src/components/charts/bar-x-axis.tsx @@ -0,0 +1,155 @@ +"use client" + +import { memo, useEffect, useMemo, useState } from "react" +import { createPortal } from "react-dom" +import { motion } from "motion/react" + +import { cn } from "@/lib/utils" +import { useChart, useChartStable } from "./chart-context" + +export interface BarXAxisProps { + /** Width of the date ticker box for fade calculation. Default: 50 */ + tickerHalfWidth?: number + /** Whether to show all labels or skip some for dense data. Default: false */ + showAllLabels?: boolean + /** Maximum number of labels to show. Default: 12 */ + maxLabels?: number +} + +interface BarXAxisLabelProps { + label: string + x: number + crosshairX: number | null + isHovering: boolean + tickerHalfWidth: number +} + +function BarXAxisLabel({ + label, + x, + crosshairX, + isHovering, + tickerHalfWidth +}: BarXAxisLabelProps) { + const fadeBuffer = 20 + const fadeRadius = tickerHalfWidth + fadeBuffer + + let opacity = 1 + if (isHovering && crosshairX !== null) { + const distance = Math.abs(x - crosshairX) + if (distance < tickerHalfWidth) { + opacity = 0 + } else if (distance < fadeRadius) { + opacity = (distance - tickerHalfWidth) / fadeBuffer + } + } + + // Zero-width container approach for perfect centering + return ( +
+ + {label} + +
+ ) +} + +export function BarXAxis(props: BarXAxisProps) { + const { containerRef, barScale } = useChartStable() + const [container, setContainer] = useState(null) + const [mounted, setMounted] = useState(false) + + useEffect(() => { + setMounted(true) + setContainer(containerRef.current) + }, [containerRef]) + + if (!(mounted && container)) { + return null + } + + if (!barScale) { + return null + } + + return +} + +const BarXAxisInner = memo(function BarXAxisInner({ + tickerHalfWidth = 50, + showAllLabels = false, + maxLabels = 12, + container +}: BarXAxisProps & { container: HTMLDivElement }) { + const { margin, tooltipData, barScale, bandWidth, barXAccessor, data } = + useChart() + + // Generate labels for each bar + const labelsToShow = useMemo(() => { + if (!(barScale && bandWidth && barXAccessor)) { + return [] + } + + const allLabels = data.map(d => { + const label = barXAccessor(d) + const bandX = barScale(label) ?? 0 + // Center the label under the bar group + const x = bandX + bandWidth / 2 + margin.left + return { label, x } + }) + + // If showAllLabels is true or we have fewer than maxLabels, show all + if (showAllLabels || allLabels.length <= maxLabels) { + return allLabels + } + + // Otherwise, skip some labels to avoid crowding + const step = Math.ceil(allLabels.length / maxLabels) + return allLabels.filter((_, i) => i % step === 0) + }, [ + barScale, + bandWidth, + barXAccessor, + data, + margin.left, + showAllLabels, + maxLabels + ]) + + const isHovering = tooltipData !== null + const crosshairX = tooltipData ? tooltipData.x + margin.left : null + + return createPortal( +
+ {labelsToShow.map(item => ( + + ))} +
, + container + ) +}) + +BarXAxis.displayName = "BarXAxis" + +export default BarXAxis diff --git a/src/components/charts/bar-y-axis.tsx b/src/components/charts/bar-y-axis.tsx new file mode 100644 index 00000000..1ba9fd52 --- /dev/null +++ b/src/components/charts/bar-y-axis.tsx @@ -0,0 +1,144 @@ +"use client" + +import { memo, useEffect, useMemo, useState } from "react" +import { createPortal } from "react-dom" +import { motion } from "motion/react" + +import { cn } from "@/lib/utils" +import { useChart, useChartStable } from "./chart-context" + +export interface BarYAxisProps { + /** Whether to show all labels or skip some for dense data. Default: true */ + showAllLabels?: boolean + /** Maximum number of labels to show. Default: 20 */ + maxLabels?: number +} + +interface BarYAxisLabelProps { + label: string + y: number + bandHeight: number + isHovered: boolean +} + +function BarYAxisLabel({ + label, + y, + bandHeight, + isHovered +}: BarYAxisLabelProps) { + return ( +
+ + {label} + +
+ ) +} + +export function BarYAxis(props: BarYAxisProps) { + const { containerRef, barScale } = useChartStable() + const [container, setContainer] = useState(null) + const [mounted, setMounted] = useState(false) + + useEffect(() => { + setMounted(true) + setContainer(containerRef.current) + }, [containerRef]) + + if (!(mounted && container)) { + return null + } + + if (!barScale) { + return null + } + + return +} + +const BarYAxisInner = memo(function BarYAxisInner({ + showAllLabels = true, + maxLabels = 20, + container +}: BarYAxisProps & { container: HTMLDivElement }) { + const { margin, barScale, bandWidth, barXAccessor, data, hoveredBarIndex } = + useChart() + + // Generate labels for each bar + const labelsToShow = useMemo(() => { + if (!(barScale && bandWidth && barXAccessor)) { + return [] + } + + const allLabels = data.map((d, i) => { + const label = barXAccessor(d) + const bandY = barScale(label) ?? 0 + // Center the label vertically within the band + const y = bandY + margin.top + return { label, y, bandHeight: bandWidth, index: i } + }) + + // If showAllLabels is true or we have fewer than maxLabels, show all + if (showAllLabels || allLabels.length <= maxLabels) { + return allLabels + } + + // Otherwise, skip some labels to avoid crowding + const step = Math.ceil(allLabels.length / maxLabels) + return allLabels.filter((_, i) => i % step === 0) + }, [ + barScale, + bandWidth, + barXAccessor, + data, + margin.top, + showAllLabels, + maxLabels + ]) + + return createPortal( +
+ {labelsToShow.map(item => ( + + ))} +
, + container + ) +}) + +BarYAxis.displayName = "BarYAxis" + +export default BarYAxis diff --git a/src/components/charts/bar.tsx b/src/components/charts/bar.tsx new file mode 100644 index 00000000..a083f63b --- /dev/null +++ b/src/components/charts/bar.tsx @@ -0,0 +1,467 @@ +"use client" + +import { memo, useId, useMemo } from "react" +import type { scaleBand } from "@visx/scale" +import type { Transition } from "motion/react" +import { motion } from "motion/react" + +import { barDepthAndRise, barDepthMaxDepth } from "./bar-depth-geometry" +import { + chartCssVars, + useChart, + useChartStable, + useYScale +} from "./chart-context" +import { useChartLegendHover } from "./chart-legend-hover" +import { transitionWithDelay } from "./motion-utils" + +type ScaleBand = ReturnType< + typeof scaleBand +> + +export type BarLineCap = "round" | "butt" | number +export type BarAnimationType = "grow" | "fade" + +// ── Bar-depth perspective trim ─────────────────────────────────────────── +// Uses the SHARED geometry (`bar-depth-geometry.ts`) so a +// `` front face lines up exactly with +// ``'s lid — the formula lives in one place for both. + +/** perspectiveRise for a positive bar whose visual top sits at `topY`. + * Returns 0 for a dead-center bar or a dense chart (degenerate depth). */ +function barDepthPerspectiveRise( + barScale: ScaleBand, + bandWidth: number, + barXAccessor: (d: Record) => string, + innerWidth: number, + datum: Record, + topY: number, + baselineY: number +): number { + const centerX = innerWidth / 2 + if (centerX <= 0) { + return 0 + } + const step = + (barScale as unknown as { step?: () => number }).step?.() ?? bandWidth + const maxDepth = barDepthMaxDepth(step, bandWidth) + const bandX = barScale(barXAccessor(datum)) ?? 0 + const cx = bandX + bandWidth / 2 + const absOffset = Math.min(1, Math.abs((cx - centerX) / centerX)) + const naturalHeight = Math.abs(baselineY - topY) + return barDepthAndRise(absOffset, naturalHeight, maxDepth).perspectiveRise +} + +export interface BarProps { + /** Key in data to use for y values */ + dataKey: string + /** Y-scale group id for vertical bars (Recharts `yAxisId`). Default: `"left"`. */ + yAxisId?: string | number + /** Fill color for the bar. Can be a color, gradient url, or pattern url. Default: var(--chart-line-primary) */ + fill?: string + /** Color for tooltip dot. Use when fill is a gradient/pattern. Default: uses fill value */ + stroke?: string + /** Line cap style for bar ends: "round", "butt", or a number for custom radius. Default: "round" */ + lineCap?: BarLineCap + /** Whether to animate the bars. Default: true */ + animate?: boolean + /** Animation type: "grow" (height) or "fade" (opacity + blur). Default: "grow" */ + animationType?: BarAnimationType + /** Opacity when not hovered (when another bar is hovered). Default: 0.3 */ + fadedOpacity?: number + /** Stagger delay between bars in seconds. Auto-calculated if not provided. */ + staggerDelay?: number + /** Gap between stacked bars in pixels. Default: 0 */ + stackGap?: number + /** Gap between grouped bars in pixels. Default: 4 */ + groupGap?: number + /** Shrink each positive bar's top by its perspective rise so the front face + * lines up with ``'s lid (instead of the lid sitting above the + * front face). Pass `true` whenever the chart also renders the bar-depth 3D + * surfaces. Default: false */ + perspective?: boolean + /** Minimum rendered bar height in px (non-stacked, vertical). Floors short or + * zero-value bars so they stay visible. Pair with the same value on + * `` when using the 3D surfaces. Default: 0 */ + minBarHeight?: number +} + +interface BarInnerProps extends BarProps { + barScale: ScaleBand + bandWidth: number + barXAccessor: (d: Record) => string +} + +interface AnimatedBarProps { + x: number + y: number + width: number + height: number + fill: string + rx: number + ry: number + index: number + isFaded: boolean + animationType: BarAnimationType + innerHeight: number + fadedOpacity: number + staggerDelay: number + enterTransition?: Transition + revealEpoch: number + isHorizontal: boolean +} + +function AnimatedBar({ + x, + y, + width, + height, + fill, + rx, + ry, + index, + isFaded, + animationType, + innerHeight, + fadedOpacity, + staggerDelay, + enterTransition, + revealEpoch, + isHorizontal +}: AnimatedBarProps) { + const enterAnim = transitionWithDelay(enterTransition, index * staggerDelay) + + if (animationType === "fade") { + return ( + + ) + } + + const initial = isHorizontal + ? { width: 0, height, x: 0, y } + : { width, height: 0, x, y: innerHeight } + const target = isHorizontal + ? { width, height, x: 0, y } + : { width, height, x, y } + + return ( + + + + ) +} + +const BarInner = memo(function BarInner({ + dataKey, + yAxisId, + fill = chartCssVars.linePrimary, + lineCap = "round", + animate = true, + animationType = "grow", + fadedOpacity = 0.3, + staggerDelay, + stackGap = 0, + groupGap = 4, + perspective = false, + minBarHeight = 0, + barScale, + bandWidth, + barXAccessor +}: BarInnerProps) { + const { + data, + yScale: chartYScale, + innerHeight, + innerWidth, + isLoaded, + hoveredBarIndex, + lines, + orientation, + stacked, + stackOffsets, + animationDuration, + enterTransition, + revealEpoch = 0 + } = useChart() + + // Calculate stagger delay automatically if not provided + // Total animation duration is ~1200ms, with 40% for stagger spread and 60% for bar animation + const totalAnimDuration = animationDuration || 1100 + const staggerSpread = totalAnimDuration * 0.4 // 40% of time for stagger spread + const calculatedStaggerDelay = + staggerDelay ?? (data.length > 1 ? staggerSpread / 1000 / data.length : 0) + const uniqueId = useId() + + const isHorizontal = orientation === "horizontal" + + // Find the index of this bar series among all bar series + const { hoveredIndex: legendHoveredIndex } = useChartLegendHover() + + const seriesIndex = useMemo(() => { + const idx = lines.findIndex(l => l.dataKey === dataKey) + return idx >= 0 ? idx : 0 + }, [lines, dataKey]) + + const seriesConfig = lines[seriesIndex] + const valueScale = useYScale(yAxisId ?? seriesConfig?.yAxisId) + + const isLegendDimmed = + legendHoveredIndex !== null && legendHoveredIndex !== seriesIndex + + const seriesCount = lines.length + const isLastSeries = seriesIndex === seriesCount - 1 + + // Calculate the width for each bar within a group (for non-stacked) + const barWidth = useMemo(() => { + if (!bandWidth || seriesCount === 0) { + return 0 + } + if (stacked) { + // Stacked bars use full band width + return bandWidth + } + // Leave a gap between grouped bars (controlled by groupGap prop) + const effectiveGroupGap = seriesCount > 1 ? groupGap : 0 + return (bandWidth - effectiveGroupGap * (seriesCount - 1)) / seriesCount + }, [bandWidth, seriesCount, stacked, groupGap]) + + // Calculate corner radius based on lineCap. Perspective bars force a flat + // top (radius 0) so the 3D lid from `` meets the bar with no + // gap — rounded corners would leave a wedge, so `perspective` overrides it. + const cornerRadius = useMemo(() => { + if (perspective) { + return 0 + } + if (typeof lineCap === "number") { + return lineCap + } + if (lineCap === "round" && barWidth) { + return Math.min(barWidth / 2, 8) + } + return 0 + }, [lineCap, barWidth, perspective]) + + return ( + + {data.map((d, i) => { + const value = d[dataKey] + if (typeof value !== "number") { + return null + } + + const categoryValue = barXAccessor(d) + const bandPos = barScale(categoryValue) ?? 0 + + let x: number + let y: number + let barHeight: number + let barW: number + + const scale = isHorizontal ? chartYScale : valueScale + + if (isHorizontal) { + // Horizontal bars: category on y-axis, value on x-axis + const valuePos = scale(value) ?? 0 + barW = valuePos // Width is the value position (grows from left) + barHeight = barWidth + + if (stacked && stackOffsets) { + const offset = stackOffsets.get(i)?.get(dataKey) ?? 0 + x = scale(offset) ?? 0 + barW = valuePos - x + // Apply stack gap for horizontal: shift right and reduce width + const gapOffset = seriesIndex * stackGap + x += gapOffset + if (!isLastSeries && stackGap > 0) { + barW = Math.max(0, barW - stackGap) + } + } else { + x = 0 + // For grouped bars, offset y position + const effectiveGroupGap = seriesCount > 1 ? groupGap : 0 + y = bandPos + seriesIndex * (barWidth + effectiveGroupGap) + } + y = stacked + ? bandPos + : bandPos + + seriesIndex * (barWidth + (seriesCount > 1 ? groupGap : 0)) + } else { + // Vertical bars: category on x-axis, value on y-axis + const valuePos = scale(value) ?? 0 + barHeight = innerHeight - valuePos + barW = barWidth + + if (stacked && stackOffsets) { + const offset = stackOffsets.get(i)?.get(dataKey) ?? 0 + const offsetY = scale(offset) ?? innerHeight + // Apply stack gap: shift up and reduce height + const gapOffset = seriesIndex * stackGap + y = offsetY - barHeight - gapOffset + // Reduce height slightly for non-last bars to create visual gap + if (!isLastSeries && stackGap > 0) { + barHeight = Math.max(0, barHeight - stackGap) + } + } else { + y = valuePos + // For grouped bars, offset x position + const effectiveGroupGap = seriesCount > 1 ? groupGap : 0 + x = bandPos + seriesIndex * (barWidth + effectiveGroupGap) + } + x = stacked + ? bandPos + : bandPos + + seriesIndex * (barWidth + (seriesCount > 1 ? groupGap : 0)) + + // Minimum visible height — floor short/zero non-stacked bars so a + // zero-value data point still reads as a tiny bar instead of + // vanishing. Grows up from the baseline. Floored bars skip the + // perspective trim (sub-pixel on a 3px bar; keeps the front aligned + // with bar-depth, which also skips trim for floored bars). + let isFloored = false + if ( + !stacked && + minBarHeight > 0 && + value >= 0 && + barHeight < minBarHeight + ) { + const baselineY = scale(0) ?? innerHeight + barHeight = minBarHeight + y = baselineY - minBarHeight + isFloored = true + } + + // Perspective trim — shrink the topmost positive bar's front-face + // top down by its perspective rise so it meets ``'s + // lid back edge. Stacked: only the last (topmost) series; grouped or + // single: every positive bar. Clamped to `barHeight - 1` so very + // short bars keep a positive height (matches bar-depth's clamp). + if ( + perspective && + value > 0 && + !isFloored && + (!stacked || isLastSeries) + ) { + const baselineY = scale(0) ?? innerHeight + const rise = barDepthPerspectiveRise( + barScale, + bandWidth, + barXAccessor, + innerWidth, + d, + y, + baselineY + ) + const trim = Math.min(rise, Math.max(0, barHeight - 1)) + y += trim + barHeight -= trim + } + } + + const isFaded = + (hoveredBarIndex !== null && hoveredBarIndex !== i) || isLegendDimmed + + // Use categoryValue as key since it's the unique identifier from data + const barKey = `bar-${dataKey}-${categoryValue}` + + // Apply rounded corners: + // - For non-stacked: always apply + // - For stacked with gap: apply to all bars + // - For stacked without gap: only apply to the last series + const applyRounding = !stacked || stackGap > 0 || isLastSeries + const effectiveRx = applyRounding ? cornerRadius : 0 + const effectiveRy = applyRounding ? cornerRadius : 0 + + if (animate && !isLoaded) { + return ( + + ) + } + + // Static bar after animation completes + return ( + + ) + })} + + ) +}) + +export function Bar(props: BarProps) { + const { barScale, bandWidth, barXAccessor } = useChartStable() + + if (!(barScale && bandWidth && barXAccessor)) { + console.warn("Bar component must be used within a BarChart") + return null + } + + return ( + + ) +} + +Bar.displayName = "Bar" + +export default Bar diff --git a/src/components/charts/chart-child-passthrough.ts b/src/components/charts/chart-child-passthrough.ts new file mode 100644 index 00000000..3fb1d3bc --- /dev/null +++ b/src/components/charts/chart-child-passthrough.ts @@ -0,0 +1,117 @@ +import { + Children, + cloneElement, + Fragment, + isValidElement, + type ReactElement, + type ReactNode +} from "react" + +/** Marker on wrapper components whose single child should inherit clip classification. */ +export const CHART_CLIP_PASSTHROUGH = "__chartClipPassthrough" as const + +export function isChartClipPassthrough(type: unknown): boolean { + return ( + typeof type === "function" && + (type as { [CHART_CLIP_PASSTHROUGH]?: boolean })[CHART_CLIP_PASSTHROUGH] === + true + ) +} + +/** Unwrap visibility wrappers so `Grid` / axes stay outside the series clip. */ +export function resolveChartChildElement(child: ReactElement): ReactElement { + if (isChartClipPassthrough(child.type)) { + const inner = (child.props as { children?: unknown }).children + if (isValidElement(inner)) { + return resolveChartChildElement(inner) + } + } + return child +} + +/** Walk chart children, flattening React fragments (studio often groups layers in `<>...`). */ +export function forEachChartChild( + children: ReactNode, + callback: (child: ReactElement, index: number) => void +) { + let index = 0 + const visit = (nodes: ReactNode) => { + Children.forEach(nodes, child => { + if (!isValidElement(child)) { + return + } + if (child.type === Fragment) { + visit((child.props as { children?: ReactNode }).children) + return + } + callback(child, index) + index += 1 + }) + } + visit(children) +} + +const CLIP_EXCLUDED_COMPONENT_NAMES = new Set([ + "Background", + "Grid", + "XAxis", + "YAxis", + "BarXAxis", + "BarYAxis", + "LiveXAxis", + "LiveYAxis" +]) + +const UNDERLAY_COMPONENT_NAMES = new Set(["ReferenceArea"]) + +/** Markers render after the interaction overlay so they stay clickable. */ +export function isPostOverlayComponent(child: ReactElement): boolean { + const childType = child.type as { + displayName?: string + name?: string + __isChartMarkers?: boolean + __isPostOverlay?: boolean + } + + if (childType.__isChartMarkers || childType.__isPostOverlay) { + return true + } + + const componentName = + typeof child.type === "function" + ? childType.displayName || childType.name || "" + : "" + + return ( + componentName === "ChartMarkers" || + componentName === "MarkerGroup" || + componentName === "ChartBrush" + ) +} + +/** Renders above grid/axes but below series; excluded from grow-clip reveal. */ +export function isUnderlayComponent(child: ReactElement): boolean { + const childType = child.type as { displayName?: string; name?: string } + const componentName = + typeof child.type === "function" + ? childType.displayName || childType.name || "" + : "" + return UNDERLAY_COMPONENT_NAMES.has(componentName) +} + +/** Grid and axes stay visible during series clip reveal (e.g. loading → ready). */ +export function isClipExcludedComponent(child: ReactElement): boolean { + const childType = child.type as { displayName?: string; name?: string } + const componentName = + typeof child.type === "function" + ? childType.displayName || childType.name || "" + : "" + return CLIP_EXCLUDED_COMPONENT_NAMES.has(componentName) +} + +/** SVG layer lists from chart shells need stable keys when rendered as arrays. */ +export function renderKeyedChartLayers(children: ReactElement[]) { + return children.map((child, index) => + cloneElement(child, { key: child.key ?? `chart-layer-${index}` }) + ) +} diff --git a/src/components/charts/chart-config-context.tsx b/src/components/charts/chart-config-context.tsx new file mode 100644 index 00000000..aae5857a --- /dev/null +++ b/src/components/charts/chart-config-context.tsx @@ -0,0 +1,92 @@ +"use client" + +import { createContext, useContext, useMemo, type ReactNode } from "react" + +export interface SpringConfig { + stiffness: number + damping: number +} + +export interface ChartConfigValue { + /** Crosshair indicator, tooltip dot, date pill. */ + tooltipSpring: SpringConfig + /** Floating tooltip panel. */ + tooltipBoxSpring: SpringConfig + /** Line/area hover-highlight band (x + width). */ + highlightSpring: SpringConfig +} + +export const DEFAULT_CHART_CONFIG: ChartConfigValue = { + tooltipSpring: { stiffness: 300, damping: 30 }, + tooltipBoxSpring: { stiffness: 100, damping: 20 }, + highlightSpring: { stiffness: 180, damping: 28 } +} + +const ChartConfigContext = createContext(null) + +export interface ChartConfigProviderProps { + value?: Partial + children: ReactNode +} + +export function ChartConfigProvider({ + value, + children +}: ChartConfigProviderProps) { + const merged = useMemo( + () => ({ + ...DEFAULT_CHART_CONFIG, + ...value + }), + [value] + ) + + return ( + + {children} + + ) +} + +export function useChartConfig(): ChartConfigValue { + return useContext(ChartConfigContext) ?? DEFAULT_CHART_CONFIG +} + +const DEFAULT_TOOLTIP_BOX_DAMPING = + DEFAULT_CHART_CONFIG.tooltipBoxSpring.damping + +/** Maps a damping slider to the floating tooltip panel follow spring. `0` = instant. */ +export function resolveTooltipBoxMotion(damping?: number): { + animate: boolean + springConfig: SpringConfig +} { + if (damping === 0) { + return { + animate: false, + springConfig: DEFAULT_CHART_CONFIG.tooltipBoxSpring + } + } + + const effectiveDamping = damping ?? DEFAULT_TOOLTIP_BOX_DAMPING + let stiffness = DEFAULT_CHART_CONFIG.tooltipBoxSpring.stiffness + + if (effectiveDamping < DEFAULT_TOOLTIP_BOX_DAMPING) { + const t = + (DEFAULT_TOOLTIP_BOX_DAMPING - effectiveDamping) / + DEFAULT_TOOLTIP_BOX_DAMPING + stiffness += t * 400 + } else if (effectiveDamping > DEFAULT_TOOLTIP_BOX_DAMPING) { + const t = + (effectiveDamping - DEFAULT_TOOLTIP_BOX_DAMPING) / + (100 - DEFAULT_TOOLTIP_BOX_DAMPING) + stiffness -= t * 85 + } + + return { + animate: true, + springConfig: { + stiffness: Math.max(12, Math.round(stiffness)), + damping: effectiveDamping + } + } +} diff --git a/src/components/charts/chart-context.tsx b/src/components/charts/chart-context.tsx new file mode 100644 index 00000000..8792bfa8 --- /dev/null +++ b/src/components/charts/chart-context.tsx @@ -0,0 +1,426 @@ +"use client" + +import { + createContext, + useContext, + useMemo, + type Dispatch, + type ReactNode, + type RefObject, + type SetStateAction +} from "react" +import type { scaleBand, scaleLinear, scaleTime } from "@visx/scale" +import type { Transition } from "motion/react" + +import type { ChartPhase, ChartStatus } from "./chart-phase" +import type { ReferenceAreaConfig } from "./reference-area-config" +import type { ChartSelection } from "./use-chart-interaction" +import { DEFAULT_Y_AXIS_ID } from "./y-axis-scales" +import type { YDomain } from "./y-domain-utils" + +type ScaleLinear = ReturnType< + typeof scaleLinear +> +type ScaleTime = ReturnType< + typeof scaleTime +> +type ScaleBand = ReturnType< + typeof scaleBand +> + +// CSS variable references for theming +export const chartCssVars = { + background: "var(--chart-background)", + foreground: "var(--chart-foreground)", + foregroundMuted: "var(--chart-foreground-muted)", + label: "var(--chart-label)", + linePrimary: "var(--chart-line-primary)", + lineSecondary: "var(--chart-line-secondary)", + crosshair: "var(--chart-crosshair)", + grid: "var(--chart-grid)", + indicatorColor: "var(--chart-indicator-color)", + indicatorSecondaryColor: "var(--chart-indicator-secondary-color)", + markerBackground: "var(--chart-marker-background)", + markerBorder: "var(--chart-marker-border)", + markerForeground: "var(--chart-marker-foreground)", + badgeBackground: "var(--chart-marker-badge-background)", + badgeForeground: "var(--chart-marker-badge-foreground)", + segmentBackground: "var(--chart-segment-background)", + segmentLine: "var(--chart-segment-line)", + brushBorder: "var(--chart-brush-border)", + tooltipBackground: "var(--chart-tooltip-background)" +} + +/** Default scatter series colors from the chart palette (`--chart-1` … `--chart-5`). */ +export const defaultScatterColors = [ + "var(--chart-1)", + "var(--chart-2)", + "var(--chart-3)", + "var(--chart-4)", + "var(--chart-5)" +] as const + +export interface Margin { + top: number + right: number + bottom: number + left: number +} + +export interface TooltipData { + /** The data point being hovered */ + point: Record + /** Index in the data array */ + index: number + /** X position in pixels (relative to chart area) */ + x: number + /** Y positions for each line, keyed by dataKey */ + yPositions: Record + /** X positions for each series (for grouped bars), keyed by dataKey */ + xPositions?: Record +} + +export interface LineConfig { + dataKey: string + stroke: string + strokeWidth: number + /** Scale group id (Recharts `yAxisId`). Default: `"left"`. */ + yAxisId?: string | number +} + +/** + * Hover/selection state — every field here changes on mouse movement. + * Lives in its own context so cold consumers (Grid, YAxis, PatternArea, …) + * can subscribe to the stable slice and skip re-rendering on every hover. + */ +export interface ChartHoverContextValue { + // Tooltip state + tooltipData: TooltipData | null + setTooltipData: Dispatch> + + // Selection state (optional - only present when useChartInteraction is used) + /** Current drag/pinch selection range */ + selection?: ChartSelection | null + /** Clear the current selection */ + clearSelection?: () => void + + // Bar chart hover (optional - only present in BarChart) + /** Index of currently hovered bar */ + hoveredBarIndex?: number | null + /** Setter for hovered bar index */ + setHoveredBarIndex?: (index: number | null) => void + + // Candlestick hover (optional - only present in CandlestickChart) + /** Index of currently hovered candle */ + hoveredCandleIndex?: number | null + /** Setter for hovered candle index */ + setHoveredCandleIndex?: (index: number | null) => void +} + +export interface ChartContextValue extends ChartHoverContextValue { + // Data + data: Record[] + /** Decimated subset for SVG path rendering; equals `data` when no decimation is needed. */ + renderData: Record[] + + // Scales + xScale: ScaleTime + /** Primary (left) y-scale — alias for `yScales[DEFAULT_Y_AXIS_ID]`. */ + yScale: ScaleLinear + /** Per-axis y-scales keyed by `yAxisId`. */ + yScales: Record> + + // Dimensions + width: number + height: number + innerWidth: number + innerHeight: number + margin: Margin + + // Column width for spacing calculations + columnWidth: number + + // Container ref for portals + containerRef: RefObject + + // Line configurations (extracted from children) + lines: LineConfig[] + + /** {@link ReferenceArea} bands — drives y-axis label colors in range. */ + referenceAreas: ReferenceAreaConfig[] + + // Loading / lifecycle (LineChart status transitions) + chartPhase: ChartPhase + chartStatus: ChartStatus + /** Centered label while `chartPhase` shows loading chrome. */ + loadingLabel?: string + /** Y-domain tween duration when transitioning loading ↔ ready (ms). */ + yDomainTweenDuration: number + /** Nice’d y-domains per axis from skeleton data (placeholder). */ + yDomainSkeletonByAxis: Record + /** Nice’d y-domains per axis from the current target data. */ + yDomainTargetByAxis: Record + + // Animation state + isLoaded: boolean + animationDuration: number + /** CSS easing for clip-reveal / line draw (cartesian charts). */ + animationEasing?: string + /** Motion enter transition (spring or tween) — drives clip reveal when spring. */ + enterTransition?: Transition + /** Increments when enter animation should replay. */ + revealEpoch?: number + /** Fired when a one-shot loading pulse (exit / enter) completes. */ + notifyLoadingPulseComplete?: () => void + + // X accessor - how to get the x value from data points + xAccessor: (d: Record) => Date + + // Pre-computed date labels for ticker animation + dateLabels: string[] + + /** Active brush zoom range — when set, axis ticks align to visible data rows. */ + xDomain?: [Date, Date] + /** Full dataset length when brush zoom is enabled (for zoom vs full-range detection). */ + xDomainSlotCount?: number + + // Bar chart specific (optional - only present in BarChart) + /** Band scale for categorical x-axis (bar charts) */ + barScale?: ScaleBand + /** Width of each bar band */ + bandWidth?: number + /** X accessor for bar charts (returns string instead of Date) */ + barXAccessor?: (d: Record) => string + /** Bar chart orientation */ + orientation?: "vertical" | "horizontal" + /** Whether bars are stacked */ + stacked?: boolean + /** Stack offsets: Map of data index -> Map of dataKey -> cumulative offset */ + stackOffsets?: Map> + + // ComposedChart + SeriesBar (optional) + /** `SeriesBar` dataKeys in tree order, for grouped columns at each x */ + composedBarDataKeys?: string[] + /** Target bar width in px (Recharts `barSize` style). */ + composedBarSize?: number + /** Max bar width in px (Recharts `maxBarSize`). */ + composedMaxBarSize?: number + /** Gap between grouped `SeriesBar` columns in px. */ + composedBarGap?: number + /** When true, `SeriesBar` segments stack in child order at each x. */ + composedStacked?: boolean + /** Per-row cumulative offsets for stacked `SeriesBar` (data index → dataKey → offset). */ + composedStackOffsets?: Map> + /** Vertical gap in px between stacked `SeriesBar` segments. Default: 0 */ + composedStackGap?: number +} + +/** + * Stable slice of the chart context — everything that doesn't change on hover + * (data, scales, dimensions, animation state, layout config). Consumers that + * subscribe via `useChartStable()` skip re-renders on every mouse move. + */ +export type ChartStableContextValue = Omit< + ChartContextValue, + keyof ChartHoverContextValue +> + +const ChartStableContext = createContext(null) +const ChartHoverContext = createContext(null) + +/** + * Splits the merged `value` into a stable slice and a volatile hover slice, + * publishing each to its own context. Each slice is memoized on its own + * field identities, so changing `tooltipData` does not bust the stable + * slice — consumers of `useChartStable()` skip re-renders on hover. + */ +export function ChartProvider({ + children, + value +}: { + children: ReactNode + value: ChartContextValue +}) { + const stable = useMemo( + () => ({ + data: value.data, + renderData: value.renderData, + xScale: value.xScale, + yScale: value.yScale, + yScales: value.yScales, + width: value.width, + height: value.height, + innerWidth: value.innerWidth, + innerHeight: value.innerHeight, + margin: value.margin, + columnWidth: value.columnWidth, + containerRef: value.containerRef, + lines: value.lines, + referenceAreas: value.referenceAreas, + chartPhase: value.chartPhase, + chartStatus: value.chartStatus, + loadingLabel: value.loadingLabel, + yDomainTweenDuration: value.yDomainTweenDuration, + yDomainSkeletonByAxis: value.yDomainSkeletonByAxis, + yDomainTargetByAxis: value.yDomainTargetByAxis, + isLoaded: value.isLoaded, + animationDuration: value.animationDuration, + animationEasing: value.animationEasing, + enterTransition: value.enterTransition, + revealEpoch: value.revealEpoch, + notifyLoadingPulseComplete: value.notifyLoadingPulseComplete, + xAccessor: value.xAccessor, + dateLabels: value.dateLabels, + xDomain: value.xDomain, + xDomainSlotCount: value.xDomainSlotCount, + barScale: value.barScale, + bandWidth: value.bandWidth, + barXAccessor: value.barXAccessor, + orientation: value.orientation, + stacked: value.stacked, + stackOffsets: value.stackOffsets, + composedBarDataKeys: value.composedBarDataKeys, + composedBarSize: value.composedBarSize, + composedMaxBarSize: value.composedMaxBarSize, + composedBarGap: value.composedBarGap, + composedStacked: value.composedStacked, + composedStackOffsets: value.composedStackOffsets, + composedStackGap: value.composedStackGap + }), + [ + value.data, + value.renderData, + value.xScale, + value.yScale, + value.yScales, + value.width, + value.height, + value.innerWidth, + value.innerHeight, + value.margin, + value.columnWidth, + value.containerRef, + value.lines, + value.referenceAreas, + value.chartPhase, + value.chartStatus, + value.loadingLabel, + value.yDomainTweenDuration, + value.yDomainSkeletonByAxis, + value.yDomainTargetByAxis, + value.isLoaded, + value.animationDuration, + value.animationEasing, + value.enterTransition, + value.revealEpoch, + value.notifyLoadingPulseComplete, + value.xAccessor, + value.dateLabels, + value.xDomain, + value.xDomainSlotCount, + value.barScale, + value.bandWidth, + value.barXAccessor, + value.orientation, + value.stacked, + value.stackOffsets, + value.composedBarDataKeys, + value.composedBarSize, + value.composedMaxBarSize, + value.composedBarGap, + value.composedStacked, + value.composedStackOffsets, + value.composedStackGap + ] + ) + + const hover = useMemo( + () => ({ + tooltipData: value.tooltipData, + setTooltipData: value.setTooltipData, + selection: value.selection, + clearSelection: value.clearSelection, + hoveredBarIndex: value.hoveredBarIndex, + setHoveredBarIndex: value.setHoveredBarIndex, + hoveredCandleIndex: value.hoveredCandleIndex, + setHoveredCandleIndex: value.setHoveredCandleIndex + }), + [ + value.tooltipData, + value.setTooltipData, + value.selection, + value.clearSelection, + value.hoveredBarIndex, + value.setHoveredBarIndex, + value.hoveredCandleIndex, + value.setHoveredCandleIndex + ] + ) + + return ( + + + {children} + + + ) +} + +/** + * Stable slice — data, scales, dimensions, animation state, layout config. + * Subscribers skip re-renders on hover (the hover slice lives in a separate + * context). Prefer this in cold consumers like axes, grid, pattern fills. + */ +export function useChartStable(): ChartStableContextValue { + const context = useContext(ChartStableContext) + if (!context) { + throw new Error( + "useChartStable must be used within a ChartProvider. " + + "Make sure your component is wrapped in , , , or ." + ) + } + return context +} + +/** Y-scale for a series axis (`yAxisId` on Line / Area / YAxis). */ +export function useYScale( + yAxisId?: string | number +): ScaleLinear { + const { yScales, yScale } = useChartStable() + const id = + yAxisId == null || yAxisId === "" ? DEFAULT_Y_AXIS_ID : String(yAxisId) + return yScales[id] ?? yScale +} + +/** + * Hover slice — tooltipData, selection, hovered bar / candle indices. + * Subscribers re-render on every mouse move. Use only when the component + * actually reads hover state. + */ +export function useChartHover(): ChartHoverContextValue { + const context = useContext(ChartHoverContext) + if (!context) { + throw new Error( + "useChartHover must be used within a ChartProvider. " + + "Make sure your component is wrapped in , , , or ." + ) + } + return context +} + +/** + * Merged stable + hover context. Convenient for components that need both, + * but re-renders on every hover (because hover changes). Prefer + * `useChartStable()` or `useChartHover()` for hot consumers that only need + * one slice. + */ +export function useChart(): ChartContextValue { + const stable = useChartStable() + const hover = useChartHover() + // Identity changes on every hover (hover is the volatile slice) — that's + // fine for consumers using this merged hook; they explicitly opted in to + // re-rendering on hover. + return { ...stable, ...hover } +} + +export default ChartStableContext diff --git a/src/components/charts/chart-defs.ts b/src/components/charts/chart-defs.ts new file mode 100644 index 00000000..27221af9 --- /dev/null +++ b/src/components/charts/chart-defs.ts @@ -0,0 +1,72 @@ +import { + Children, + isValidElement, + type ReactElement, + type ReactNode +} from "react" + +export function getChartChildComponentName(child: ReactElement): string { + const childType = child.type as { displayName?: string; name?: string } + return typeof child.type === "function" + ? childType.displayName || childType.name || "" + : "" +} + +const VISX_PATTERN_COMPONENT_NAMES = new Set([ + "Lines", + "Circles", + "Waves", + "Hexagons", + "Path", + "Pattern" +]) + +/** @visx/pattern default exports use short names (e.g. `Lines`); also match *Pattern* displayNames. */ +export function isPatternDefComponent(child: ReactElement): boolean { + const name = getChartChildComponentName(child) + return name.includes("Pattern") || VISX_PATTERN_COMPONENT_NAMES.has(name) +} + +export function isGradientDefComponent(child: ReactElement): boolean { + const name = getChartChildComponentName(child) + return ( + name.includes("Gradient") || + name === "LinearGradient" || + name === "RadialGradient" + ) +} + +export function isChartDefsComponent(child: ReactElement): boolean { + return isPatternDefComponent(child) || isGradientDefComponent(child) +} + +/** Split hoisted defs: @visx/pattern nodes already wrap `` and render at the svg root. */ +export function partitionChartDefNodes(defNodes: ReactElement[]): { + patternDefNodes: ReactElement[] + gradientDefNodes: ReactElement[] +} { + const patternDefNodes: ReactElement[] = [] + const gradientDefNodes: ReactElement[] = [] + + for (const node of defNodes) { + if (isPatternDefComponent(node)) { + patternDefNodes.push(node) + } else { + gradientDefNodes.push(node) + } + } + + return { patternDefNodes, gradientDefNodes } +} + +export function collectChartDefsChildren(children: ReactNode): ReactElement[] { + const defNodes: ReactElement[] = [] + + Children.forEach(children, child => { + if (isValidElement(child) && isChartDefsComponent(child)) { + defNodes.push(child) + } + }) + + return defNodes +} diff --git a/src/components/charts/chart-formatters.ts b/src/components/charts/chart-formatters.ts new file mode 100644 index 00000000..b0ed580d --- /dev/null +++ b/src/components/charts/chart-formatters.ts @@ -0,0 +1,20 @@ +export const shortDateFmt = new Intl.DateTimeFormat("en-US", { + month: "short", + day: "numeric" +}) + +export const weekdayDateFmt = new Intl.DateTimeFormat("en-US", { + weekday: "short", + month: "short", + day: "numeric" +}) + +export const hmsTimeFmt = new Intl.DateTimeFormat("en-US", { + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + hour12: false +}) + +// `Intl.NumberFormat.prototype.format` is a bound getter — safe to extract. +export const intFmt = new Intl.NumberFormat("en-US").format diff --git a/src/components/charts/chart-legend-hover.tsx b/src/components/charts/chart-legend-hover.tsx new file mode 100644 index 00000000..def2d301 --- /dev/null +++ b/src/components/charts/chart-legend-hover.tsx @@ -0,0 +1,44 @@ +"use client" + +import { createContext, useContext, useMemo, type ReactNode } from "react" + +interface ChartLegendHoverContextValue { + hoveredIndex: number | null + setHoveredIndex: (index: number | null) => void +} + +const ChartLegendHoverContext = + createContext(null) + +export function ChartLegendHoverProvider({ + hoveredIndex, + onHoverChange, + children +}: { + hoveredIndex: number | null + onHoverChange: (index: number | null) => void + children: ReactNode +}) { + const value = useMemo( + () => ({ hoveredIndex, setHoveredIndex: onHoverChange }), + [hoveredIndex, onHoverChange] + ) + + return ( + + {children} + + ) +} + +export function useChartLegendHover(): ChartLegendHoverContextValue { + const context = useContext(ChartLegendHoverContext) + return ( + context ?? { + hoveredIndex: null, + setHoveredIndex: () => { + /* noop outside ChartLegendHoverProvider */ + } + } + ) +} diff --git a/src/components/charts/chart-phase.ts b/src/components/charts/chart-phase.ts new file mode 100644 index 00000000..709f8d9e --- /dev/null +++ b/src/components/charts/chart-phase.ts @@ -0,0 +1,52 @@ +export type ChartStatus = "loading" | "ready" + +/** Loading animation style: the default traveling pulse, or a diagonal + * shimmer that sweeps across the skeleton. */ +export type LoadingStyle = "pulse" | "sweep" + +/** + * Internal visual lifecycle phase. Forward and reverse transitions add + * intermediate phases in later stack branches. + */ +export type ChartPhase = + | "loading" + | "exiting" + | "gridTweenReady" + | "revealing" + | "ready" + | "exitingReady" + | "gridTweenLoading" + | "revealingLoading" + +export const DEFAULT_CHART_STATUS: ChartStatus = "ready" + +/** Default Y-domain tween when transitioning loading ↔ ready (ms). */ +export const DEFAULT_Y_DOMAIN_TWEEN_MS = 500 + +/** Relative domain delta below which Y tween may be skipped (see plan). */ +export const Y_DOMAIN_TWEEN_SKIP_THRESHOLD = 0.02 + +/** Resting phase for a given status before transition orchestration runs. */ +export function resolveRestingChartPhase(status: ChartStatus): ChartPhase { + return status === "loading" ? "loading" : "ready" +} + +export function isChartInteractionPhase(phase: ChartPhase): boolean { + return phase === "ready" +} + +export const DEFAULT_CHART_LIFECYCLE = { + chartPhase: "ready", + chartStatus: "ready", + loadingLabel: undefined, + yDomainTweenDuration: DEFAULT_Y_DOMAIN_TWEEN_MS, + yDomainSkeletonByAxis: { left: [0, 100] as [number, number] }, + yDomainTargetByAxis: { left: [0, 100] as [number, number] } +} as const satisfies { + chartPhase: ChartPhase + chartStatus: ChartStatus + loadingLabel: undefined + yDomainTweenDuration: number + yDomainSkeletonByAxis: Record + yDomainTargetByAxis: Record +} diff --git a/src/components/charts/chart-reveal-clip.tsx b/src/components/charts/chart-reveal-clip.tsx new file mode 100644 index 00000000..cafb641e --- /dev/null +++ b/src/components/charts/chart-reveal-clip.tsx @@ -0,0 +1,93 @@ +"use client" + +import type { Transition } from "motion/react" +import { motion } from "motion/react" + +import { clipRevealTransition } from "./animation" + +export type ChartRevealClipMode = "reveal" | "conceal" + +export interface ChartRevealClipProps { + clipPathId: string + height: number + targetWidth: number + enterTransition?: Transition + /** Bumps when motion settings change to replay the reveal. */ + revealEpoch: number + /** Extra inset around the clip rect so edge glyphs are not cut off. */ + padding?: number + /** When false, clip stays at full width (no grow animation). */ + animating?: boolean + /** Reveal grows 0 → full; conceal shrinks full → 0 (ready → loading). */ + mode?: ChartRevealClipMode + /** Called when a conceal animation finishes. */ + onComplete?: () => void +} + +/** + * Left-to-right clip reveal for cartesian series. + * Grows clip rect width from 0 → full (true LTR; scaleX is avoided — it reveals from center). + */ +export function ChartRevealClip({ + clipPathId, + height, + targetWidth, + enterTransition, + revealEpoch, + padding = 0, + animating = true, + mode = "reveal", + onComplete +}: ChartRevealClipProps) { + const transition = clipRevealTransition(enterTransition) + const paddedWidth = Math.max(0, targetWidth + padding * 2) + const paddedHeight = height + padding * 2 + + if (!animating) { + return ( + + + + ) + } + + if (mode === "conceal") { + // Mirror the LTR reveal: advance the clip's left edge rightward while width + // shrinks (same geometry as `LineLoadingPulseStroke` exit half-cycle). + const rightEdge = -padding + paddedWidth + + return ( + + onComplete?.()} + transition={transition} + y={-padding} + /> + + ) + } + + return ( + + + + ) +} diff --git a/src/components/charts/decimate-time-series.ts b/src/components/charts/decimate-time-series.ts new file mode 100644 index 00000000..78405891 --- /dev/null +++ b/src/components/charts/decimate-time-series.ts @@ -0,0 +1,136 @@ +export function decimateTimeSeries>( + data: T[], + maxPoints: number, + valueKeys: string[] = [] +): T[] { + const len = data.length + if (maxPoints >= len || maxPoints < 3) { + return data + } + + const getY = (point: T, index: number): number => { + if (valueKeys.length === 0) { + for (const val of Object.values(point)) { + if (typeof val === "number") { + return val + } + } + return index + } + + let sum = 0 + let count = 0 + for (const key of valueKeys) { + const val = point[key] + if (typeof val === "number") { + sum += val + count++ + } + } + return count > 0 ? sum / count : index + } + + const sampled: T[] = [data[0] as T] + const bucketSize = (len - 2) / (maxPoints - 2) + let previousIndex = 0 + + for (let i = 0; i < maxPoints - 2; i++) { + const rangeStart = Math.floor((i + 1) * bucketSize) + 1 + const rangeEnd = Math.min(Math.floor((i + 2) * bucketSize) + 1, len - 1) + + const nextRangeStart = Math.floor((i + 2) * bucketSize) + 1 + const nextRangeEnd = Math.min(Math.floor((i + 3) * bucketSize) + 1, len) + const nextCount = Math.max(0, nextRangeEnd - nextRangeStart) + + let avgX = len - 1 + let avgY = getY(data[len - 1] as T, len - 1) + if (nextCount > 0) { + avgX = 0 + avgY = 0 + for (let j = nextRangeStart; j < nextRangeEnd; j++) { + avgX += j + avgY += getY(data[j] as T, j) + } + avgX /= nextCount + avgY /= nextCount + } + + const pointA = data[previousIndex] as T + const ax = previousIndex + const ay = getY(pointA, previousIndex) + + let maxArea = -1 + let maxIndex = rangeStart + + for (let j = rangeStart; j < rangeEnd; j++) { + const area = + Math.abs( + (ax - avgX) * (getY(data[j] as T, j) - ay) - (ax - j) * (avgY - ay) + ) * 0.5 + if (area > maxArea) { + maxArea = area + maxIndex = j + } + } + + sampled.push(data[maxIndex] as T) + previousIndex = maxIndex + } + + sampled.push(data[len - 1] as T) + return sampled +} + +/** ~1.5 points per pixel — enough for crisp curves without over-drawing. */ +export function maxRenderPointsForWidth(innerWidth: number): number { + return Math.max(64, Math.ceil(innerWidth * 1.5)) +} + +/** Bucket OHLC rows into fewer candles while preserving high/low extremes. */ +export function decimateOhlcData>( + data: T[], + maxPoints: number +): T[] { + const len = data.length + if (maxPoints >= len || maxPoints < 2) { + return data + } + + const bucketSize = len / maxPoints + const sampled: T[] = [] + + for (let i = 0; i < maxPoints; i++) { + const start = Math.floor(i * bucketSize) + const end = Math.min(len, Math.floor((i + 1) * bucketSize)) + if (start >= end) { + continue + } + + const bucket = data.slice(start, end) + const first = bucket[0] as T + const last = bucket.at(-1) as T + + let high = Number.NEGATIVE_INFINITY + let low = Number.POSITIVE_INFINITY + for (const row of bucket) { + const rowHigh = row.high + const rowLow = row.low + if (typeof rowHigh === "number" && rowHigh > high) { + high = rowHigh + } + if (typeof rowLow === "number" && rowLow < low) { + low = rowLow + } + } + + sampled.push({ + ...last, + open: first.open, + high: Number.isFinite(high) ? high : last.high, + low: Number.isFinite(low) ? low : last.low, + close: last.close + } as T) + } + + return sampled +} diff --git a/src/components/charts/filter-data-by-x-domain.ts b/src/components/charts/filter-data-by-x-domain.ts new file mode 100644 index 00000000..08ba70a8 --- /dev/null +++ b/src/components/charts/filter-data-by-x-domain.ts @@ -0,0 +1,59 @@ +export function filterDataByXDomain( + data: Record[], + xDomain: [Date, Date], + xAccessor: (d: Record) => Date +): Record[] { + const start = xDomain[0].getTime() + const end = xDomain[1].getTime() + const minTime = Math.min(start, end) + const maxTime = Math.max(start, end) + + return data.filter(d => { + const time = xAccessor(d).getTime() + return time >= minTime && time <= maxTime + }) +} + +export function resolveDataXExtent( + data: Record[], + xAccessor: (d: Record) => Date +): [Date, Date] | null { + if (data.length === 0) { + return null + } + + let minTime = Number.POSITIVE_INFINITY + let maxTime = Number.NEGATIVE_INFINITY + + for (const point of data) { + const time = xAccessor(point).getTime() + if (time < minTime) { + minTime = time + } + if (time > maxTime) { + maxTime = time + } + } + + if (minTime === Number.POSITIVE_INFINITY) { + return null + } + + return [new Date(minTime), new Date(maxTime)] +} + +/** Brush track extent — optionally extends past the last data row (e.g. projections). */ +export function resolveBrushTrackXExtent( + data: Record[], + xAccessor: (d: Record) => Date, + xExtentMax?: Date +): [Date, Date] | null { + const extent = resolveDataXExtent(data, xAccessor) + if (!extent) { + return null + } + if (!xExtentMax || xExtentMax.getTime() <= extent[1].getTime()) { + return extent + } + return [extent[0], xExtentMax] +} diff --git a/src/components/charts/generate-chart-skeleton-data.ts b/src/components/charts/generate-chart-skeleton-data.ts new file mode 100644 index 00000000..78257f39 --- /dev/null +++ b/src/components/charts/generate-chart-skeleton-data.ts @@ -0,0 +1,42 @@ +const DEFAULT_SKELETON_DATA_KEY = "value" +const DEFAULT_SKELETON_POINT_COUNT = 7 + +export interface GenerateChartSkeletonDataOptions { + /** Key used for y values in each row. Default: `"value"`. */ + dataKey?: string + /** Number of points. Default: 7. */ + pointCount?: number + /** Start date for the x axis. Default: 2025-01-01. */ + baseDate?: Date +} + +/** Placeholder series used while `status="loading"` and data is empty. */ +export function generateChartSkeletonData( + options: GenerateChartSkeletonDataOptions = {} +): Record[] { + const dataKey = options.dataKey ?? DEFAULT_SKELETON_DATA_KEY + const pointCount = options.pointCount ?? DEFAULT_SKELETON_POINT_COUNT + const baseDate = options.baseDate ?? new Date("2025-01-01") + + return Array.from({ length: pointCount }, (_, index) => { + const date = new Date(baseDate) + date.setDate(baseDate.getDate() + index) + return { + date, + [dataKey]: Math.round(110 + Math.sin(index * 1.15) * 36 + index * 9) + } + }) +} + +/** Skeleton rows that mirror target dates/count with lower magnitudes for Y tween. */ +export function generateChartSkeletonFromTarget( + targetData: Record[], + dataKey: string +): Record[] { + return targetData.map((row, index) => ({ + ...row, + [dataKey]: Math.round(95 + Math.sin(index * 1.05) * 28 + index * 7) + })) +} + +export { DEFAULT_SKELETON_DATA_KEY, DEFAULT_SKELETON_POINT_COUNT } diff --git a/src/components/charts/grid.tsx b/src/components/charts/grid.tsx new file mode 100644 index 00000000..8bd84e20 --- /dev/null +++ b/src/components/charts/grid.tsx @@ -0,0 +1,318 @@ +"use client" + +import { useId } from "react" +import { GridColumns, GridRows } from "@visx/grid" +import { motion } from "motion/react" + +import { chartCssVars, useChartStable, useYScale } from "./chart-context" +import { useGridShimmer } from "./use-grid-shimmer" +import { + isLoadingChromePhase, + isLoadingGridChromePhase +} from "./y-domain-utils" + +const DEFAULT_SHIMMER_LENGTH_PX = 140 +const DEFAULT_SHIMMER_SPEED = 1 +const DEFAULT_SHIMMER_STROKE = + "color-mix(in oklch, var(--foreground) 68%, transparent)" + +export interface GridProps { + /** Show horizontal grid lines. Default: true */ + horizontal?: boolean + /** Show vertical grid lines. Default: false */ + vertical?: boolean + /** Number of horizontal grid lines. Default: 5 */ + numTicksRows?: number + /** Number of vertical grid lines. Default: 10 */ + numTicksColumns?: number + /** Explicit tick values for horizontal grid lines. Overrides numTicksRows. */ + rowTickValues?: number[] + /** Grid line stroke color. Default: var(--chart-grid) */ + stroke?: string + /** Grid stroke while loading chrome is active. Falls back to `stroke`. */ + loadingStroke?: string + /** Grid line stroke opacity. Default: 1 */ + strokeOpacity?: number + /** Grid line stroke width. Default: 1 */ + strokeWidth?: number + /** Grid line dash array. Default: "4,4" for dashed lines */ + strokeDasharray?: string + /** Horizontal row values rendered with alternate styling (e.g. zero baseline). */ + highlightRowValues?: number[] + /** Stroke for highlighted rows. Default: var(--chart-foreground-muted) */ + highlightRowStroke?: string + /** Stroke opacity for highlighted rows. Default: 1 */ + highlightRowStrokeOpacity?: number + /** Stroke width for highlighted rows. Default: 1 */ + highlightRowStrokeWidth?: number + /** Dash array for highlighted rows. Default: solid line */ + highlightRowStrokeDasharray?: string + /** Enable horizontal fade effect on grid rows (fades at left/right). Default: true */ + fadeHorizontal?: boolean + /** Enable vertical fade effect on grid columns (fades at top/bottom). Default: false */ + fadeVertical?: boolean + /** Omit the first and last horizontal grid lines. Default: false */ + hideHorizontalEdgeLines?: boolean + /** Omit the first and last vertical grid lines. Default: false */ + hideVerticalEdgeLines?: boolean + /** Y-scale for horizontal grid lines. Default: primary (`"left"`) axis. */ + yAxisId?: string | number + /** Animate a shimmer band across horizontal grid lines. Default: false */ + shimmer?: boolean + /** Shimmer band stroke (color and opacity via color-mix or oklch alpha). */ + shimmerStroke?: string + /** Shimmer band width in pixels. Default: 140 */ + shimmerLength?: number + /** Shimmer speed multiplier (higher = faster). Default: 1 */ + shimmerSpeed?: number + /** Match loop timing to the loading line pulse (cycle + inter-loop pause). */ + shimmerSync?: boolean +} + +function hideEdgeTicks(ticks: T[], hideEdgeLines: boolean): T[] { + if (!hideEdgeLines || ticks.length <= 2) { + return ticks + } + return ticks.slice(1, -1) +} + +function resolveRowTickValues(options: { + hideHorizontalEdgeLines: boolean + numTicksRows: number + rowTickValues?: number[] + yScale: { ticks?: (count: number) => number[] } +}): number[] | undefined { + const { hideHorizontalEdgeLines, numTicksRows, rowTickValues, yScale } = + options + const ticks = + rowTickValues ?? (yScale.ticks ? yScale.ticks(numTicksRows) : []) + const filtered = hideEdgeTicks(ticks, hideHorizontalEdgeLines) + if (filtered === ticks && !rowTickValues && !hideHorizontalEdgeLines) { + return undefined + } + return filtered.length > 0 ? filtered : undefined +} + +// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: grid fade masks and shimmer share one layer tree +export function Grid({ + horizontal = true, + vertical = false, + numTicksRows = 5, + numTicksColumns = 10, + rowTickValues, + stroke = chartCssVars.grid, + loadingStroke, + strokeOpacity = 1, + strokeWidth = 1, + strokeDasharray = "4,4", + highlightRowValues, + highlightRowStroke = chartCssVars.foregroundMuted, + highlightRowStrokeOpacity = 1, + highlightRowStrokeWidth = 1, + highlightRowStrokeDasharray = "0", + fadeHorizontal = true, + fadeVertical = false, + hideHorizontalEdgeLines = false, + hideVerticalEdgeLines = false, + yAxisId, + shimmer = false, + shimmerStroke = DEFAULT_SHIMMER_STROKE, + shimmerLength = DEFAULT_SHIMMER_LENGTH_PX, + shimmerSpeed = DEFAULT_SHIMMER_SPEED, + shimmerSync = false +}: GridProps) { + const { xScale, innerWidth, innerHeight, orientation, barScale, chartPhase } = + useChartStable() + const yScale = useYScale(yAxisId) + const shimmerActive = shimmer && isLoadingChromePhase(chartPhase) + const gridStroke = + isLoadingGridChromePhase(chartPhase) && loadingStroke != null + ? loadingStroke + : stroke + const { shimmerEnabled, shimmerTransform } = useGridShimmer({ + innerWidth, + shimmer, + shimmerLength, + shimmerSpeed, + shimmerSync, + active: shimmerActive + }) + + // For bar charts, determine which scale to use for grid lines + // Horizontal bar charts: vertical grid should use yScale (value scale) + // Vertical bar charts: horizontal grid uses yScale (value scale) + const isHorizontalBarChart = orientation === "horizontal" && barScale + + // For vertical grid lines in horizontal bar charts, use yScale (the value scale) + // For time-based charts, use xScale + const columnScale = isHorizontalBarChart ? yScale : xScale + const rowTickValuesResolved = resolveRowTickValues({ + hideHorizontalEdgeLines, + numTicksRows, + rowTickValues, + yScale + }) + const columnTickValuesResolved = + vertical && + columnScale && + typeof columnScale === "function" && + hideVerticalEdgeLines + ? (() => { + const ticks = columnScale.ticks?.(numTicksColumns) ?? [] + const filtered = hideEdgeTicks(ticks, true) + return filtered.length > 0 ? filtered : undefined + })() + : undefined + const uniqueId = useId() + + // Horizontal fade mask (for grid rows - fades left/right) + const hMaskId = `grid-rows-fade-${uniqueId}` + const hGradientId = `${hMaskId}-gradient` + const shimmerGradientId = `grid-shimmer-${uniqueId}` + + // Vertical fade mask (for grid columns - fades top/bottom) + const vMaskId = `grid-cols-fade-${uniqueId}` + const vGradientId = `${vMaskId}-gradient` + const horizontalFadeMask = fadeHorizontal ? `url(#${hMaskId})` : undefined + + return ( + + {/* Gradient mask for horizontal grid lines - fades at left/right */} + {horizontal && fadeHorizontal && ( + + + + + + + + + + + + )} + + {horizontal && shimmerEnabled ? ( + + + + + + + + + + ) : null} + + {/* Gradient mask for vertical grid lines - fades at top/bottom */} + {vertical && fadeVertical && ( + + + + + + + + + + + + )} + + {horizontal && ( + + + {shimmerEnabled ? ( + + ) : null} + + )} + {horizontal && highlightRowValues && highlightRowValues.length > 0 ? ( + + {highlightRowValues.map(value => { + const y = yScale(value) + if (y == null || !Number.isFinite(y)) { + return null + } + + return ( + + ) + })} + + ) : null} + {vertical && columnScale && typeof columnScale === "function" && ( + + + + )} + + ) +} + +Grid.displayName = "Grid" + +export default Grid diff --git a/src/components/charts/indicator-fade.ts b/src/components/charts/indicator-fade.ts new file mode 100644 index 00000000..35641b49 --- /dev/null +++ b/src/components/charts/indicator-fade.ts @@ -0,0 +1,64 @@ +export type IndicatorFadeEdges = "both" | "none" | "top" | "bottom" + +export interface VerticalFadeSides { + top: boolean + bottom: boolean + any: boolean +} + +export function resolveVerticalFadeSides( + fade: IndicatorFadeEdges | boolean +): VerticalFadeSides { + if (fade === false || fade === "none") { + return { top: false, bottom: false, any: false } + } + if (fade === true || fade === "both") { + return { top: true, bottom: true, any: true } + } + if (fade === "top") { + return { top: true, bottom: false, any: true } + } + return { top: false, bottom: true, any: true } +} + +export interface IndicatorFadeGradientStop { + offset: string + opacity: number +} + +/** Opacity stops for the crosshair vertical gradient. */ +export function indicatorFadeGradientStops( + sides: VerticalFadeSides, + fadeLengthPercent = 10 +): IndicatorFadeGradientStop[] { + const fade = Math.min(40, Math.max(2, fadeLengthPercent)) + const innerEnd = 100 - fade + + if (!sides.any) { + return [{ offset: "0%", opacity: 1 }] + } + + if (sides.top && sides.bottom) { + return [ + { offset: "0%", opacity: 0 }, + { offset: `${fade}%`, opacity: 1 }, + { offset: "50%", opacity: 1 }, + { offset: `${innerEnd}%`, opacity: 1 }, + { offset: "100%", opacity: 0 } + ] + } + + if (sides.top) { + return [ + { offset: "0%", opacity: 0 }, + { offset: `${fade}%`, opacity: 1 }, + { offset: "100%", opacity: 1 } + ] + } + + return [ + { offset: "0%", opacity: 1 }, + { offset: `${innerEnd}%`, opacity: 1 }, + { offset: "100%", opacity: 0 } + ] +} diff --git a/src/components/charts/line-loading-timing.ts b/src/components/charts/line-loading-timing.ts new file mode 100644 index 00000000..f4b1a617 --- /dev/null +++ b/src/components/charts/line-loading-timing.ts @@ -0,0 +1,12 @@ +export const LINE_LOADING_PULSE_CYCLE_S = 2.2 + +/** Idle gap before the loading line pulse restarts (milliseconds). */ +export const LINE_LOADING_LOOP_PAUSE_MS = 280 + +/** Loading label exit on loading → ready (seconds). */ +export const LOADING_LABEL_EXIT_S = 0.45 + +/** Loading label drops this many pixels while exiting. */ +export const LOADING_LABEL_EXIT_Y_PX = 30 + +export const LINE_LOADING_PULSE_EASE = [0.85, 0, 0.15, 1] as const diff --git a/src/components/charts/loading-sweep.tsx b/src/components/charts/loading-sweep.tsx new file mode 100644 index 00000000..d9d78e0e --- /dev/null +++ b/src/components/charts/loading-sweep.tsx @@ -0,0 +1,481 @@ +"use client" + +import { useCallback, useEffect, useId, useMemo, useRef, useState } from "react" +import { scaleLinear } from "@visx/scale" +import { AreaClosed, LinePath } from "@visx/shape" +import type { CurveFactory, CurveFactoryLineOnly } from "@visx/vendor/d3-shape" +import { motion, useReducedMotion } from "motion/react" + +import { chartCssVars, useChartStable } from "./chart-context" +import { + LINE_LOADING_PULSE_EASE, + LOADING_LABEL_EXIT_S +} from "./line-loading-timing" + +/** + * Shared "sweep" loading visuals. A soft diagonal shimmer band travels across a + * self-contained skeleton silhouette on a loop, painted via an SVG mask. The + * silhouette re-randomizes between passes (held steady during a pass, re-rolled + * once the band clears the right edge) so it reads as live and still loading, + * without warping mid-sweep. Used as the `loadingStyle="sweep"` alternative to + * the traveling pulse on `` and ``, and as the skeleton for + * ``. + */ + +type LoadingSweepCurveFactory = CurveFactory | CurveFactoryLineOnly + +/** One shimmer sweep, in seconds. */ +const DEFAULT_SWEEP_DURATION_S = 2 +/** Sweep travel in objectBoundingBox space: off the left edge to off the right. */ +const SWEEP_START_X = -1 +const SWEEP_END_X = 2 +/** Diagonal tilt of the shimmer band, in degrees. */ +const SWEEP_ANGLE_DEG = 25 +const HEIGHT_MIN_PCT = 20 +const HEIGHT_MAX_PCT = 80 +const DEFAULT_POINT_COUNT = 14 +const BAR_CORNER_RADIUS = 2 +const DEFAULT_BAR_COUNT = 12 +const DEFAULT_FILL = "var(--foreground)" +const DEFAULT_BAR_FILL_OPACITY = 0.45 +const LINE_STROKE_OPACITY = 0.55 +const AREA_FILL_TOP_OPACITY = 0.18 +const AREA_FILL_BOTTOM_OPACITY = 0.02 +/** Bar width as a fraction of its band (the rest is the inter-bar gap). */ +const DEFAULT_BAR_FRACTION = 0.7 + +// ─── Pure, SSR-safe helpers ────────────────────────────────────────────── +// Heights come from a deterministic hash of (index, seed), never +// `Math.random()`, so the first server render and first client render agree +// (no Next.js hydration mismatch). Re-randomizing only bumps the numeric seed +// on the client, after a sweep completes. + +/** Cheap deterministic hash to a fractional part in [0, 1). */ +function hashFract(n: number): number { + const x = Math.sin(n) * 43_758.5453 + return x - Math.floor(x) +} + +/** Deterministic heights (percentages of the available height) for a seed. */ +export function getSkeletonHeights( + count: number, + seed = 0, + min = HEIGHT_MIN_PCT, + max = HEIGHT_MAX_PCT +): number[] { + const range = max - min + return Array.from( + { length: count }, + (_, i) => min + Math.floor(hashFract((i + 1) * 12.9898 + seed) * range) + ) +} + +/** Deterministic up/down (±1) per bar for the "center" baseline. */ +function getSkeletonSigns(count: number, seed = 0): number[] { + return Array.from({ length: count }, (_, i) => + hashFract((i + 1) * 78.233 + seed) < 0.5 ? -1 : 1 + ) +} + +/** Bell-curve opacity stops (sin squared) for the shimmer band's soft edges. */ +function generateEasedGradientStops( + steps = 17, + minOpacity = 0.05, + maxOpacity = 0.9 +) { + return Array.from({ length: steps }, (_, i) => { + const t = i / (steps - 1) + const eased = Math.sin(t * Math.PI) ** 2 + const opacity = minOpacity + eased * (maxOpacity - minOpacity) + return { + offset: `${(t * 100).toFixed(0)}%`, + opacity: Number(opacity.toFixed(3)) + } + }) +} + +// ─── Shared mask defs (`${chartId}-mask` is the mask id) ───────────────────── + +function LoadingSweepMask({ + chartId, + width, + height, + durationSeconds, + onSweepComplete +}: { + chartId: string + width: number + height: number + durationSeconds: number + onSweepComplete: () => void +}) { + const gradientStops = useMemo(() => generateEasedGradientStops(), []) + const lastXRef = useRef(SWEEP_START_X) + + const handleUpdate = useCallback( + (latest: { x?: number }) => { + const xValue = typeof latest.x === "number" ? latest.x : SWEEP_START_X + // Re-roll once the band has cleared the visible area (crossed past 1), + // so the silhouette never changes shape under the user's eye. + if (xValue >= 1 && lastXRef.current < 1) { + onSweepComplete() + } + lastXRef.current = xValue + }, + [onSweepComplete] + ) + + return ( + <> + + {gradientStops.map(({ offset, opacity }) => ( + + ))} + + + + + + + + + ) +} + +// ─── Line / Area loading sweep (its own re-randomizing silhouette) ─────────── + +export interface LineLoadingSweepProps { + /** Curve factory from the host `` / ``, so the silhouette matches + * the chart's interpolation (step, smooth, linear, …). */ + curve: LoadingSweepCurveFactory + /** Fill the silhouette as an area (for ``); otherwise stroke only. */ + withArea?: boolean + /** Loading phase: `"loop"` (steady), `"exit"` (loading → ready), or `"enter"` + * (ready → loading). Exit/enter fade the silhouette and then signal the chart + * to continue its reveal. Default: `"loop"`. */ + mode?: "loop" | "exit" | "enter" + /** Fired when an exit/enter transition finishes, to advance the chart phase. */ + onTransitionComplete?: () => void + stroke?: string + strokeOpacity?: number + strokeWidth?: number + pointCount?: number + durationSeconds?: number +} + +/** + * Renders a placeholder line/area silhouette (its own, not the chart's skeleton) + * with the shimmer sweeping across it. The silhouette re-randomizes between + * passes. Reads inner dimensions from chart context. + */ +export function LineLoadingSweep({ + curve, + withArea = false, + mode = "loop", + onTransitionComplete, + stroke = chartCssVars.foreground, + strokeOpacity = LINE_STROKE_OPACITY, + strokeWidth = 2, + pointCount = DEFAULT_POINT_COUNT, + durationSeconds = DEFAULT_SWEEP_DURATION_S +}: LineLoadingSweepProps) { + const { innerWidth, innerHeight } = useChartStable() + const reduceMotion = useReducedMotion() + const reactId = useId() + const chartId = `line-sweep-${reactId.replace(/[^a-zA-Z0-9_-]/g, "")}` + const isLoop = mode === "loop" + + const [tick, setTick] = useState(0) + // Re-randomize only while looping; hold the silhouette steady through a + // transition so it fades out (or in) as one piece. + const onSweepComplete = useCallback(() => { + if (isLoop) { + setTick(prev => prev + 1) + } + }, [isLoop]) + const heights = useMemo( + () => getSkeletonHeights(pointCount, tick), + [pointCount, tick] + ) + + // With reduced motion there is no fade to await, so signal the handoff + // immediately or the phase machine would stall mid-transition. + useEffect(() => { + if (reduceMotion && !isLoop) { + onTransitionComplete?.() + } + }, [reduceMotion, isLoop, onTransitionComplete]) + + if (innerWidth <= 0 || innerHeight <= 0 || heights.length < 2) { + return null + } + + const xScale = scaleLinear({ + domain: [0, heights.length - 1], + range: [0, innerWidth] + }) + const yScale = scaleLinear({ domain: [0, 100], range: [innerHeight, 0] }) + const points = heights.map((value, index) => ({ index, value })) + const getX = (d: { index: number }) => xScale(d.index) + const getY = (d: { value: number }) => yScale(d.value) + + const silhouette = ( + <> + {withArea ? ( + + ) : null} + + + ) + + const areaGradient = withArea ? ( + + + + + ) : null + + if (reduceMotion) { + return ( + <> + {areaGradient ? {areaGradient} : null} + {silhouette} + + ) + } + + const maskUrl = `url(#${chartId}-mask)` + const defs = ( + + {areaGradient} + + + ) + + if (isLoop) { + return ( + <> + {defs} + {silhouette} + + ) + } + + // Transition: fade the swept silhouette out (exit) or in (enter), then hand + // off to the chart so it can reveal the real series. + return ( + <> + {defs} + + {silhouette} + + + ) +} + +LineLoadingSweep.displayName = "LineLoadingSweep" + +// ─── Bar loading skeleton (seeded bars under the sweep, inner coords) ───────── + +function SkeletonBars({ + heights, + signs, + innerWidth, + innerHeight, + baseline, + barFraction, + fill, + fillOpacity +}: { + heights: number[] + signs: number[] + innerWidth: number + innerHeight: number + baseline: "bottom" | "center" + barFraction: number + fill: string + fillOpacity: number +}) { + const bandWidth = innerWidth / heights.length + const barW = bandWidth * barFraction + const xOffset = (bandWidth * (1 - barFraction)) / 2 + const isCenter = baseline === "center" + const baselineY = isCenter ? innerHeight / 2 : innerHeight + const halfBarH = isCenter ? innerHeight / 2 : innerHeight + + return ( + <> + {heights.map((value, i) => { + const sign = isCenter ? (signs[i] ?? 1) : 1 + const barH = Math.max(1, halfBarH * (value / 100)) + const x = i * bandWidth + xOffset + const y = sign === 1 ? baselineY - barH : baselineY + return ( + + ) + })} + + ) +} + +export interface BarLoadingSkeletonProps { + innerWidth: number + innerHeight: number + /** Number of skeleton bars. Default: 12 */ + barCount?: number + /** Bar fill color. Default: `var(--foreground)` */ + fill?: string + /** Bar fill opacity. Default: 0.45 */ + fillOpacity?: number + /** Bars rise from the bottom or diverge from the vertical center. Default: `"bottom"` */ + baseline?: "bottom" | "center" + /** Bar width as a fraction of its band (0–1). Default: 0.7 */ + barFraction?: number + /** One shimmer sweep, in seconds. Default: 2 */ + durationSeconds?: number +} + +/** + * Skeleton bars masked by the shimmer sweep, re-randomizing between passes. + * Rendered in the chart's inner coordinate space (origin at the inner top-left), + * so a `BarChart` drops it inside its margin-translated group. + */ +export function BarLoadingSkeleton({ + innerWidth, + innerHeight, + barCount = DEFAULT_BAR_COUNT, + fill = DEFAULT_FILL, + fillOpacity = DEFAULT_BAR_FILL_OPACITY, + baseline = "bottom", + barFraction = DEFAULT_BAR_FRACTION, + durationSeconds = DEFAULT_SWEEP_DURATION_S +}: BarLoadingSkeletonProps) { + const reduceMotion = useReducedMotion() + const reactId = useId() + const chartId = `bar-sweep-${reactId.replace(/[^a-zA-Z0-9_-]/g, "")}` + const [tick, setTick] = useState(0) + const onSweepComplete = useCallback(() => setTick(prev => prev + 1), []) + const heights = useMemo( + () => getSkeletonHeights(barCount, tick), + [barCount, tick] + ) + const signs = useMemo( + () => getSkeletonSigns(barCount, tick), + [barCount, tick] + ) + + if (innerWidth <= 0 || innerHeight <= 0) { + return null + } + + const bars = ( + + ) + + if (reduceMotion) { + return bars + } + + return ( + <> + + + + {bars} + + ) +} + +BarLoadingSkeleton.displayName = "BarLoadingSkeleton" diff --git a/src/components/charts/motion-utils.ts b/src/components/charts/motion-utils.ts new file mode 100644 index 00000000..560119ad --- /dev/null +++ b/src/components/charts/motion-utils.ts @@ -0,0 +1,59 @@ +import type { Transition } from "motion/react" + +import { DEFAULT_CHART_ENTER_TRANSITION } from "./animation" + +export function transitionWithDelay( + transition: Transition | undefined, + delaySeconds: number, + fallback: Transition = DEFAULT_CHART_ENTER_TRANSITION +): Transition { + const base = transition ?? fallback + return { ...base, delay: delaySeconds } +} + +export interface SpringOptions { + stiffness: number + damping: number + mass?: number +} + +export function springOptionsFromTransition( + transition?: Transition, + fallback: SpringOptions = { stiffness: 60, damping: 20 } +): SpringOptions { + if (!transition) { + return fallback + } + if (transition.type === "spring") { + const bounce = + typeof transition.bounce === "number" ? transition.bounce : undefined + const baseStiffness = + typeof transition.stiffness === "number" + ? transition.stiffness + : fallback.stiffness + const baseDamping = + typeof transition.damping === "number" + ? transition.damping + : fallback.damping + return { + stiffness: + bounce == null + ? baseStiffness + : Math.min(400, Math.max(80, baseStiffness * (1 + bounce * 0.35))), + damping: + bounce == null + ? baseDamping + : Math.max(8, baseDamping * (1 - bounce * 0.25)), + mass: + typeof transition.mass === "number" ? transition.mass : fallback.mass + } + } + const duration = + "duration" in transition && typeof transition.duration === "number" + ? transition.duration + : 0.8 + return { + stiffness: Math.min(500, Math.max(40, 280 / duration)), + damping: Math.min(40, Math.max(12, 18 + duration * 4)) + } +} diff --git a/src/components/charts/reference-area-config.ts b/src/components/charts/reference-area-config.ts new file mode 100644 index 00000000..4dccd028 --- /dev/null +++ b/src/components/charts/reference-area-config.ts @@ -0,0 +1,69 @@ +import { + Children, + isValidElement, + type ReactElement, + type ReactNode +} from "react" + +import { normalizeYAxisId } from "./y-axis-scales" + +export interface ReferenceAreaConfig { + yAxisId: string + y1?: number + y2?: number + axisLabelColor?: string +} + +interface ReferenceAreaConfigProps { + yAxisId?: string | number + y1?: number + y2?: number + axisLabelColor?: string +} + +function getChildComponentName(child: ReactElement) { + const childType = child.type as { displayName?: string; name?: string } + return typeof child.type === "function" + ? childType.displayName || childType.name || "" + : "" +} + +function isReferenceAreaElement(child: ReactElement): boolean { + return getChildComponentName(child) === "ReferenceArea" +} + +/** Collect {@link ReferenceArea} props from chart children for axis label styling. */ +export function extractReferenceAreaConfigs( + children: ReactNode +): ReferenceAreaConfig[] { + const configs: ReferenceAreaConfig[] = [] + + const visit = (node: ReactNode) => { + Children.forEach(node, child => { + if (!isValidElement(child)) { + return + } + + if (isReferenceAreaElement(child)) { + const props = child.props as ReferenceAreaConfigProps | undefined + if (props) { + configs.push({ + yAxisId: normalizeYAxisId(props.yAxisId), + y1: props.y1, + y2: props.y2, + axisLabelColor: props.axisLabelColor + }) + } + return + } + + const childProps = child.props as { children?: ReactNode } | undefined + if (childProps?.children) { + visit(childProps.children) + } + }) + } + + visit(children) + return configs +} diff --git a/src/components/charts/static-chart-preview-context.tsx b/src/components/charts/static-chart-preview-context.tsx new file mode 100644 index 00000000..59eec596 --- /dev/null +++ b/src/components/charts/static-chart-preview-context.tsx @@ -0,0 +1,22 @@ +"use client" + +import { createContext, useContext, type ReactNode } from "react" + +const StaticChartPreviewContext = createContext(false) + +/** Disables cartesian reveal clip-path for static docs previews. */ +export function StaticChartPreviewProvider({ + children +}: { + children: ReactNode +}) { + return ( + + {children} + + ) +} + +export function useStaticChartPreview() { + return useContext(StaticChartPreviewContext) +} diff --git a/src/components/charts/tooltip/chart-tooltip.tsx b/src/components/charts/tooltip/chart-tooltip.tsx new file mode 100644 index 00000000..84f95895 --- /dev/null +++ b/src/components/charts/tooltip/chart-tooltip.tsx @@ -0,0 +1,398 @@ +"use client" + +import { memo, useEffect, useMemo, useState } from "react" +import { createPortal } from "react-dom" +import { motion, useSpring } from "motion/react" + +import { + resolveTooltipBoxMotion, + useChartConfig, + type SpringConfig +} from "../chart-config-context" +import { + chartCssVars, + useChart, + useChartStable, + type LineConfig +} from "../chart-context" +import { weekdayDateFmt } from "../chart-formatters" +import type { IndicatorFadeEdges } from "../indicator-fade" +import { DateTicker } from "./date-ticker" +import { TooltipBox } from "./tooltip-box" +import { TooltipContent, type TooltipRow } from "./tooltip-content" +import { TooltipDot } from "./tooltip-dot" +import { TooltipIndicator } from "./tooltip-indicator" + +export interface ChartTooltipProps { + /** Whether to show the date pill at bottom. Default: true */ + showDatePill?: boolean + /** Whether to show the vertical crosshair line. Default: true */ + showCrosshair?: boolean + /** Whether to show dots on the lines. Default: true */ + showDots?: boolean + /** + * Color for the crosshair/indicator line. When a function, receives the hovered point + * (e.g. for candlestick: match candle color from close vs open). Default: --chart-crosshair. + */ + indicatorColor?: string | ((point: Record) => string) + /** Custom content renderer for the tooltip box */ + content?: (props: { + point: Record + index: number + }) => React.ReactNode + /** Custom row renderer - return array of TooltipRow */ + rows?: (point: Record) => TooltipRow[] + /** + * Override tooltip dot fill. When omitted and `rows` is set, dot colors match row colors. + * When a function, receives the hovered point and line config. + */ + dotColor?: + | string + | ((point: Record, line: LineConfig) => string) + /** Additional content to show below rows (e.g., markers) */ + children?: React.ReactNode + /** Custom class name */ + className?: string + /** Per-chart override for the crosshair / dot / date-pill spring. */ + springConfig?: SpringConfig + /** + * When `true`, the floating panel uses the crosshair spring and stays in sync. + * Default `false` — panel follow uses `damping` (`20`). + */ + matchCrosshair?: boolean + /** + * Spring damping for the floating tooltip panel when `matchCrosshair` is `false`. + * `0` disables spring motion (instant). Default: `20`. + */ + damping?: number + /** SVG stroke dash pattern for the crosshair. Omit for solid. */ + indicatorDasharray?: string + /** Vertical crosshair fade: `both`, `top`, `bottom`, or `none` (solid). Default: `both`. */ + indicatorFadeEdges?: IndicatorFadeEdges + /** Crosshair fade zone size (% of height). Default: `10`. */ + indicatorFadeLength?: number + /** Per-chart override for the floating-panel spring. */ + boxSpringConfig?: SpringConfig + /** Inline styles for the tooltip panel (background, blur, etc.). */ + panelStyle?: React.CSSProperties + /** + * Tooltip panel background color (CSS variable or color value). + * Default: `var(--chart-tooltip-background)`. + */ + backgroundColor?: string +} + +interface ChartTooltipInnerProps extends ChartTooltipProps { + container: HTMLElement +} + +const ChartTooltipInner = memo(function ChartTooltipInner({ + showDatePill = true, + showCrosshair = true, + showDots = true, + indicatorColor: indicatorColorProp, + content, + rows: rowsRenderer, + dotColor: dotColorProp, + children, + className = "", + container, + springConfig, + matchCrosshair = false, + damping, + indicatorDasharray, + indicatorFadeEdges, + indicatorFadeLength, + boxSpringConfig, + panelStyle, + backgroundColor +}: ChartTooltipInnerProps) { + const { + tooltipData, + width, + height, + innerHeight, + margin, + columnWidth, + lines, + xAccessor, + dateLabels, + containerRef, + orientation, + barXAccessor + } = useChart() + const { tooltipSpring } = useChartConfig() + + const isHorizontal = orientation === "horizontal" + const discreteInteraction = dateLabels.length > 60 + const boxMotion = useMemo(() => { + if (boxSpringConfig) { + return { + animate: !discreteInteraction, + springConfig: boxSpringConfig + } + } + if (matchCrosshair) { + return { + animate: !discreteInteraction, + springConfig: springConfig ?? tooltipSpring + } + } + return resolveTooltipBoxMotion(damping) + }, [ + boxSpringConfig, + damping, + discreteInteraction, + matchCrosshair, + springConfig, + tooltipSpring + ]) + + const visible = tooltipData !== null + const x = tooltipData?.x ?? 0 + const xWithMargin = x + margin.left + + // For horizontal charts, get the y position from the first line's yPosition (center of bar) + const firstLineDataKey = lines[0]?.dataKey + const firstLineY = firstLineDataKey + ? (tooltipData?.yPositions[firstLineDataKey] ?? 0) + : 0 + const yWithMargin = firstLineY + margin.top + + const tooltipRows = useMemo(() => { + if (!tooltipData) { + return [] + } + + if (rowsRenderer) { + return rowsRenderer(tooltipData.point) + } + + // Default: generate rows from registered lines + return lines.map(line => ({ + color: line.stroke, + label: line.dataKey, + value: (tooltipData.point[line.dataKey] as number) ?? 0 + })) + }, [tooltipData, lines, rowsRenderer]) + + const resolveDotColor = useMemo(() => { + return (line: LineConfig, index: number): string => { + if (rowsRenderer && tooltipRows[index]?.color) { + return tooltipRows[index].color + } + if (dotColorProp != null) { + if (typeof dotColorProp === "function" && tooltipData) { + return dotColorProp(tooltipData.point, line) + } + if (typeof dotColorProp === "string") { + return dotColorProp + } + } + return line.stroke + } + }, [dotColorProp, rowsRenderer, tooltipData, tooltipRows]) + + // Resolve indicator color (static or from hovered point) + const indicatorColor = useMemo(() => { + if (indicatorColorProp == null) { + return chartCssVars.crosshair + } + if (typeof indicatorColorProp === "function") { + return tooltipData + ? indicatorColorProp(tooltipData.point) + : chartCssVars.crosshair + } + return indicatorColorProp + }, [indicatorColorProp, tooltipData]) + + // Title from date or category + const title = useMemo(() => { + if (!tooltipData) { + return undefined + } + // For bar charts (horizontal or vertical), use the category name + if (barXAccessor) { + return barXAccessor(tooltipData.point) + } + // For line/area charts, use the date + return weekdayDateFmt.format(xAccessor(tooltipData.point)) + }, [tooltipData, barXAccessor, xAccessor]) + + const tooltipContent = ( + <> + {/* Crosshair indicator - rendered as SVG overlay */} + {showCrosshair && ( + + )} + + {/* Dots on bars/lines - show for vertical charts only */} + {showDots && visible && !isHorizontal && ( + + )} + + {/* Tooltip Box */} + + {content && tooltipData + ? content({ + point: tooltipData.point, + index: tooltipData.index + }) + : !content && ( + + {children} + + )} + + + {/* Date/Category Ticker - only show for vertical charts */} + + + ) + + return createPortal(tooltipContent, container) +}) + +export function ChartTooltip(props: ChartTooltipProps) { + const { containerRef } = useChartStable() + const [container, setContainer] = useState(null) + const [mounted, setMounted] = useState(false) + + // Only render portals on client side after mount + useEffect(() => { + setMounted(true) + setContainer(containerRef.current) + }, [containerRef]) + + if (!(mounted && container)) { + return null + } + + return +} + +ChartTooltip.displayName = "ChartTooltip" + +interface DatePillTrackerProps { + enabled: boolean + visible: boolean + labels: string[] + currentIndex: number + xWithMargin: number + discreteInteraction: boolean + springConfig?: SpringConfig +} + +// Inner-only-on-visible so `useSpring` initializes at the real cursor x +// instead of `margin.left` on first hover. +function DatePillTracker(props: DatePillTrackerProps) { + if (!(props.enabled && props.visible && props.labels.length > 0)) { + return null + } + return +} + +function DatePillTrackerInner({ + labels, + currentIndex, + xWithMargin, + discreteInteraction, + springConfig, + visible +}: DatePillTrackerProps) { + const { tooltipSpring } = useChartConfig() + const effectiveSpring = springConfig ?? tooltipSpring + const animatedX = useSpring(xWithMargin, effectiveSpring) + + if (!discreteInteraction) { + animatedX.set(xWithMargin) + } + + // biome-ignore lint/correctness/useExhaustiveDependencies: we need to jump the animatedX when the visible prop changes + useEffect(() => { + animatedX.set(xWithMargin) + }, [animatedX, visible, xWithMargin]) + + return ( + + + + ) +} + +export default ChartTooltip diff --git a/src/components/charts/tooltip/date-ticker.tsx b/src/components/charts/tooltip/date-ticker.tsx new file mode 100644 index 00000000..98c9a476 --- /dev/null +++ b/src/components/charts/tooltip/date-ticker.tsx @@ -0,0 +1,161 @@ +"use client" + +import { memo, useEffect, useMemo, useRef } from "react" +import { motion, useSpring } from "motion/react" + +const TICKER_ITEM_HEIGHT = 24 +/** Full scroll stacks are skipped above this count — single label + instant updates. */ +const COMPACT_TICKER_THRESHOLD = 60 + +export interface DateTickerProps { + currentIndex: number + labels: string[] + visible: boolean +} + +const DateTickerCompact = memo(function DateTickerCompact({ + currentIndex, + labels +}: Omit) { + const label = labels[currentIndex] ?? labels[0] ?? "" + + return ( +
+
+ {label} +
+
+ ) +}) + +const DateTickerInner = memo(function DateTickerInner({ + currentIndex, + labels +}: Omit) { + // Parse labels into month and day parts + const parsedLabels = useMemo(() => { + return labels.map((label, index) => { + const parts = label.split(" ") + const month = parts[0] || "" + const day = parts[1] || "" + return { month, day, full: label, key: `${label}::${index}` } + }) + }, [labels]) + + // Month segments: one entry per consecutive run (Jan → Feb → …), keyed by start index + const monthSegments = useMemo(() => { + const segments: { month: string; key: string; startIndex: number }[] = [] + + parsedLabels.forEach((label, index) => { + const prev = segments.at(-1) + if (!prev || prev.month !== label.month) { + segments.push({ + month: label.month, + key: `${label.month}-${index}`, + startIndex: index + }) + } + }) + + return segments + }, [parsedLabels]) + + // Index into monthSegments for the current data point + const currentMonthIndex = useMemo(() => { + if (currentIndex < 0 || currentIndex >= parsedLabels.length) { + return 0 + } + for (let i = monthSegments.length - 1; i >= 0; i--) { + const segment = monthSegments[i] + if (segment && segment.startIndex <= currentIndex) { + return i + } + } + return 0 + }, [currentIndex, parsedLabels.length, monthSegments]) + + // Track previous month index + const prevMonthIndexRef = useRef(-1) + + // Animated Y offsets + const dayY = useSpring(0, { stiffness: 400, damping: 35 }) + const monthY = useSpring(0, { stiffness: 400, damping: 35 }) + + useEffect(() => { + dayY.set(-currentIndex * TICKER_ITEM_HEIGHT) + + if (currentMonthIndex < 0) { + return + } + + const isFirstRender = prevMonthIndexRef.current === -1 + const monthChanged = prevMonthIndexRef.current !== currentMonthIndex + + if (isFirstRender || monthChanged) { + monthY.set(-currentMonthIndex * TICKER_ITEM_HEIGHT) + prevMonthIndexRef.current = currentMonthIndex + } + }, [currentIndex, currentMonthIndex, dayY, monthY]) + + return ( +
+
+
+ {/* Month stack */} +
+ + {monthSegments.map(segment => ( +
+ + {segment.month} + +
+ ))} +
+
+ + {/* Day stack */} +
+ + {parsedLabels.map(label => ( +
+ + {label.day} + +
+ ))} +
+
+
+
+
+ ) +}) + +export function DateTicker({ currentIndex, labels, visible }: DateTickerProps) { + if (!visible || labels.length === 0) { + return null + } + + if (labels.length > COMPACT_TICKER_THRESHOLD) { + return + } + + return +} + +DateTicker.displayName = "DateTicker" + +export default DateTicker diff --git a/src/components/charts/tooltip/index.ts b/src/components/charts/tooltip/index.ts new file mode 100644 index 00000000..acb80794 --- /dev/null +++ b/src/components/charts/tooltip/index.ts @@ -0,0 +1,14 @@ +export { ChartTooltip, type ChartTooltipProps } from "./chart-tooltip" +export { DateTicker, type DateTickerProps } from "./date-ticker" +export { TooltipBox, type TooltipBoxProps } from "./tooltip-box" +export { + TooltipContent, + type TooltipContentProps, + type TooltipRow +} from "./tooltip-content" +export { TooltipDot, type TooltipDotProps } from "./tooltip-dot" +export { + type IndicatorWidth, + TooltipIndicator, + type TooltipIndicatorProps +} from "./tooltip-indicator" diff --git a/src/components/charts/tooltip/tooltip-box.tsx b/src/components/charts/tooltip/tooltip-box.tsx new file mode 100644 index 00000000..f9902d6d --- /dev/null +++ b/src/components/charts/tooltip/tooltip-box.tsx @@ -0,0 +1,221 @@ +"use client" + +import type { RefObject } from "react" +import { useEffect, useLayoutEffect, useRef, useState } from "react" +import { createPortal } from "react-dom" +import { motion, useSpring } from "motion/react" + +import { cn } from "@/lib/utils" +import { useChartConfig, type SpringConfig } from "../chart-config-context" +import { chartCssVars } from "../chart-context" + +export interface TooltipBoxProps { + /** X position in pixels (relative to container) */ + x: number + /** Y position in pixels (relative to container) */ + y: number + /** Whether the tooltip is visible */ + visible: boolean + /** Container ref for portal rendering */ + containerRef: RefObject + /** Container width for flip detection */ + containerWidth: number + /** Container height for bounds clamping */ + containerHeight: number + /** Offset from the target position */ + offset?: number + /** Custom class name */ + className?: string + /** Tooltip content */ + children: React.ReactNode + /** Override left position (bypasses internal calculation) */ + left?: number | ReturnType + /** Override top position (bypasses internal calculation) */ + top?: number | ReturnType + /** Force flip direction (for custom positioning) */ + flipped?: boolean + /** Per-chart override; falls back to `ChartConfigProvider.tooltipBoxSpring`. */ + springConfig?: SpringConfig + /** Animate panel position with a spring. Default: true */ + animate?: boolean + /** Inline styles for the inner tooltip panel. */ + panelStyle?: React.CSSProperties + /** + * Tooltip panel background color (CSS variable or color value). + * Default: `var(--chart-tooltip-background)`. + */ + backgroundColor?: string +} + +// Inner-only-on-visible so `useSpring` initializes at the cursor's actual x/y +// instead of (0, 0) on first hover. +export function TooltipBox(props: TooltipBoxProps) { + const [container, setContainer] = useState(null) + const [mounted, setMounted] = useState(false) + + useEffect(() => { + setMounted(true) + setContainer(props.containerRef.current) + }, [props.containerRef]) + + if (!(mounted && container)) { + return null + } + if (!props.visible) { + return null + } + return +} + +function TooltipBoxInner({ + x, + y, + containerWidth, + containerHeight, + offset = 16, + className = "", + children, + left: leftOverride, + top: topOverride, + flipped: flippedOverride, + springConfig, + animate = true, + panelStyle, + backgroundColor = chartCssVars.tooltipBackground, + container +}: Omit & { + container: HTMLElement +}) { + const { tooltipBoxSpring } = useChartConfig() + const effectiveSpring = springConfig ?? tooltipBoxSpring + + const tooltipRef = useRef(null) + const tooltipWidthRef = useRef(180) + const tooltipHeightRef = useRef(80) + const [staticPosition, setStaticPosition] = useState({ left: x, top: y }) + const [tooltipSize, setTooltipSize] = useState({ width: 180, height: 80 }) + + const tw = tooltipSize.width + const th = tooltipSize.height + const shouldFlipX = x + tw + offset > containerWidth + const targetX = shouldFlipX ? x - offset - tw : x + offset + const targetY = Math.max( + offset, + Math.min(y - th / 2, containerHeight - th - offset) + ) + + const animatedLeft = useSpring(targetX, effectiveSpring) + const animatedTop = useSpring(targetY, effectiveSpring) + + if (animate && leftOverride === undefined) { + animatedLeft.set(targetX) + } + if (animate && topOverride === undefined) { + animatedTop.set(targetY) + } + + useLayoutEffect(() => { + if (!tooltipRef.current) { + return + } + const el = tooltipRef.current + const w = el.offsetWidth + const h = el.offsetHeight + if (w > 0) { + tooltipWidthRef.current = w + } + if (h > 0) { + tooltipHeightRef.current = h + } + const w2 = tooltipWidthRef.current + const h2 = tooltipHeightRef.current + setTooltipSize(size => + size.width === w2 && size.height === h2 ? size : { width: w2, height: h2 } + ) + const flip = x + w2 + offset > containerWidth + const tx = flip ? x - offset - w2 : x + offset + const ty = Math.max( + offset, + Math.min(y - h2 / 2, containerHeight - h2 - offset) + ) + if (!animate) { + setStaticPosition({ left: tx, top: ty }) + return + } + if (leftOverride === undefined) { + animatedLeft.set(tx) + } + if (topOverride === undefined) { + animatedTop.set(ty) + } + }, [ + x, + y, + containerWidth, + containerHeight, + offset, + leftOverride, + topOverride, + animate, + animatedLeft, + animatedTop + ]) + + const prevFlipRef = useRef(shouldFlipX) + const [flipKey, setFlipKey] = useState(0) + + useEffect(() => { + if (prevFlipRef.current !== shouldFlipX) { + setFlipKey(k => k + 1) + prevFlipRef.current = shouldFlipX + } + }, [shouldFlipX]) + + const finalLeft = animate + ? (leftOverride ?? animatedLeft) + : staticPosition.left + const finalTop = animate ? (topOverride ?? animatedTop) : staticPosition.top + const isFlipped = flippedOverride ?? shouldFlipX + const transformOrigin = isFlipped ? "right top" : "left top" + + return createPortal( + + + {children} + + , + container + ) +} + +TooltipBox.displayName = "TooltipBox" + +export default TooltipBox diff --git a/src/components/charts/tooltip/tooltip-content.tsx b/src/components/charts/tooltip/tooltip-content.tsx new file mode 100644 index 00000000..a7ef36b8 --- /dev/null +++ b/src/components/charts/tooltip/tooltip-content.tsx @@ -0,0 +1,69 @@ +"use client" + +import type { ReactNode } from "react" + +import { intFmt } from "../chart-formatters" + +export interface TooltipRow { + color: string + label: string + value: string | number +} + +export interface TooltipContentProps { + title?: string + rows: TooltipRow[] + /** Optional additional content (e.g., markers) */ + children?: ReactNode +} + +export function TooltipContent({ title, rows, children }: TooltipContentProps) { + return ( +
+
+ {title && ( +
+ {title} +
+ )} +
+ {rows.map(row => ( +
+
+ + + {row.label} + +
+ + {typeof row.value === "number" ? intFmt(row.value) : row.value} + +
+ ))} +
+ + {children && ( +
+ {children} +
+ )} +
+
+ ) +} + +TooltipContent.displayName = "TooltipContent" + +export default TooltipContent diff --git a/src/components/charts/tooltip/tooltip-dot.tsx b/src/components/charts/tooltip/tooltip-dot.tsx new file mode 100644 index 00000000..6f2832a1 --- /dev/null +++ b/src/components/charts/tooltip/tooltip-dot.tsx @@ -0,0 +1,74 @@ +"use client" + +import { motion, useSpring } from "motion/react" + +import { useChartConfig, type SpringConfig } from "../chart-config-context" +import { chartCssVars } from "../chart-context" + +export interface TooltipDotProps { + x: number + y: number + visible: boolean + color: string + size?: number + strokeColor?: string + strokeWidth?: number + /** Per-chart override; falls back to `ChartConfigProvider.tooltipSpring`. */ + springConfig?: SpringConfig + /** Animate position with a spring. Default: true */ + animate?: boolean +} + +export function TooltipDot({ + x, + y, + visible, + color, + size = 5, + strokeColor = chartCssVars.background, + strokeWidth = 2, + springConfig, + animate = true +}: TooltipDotProps) { + const { tooltipSpring } = useChartConfig() + const effectiveSpring = springConfig ?? tooltipSpring + const animatedX = useSpring(x, effectiveSpring) + const animatedY = useSpring(y, effectiveSpring) + + if (animate) { + animatedX.set(x) + animatedY.set(y) + } + + if (!visible) { + return null + } + + if (!animate) { + return ( + + ) + } + + return ( + + ) +} + +TooltipDot.displayName = "TooltipDot" + +export default TooltipDot diff --git a/src/components/charts/tooltip/tooltip-indicator.tsx b/src/components/charts/tooltip/tooltip-indicator.tsx new file mode 100644 index 00000000..90af0cb2 --- /dev/null +++ b/src/components/charts/tooltip/tooltip-indicator.tsx @@ -0,0 +1,212 @@ +"use client" + +import { useEffect } from "react" +import { motion, useSpring } from "motion/react" + +import { useChartConfig, type SpringConfig } from "../chart-config-context" +import { chartCssVars } from "../chart-context" +import { + indicatorFadeGradientStops, + resolveVerticalFadeSides, + type IndicatorFadeEdges +} from "../indicator-fade" + +export type IndicatorWidth = + | number // Pixel width + | "line" // 1px line (default) + | "thin" // 2px + | "medium" // 4px + | "thick" // 8px + +export interface TooltipIndicatorProps { + /** X position in pixels (center of the indicator) */ + x: number + /** Height of the indicator */ + height: number + /** Whether the indicator is visible */ + visible: boolean + /** + * Width of the indicator - number (pixels) or preset. + * Ignored if `span` is provided. + */ + width?: IndicatorWidth + /** + * Number of columns/days to span, with current point centered. + * Requires `columnWidth` to be set. + */ + span?: number + /** Width of a single column/day in pixels. Required when using `span`. */ + columnWidth?: number + /** Primary color at edges (10% and 90%) */ + colorEdge?: string + /** Secondary color at center (50%) */ + colorMid?: string + /** Vertical fade: both ends, top, bottom, or none (solid). */ + fadeEdges?: IndicatorFadeEdges | boolean + /** Fade zone size as a percentage of indicator height. Default: 10 */ + fadeLength?: number + /** Animate position with a spring. Default: true */ + animate?: boolean + /** Unique ID for the gradient */ + gradientId?: string + /** Per-chart override; falls back to `ChartConfigProvider.tooltipSpring`. */ + springConfig?: SpringConfig + /** SVG stroke dash pattern. When set, renders a dashed stroke instead of a solid fill. */ + strokeDasharray?: string +} + +function resolveWidth(width: IndicatorWidth): number { + if (typeof width === "number") { + return width + } + switch (width) { + case "line": + return 1 + case "thin": + return 2 + case "medium": + return 4 + case "thick": + return 8 + default: + return 1 + } +} + +// Inner-only-on-visible so `useSpring` initializes at the real cursor x +// instead of 0 on first hover. +export function TooltipIndicator(props: TooltipIndicatorProps) { + if (!props.visible) { + return null + } + return +} + +function TooltipIndicatorInner({ + x, + visible, + height, + width = "line", + span, + columnWidth, + colorEdge = chartCssVars.crosshair, + colorMid = chartCssVars.crosshair, + fadeEdges = "both", + fadeLength = 10, + animate = true, + gradientId = "tooltip-indicator-gradient", + springConfig, + strokeDasharray +}: TooltipIndicatorProps) { + const { tooltipSpring } = useChartConfig() + const effectiveSpring = springConfig ?? tooltipSpring + + const pixelWidth = + span !== undefined && columnWidth !== undefined + ? span * columnWidth + : resolveWidth(width) + + const rectX = x - pixelWidth / 2 + const lineX = x + const animatedX = useSpring(rectX, effectiveSpring) + const animatedLineX = useSpring(lineX, effectiveSpring) + + if (animate) { + animatedX.set(rectX) + animatedLineX.set(lineX) + } + + // biome-ignore lint/correctness/useExhaustiveDependencies: we need to jump the animatedX when the visible prop changes + useEffect(() => { + animatedX.set(rectX) + animatedLineX.set(lineX) + }, [animatedLineX, animatedX, lineX, rectX, visible]) + + const indicatorFill = colorMid || colorEdge + const fadeSides = resolveVerticalFadeSides(fadeEdges) + const dashed = Boolean(strokeDasharray) + + if (dashed) { + const strokeWidth = Math.max(1, pixelWidth) + return animate ? ( + + ) : ( + + ) + } + + if (!fadeSides.any) { + return animate ? ( + + ) : ( + + ) + } + + const fadeStops = indicatorFadeGradientStops(fadeSides, fadeLength) + + return ( + + + + {fadeStops.map(stop => ( + + ))} + + + {animate ? ( + + ) : ( + + )} + + ) +} + +TooltipIndicator.displayName = "TooltipIndicator" + +export default TooltipIndicator diff --git a/src/components/charts/use-animated-y-domains.ts b/src/components/charts/use-animated-y-domains.ts new file mode 100644 index 00000000..19a2c10e --- /dev/null +++ b/src/components/charts/use-animated-y-domains.ts @@ -0,0 +1,254 @@ +"use client" + +import { useEffect, useRef, useState } from "react" +import { animate, useReducedMotion } from "motion/react" + +import type { ChartPhase } from "./chart-phase" +import { LINE_LOADING_PULSE_EASE } from "./line-loading-timing" +import { + domainsEqual, + isYDomainTweenPhase, + resolveAnimatedYDestinationDomains, + shouldTweenYDomain, + type YDomain +} from "./y-domain-utils" + +function lerpDomain(from: YDomain, to: YDomain, progress: number): YDomain { + return [ + from[0] + (to[0] - from[0]) * progress, + from[1] + (to[1] - from[1]) * progress + ] +} + +function snapDomains( + domains: Record, + setAnimatedByAxis: (domains: Record) => void, + animatedRef: { current: Record } +) { + if (domainsEqual(animatedRef.current, domains)) { + return + } + setAnimatedByAxis(domains) + animatedRef.current = domains +} + +function tweenDomains({ + destination, + durationMs, + enabled, + reducedMotion, + animatedRef, + setAnimatedByAxis, + onSettled +}: { + destination: Record + durationMs: number + enabled: boolean + reducedMotion: boolean | null + animatedRef: { current: Record } + setAnimatedByAxis: (domains: Record) => void + onSettled?: () => void +}) { + if (domainsEqual(animatedRef.current, destination)) { + onSettled?.() + return + } + + if (!enabled || reducedMotion) { + snapDomains(destination, setAnimatedByAxis, animatedRef) + onSettled?.() + return + } + + const axisIds = Object.keys(destination) + const fromSnapshot = animatedRef.current + + let needsTween = false + for (const axisId of axisIds) { + const from = + fromSnapshot[axisId] ?? destination[axisId] ?? ([0, 100] as YDomain) + const to = destination[axisId] ?? from + if (shouldTweenYDomain(from, to)) { + needsTween = true + break + } + } + + if (!needsTween) { + snapDomains(destination, setAnimatedByAxis, animatedRef) + onSettled?.() + return + } + + const fromByAxis: Record = {} + for (const axisId of axisIds) { + fromByAxis[axisId] = fromSnapshot[axisId] ?? destination[axisId] ?? [0, 100] + } + + const control = animate(0, 1, { + duration: durationMs / 1000, + ease: [...LINE_LOADING_PULSE_EASE], + onUpdate: progress => { + const next: Record = {} + for (const axisId of axisIds) { + const from = + fromByAxis[axisId] ?? destination[axisId] ?? ([0, 100] as YDomain) + const to = destination[axisId] ?? from + next[axisId] = shouldTweenYDomain(from, to) + ? lerpDomain(from, to, progress) + : to + } + animatedRef.current = next + setAnimatedByAxis(next) + }, + onComplete: () => { + snapDomains(destination, setAnimatedByAxis, animatedRef) + onSettled?.() + } + }) + + return control +} + +export interface UseAnimatedYDomainsOptions { + enabled: boolean + durationMs: number + chartPhase: ChartPhase + skeletonByAxis: Record + targetByAxis: Record + onSettled?: () => void + /** When true, tweens y-domains on target changes while the chart is in the ready phase (e.g. brush zoom). */ + tweenOnTargetChange?: boolean +} + +export function useAnimatedYDomains({ + enabled, + durationMs, + chartPhase, + skeletonByAxis, + targetByAxis, + onSettled, + tweenOnTargetChange = false +}: UseAnimatedYDomainsOptions): Record { + const reducedMotion = useReducedMotion() + const destinationByAxis = resolveAnimatedYDestinationDomains( + chartPhase, + skeletonByAxis, + targetByAxis + ) + const destinationRef = useRef(destinationByAxis) + const skeletonRef = useRef(skeletonByAxis) + const targetRef = useRef(targetByAxis) + + const [animatedByAxis, setAnimatedByAxis] = useState(destinationByAxis) + const animatedRef = useRef(animatedByAxis) + const prevPhaseRef = useRef(chartPhase) + const onSettledRef = useRef(onSettled) + + useEffect(() => { + destinationRef.current = destinationByAxis + }, [destinationByAxis]) + + useEffect(() => { + skeletonRef.current = skeletonByAxis + }, [skeletonByAxis]) + + useEffect(() => { + targetRef.current = targetByAxis + }, [targetByAxis]) + + useEffect(() => { + onSettledRef.current = onSettled + }, [onSettled]) + + useEffect(() => { + animatedRef.current = animatedByAxis + }, [animatedByAxis]) + + useEffect(() => { + if (prevPhaseRef.current === chartPhase) { + return + } + prevPhaseRef.current = chartPhase + + const settle = () => { + onSettledRef.current?.() + } + + // Keep grid spacing frozen while the series exits the viewport. + if (chartPhase === "exiting") { + snapDomains(skeletonRef.current, setAnimatedByAxis, animatedRef) + return + } + if (chartPhase === "exitingReady") { + snapDomains(targetRef.current, setAnimatedByAxis, animatedRef) + return + } + if (chartPhase === "loading") { + snapDomains(skeletonRef.current, setAnimatedByAxis, animatedRef) + return + } + if (chartPhase === "revealing" || chartPhase === "ready") { + snapDomains(targetRef.current, setAnimatedByAxis, animatedRef) + return + } + + if (!isYDomainTweenPhase(chartPhase)) { + return + } + + const control = tweenDomains({ + destination: destinationRef.current, + durationMs, + enabled, + reducedMotion, + animatedRef, + setAnimatedByAxis, + onSettled: settle + }) + + return () => control?.stop() + }, [chartPhase, durationMs, enabled, reducedMotion]) + + const targetSignature = JSON.stringify(targetByAxis) + const prevTargetSignatureRef = useRef(targetSignature) + + useEffect(() => { + const inLivePhase = chartPhase === "ready" || chartPhase === "revealing" + + if (!inLivePhase) { + prevTargetSignatureRef.current = targetSignature + return + } + + if (prevTargetSignatureRef.current === targetSignature) { + return + } + prevTargetSignatureRef.current = targetSignature + + if (tweenOnTargetChange && chartPhase === "ready") { + const control = tweenDomains({ + destination: targetRef.current, + durationMs, + enabled, + reducedMotion, + animatedRef, + setAnimatedByAxis, + onSettled: () => onSettledRef.current?.() + }) + + return () => control?.stop() + } + + snapDomains(targetRef.current, setAnimatedByAxis, animatedRef) + }, [ + chartPhase, + durationMs, + enabled, + reducedMotion, + targetSignature, + tweenOnTargetChange + ]) + + return animatedByAxis +} diff --git a/src/components/charts/use-chart-interaction.ts b/src/components/charts/use-chart-interaction.ts new file mode 100644 index 00000000..63d1d3eb --- /dev/null +++ b/src/components/charts/use-chart-interaction.ts @@ -0,0 +1,353 @@ +"use client" + +import { useCallback, useEffect, useRef, useState } from "react" +import { localPoint } from "@visx/event" +import type { scaleLinear, scaleTime } from "@visx/scale" + +import type { LineConfig, Margin, TooltipData } from "./chart-context" +import { useScheduledTooltip } from "./use-scheduled-tooltip" +import { normalizeYAxisId } from "./y-axis-scales" + +type ScaleTime = ReturnType> +type ScaleLinear = ReturnType> + +export interface ChartSelection { + startX: number + endX: number + startIndex: number + endIndex: number + active: boolean +} + +interface UseChartInteractionParams { + xScale: ScaleTime + yScale: ScaleLinear + yScales: Record + data: Record[] + lines: LineConfig[] + margin: Margin + xAccessor: (d: Record) => Date + bisectDate: ( + data: Record[], + date: Date, + lo: number + ) => number + canInteract: boolean +} + +interface ChartInteractionResult { + tooltipData: TooltipData | null + setTooltipData: React.Dispatch> + selection: ChartSelection | null + clearSelection: () => void + interactionHandlers: { + onMouseMove?: (event: React.MouseEvent) => void + onMouseLeave?: () => void + onMouseDown?: (event: React.MouseEvent) => void + onMouseUp?: () => void + onTouchStart?: (event: React.TouchEvent) => void + onTouchMove?: (event: React.TouchEvent) => void + onTouchEnd?: () => void + } + interactionStyle: React.CSSProperties +} + +export function useChartInteraction({ + xScale, + yScale, + yScales, + data, + lines, + margin, + xAccessor, + bisectDate, + canInteract +}: UseChartInteractionParams): ChartInteractionResult { + const [selection, setSelection] = useState(null) + const { + tooltipData, + setTooltipData, + scheduleTooltip, + clearTooltip, + resetTooltipDedupe + } = useScheduledTooltip() + + const isDraggingRef = useRef(false) + const dragStartXRef = useRef(0) + const lastHoveredXRef = useRef(null) + + const resolveTooltipFromX = useCallback( + (pixelX: number): TooltipData | null => { + const x0 = xScale.invert(pixelX) + const index = bisectDate(data, x0, 1) + const d0 = data[index - 1] + const d1 = data[index] + + if (!d0) { + return null + } + + let d = d0 + let finalIndex = index - 1 + if (d1) { + const d0Time = xAccessor(d0).getTime() + const d1Time = xAccessor(d1).getTime() + if (x0.getTime() - d0Time > d1Time - x0.getTime()) { + d = d1 + finalIndex = index + } + } + + const yPositions: Record = {} + for (const line of lines) { + const value = d[line.dataKey] + if (typeof value === "number") { + const axisScale = yScales[normalizeYAxisId(line.yAxisId)] ?? yScale + yPositions[line.dataKey] = axisScale(value) ?? 0 + } + } + + return { + point: d, + index: finalIndex, + x: xScale(xAccessor(d)) ?? 0, + yPositions + } + }, + [xScale, yScale, yScales, data, lines, xAccessor, bisectDate] + ) + + const resolveIndexFromX = useCallback( + (pixelX: number): number => { + const x0 = xScale.invert(pixelX) + const index = bisectDate(data, x0, 1) + const d0 = data[index - 1] + const d1 = data[index] + if (!d0) { + return 0 + } + if (d1) { + const d0Time = xAccessor(d0).getTime() + const d1Time = xAccessor(d1).getTime() + if (x0.getTime() - d0Time > d1Time - x0.getTime()) { + return index + } + } + return index - 1 + }, + [xScale, data, xAccessor, bisectDate] + ) + + const getChartX = useCallback( + ( + event: React.MouseEvent | React.TouchEvent, + touchIndex = 0 + ): number | null => { + let point: { x: number; y: number } | null = null + + if ("touches" in event) { + const touch = event.touches[touchIndex] + if (!touch) { + return null + } + const svg = event.currentTarget.ownerSVGElement + if (!svg) { + return null + } + point = localPoint(svg, touch as unknown as MouseEvent) + } else { + point = localPoint(event) + } + + if (!point) { + return null + } + return point.x - margin.left + }, + [margin.left] + ) + + const handleMouseMove = useCallback( + (event: React.MouseEvent) => { + const chartX = getChartX(event) + if (chartX === null) { + return + } + + if (isDraggingRef.current) { + const startX = Math.min(dragStartXRef.current, chartX) + const endX = Math.max(dragStartXRef.current, chartX) + setSelection({ + startX, + endX, + startIndex: resolveIndexFromX(startX), + endIndex: resolveIndexFromX(endX), + active: true + }) + return + } + + lastHoveredXRef.current = chartX + const tooltip = resolveTooltipFromX(chartX) + if (tooltip) { + scheduleTooltip(tooltip) + } + }, + [getChartX, resolveTooltipFromX, resolveIndexFromX, scheduleTooltip] + ) + + const handleMouseLeave = useCallback(() => { + lastHoveredXRef.current = null + clearTooltip() + if (isDraggingRef.current) { + isDraggingRef.current = false + } + setSelection(null) + }, [clearTooltip]) + + const handleMouseDown = useCallback( + (event: React.MouseEvent) => { + const chartX = getChartX(event) + if (chartX === null) { + return + } + isDraggingRef.current = true + dragStartXRef.current = chartX + clearTooltip() + setSelection(null) + }, + [getChartX, clearTooltip] + ) + + const handleMouseUp = useCallback(() => { + if (isDraggingRef.current) { + isDraggingRef.current = false + } + setSelection(null) + }, []) + + const handleTouchStart = useCallback( + (event: React.TouchEvent) => { + if (event.touches.length === 1) { + event.preventDefault() + const chartX = getChartX(event, 0) + if (chartX === null) { + return + } + lastHoveredXRef.current = chartX + const tooltip = resolveTooltipFromX(chartX) + if (tooltip) { + scheduleTooltip(tooltip) + } + } else if (event.touches.length === 2) { + event.preventDefault() + resetTooltipDedupe() + clearTooltip() + const x0 = getChartX(event, 0) + const x1 = getChartX(event, 1) + if (x0 === null || x1 === null) { + return + } + const startX = Math.min(x0, x1) + const endX = Math.max(x0, x1) + setSelection({ + startX, + endX, + startIndex: resolveIndexFromX(startX), + endIndex: resolveIndexFromX(endX), + active: true + }) + } + }, + [ + getChartX, + resolveTooltipFromX, + resolveIndexFromX, + scheduleTooltip, + resetTooltipDedupe, + clearTooltip + ] + ) + + const handleTouchMove = useCallback( + (event: React.TouchEvent) => { + if (event.touches.length === 1) { + event.preventDefault() + const chartX = getChartX(event, 0) + if (chartX === null) { + return + } + lastHoveredXRef.current = chartX + const tooltip = resolveTooltipFromX(chartX) + if (tooltip) { + scheduleTooltip(tooltip) + } + } else if (event.touches.length === 2) { + event.preventDefault() + const x0 = getChartX(event, 0) + const x1 = getChartX(event, 1) + if (x0 === null || x1 === null) { + return + } + const startX = Math.min(x0, x1) + const endX = Math.max(x0, x1) + setSelection({ + startX, + endX, + startIndex: resolveIndexFromX(startX), + endIndex: resolveIndexFromX(endX), + active: true + }) + } + }, + [getChartX, resolveTooltipFromX, resolveIndexFromX, scheduleTooltip] + ) + + const handleTouchEnd = useCallback(() => { + clearTooltip() + setSelection(null) + }, [clearTooltip]) + + const clearSelection = useCallback(() => { + setSelection(null) + }, []) + + // Re-anchor tooltip/crosshair when x-scale or visible data changes (e.g. brush zoom commit). + useEffect(() => { + if (!canInteract || lastHoveredXRef.current === null) { + return + } + const tooltip = resolveTooltipFromX(lastHoveredXRef.current) + if (tooltip) { + scheduleTooltip(tooltip, `${tooltip.index}:${Math.round(tooltip.x)}`) + return + } + clearTooltip() + }, [canInteract, clearTooltip, resolveTooltipFromX, scheduleTooltip]) + + const interactionHandlers = canInteract + ? { + onMouseMove: handleMouseMove, + onMouseLeave: handleMouseLeave, + onMouseDown: handleMouseDown, + onMouseUp: handleMouseUp, + onTouchStart: handleTouchStart, + onTouchMove: handleTouchMove, + onTouchEnd: handleTouchEnd + } + : {} + + const interactionStyle: React.CSSProperties = { + cursor: canInteract ? "crosshair" : "default", + touchAction: "none" + } + + return { + tooltipData, + setTooltipData, + selection, + clearSelection, + interactionHandlers, + interactionStyle + } +} diff --git a/src/components/charts/use-chart-phase-orchestrator.ts b/src/components/charts/use-chart-phase-orchestrator.ts new file mode 100644 index 00000000..5a9bf878 --- /dev/null +++ b/src/components/charts/use-chart-phase-orchestrator.ts @@ -0,0 +1,187 @@ +"use client" + +import { useCallback, useEffect, useRef, useState } from "react" + +import { + resolveRestingChartPhase, + type ChartPhase, + type ChartStatus +} from "./chart-phase" + +export interface UseChartPhaseOrchestratorOptions { + chartStatus: ChartStatus + targetData: Record[] + skeletonData: Record[] + animationDuration: number + yDomainTweenDuration: number + /** Signature of motion URL state — replays clip reveal in Studio. */ + revealSignature?: string + /** Skip mount/signature enter reveal (static docs previews). */ + skipEnterReveal?: boolean +} + +export function useChartPhaseOrchestrator({ + chartStatus, + targetData, + skeletonData, + animationDuration, + yDomainTweenDuration, + revealSignature = "", + skipEnterReveal = false +}: UseChartPhaseOrchestratorOptions) { + const [chartPhase, setChartPhase] = useState(() => + resolveRestingChartPhase(chartStatus) + ) + const [plotData, setPlotData] = useState[]>(() => + chartStatus === "loading" ? skeletonData : targetData + ) + const [revealEpoch, setRevealEpoch] = useState(0) + const [concealEpoch, setConcealEpoch] = useState(0) + const [isLoaded, setIsLoaded] = useState(() => chartStatus === "ready") + const prevStatusRef = useRef(chartStatus) + const phaseRef = useRef(chartPhase) + + useEffect(() => { + phaseRef.current = chartPhase + }, [chartPhase]) + + // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: status transition branches for animation durations + useEffect(() => { + const prevStatus = prevStatusRef.current + if (prevStatus === chartStatus) { + return + } + prevStatusRef.current = chartStatus + + if (chartStatus === "ready" && prevStatus === "loading") { + setIsLoaded(false) + if (animationDuration <= 0) { + if (yDomainTweenDuration <= 0) { + setPlotData(targetData) + setChartPhase("revealing") + } else { + setChartPhase("gridTweenReady") + } + } else { + setChartPhase("exiting") + } + return + } + + if (chartStatus === "loading" && prevStatus === "ready") { + setIsLoaded(false) + if (animationDuration <= 0) { + if (yDomainTweenDuration <= 0) { + setPlotData(skeletonData) + setChartPhase("loading") + } else { + setChartPhase("gridTweenLoading") + } + } else { + setConcealEpoch(epoch => epoch + 1) + setChartPhase("exitingReady") + } + } + }, [ + animationDuration, + chartStatus, + skeletonData, + targetData, + yDomainTweenDuration + ]) + + // biome-ignore lint/correctness/useExhaustiveDependencies: revealSignature replays enter + useEffect(() => { + if (skipEnterReveal) { + return + } + if (chartStatus !== "ready") { + return + } + if (phaseRef.current !== "ready") { + return + } + + setChartPhase("revealing") + setIsLoaded(false) + }, [animationDuration, chartStatus, revealSignature, skipEnterReveal]) + + useEffect(() => { + switch (chartPhase) { + case "loading": + if (chartStatus === "loading") { + setPlotData(skeletonData) + } + break + case "exiting": + setPlotData(skeletonData) + break + case "exitingReady": + case "gridTweenLoading": + case "gridTweenReady": + case "revealing": + case "ready": + setPlotData(targetData) + break + default: + break + } + }, [chartPhase, chartStatus, skeletonData, targetData]) + + /** Loading pulse exit finished — tween grid to ready spacing next. */ + const notifyLoadingPulseComplete = useCallback(() => { + if (phaseRef.current !== "exiting") { + return + } + setChartPhase("gridTweenReady") + }, []) + + /** Ready series conceal finished — tween grid to loading spacing next. */ + const notifyRevealConcealComplete = useCallback(() => { + if (phaseRef.current !== "exitingReady") { + return + } + setChartPhase("gridTweenLoading") + }, []) + + /** Grid tween finished — enter the next resting phase. */ + const notifyYDomainTweenComplete = useCallback(() => { + if (phaseRef.current === "gridTweenLoading") { + setChartPhase("loading") + return + } + if (phaseRef.current === "gridTweenReady") { + setChartPhase("revealing") + } + }, []) + + useEffect(() => { + if (chartPhase !== "revealing") { + return + } + + setRevealEpoch(epoch => epoch + 1) + if (animationDuration <= 0) { + setChartPhase("ready") + setIsLoaded(true) + return + } + + const timer = window.setTimeout(() => { + setChartPhase("ready") + setIsLoaded(true) + }, animationDuration) + return () => window.clearTimeout(timer) + }, [animationDuration, chartPhase]) + + return { + chartPhase, + plotData, + revealEpoch, + concealEpoch, + isLoaded, + notifyLoadingPulseComplete, + notifyRevealConcealComplete, + notifyYDomainTweenComplete + } +} diff --git a/src/components/charts/use-enter-complete.ts b/src/components/charts/use-enter-complete.ts new file mode 100644 index 00000000..43eed397 --- /dev/null +++ b/src/components/charts/use-enter-complete.ts @@ -0,0 +1,28 @@ +"use client" + +import { useEffect, useState } from "react" +import type { MotionValue } from "motion/react" + +/** + * Returns true once a mount-progress MotionValue reaches 1. + * Use to swap animated MotionValue-driven props for static values after + * enter completes — drops per-frame subscriptions during pan/hover. + */ +export function useEnterComplete(mountProgress: MotionValue): boolean { + const [complete, setComplete] = useState(() => mountProgress.get() >= 1) + + useEffect(() => { + if (mountProgress.get() >= 1) { + setComplete(true) + return + } + + return mountProgress.on("change", value => { + if (value >= 1) { + setComplete(true) + } + }) + }, [mountProgress]) + + return complete +} diff --git a/src/components/charts/use-grid-shimmer.ts b/src/components/charts/use-grid-shimmer.ts new file mode 100644 index 00000000..d950ff41 --- /dev/null +++ b/src/components/charts/use-grid-shimmer.ts @@ -0,0 +1,111 @@ +"use client" + +import { useEffect } from "react" +import { + animate, + useMotionValue, + useReducedMotion, + useTransform +} from "motion/react" + +import { + LINE_LOADING_LOOP_PAUSE_MS, + LINE_LOADING_PULSE_CYCLE_S, + LINE_LOADING_PULSE_EASE +} from "./line-loading-timing" + +export interface UseGridShimmerOptions { + innerWidth: number + shimmer: boolean + shimmerLength: number + shimmerSpeed: number + shimmerSync: boolean + /** When false, shimmer animation is paused (e.g. during exit transition). */ + active: boolean + /** Run a single synced sweep (loading → ready handoff). */ + oneShot?: boolean +} + +export function useGridShimmer({ + innerWidth, + shimmer, + shimmerLength, + shimmerSpeed, + shimmerSync, + active, + oneShot = false +}: UseGridShimmerOptions) { + const progress = useMotionValue(0) + const reducedMotion = useReducedMotion() + const shimmerCycleS = LINE_LOADING_PULSE_CYCLE_S / Math.max(shimmerSpeed, 0.1) + const shimmerEnabled = + active && shimmer && reducedMotion !== true && innerWidth > 0 + + useEffect(() => { + if (!shimmerEnabled) { + return + } + + let cancelled = false + let timeoutId: number | undefined + let controls: ReturnType | undefined + + const runSyncedCycle = () => { + if (cancelled) { + return + } + + progress.set(0) + controls = animate(progress, 1, { + duration: shimmerCycleS, + ease: [...LINE_LOADING_PULSE_EASE], + onComplete: () => { + if (cancelled) { + return + } + timeoutId = window.setTimeout( + runSyncedCycle, + LINE_LOADING_LOOP_PAUSE_MS + ) + } + }) + } + + if (shimmerSync && oneShot) { + progress.set(0) + controls = animate(progress, 1, { + duration: shimmerCycleS / 2, + ease: [...LINE_LOADING_PULSE_EASE] + }) + return () => controls?.stop() + } + + if (shimmerSync) { + runSyncedCycle() + return () => { + cancelled = true + controls?.stop() + if (timeoutId !== undefined) { + window.clearTimeout(timeoutId) + } + } + } + + progress.set(0) + controls = animate(progress, 1, { + duration: shimmerCycleS, + repeat: Number.POSITIVE_INFINITY, + ease: [...LINE_LOADING_PULSE_EASE] + }) + + return () => controls?.stop() + }, [oneShot, progress, shimmerCycleS, shimmerEnabled, shimmerSync]) + + const shimmerX = useTransform( + progress, + value => -shimmerLength + value * (innerWidth + shimmerLength * 2) + ) + const shimmerTransform = useTransform(shimmerX, x => `translate(${x}, 0)`) + + return { shimmerEnabled, shimmerTransform } +} diff --git a/src/components/charts/use-mount-progress.ts b/src/components/charts/use-mount-progress.ts new file mode 100644 index 00000000..3c29cd17 --- /dev/null +++ b/src/components/charts/use-mount-progress.ts @@ -0,0 +1,33 @@ +"use client" + +import { useEffect, useRef } from "react" +import { animate, useMotionValue, type Transition } from "motion/react" + +import { DEFAULT_CHART_ENTER_TRANSITION } from "./animation" + +/** Drives 0→1 enter progress using the studio motion transition (spring or tween). */ +export function useMountProgress( + enterTransition: Transition | undefined, + delaySeconds: number, + replayKey: number | string +) { + const progress = useMotionValue(0) + const transitionRef = useRef(enterTransition) + + useEffect(() => { + transitionRef.current = enterTransition + }, [enterTransition]) + + // replayKey intentionally retriggers enter when motion settings change + // biome-ignore lint/correctness/useExhaustiveDependencies: replayKey + useEffect(() => { + progress.set(0) + const controls = animate(progress, 1, { + ...(transitionRef.current ?? DEFAULT_CHART_ENTER_TRANSITION), + delay: delaySeconds + }) + return () => controls.stop() + }, [delaySeconds, replayKey, progress]) + + return progress +} diff --git a/src/components/charts/use-scheduled-tooltip.ts b/src/components/charts/use-scheduled-tooltip.ts new file mode 100644 index 00000000..77213627 --- /dev/null +++ b/src/components/charts/use-scheduled-tooltip.ts @@ -0,0 +1,97 @@ +"use client" + +import { useCallback, useEffect, useRef, useState } from "react" + +export interface ScheduledTooltipControls { + tooltipData: T | null + setTooltipData: React.Dispatch> + scheduleTooltip: (tooltip: T, dedupeKey?: string) => void + clearTooltip: () => void + resetTooltipDedupe: () => void +} + +function defaultDedupeKey(tooltip: T): string { + if ( + typeof tooltip === "object" && + tooltip !== null && + "index" in tooltip && + typeof (tooltip as { index: unknown }).index === "number" + ) { + const { index, x } = tooltip as { index: number; x?: number } + if (typeof x === "number") { + return `${index}:${Math.round(x)}` + } + return String(index) + } + return JSON.stringify(tooltip) +} + +export function useScheduledTooltip(): ScheduledTooltipControls { + const [tooltipData, setTooltipData] = useState(null) + const lastKeyRef = useRef(null) + const pendingRef = useRef(null) + const rafRef = useRef(null) + const pendingKeyRef = useRef(null) + + useEffect(() => { + return () => { + if (rafRef.current !== null) { + cancelAnimationFrame(rafRef.current) + } + } + }, []) + + const commitTooltip = useCallback((tooltip: T, dedupeKey: string) => { + if (dedupeKey === lastKeyRef.current) { + return + } + lastKeyRef.current = dedupeKey + setTooltipData(tooltip) + }, []) + + const scheduleTooltip = useCallback( + (tooltip: T, dedupeKey?: string) => { + const key = dedupeKey ?? defaultDedupeKey(tooltip) + pendingRef.current = tooltip + pendingKeyRef.current = key + if (key === lastKeyRef.current) { + return + } + if (rafRef.current !== null) { + return + } + rafRef.current = requestAnimationFrame(() => { + rafRef.current = null + const next = pendingRef.current + const nextKey = pendingKeyRef.current + if (next && nextKey) { + commitTooltip(next, nextKey) + } + }) + }, + [commitTooltip] + ) + + const clearTooltip = useCallback(() => { + if (rafRef.current !== null) { + cancelAnimationFrame(rafRef.current) + rafRef.current = null + } + pendingRef.current = null + pendingKeyRef.current = null + lastKeyRef.current = null + setTooltipData(null) + }, []) + + const resetTooltipDedupe = useCallback(() => { + lastKeyRef.current = null + }, []) + + return { + tooltipData, + setTooltipData, + scheduleTooltip, + clearTooltip, + resetTooltipDedupe + } +} diff --git a/src/components/charts/y-axis-scales.ts b/src/components/charts/y-axis-scales.ts new file mode 100644 index 00000000..dad8d124 --- /dev/null +++ b/src/components/charts/y-axis-scales.ts @@ -0,0 +1,116 @@ +import { scaleLinear } from "@visx/scale" + +import type { LineConfig } from "./chart-context" + +/** Default axis id when `yAxisId` is omitted (Recharts-style `0` / primary left axis). */ +export const DEFAULT_Y_AXIS_ID = "left" + +export type YAxisOrientation = "left" | "right" + +export function normalizeYAxisId(id?: string | number): string { + if (id == null || id === "") { + return DEFAULT_Y_AXIS_ID + } + return String(id) +} + +export function groupLinesByYAxisId( + lines: LineConfig[] +): Map { + const groups = new Map() + for (const line of lines) { + const axisId = normalizeYAxisId(line.yAxisId) + const bucket = groups.get(axisId) ?? [] + bucket.push(line) + groups.set(axisId, bucket) + } + return groups +} + +type YScale = ReturnType> + +export function getPrimaryYScale( + yScales: Record, + fallback: YScale +): YScale { + const primary = yScales[DEFAULT_Y_AXIS_ID] + if (primary) { + return primary + } + const first = Object.values(yScales)[0] + return first ?? fallback +} + +export function buildYScalesForLines({ + lines, + innerHeight, + resolveDomain +}: { + lines: LineConfig[] + /** Passed by callers; domain is resolved via `resolveDomain`. */ + data?: Record[] + innerHeight: number + resolveDomain: (dataKeys: string[]) => [number, number] +}): Record { + const groups = groupLinesByYAxisId(lines) + const scales: Record = {} + + for (const [axisId, axisLines] of groups) { + const dataKeys = axisLines.map(line => line.dataKey) + const domain = resolveDomain(dataKeys) + scales[axisId] = scaleLinear({ + range: [innerHeight, 0], + domain, + nice: true + }) + } + + if (!scales[DEFAULT_Y_AXIS_ID]) { + scales[DEFAULT_Y_AXIS_ID] = scaleLinear({ + range: [innerHeight, 0], + domain: [0, 100], + nice: true + }) + } + + return scales +} + +/** Build y-scales from pre-computed (already nice'd) domain endpoints. */ +export function buildYScalesFromDomains({ + lines, + innerHeight, + domainsByAxis +}: { + lines: LineConfig[] + innerHeight: number + domainsByAxis: Record +}): Record { + const groups = groupLinesByYAxisId(lines) + const scales: Record = {} + + for (const [axisId] of groups) { + const domain = + domainsByAxis[axisId] ?? + domainsByAxis[DEFAULT_Y_AXIS_ID] ?? + ([0, 100] as [number, number]) + scales[axisId] = scaleLinear({ + range: [innerHeight, 0], + domain + }) + } + + if (!scales[DEFAULT_Y_AXIS_ID]) { + scales[DEFAULT_Y_AXIS_ID] = scaleLinear({ + range: [innerHeight, 0], + domain: domainsByAxis[DEFAULT_Y_AXIS_ID] ?? [0, 100] + }) + } + + return scales +} + +/** Single-axis charts (bar, scatter, candlestick, live line). */ +export function wrapSingleYScale(yScale: YScale): Record { + return { [DEFAULT_Y_AXIS_ID]: yScale } +} diff --git a/src/components/charts/y-axis-ticks.ts b/src/components/charts/y-axis-ticks.ts new file mode 100644 index 00000000..9a5ade7b --- /dev/null +++ b/src/components/charts/y-axis-ticks.ts @@ -0,0 +1,25 @@ +export const Y_AXIS_DEFAULT_TICK_COUNT = 5 + +/** Minimum valid `numTicks` for `scale.ticks()` — values ≤ 0 yield no ticks. */ +export const Y_AXIS_MIN_TICK_COUNT = 1 + +/** + * Upper bound for the tick count hint. D3 may return more "nice" ticks above ~10; + * keeping the hint in a modest range avoids overcrowded axes. + */ +export const Y_AXIS_MAX_TICK_COUNT = 10 + +/** Clamps a user `numTicks` value to a valid d3 tick-count hint. */ +export function resolveYAxisTickCount(numTicks?: number): number { + if (numTicks == null || !Number.isFinite(numTicks)) { + return Y_AXIS_DEFAULT_TICK_COUNT + } + const rounded = Math.round(numTicks) + if (rounded < Y_AXIS_MIN_TICK_COUNT) { + return Y_AXIS_MIN_TICK_COUNT + } + if (rounded > Y_AXIS_MAX_TICK_COUNT) { + return Y_AXIS_MAX_TICK_COUNT + } + return rounded +} diff --git a/src/components/charts/y-domain-utils.ts b/src/components/charts/y-domain-utils.ts new file mode 100644 index 00000000..9acf6337 --- /dev/null +++ b/src/components/charts/y-domain-utils.ts @@ -0,0 +1,128 @@ +import { scaleLinear } from "@visx/scale" + +import type { LineConfig } from "./chart-context" +import { Y_DOMAIN_TWEEN_SKIP_THRESHOLD, type ChartPhase } from "./chart-phase" +import { groupLinesByYAxisId, normalizeYAxisId } from "./y-axis-scales" + +export type YDomain = [number, number] + +/** Apply visx `nice()` to raw domain endpoints for stable grid ticks. */ +export function niceYDomain(domain: YDomain): YDomain { + const scale = scaleLinear({ domain, range: [0, 1], nice: true }) + const niceDomain = scale.domain() + return [niceDomain[0] ?? domain[0], niceDomain[1] ?? domain[1]] +} + +/** + * Skip Y tween when both endpoints move less than the threshold relative to span. + * When in doubt callers should tween — beauty wins over micro-optimization. + */ +export function shouldTweenYDomain(from: YDomain, to: YDomain): boolean { + const span = Math.max(Math.abs(to[1] - to[0]), Math.abs(from[1] - from[0]), 1) + const deltaMin = Math.abs(to[0] - from[0]) / span + const deltaMax = Math.abs(to[1] - from[1]) / span + return ( + deltaMin >= Y_DOMAIN_TWEEN_SKIP_THRESHOLD || + deltaMax >= Y_DOMAIN_TWEEN_SKIP_THRESHOLD + ) +} + +/** Phases where the chart shows loading chrome (shimmer, pulse, label). */ +export function isLoadingChromePhase(phase: ChartPhase): boolean { + return phase === "loading" || phase === "revealingLoading" +} + +/** Phases where grid lines use loading stroke styling (muted / dashed chrome). */ +export function isLoadingGridChromePhase(phase: ChartPhase): boolean { + return ( + phase === "loading" || phase === "exiting" || phase === "gridTweenLoading" + ) +} + +/** Phases where Y-domain tween runs after the series has exited. */ +export function isYDomainTweenPhase(phase: ChartPhase): boolean { + return phase === "gridTweenLoading" || phase === "gridTweenReady" +} + +/** Phases where {@link ReferenceArea} bands are shown (fade in/out on transitions). */ +export function isReferenceAreaVisiblePhase(phase: ChartPhase): boolean { + return ( + phase === "ready" || phase === "revealing" || phase === "gridTweenReady" + ) +} + +export function resolveAnimatedYDestinationDomains( + chartPhase: ChartPhase, + skeletonByAxis: Record, + targetByAxis: Record +): Record { + switch (chartPhase) { + case "loading": + case "exiting": + case "gridTweenLoading": + return skeletonByAxis + case "exitingReady": + case "gridTweenReady": + case "revealing": + case "ready": + return targetByAxis + default: + return targetByAxis + } +} + +export function computeYDomainsByAxis({ + lines, + resolveDomain +}: { + lines: LineConfig[] + resolveDomain: (dataKeys: string[]) => YDomain +}): Record { + const groups = groupLinesByYAxisId(lines) + const domains: Record = {} + + for (const [axisId, axisLines] of groups) { + const dataKeys = axisLines.map(line => line.dataKey) + domains[normalizeYAxisId(axisId)] = niceYDomain(resolveDomain(dataKeys)) + } + + if (!domains.left) { + domains.left = niceYDomain([0, 100]) + } + + return domains +} + +/** Merge domain maps, normalizing axis ids to strings. */ +export function mergeYDomainRecords( + ...records: Record[] +): Record { + const merged: Record = {} + for (const record of records) { + for (const [axisId, domain] of Object.entries(record)) { + merged[normalizeYAxisId(axisId)] = domain + } + } + return merged +} + +export function domainsEqual( + left: Record, + right: Record +): boolean { + const leftKeys = Object.keys(left) + const rightKeys = Object.keys(right) + if (leftKeys.length !== rightKeys.length) { + return false + } + + for (const axisId of leftKeys) { + const from = left[axisId] + const to = right[axisId] + if (!(from && to) || from[0] !== to[0] || from[1] !== to[1]) { + return false + } + } + + return true +} diff --git a/src/components/sales/sales-dashboard-period-filter.tsx b/src/components/sales/sales-dashboard-period-filter.tsx new file mode 100644 index 00000000..4db3641c --- /dev/null +++ b/src/components/sales/sales-dashboard-period-filter.tsx @@ -0,0 +1,82 @@ +"use client" + +import { parseAsStringEnum, useQueryState } from "nuqs" + +import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group" +import { + defaultSalesDashboardPeriod, + isSalesDashboardPeriod, + salesDashboardPeriodOptions, + salesDashboardPeriodValues +} from "@/lib/sales-dashboard-period" +import { cn } from "@/lib/utils" + +const salesDashboardPeriodQueryState = parseAsStringEnum([ + ...salesDashboardPeriodValues +]) + .withDefault(defaultSalesDashboardPeriod) + .withOptions({ + shallow: false, + scroll: false + }) + +export function SalesDashboardPeriodFilter({ + className, + inline = false, + label = "Periodo acumulado" +}: { + className?: string + inline?: boolean + label?: string +}) { + const [period, setPeriod] = useQueryState( + "period", + salesDashboardPeriodQueryState + ) + + return ( +
+

+ {label} +

+ { + if (isSalesDashboardPeriod(value)) { + void setPeriod(value) + } + }} + className="bg-muted grid w-full grid-cols-2 gap-1 rounded-lg p-[3px] + sm:inline-flex sm:w-auto sm:flex-nowrap" + variant="default" + size="sm" + > + {salesDashboardPeriodOptions.map(option => ( + + {option.label} + + ))} + +
+ ) +} diff --git a/src/components/sales/sales-dashboard.tsx b/src/components/sales/sales-dashboard.tsx index 4a8dd900..4b67d3cc 100644 --- a/src/components/sales/sales-dashboard.tsx +++ b/src/components/sales/sales-dashboard.tsx @@ -1,13 +1,8 @@ +import { Fragment } from "react" import { Banknote, ShoppingCart, TrendingUp, WalletCards } from "lucide-react" +import { SalesRevenueChart } from "@/components/sales/sales-revenue-chart" import { Badge } from "@/components/ui/badge" -import { - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle -} from "@/components/ui/card" import { Empty, EmptyDescription, @@ -15,6 +10,15 @@ import { EmptyMedia, EmptyTitle } from "@/components/ui/empty" +import { + Item, + ItemActions, + ItemContent, + ItemGroup, + ItemMedia, + ItemSeparator, + ItemTitle +} from "@/components/ui/item" import { Separator } from "@/components/ui/separator" import { Table, @@ -25,11 +29,13 @@ import { TableRow } from "@/components/ui/table" import { formatPrice } from "@/lib/currency" +import { salesDashboardPeriodRangeLabels } from "@/lib/sales-dashboard-period" import { salesOrderTypeBadgeVariants, salesOrderTypeLabels, type SalesDashboardData } from "@/lib/types/sales" +import { cn } from "@/lib/utils" function formatDateTime(value: string) { return new Intl.DateTimeFormat("es-MX", { @@ -43,187 +49,214 @@ function getKpiItems(data: SalesDashboardData) { { title: "Ingresos de hoy", value: formatPrice(data.todayRevenue, data.currency), - description: "Ingresos de hoy", icon: Banknote }, { title: "Órdenes de hoy", value: data.todayOrders.toString(), - description: "Órdenes completadas hoy", icon: ShoppingCart }, { - title: "Ingresos del mes", - value: formatPrice(data.monthRevenue, data.currency), - description: "Ingresos del mes", + title: "Ingresos acumulados", + value: formatPrice(data.periodRevenue, data.currency), icon: TrendingUp }, { title: "Ticket promedio", - value: formatPrice(data.averageTicket, data.currency), - description: "Promedio por venta hoy", + value: formatPrice(data.periodAverageTicket, data.currency), icon: WalletCards } ] } export function SalesDashboard({ data }: { data: SalesDashboardData }) { + const kpiItems = getKpiItems(data) + return (
-
- {getKpiItems(data).map(item => ( - - + + {kpiItems.map((item, index) => ( + -
- {item.title} - {item.value} -
-
- -
-
- -

- {item.description} -

-
-
- ))} + + + {item.title} + +

+ {item.value} +

+
+ + + + + ))} + +
+ +
+
+

+ Ventas a través del tiempo +

+ + {salesDashboardPeriodRangeLabels[data.period]} + +
+ +
+ +
+ +
- - +
-
- Ventas recientes - Últimas 25 ventas completadas -
+

+ Ventas recientes +

{data.recentSales.length} - - - {data.recentSales.length === 0 ? ( -
- - - - - - No hay ventas aún - - Cuando completes la primera venta, aparecerá aquí. - - - -
- ) : ( - - - - Fecha - Tipo de orden - Artículos - Total + + + {data.recentSales.length === 0 ? ( + + + + + + No hay ventas aún + + Cuando completes la primera venta, aparecerá aquí. + + + + ) : ( +
+ + + Fecha + Tipo de orden + Artículos + Total + + + + {data.recentSales.map(sale => ( + + + {formatDateTime(sale.createdAt)} + + + + {salesOrderTypeLabels[sale.orderType]} + + + + {sale.items} + + + {formatPrice(sale.total, data.currency)} + - - - {data.recentSales.map(sale => ( - - - {formatDateTime(sale.createdAt)} - - - - {salesOrderTypeLabels[sale.orderType]} - - - {sale.items} - - {formatPrice(sale.total, data.currency)} - - - ))} - -
- )} -
- + ))} + + + )} +
+ + - - - Más vendidos - - Top 10 productos por cantidad vendida - - - - {data.bestSellers.length === 0 ? ( -
- - - - - - Sin ventas para analizar - - Tu ranking de productos aparecerá después de capturar - algunas ventas. - - - -
- ) : ( -
- {data.bestSellers.map((item, index) => ( -
-
+
+

+ Más vendidos +

+ {data.bestSellers.length} +
+ + {data.bestSellers.length === 0 ? ( + + + + + + Sin ventas para analizar + + Tu ranking de productos aparecerá cuando haya ventas en el + periodo seleccionado. + + + + ) : ( + + {data.bestSellers.map((item, index) => ( + + + + + #{index + 1} + + + + + {item.productName} + + + -
- - #{index + 1} - -
-

- {item.productName} -

-

- {item.quantity} vendidos -

-
-
-
-

- {formatPrice(item.revenue, data.currency)} -

-

- {item.quantity} qty -

-
-
- {index < data.bestSellers.length - 1 && } -
- ))} -
- )} -
-
+

+ {formatPrice(item.revenue, data.currency)} +

+

+ {item.quantity} unidades +

+ + + {index < data.bestSellers.length - 1 && } + + ))} + + )} +
) diff --git a/src/components/sales/sales-revenue-chart.tsx b/src/components/sales/sales-revenue-chart.tsx new file mode 100644 index 00000000..4220fbc4 --- /dev/null +++ b/src/components/sales/sales-revenue-chart.tsx @@ -0,0 +1,112 @@ +"use client" + +import { BarChart3 } from "lucide-react" + +import { Bar } from "@/components/charts/bar" +import { BarChart } from "@/components/charts/bar-chart" +import { BarXAxis } from "@/components/charts/bar-x-axis" +import { chartCssVars, useYScale } from "@/components/charts/chart-context" +import { Grid } from "@/components/charts/grid" +import { ChartTooltip } from "@/components/charts/tooltip/chart-tooltip" +import { + Empty, + EmptyDescription, + EmptyHeader, + EmptyMedia, + EmptyTitle +} from "@/components/ui/empty" +import { formatPrice, type Currency } from "@/lib/currency" +import type { SalesDashboardPeriod } from "@/lib/sales-dashboard-period" +import type { SalesChartBucket } from "@/lib/types/sales" + +function SalesRevenueYAxis({ currency }: { currency: Currency }) { + const yScale = useYScale() + + const ticks = yScale.ticks?.(4) ?? [] + + if (ticks.length === 0) { + return null + } + + return ( + + ) +} + +export function SalesRevenueChart({ + chart, + currency, + period +}: { + chart: SalesChartBucket[] + currency: Currency + period: SalesDashboardPeriod +}) { + const hasSales = chart.some(bucket => bucket.revenue > 0) + + if (!hasSales) { + return ( + + + + + + Sin ventas en este periodo + + Cambia el filtro o registra nuevas ventas para ver la tendencia. + + + + ) + } + + return ( + + + + + + { + const label = String(point.label ?? "") + const revenue = typeof point.revenue === "number" ? point.revenue : 0 + const orders = typeof point.orders === "number" ? point.orders : 0 + + return ( +
+
{label}
+
+ {formatPrice(revenue, currency)} + {orders} ventas +
+
+ ) + }} + /> +
+ ) +} diff --git a/src/components/ui/card.tsx b/src/components/ui/card.tsx index 14c10e4a..432975c7 100644 --- a/src/components/ui/card.tsx +++ b/src/components/ui/card.tsx @@ -10,7 +10,7 @@ const Card = React.forwardRef< ref={ref} className={cn( `bg-card text-card-foreground inset-ring-foreground/10 rounded-lg - shadow-sm inset-ring`, + shadow-sm/5 inset-ring`, className )} {...props} diff --git a/src/lib/sales-dashboard-period.ts b/src/lib/sales-dashboard-period.ts new file mode 100644 index 00000000..6ab3f030 --- /dev/null +++ b/src/lib/sales-dashboard-period.ts @@ -0,0 +1,40 @@ +export const salesDashboardPeriodValues = ["7d", "1m", "3m", "1y"] as const + +export type SalesDashboardPeriod = (typeof salesDashboardPeriodValues)[number] + +export const defaultSalesDashboardPeriod = + "1m" as const satisfies SalesDashboardPeriod + +export const salesDashboardPeriodLabels = { + "7d": "7 días", + "1m": "1 mes", + "3m": "3 meses", + "1y": "1 año" +} as const satisfies Record + +export const salesDashboardPeriodRangeLabels = { + "7d": "Últimos 7 días", + "1m": "Último mes", + "3m": "Últimos 3 meses", + "1y": "Último año" +} as const satisfies Record + +export const salesDashboardPeriodSentenceLabels = { + "7d": "los últimos 7 días", + "1m": "el último mes", + "3m": "los últimos 3 meses", + "1y": "el último año" +} as const satisfies Record + +export const salesDashboardPeriodOptions = salesDashboardPeriodValues.map( + value => ({ + value, + label: salesDashboardPeriodLabels[value] + }) +) + +export function isSalesDashboardPeriod( + value: string +): value is SalesDashboardPeriod { + return value in salesDashboardPeriodLabels +} diff --git a/src/lib/types/sales.ts b/src/lib/types/sales.ts index b5974c58..d8ef5d21 100644 --- a/src/lib/types/sales.ts +++ b/src/lib/types/sales.ts @@ -1,6 +1,7 @@ import { z } from "zod/v4" import type { Currency } from "@/lib/currency" +import type { SalesDashboardPeriod } from "@/lib/sales-dashboard-period" export const salesOrderTypeValues = ["DINE_IN", "TAKEOUT", "DELIVERY"] as const @@ -92,12 +93,21 @@ export type SalesRevenueByOrderType = { orders: number } +export type SalesChartBucket = { + label: string + revenue: number + orders: number +} + export type SalesDashboardData = { currency: Currency + period: SalesDashboardPeriod todayRevenue: number todayOrders: number - monthRevenue: number - averageTicket: number + periodRevenue: number + periodOrders: number + periodAverageTicket: number + chart: SalesChartBucket[] bestSellers: SalesBestSeller[] recentSales: SalesRecentSale[] } diff --git a/src/server/actions/sales/queries.ts b/src/server/actions/sales/queries.ts index d82b4acc..c342bdec 100644 --- a/src/server/actions/sales/queries.ts +++ b/src/server/actions/sales/queries.ts @@ -4,6 +4,7 @@ import { cacheLife, cacheTag } from "next/cache" import { formatPriceRange, type Currency } from "@/lib/currency" import prisma from "@/lib/prisma" +import type { SalesDashboardPeriod } from "@/lib/sales-dashboard-period" import { salesOrderTypeValues, type SalesBestSeller, @@ -41,10 +42,125 @@ function startOfMonth(date = new Date()) { return next } -function startOfNextMonth(date = new Date()) { - const next = startOfMonth(date) - next.setMonth(next.getMonth() + 1) - return next +function startOfRollingPeriod( + period: SalesDashboardPeriod, + date = new Date() +): Date { + const next = startOfDay(date) + + switch (period) { + case "7d": + next.setDate(next.getDate() - 6) + return next + case "1m": + next.setMonth(next.getMonth() - 1) + return next + case "3m": + next.setMonth(next.getMonth() - 3) + return next + case "1y": + next.setFullYear(next.getFullYear() - 1) + return next + } +} + +function getSalesChartBucketType(period: SalesDashboardPeriod) { + return period === "1y" ? "month" : "day" +} + +const salesChartDayLabelFormatter = new Intl.DateTimeFormat("es-MX", { + day: "numeric", + month: "short" +}) + +const salesChartMonthLabelFormatter = new Intl.DateTimeFormat("es-MX", { + month: "short", + year: "2-digit" +}) + +function getDayBucketKey(date: Date) { + return [ + date.getFullYear(), + String(date.getMonth() + 1).padStart(2, "0"), + String(date.getDate()).padStart(2, "0") + ].join("-") +} + +function getMonthBucketKey(date: Date) { + return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}` +} + +function buildSalesChartBuckets({ + rows, + startDate, + endDate, + period +}: { + rows: Array<{ createdAt: Date; total: number }> + startDate: Date + endDate: Date + period: SalesDashboardPeriod +}) { + const bucketType = getSalesChartBucketType(period) + const buckets = new Map< + string, + { + label: string + revenue: number + orders: number + } + >() + + if (bucketType === "month") { + const cursor = startOfMonth(startDate) + + while (cursor < endDate) { + const key = getMonthBucketKey(cursor) + buckets.set(key, { + label: salesChartMonthLabelFormatter.format(cursor), + revenue: 0, + orders: 0 + }) + cursor.setMonth(cursor.getMonth() + 1) + } + + for (const row of rows) { + const key = getMonthBucketKey(row.createdAt) + const bucket = buckets.get(key) + + if (!bucket) continue + + bucket.revenue += row.total + bucket.orders += 1 + } + } else { + const cursor = startOfDay(startDate) + + while (cursor < endDate) { + const key = getDayBucketKey(cursor) + buckets.set(key, { + label: salesChartDayLabelFormatter.format(cursor), + revenue: 0, + orders: 0 + }) + cursor.setDate(cursor.getDate() + 1) + } + + for (const row of rows) { + const key = getDayBucketKey(row.createdAt) + const bucket = buckets.get(key) + + if (!bucket) continue + + bucket.revenue += row.total + bucket.orders += 1 + } + } + + return [...buckets.values()].map(bucket => ({ + ...bucket, + revenue: roundMoney(bucket.revenue) + })) } function mapCatalogProduct({ @@ -256,6 +372,29 @@ async function getRecentSales( })) } +async function getSalesChartRows( + organizationId: string, + startDate: Date, + endDate: Date +) { + return prisma.sale.findMany({ + where: { + organizationId, + createdAt: { + gte: startDate, + lt: endDate + } + }, + orderBy: { + createdAt: "asc" + }, + select: { + createdAt: true, + total: true + } + }) +} + async function getRevenueByOrderType( organizationId: string, startDate: Date, @@ -417,7 +556,8 @@ export async function getSalesCatalog( } export async function getSalesDashboardData( - organizationId: string + organizationId: string, + period: SalesDashboardPeriod ): Promise { "use cache: private" cacheLife({ stale: 30 }) @@ -425,10 +565,13 @@ export async function getSalesDashboardData( if (!organizationId) { return { currency: "MXN", + period, todayRevenue: 0, todayOrders: 0, - monthRevenue: 0, - averageTicket: 0, + periodRevenue: 0, + periodOrders: 0, + periodAverageTicket: 0, + chart: [], bestSellers: [], recentSales: [] } @@ -439,27 +582,37 @@ export async function getSalesDashboardData( const now = new Date() const todayStart = startOfDay(now) const tomorrowStart = startOfNextDay(now) - const monthStart = startOfMonth(now) - const nextMonthStart = startOfNextMonth(now) + const periodStart = startOfRollingPeriod(period, now) - const [currency, todayTotals, monthTotals, bestSellers, recentSales] = + const [currency, todayTotals, periodSales, bestSellers, recentSales] = await Promise.all([ getOrganizationCurrency(organizationId), getSalesTotals(organizationId, todayStart, tomorrowStart), - getSalesTotals(organizationId, monthStart, nextMonthStart), - getBestSellers(organizationId, monthStart, nextMonthStart), + getSalesChartRows(organizationId, periodStart, tomorrowStart), + getBestSellers(organizationId, periodStart, tomorrowStart), getRecentSales(organizationId) ]) + const periodRevenue = roundMoney( + periodSales.reduce((sum, sale) => sum + sale.total, 0) + ) + const periodOrders = periodSales.length + return { currency, + period, todayRevenue: todayTotals.revenue, todayOrders: todayTotals.orders, - monthRevenue: monthTotals.revenue, - averageTicket: - todayTotals.orders > 0 - ? roundMoney(todayTotals.revenue / todayTotals.orders) - : 0, + periodRevenue, + periodOrders, + periodAverageTicket: + periodOrders > 0 ? roundMoney(periodRevenue / periodOrders) : 0, + chart: buildSalesChartBuckets({ + rows: periodSales, + startDate: periodStart, + endDate: tomorrowStart, + period + }), bestSellers, recentSales } diff --git a/styles/globals.css b/styles/globals.css index 44f8f0ea..b17fc7a8 100644 --- a/styles/globals.css +++ b/styles/globals.css @@ -403,6 +403,22 @@ --success-foreground: var(--color-emerald-700); --warning: var(--color-amber-500); --warning-foreground: var(--color-amber-700); + --chart-background: oklch(1 0 0); + --chart-foreground: oklch(0.145 0.004 285); + --chart-foreground-muted: oklch(0.55 0.014 260); + --chart-line-primary: var(--chart-1); + --chart-line-secondary: var(--chart-2); + --chart-crosshair: oklch(0.4 0.1828 274.34); + --chart-grid: oklch(0.9 0 0); + --chart-brush-border: var(--chart-grid); + --chart-tooltip-background: oklch(0.21 0.006 285 / 0.8); + --chart-tooltip-foreground: oklch(0.985 0 0); + --chart-tooltip-muted: oklch(0.65 0.01 260); + --chart-marker-background: oklch(0.97 0.005 260); + --chart-marker-border: oklch(0.85 0.01 260); + --chart-marker-foreground: oklch(0.3 0.01 260); + --chart-ring-background: oklch(0.9 0.005 260 / 0.25); + --chart-label: oklch(0.45 0.01 260); } .dark { @@ -449,6 +465,17 @@ --success-foreground: var(--color-emerald-400); --warning: var(--color-amber-500); --warning-foreground: var(--color-amber-400); + --chart-background: oklch(0.145 0 0); + --chart-foreground: oklch(0.45 0 0); + --chart-foreground-muted: oklch(0.65 0.01 260); + --chart-crosshair: oklch(0.45 0 0); + --chart-grid: oklch(0.25 0 0); + --chart-brush-border: var(--chart-grid); + --chart-marker-background: oklch(0.25 0.01 260); + --chart-marker-border: oklch(0.4 0.01 260); + --chart-marker-foreground: oklch(0.9 0 0); + --chart-ring-background: oklch(0.35 0.01 260 / 0.25); + --chart-label: oklch(0.75 0.01 260); } /* @@ -523,6 +550,22 @@ --color-success: var(--success); --color-info-foreground: var(--info-foreground); --color-info: var(--info); + --color-chart-label: var(--chart-label); + --color-chart-ring-background: var(--chart-ring-background); + --color-chart-marker-foreground: var(--chart-marker-foreground); + --color-chart-marker-border: var(--chart-marker-border); + --color-chart-marker-background: var(--chart-marker-background); + --color-chart-tooltip-muted: var(--chart-tooltip-muted); + --color-chart-tooltip-foreground: var(--chart-tooltip-foreground); + --color-chart-tooltip-background: var(--chart-tooltip-background); + --color-chart-brush-border: var(--chart-brush-border); + --color-chart-grid: var(--chart-grid); + --color-chart-crosshair: var(--chart-crosshair); + --color-chart-line-secondary: var(--chart-line-secondary); + --color-chart-line-primary: var(--chart-line-primary); + --color-chart-foreground-muted: var(--chart-foreground-muted); + --color-chart-foreground: var(--chart-foreground); + --color-chart-background: var(--chart-background); @keyframes ripple { 0%, 100% { From c889f7d97609873c66f1691baa8f29675edd1358 Mon Sep 17 00:00:00 2001 From: Daniel Castillejo Date: Mon, 22 Jun 2026 14:52:38 -0500 Subject: [PATCH 07/18] refactor: skeleton and better mobile layout --- src/app/dashboard/sales/loading.tsx | 151 ++++++++++++++-- src/app/dashboard/sales/page.tsx | 2 +- src/components/dashboard/app-sidebar.tsx | 2 +- .../sales/sales-dashboard-period-filter.tsx | 2 +- src/components/sales/sales-dashboard.tsx | 171 +++++++++++------- src/components/sales/sales-revenue-chart.tsx | 4 +- 6 files changed, 248 insertions(+), 84 deletions(-) diff --git a/src/app/dashboard/sales/loading.tsx b/src/app/dashboard/sales/loading.tsx index 4001b5c7..783d74c4 100644 --- a/src/app/dashboard/sales/loading.tsx +++ b/src/app/dashboard/sales/loading.tsx @@ -3,26 +3,141 @@ import { Skeleton } from "@/components/ui/skeleton" export default function Loading() { return (
-
-
- - +
+
+ +
+ + +
- -
- -
- {Array.from({ length: 4 }).map((_, index) => ( - - ))} -
- -
- - -
+ +
+
+ +
+ {Array.from({ length: 4 }).map((_, index) => ( + + ))} +
+
+
+ + +
+
+ {Array.from({ length: 4 }).map((_, index) => ( +
+
+ + +
+ +
+ ))} +
+
+ +
+
+
+ + +
+ +
+ +
+ +
+ {Array.from({ length: 6 }).map((_, index) => ( + + ))} +
+
+
+ + + +
+
+
+ + +
+ +
+
+
+ {Array.from({ length: 4 }).map((_, index) => ( + + ))} +
+ {Array.from({ length: 5 }).map((_, rowIndex) => ( +
+ + + + +
+ ))} +
+
+
+ + + +
+
+ + +
+ +
+ {Array.from({ length: 5 }).map((_, index) => ( +
+ +
+ + +
+
+ + +
+
+ ))} +
+
+
) } diff --git a/src/app/dashboard/sales/page.tsx b/src/app/dashboard/sales/page.tsx index 59ac5c54..4d32ce1f 100644 --- a/src/app/dashboard/sales/page.tsx +++ b/src/app/dashboard/sales/page.tsx @@ -46,7 +46,7 @@ export default async function SalesPage(props: { Ventas - Captura rápida y métricas de ventas + Resumen de ventas y actividad reciente {item.title} -

- {item.value} -

+ @@ -113,7 +159,7 @@ export function SalesDashboard({ data }: { data: SalesDashboardData }) { sm:justify-between sm:gap-4" >

- Ventas a través del tiempo + Ventas por periodo

{salesDashboardPeriodRangeLabels[data.period]} @@ -132,10 +178,10 @@ export function SalesDashboard({ data }: { data: SalesDashboardData }) {
-
+
- No hay ventas aún + Aún no hay ventas registradas - Cuando completes la primera venta, aparecerá aquí. + Registra la primera venta para verla aquí. ) : ( - - - - Fecha - Tipo de orden - Artículos - Total - - - - {data.recentSales.map(sale => ( - - - {formatDateTime(sale.createdAt)} - - - - {salesOrderTypeLabels[sale.orderType]} - - - - {sale.items} - - - {formatPrice(sale.total, data.currency)} - +
+
+ + + Fecha + Canal de venta + Unidades + Total - ))} - -
+ + + {data.recentSales.map(sale => ( + + + {formatDateTime(sale.createdAt)} + + + + {salesOrderTypeLabels[sale.orderType]} + + + + {sale.items} + + + {formatPrice(sale.total, data.currency)} + + + ))} + + +
)}
-
+

- Más vendidos + Productos más vendidos

{data.bestSellers.length}
@@ -218,10 +266,9 @@ export function SalesDashboard({ data }: { data: SalesDashboardData }) { - Sin ventas para analizar + Aún no hay productos vendidos - Tu ranking de productos aparecerá cuando haya ventas en el - periodo seleccionado. + Cuando haya ventas, aquí verás el ranking de productos. diff --git a/src/components/sales/sales-revenue-chart.tsx b/src/components/sales/sales-revenue-chart.tsx index 4220fbc4..8f997cc6 100644 --- a/src/components/sales/sales-revenue-chart.tsx +++ b/src/components/sales/sales-revenue-chart.tsx @@ -33,7 +33,8 @@ function SalesRevenueYAxis({ currency }: { currency: Currency }) { {ticks.map(tick => ( From 9c2502047b062fe715f003b08b83ff0a160e5f70 Mon Sep 17 00:00:00 2001 From: Daniel Castillejo Date: Tue, 23 Jun 2026 00:00:20 -0500 Subject: [PATCH 08/18] feat: enhance sales closing functionality with date filtering and improved data handling --- src/app/dashboard/sales/closing/page.tsx | 50 ++-- .../sales/sales-closing-date-filter.tsx | 100 +++++++ src/components/sales/sales-closing-export.tsx | 26 +- src/components/sales/sales-closing.tsx | 222 ++++++++------ src/components/ui/calendar.tsx | 283 +++++++++++++++--- src/lib/sales-closing-date.ts | 86 ++++++ src/lib/types/sales.ts | 1 + src/server/actions/sales/queries.ts | 19 +- 8 files changed, 618 insertions(+), 169 deletions(-) create mode 100644 src/components/sales/sales-closing-date-filter.tsx create mode 100644 src/lib/sales-closing-date.ts diff --git a/src/app/dashboard/sales/closing/page.tsx b/src/app/dashboard/sales/closing/page.tsx index a0f7a1b6..3aff9712 100644 --- a/src/app/dashboard/sales/closing/page.tsx +++ b/src/app/dashboard/sales/closing/page.tsx @@ -1,47 +1,63 @@ -import { ArrowLeft, ReceiptText } from "lucide-react" +import { ReceiptText } from "lucide-react" import type { Metadata } from "next" -import Link from "next/link" import { notFound } from "next/navigation" +import { createLoader, parseAsString } from "nuqs/server" import PageSubtitle from "@/components/dashboard/page-subtitle" import { SalesClosingReport } from "@/components/sales/sales-closing" +import { SalesClosingDateFilter } from "@/components/sales/sales-closing-date-filter" import { SalesClosingExportButton } from "@/components/sales/sales-closing-export" -import { Button } from "@/components/ui/button" import { getSalesClosingData } from "@/server/actions/sales/queries" import { getCurrentOrganization } from "@/server/actions/user/queries" +import { + getSalesClosingDateValue, + resolveSalesClosingDateValue +} from "@/lib/sales-closing-date" export const metadata: Metadata = { title: "Cierre diario" } -export default async function SalesClosingPage() { - const currentOrg = await getCurrentOrganization() +const loadSalesClosingSearchParams = createLoader({ + date: parseAsString.withDefault(getSalesClosingDateValue()) +}) + +export default async function SalesClosingPage(props: { + searchParams: Promise<{ [key: string]: string | string[] | undefined }> +}) { + const [{ date }, currentOrg] = await Promise.all([ + loadSalesClosingSearchParams(props.searchParams), + getCurrentOrganization() + ]) if (!currentOrg) { notFound() } - const data = await getSalesClosingData(currentOrg.id) + const selectedDateValue = resolveSalesClosingDateValue(date) + const data = await getSalesClosingData(currentOrg.id, selectedDateValue) return (
- + Cierre diario Reporte de fin de jornada para restaurante - -
- - + +
+ +
diff --git a/src/components/sales/sales-closing-date-filter.tsx b/src/components/sales/sales-closing-date-filter.tsx new file mode 100644 index 00000000..54c8420e --- /dev/null +++ b/src/components/sales/sales-closing-date-filter.tsx @@ -0,0 +1,100 @@ +"use client" + +import * as React from "react" +import { Calendar as CalendarIcon } from "lucide-react" +import { parseAsString, useQueryState } from "nuqs" + +import { Button } from "@/components/ui/button" +import { Calendar } from "@/components/ui/calendar" +import { + Popover, + PopoverContent, + PopoverTrigger +} from "@/components/ui/popover" +import { + formatSalesClosingDateLabel, + formatSalesClosingDateValue, + parseSalesClosingDateValue, + resolveSalesClosingDateValue +} from "@/lib/sales-closing-date" +import { cn } from "@/lib/utils" + +export function SalesClosingDateFilter({ + className, + inline = false, + label = "Fecha", + selectedDateValue +}: { + className?: string + inline?: boolean + label?: string + selectedDateValue: string +}) { + const queryState = React.useMemo( + () => + parseAsString.withDefault(selectedDateValue).withOptions({ + shallow: false, + scroll: false + }), + [selectedDateValue] + ) + const [dateValue, setDateValue] = useQueryState("date", queryState) + + const resolvedDateValue = React.useMemo( + () => resolveSalesClosingDateValue(dateValue, selectedDateValue), + [dateValue, selectedDateValue] + ) + + const selectedDate = React.useMemo( + () => parseSalesClosingDateValue(resolvedDateValue), + [resolvedDateValue] + ) + + const [open, setOpen] = React.useState(false) + + React.useEffect(() => { + if (dateValue !== resolvedDateValue) { + void setDateValue(resolvedDateValue) + } + }, [dateValue, resolvedDateValue, setDateValue]) + + return ( + + + + + + date > new Date()} + onSelect={date => { + if (!date) return + + const nextValue = formatSalesClosingDateValue(date) + void setDateValue(nextValue) + setOpen(false) + }} + /> + + + ) +} diff --git a/src/components/sales/sales-closing-export.tsx b/src/components/sales/sales-closing-export.tsx index c35c4236..daedca0d 100644 --- a/src/components/sales/sales-closing-export.tsx +++ b/src/components/sales/sales-closing-export.tsx @@ -5,6 +5,7 @@ import Papa from "papaparse" import { Button } from "@/components/ui/button" import { salesOrderTypeLabels, type SalesClosingData } from "@/lib/types/sales" +import { cn } from "@/lib/utils" type ClosingCsvRow = { section: string @@ -20,7 +21,7 @@ function buildRows(data: SalesClosingData): ClosingCsvRow[] { const rows: ClosingCsvRow[] = [ { section: "summary", - label: "Ingresos de hoy", + label: "Ingresos del día", orderType: "", quantity: "", orders: "", @@ -29,7 +30,7 @@ function buildRows(data: SalesClosingData): ClosingCsvRow[] { }, { section: "summary", - label: "Órdenes de hoy", + label: "Órdenes del día", orderType: "", quantity: "", orders: data.todayOrders, @@ -59,22 +60,35 @@ function buildRows(data: SalesClosingData): ClosingCsvRow[] { return rows } -function downloadCsv(rows: ClosingCsvRow[]) { +function downloadCsv(rows: ClosingCsvRow[], fileDateValue: string) { const csv = Papa.unparse(rows) const blob = new Blob([csv], { type: "text/csv;charset=utf-8;" }) const url = URL.createObjectURL(blob) const link = document.createElement("a") link.href = url - link.download = `cierre-ventas-${new Date().toISOString().slice(0, 10)}.csv` + link.download = `cierre-ventas-${fileDateValue}.csv` document.body.appendChild(link) link.click() document.body.removeChild(link) URL.revokeObjectURL(url) } -export function SalesClosingExportButton({ data }: { data: SalesClosingData }) { +export function SalesClosingExportButton({ + className, + data +}: { + className?: string + data: SalesClosingData +}) { + const fileDateValue = data.selectedDateValue + return ( - diff --git a/src/components/sales/sales-closing.tsx b/src/components/sales/sales-closing.tsx index ad2fabc8..9d0dc216 100644 --- a/src/components/sales/sales-closing.tsx +++ b/src/components/sales/sales-closing.tsx @@ -1,13 +1,7 @@ +import { Fragment } from "react" import { Banknote, ShoppingCart } from "lucide-react" import { Badge } from "@/components/ui/badge" -import { - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle -} from "@/components/ui/card" import { Empty, EmptyDescription, @@ -15,6 +9,15 @@ import { EmptyMedia, EmptyTitle } from "@/components/ui/empty" +import { + Item, + ItemActions, + ItemContent, + ItemGroup, + ItemMedia, + ItemSeparator, + ItemTitle +} from "@/components/ui/item" import { Separator } from "@/components/ui/separator" import { Table, @@ -25,123 +28,146 @@ import { TableRow } from "@/components/ui/table" import { formatPrice } from "@/lib/currency" +import { formatSalesClosingDateLongLabel } from "@/lib/sales-closing-date" import { salesOrderTypeBadgeVariants, salesOrderTypeLabels, type SalesClosingData } from "@/lib/types/sales" +import { cn } from "@/lib/utils" function getSummaryItems(data: SalesClosingData) { return [ { - title: "Ingresos de hoy", + title: "Ingresos del día", value: formatPrice(data.todayRevenue, data.currency), - description: "Ingresos totales del día", icon: Banknote }, { - title: "Órdenes de hoy", + title: "Órdenes del día", value: data.todayOrders.toString(), - description: "Ventas completadas hoy", icon: ShoppingCart } ] } export function SalesClosingReport({ data }: { data: SalesClosingData }) { + const selectedDateLabel = + formatSalesClosingDateLongLabel(data.selectedDateValue) || + "la fecha seleccionada" + return (
-
- {getSummaryItems(data).map(item => ( - - + + {getSummaryItems(data).map((item, index) => ( + -
- {item.title} - {item.value} -
-
- -
-
- -

- {item.description} -

-
-
- ))} + + + {item.title} + +

+ {item.value} +

+
+ + + + + ))} +
+ +
- - - Productos más vendidos - Productos más vendidos del día - - - {data.bestSellers.length === 0 ? ( -
- - - - - - Sin ventas hoy - - No hay productos vendidos para el cierre de hoy. - - - -
- ) : ( -
- {data.bestSellers.map((item, index) => ( -
-
+
+

+ Productos más vendidos +

+ {data.bestSellers.length} +
+ + {data.bestSellers.length === 0 ? ( + + + + + + Sin ventas para este día + + No hay productos vendidos para el cierre de{" "} + {selectedDateLabel}. + + + + ) : ( + + {data.bestSellers.map((item, index) => ( + + + + + #{index + 1} + + + + + {item.productName} + + + -
- - #{index + 1} - -
-

- {item.productName} -

-

- {item.quantity} unidades -

-
-
-

+

{formatPrice(item.revenue, data.currency)}

-
- {index < data.bestSellers.length - 1 && } -
- ))} -
- )} -
-
+

+ {item.quantity} unidades +

+ + + {index < data.bestSellers.length - 1 && } + + ))} + + )} +
- - - Ingresos por tipo de orden - - Ventas agrupadas por tipo de orden - - - - + + +
+
+

+ Ingresos por tipo de orden +

+ {data.revenueByOrderType.length} +
+ +
+
Tipo de orden @@ -164,16 +190,18 @@ export function SalesClosingReport({ data }: { data: SalesClosingData }) { {salesOrderTypeLabels[item.orderType]} - {item.orders} - + + {item.orders} + + {formatPrice(item.revenue, data.currency)} ))}
-
-
+
+
) diff --git a/src/components/ui/calendar.tsx b/src/components/ui/calendar.tsx index 5955c32f..e0afc01b 100644 --- a/src/components/ui/calendar.tsx +++ b/src/components/ui/calendar.tsx @@ -1,68 +1,261 @@ "use client" import * as React from "react" -import { DayPicker } from "react-day-picker" +import { + DayPicker, + getDefaultClassNames, + type DayButton, + type Locale +} from "react-day-picker" +import { + ChevronDownIcon, + ChevronLeftIcon, + ChevronRightIcon +} from "lucide-react" -// import { ChevronLeft, ChevronRight } from "lucide-react" - -import { buttonVariants } from "@/components/ui/button" +import { Button, buttonVariants } from "@/components/ui/button" import { cn } from "@/lib/utils" -export type CalendarProps = React.ComponentProps - function Calendar({ className, classNames, showOutsideDays = true, + captionLayout = "label", + buttonVariant = "ghost", + locale, + formatters, + components, ...props -}: CalendarProps) { +}: React.ComponentProps & { + buttonVariant?: React.ComponentProps["variant"] +}) { + const defaultClassNames = getDefaultClassNames() + return ( svg]:rotate-180`, + String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`, + className + )} + captionLayout={captionLayout} + locale={locale} + formatters={{ + formatMonthDropdown: date => + date.toLocaleString(locale?.code, { month: "short" }), + ...formatters + }} classNames={{ - months: "flex flex-col sm:flex-row space-y-4 sm:space-x-4 sm:space-y-0", - month: "space-y-4", - caption: "flex justify-center pt-1 relative items-center", - caption_label: "text-sm font-medium", - nav: "space-x-1 flex items-center", - nav_button: cn( - buttonVariants({ variant: "outline" }), - "h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100" - ), - nav_button_previous: "absolute left-1", - nav_button_next: "absolute right-1", - table: "w-full border-collapse space-y-1", - head_row: "flex", - head_cell: - "text-gray-500 rounded-md w-9 font-normal text-[0.8rem] dark:text-gray-400", - row: "flex w-full mt-2", - cell: "text-center text-sm p-0 relative [&:has([aria-selected])]:bg-gray-100 first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md focus-within:relative focus-within:z-20 dark:[&:has([aria-selected])]:bg-gray-800", + root: cn("w-fit", defaultClassNames.root), + months: cn( + "relative flex flex-col gap-4 md:flex-row", + defaultClassNames.months + ), + month: cn("flex w-full flex-col gap-4", defaultClassNames.month), + nav: cn( + `absolute inset-x-0 top-0 flex w-full items-center justify-between + gap-1`, + defaultClassNames.nav + ), + button_previous: cn( + buttonVariants({ variant: buttonVariant }), + "size-(--cell-size) p-0 select-none aria-disabled:opacity-50", + defaultClassNames.button_previous + ), + button_next: cn( + buttonVariants({ variant: buttonVariant }), + "size-(--cell-size) p-0 select-none aria-disabled:opacity-50", + defaultClassNames.button_next + ), + month_caption: cn( + `flex h-(--cell-size) w-full items-center justify-center + px-(--cell-size)`, + defaultClassNames.month_caption + ), + dropdowns: cn( + `flex h-(--cell-size) w-full items-center justify-center gap-1.5 + text-sm font-medium`, + defaultClassNames.dropdowns + ), + dropdown_root: cn( + "relative rounded-(--cell-radius)", + defaultClassNames.dropdown_root + ), + dropdown: cn( + "absolute inset-0 bg-popover opacity-0", + defaultClassNames.dropdown + ), + caption_label: cn( + "font-medium select-none", + captionLayout === "label" + ? "text-sm" + : `flex items-center gap-1 rounded-(--cell-radius) text-sm + [&>svg]:size-3.5 [&>svg]:text-muted-foreground`, + defaultClassNames.caption_label + ), + month_grid: cn("w-full border-collapse", defaultClassNames.month_grid), + weekdays: cn("flex", defaultClassNames.weekdays), + weekday: cn( + `flex-1 rounded-(--cell-radius) text-[0.8rem] font-normal + text-muted-foreground select-none`, + defaultClassNames.weekday + ), + week: cn("mt-2 flex w-full", defaultClassNames.week), + week_number_header: cn( + "w-(--cell-size) select-none", + defaultClassNames.week_number_header + ), + week_number: cn( + "text-[0.8rem] text-muted-foreground select-none", + defaultClassNames.week_number + ), day: cn( - buttonVariants({ variant: "ghost" }), - "h-9 w-9 p-0 font-normal aria-selected:opacity-100" - ), - day_selected: - "bg-gray-900 text-gray-50 hover:bg-gray-900 hover:text-gray-50 focus:bg-gray-900 focus:text-gray-50 dark:bg-gray-50 dark:text-gray-900 dark:hover:bg-gray-50 dark:hover:text-gray-900 dark:focus:bg-gray-50 dark:focus:text-gray-900", - day_today: - "bg-gray-100 text-gray-900 dark:bg-gray-800 dark:text-gray-50", - day_outside: "text-gray-500 opacity-50 dark:text-gray-400", - day_disabled: "text-gray-500 opacity-50 dark:text-gray-400", - day_range_middle: - "aria-selected:bg-gray-100 aria-selected:text-gray-900 dark:aria-selected:bg-gray-800 dark:aria-selected:text-gray-50", - day_hidden: "invisible", + `group/day relative aspect-square h-full w-full + rounded-(--cell-radius) p-0 text-center select-none + [&:last-child[data-selected=true]_button]:rounded-r-(--cell-radius)`, + props.showWeekNumber + ? "[&:nth-child(2)[data-selected=true]_button]:rounded-l-(--cell-radius)" + : "[&:first-child[data-selected=true]_button]:rounded-l-(--cell-radius)", + defaultClassNames.day + ), + range_start: cn( + `relative isolate z-0 rounded-l-(--cell-radius) bg-muted + after:absolute after:inset-y-0 after:right-0 after:w-4 + after:bg-muted`, + defaultClassNames.range_start + ), + range_middle: cn("rounded-none", defaultClassNames.range_middle), + range_end: cn( + `relative isolate z-0 rounded-r-(--cell-radius) bg-muted + after:absolute after:inset-y-0 after:left-0 after:w-4 + after:bg-muted`, + defaultClassNames.range_end + ), + today: cn( + `rounded-(--cell-radius) bg-muted text-foreground + data-[selected=true]:rounded-none`, + defaultClassNames.today + ), + outside: cn( + "text-muted-foreground aria-selected:text-muted-foreground", + defaultClassNames.outside + ), + disabled: cn( + "text-muted-foreground opacity-50", + defaultClassNames.disabled + ), + hidden: cn("invisible", defaultClassNames.hidden), ...classNames }} - // components={{ - // // skipcq: JS-0356 - // IconLeft: ({ ...props }) => , - // // skipcq: JS-0356 - // IconRight: ({ ...props }) => - // }} + components={{ + Root: ({ className, rootRef, ...props }) => ( +
+ ), + Chevron: ({ className, orientation, ...props }) => { + if (orientation === "left") { + return ( + + ) + } + + if (orientation === "right") { + return ( + + ) + } + + return ( + + ) + }, + DayButton: props => , + WeekNumber: ({ children, ...props }) => ( + +
+ {children} +
+ + ), + ...components + }} + {...props} + /> + ) +} + +function CalendarDayButton({ + className, + day, + modifiers, + locale, + ...props +}: React.ComponentProps & { locale?: Partial }) { + const defaultClassNames = getDefaultClassNames() + + const ref = React.useRef(null) + + React.useEffect(() => { + if (modifiers.focused) ref.current?.focus() + }, [modifiers.focused]) + + return ( +
-

+

Has consumido {itemCount} de los {appConfig.itemLimit} productos disponibles en tu plan

-

+

Actualiza tu plan para agregar más productos a tu menú y disfrutar de más beneficios.

@@ -91,19 +89,19 @@ export function BasicPlanView({ itemCount }: { itemCount: number }) { onValueChange={value => value && setBillingInterval(value as "monthly" | "yearly") } - className="w-full justify-center gap-0.5 rounded-lg border - border-gray-200 p-1 dark:border-gray-800" + className="border-border w-full justify-center gap-0.5 rounded-lg + border p-1" > Mensual Anual @@ -122,7 +120,7 @@ export function BasicPlanView({ itemCount }: { itemCount: number }) { key={tier.id} className={ tier.id === Plan.PRO - ? "border-indigo-500 dark:border-indigo-500" + ? "border-primary dark:border-primary" : "hidden border-dashed sm:block" } > @@ -138,7 +136,9 @@ export function BasicPlanView({ itemCount }: { itemCount: number }) { : `${new Intl.NumberFormat("es-MX", { style: "currency", currency: "MXN" }).format(tier.priceYearly)} MXN/año`}
) : ( -
Gratis
+
+ Gratis +
)} diff --git a/src/app/dashboard/settings/billing/pro-plan-view.tsx b/src/app/dashboard/settings/billing/pro-plan-view.tsx index 50fcd862..38f1635e 100644 --- a/src/app/dashboard/settings/billing/pro-plan-view.tsx +++ b/src/app/dashboard/settings/billing/pro-plan-view.tsx @@ -196,7 +196,7 @@ export async function ProPlanView() { ¿Tienes algún problema con tu suscripción? Envía un correo a{" "} contacto@biztro.co diff --git a/src/components/sales/sales-pro-banner.tsx b/src/components/sales/sales-pro-banner.tsx index 7ceba9fb..22725f7c 100644 --- a/src/components/sales/sales-pro-banner.tsx +++ b/src/components/sales/sales-pro-banner.tsx @@ -15,29 +15,22 @@ export function SalesProBanner() { return ( - + - El módulo de ventas requiere el plan Pro. Actualiza para registrar - ventas y seguir su rendimiento desde el dashboard. + Actualiza a Pro para acceder a todas las funciones de ventas. - - + + Actualizar a Pro - + ) } From 9acb631c284e15dd7bc97b558f7d406e3d7a27c4 Mon Sep 17 00:00:00 2001 From: Daniel Castillejo Date: Thu, 9 Jul 2026 00:45:12 -0500 Subject: [PATCH 18/18] feat: add lifecycle fields to Subscription model and update billing logic in related components --- .../migration.sql | 9 +++++++ prisma/models/auth.prisma | 5 ++++ src/app/dashboard/settings/billing/page.tsx | 9 ++++--- .../settings/billing/pro-plan-view.tsx | 25 ++++++++++--------- src/lib/auth.ts | 4 --- 5 files changed, 33 insertions(+), 19 deletions(-) create mode 100644 prisma/migrations/20260709053324_add_stripe_subscription_lifecycle_fields/migration.sql diff --git a/prisma/migrations/20260709053324_add_stripe_subscription_lifecycle_fields/migration.sql b/prisma/migrations/20260709053324_add_stripe_subscription_lifecycle_fields/migration.sql new file mode 100644 index 00000000..e0fc8d58 --- /dev/null +++ b/prisma/migrations/20260709053324_add_stripe_subscription_lifecycle_fields/migration.sql @@ -0,0 +1,9 @@ +-- AlterTable +ALTER TABLE "Subscription" ADD COLUMN "billingInterval" TEXT; +ALTER TABLE "Subscription" ADD COLUMN "cancelAt" DATETIME; +ALTER TABLE "Subscription" ADD COLUMN "canceledAt" DATETIME; +ALTER TABLE "Subscription" ADD COLUMN "endedAt" DATETIME; +ALTER TABLE "Subscription" ADD COLUMN "stripeScheduleId" TEXT; + +-- CreateIndex +CREATE INDEX "Verification_identifier_idx" ON "Verification"("identifier"); diff --git a/prisma/models/auth.prisma b/prisma/models/auth.prisma index efbd9418..b968c67b 100644 --- a/prisma/models/auth.prisma +++ b/prisma/models/auth.prisma @@ -140,5 +140,10 @@ model Subscription { trialStart DateTime? trialEnd DateTime? cancelAtPeriodEnd Boolean? @default(false) + cancelAt DateTime? + canceledAt DateTime? + endedAt DateTime? + billingInterval String? + stripeScheduleId String? seats Int? } diff --git a/src/app/dashboard/settings/billing/page.tsx b/src/app/dashboard/settings/billing/page.tsx index f198a86f..2ad01d6f 100644 --- a/src/app/dashboard/settings/billing/page.tsx +++ b/src/app/dashboard/settings/billing/page.tsx @@ -16,6 +16,7 @@ import { import { BasicPlanView } from "@/app/dashboard/settings/billing/basic-plan-view" import { ProPlanView } from "@/app/dashboard/settings/billing/pro-plan-view" import RevalidateStatus from "@/app/dashboard/settings/billing/revalidate-status" +import { isStripeBillingConfigured } from "@/lib/auth" import { MembershipRole } from "@/lib/types/organization" export const metadata: Metadata = { @@ -30,6 +31,7 @@ export default async function BillingPage() { getItemCount(), getCurrentOrganization() ]) + const billingEnabled = subsEnabled && isStripeBillingConfigured if (!currentOrg) { return notFound() @@ -44,7 +46,7 @@ export default async function BillingPage() { Maneja tu plan de suscripción e historial de pagos - {role === MembershipRole.OWNER && subsEnabled ? ( + {role === MembershipRole.OWNER && billingEnabled ? (
{isPro ? ( }> @@ -64,8 +66,9 @@ export default async function BillingPage() { Aviso - Solo los miembros propietarios de la organización pueden acceder a - esta página + {role === MembershipRole.OWNER && subsEnabled + ? "La facturación con Stripe no está configurada en este entorno, así que no se puede iniciar el checkout." + : "Solo los miembros propietarios de la organización pueden acceder a esta página"} )} diff --git a/src/app/dashboard/settings/billing/pro-plan-view.tsx b/src/app/dashboard/settings/billing/pro-plan-view.tsx index 38f1635e..75511de4 100644 --- a/src/app/dashboard/settings/billing/pro-plan-view.tsx +++ b/src/app/dashboard/settings/billing/pro-plan-view.tsx @@ -41,14 +41,11 @@ export async function ProPlanView() { } return ( -
+
Plan -
+
PRO {(() => { switch (subscription.status) { @@ -75,7 +72,7 @@ export async function ProPlanView() { } })()} {subscription.status === "trialing" && ( - + - Termina el{" "} {subscription?.trialEnd ? new Date(subscription.trialEnd).toLocaleDateString( @@ -95,7 +92,7 @@ export async function ProPlanView() { {/* */}
-
Precio
+
Precio
{(() => { const tier = Tiers.find( @@ -114,7 +111,9 @@ export async function ProPlanView() {
-
Periodo activo
+
+ Periodo activo +
{subscription?.periodStart ? new Date(subscription.periodStart).toLocaleDateString( @@ -130,7 +129,7 @@ export async function ProPlanView() {
{subscription?.cancelAtPeriodEnd ? (
-
Cancela el
+
Cancela el
{subscription?.periodEnd ? new Date(subscription.periodEnd).toLocaleDateString( @@ -146,7 +145,9 @@ export async function ProPlanView() {
) : (
-
Próxima renovación
+
+ Próxima renovación +
{subscription?.periodEnd ? new Date(subscription.periodEnd).toLocaleDateString( @@ -186,13 +187,13 @@ export async function ProPlanView() { -

+

Maneja tu suscripción en Stripe

-
+
¿Tienes algún problema con tu suscripción? Envía un correo a{" "} | undefined) { const stripeSecretKey = process.env.STRIPE_SECRET_KEY const stripeWebhookSecret = process.env.STRIPE_WEBHOOK_SECRET -const stripeBasicPriceId = process.env.NEXT_PUBLIC_STRIPE_PRICE_BASIC const stripeProMonthlyPriceId = process.env.NEXT_PUBLIC_STRIPE_PRICE_PRO_MONTHLY const stripeProYearlyPriceId = process.env.NEXT_PUBLIC_STRIPE_PRICE_PRO_YEARLY @@ -72,13 +71,11 @@ const stripeClient = stripeSecretKey const stripeBillingConfig = stripeClient && stripeWebhookSecret && - stripeBasicPriceId && stripeProMonthlyPriceId && stripeProYearlyPriceId ? { stripeClient, stripeWebhookSecret, - stripeBasicPriceId, stripeProMonthlyPriceId, stripeProYearlyPriceId } @@ -277,7 +274,6 @@ export const auth = betterAuth({ plans: [ { name: "BASIC", - priceId: stripeBillingConfig.stripeBasicPriceId, limits: { menus: appConfig.menuLimit, products: appConfig.itemLimit