Quote Cart

Your cart is empty

Add items to request a quote

/**

* RFQCart

* Request for Quote Cart Component

*

* Version: 1.0.0

* Created for Framer Marketplace

*

* @framerSupportedLayoutWidth any

* @framerSupportedLayoutHeight any

*/


/**

* RFQ Cart - Request for Quote Shopping Cart

* A professional cart system for quote requests, bulk orders, and custom inquiries without payment processing.

* Features localStorage persistence, webhook submission, and beautiful animations.

*

* @framerSupportedLayoutWidth any

* @framerSupportedLayoutHeight any

*/


import * as React from "react"

import { useState, useEffect, useCallback, useMemo } from "react"

import { motion, AnimatePresence } from "framer-motion"

import { addPropertyControls, ControlType } from "framer"


// ============================================================================

// Types

// ============================================================================


interface CartItem {

id: string

name: string

price: number

quantity: number

imageUrl?: string

options?: string

note?: string

}


interface CustomerInfo {

name: string

email: string

phone: string

company: string

message: string

}


interface ColorsConfig {

primary: string

secondary: string

background: string

surface: string

text: string

textSecondary: string

border: string

success: string

error: string

}


interface TextConfig {

cartTitle: string

emptyCartMessage: string

emptyCartSubtext: string

quantityLabel: string

noteLabel: string

notePlaceholder: string

subtotalLabel: string

totalItemsLabel: string

requestQuoteButton: string

clearCartButton: string

removeItemButton: string

formTitle: string

nameLabel: string

namePlaceholder: string

emailLabel: string

emailPlaceholder: string

phoneLabel: string

phonePlaceholder: string

companyLabel: string

companyPlaceholder: string

messageLabel: string

messagePlaceholder: string

submitButton: string

submittingButton: string

successTitle: string

successMessage: string

errorTitle: string

errorMessage: string

backToCartButton: string

continueShoppingButton: string

requiredField: string

}


interface SubmissionConfig {

webhookUrl: string

storageKey: string

}


interface ContentConfig {

showPrices: boolean

showImages: boolean

showNotes: boolean

maxQuantity: number

}


interface CurrencyConfig {

currencySymbol: string

currencyPosition: "before" | "after"

}


interface FormField {

id: string

label: string

placeholder: string

type: "text" | "email" | "tel" | "textarea"

required: boolean

}


interface FormConfig {

showForm: boolean

customFields: FormField[]

}


interface TextsConfig {

cartTitle: string

formTitle: string

emptyMessage: string

emptySubtext: string

cartButton: string

submitButton: string

successTitle: string

successMessage: string

continueButton: string

}


interface StylingConfig {

borderRadius: number

showShadow: boolean

compactMode: boolean

enableAnimations: boolean

showScrollbar: boolean

scrollbarColor: string

scrollbarWidth: number

}


// Font style interface (from ControlType.Font)

interface FontStyle {

fontFamily?: string

fontWeight?: string | number

fontSize?: number | string

lineHeight?: number | string

letterSpacing?: number | string

textAlign?: "left" | "center" | "right" | "justify"

}


interface TypographyConfig {

title: FontStyle

body: FontStyle

button: FontStyle

}


interface ErrorCodeMapping {

code: string

message: string

}


interface Props {

// Component Type (top-level selection)

componentType: "cart" | "addToCart"


// Display Mode (for cart type)

mode: "cart" | "button" | "mini" | "header" | "header-popup"


// Flat props (not grouped)

storageKey: string

webhookUrl: string

continueShoppingUrl: string


// Product Info (for addToCart type)

product: {

productId: string

productName: string

productPrice: number

productImage?: string

productOptions?: string

}


// Add to Cart Button Appearance

addToCartAppearance: {

buttonText: string

buttonStyle: "primary" | "secondary" | "outline"

showIcon: boolean

fullWidth: boolean

}


// Grouped Controls

colors: ColorsConfig

texts: TextsConfig

submission: SubmissionConfig // Legacy support

content: ContentConfig

currency: CurrencyConfig

form: FormConfig

styling: StylingConfig

typography: TypographyConfig

errorCodeMappings: ErrorCodeMapping[]


// Text/Labels (internal use - for legacy support)

text: TextConfig


// Callbacks (for Framer interactions)

onCartUpdate?: (itemCount: number, total: number) => void

onSubmitSuccess?: () => void

onSubmitError?: (error: string) => void

onAddToCart?: (productId: string, quantity: number) => void

}


// ============================================================================

// Default Values

// ============================================================================


const defaultColors: ColorsConfig = {

primary: "#6366F1",

secondary: "#8B5CF6",

background: "#FFFFFF",

surface: "#F8FAFC",

text: "#1E293B",

textSecondary: "#64748B",

border: "#E2E8F0",

success: "#10B981",

error: "#EF4444",

}


const defaultText: TextConfig = {

cartTitle: "Quote Cart",

emptyCartMessage: "Your cart is empty",

emptyCartSubtext: "Add items to request a quote",

quantityLabel: "Qty",

noteLabel: "Note",

notePlaceholder: "Add special instructions...",

subtotalLabel: "Subtotal",

totalItemsLabel: "items",

requestQuoteButton: "Request Quote",

clearCartButton: "Clear All",

removeItemButton: "Remove",

formTitle: "Contact Information",

nameLabel: "Full Name",

namePlaceholder: "Enter your name",

emailLabel: "Email Address",

emailPlaceholder: "your@email.com",

phoneLabel: "Phone Number",

phonePlaceholder: "+1 (555) 000-0000",

companyLabel: "Company Name",

companyPlaceholder: "Your company",

messageLabel: "Additional Message",

messagePlaceholder: "Any special requirements or questions...",

submitButton: "Submit Quote Request",

submittingButton: "Submitting...",

successTitle: "Quote Request Sent!",

successMessage: "We'll get back to you within 24 hours.",

errorTitle: "Submission Failed",

errorMessage: "Please try again or contact us directly.",

backToCartButton: "Back to Cart",

continueShoppingButton: "Continue Shopping",

requiredField: "*",

}


// ============================================================================

// Utility Functions

// ============================================================================


const formatPrice = (

price: number,

symbol: string,

position: "before" | "after"

): string => {

const formatted = price.toLocaleString(undefined, {

minimumFractionDigits: 0,

maximumFractionDigits: 2,

})

return position === "before"

? `${symbol}${formatted}`

: `${formatted}${symbol}`

}


const generateRequestId = (): string => {

return `RFQ-${Date.now().toString(36).toUpperCase()}-${Math.random()

.toString(36)

.substring(2, 6)

.toUpperCase()}`

}


// ============================================================================

// Icons

// ============================================================================


const CartIcon = ({ size = 24 }: { size?: number }) => (

<svg

width={size}

height={size}

viewBox="0 0 24 24"

fill="none"

stroke="currentColor"

strokeWidth="2"

strokeLinecap="round"

strokeLinejoin="round"

>

<circle cx="9" cy="21" r="1" />

<circle cx="20" cy="21" r="1" />

<path d="M1 1h4l2.68 13.39a2 2 0 0 0 2 1.61h9.72a2 2 0 0 0 2-1.61L23 6H6" />

</svg>

)


const TrashIcon = ({ size = 20 }: { size?: number }) => (

<svg

width={size}

height={size}

viewBox="0 0 24 24"

fill="none"

stroke="currentColor"

strokeWidth="2"

strokeLinecap="round"

strokeLinejoin="round"

>

<polyline points="3,6 5,6 21,6" />

<path d="M19,6v14a2,2 0 0,1 -2,2H7a2,2 0 0,1 -2,-2V6m3,0V4a2,2 0 0,1 2,-2h4a2,2 0 0,1 2,2v2" />

</svg>

)


const PlusIcon = ({ size = 16 }: { size?: number }) => (

<svg

width={size}

height={size}

viewBox="0 0 24 24"

fill="none"

stroke="currentColor"

strokeWidth="2.5"

strokeLinecap="round"

>

<line x1="12" y1="5" x2="12" y2="19" />

<line x1="5" y1="12" x2="19" y2="12" />

</svg>

)


const MinusIcon = ({ size = 16 }: { size?: number }) => (

<svg

width={size}

height={size}

viewBox="0 0 24 24"

fill="none"

stroke="currentColor"

strokeWidth="2.5"

strokeLinecap="round"

>

<line x1="5" y1="12" x2="19" y2="12" />

</svg>

)


const CheckIcon = ({ size = 24 }: { size?: number }) => (

<svg

width={size}

height={size}

viewBox="0 0 24 24"

fill="none"

stroke="currentColor"

strokeWidth="2.5"

strokeLinecap="round"

strokeLinejoin="round"

>

<polyline points="20,6 9,17 4,12" />

</svg>

)


const AlertIcon = ({ size = 24 }: { size?: number }) => (

<svg

width={size}

height={size}

viewBox="0 0 24 24"

fill="none"

stroke="currentColor"

strokeWidth="2"

strokeLinecap="round"

strokeLinejoin="round"

>

<circle cx="12" cy="12" r="10" />

<line x1="12" y1="8" x2="12" y2="12" />

<line x1="12" y1="16" x2="12.01" y2="16" />

</svg>

)


const ArrowLeftIcon = ({ size = 20 }: { size?: number }) => (

<svg

width={size}

height={size}

viewBox="0 0 24 24"

fill="none"

stroke="currentColor"

strokeWidth="2"

strokeLinecap="round"

strokeLinejoin="round"

>

<line x1="19" y1="12" x2="5" y2="12" />

<polyline points="12,19 5,12 12,5" />

</svg>

)


const CloseIcon = ({ size = 20 }: { size?: number }) => (

<svg

width={size}

height={size}

viewBox="0 0 24 24"

fill="none"

stroke="currentColor"

strokeWidth="2"

strokeLinecap="round"

>

<line x1="18" y1="6" x2="6" y2="18" />

<line x1="6" y1="6" x2="18" y2="18" />

</svg>

)


// ============================================================================

// Sub Components

// ============================================================================


interface CartItemCardProps {

item: CartItem

colors: ColorsConfig

text: TextConfig

showPrices: boolean

showImages: boolean

showNotes: boolean

maxQuantity: number

currencySymbol: string

currencyPosition: "before" | "after"

borderRadius: number

enableAnimations: boolean

layoutId: string

onUpdateQuantity: (id: string, quantity: number) => void

onUpdateNote: (id: string, note: string) => void

onRemove: (id: string) => void

}


