/**
* 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