diff --git a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/partners/invite-partner-sheet.tsx b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/partners/invite-partner-sheet.tsx index 5e458eba9d0..e7f5f8d5182 100644 --- a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/partners/invite-partner-sheet.tsx +++ b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/partners/invite-partner-sheet.tsx @@ -1,24 +1,32 @@ import { parseActionError } from "@/lib/actions/parse-action-errors"; +import { bulkInvitePartnersAction } from "@/lib/actions/partners/bulk-invite-partners"; import { invitePartnerAction } from "@/lib/actions/partners/invite-partner"; import { saveInviteEmailDataAction } from "@/lib/actions/partners/save-invite-email-data"; +import { MAX_PARTNERS_INVITES_PER_REQUEST } from "@/lib/constants/program"; import { useEmailDomains } from "@/lib/swr/use-email-domains"; import useProgram from "@/lib/swr/use-program"; import useWorkspace from "@/lib/swr/use-workspace"; import { ProgramInviteEmailData, ProgramProps } from "@/lib/types"; -import { invitePartnerSchema } from "@/lib/zod/schemas/partners"; +import { + bulkInvitePartnersSchema, + invitePartnerSchema, +} from "@/lib/zod/schemas/partners"; import { GroupSelector } from "@/ui/partners/groups/group-selector"; import { X } from "@/ui/shared/icons"; import { + AnimatedSizeContainer, BlurImage, Button, InfoTooltip, + MultiValueInput, + type MultiValueInputRef, RichTextArea, RichTextProvider, RichTextToolbar, Sheet, useMediaQuery, } from "@dub/ui"; -import { cn } from "@dub/utils"; +import { cn, pluralize } from "@dub/utils"; import { useAction } from "next-safe-action/hooks"; import { Dispatch, @@ -30,13 +38,18 @@ import { } from "react"; import { useForm } from "react-hook-form"; import { toast } from "sonner"; -import * as z from "zod/v4"; interface InvitePartnerSheetProps { setIsOpen: Dispatch>; } -type InvitePartnerFormData = z.infer; +type InvitePartnerFormData = { + email: string; + emails: string[]; + name?: string; + username?: string; + groupId: string | null; +}; type EmailContent = { subject: string; @@ -92,21 +105,55 @@ function InvitePartnerSheetContent({ setIsOpen }: InvitePartnerSheetProps) { setValue, } = useForm({ defaultValues: { + email: "", + emails: [], groupId: program?.defaultGroupId || "", }, }); - const email = watch("email"); + const multiValueInputRef = useRef(null); + const emails = watch("emails") ?? []; + const hasMultipleRecipients = emails.length > 1; - const { executeAsync, isPending } = useAction(invitePartnerAction, { - onSuccess: () => { - toast.success("Invitation sent to partner!"); - setIsOpen(false); - }, - onError({ error }) { - toast.error(error.serverError); + const { executeAsync: invitePartner, isPending } = useAction( + invitePartnerAction, + { + onSuccess: () => { + toast.success("Invitation sent to partner!"); + setIsOpen(false); + }, + onError({ error }) { + toast.error(error.serverError); + }, }, - }); + ); + + const { executeAsync: bulkInvitePartners, isPending: isBulkPending } = + useAction(bulkInvitePartnersAction, { + onSuccess: ({ data: { invitedCount, skippedCount } }) => { + const parts: string[] = []; + + if (invitedCount > 0) { + parts.push( + invitedCount === 1 + ? "Invitation sent to 1 partner." + : `Invitations sent to ${invitedCount} partners.`, + ); + } + + if (skippedCount > 0) { + parts.push( + `${skippedCount} ${pluralize("partner", skippedCount)} were skipped because they're already enrolled or previously invited.`, + ); + } + + toast.success(parts.join(" ")); + setIsOpen(false); + }, + onError({ error }) { + toast.error(error.serverError); + }, + }); const { executeAsync: saveEmailDataAsync, isPending: isSavingEmailData } = useAction(saveInviteEmailDataAction, { @@ -133,10 +180,44 @@ function InvitePartnerSheetContent({ setIsOpen }: InvitePartnerSheetProps) { return; } - await executeAsync({ - ...data, + const finalEmails = + multiValueInputRef.current?.commitPendingInput() ?? data.emails ?? []; + + if (finalEmails.length === 0) { + toast.error("Please enter at least one email address."); + return; + } + + if (finalEmails.length === 1) { + const parsed = invitePartnerSchema.safeParse({ + workspaceId, + email: finalEmails[0], + name: data.name, + username: data.username, + groupId: data.groupId ?? null, + }); + + if (!parsed.success) { + toast.error(parsed.error.issues[0]?.message ?? "Invalid input"); + return; + } + + await invitePartner(parsed.data); + return; + } + + const parsed = bulkInvitePartnersSchema.safeParse({ workspaceId, + emails: finalEmails, + groupId: data.groupId ?? null, }); + + if (!parsed.success) { + toast.error(parsed.error.issues[0]?.message ?? "Invalid input"); + return; + } + + await bulkInvitePartners(parsed.data); }; const handleStartEditing = () => { @@ -213,70 +294,95 @@ function InvitePartnerSheetContent({ setIsOpen }: InvitePartnerSheetProps) {
-
- + { + setValue("emails", values, { + shouldDirty: true, + shouldValidate: true, + }); + setValue("email", values[0] ?? "", { + shouldDirty: true, + shouldValidate: true, + }); + }} placeholder="panic@thedis.co" - type="email" - autoComplete="off" + normalize={(v) => v.trim().toLowerCase()} + maxValues={MAX_PARTNERS_INVITES_PER_REQUEST} + disabled={isEditingEmail || isSavingEmailData} autoFocus={!isMobile} />
+

+ Separate multiple emails with commas, or paste a list +

- - -
- -
-
- -
-
- -
+ {!hasMultipleRecipients && ( +
+
+ + +
+ +
+
-
- - {program?.domain} - - -
-
+
+
+ +
-
+
+ + {program?.domain} + + +
+
+
+ )} + @@ -315,16 +421,18 @@ function InvitePartnerSheetContent({ setIsOpen }: InvitePartnerSheetProps) { onClick={() => setIsOpen(false)} text="Cancel" className="w-fit" - disabled={isPending} + disabled={isPending || isBulkPending} />
diff --git a/apps/web/lib/actions/partners/bulk-invite-partners.ts b/apps/web/lib/actions/partners/bulk-invite-partners.ts new file mode 100644 index 00000000000..c8987a57f5b --- /dev/null +++ b/apps/web/lib/actions/partners/bulk-invite-partners.ts @@ -0,0 +1,236 @@ +"use server"; + +import { recordAuditLog } from "@/lib/api/audit-logs/record-audit-log"; +import { createId } from "@/lib/api/create-id"; +import { bulkCreateLinks } from "@/lib/api/links"; +import { getPartnerInviteRewardsAndBounties } from "@/lib/api/partners/get-partner-invite-rewards-and-bounties"; +import { getDefaultProgramIdOrThrow } from "@/lib/api/programs/get-default-program-id-or-throw"; +import { extractUtmParams } from "@/lib/api/utm/extract-utm-params"; +import { DEFAULT_PARTNER_GROUP } from "@/lib/zod/schemas/groups"; +import { bulkInvitePartnersSchema } from "@/lib/zod/schemas/partners"; +import { sendBatchEmail } from "@dub/email"; +import ProgramInvite from "@dub/email/templates/program-invite"; +import { prisma } from "@dub/prisma"; +import { constructURLFromUTMParams, nanoid } from "@dub/utils"; +import { prettyPrint } from "@dub/utils/src"; +import slugify from "@sindresorhus/slugify"; +import { waitUntil } from "@vercel/functions"; +import { getProgramOrThrow } from "../../api/programs/get-program-or-throw"; +import { authActionClient } from "../safe-action"; +import { throwIfNoPermission } from "../throw-if-no-permission"; + +export const bulkInvitePartnersAction = authActionClient + .inputSchema(bulkInvitePartnersSchema) + .action(async ({ parsedInput, ctx }) => { + const { workspace, user } = ctx; + const { groupId, emails } = parsedInput; + + throwIfNoPermission({ + role: workspace.role, + requiredRoles: ["owner", "member"], + }); + + const uniqueRecipientEmails = [...new Set(emails)]; + + const programId = getDefaultProgramIdOrThrow(workspace); + + const program = await getProgramOrThrow({ + workspaceId: workspace.id, + programId, + include: { + groups: { + where: groupId + ? { id: groupId } + : { slug: DEFAULT_PARTNER_GROUP.slug }, + include: { + partnerGroupDefaultLinks: true, + utmTemplate: true, + }, + }, + partners: { + where: { + partner: { + email: { + in: uniqueRecipientEmails, + }, + }, + }, + include: { + partner: { + select: { + email: true, + }, + }, + }, + }, + emailDomains: { + where: { + status: "verified", + }, + }, + }, + }); + + const alreadyEnrolledEmails = new Set( + program.partners.map((p) => p.partner.email).filter(Boolean), + ); + + // Filter out emails that are already enrolled + const emailsToInvite = uniqueRecipientEmails.filter( + (email) => !alreadyEnrolledEmails.has(email), + ); + + if (emailsToInvite.length === 0) { + return { + invitedCount: 0, + skippedCount: alreadyEnrolledEmails.size, + }; + } + + if (program.groups.length === 0) { + throw new Error("Invalid group ID provided."); + } + + const { count: createdPartnersCount } = await prisma.partner.createMany({ + data: emailsToInvite.map((email) => ({ + id: createId({ prefix: "pn_" }), + email, + name: email, + })), + skipDuplicates: true, + }); + + console.log( + `Created ${createdPartnersCount} out of ${emailsToInvite.length} provided partners (${emailsToInvite.length - createdPartnersCount} already exist on Dub)`, + ); + + // Fetch the created partners + const partners = await prisma.partner.findMany({ + where: { + email: { + in: emailsToInvite, + }, + }, + select: { + id: true, + email: true, + name: true, + }, + }); + + const group = program.groups[0]; + const partnerGroupDefaultLinks = group.partnerGroupDefaultLinks; + const utmTemplate = group.utmTemplate; + + const { count: invitedCount } = await prisma.programEnrollment.createMany({ + data: partners.map((partner) => ({ + id: createId({ prefix: "pge_" }), + programId, + partnerId: partner.id, + status: "invited", + groupId: group.id, + clickRewardId: group.clickRewardId, + leadRewardId: group.leadRewardId, + saleRewardId: group.saleRewardId, + discountId: group.discountId, + })), + skipDuplicates: true, + }); + + console.log( + `Created ${invitedCount} program enrollments with status "invited"`, + ); + + waitUntil( + (async () => { + // Create default links for the partners for each group default link + for (const partnerGroupDefaultLink of partnerGroupDefaultLinks) { + const links = await bulkCreateLinks({ + links: partners.map((partner) => ({ + domain: partnerGroupDefaultLink.domain, + key: `${slugify(partner.email!.split("@")[0])}-${nanoid(4)}`, + url: constructURLFromUTMParams( + partnerGroupDefaultLink.url, + extractUtmParams(utmTemplate), + ), + ...extractUtmParams(utmTemplate, { excludeRef: true }), + projectId: workspace.id, + programId: program.id, + partnerId: partner.id, + userId: user.id, + folderId: program.defaultFolderId, + partnerGroupDefaultLinkId: partnerGroupDefaultLink.id, + })), + }); + + console.log( + `Created ${links.length} links for the partner for the default link ${partnerGroupDefaultLink.id}`, + ); + } + + const rewardsAndBounties = await getPartnerInviteRewardsAndBounties({ + programId, + groupId: groupId || program.defaultGroupId, + }); + + const inviteEmailData = program.inviteEmailData; + const emailDomains = program.emailDomains; + + const { data: resendData } = await sendBatchEmail( + partners.map((partner) => ({ + subject: + inviteEmailData?.subject || + `${program.name} invited you to join Dub Partners`, + variant: "notifications", + from: + emailDomains.length > 0 + ? `${program.name} ` + : undefined, + to: partner.email!, + replyTo: program.supportEmail || "noreply", + react: ProgramInvite({ + email: partner.email!, + name: partner.name, + program: { + name: program.name, + slug: program.slug, + logo: program.logo, + }, + ...(inviteEmailData?.subject && { + subject: inviteEmailData.subject, + }), + ...(inviteEmailData?.title && { title: inviteEmailData.title }), + ...(inviteEmailData?.body && { body: inviteEmailData.body }), + ...rewardsAndBounties, + }), + })), + ); + + console.log( + `Sent invitation emails to ${emailsToInvite.length} partners. ${prettyPrint(resendData)}`, + ); + + await recordAuditLog( + partners.map((partner) => ({ + workspaceId: workspace.id, + programId, + action: "partner.invited", + description: `Partner ${partner.id} invited`, + actor: user, + targets: [ + { + type: "partner", + id: partner.id, + metadata: partner, + }, + ], + })), + ); + })(), + ); + + return { + invitedCount, + skippedCount: alreadyEnrolledEmails.size, + }; + }); diff --git a/apps/web/lib/actions/partners/invite-partner.ts b/apps/web/lib/actions/partners/invite-partner.ts index 8cd85107439..f3fca65496d 100644 --- a/apps/web/lib/actions/partners/invite-partner.ts +++ b/apps/web/lib/actions/partners/invite-partner.ts @@ -7,7 +7,6 @@ import { getDefaultProgramIdOrThrow } from "@/lib/api/programs/get-default-progr import { invitePartnerSchema } from "@/lib/zod/schemas/partners"; import { sendEmail } from "@dub/email"; import ProgramInvite from "@dub/email/templates/program-invite"; -import { prisma } from "@dub/prisma"; import { waitUntil } from "@vercel/functions"; import { getProgramOrThrow } from "../../api/programs/get-program-or-throw"; import { authActionClient } from "../safe-action"; @@ -17,7 +16,7 @@ export const invitePartnerAction = authActionClient .inputSchema(invitePartnerSchema) .action(async ({ parsedInput, ctx }) => { const { workspace, user } = ctx; - const { email, username, name, groupId } = parsedInput; + const { groupId, email, username, name } = parsedInput; throwIfNoPermission({ role: workspace.role, @@ -26,30 +25,26 @@ export const invitePartnerAction = authActionClient const programId = getDefaultProgramIdOrThrow(workspace); - const [program, programEnrollment] = await Promise.all([ - getProgramOrThrow({ - workspaceId: workspace.id, - programId, - include: { - emailDomains: { - where: { - status: "verified", + const program = await getProgramOrThrow({ + workspaceId: workspace.id, + programId, + include: { + partners: { + where: { + partner: { + email, }, }, }, - }), - - prisma.programEnrollment.findFirst({ - where: { - programId, - partner: { - email, + emailDomains: { + where: { + status: "verified", }, }, - }), - ]); + }, + }); - if (programEnrollment) { + if (program.partners.length > 0) { const statusMessages = { invited: "has already been invited to", approved: "is already enrolled in", @@ -58,7 +53,7 @@ export const invitePartnerAction = authActionClient pending: "has a pending application to join", }; - const message = statusMessages[programEnrollment.status]; + const message = statusMessages[program.partners[0].status]; if (message) { throw new Error(`Partner ${email} ${message} this program.`); diff --git a/apps/web/lib/constants/program.ts b/apps/web/lib/constants/program.ts index 075a7ccbd39..7dc33723987 100644 --- a/apps/web/lib/constants/program.ts +++ b/apps/web/lib/constants/program.ts @@ -1,4 +1,6 @@ export const PROGRAM_ONBOARDING_PARTNERS_LIMIT = 5; +export const MAX_PARTNERS_INVITES_PER_REQUEST = 50; + export const MAX_PROGRAM_CATEGORIES = 3; export const PROGRAM_SIMILARITY_SCORE_THRESHOLD = 0.3; diff --git a/apps/web/lib/zod/schemas/partners.ts b/apps/web/lib/zod/schemas/partners.ts index 3f6eb1dc5e2..babddba6fd4 100644 --- a/apps/web/lib/zod/schemas/partners.ts +++ b/apps/web/lib/zod/schemas/partners.ts @@ -1,3 +1,4 @@ +import { MAX_PARTNERS_INVITES_PER_REQUEST } from "@/lib/constants/program"; import { IndustryInterest, MonthlyTraffic, @@ -730,10 +731,18 @@ export const partnerAnalyticsResponseSchema = { export const invitePartnerSchema = z.object({ workspaceId: z.string(), + groupId: z.string().nullish(), name: z.string().max(100).optional(), email: z.email().trim().min(1).max(100), username: z.string().max(100).optional(), - groupId: z.string().nullish().default(null), +}); + +export const bulkInvitePartnersSchema = z.object({ + workspaceId: z.string(), + groupId: z.string().nullish(), + emails: z + .array(z.email().trim().min(1).max(100)) + .max(MAX_PARTNERS_INVITES_PER_REQUEST), }); export const approvePartnerSchema = z.object({ diff --git a/packages/email/src/templates/program-invite.tsx b/packages/email/src/templates/program-invite.tsx index cd7bb78bad9..63d128b5e2e 100644 --- a/packages/email/src/templates/program-invite.tsx +++ b/packages/email/src/templates/program-invite.tsx @@ -133,7 +133,7 @@ export default function ProgramInvite({ body, }: { email: string; - name: string | null; + name?: string | null; program: { name: string; slug: string; diff --git a/packages/ui/src/index.tsx b/packages/ui/src/index.tsx index 8a8ddba900c..eb468c27f0c 100644 --- a/packages/ui/src/index.tsx +++ b/packages/ui/src/index.tsx @@ -23,6 +23,7 @@ export * from "./form"; export * from "./grid"; export * from "./input"; export * from "./label"; +export * from "./multi-value-input"; export * from "./menu-item"; export * from "./mini-area-chart"; export * from "./modal"; diff --git a/packages/ui/src/multi-value-input.tsx b/packages/ui/src/multi-value-input.tsx new file mode 100644 index 00000000000..0a32ef19bce --- /dev/null +++ b/packages/ui/src/multi-value-input.tsx @@ -0,0 +1,310 @@ +"use client"; + +import { cn } from "@dub/utils"; +import { X } from "lucide-react"; +import React, { + useCallback, + useEffect, + useImperativeHandle, + useRef, + useState, +} from "react"; + +/** + * Parses CSV-like pasted text: splits on commas and newlines, and respects + * double-quoted fields (commas/newlines inside quotes are kept). + */ +function parseCsvLikeValues(raw: string): string[] { + const result: string[] = []; + let current = ""; + let inQuotes = false; + + for (let i = 0; i < raw.length; i++) { + const c = raw[i]; + + if (c === '"') { + inQuotes = !inQuotes; + continue; + } + + if (!inQuotes && (c === "," || c === "\n" || c === "\r")) { + // Treat \r\n as single newline + if (c === "\r" && raw[i + 1] === "\n") i++; + const trimmed = current.trim(); + if (trimmed) result.push(trimmed); + current = ""; + continue; + } + + current += c; + } + + const trimmed = current.trim(); + if (trimmed) result.push(trimmed); + return result; +} + +export interface MultiValueInputRef { + /** Commits any pending input, updates parent, and returns the full list. */ + commitPendingInput: () => string[]; +} + +export interface MultiValueInputProps { + values: string[]; + onChange: (values: string[]) => void; + placeholder?: string; + id?: string; + className?: string; + inputClassName?: string; + disabled?: boolean; + autoFocus?: boolean; + /** Optional normalizer for each value when adding (e.g. trim + lowercase). */ + normalize?: (value: string) => string; + /** Optional max number of values (no limit if omitted). */ + maxValues?: number; +} + +const MultiValueInput = React.forwardRef< + MultiValueInputRef, + MultiValueInputProps +>(function MultiValueInput( + { + values, + onChange, + placeholder, + id, + className, + inputClassName, + disabled, + normalize = (v) => v.trim(), + maxValues, + autoFocus, + }, + ref, +) { + const [inputValue, setInputValue] = useState(""); + const [selectedValue, setSelectedValue] = useState(null); + const containerRef = useRef(null); + const inputRef = useRef(null); + const [isWrapped, setIsWrapped] = useState(false); + + const addValues = useCallback( + (candidates: string[]) => { + const normalized = candidates + .map(normalize) + .filter(Boolean) + .filter((v) => !values.includes(v)); + if (normalized.length === 0) return values; + const next = [...values]; + for (const v of normalized) { + if (maxValues != null && next.length >= maxValues) break; + next.push(v); + } + return next; + }, + [values, normalize, maxValues], + ); + + const commitPendingInput = useCallback((): string[] => { + const parsed = parseCsvLikeValues(inputValue); + if (parsed.length === 0) { + setInputValue(""); + return values; + } + const next = addValues(parsed); + onChange(next); + setInputValue(""); + return next; + }, [inputValue, values, addValues, onChange]); + + useImperativeHandle( + ref, + () => ({ + commitPendingInput() { + return commitPendingInput(); + }, + }), + [commitPendingInput], + ); + + // Clear selection when the selected value is removed from the list + useEffect(() => { + if (selectedValue && !values.includes(selectedValue)) { + setSelectedValue(null); + } + }, [values, selectedValue]); + + // ResizeObserver for wrapped layout + useEffect(() => { + const container = containerRef.current; + if (!container) return; + + const checkWrapped = () => { + const children = Array.from(container.children) as HTMLElement[]; + if (children.length <= 1) { + setIsWrapped(false); + return; + } + const tops = children.map((el) => el.offsetTop); + const firstRowTop = Math.min(...tops); + setIsWrapped(tops.some((top) => top - firstRowTop > 2)); + }; + + checkWrapped(); + const observer = new ResizeObserver(checkWrapped); + observer.observe(container); + return () => observer.disconnect(); + }, [values, inputValue]); + + const handleKeyDown = (e: React.KeyboardEvent) => { + const inputEl = e.currentTarget; + const selectedIndex = selectedValue ? values.indexOf(selectedValue) : -1; + + if (e.key === "," || e.key === "Enter") { + e.preventDefault(); + commitPendingInput(); + return; + } + + if (e.key === "ArrowLeft" && values.length > 0) { + if (selectedIndex > 0) { + e.preventDefault(); + setSelectedValue(values[selectedIndex - 1]); + return; + } + if ( + selectedIndex === -1 && + inputEl.selectionStart === 0 && + inputEl.selectionEnd === 0 + ) { + e.preventDefault(); + setSelectedValue(values[values.length - 1]); + return; + } + } + + if (e.key === "ArrowRight" && selectedIndex !== -1) { + e.preventDefault(); + if (selectedIndex < values.length - 1) { + setSelectedValue(values[selectedIndex + 1]); + return; + } + setSelectedValue(null); + return; + } + + if ( + (e.key === "Backspace" || e.key === "Delete") && + !inputValue && + values.length > 0 + ) { + e.preventDefault(); + if (selectedValue) { + const next = values.filter((v) => v !== selectedValue); + onChange(next); + setSelectedValue(null); + return; + } + setSelectedValue(values[values.length - 1]); + } + + if (e.key === "Tab" && selectedValue) { + e.preventDefault(); + setSelectedValue(null); + } + }; + + const handlePaste = (e: React.ClipboardEvent) => { + const pasted = e.clipboardData.getData("text"); + const parsed = parseCsvLikeValues(pasted); + if (parsed.length === 0) return; + + e.preventDefault(); + const next = addValues(parsed); + onChange(next); + setInputValue(""); + }; + + const handleBlur = () => { + setSelectedValue(null); + commitPendingInput(); + }; + + const removeValue = (value: string) => { + setSelectedValue((prev) => (prev === value ? null : prev)); + onChange(values.filter((v) => v !== value)); + }; + + return ( +
+ {values.map((value) => ( + setSelectedValue(value)} + className={cn( + "inline-flex items-center gap-1 rounded-md py-0.5 pl-1.5 pr-1 text-sm leading-6", + selectedValue === value + ? "bg-neutral-300 text-neutral-900" + : "bg-neutral-100 text-neutral-900", + )} + > + {value} + + + ))} +
+ { + setSelectedValue(null); + setInputValue(e.target.value); + }} + onKeyDown={handleKeyDown} + onBlur={handleBlur} + onPaste={handlePaste} + disabled={disabled} + className={cn( + "h-7 w-full border-0 bg-transparent px-1.5 text-sm leading-6 text-neutral-900 placeholder-neutral-400 focus:outline-none focus:ring-0", + selectedValue && + "text-transparent caret-transparent placeholder:text-transparent", + inputClassName, + )} + placeholder={placeholder} + type="text" + autoComplete="off" + autoFocus={autoFocus} + /> +
+
+ ); +}); + +MultiValueInput.displayName = "MultiValueInput"; + +export { MultiValueInput };