const CartItemCard: React.FC<CartItemCardProps> = ({

item,

colors,

text,

showPrices,

showImages,

showNotes,

maxQuantity,

currencySymbol,

currencyPosition,

borderRadius,

enableAnimations,

layoutId,

onUpdateQuantity,

onUpdateNote,

onRemove,

}) => {

const [showNote, setShowNote] = useState(!!item.note)


const itemTotal = item.price * item.quantity


const MotionWrapper = enableAnimations ? motion.div : ("div" as any)


return (

<MotionWrapper

key={layoutId}

layoutId={enableAnimations ? layoutId : undefined}

layout={enableAnimations}

initial={enableAnimations ? { opacity: 0, y: 20 } : undefined}

animate={enableAnimations ? { opacity: 1, y: 0 } : undefined}

exit={enableAnimations ? { opacity: 0, x: -100 } : undefined}

style={{

display: "flex",

flexDirection: "column",

gap: 12,

padding: 16,

background: colors.surface,

borderRadius: borderRadius,

border: `1px solid ${colors.border}`,

}}

>

<div style={{ display: "flex", gap: 12, alignItems: "flex-start" }}>

{/* Image */}

{showImages && item.imageUrl && (

<div

style={{

width: 64,

height: 64,

borderRadius: borderRadius * 0.5,

overflow: "hidden",

flexShrink: 0,

background: colors.border,

}}

>

<img

src={item.imageUrl}

alt={item.name}

style={{

width: "100%",

height: "100%",

objectFit: "cover",

}}

/>

</div>

)}


{/* Info */}

<div style={{ flex: 1, minWidth: 0 }}>

<div

style={{

fontSize: 15,

fontWeight: 600,

color: colors.text,

marginBottom: 4,

overflow: "hidden",

textOverflow: "ellipsis",

whiteSpace: "nowrap",

}}

>

{item.name}

</div>


{item.options && (

<div

style={{

fontSize: 13,

color: colors.textSecondary,

marginBottom: 4,

}}

>

{item.options}

</div>

)}


{showPrices && (

<div

style={{

fontSize: 14,

fontWeight: 500,

color: colors.primary,

}}

>

{formatPrice(

item.price,

currencySymbol,

currencyPosition

)}

{item.quantity > 1 && (

<span

style={{

color: colors.textSecondary,

fontWeight: 400,

}}

>

{" × "}

{item.quantity}

{" = "}

{formatPrice(

itemTotal,

currencySymbol,

currencyPosition

)}

</span>

)}

</div>

)}

</div>


{/* Remove Button */}

<button

onClick={() => onRemove(item.id)}

style={{

width: 32,

height: 32,

display: "flex",

alignItems: "center",

justifyContent: "center",

background: "transparent",

border: "none",

borderRadius: borderRadius * 0.5,

color: colors.textSecondary,

cursor: "pointer",

transition: "all 0.2s",

}}

onMouseEnter={(e) => {

e.currentTarget.style.background = `${colors.error}15`

e.currentTarget.style.color = colors.error

}}

onMouseLeave={(e) => {

e.currentTarget.style.background = "transparent"

e.currentTarget.style.color = colors.textSecondary

}}

>

<TrashIcon size={18} />

</button>

</div>


{/* Quantity Controls */}

<div

style={{

display: "flex",

alignItems: "center",

justifyContent: "space-between",

gap: 12,

}}

>

<div style={{ display: "flex", alignItems: "center", gap: 8 }}>

<span style={{ fontSize: 13, color: colors.textSecondary }}>

{text.quantityLabel}:

</span>

<div

style={{

display: "flex",

alignItems: "center",

background: colors.background,

borderRadius: borderRadius * 0.5,

border: `1px solid ${colors.border}`,

overflow: "hidden",

}}

>

<button

onClick={() =>

onUpdateQuantity(

item.id,

Math.max(1, item.quantity - 1)

)

}

disabled={item.quantity <= 1}

style={{

width: 32,

height: 32,

display: "flex",

alignItems: "center",

justifyContent: "center",

background: "transparent",

border: "none",

color:

item.quantity <= 1

? colors.border

: colors.text,

cursor:

item.quantity <= 1

? "not-allowed"

: "pointer",

}}

>

<MinusIcon />

</button>

<span

style={{

width: 40,

textAlign: "center",

fontSize: 14,

fontWeight: 600,

color: colors.text,

}}

>

{item.quantity}

</span>

<button

onClick={() =>

onUpdateQuantity(

item.id,

Math.min(maxQuantity, item.quantity + 1)

)

}

disabled={item.quantity >= maxQuantity}

style={{

width: 32,

height: 32,

display: "flex",

alignItems: "center",

justifyContent: "center",

background: "transparent",

border: "none",

color:

item.quantity >= maxQuantity

? colors.border

: colors.text,

cursor:

item.quantity >= maxQuantity

? "not-allowed"

: "pointer",

}}

>

<PlusIcon />

</button>

</div>

</div>


{showNotes && (

<button

onClick={() => setShowNote(!showNote)}

style={{

fontSize: 13,

color: colors.primary,

background: "transparent",

border: "none",

cursor: "pointer",

textDecoration: "underline",

}}

>

{showNote ? "Hide note" : "+ Add note"}

</button>

)}

</div>


{/* Note Input */}

<AnimatePresence>

{showNotes && showNote && (

<motion.div

initial={{ height: 0, opacity: 0 }}

animate={{ height: "auto", opacity: 1 }}

exit={{ height: 0, opacity: 0 }}

style={{ overflow: "hidden" }}

>

<textarea

value={item.note || ""}

onChange={(e) =>

onUpdateNote(item.id, e.target.value)

}

placeholder={text.notePlaceholder}

style={{

width: "100%",

minHeight: 60,

padding: 10,

fontSize: 13,

border: `1px solid ${colors.border}`,

borderRadius: borderRadius * 0.5,

background: colors.background,

color: colors.text,

resize: "vertical",

outline: "none",

fontFamily: "inherit",

}}

/>

</motion.div>

)}

</AnimatePresence>

</MotionWrapper>

)

}


// ============================================================================

// Main Component

// ============================================================================


