Send Quotation Modal
Dialog for sending a ManpowerHub quotation by email — multi-email recipient/CC chip input, subject, and message body with default template.
"use client";
import * as React from "react";
import { SendQuotationModal } from "@/registry/new-york/items/manpowerhub-send-quotation-modal/components/send-quotation-modal";import { Button } from "@/components/ui/button";
export function ManpowerhubSendQuotationModalBasic() { const [open, setOpen] = React.useState(false);
return ( <div className="flex items-center justify-center p-8"> <Button size="sm" onClick={() => setOpen(true)}> Send quotation </Button> <SendQuotationModal open={open} onOpenChange={setOpen} quotationNumber="KAT/QTN/2026/05/041" onSend={(values) => { console.log("send", values); setOpen(false); }} /> </div> );}Installation
Section titled “Installation”This installation provides support for all official Shadcn icon libraries. The component is otherwise identical to the non-experimental installation.
Icon support is experimental and may not be fully stable since it uses internal Shadcn APIs.
This component relies on other items which must be installed first.
Copy and paste the following code into your project.
"use client";
import * as React from "react";import { Send, X } from "lucide-react";
import { Modal, ModalBody, ModalContent, ModalFooter, ModalHeader, ModalTitle,} from "@/registry/new-york/items/manpowerhub-modal/components/modal";import { Field, FieldError, FieldLabel,} from "@/registry/new-york/items/manpowerhub-field/components/field";import { Button } from "@/components/ui/button";import { Input } from "@/components/ui/input";import { Textarea } from "@/components/ui/textarea";import { cn } from "@/lib/utils";
// ─── Types ────────────────────────────────────────────────────────────────────
export interface SendQuotationValues { recipients: string[]; cc: string[]; subject: string; body: string;}
export interface SendQuotationModalProps { open: boolean; onOpenChange: (open: boolean) => void; quotationNumber?: string; initialSubject?: string; initialBody?: string; onSend: (values: SendQuotationValues) => void | Promise<void>;}
// ─── Email chip input ─────────────────────────────────────────────────────────
function isValidEmail(value: string) { return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value.trim());}
interface EmailChipInputProps { emails: string[]; onChange: (emails: string[]) => void; placeholder?: string; className?: string; hasError?: boolean;}
function EmailChipInput({ emails, onChange, placeholder, className, hasError,}: EmailChipInputProps) { const [inputValue, setInputValue] = React.useState(""); const inputRef = React.useRef<HTMLInputElement>(null);
function addEmail(raw: string) { const trimmed = raw.trim().replace(/,$/, ""); if (!trimmed) return; const parts = trimmed.split(/[\s,;]+/).filter(Boolean); const next = [...emails]; for (const part of parts) { if (!next.includes(part)) next.push(part); } onChange(next); setInputValue(""); }
function removeEmail(email: string) { onChange(emails.filter((e) => e !== email)); }
function handleKeyDown(e: React.KeyboardEvent<HTMLInputElement>) { if (e.key === "Enter" || e.key === "," || e.key === " ") { e.preventDefault(); addEmail(inputValue); } else if (e.key === "Backspace" && !inputValue && emails.length > 0) { onChange(emails.slice(0, -1)); } }
return ( <div className={cn( "flex min-h-[36px] flex-wrap gap-1 rounded-[var(--r-md)] border bg-background px-2.5 py-1.5 text-[13px] transition-colors focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-0", hasError ? "border-[var(--red)]" : "border-input", className, )} onClick={() => inputRef.current?.focus()} > {emails.map((email) => ( <span key={email} className={cn( "inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-[11.5px] font-medium", isValidEmail(email) ? "border-[var(--brand-border)] bg-[var(--brand-subtle)] text-[var(--brand)]" : "border-[var(--red-border)] bg-[var(--red-bg)] text-[var(--red)]", )} > {email} <button type="button" className="ml-0.5 opacity-60 hover:opacity-100" onClick={() => removeEmail(email)} tabIndex={-1} > <X className="size-2.5" /> </button> </span> ))} <input ref={inputRef} value={inputValue} onChange={(e) => setInputValue(e.target.value)} onKeyDown={handleKeyDown} onBlur={() => addEmail(inputValue)} placeholder={emails.length === 0 ? placeholder : ""} className="min-w-[120px] flex-1 bg-transparent text-[13px] outline-none placeholder:text-muted-foreground" /> </div> );}
// ─── Component ────────────────────────────────────────────────────────────────
const DEFAULT_BODY = `Dear Sir/Madam,
Please find attached the quotation as requested. We look forward to your valued feedback.
Best regards`;
function SendQuotationModal({ open, onOpenChange, quotationNumber, initialSubject, initialBody, onSend,}: SendQuotationModalProps) { const [recipients, setRecipients] = React.useState<string[]>([]); const [cc, setCc] = React.useState<string[]>([]); const [subject, setSubject] = React.useState( initialSubject ?? (quotationNumber ? `Quotation ${quotationNumber}` : ""), ); const [body, setBody] = React.useState(initialBody ?? DEFAULT_BODY); const [errors, setErrors] = React.useState<Record<string, string>>({}); const [isSending, setIsSending] = React.useState(false);
React.useEffect(() => { if (open) { setRecipients([]); setCc([]); setSubject( initialSubject ?? (quotationNumber ? `Quotation ${quotationNumber}` : ""), ); setBody(initialBody ?? DEFAULT_BODY); setErrors({}); } }, [open, initialSubject, initialBody, quotationNumber]);
function validate() { const next: Record<string, string> = {}; if (recipients.length === 0) { next.recipients = "At least one recipient is required."; } else if (recipients.some((r) => !isValidEmail(r))) { next.recipients = "One or more email addresses are invalid."; } if (cc.some((r) => !isValidEmail(r))) { next.cc = "One or more CC email addresses are invalid."; } if (!subject.trim()) { next.subject = "Subject is required."; } return next; }
async function handleSend() { const errs = validate(); if (Object.keys(errs).length > 0) { setErrors(errs); return; } setIsSending(true); try { await onSend({ recipients, cc, subject: subject.trim(), body }); onOpenChange(false); } finally { setIsSending(false); } }
return ( <Modal open={open} onOpenChange={onOpenChange}> <ModalContent className="max-w-[520px]"> <ModalHeader> <ModalTitle> Send quotation {quotationNumber && ( <span className="ml-2 font-mono text-[12px] font-normal text-[var(--text-3)]"> {quotationNumber} </span> )} </ModalTitle> </ModalHeader>
<ModalBody className="flex flex-col gap-4 px-4.5 py-4"> <Field> <FieldLabel> To <span className="ml-1 text-[var(--red)]">*</span> </FieldLabel> <EmailChipInput emails={recipients} onChange={(v) => { setRecipients(v); if (errors.recipients) setErrors((prev) => ({ ...prev, recipients: "" })); }} placeholder="Enter email and press Enter or comma…" hasError={!!errors.recipients} /> {errors.recipients && <FieldError>{errors.recipients}</FieldError>} </Field>
<Field> <FieldLabel>CC</FieldLabel> <EmailChipInput emails={cc} onChange={(v) => { setCc(v); if (errors.cc) setErrors((prev) => ({ ...prev, cc: "" })); }} placeholder="Optional CC recipients…" hasError={!!errors.cc} /> {errors.cc && <FieldError>{errors.cc}</FieldError>} </Field>
<Field> <FieldLabel> Subject <span className="ml-1 text-[var(--red)]">*</span> </FieldLabel> <Input value={subject} onChange={(e) => { setSubject(e.target.value); if (errors.subject) setErrors((prev) => ({ ...prev, subject: "" })); }} placeholder="Email subject…" className={cn( "h-8 text-[13px]", errors.subject && "border-[var(--red)]", )} /> {errors.subject && <FieldError>{errors.subject}</FieldError>} </Field>
<Field> <FieldLabel>Message</FieldLabel> <Textarea value={body} onChange={(e) => setBody(e.target.value)} placeholder="Email body…" className="min-h-[130px] resize-none text-[13px]" /> </Field> </ModalBody>
<ModalFooter> <Button variant="outline" size="sm" onClick={() => onOpenChange(false)} disabled={isSending} > Cancel </Button> <Button size="sm" className="gap-1.5" disabled={isSending} onClick={handleSend} > <Send className="size-3.5" /> {isSending ? "Sending…" : "Send quotation"} </Button> </ModalFooter> </ModalContent> </Modal> );}
export { SendQuotationModal };Update the import paths to match your project setup.
Install the ManpowerHub theme first, then add the block:
npx shadcn@latest add https://woxcn-registry.woxware.io/r/manpowerhub-send-quotation-modal.jsonThis installs the send quotation modal at components/ui/manpowerhub-send-quotation-modal.tsx.
Example
Section titled “Example”import { useState } from "react";import { SendQuotationModal } from "@/components/ui/manpowerhub-send-quotation-modal";import { Button } from "@/components/ui/button";
export function QuotationActions({ quotation }) { const [open, setOpen] = useState(false);
return ( <> <Button onClick={() => setOpen(true)}>Send quotation</Button> <SendQuotationModal open={open} onOpenChange={setOpen} quotationNumber={quotation.quotationNumber} initialSubject={`Quotation ${quotation.quotationNumber} — ${quotation.proposalSubject}`} onSend={async ({ recipients, cc, subject, body }) => { await api.quotations.send(quotation.id, { recipients, cc, subject, body }); setOpen(false); }} /> </> );}Recipient input
Section titled “Recipient input”Recipients and CC fields accept multiple emails entered by pressing Enter, comma, or space. Invalid addresses are shown in red. Clicking an email chip’s × removes it.
open: controls dialog visibility.onOpenChange(open): called when the dialog open state changes.quotationNumber: optional — shown in title and used to pre-fill the default subject.initialSubject: optional override for the pre-filled subject line.initialBody: optional override for the default message body template.onSend(values): called with{ recipients, cc, subject, body }after validation passes.
interface SendQuotationValues { recipients: string[]; cc: string[]; subject: string; body: string;}