Browse Source

add xml signing

Matthew Trejo 1 month ago
parent
commit
88086fd99f

+ 89 - 5
package-lock.json

@@ -20,11 +20,13 @@
         "@radix-ui/react-tooltip": "^1.2.8",
         "class-variance-authority": "^0.7.1",
         "clsx": "^2.1.1",
+        "ec-sri-invoice-signer": "^1.5.0",
         "lucide-react": "^0.548.0",
         "next": "16.0.1",
         "next-themes": "^0.4.6",
         "react": "19.2.0",
         "react-dom": "19.2.0",
+        "react-dropzone": "^14.3.8",
         "sonner": "^2.0.7",
         "tailwind-merge": "^3.3.1",
         "zod": "^4.1.12"
@@ -3339,6 +3341,15 @@
         "node": ">= 0.4"
       }
     },
+    "node_modules/attr-accept": {
+      "version": "2.2.5",
+      "resolved": "https://registry.npmjs.org/attr-accept/-/attr-accept-2.2.5.tgz",
+      "integrity": "sha512-0bDNnY/u6pPwHDMoF0FieU354oBi0a8rD9FcsLwzcGWbc8KS8KPIi7y+s13OlVY+gMWc/9xEMUgNE6Qm8ZllYQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=4"
+      }
+    },
     "node_modules/available-typed-arrays": {
       "version": "1.0.7",
       "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz",
@@ -3905,6 +3916,16 @@
         "node": ">= 0.4"
       }
     },
+    "node_modules/ec-sri-invoice-signer": {
+      "version": "1.5.0",
+      "resolved": "https://registry.npmjs.org/ec-sri-invoice-signer/-/ec-sri-invoice-signer-1.5.0.tgz",
+      "integrity": "sha512-kKHh/CcOA7xevBb9mew3B5ZI+AfANnZavh0z8ex2XUYDTThysfaIY9AuwbWYnS/bhUuaRHHw2d3Wtx2R7v6Stw==",
+      "license": "MIT",
+      "dependencies": {
+        "fast-xml-parser": "^4.2.5",
+        "node-forge": "^1.3.1"
+      }
+    },
     "node_modules/effect": {
       "version": "3.18.4",
       "resolved": "https://registry.npmjs.org/effect/-/effect-3.18.4.tgz",
@@ -4659,6 +4680,24 @@
       "dev": true,
       "license": "MIT"
     },
+    "node_modules/fast-xml-parser": {
+      "version": "4.5.3",
+      "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-4.5.3.tgz",
+      "integrity": "sha512-RKihhV+SHsIUGXObeVy9AXiBbFwkVk7Syp8XgwN5U3JV416+Gwp/GO9i0JYKmikykgz/UHRrrV4ROuZEo/T0ig==",
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/NaturalIntelligence"
+        }
+      ],
+      "license": "MIT",
+      "dependencies": {
+        "strnum": "^1.1.1"
+      },
+      "bin": {
+        "fxparser": "src/cli/cli.js"
+      }
+    },
     "node_modules/fastq": {
       "version": "1.19.1",
       "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz",
@@ -4682,6 +4721,18 @@
         "node": ">=16.0.0"
       }
     },