export default function RFQCart(props: Partial<Props>) {

// Component Type - determines if this is a Cart or Add to Cart Button

const componentType = props.componentType ?? "cart"


// Props with defaults

const mode = props.mode ?? "cart"


const colors = useMemo(

() => ({

primary: props.colors?.primary ?? defaultColors.primary,

secondary: props.colors?.secondary ?? defaultColors.secondary,

background: props.colors?.background ?? defaultColors.background,

surface: props.colors?.surface ?? defaultColors.surface,

text: props.colors?.text ?? defaultColors.text,

textSecondary:

props.colors?.textSecondary ?? defaultColors.textSecondary,

border: props.colors?.border ?? defaultColors.border,

success: props.colors?.success ?? defaultColors.success,

error: props.colors?.error ?? defaultColors.error,

}),

[props.colors]

)


const text = useMemo(

() => ({

...defaultText,

...props.text,

}),

[props.text]

)


// Texts group (user editable)

const texts = useMemo(

() => ({

cartTitle: props.texts?.cartTitle ?? "Quote Cart",

formTitle: props.texts?.formTitle ?? "Contact Information",

emptyMessage: props.texts?.emptyMessage ?? "Your cart is empty",

emptySubtext:

props.texts?.emptySubtext ?? "Add items to request a quote",

cartButton: props.texts?.cartButton ?? "Request Quote",

submitButton: props.texts?.submitButton ?? "Submit Quote Request",

successTitle: props.texts?.successTitle ?? "Quote Request Sent!",

successMessage:

props.texts?.successMessage ??

"We'll get back to you within 24 hours.",

continueButton: props.texts?.continueButton ?? "Continue Shopping",

}),

[props.texts]

)


// Storage & Webhook (now flat props, not in submission group)

const storageKey =

props.storageKey ?? props.submission?.storageKey ?? "rfq-cart"

const webhookUrl = props.webhookUrl ?? props.submission?.webhookUrl ?? ""

const continueShoppingUrl = props.continueShoppingUrl ?? ""


// Content group

const showPrices = props.content?.showPrices ?? true

const showImages = props.content?.showImages ?? true

const showNotes = props.content?.showNotes ?? true

const maxQuantity = props.content?.maxQuantity ?? 999


// Currency group

const currencySymbol = props.currency?.currencySymbol ?? "$"

const currencyPosition = props.currency?.currencyPosition ?? "before"


// Form group

const showForm = props.form?.showForm ?? true

const defaultFormFields: FormField[] = [

{

id: "name",

label: "Full Name",

placeholder: "Enter your name",

type: "text",

required: true,

},

{

id: "email",

label: "Email Address",

placeholder: "your@email.com",

type: "email",

required: true,

},

{

id: "phone",

label: "Phone Number",

placeholder: "+1 (555) 000-0000",

type: "tel",

required: false,

},

{

id: "company",

label: "Company Name",

placeholder: "Your company",

type: "text",

required: false,

},

{

id: "message",

label: "Additional Message",

placeholder: "Any special requirements...",

type: "textarea",

required: false,

},

]

const formFields = props.form?.customFields?.length

? props.form.customFields

: defaultFormFields


// Styling group

const borderRadius = props.styling?.borderRadius ?? 12

const showShadow = props.styling?.showShadow ?? true

const compactMode = props.styling?.compactMode ?? false

const enableAnimations = props.styling?.enableAnimations ?? true

const showScrollbar = props.styling?.showScrollbar ?? true

const scrollbarColor = props.styling?.scrollbarColor ?? "rgba(0,0,0,0.3)"

const scrollbarWidth = props.styling?.scrollbarWidth ?? 6


// Typography group - Font styles from ControlType.Font

const titleFont = useMemo(

() => ({

fontFamily: props.typography?.title?.fontFamily ?? "inherit",

fontWeight: props.typography?.title?.fontWeight ?? 600,

fontSize: props.typography?.title?.fontSize ?? 20,

lineHeight: props.typography?.title?.lineHeight ?? 1.3,

letterSpacing: props.typography?.title?.letterSpacing ?? 0,

textAlign: props.typography?.title?.textAlign ?? "left",

}),

[props.typography?.title]

)


const bodyFont = useMemo(

() => ({

fontFamily: props.typography?.body?.fontFamily ?? "inherit",

fontWeight: props.typography?.body?.fontWeight ?? 400,

fontSize: props.typography?.body?.fontSize ?? 14,

lineHeight: props.typography?.body?.lineHeight ?? 1.5,

letterSpacing: props.typography?.body?.letterSpacing ?? 0,

textAlign: props.typography?.body?.textAlign ?? "left",

}),

[props.typography?.body]

)


const buttonFont = useMemo(

() => ({

fontFamily: props.typography?.button?.fontFamily ?? "inherit",

fontWeight: props.typography?.button?.fontWeight ?? 600,

fontSize: props.typography?.button?.fontSize ?? 14,

lineHeight: props.typography?.button?.lineHeight ?? 1,

letterSpacing: props.typography?.button?.letterSpacing ?? 0,

textAlign: props.typography?.button?.textAlign ?? "center",

}),

[props.typography?.button]

)


// Scrollbar styles (CSS-in-JS for webkit browsers)

const scrollbarStyles = showScrollbar

? {

scrollbarWidth: "thin" as const,

scrollbarColor: `${scrollbarColor} transparent`,

}

: {

scrollbarWidth: "none" as const,

msOverflowStyle: "none" as const,

}


// Generate unique ID for this component instance

const scrollContainerId = React.useMemo(

() => `rfq-scroll-${Math.random().toString(36).substr(2, 9)}`,

[]

)


// Inject custom scrollbar styles

useEffect(() => {

const styleId = `scrollbar-style-${scrollContainerId}`

let styleElement = document.getElementById(styleId)


if (!styleElement) {

styleElement = document.createElement("style")

styleElement.id = styleId

document.head.appendChild(styleElement)

}


if (showScrollbar) {

styleElement.textContent = `

.${scrollContainerId}::-webkit-scrollbar {

width: ${scrollbarWidth}px;

}

.${scrollContainerId}::-webkit-scrollbar-track {

background: transparent;

}

.${scrollContainerId}::-webkit-scrollbar-thumb {

background: ${scrollbarColor};

border-radius: ${scrollbarWidth / 2}px;

}

.${scrollContainerId}::-webkit-scrollbar-thumb:hover {

background: ${scrollbarColor};

opacity: 0.8;

}

`

} else {

styleElement.textContent = `

.${scrollContainerId}::-webkit-scrollbar {

display: none;

}

`

}


return () => {

const el = document.getElementById(styleId)

if (el) el.remove()

}

}, [scrollContainerId, showScrollbar, scrollbarColor, scrollbarWidth])


// =========================================================================

// ADD TO CART BUTTON MODE

// =========================================================================


// Product Info (for addToCart type)

const productId = props.product?.productId ?? "product-1"

const productName = props.product?.productName ?? "Sample Product"

const productPrice = props.product?.productPrice ?? 99

const productImage = props.product?.productImage

const productOptions = props.product?.productOptions


// Add to Cart Button Appearance

const addToCartButtonText =

props.addToCartAppearance?.buttonText ?? "Add to Quote"

const addToCartButtonStyle =

props.addToCartAppearance?.buttonStyle ?? "primary"

const addToCartShowIcon = props.addToCartAppearance?.showIcon ?? true

const addToCartFullWidth = props.addToCartAppearance?.fullWidth ?? false


// Add to Cart Button State

const [addedToCart, setAddedToCart] = useState(false)


// =========================================================================

// CART MODE HOOKS (must be called unconditionally for React rules of hooks)

// =========================================================================


// State - Initialize with empty array, load from localStorage in useEffect to avoid hydration mismatch

const [items, setItems] = useState<CartItem[]>([])

const [isInitialized, setIsInitialized] = useState(false) // Track if initial load is done

const [view, setView] = useState<"cart" | "form" | "success" | "error">(

"cart"

)

const [isOpen, setIsOpen] = useState(mode === "cart")

const [isPopupOpen, setIsPopupOpen] = useState(false) // For header-popup mode

const [isSubmitting, setIsSubmitting] = useState(false)

const [formData, setFormData] = useState<Record<string, string>>({})

const [formErrors, setFormErrors] = useState<Record<string, string>>({})

const [customErrorMessage, setCustomErrorMessage] = useState<string | null>(

null

)


// Error code mappings

const errorCodeMappings: ErrorCodeMapping[] = props.errorCodeMappings ?? []


// Computed values

const totalItems = items.reduce((sum, item) => sum + item.quantity, 0)

const totalPrice = items.reduce(

(sum, item) => sum + item.price * item.quantity,

0

)


// Load cart from localStorage on mount (client-side only to avoid hydration mismatch)

useEffect(() => {

if (typeof window === "undefined") return

if (componentType === "addToCart") return // Skip for addToCart mode


try {

const saved = localStorage.getItem(storageKey)

if (saved) {

const parsed = JSON.parse(saved)

if (Array.isArray(parsed)) {

setItems(parsed)

}

}

} catch (e) {

console.warn("Failed to load cart from localStorage:", e)

}


// Mark as initialized after loading

setIsInitialized(true)

}, [storageKey, componentType])


// Persist cart data on page navigation (visibilitychange, beforeunload, pagehide)

useEffect(() => {

if (typeof window === "undefined") return

if (componentType === "addToCart") return // Skip for addToCart mode


const persistCart = () => {

try {

localStorage.setItem(storageKey, JSON.stringify(items))

} catch (e) {

console.warn("Failed to persist cart:", e)

}

}


// Handle page visibility change (e.g., switching tabs, minimizing)

const handleVisibilityChange = () => {

if (document.visibilityState === "hidden") {

persistCart()

}

}


// Handle before unload (page refresh, close, navigation)

const handleBeforeUnload = () => {

persistCart()

}


// Handle pagehide for Safari and mobile browsers

const handlePageHide = () => {

persistCart()

}


document.addEventListener("visibilitychange", handleVisibilityChange)

window.addEventListener("beforeunload", handleBeforeUnload)

window.addEventListener("pagehide", handlePageHide)


return () => {

document.removeEventListener(

"visibilitychange",

handleVisibilityChange

)

window.removeEventListener("beforeunload", handleBeforeUnload)

window.removeEventListener("pagehide", handlePageHide)

}

}, [items, storageKey, componentType])


// Listen for cart updates from AddToCartButton and other RFQCart instances (same-window and cross-tab)

useEffect(() => {

if (typeof window === "undefined") return

if (componentType === "addToCart") return // Skip for addToCart mode


// Handler for cross-tab storage events

const handleStorageChange = (e: StorageEvent) => {

if (e.key === storageKey && e.newValue) {

try {

const parsed = JSON.parse(e.newValue)

if (Array.isArray(parsed)) {

setItems(parsed)

}

} catch (err) {

console.warn("Failed to parse storage update:", err)

}

}

}


// Handler for same-window custom events from other components

const handleCustomCartUpdate = (e: Event) => {

const customEvent = e as CustomEvent<{

key: string

items: CartItem[]

}>

if (

customEvent.detail?.key === storageKey &&

customEvent.detail?.items

) {

// Only update if items are actually different (prevent infinite loop)

const newItems = customEvent.detail.items

setItems((currentItems) => {

// Compare by JSON string to check if actually different

if (

JSON.stringify(currentItems) !==

JSON.stringify(newItems)

) {

return newItems

}

return currentItems

})

}

}


window.addEventListener("storage", handleStorageChange)

window.addEventListener("rfq-cart-update", handleCustomCartUpdate)


return () => {

window.removeEventListener("storage", handleStorageChange)

window.removeEventListener(

"rfq-cart-update",

handleCustomCartUpdate

)

}

}, [storageKey, componentType])


// Save cart to localStorage and notify other components

useEffect(() => {

if (typeof window === "undefined") return

if (componentType === "addToCart") return // Skip for addToCart mode

// Don't save on initial render - wait until initialized to prevent overwriting with empty array

if (!isInitialized) return


try {

localStorage.setItem(storageKey, JSON.stringify(items))


// Dispatch custom event for same-window updates to other RFQCart instances

window.dispatchEvent(

new CustomEvent("rfq-cart-update", {

detail: { key: storageKey, items: items },

})

)


props.onCartUpdate?.(totalItems, totalPrice)

} catch (e) {

console.warn("Failed to save cart:", e)

}

}, [

items,

storageKey,

totalItems,

totalPrice,

isInitialized,

componentType,

])


// Cart operations

const updateQuantity = useCallback((id: string, quantity: number) => {

setItems((prev) =>

prev.map((item) => (item.id === id ? { ...item, quantity } : item))

)

}, [])


const updateNote = useCallback((id: string, note: string) => {

setItems((prev) =>

prev.map((item) => (item.id === id ? { ...item, note } : item))

)

}, [])


const removeItem = useCallback((id: string) => {

setItems((prev) => prev.filter((item) => item.id !== id))

}, [])


const clearCart = useCallback(() => {

setItems([])

}, [])


// Form validation (skip if showForm is false)

const validateForm = useCallback((): boolean => {

if (!showForm) return true // Skip validation when form is hidden


const newErrors: Record<string, string> = {}


formFields.forEach((field) => {

const value = formData[field.id]?.trim() ?? ""


if (field.required && !value) {

newErrors[field.id] = "Required"

} else if (

field.type === "email" &&

value &&

!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)

) {

newErrors[field.id] = "Invalid email"

}

})


setFormErrors(newErrors)

return Object.keys(newErrors).length === 0

}, [showForm, formFields, formData])


// Submit quote request

const handleSubmit = useCallback(async () => {

if (!validateForm()) return


setIsSubmitting(true)


const requestId = generateRequestId()

const payload = {

requestId,

timestamp: new Date().toISOString(),

customer: showForm ? formData : null, // Only include form data if form is shown

items: items.map((item) => ({

id: item.id,

name: item.name,

price: item.price,

quantity: item.quantity,

options: item.options,

note: item.note,

subtotal: item.price * item.quantity,

})),

summary: {

totalItems,

totalPrice,

currency: currencySymbol,

},

}


try {

if (webhookUrl) {

const response = await fetch(webhookUrl, {

method: "POST",

headers: { "Content-Type": "application/json" },

body: JSON.stringify(payload),

})


if (!response.ok) {

// Try to parse error response for error code

let errorCode: string | null = null

let serverMessage: string | null = null


try {

const errorData = await response.json()

errorCode =

errorData.errorCode ||

errorData.error_code ||

errorData.code ||

null

serverMessage =

errorData.message || errorData.error || null

} catch {

// Response body is not JSON, ignore

}


// Check if error code has a mapping

if (errorCode && errorCodeMappings.length > 0) {

const mapping = errorCodeMappings.find(

(m) => m.code === errorCode

)

if (mapping) {

setCustomErrorMessage(mapping.message)

throw new Error(mapping.message)

}

}


// Use server message if available, otherwise default

const errorMsg =

serverMessage ||

`Error ${response.status}: Submission failed`

setCustomErrorMessage(null) // Use default error message

throw new Error(errorMsg)

}

}


// Success

setCustomErrorMessage(null)

setView("success")

clearCart()

setFormData({})

props.onSubmitSuccess?.()

} catch (error) {

setView("error")

props.onSubmitError?.(

error instanceof Error ? error.message : "Unknown error"

)

} finally {

setIsSubmitting(false)

}

}, [

validateForm,

showForm,

formData,

items,

totalItems,

totalPrice,

currencySymbol,

webhookUrl,

clearCart,

errorCodeMappings,

])


// =========================================================================

// ADD TO CART BUTTON MODE LOGIC

// =========================================================================


// Add to Cart Handler

const handleAddToCart = useCallback(() => {

if (typeof window === "undefined") return


try {

const saved = localStorage.getItem(storageKey)

const cartItems: CartItem[] = saved ? JSON.parse(saved) : []


const existingIndex = cartItems.findIndex(

(item) => item.id === productId

)


if (existingIndex >= 0) {

cartItems[existingIndex].quantity += 1

} else {

cartItems.push({

id: productId,

name: productName,

price: productPrice,

quantity: 1,

imageUrl: productImage,

options: productOptions,

})

}


localStorage.setItem(storageKey, JSON.stringify(cartItems))


// Dispatch custom event for same-window updates

window.dispatchEvent(

new CustomEvent("rfq-cart-update", {

detail: { key: storageKey, items: cartItems },

})

)


// Also dispatch StorageEvent for cross-tab updates

window.dispatchEvent(

new StorageEvent("storage", {

key: storageKey,

newValue: JSON.stringify(cartItems),

})

)


setAddedToCart(true)

setTimeout(() => setAddedToCart(false), 2000)


props.onAddToCart?.(productId, 1)

} catch (e) {

console.warn("Failed to add to cart:", e)

}

}, [

storageKey,

productId,

productName,

productPrice,

productImage,

productOptions,

])


// Get Add to Cart Button Styles

const getAddToCartButtonStyles = () => {

switch (addToCartButtonStyle) {

case "secondary":

return {

background: colors.secondary,

color: "#FFFFFF",

border: "none",

}

case "outline":

return {

background: "transparent",

color: colors.primary,

border: `2px solid ${colors.primary}`,

}

default:

return {

background: `linear-gradient(135deg, ${colors.primary}, ${colors.secondary})`,

color: "#FFFFFF",

border: "none",

}

}

}


// Render Add to Cart Button if componentType is "addToCart"

if (componentType === "addToCart") {

return (

<motion.button

onClick={handleAddToCart}

whileHover={enableAnimations ? { scale: 1.02 } : undefined}

whileTap={enableAnimations ? { scale: 0.98 } : undefined}

style={{

display: "flex",

alignItems: "center",

justifyContent: "center",

gap: 8,

width: addToCartFullWidth ? "100%" : "auto",

padding: "12px 20px",

...getAddToCartButtonStyles(),

borderRadius: borderRadius,

fontSize: 14,

fontWeight: 600,

cursor: "pointer",

boxShadow: showShadow

? `0 4px 14px ${colors.primary}30`

: "none",

transition: "all 0.2s ease",

}}

>

{addToCartShowIcon && !addedToCart && (

<svg

width={18}

height={18}

viewBox="0 0 24 24"

fill="none"

stroke="currentColor"

strokeWidth={2}

strokeLinecap="round"

strokeLinejoin="round"

>

<circle cx="9" cy="21" r="1" />

<circle cx="20" cy="21" r="1" />

<path d="M1 1h4l2.68 13.39a2 2 0 0 0 2 1.61h9.72a2 2 0 0 0 2-1.61L23 6H6" />

</svg>

)}

{addedToCart && (

<motion.div

initial={{ scale: 0 }}

animate={{ scale: 1 }}

transition={{

type: "spring",

stiffness: 500,

damping: 30,

}}

>

<CheckIcon size={18} />

</motion.div>

)}

<span>{addedToCart ? "Added!" : addToCartButtonText}</span>

</motion.button>

)

}


// Render header mode (icon + badge only, for navigation bars)

if (mode === "header") {

return (

<motion.button

onClick={() => setIsOpen(true)}

whileHover={enableAnimations ? { scale: 1.1 } : undefined}

whileTap={enableAnimations ? { scale: 0.9 } : undefined}

style={{

position: "relative",

display: "flex",

alignItems: "center",

justifyContent: "center",

width: 44,

height: 44,

background: "transparent",

color: colors.text,

border: "none",

borderRadius: borderRadius,

cursor: "pointer",

padding: 0,

}}

>

<CartIcon size={24} />

{totalItems > 0 && (

<motion.span

initial={enableAnimations ? { scale: 0 } : undefined}

animate={enableAnimations ? { scale: 1 } : undefined}

key={totalItems}

style={{

position: "absolute",

top: 2,

right: 2,

minWidth: 18,

height: 18,

display: "flex",

alignItems: "center",

justifyContent: "center",

background: colors.primary,

borderRadius: 9,

fontSize: 11,

fontWeight: 700,

color: "#FFFFFF",

padding: "0 4px",

}}

>

{totalItems > 99 ? "99+" : totalItems}

</motion.span>

)}

</motion.button>

)

}


// Render header-popup mode (header icon + popup overlay with full cart)