+    "node_modules/file-selector": {
+      "version": "2.1.2",
+      "resolved": "https://registry.npmjs.org/file-selector/-/file-selector-2.1.2.tgz",
+      "integrity": "sha512-QgXo+mXTe8ljeqUFaX3QVHc5osSItJ/Km+xpocx0aSqWGMSCf6qYs/VnzZgS864Pjn5iceMRFigeAV7AfTlaig==",
+      "license": "MIT",
+      "dependencies": {
+        "tslib": "^2.7.0"
+      },
+      "engines": {
+        "node": ">= 12"
+      }
+    },
     "node_modules/fill-range": {
       "version": "7.1.1",
       "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
@@ -5601,7 +5652,6 @@
       "version": "4.0.0",
       "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
       "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
-      "dev": true,
       "license": "MIT"
     },
     "node_modules/js-yaml": {
@@ -6012,7 +6062,6 @@
       "version": "1.4.0",
       "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
       "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
-      "dev": true,
       "license": "MIT",
       "dependencies": {
         "js-tokens": "^3.0.0 || ^4.0.0"
@@ -6252,6 +6301,15 @@
       "devOptional": true,
       "license": "MIT"
     },
+    "node_modules/node-forge": {
+      "version": "1.3.1",
+      "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.3.1.tgz",
+      "integrity": "sha512-dPEtOeMvF9VMcYV/1Wb8CPoVAXtp6MKMlcbAt4ddqmGqUJ6fQZFXkNZNkNlfevtNkGtaSoXf/vNNNSvgrdXwtA==",
+      "license": "(BSD-3-Clause OR GPL-2.0)",
+      "engines": {
+        "node": ">= 6.13.0"
+      }
+    },
     "node_modules/node-releases": {
       "version": "2.0.27",
       "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz",
@@ -6283,7 +6341,6 @@
       "version": "4.1.1",
       "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
       "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
-      "dev": true,
       "license": "MIT",
       "engines": {
         "node": ">=0.10.0"
@@ -6641,7 +6698,6 @@
       "version": "15.8.1",
       "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
       "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
-      "dev": true,
       "license": "MIT",
       "dependencies": {
         "loose-envify": "^1.4.0",
@@ -6729,11 +6785,27 @@
         "react": "^19.2.0"
       }
     },
+    "node_modules/react-dropzone": {
+      "version": "14.3.8",
+      "resolved": "https://registry.npmjs.org/react-dropzone/-/react-dropzone-14.3.8.tgz",
+      "integrity": "sha512-sBgODnq+lcA4P296DY4wacOZz3JFpD99fp+hb//iBO2HHnyeZU3FwWyXJ6salNpqQdsZrgMrotuko/BdJMV8Ug==",
+      "license": "MIT",
+      "dependencies": {
+        "attr-accept": "^2.2.4",
+        "file-selector": "^2.1.0",
+        "prop-types": "^15.8.1"
+      },
+      "engines": {
+        "node": ">= 10.13"
+      },
+      "peerDependencies": {
+        "react": ">= 16.8 || 18.0.0"
+      }
+    },
     "node_modules/react-is": {
       "version": "16.13.1",
       "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
       "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
-      "dev": true,
       "license": "MIT"
     },
     "node_modules/react-remove-scroll": {
@@ -7390,6 +7462,18 @@
         "url": "https://github.com/sponsors/sindresorhus"
       }
     },
+    "node_modules/strnum": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmjs.org/strnum/-/strnum-1.1.2.tgz",
+      "integrity": "sha512-vrN+B7DBIoTTZjnPNewwhx6cBA/H+IS7rfW68n7XxC1y7uoiGQBxaKzqucGUgavX15dJgiGztLJ8vxuEzwqBdA==",
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/NaturalIntelligence"
+        }
+      ],
+      "license": "MIT"
+    },
     "node_modules/styled-jsx": {
       "version": "5.1.6",
       "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz",

+ 2 - 0
package.json

@@ -21,11 +21,13 @@
     "@radix-ui/react-tooltip": "^1.2.8",
     "class-variance-authority": "^0.7.1",
     "clsx": "^2.1.1",
+    "ec-sri-invoice-signer": "^1.5.0",
     "lucide-react": "^0.548.0",
     "next": "16.0.1",
     "next-themes": "^0.4.6",
     "react": "19.2.0",
     "react-dom": "19.2.0",
+    "react-dropzone": "^14.3.8",
     "sonner": "^2.0.7",
     "tailwind-merge": "^3.3.1",
     "zod": "^4.1.12"

+ 43 - 0
src/app/api/sign-invoice/route.ts

@@ -0,0 +1,43 @@
+import { NextRequest, NextResponse } from 'next/server'
+import { signInvoiceXml } from 'ec-sri-invoice-signer'
+
+export async function POST(request: NextRequest) {
+  try {
+    const formData = await request.formData()
+
+    const xmlFile = formData.get('xml') as File
+    const p12File = formData.get('p12') as File
+    const password = formData.get('password') as string
+
+    if (!xmlFile || !p12File || !password) {
+      return NextResponse.json(
+        { error: 'Faltan archivos o contraseña requeridos' },
+        { status: 400 }
+      )
+    }
+
+    // Leer archivos
+    const xmlContent = await xmlFile.text()
+    const p12Buffer = Buffer.from(await p12File.arrayBuffer())
+
+    // Firmar XML
+    const signedXml = signInvoiceXml(xmlContent, p12Buffer, {
+      pkcs12Password: password
+    })
+
+    return NextResponse.json({
+      success: true,
+      signedXml: signedXml
+    })
+
+  } catch (error) {
+    console.error('Error firmando XML:', error)
+    return NextResponse.json(
+      {
+        error: 'Error al firmar el XML',
+        details: error instanceof Error ? error.message : 'Error desconocido'
+      },
+      { status: 500 }
+    )
+  }
+}

+ 216 - 0
src/app/firmar/page.tsx

@@ -0,0 +1,216 @@
+"use client"
+
+import { useState } from "react"
+import { toast } from "sonner"
+import { FileText, FileKey, Loader2, Download, Check } from "lucide-react"
+import { Button } from "@/components/ui/button"
+import { Input } from "@/components/ui/input"
+import { Label } from "@/components/ui/label"
+import { Dropzone } from "@/components/ui/shadcn-io/dropzone"
+
+export default function FirmarPage() {
+  const [xmlFile, setXmlFile] = useState<File | null>(null)
+  const [p12File, setP12File] = useState<File | null>(null)
+  const [password, setPassword] = useState("")
+  const [isLoading, setIsLoading] = useState(false)
+  const [signedXml, setSignedXml] = useState<string | null>(null)
+
+  const handleSign = async () => {
+    if (!xmlFile || !p12File || !password) {
+      toast.error("Por favor completa todos los campos")
+      return
+    }
+
+    setIsLoading(true)
+    setSignedXml(null)
+
+    try {
+      const formData = new FormData()
+      formData.append("xml", xmlFile)
+      formData.append("p12", p12File)
+      formData.append("password", password)
+
+      const response = await fetch("/api/sign-invoice", {
+        method: "POST",
+        body: formData,
+      })
+
+      const data = await response.json()
+
+      if (!response.ok) {
+        throw new Error(data.details || data.error || "Error al firmar")
+      }
+
+      setSignedXml(data.signedXml)
+      toast.success("XML firmado exitosamente")
+    } catch (error) {
+      console.error(error)
+      toast.error(error instanceof Error ? error.message : "Error al firmar el XML")
+    } finally {
+      setIsLoading(false)
+    }
+  }
+
+  const handleDownload = () => {
+    if (!signedXml) return
+
+    const blob = new Blob([signedXml], { type: "application/xml" })
+    const url = URL.createObjectURL(blob)
+    const a = document.createElement("a")
+    a.href = url
+    a.download = `factura_firmada_${new Date().getTime()}.xml`
+    document.body.appendChild(a)
+    a.click()
+    document.body.removeChild(a)
+    URL.revokeObjectURL(url)
+    toast.success("XML firmado descargado")
+  }
+
+  const handleReset = () => {
+    setXmlFile(null)
+    setP12File(null)
+    setPassword("")
+    setSignedXml(null)
+  }
+
+  return (
+    <div className="container mx-auto max-w-4xl py-8 space-y-8">
+      {/* Upload Section */}
+      <div className="grid gap-6">
+        {/* XML File */}
+        <div className="space-y-2">
+          <Label>Archivo XML de la Factura</Label>
+          <Dropzone
+            accept={{
+              "text/xml": [".xml"],
+              "application/xml": [".xml"],
+            }}
+            maxFiles={1}
+            onDrop={(acceptedFiles) => {
+              if (acceptedFiles.length > 0) {
+                setXmlFile(acceptedFiles[0])
+                toast.success(`XML cargado: ${acceptedFiles[0].name}`)
+              }
+            }}
+            className="border-2 border-dashed rounded-lg p-8"
+          >
+            <div className="flex flex-col items-center gap-2 text-center">
+              <div className="rounded-full bg-primary/10 p-3">
+                <FileText className="h-6 w-6 text-primary" />
+              </div>
+              <div className="space-y-1">
+                <p className="text-sm font-medium">
+                  {xmlFile ? (
+                    <span className="flex items-center gap-2">
+                      <Check className="h-4 w-4 text-green-500" />
+                      {xmlFile.name}
+                    </span>
+                  ) : (
+                    "Arrastra tu archivo XML aquí"
+                  )}
+                </p>
+                <p className="text-xs text-muted-foreground">
+                  o haz clic para seleccionar
+                </p>
+              </div>
+            </div>
+          </Dropzone>
+        </div>
+
+        {/* P12 Certificate */}
+        <div className="space-y-2">
+          <Label>Certificado Digital (.p12)</Label>
+          <Dropzone
+            accept={{
+              "application/x-pkcs12": [".p12", ".pfx"],
+            }}
+            maxFiles={1}
+            onDrop={(acceptedFiles) => {
+              if (acceptedFiles.length > 0) {
+                setP12File(acceptedFiles[0])
+                toast.success(`Certificado cargado: ${acceptedFiles[0].name}`)
+              }
+            }}
+            className="border-2 border-dashed rounded-lg p-8"
+          >
+            <div className="flex flex-col items-center gap-2 text-center">
+              <div className="rounded-full bg-primary/10 p-3">
+                <FileKey className="h-6 w-6 text-primary" />
+              </div>
+              <div className="space-y-1">
+                <p className="text-sm font-medium">
+                  {p12File ? (
+                    <span className="flex items-center gap-2">
+                      <Check className="h-4 w-4 text-green-500" />
+                      {p12File.name}
+                    </span>
+                  ) : (
+                    "Arrastra tu certificado .p12 aquí"
+                  )}
+                </p>
+                <p className="text-xs text-muted-foreground">
+                  o haz clic para seleccionar
+                </p>
+              </div>
+            </div>
+          </Dropzone>
+        </div>
+
+        {/* Password */}
+        <div className="space-y-2">
+          <Label htmlFor="password">Contraseña del Certificado</Label>
+          <Input
+            id="password"
+            type="password"
+            placeholder="Ingresa la contraseña de tu certificado"
+            value={password}
+            onChange={(e) => setPassword(e.target.value)}
+            disabled={isLoading}
+          />
+        </div>
+      </div>
+
+      {/* Actions */}
+      <div className="flex gap-4">
+        <Button
+          onClick={handleSign}
+          disabled={!xmlFile || !p12File || !password || isLoading}
+          className="flex-1"
+        >
+          {isLoading ? (
+            <>
+              <Loader2 className="mr-2 h-4 w-4 animate-spin" />
+              Firmando...
+            </>
+          ) : (
+            "Firmar XML"
+          )}
+        </Button>
+
+        {signedXml && (
+          <>
+            <Button onClick={handleDownload} variant="outline">
+              <Download className="mr-2 h-4 w-4" />
+              Descargar XML Firmado
+            </Button>
+            <Button onClick={handleReset} variant="ghost">
+              Limpiar
+            </Button>
+          </>
+        )}
+      </div>
+
+      {/* Result Preview */}
+      {signedXml && (
+        <div className="space-y-2">
+          <Label>XML Firmado (Vista Previa)</Label>
+          <div className="rounded-lg border bg-muted p-4 max-h-96 overflow-auto">
+            <pre className="text-xs font-mono whitespace-pre-wrap break-all">
+              {signedXml}
+            </pre>
+          </div>
+        </div>
+      )}
+    </div>
+  )
+}

+ 2 - 5
src/app/page.tsx

@@ -35,11 +35,8 @@ export default function Home() {
   return (
     <div className="space-y-8">
       {/* Header */}
-      <div className="text-center">
-        <h1 className="text-4xl font-bold tracking-tight">sumire</h1>
-        <p className="mt-2 text-muted-foreground">
-          facturación electronica sin dependencias
-        </p>
+      <div className="flex flex-row min-h-screen justify-center items-center">
+        <h1 className="text-5xl font-bold tracking-tight">sumire</h1>
       </div>
     </div>
   );

+ 9 - 4
src/components/app-sidebar.tsx

@@ -1,6 +1,6 @@
 "use client"
 
-import { ChevronUp, Home, FileText, Settings } from "lucide-react"
+import { ChevronUp, Home, FileText, Settings, FileSignature } from "lucide-react"
 
 import {
   DropdownMenu,
@@ -32,10 +32,15 @@ const items = [
     icon: Home,
   },
   {
-    title: "Factura (Generar XML)",
+    title: "Crear Factura (XML)",
     url: "/factura",
     icon: FileText,
   },
+  {
+    title: "Firmar Factura (XML)",
+    url: "/firmar",
+    icon: FileSignature,
+  },
   {
     title: "Configuración",
     url: "/configuracion",
@@ -94,7 +99,7 @@ export function AppSidebar() {
           <SidebarMenuItem>
             <DropdownMenu>
               <DropdownMenuTrigger asChild>
-                <SidebarMenuButton
+                {/* <SidebarMenuButton
                   size="sm"
                   className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
                 >
@@ -103,7 +108,7 @@ export function AppSidebar() {
                     <span className="truncate text-xs">usuario@ejemplo.com</span>
                   </div>
                   <ChevronUp className="ml-auto transition-transform group-data-[state=open]/dropdown-menu:rotate-180" />
-                </SidebarMenuButton>
+                </SidebarMenuButton> */}
               </DropdownMenuTrigger>
               <DropdownMenuContent
                 side="top"

+ 5 - 5
src/components/configuracion/ConfiguracionTributariaManager.tsx

@@ -266,23 +266,23 @@ export function ConfiguracionTributariaManager() {
               ) : (
                 <div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
                   <div>
-                    <Label className="text-gray-600">Ambiente</Label>
+                    <Label className="mt-2 text-muted-foreground">Ambiente</Label>
                     <p>{config.ambiente === '1' ? 'Pruebas' : 'Producción'}</p>
                   </div>
                   <div>
-                    <Label className="text-gray-600">Establecimiento</Label>
+                    <Label className="mt-2 text-muted-foreground">Establecimiento</Label>
                     <p>{config.estab}</p>
                   </div>
                   <div>
-                    <Label className="text-gray-600">Punto Emisión</Label>
+                    <Label className="mt-2 text-muted-foreground">Punto Emisión</Label>
                     <p>{config.ptoEmi}</p>
                   </div>
                   <div>
-                    <Label className="text-gray-600">Secuencial</Label>
+                    <Label className="mt-2 text-muted-foreground">Secuencial</Label>
                     <p>{config.secuencial}</p>
                   </div>
                   <div className="col-span-2 md:col-span-4">
-                    <Label className="text-gray-600">Dirección Matriz</Label>
+                    <Label className="mt-2 text-muted-foreground">Dirección Matriz</Label>
                     <p>{config.dirMatriz}</p>
                   </div>
                 </div>

+ 202 - 0
src/components/ui/shadcn-io/dropzone/index.tsx

@@ -0,0 +1,202 @@
+'use client';
+
+import { UploadIcon } from 'lucide-react';
+import type { ReactNode } from 'react';
+import { createContext, useContext } from 'react';
+import type { DropEvent, DropzoneOptions, FileRejection } from 'react-dropzone';
+import { useDropzone } from 'react-dropzone';
+import { Button } from '@/components/ui/button';
+import { cn } from '@/lib/utils';
+
+type DropzoneContextType = {
+  src?: File[];
+  accept?: DropzoneOptions['accept'];
+  maxSize?: DropzoneOptions['maxSize'];
+  minSize?: DropzoneOptions['minSize'];
+  maxFiles?: DropzoneOptions['maxFiles'];
+};
+
+const renderBytes = (bytes: number) => {
+  const units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB'];
+  let size = bytes;
+  let unitIndex = 0;
+
+  while (size >= 1024 && unitIndex < units.length - 1) {
+    size /= 1024;
+    unitIndex++;
+  }
+
+  return `${size.toFixed(2)}${units[unitIndex]}`;
+};
+
+const DropzoneContext = createContext<DropzoneContextType | undefined>(
+  undefined
+);
+
+export type DropzoneProps = Omit<DropzoneOptions, 'onDrop'> & {
+  src?: File[];
+  className?: string;
+  onDrop?: (
+    acceptedFiles: File[],
+    fileRejections: FileRejection[],
+    event: DropEvent
+  ) => void;
+  children?: ReactNode;
+};
+
+export const Dropzone = ({
+  accept,
+  maxFiles = 1,
+  maxSize,
+  minSize,
+  onDrop,
+  onError,
+  disabled,
+  src,
+  className,
+  children,
+  ...props
+}: DropzoneProps) => {
+  const { getRootProps, getInputProps, isDragActive } = useDropzone({
+    accept,
+    maxFiles,
+    maxSize,
+    minSize,
+    onError,
+    disabled,
+    onDrop: (acceptedFiles, fileRejections, event) => {
+      if (fileRejections.length > 0) {
+        const message = fileRejections.at(0)?.errors.at(0)?.message;
+        onError?.(new Error(message));
+        return;
+      }
+
+      onDrop?.(acceptedFiles, fileRejections, event);
+    },
+    ...props,
+  });
+
+  return (
+    <DropzoneContext.Provider
+      key={JSON.stringify(src)}
+      value={{ src, accept, maxSize, minSize, maxFiles }}
+    >
+      <Button
+        className={cn(
+          'relative h-auto w-full flex-col overflow-hidden p-8',
+          isDragActive && 'outline-none ring-1 ring-ring',
+          className
+        )}
+        disabled={disabled}
+        type="button"
+        variant="outline"
+        {...getRootProps()}
+      >
+        <input {...getInputProps()} disabled={disabled} />
+        {children}
+      </Button>
+    </DropzoneContext.Provider>
+  );
+};
+
+const useDropzoneContext = () => {
+  const context = useContext(DropzoneContext);
+
+  if (!context) {
+    throw new Error('useDropzoneContext must be used within a Dropzone');
+  }
+
+  return context;
+};
+
+export type DropzoneContentProps = {
+  children?: ReactNode;
+  className?: string;
+};
+
+const maxLabelItems = 3;
+
+export const DropzoneContent = ({
+  children,
+  className,
+}: DropzoneContentProps) => {
+  const { src } = useDropzoneContext();
+
+  if (!src) {
+    return null;
+  }
+
+  if (children) {
+    return children;
+  }
+
+  return (
+    <div className={cn('flex flex-col items-center justify-center', className)}>
+      <div className="flex size-8 items-center justify-center rounded-md bg-muted text-muted-foreground">
+        <UploadIcon size={16} />
+      </div>
+      <p className="my-2 w-full truncate font-medium text-sm">
+        {src.length > maxLabelItems
+          ? `${new Intl.ListFormat('en').format(
+              src.slice(0, maxLabelItems).map((file) => file.name)
+            )} and ${src.length - maxLabelItems} more`
+          : new Intl.ListFormat('en').format(src.map((file) => file.name))}
+      </p>
+      <p className="w-full text-wrap text-muted-foreground text-xs">
+        Drag and drop or click to replace
+      </p>
+    </div>
+  );
+};
+
+export type DropzoneEmptyStateProps = {
+  children?: ReactNode;
+  className?: string;
+};
+
+export const DropzoneEmptyState = ({
+  children,
+  className,
+}: DropzoneEmptyStateProps) => {
+  const { src, accept, maxSize, minSize, maxFiles } = useDropzoneContext();
+
+  if (src) {
+    return null;
+  }
+
+  if (children) {
+    return children;
+  }
+
+  let caption = '';
+
+  if (accept) {
+    caption += 'Accepts ';
+    caption += new Intl.ListFormat('en').format(Object.keys(accept));
+  }
+
+  if (minSize && maxSize) {
+    caption += ` between ${renderBytes(minSize)} and ${renderBytes(maxSize)}`;
+  } else if (minSize) {
+    caption += ` at least ${renderBytes(minSize)}`;
+  } else if (maxSize) {
+    caption += ` less than ${renderBytes(maxSize)}`;
+  }
+
+  return (
+    <div className={cn('flex flex-col items-center justify-center', className)}>
+      <div className="flex size-8 items-center justify-center rounded-md bg-muted text-muted-foreground">
+        <UploadIcon size={16} />
+      </div>
+      <p className="my-2 w-full truncate text-wrap font-medium text-sm">
+        Upload {maxFiles === 1 ? 'a file' : 'files'}
+      </p>
+      <p className="w-full truncate text-wrap text-muted-foreground text-xs">
+        Drag and drop or click to upload
+      </p>
+      {caption && (
+        <p className="text-wrap text-muted-foreground text-xs">{caption}.</p>
+      )}
+    </div>
+  );
+};