if (mode === "header-popup") {

return (

<>

{/* Header Icon Button */}

<motion.button

onClick={() => setIsPopupOpen(true)}

whileHover={enableAnimations ? { scale: 1.1 } : undefined}

whileTap={enableAnimations ? { scale: 0.9 } : undefined}

style={{

position: "relative",

display: "flex",

alignItems: "center",

justifyContent: "center",

width: 44,

height: 44,

background: "transparent",

color: colors.text,

border: "none",

borderRadius: borderRadius,

cursor: "pointer",

padding: 0,

}}

>

<CartIcon size={24} />

{totalItems > 0 && (

<motion.span

initial={

enableAnimations ? { scale: 0 } : undefined

}

animate={

enableAnimations ? { scale: 1 } : undefined

}

key={totalItems}

style={{

position: "absolute",

top: 2,

right: 2,

minWidth: 18,

height: 18,

display: "flex",

alignItems: "center",

justifyContent: "center",

background: colors.primary,

borderRadius: 9,

fontSize: 11,

fontWeight: 700,

color: "#FFFFFF",

padding: "0 4px",

}}

>

{totalItems > 99 ? "99+" : totalItems}

</motion.span>

)}

</motion.button>


{/* Popup Overlay */}

<AnimatePresence>

{isPopupOpen && (

<>

{/* Backdrop */}

<motion.div

initial={{ opacity: 0 }}

animate={{ opacity: 1 }}

exit={{ opacity: 0 }}

onClick={() => setIsPopupOpen(false)}

style={{

position: "fixed",

top: 0,

left: 0,

right: 0,

bottom: 0,

background: "rgba(0, 0, 0, 0.5)",

zIndex: 9998,

}}

/>

{/* Popup Cart Panel */}

<motion.div

initial={{ opacity: 0, x: 300 }}

animate={{ opacity: 1, x: 0 }}

exit={{ opacity: 0, x: 300 }}

transition={{

type: "spring",

damping: 25,

stiffness: 300,

}}

style={{

position: "fixed",

top: 0,

right: 0,

width: "min(420px, 90vw)",

height: "100vh",

background: colors.background,

boxShadow:

"-4px 0 20px rgba(0, 0, 0, 0.15)",

zIndex: 9999,

display: "flex",

flexDirection: "column",

overflow: "hidden",

}}

>

{/* Popup Header */}

<div

style={{

display: "flex",

alignItems: "center",

justifyContent: "space-between",

padding: "16px 20px",

borderBottom: `1px solid ${colors.border}`,

}}

>

<h2

style={{

margin: 0,

fontSize: titleFont.fontSize || 18,

fontWeight: titleFont.fontWeight || 700,

fontFamily: titleFont.fontFamily,

lineHeight: titleFont.lineHeight,

letterSpacing: titleFont.letterSpacing,

color: colors.text,

}}

>

{view === "form"

? texts.formTitle

: texts.cartTitle}

</h2>

<button

onClick={() => setIsPopupOpen(false)}

style={{

width: 32,

height: 32,

display: "flex",

alignItems: "center",

justifyContent: "center",

background: "transparent",

border: "none",

borderRadius: borderRadius * 0.5,

color: colors.textSecondary,

cursor: "pointer",

}}

>

<CloseIcon size={20} />

</button>

</div>


{/* Popup Content - Scrollable */}

<div

className={scrollContainerId}

style={{

flex: 1,

overflowY: "auto",

padding: 0,

...scrollbarStyles,

}}

>

{/* Empty State */}

{view === "cart" && items.length === 0 && (

<motion.div

initial={

enableAnimations

? { opacity: 0 }

: undefined

}

animate={

enableAnimations

? { opacity: 1 }

: undefined

}

style={{

display: "flex",

flexDirection: "column",

alignItems: "center",

justifyContent: "center",

padding: 40,

textAlign: "center",

}}

>

<div

style={{

width: 64,

height: 64,

display: "flex",

alignItems: "center",

justifyContent: "center",

background: colors.surface,

borderRadius: "50%",

marginBottom: 16,

color: colors.textSecondary,

}}

>

<CartIcon size={28} />

</div>

<h3

style={{

margin: 0,

marginBottom: 8,

fontSize: titleFont.fontSize || 18,

fontWeight: titleFont.fontWeight || 600,

fontFamily: titleFont.fontFamily,

lineHeight: titleFont.lineHeight,

letterSpacing: titleFont.letterSpacing,

color: colors.text,

}}

>

{texts.emptyMessage}

</h3>

<p

style={{

margin: 0,

fontSize: bodyFont.fontSize || 14,

fontFamily: bodyFont.fontFamily,

fontWeight: bodyFont.fontWeight,

lineHeight: bodyFont.lineHeight,

letterSpacing: bodyFont.letterSpacing,

color: colors.textSecondary,

}}

>

{texts.emptySubtext}

</p>

</motion.div>

)}


{/* Cart Items */}

{view === "cart" && items.length > 0 && (

<div

style={{

padding: 16,

display: "flex",

flexDirection: "column",

gap: 12,

}}

>

<AnimatePresence>

{items.map((item) => (

<CartItemCard

key={item.id}

layoutId={item.id}

item={item}

colors={colors}

text={text}

showPrices={showPrices}

showImages={showImages}

showNotes={showNotes}

maxQuantity={

maxQuantity

}

currencySymbol={

currencySymbol

}

currencyPosition={

currencyPosition

}

borderRadius={

borderRadius

}

enableAnimations={

enableAnimations

}

onUpdateQuantity={

updateQuantity

}

onUpdateNote={

updateNote

}

onRemove={removeItem}

/>

))}

</AnimatePresence>

</div>

)}


{/* Quote Form */}

{view === "form" && (

<motion.div

initial={

enableAnimations

? { opacity: 0, x: 20 }

: undefined

}

animate={

enableAnimations

? { opacity: 1, x: 0 }

: undefined

}

style={{

padding: 20,

display: "flex",

flexDirection: "column",

gap: 16,

}}

>

{formFields.map((field) => (

<div key={field.id}>

<label

style={{

display: "block",

marginBottom: 6,

fontSize: 13,

fontWeight: 600,

fontFamily: bodyFont.fontFamily,

letterSpacing: bodyFont.letterSpacing,

color: colors.text,

}}

>

{field.label}{" "}

{field.required && (

<span

style={{

color: colors.error,

}}

>

*

</span>

)}

</label>

{field.type ===

"textarea" ? (

<textarea

value={

formData[

field.id

] || ""

}

onChange={(e) =>

setFormData({

...formData,

[field.id]:

e.target

.value,

})

}

placeholder={

field.placeholder

}

rows={4}

style={{

width: "100%",

padding:

"10px 12px",

fontSize: bodyFont.fontSize || 14,

fontFamily: bodyFont.fontFamily || "inherit",

fontWeight: bodyFont.fontWeight,

letterSpacing: bodyFont.letterSpacing,

border: `1px solid ${formErrors[field.id] ? colors.error : colors.border}`,

borderRadius:

borderRadius *

0.6,

background:

colors.background,

color: colors.text,

outline: "none",

resize: "vertical",

}}

/>

) : (

<input

type={

field.type ||

"text"

}

value={

formData[

field.id

] || ""

}

onChange={(e) =>

setFormData({

...formData,

[field.id]:

e.target

.value,

})

}

placeholder={

field.placeholder

}

style={{

width: "100%",

padding:

"10px 12px",

fontSize: bodyFont.fontSize || 14,

fontFamily: bodyFont.fontFamily || "inherit",

fontWeight: bodyFont.fontWeight,

letterSpacing: bodyFont.letterSpacing,

border: `1px solid ${formErrors[field.id] ? colors.error : colors.border}`,

borderRadius:

borderRadius *

0.6,

background:

colors.background,

color: colors.text,

outline: "none",

}}

/>

)}

{formErrors[field.id] && (

<span

style={{

fontSize: 12,

color: colors.error,

marginTop: 4,

display:

"block",

}}

>

{

formErrors[

field.id

]

}

</span>

)}

</div>

))}

</motion.div>

)}


{/* Success State */}

{view === "success" && (

<motion.div

initial={

enableAnimations

? { opacity: 0, scale: 0.9 }

: undefined

}

animate={

enableAnimations

? { opacity: 1, scale: 1 }

: undefined

}

style={{

display: "flex",

flexDirection: "column",

alignItems: "center",

justifyContent: "center",

padding: 40,

textAlign: "center",

}}

>

<motion.div

initial={

enableAnimations

? { scale: 0 }

: undefined

}

animate={

enableAnimations

? { scale: 1 }

: undefined

}

transition={{

delay: 0.2,

type: "spring",

}}

style={{

width: 80,

height: 80,

display: "flex",

alignItems: "center",

justifyContent: "center",

background: `${colors.success}15`,

borderRadius: "50%",

marginBottom: 20,

color: colors.success,

}}

>

<CheckIcon size={40} />

</motion.div>

<h3

style={{

margin: 0,

marginBottom: 8,

fontSize: titleFont.fontSize || 20,

fontWeight: titleFont.fontWeight || 700,

fontFamily: titleFont.fontFamily,

lineHeight: titleFont.lineHeight,

letterSpacing: titleFont.letterSpacing,

color: colors.text,

}}

>

{texts.successTitle}

</h3>

<p

style={{

margin: 0,

marginBottom: 24,

fontSize: bodyFont.fontSize || 14,

fontFamily: bodyFont.fontFamily,

fontWeight: bodyFont.fontWeight,

lineHeight: bodyFont.lineHeight,

letterSpacing: bodyFont.letterSpacing,

color: colors.textSecondary,

}}

>

{texts.successMessage}

</p>

<button

onClick={() => {

if (continueShoppingUrl) {

window.location.href =

continueShoppingUrl

} else {

setView("cart")

setIsPopupOpen(false)

}

}}

style={{

padding: "10px 24px",

background: colors.primary,

color: "#FFFFFF",

border: "none",

borderRadius:

borderRadius * 0.6,

fontSize: buttonFont.fontSize || 14,

fontWeight: buttonFont.fontWeight || 600,

fontFamily: buttonFont.fontFamily,

letterSpacing: buttonFont.letterSpacing,

cursor: "pointer",

}}

>

{texts.continueButton}

</button>

</motion.div>

)}


{/* Error State */}

{view === "error" && (

<motion.div

initial={

enableAnimations

? { opacity: 0, scale: 0.9 }

: undefined

}

animate={

enableAnimations

? { opacity: 1, scale: 1 }

: undefined

}

style={{

display: "flex",

flexDirection: "column",

alignItems: "center",

justifyContent: "center",

padding: 40,

textAlign: "center",

}}

>

<motion.div

initial={

enableAnimations

? { scale: 0 }

: undefined

}

animate={

enableAnimations

? { scale: 1 }

: undefined

}

transition={{

delay: 0.2,

type: "spring",

}}

style={{

width: 80,

height: 80,

display: "flex",

alignItems: "center",

justifyContent: "center",

background: `${colors.error}15`,

borderRadius: "50%",

marginBottom: 20,

color: colors.error,

}}

>

<AlertIcon size={40} />

</motion.div>

<h3

style={{

margin: 0,

marginBottom: 8,

fontSize: titleFont.fontSize || 20,

fontWeight: titleFont.fontWeight || 700,

fontFamily: titleFont.fontFamily,

lineHeight: titleFont.lineHeight,

letterSpacing: titleFont.letterSpacing,

color: colors.text,

}}

>

{text.errorTitle}

</h3>

<p

style={{

margin: 0,

marginBottom: 24,

fontSize: bodyFont.fontSize || 14,

fontFamily: bodyFont.fontFamily,

fontWeight: bodyFont.fontWeight,

lineHeight: bodyFont.lineHeight,

letterSpacing: bodyFont.letterSpacing,

color: colors.textSecondary,

}}

>

{customErrorMessage ||

text.errorMessage}

</p>

<button

onClick={() =>

setView(

showForm

? "form"

: "cart"

)

}

style={{

padding: "10px 24px",

background: colors.primary,

color: "#FFFFFF",

border: "none",

borderRadius:

borderRadius * 0.6,

fontSize: buttonFont.fontSize || 14,

fontWeight: buttonFont.fontWeight || 600,

fontFamily: buttonFont.fontFamily,

letterSpacing: buttonFont.letterSpacing,

cursor: "pointer",

}}

>

{text.backToCartButton}

</button>

</motion.div>

)}

</div>


{/* Popup Footer */}

{view === "cart" && items.length > 0 && (

<div

style={{

padding: 16,

borderTop: `1px solid ${colors.border}`,

background: colors.surface,

}}

>

{/* Summary */}

{showPrices && (

<div

style={{

display: "flex",

justifyContent:

"space-between",

alignItems: "center",

marginBottom: 12,

}}

>

<span

style={{

fontSize: bodyFont.fontSize || 14,

fontFamily: bodyFont.fontFamily,

fontWeight: bodyFont.fontWeight,

letterSpacing: bodyFont.letterSpacing,

color: colors.textSecondary,

}}

>

{text.subtotalLabel} (

{totalItems}{" "}

{text.totalItemsLabel})

</span>

<span

style={{

fontSize: titleFont.fontSize || 18,

fontWeight: titleFont.fontWeight || 700,

fontFamily: titleFont.fontFamily,

letterSpacing: titleFont.letterSpacing,

color: colors.text,

}}

>

{formatPrice(

totalPrice,

currencySymbol,

currencyPosition

)}

</span>

</div>

)}

{/* Action Button */}

<motion.button

onClick={() =>

showForm

? setView("form")

: handleSubmit()

}

disabled={!showForm && isSubmitting}

whileHover={

enableAnimations &&

!(!showForm && isSubmitting)

? { scale: 1.02 }

: undefined

}

whileTap={

enableAnimations &&

!(!showForm && isSubmitting)

? { scale: 0.98 }

: undefined

}

style={{

width: "100%",

padding: "14px 24px",

background:

!showForm && isSubmitting

? colors.textSecondary

: `linear-gradient(135deg, ${colors.primary}, ${colors.secondary})`,

color: "#FFFFFF",

border: "none",

borderRadius:

borderRadius * 0.6,

fontSize: buttonFont.fontSize || 15,

fontWeight: buttonFont.fontWeight || 600,

fontFamily: buttonFont.fontFamily,

letterSpacing: buttonFont.letterSpacing,

cursor:

!showForm && isSubmitting

? "not-allowed"

: "pointer",

boxShadow:

showShadow &&

!(!showForm && isSubmitting)

? `0 4px 14px ${colors.primary}40`

: "none",

}}

>

{!showForm && isSubmitting

? text.submittingButton

: showForm

? texts.cartButton

: texts.submitButton}

</motion.button>

</div>

)}


{view === "form" && (

<div

style={{

padding: 16,

borderTop: `1px solid ${colors.border}`,

background: colors.surface,

display: "flex",

gap: 12,

}}

>

<button

onClick={() => setView("cart")}

style={{

flex: 1,

padding: "12px 16px",

background: colors.surface,

color: colors.text,

border: `1px solid ${colors.border}`,

borderRadius:

borderRadius * 0.6,

fontSize: buttonFont.fontSize || 14,

fontWeight: buttonFont.fontWeight || 600,

fontFamily: buttonFont.fontFamily,

letterSpacing: buttonFont.letterSpacing,

cursor: "pointer",

}}

>

{text.backToCartButton}

</button>

<motion.button

onClick={handleSubmit}

disabled={isSubmitting}

whileHover={

enableAnimations &&

!isSubmitting

? { scale: 1.02 }

: undefined

}

whileTap={

enableAnimations &&

!isSubmitting

? { scale: 0.98 }

: undefined

}

style={{

flex: 2,

padding: "12px 16px",

background: isSubmitting

? colors.textSecondary

: `linear-gradient(135deg, ${colors.primary}, ${colors.secondary})`,

color: "#FFFFFF",

border: "none",

borderRadius:

borderRadius * 0.6,

fontSize: buttonFont.fontSize || 14,

fontWeight: buttonFont.fontWeight || 600,

fontFamily: buttonFont.fontFamily,

letterSpacing: buttonFont.letterSpacing,

cursor: isSubmitting

? "not-allowed"

: "pointer",

}}

>

{isSubmitting

? text.submittingButton

: texts.submitButton}

</motion.button>

</div>

)}

</motion.div>

</>

)}

</AnimatePresence>

</>

)

}


// Render button mode

if (mode === "button") {

return (

<motion.button

onClick={() => setIsOpen(true)}

whileHover={enableAnimations ? { scale: 1.05 } : undefined}

whileTap={enableAnimations ? { scale: 0.95 } : undefined}

style={{

position: "relative",

display: "flex",

alignItems: "center",

justifyContent: "center",

gap: 8,

padding: "12px 20px",

background: `linear-gradient(135deg, ${colors.primary}, ${colors.secondary})`,

color: "#FFFFFF",

border: "none",

borderRadius: borderRadius,

fontSize: buttonFont.fontSize || 15,

fontWeight: buttonFont.fontWeight || 600,

fontFamily: buttonFont.fontFamily,

letterSpacing: buttonFont.letterSpacing,

cursor: "pointer",

boxShadow: showShadow

? `0 4px 14px ${colors.primary}40`

: "none",

}}

>

<CartIcon size={20} />

<span>{texts.cartTitle}</span>

{totalItems > 0 && (

<motion.span

initial={enableAnimations ? { scale: 0 } : undefined}

animate={enableAnimations ? { scale: 1 } : undefined}

style={{

position: "absolute",

top: -8,

right: -8,

minWidth: 22,

height: 22,

display: "flex",

alignItems: "center",

justifyContent: "center",

background: colors.error,

borderRadius: 11,

fontSize: 12,

fontWeight: 700,

padding: "0 6px",

}}

>

{totalItems}

</motion.span>

)}

</motion.button>

)

}


// Render mini mode

if (mode === "mini") {

return (

<div

style={{

display: "flex",

alignItems: "center",

gap: 12,

padding: compactMode ? "8px 12px" : "12px 16px",

background: colors.surface,

borderRadius: borderRadius,

border: `1px solid ${colors.border}`,

}}

>

<div style={{ color: colors.primary }}>

<CartIcon size={compactMode ? 20 : 24} />

</div>

<div style={{ flex: 1 }}>

<div

style={{

fontSize: compactMode ? 13 : (bodyFont.fontSize || 14),

fontWeight: bodyFont.fontWeight || 600,

fontFamily: bodyFont.fontFamily,

letterSpacing: bodyFont.letterSpacing,

color: colors.text,

}}

>

{totalItems} {text.totalItemsLabel}

</div>

{showPrices && (

<div

style={{

fontSize: compactMode ? 12 : 13,

fontFamily: bodyFont.fontFamily,

letterSpacing: bodyFont.letterSpacing,

color: colors.textSecondary,

}}

>

{formatPrice(

totalPrice,

currencySymbol,

currencyPosition

)}

</div>

)}

</div>

<motion.button

whileHover={enableAnimations ? { scale: 1.02 } : undefined}

whileTap={enableAnimations ? { scale: 0.98 } : undefined}

style={{

padding: compactMode ? "6px 12px" : "8px 16px",

background: colors.primary,

color: "#FFFFFF",

border: "none",

borderRadius: borderRadius * 0.6,

fontSize: compactMode ? 12 : (buttonFont.fontSize || 13),

fontWeight: buttonFont.fontWeight || 600,

fontFamily: buttonFont.fontFamily,

letterSpacing: buttonFont.letterSpacing,

cursor: "pointer",

}}

>

{texts.cartButton}

</motion.button>

</div>

)

}


// Full cart view

return (

<div

style={{

width: "100%",

height: "100%",

minHeight: 400,

background: colors.background,

borderRadius: borderRadius,

border: `1px solid ${colors.border}`,

boxShadow: showShadow ? "0 4px 24px rgba(0,0,0,0.08)" : "none",

overflow: "hidden",

display: "flex",

flexDirection: "column",

}}

>

{/* Header */}

<div

style={{

display: "flex",

alignItems: "center",

justifyContent: "space-between",

padding: compactMode ? "12px 16px" : "16px 20px",

borderBottom: `1px solid ${colors.border}`,

background: colors.surface,

}}

>

<div style={{ display: "flex", alignItems: "center", gap: 10 }}>

{view !== "cart" && (

<button

onClick={() => setView("cart")}

style={{

display: "flex",

alignItems: "center",

justifyContent: "center",

width: 32,

height: 32,

background: "transparent",

border: "none",

borderRadius: borderRadius * 0.5,

color: colors.textSecondary,

cursor: "pointer",

}}

>

<ArrowLeftIcon />

</button>

)}

<div style={{ color: colors.primary }}>

<CartIcon size={24} />

</div>

<h2

style={{

margin: 0,

fontSize: compactMode ? 16 : (titleFont.fontSize || 18),

fontWeight: titleFont.fontWeight || 700,

fontFamily: titleFont.fontFamily,

lineHeight: titleFont.lineHeight,

letterSpacing: titleFont.letterSpacing,

color: colors.text,

}}

>

{view === "form" ? texts.formTitle : texts.cartTitle}

</h2>

</div>


{view === "cart" && items.length > 0 && (

<button

onClick={clearCart}

style={{

fontSize: 13,

color: colors.error,

background: "transparent",

border: "none",

cursor: "pointer",

}}

>

{text.clearCartButton}

</button>

)}

</div>


{/* Content */}

<div

className={scrollContainerId}

style={{ flex: 1, overflow: "auto", ...scrollbarStyles }}

>

<AnimatePresence mode="wait">

{/* Empty State */}

{view === "cart" && items.length === 0 && (

<motion.div

key="empty"

initial={

enableAnimations ? { opacity: 0 } : undefined

}

animate={

enableAnimations ? { opacity: 1 } : undefined

}

exit={enableAnimations ? { opacity: 0 } : undefined}

style={{

display: "flex",

flexDirection: "column",

alignItems: "center",

justifyContent: "center",

padding: 40,

textAlign: "center",

}}

>

<div

style={{

width: 80,

height: 80,

display: "flex",

alignItems: "center",

justifyContent: "center",

background: colors.surface,

borderRadius: "50%",

marginBottom: 16,

color: colors.textSecondary,

}}

>

<CartIcon size={36} />

</div>

<h3

style={{

margin: 0,

marginBottom: 8,

fontSize: titleFont.fontSize || 18,

fontWeight: titleFont.fontWeight || 600,

fontFamily: titleFont.fontFamily,

lineHeight: titleFont.lineHeight,

letterSpacing: titleFont.letterSpacing,

color: colors.text,

}}

>

{texts.emptyMessage}

</h3>

<p

style={{

margin: 0,

fontSize: bodyFont.fontSize || 14,

fontFamily: bodyFont.fontFamily,

fontWeight: bodyFont.fontWeight,

lineHeight: bodyFont.lineHeight,

letterSpacing: bodyFont.letterSpacing,

color: colors.textSecondary,

}}

>

{texts.emptySubtext}

</p>

</motion.div>

)}


{/* Cart Items */}

{view === "cart" && items.length > 0 && (

<motion.div

key="items"

initial={

enableAnimations ? { opacity: 0 } : undefined

}

animate={

enableAnimations ? { opacity: 1 } : undefined

}

style={{

display: "flex",

flexDirection: "column",

gap: 12,

padding: compactMode ? 12 : 16,

}}

>

<AnimatePresence>

{items.map((item) => (

<CartItemCard

key={item.id}

layoutId={item.id}

item={item}

colors={colors}

text={text}

showPrices={showPrices}

showImages={showImages}

showNotes={showNotes}

maxQuantity={maxQuantity}

currencySymbol={currencySymbol}

currencyPosition={currencyPosition}

borderRadius={borderRadius}

enableAnimations={enableAnimations}

onUpdateQuantity={updateQuantity}

onUpdateNote={updateNote}

onRemove={removeItem}

/>

))}

</AnimatePresence>

</motion.div>

)}


{/* Quote Form */}

{view === "form" && (

<motion.div

key="form"

initial={

enableAnimations

? { opacity: 0, x: 20 }

: undefined

}

animate={

enableAnimations

? { opacity: 1, x: 0 }

: undefined

}

style={{

padding: compactMode ? 16 : 20,

display: "flex",

flexDirection: "column",

gap: 16,

}}

>

{/* Dynamic Form Fields */}

{formFields.map((field) => (

<div key={field.id}>

<label

style={{

display: "block",

marginBottom: 6,

fontSize: 13,

fontWeight: 600,

fontFamily: bodyFont.fontFamily,

letterSpacing: bodyFont.letterSpacing,

color: colors.text,

}}

>

{field.label}{" "}

{field.required && (

<span

style={{ color: colors.error }}

>

*

</span>

)}

</label>

{field.type === "textarea" ? (

<textarea

value={formData[field.id] || ""}

onChange={(e) =>

setFormData({

...formData,

[field.id]: e.target.value,

})

}

placeholder={field.placeholder}

rows={4}

style={{

width: "100%",

padding: "10px 12px",

fontSize: bodyFont.fontSize || 14,

fontFamily: bodyFont.fontFamily || "inherit",

fontWeight: bodyFont.fontWeight,

letterSpacing: bodyFont.letterSpacing,

border: `1px solid ${

formErrors[field.id]

? colors.error

: colors.border

}`,

borderRadius:

borderRadius * 0.6,

background: colors.background,

color: colors.text,

outline: "none",

resize: "vertical",

}}

/>

) : (

<input

type={field.type || "text"}

value={formData[field.id] || ""}

onChange={(e) =>

setFormData({

...formData,

[field.id]: e.target.value,

})

}

placeholder={field.placeholder}

style={{

width: "100%",

padding: "10px 12px",

fontSize: bodyFont.fontSize || 14,

fontFamily: bodyFont.fontFamily || "inherit",

fontWeight: bodyFont.fontWeight,

letterSpacing: bodyFont.letterSpacing,

border: `1px solid ${

formErrors[field.id]

? colors.error

: colors.border

}`,

borderRadius:

borderRadius * 0.6,

background: colors.background,

color: colors.text,

outline: "none",

}}

/>

)}

{formErrors[field.id] && (

<span

style={{

fontSize: 12,

color: colors.error,

marginTop: 4,

display: "block",

}}

>

{formErrors[field.id]}

</span>

)}

</div>

))}

</motion.div>

)}


{/* Success State */}

{view === "success" && (

<motion.div

key="success"

initial={

enableAnimations

? { opacity: 0, scale: 0.9 }

: undefined

}

animate={

enableAnimations

? { opacity: 1, scale: 1 }

: undefined

}

style={{

display: "flex",

flexDirection: "column",

alignItems: "center",

justifyContent: "center",

padding: 40,

textAlign: "center",

}}

>

<motion.div

initial={

enableAnimations ? { scale: 0 } : undefined

}

animate={

enableAnimations ? { scale: 1 } : undefined

}

transition={{ delay: 0.2, type: "spring" }}

style={{

width: 80,

height: 80,

display: "flex",

alignItems: "center",

justifyContent: "center",

background: `${colors.success}15`,

borderRadius: "50%",

marginBottom: 20,

color: colors.success,

}}

>

<CheckIcon size={40} />

</motion.div>

<h3

style={{

margin: 0,

marginBottom: 8,

fontSize: titleFont.fontSize || 20,

fontWeight: titleFont.fontWeight || 700,

fontFamily: titleFont.fontFamily,

lineHeight: titleFont.lineHeight,

letterSpacing: titleFont.letterSpacing,

color: colors.text,

}}

>

{texts.successTitle}

</h3>

<p

style={{

margin: 0,

marginBottom: 24,

fontSize: bodyFont.fontSize || 14,

fontFamily: bodyFont.fontFamily,

fontWeight: bodyFont.fontWeight,

lineHeight: bodyFont.lineHeight,

letterSpacing: bodyFont.letterSpacing,

color: colors.textSecondary,

}}

>

{texts.successMessage}

</p>

<button

onClick={() => {

if (continueShoppingUrl) {

window.location.href =

continueShoppingUrl

} else {

setView("cart")

}

}}

style={{

padding: "10px 24px",

background: colors.primary,

color: "#FFFFFF",

border: "none",

borderRadius: borderRadius * 0.6,

fontSize: buttonFont.fontSize || 14,

fontWeight: buttonFont.fontWeight || 600,

fontFamily: buttonFont.fontFamily,

letterSpacing: buttonFont.letterSpacing,

cursor: "pointer",

}}

>

{texts.continueButton}

</button>

</motion.div>

)}


{/* Error State */}

{view === "error" && (

<motion.div

key="error"

initial={

enableAnimations ? { opacity: 0 } : undefined

}

animate={

enableAnimations ? { opacity: 1 } : undefined

}

style={{

display: "flex",

flexDirection: "column",

alignItems: "center",

justifyContent: "center",

padding: 40,

textAlign: "center",

}}

>

<div

style={{

width: 80,

height: 80,

display: "flex",

alignItems: "center",

justifyContent: "center",

background: `${colors.error}15`,

borderRadius: "50%",

marginBottom: 20,

color: colors.error,

}}

>

<AlertIcon size={40} />

</div>

<h3

style={{

margin: 0,

marginBottom: 8,

fontSize: titleFont.fontSize || 20,

fontWeight: titleFont.fontWeight || 700,

fontFamily: titleFont.fontFamily,

lineHeight: titleFont.lineHeight,

letterSpacing: titleFont.letterSpacing,

color: colors.text,

}}

>

{text.errorTitle}

</h3>

<p

style={{

margin: 0,

marginBottom: 24,

fontSize: bodyFont.fontSize || 14,

fontFamily: bodyFont.fontFamily,

fontWeight: bodyFont.fontWeight,

lineHeight: bodyFont.lineHeight,

letterSpacing: bodyFont.letterSpacing,

color: colors.textSecondary,

}}

>

{customErrorMessage || text.errorMessage}

</p>

<button

onClick={() =>

setView(showForm ? "form" : "cart")

}

style={{

padding: "10px 24px",

background: colors.primary,

color: "#FFFFFF",

border: "none",

borderRadius: borderRadius * 0.6,

fontSize: buttonFont.fontSize || 14,

fontWeight: buttonFont.fontWeight || 600,

fontFamily: buttonFont.fontFamily,

letterSpacing: buttonFont.letterSpacing,

cursor: "pointer",

}}

>

{text.backToCartButton}

</button>

</motion.div>

)}

</AnimatePresence>

</div>


{/* Footer */}

{(view === "cart" && items.length > 0) ||

(view === "form" && showForm) ? (

<div

style={{

padding: compactMode ? "12px 16px" : "16px 20px",

borderTop: `1px solid ${colors.border}`,

background: colors.surface,

flexShrink: 0,

}}

>

{view === "cart" && (

<>

{/* Summary */}

<div

style={{

display: "flex",

justifyContent: "space-between",

alignItems: "center",

marginBottom: 16,

}}

>

<span

style={{

fontSize: bodyFont.fontSize || 14,

fontFamily: bodyFont.fontFamily,

fontWeight: bodyFont.fontWeight,

letterSpacing: bodyFont.letterSpacing,

color: colors.textSecondary,

}}

>

{text.subtotalLabel} ({totalItems}{" "}

{text.totalItemsLabel})

</span>

{showPrices && (

<span

style={{

fontSize: titleFont.fontSize || 20,

fontWeight: titleFont.fontWeight || 700,

fontFamily: titleFont.fontFamily,

letterSpacing: titleFont.letterSpacing,

color: colors.text,

}}

>

{formatPrice(

totalPrice,

currencySymbol,

currencyPosition

)}

</span>

)}

</div>


{/* Request Quote Button - direct submit if showForm is false */}

<motion.button

onClick={() =>

showForm ? setView("form") : handleSubmit()

}

disabled={!showForm && isSubmitting}

whileHover={

enableAnimations &&

!(!showForm && isSubmitting)

? { scale: 1.02 }

: undefined

}

whileTap={

enableAnimations &&

!(!showForm && isSubmitting)

? { scale: 0.98 }

: undefined

}

style={{

width: "100%",

padding: "14px 24px",

background:

!showForm && isSubmitting

? colors.textSecondary

: `linear-gradient(135deg, ${colors.primary}, ${colors.secondary})`,

color: "#FFFFFF",

border: "none",

borderRadius: borderRadius * 0.6,

fontSize: buttonFont.fontSize || 15,

fontWeight: buttonFont.fontWeight || 600,

fontFamily: buttonFont.fontFamily,

letterSpacing: buttonFont.letterSpacing,

cursor:

!showForm && isSubmitting

? "not-allowed"

: "pointer",

boxShadow:

showShadow &&

!(!showForm && isSubmitting)

? `0 4px 14px ${colors.primary}40`

: "none",

}}

>

{!showForm && isSubmitting

? text.submittingButton

: showForm

? texts.cartButton

: texts.submitButton}

</motion.button>

</>

)}


{view === "form" && (

<motion.button

onClick={handleSubmit}

disabled={isSubmitting}

whileHover={

enableAnimations && !isSubmitting

? { scale: 1.02 }

: undefined

}

whileTap={

enableAnimations && !isSubmitting

? { scale: 0.98 }

: undefined

}

style={{

width: "100%",

padding: "14px 24px",

background: isSubmitting

? colors.textSecondary

: `linear-gradient(135deg, ${colors.primary}, ${colors.secondary})`,

color: "#FFFFFF",

border: "none",

borderRadius: borderRadius * 0.6,

fontSize: buttonFont.fontSize || 15,

fontWeight: buttonFont.fontWeight || 600,

fontFamily: buttonFont.fontFamily,

letterSpacing: buttonFont.letterSpacing,

cursor: isSubmitting

? "not-allowed"

: "pointer",

boxShadow:

showShadow && !isSubmitting

? `0 4px 14px ${colors.primary}40`

: "none",

}}

>

{isSubmitting

? text.submittingButton

: texts.submitButton}

</motion.button>

)}

</div>

) : null}

</div>

)

}


// ============================================================================

// Property Controls

// ============================================================================


addPropertyControls(RFQCart, {

// -------------------------------------------------------------------------

// Component Type (Top-Level Selection)

// -------------------------------------------------------------------------

componentType: {

type: ControlType.Enum,

title: "Component",

options: ["cart", "addToCart"],

optionTitles: ["Quote Cart", "Add to Cart Button"],

defaultValue: "cart",

displaySegmentedControl: false,

description: "Choose component type",

},


// -------------------------------------------------------------------------

// Display Mode (only for cart type)

// -------------------------------------------------------------------------

mode: {

type: ControlType.Enum,

title: "Display Mode",

options: ["cart", "button", "mini", "header", "header-popup"],

optionTitles: [

"Full Cart",

"Button Only",

"Mini Summary",

"Header Icon",

"Header + Popup",

],

defaultValue: "cart",

displaySegmentedControl: false,

description: "Choose how the cart is displayed",

hidden: (props: any) => props.componentType === "addToCart",

},


// -------------------------------------------------------------------------

// Storage Key (needed for all modes for localStorage sync)

// -------------------------------------------------------------------------

storageKey: {

type: ControlType.String,

title: "Storage Key",

defaultValue: "rfq-cart",

description: "Unique key for localStorage",

},


// -------------------------------------------------------------------------

// Product Info (only for addToCart type)

// -------------------------------------------------------------------------

product: {

type: ControlType.Object,

title: "Product",

buttonTitle: "Product Info",

icon: "object",

description: "Product information for Add to Cart button",

hidden: (props: any) => props.componentType !== "addToCart",

controls: {

productId: {

type: ControlType.String,

title: "ID",

defaultValue: "product-1",

description:

"Unique product identifier (must be unique per product)",

},

productName: {

type: ControlType.String,

title: "Name",

defaultValue: "Sample Product",

description: "Product name displayed in cart",

},

productPrice: {

type: ControlType.Number,

title: "Price",

defaultValue: 99,

min: 0,

step: 0.01,

description: "Unit price of the product",

},

productImage: {

type: ControlType.Image,

title: "Image",

description: "Product thumbnail shown in cart",

},

productOptions: {

type: ControlType.String,

title: "Options",

defaultValue: "",

description: "Product variants (e.g., Color: Red, Size: L)",

},

},

},


// -------------------------------------------------------------------------

// Add to Cart Button Appearance (only for addToCart type)

// -------------------------------------------------------------------------

addToCartAppearance: {

type: ControlType.Object,

title: "Button",

buttonTitle: "Button Settings",

icon: "effect",

description: "Customize Add to Cart button appearance",

hidden: (props: any) => props.componentType !== "addToCart",

controls: {

buttonText: {

type: ControlType.String,

title: "Text",

defaultValue: "Add to Quote",

description: "Button label text",

},

buttonStyle: {

type: ControlType.Enum,

title: "Style",

options: ["primary", "secondary", "outline"],

optionTitles: ["Primary", "Secondary", "Outline"],

defaultValue: "primary",

description: "Button visual style",

},

showIcon: {

type: ControlType.Boolean,

title: "Show Icon",

defaultValue: true,

description: "Display cart icon on button",

},

fullWidth: {

type: ControlType.Boolean,

title: "Full Width",

defaultValue: false,

description: "Expand button to fill container width",

},

},

},


// -------------------------------------------------------------------------

// Webhook (only for cart and header-popup modes)

// -------------------------------------------------------------------------

webhookUrl: {

type: ControlType.Link,

title: "Webhook URL",

defaultValue: "",

description: "URL to receive quote requests",

hidden: (props: any) =>

props.componentType === "addToCart" ||

!["cart", "header-popup"].includes(props.mode ?? "cart"),

},


// -------------------------------------------------------------------------

// Continue Shopping URL (only for cart and header-popup modes)

// -------------------------------------------------------------------------

continueShoppingUrl: {

type: ControlType.Link,

title: "Continue URL",

defaultValue: "",

description: "URL to navigate after success (leave empty to stay)",

hidden: (props: any) =>

props.componentType === "addToCart" ||

!["cart", "header-popup"].includes(props.mode ?? "cart"),

},


// -------------------------------------------------------------------------

// Error Code Mappings (for custom error messages from webhook)

// -------------------------------------------------------------------------

errorCodeMappings: {

type: ControlType.Array,

title: "Error Codes",

description: "Map server error codes to custom messages",

hidden: (props: any) =>

props.componentType === "addToCart" ||

!["cart", "header-popup"].includes(props.mode ?? "cart"),

control: {

type: ControlType.Object,

controls: {

code: {

type: ControlType.String,

title: "Code",

defaultValue: "ERROR_CODE",

description: "Error code from server (e.g., INVALID_EMAIL)",

},

message: {

type: ControlType.String,

title: "Message",

defaultValue: "Custom error message",

description: "Message to display for this error code",

},

},

},

},


// -------------------------------------------------------------------------

// Colors

// -------------------------------------------------------------------------

colors: {

type: ControlType.Object,

title: "Colors",

optional: true,

buttonTitle: "Colors",

description: "Customize component color scheme",

controls: {

primary: {

type: ControlType.Color,

title: "Primary",

defaultValue: "#6366F1",

description: "Main accent color for buttons and highlights",

},

secondary: {

type: ControlType.Color,

title: "Secondary",

defaultValue: "#8B5CF6",

description: "Gradient end color for buttons",

},

background: {

type: ControlType.Color,

title: "Background",

defaultValue: "#FFFFFF",

description: "Main background color",

hidden: (props: any) => ["header"].includes(props.mode),

},

surface: {

type: ControlType.Color,

title: "Surface",

defaultValue: "#F8FAFC",

description: "Card and header background color",

hidden: (props: any) =>

["button", "header"].includes(props.mode),

},

text: {

type: ControlType.Color,

title: "Text",

defaultValue: "#1E293B",

description: "Primary text color",

},

textSecondary: {

type: ControlType.Color,

title: "Text Muted",

defaultValue: "#64748B",

description: "Secondary/muted text color",

hidden: (props: any) =>

["button", "header"].includes(props.mode),

},

border: {

type: ControlType.Color,

title: "Border",

defaultValue: "#E2E8F0",

description: "Border and divider color",

hidden: (props: any) =>

["button", "header"].includes(props.mode),

},

success: {

type: ControlType.Color,

title: "Success",

defaultValue: "#10B981",

description: "Success state color",

hidden: (props: any) =>

!["cart", "header-popup"].includes(props.mode ?? "cart"),

},

error: {

type: ControlType.Color,

title: "Error",

defaultValue: "#EF4444",

description: "Error state and badge color",

hidden: (props: any) =>

!["cart", "header-popup"].includes(props.mode ?? "cart"),

},

},

},


// -------------------------------------------------------------------------

// Content Display (only for cart, header-popup, mini)

// -------------------------------------------------------------------------

content: {

type: ControlType.Object,

title: "Content",

optional: true,

buttonTitle: "Content Display",

icon: "object",

description: "Control what content is displayed in the cart",

hidden: (props: any) =>

props.componentType === "addToCart" ||

["button", "header"].includes(props.mode),

controls: {

showPrices: {

type: ControlType.Boolean,

title: "Prices",

defaultValue: true,

description: "Display product prices and totals",

},

showImages: {

type: ControlType.Boolean,

title: "Images",

defaultValue: true,

description: "Display product thumbnails",

hidden: (props: any) => props.mode === "mini",

},

showNotes: {

type: ControlType.Boolean,

title: "Notes",

defaultValue: true,

description: "Allow customers to add notes per item",

hidden: (props: any) => props.mode === "mini",

},

maxQuantity: {

type: ControlType.Number,

title: "Max Qty",

defaultValue: 999,

min: 1,

max: 9999,

step: 1,

displayStepper: true,

description: "Maximum quantity per item",

hidden: (props: any) => props.mode === "mini",

},

},

},


// -------------------------------------------------------------------------

// Currency (only for cart, header-popup, mini)

// -------------------------------------------------------------------------

currency: {

type: ControlType.Object,

title: "Currency",

optional: true,

buttonTitle: "Currency",

description: "Currency display settings",

hidden: (props: any) =>

props.componentType === "addToCart" ||

["button", "header"].includes(props.mode),

controls: {

currencySymbol: {

type: ControlType.String,

title: "Symbol",

defaultValue: "$",

description: "Currency symbol (e.g., $, €, ¥, ₩)",

},

currencyPosition: {

type: ControlType.Enum,

title: "Position",

options: ["before", "after"],

optionTitles: ["Before", "After"],

defaultValue: "before",

description: "Symbol position ($100 vs 100$)",

},

},

},


// -------------------------------------------------------------------------

// Form Settings (only for cart, header-popup)

// -------------------------------------------------------------------------

form: {

type: ControlType.Object,

title: "Form",

optional: true,

buttonTitle: "Form Settings",

description: "Customer information form settings",

hidden: (props: any) =>

props.componentType === "addToCart" ||

!["cart", "header-popup"].includes(props.mode ?? "cart"),

controls: {

showForm: {

type: ControlType.Boolean,

title: "Show Form",

defaultValue: true,

description: "When off, cart submits directly without form",

},

customFields: {

type: ControlType.Array,

title: "Form Fields",

description: "Add, remove, or reorder form fields",

control: {

type: ControlType.Object,

controls: {

id: {

type: ControlType.String,

title: "Field ID",

defaultValue: "field",

description: "Unique ID (used in webhook payload)",

},

label: {

type: ControlType.String,

title: "Label",

defaultValue: "Field",

description: "Label shown above the field",

},

placeholder: {

type: ControlType.String,

title: "Placeholder",

defaultValue: "",

description: "Placeholder text inside field",

},

type: {

type: ControlType.Enum,

title: "Type",

options: ["text", "email", "tel", "textarea"],

optionTitles: [

"Text",

"Email",

"Phone",

"Textarea",

],

defaultValue: "text",

description: "Field type (email validates format)",

},

required: {

type: ControlType.Boolean,

title: "Required",

defaultValue: false,

description: "Must be filled to submit",

},

},

},

defaultValue: [

{

id: "name",

label: "Name",

placeholder: "Your name",

type: "text",

required: true,

},

{

id: "email",

label: "Email",

placeholder: "your@email.com",

type: "email",

required: true,

},

{

id: "phone",

label: "Phone",

placeholder: "Phone number",

type: "tel",

required: false,

},

{

id: "message",

label: "Message",

placeholder: "Additional notes...",

type: "textarea",

required: false,

},

],

},

},

},


// -------------------------------------------------------------------------

// Texts (Editable Labels) - only for cart, header-popup, mini

// -------------------------------------------------------------------------

texts: {

type: ControlType.Object,

title: "Texts",

optional: true,

buttonTitle: "Edit Texts",

description: "Customize all text labels",

hidden: (props: any) =>

props.componentType === "addToCart" ||

["button", "header"].includes(props.mode),

controls: {

cartTitle: {

type: ControlType.String,

title: "Cart Title",

defaultValue: "Your Cart",

description: "Header title for cart view",

hidden: (props: any) => props.mode === "mini",

},

formTitle: {

type: ControlType.String,

title: "Form Title",

defaultValue: "Contact Info",

description: "Header title for form view",

hidden: (props: any) => props.mode === "mini",

},

emptyMessage: {

type: ControlType.String,

title: "Empty Title",

defaultValue: "Your cart is empty",

description: "Title when cart is empty",

hidden: (props: any) => props.mode === "mini",

},

emptySubtext: {

type: ControlType.String,

title: "Empty Subtext",

defaultValue: "Add items to get started",

description: "Subtext when cart is empty",

hidden: (props: any) => props.mode === "mini",

},

cartButton: {

type: ControlType.String,

title: "Cart Button",

defaultValue: "Request Quote",

description: "Main action button text",

},

submitButton: {

type: ControlType.String,

title: "Submit Btn",

defaultValue: "Send Request",

description: "Form submit button text",

hidden: (props: any) => props.mode === "mini",

},

successTitle: {

type: ControlType.String,

title: "Success Title",

defaultValue: "Request Sent!",

description: "Title after successful submission",

hidden: (props: any) => props.mode === "mini",

},

successMessage: {

type: ControlType.String,

title: "Success Msg",

defaultValue: "We'll contact you soon",

description: "Message after successful submission",

hidden: (props: any) => props.mode === "mini",

},

continueButton: {

type: ControlType.String,

title: "Continue Btn",

defaultValue: "Continue Shopping",

description: "Button text after success",

hidden: (props: any) => props.mode === "mini",

},

},

},


// -------------------------------------------------------------------------

// Styling

// -------------------------------------------------------------------------

styling: {

type: ControlType.Object,

title: "Styling",

optional: true,

buttonTitle: "Styling",

icon: "effect",

description: "Visual appearance settings",

controls: {

borderRadius: {

type: ControlType.Number,

title: "Radius",

defaultValue: 12,

min: 0,

max: 32,

step: 2,

unit: "px",

description: "Corner roundness of cards and buttons",

},

showShadow: {

type: ControlType.Boolean,

title: "Shadow",

defaultValue: true,

description: "Enable drop shadow effect",

hidden: (props: any) => props.mode === "header",

},

compactMode: {

type: ControlType.Boolean,

title: "Compact",

defaultValue: false,

description: "Reduce padding and spacing",

hidden: (props: any) =>

!["cart", "header-popup", "mini"].includes(

props.mode ?? "cart"

),

},

enableAnimations: {

type: ControlType.Boolean,

title: "Animation",

defaultValue: true,

description: "Enable smooth transitions and effects",

},

showScrollbar: {

type: ControlType.Boolean,

title: "Scrollbar",

defaultValue: true,

description: "Show or hide the scrollbar",

},

scrollbarColor: {

type: ControlType.Color,

title: "Scrollbar Color",

defaultValue: "rgba(0,0,0,0.3)",

hidden: (props: any) => props.showScrollbar === false,

description: "Color of the scrollbar thumb",

},

scrollbarWidth: {

type: ControlType.Number,

title: "Scrollbar Width",

defaultValue: 6,

min: 2,

max: 12,

step: 1,

unit: "px",

hidden: (props: any) => props.showScrollbar === false,

description: "Width of the scrollbar",

},

},

},


// -------------------------------------------------------------------------

// Typography (Font Customization)

// -------------------------------------------------------------------------

typography: {

type: ControlType.Object,

title: "Typography",

optional: true,

buttonTitle: "Edit Fonts",

icon: "effect",

description: "Customize fonts for titles, body text, and buttons",

hidden: (props: any) =>

props.componentType === "addToCart" ||

["header"].includes(props.mode),

controls: {

title: {

type: ControlType.Font,

title: "Title Font",

controls: "extended",

displayFontSize: true,

displayTextAlignment: false,

defaultFontType: "sans-serif",

} as any,

body: {

type: ControlType.Font,

title: "Body Font",

controls: "extended",

displayFontSize: true,

displayTextAlignment: false,

defaultFontType: "sans-serif",

} as any,

button: {

type: ControlType.Font,

title: "Button Font",

controls: "extended",

displayFontSize: true,

displayTextAlignment: false,

defaultFontType: "sans-serif",

} as any,

},

},

})


// ============================================================================

// Named Exports for Framer compatibility

// ============================================================================


// AddToCartButton is an alias for RFQCart with componentType="addToCart"

export const AddToCartButton = RFQCart


// QuoteCart is an alias for RFQCart with componentType="cart"

export const QuoteCart = RFQCart

Create a free website with Framer, the website builder loved by startups, designers and agencies.