diff --git a/quote-builder/README.md b/quote-builder/README.md new file mode 100644 index 0000000..95da83b --- /dev/null +++ b/quote-builder/README.md @@ -0,0 +1,124 @@ +# QuoteBuilder + +An interactive pricing calculator with real-time updates, featuring a two-column layout with input controls and a live results panel. + +## Getting Started + +Install dependencies: +```bash +npm install +``` + +Share the component to your Webflow workspace: +```bash +npx webflow library share +``` + +For local development: +```bash +npm run dev +``` + +## Designer Properties + +| Property | Type | Default | Description | +|----------|------|---------|-------------| +| ID | Id | — | HTML ID attribute for targeting in custom code | +| Layout | Variant | side-by-side | Results panel position relative to inputs (side-by-side, stacked) | +| Heading | TextNode | Get Your Custom Quote | Main heading for the calculator | +| Subheading | Text | Customize your package and see pricing in real-time | Descriptive subheading below main heading | +| Currency Symbol | Text | $ | Currency symbol to display before prices | +| Input Section Title | Text | Configure Your Package | Title for the input controls section | +| Results Section Title | Text | Your Quote | Title for the results panel | +| Item 1 Label | Text | Team Members | Label for first line item | +| Item 1 Type | Variant | number | Input type for first item (number, dropdown, toggle) | +| Item 1 Default Value | Number | 5 | Default quantity or value for first item | +| Item 1 Unit Price | Number | 25 | Price per unit for first item | +| Item 1 Dropdown Options | Text | Basic\|1\|10
Standard\|2\|25
Premium\|3\|50 | Dropdown options for first item (one per line, format: label\|value\|price) | +| Item 1 Visible | Visibility | — | Show or hide first line item | +| Item 2 Label | Text | Storage Space (GB) | Label for second line item | +| Item 2 Type | Variant | dropdown | Input type for second item (number, dropdown, toggle) | +| Item 2 Default Value | Number | 2 | Default quantity or value for second item | +| Item 2 Unit Price | Number | 15 | Price per unit for second item | +| Item 2 Dropdown Options | Text | 50GB\|1\|15
100GB\|2\|25
500GB\|3\|75 | Dropdown options for second item (one per line, format: label\|value\|price) | +| Item 2 Visible | Visibility | — | Show or hide second line item | +| Item 3 Label | Text | Priority Support | Label for third line item | +| Item 3 Type | Variant | toggle | Input type for third item (number, dropdown, toggle) | +| Item 3 Default Value | Number | 0 | Default quantity or value for third item (0 or 1 for toggle) | +| Item 3 Unit Price | Number | 99 | Price per unit for third item | +| Item 3 Dropdown Options | Text | None\|0\|0
Enabled\|1\|99 | Dropdown options for third item (one per line, format: label\|value\|price) | +| Item 3 Visible | Visibility | — | Show or hide third line item | +| Item 4 Label | Text | API Access | Label for fourth line item | +| Item 4 Type | Variant | toggle | Input type for fourth item (number, dropdown, toggle) | +| Item 4 Default Value | Number | 0 | Default quantity or value for fourth item (0 or 1 for toggle) | +| Item 4 Unit Price | Number | 49 | Price per unit for fourth item | +| Item 4 Dropdown Options | Text | None\|0\|0
Enabled\|1\|49 | Dropdown options for fourth item (one per line, format: label\|value\|price) | +| Item 4 Visible | Visibility | — | Show or hide fourth line item | +| Show Subtotals | Boolean | true | Display individual subtotals for each line item in results | +| Show Unit Prices | Boolean | true | Display unit prices for each line item in results | +| Total Label | Text | Total Monthly Cost | Label for the total amount | +| CTA Text | Text | Get Your Quote | Call-to-action button text | +| CTA Link | Link | — | Call-to-action button link destination | +| CTA Subtext | Text | No credit card required. Get a detailed quote in minutes. | Helper text below the CTA button | +| Show CTA Subtext | Boolean | true | Display helper text below CTA button | + +## Styling + +This component automatically adapts to your Webflow site's design system through site variables and inherited properties. + +### Site Variables + +To match your site's design system, define these CSS variables in your Webflow project settings. The component will use the fallback values shown below until you configure them. + +| Site Variable | What It Controls | Fallback | +|---------------|------------------|----------| +| --background-primary | Main background color for cards and panels | #ffffff | +| --background-secondary | Hover states and subtle backgrounds | #f5f5f5 | +| --text-primary | Main text color for headings and labels | #1a1a1a | +| --text-secondary | Muted text for subheadings and helper text | #737373 | +| --border-color | Borders, dividers, and input outlines | #e5e5e5 | +| --accent-color | CTA button, toggle active state, focus outlines | #1a1a1a | +| --accent-text-color | Text color on accent backgrounds | #ffffff | +| --border-radius | Corner rounding for all elements | 8px | + +### Inherited Properties + +The component inherits these CSS properties from its parent element: +- `font-family` — Typography style +- `color` — Text color +- `line-height` — Text spacing + +## Extending in Code + +### Custom Calculation Logic + +Add custom pricing rules or discounts by listening to value changes: + +```javascript +const calculator = document.querySelector('[data-component="quote-builder"]'); +calculator.addEventListener('change', (e) => { + // Apply volume discount when team members > 10 + const teamMembers = parseInt(e.target.dataset.item); + if (teamMembers > 10) { + // Apply 15% discount logic + } +}); +``` + +### Integration with Forms + +Connect the calculator to your form submission: + +```javascript +const ctaButton = document.querySelector('.wf-quotebuilder-cta'); +ctaButton.addEventListener('click', (e) => { + e.preventDefault(); + const total = document.querySelector('.wf-quotebuilder-total-amount').textContent; + // Send quote data to your backend or CRM + submitQuote({ total, items: getSelectedItems() }); +}); +``` + +## Dependencies + +No external dependencies. \ No newline at end of file diff --git a/quote-builder/index.html b/quote-builder/index.html new file mode 100644 index 0000000..798375e --- /dev/null +++ b/quote-builder/index.html @@ -0,0 +1,17 @@ + + + + + + QuoteBuilder + + + +
+ + + diff --git a/quote-builder/metadata.json b/quote-builder/metadata.json new file mode 100644 index 0000000..7fadb74 --- /dev/null +++ b/quote-builder/metadata.json @@ -0,0 +1,5 @@ +{ + "name": "Calculator / Quote Builder", + "description": "Interactive pricing calculator with number inputs, dropdowns, toggle add-ons, and real-time total calculation.", + "category": "Forms & Input" +} diff --git a/quote-builder/package.json b/quote-builder/package.json new file mode 100644 index 0000000..9ed29d6 --- /dev/null +++ b/quote-builder/package.json @@ -0,0 +1,25 @@ +{ + "name": "quote-builder", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "preview": "vite preview" + }, + "dependencies": { + "react": "^19.1.1", + "react-dom": "^19.1.1" + }, + "devDependencies": { + "@types/react": "^19.1.13", + "@types/react-dom": "^19.1.9", + "@vitejs/plugin-react": "^5.0.3", + "@webflow/data-types": "^1.0.1", + "@webflow/react": "^1.0.1", + "@webflow/webflow-cli": "^1.8.44", + "typescript": "~5.8.3", + "vite": "^7.1.7" + } +} \ No newline at end of file diff --git a/quote-builder/screenshot-brand.png b/quote-builder/screenshot-brand.png new file mode 100644 index 0000000..f1d4634 Binary files /dev/null and b/quote-builder/screenshot-brand.png differ diff --git a/quote-builder/screenshot-dark.png b/quote-builder/screenshot-dark.png new file mode 100644 index 0000000..f1d4634 Binary files /dev/null and b/quote-builder/screenshot-dark.png differ diff --git a/quote-builder/screenshot-light.png b/quote-builder/screenshot-light.png new file mode 100644 index 0000000..f1d4634 Binary files /dev/null and b/quote-builder/screenshot-light.png differ diff --git a/quote-builder/src/components/QuoteBuilder/QuoteBuilder.css b/quote-builder/src/components/QuoteBuilder/QuoteBuilder.css new file mode 100644 index 0000000..2d1bdd9 --- /dev/null +++ b/quote-builder/src/components/QuoteBuilder/QuoteBuilder.css @@ -0,0 +1,398 @@ +/* + * Webflow Site Variables Used: + * - --background-primary: Main background color for cards and panels + * - --background-secondary: Hover states and subtle backgrounds + * - --text-primary: Main text color for headings and labels + * - --text-secondary: Muted text for subheadings and helper text + * - --border-color: Borders, dividers, and input outlines + * - --accent-color: CTA button, toggle active state, focus outlines + * - --accent-text-color: Text color on accent backgrounds + * - --border-radius: Corner rounding for all elements + */ + +/* Box sizing reset scoped to component */ +.wf-quotebuilder *, +.wf-quotebuilder *::before, +.wf-quotebuilder *::after { + box-sizing: border-box; +} + +/* Root element - inherit Webflow typography + default padding */ +.wf-quotebuilder { + font-family: inherit; + color: inherit; + line-height: inherit; + padding: 24px; +} + +/* Layout variants */ +.wf-quotebuilder-layout-side-by-side .wf-quotebuilder-container { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 32px; +} + +.wf-quotebuilder-layout-stacked .wf-quotebuilder-container { + display: flex; + flex-direction: column; + gap: 32px; +} + +/* Header section */ +.wf-quotebuilder-header { + margin-bottom: 32px; +} + +.wf-quotebuilder-heading { + font-size: 32px; + font-weight: 700; + color: var(--text-primary, #1a1a1a); + margin: 0 0 8px 0; + line-height: 1.2; +} + +.wf-quotebuilder-subheading { + font-size: 16px; + color: var(--text-secondary, #737373); + margin: 0; + line-height: 1.5; +} + +/* Container */ +.wf-quotebuilder-container { + margin-bottom: 32px; +} + +/* Input section */ +.wf-quotebuilder-inputs { + background: var(--background-primary, #ffffff); + border: 1px solid var(--border-color, #e5e5e5); + border-radius: var(--border-radius, 8px); + padding: 24px; +} + +.wf-quotebuilder-section-title { + font-size: 20px; + font-weight: 600; + color: var(--text-primary, #1a1a1a); + margin: 0 0 20px 0; + line-height: 1.3; +} + +.wf-quotebuilder-inputs-list { + display: flex; + flex-direction: column; + gap: 20px; +} + +.wf-quotebuilder-input-row { + display: flex; + flex-direction: column; + gap: 8px; +} + +.wf-quotebuilder-label { + font-size: 14px; + font-weight: 500; + color: var(--text-primary, #1a1a1a); + line-height: 1.4; +} + +/* Number input */ +.wf-quotebuilder-number-input { + display: flex; + align-items: center; + gap: 8px; + width: fit-content; +} + +.wf-quotebuilder-decrement, +.wf-quotebuilder-increment { + width: 36px; + height: 36px; + border: 1px solid var(--border-color, #e5e5e5); + background: var(--background-primary, #ffffff); + border-radius: var(--border-radius, 8px); + color: var(--text-primary, #1a1a1a); + font-size: 18px; + font-weight: 500; + cursor: pointer; + transition: background-color 0.2s, border-color 0.2s; + display: flex; + align-items: center; + justify-content: center; + padding: 0; +} + +.wf-quotebuilder-decrement:hover, +.wf-quotebuilder-increment:hover { + background: var(--background-secondary, #f5f5f5); + border-color: var(--text-secondary, #737373); +} + +.wf-quotebuilder-decrement:focus-visible, +.wf-quotebuilder-increment:focus-visible { + outline: 2px solid var(--accent-color, #1a1a1a); + outline-offset: 2px; +} + +.wf-quotebuilder-decrement:active, +.wf-quotebuilder-increment:active { + background: var(--border-color, #e5e5e5); +} + +.wf-quotebuilder-number-field { + width: 80px; + height: 36px; + border: 1px solid var(--border-color, #e5e5e5); + background: var(--background-primary, #ffffff); + border-radius: var(--border-radius, 8px); + color: var(--text-primary, #1a1a1a); + font-size: 14px; + text-align: center; + padding: 0 8px; + transition: border-color 0.2s; +} + +.wf-quotebuilder-number-field:hover { + border-color: var(--text-secondary, #737373); +} + +.wf-quotebuilder-number-field:focus { + outline: none; + border-color: var(--accent-color, #1a1a1a); +} + +.wf-quotebuilder-number-field:focus-visible { + outline: 2px solid var(--accent-color, #1a1a1a); + outline-offset: 2px; +} + +/* Select dropdown */ +.wf-quotebuilder-select { + width: 100%; + height: 40px; + border: 1px solid var(--border-color, #e5e5e5); + background: var(--background-primary, #ffffff); + border-radius: var(--border-radius, 8px); + color: var(--text-primary, #1a1a1a); + font-size: 14px; + padding: 0 12px; + cursor: pointer; + transition: border-color 0.2s; +} + +.wf-quotebuilder-select:hover { + border-color: var(--text-secondary, #737373); +} + +.wf-quotebuilder-select:focus { + outline: none; + border-color: var(--accent-color, #1a1a1a); +} + +.wf-quotebuilder-select:focus-visible { + outline: 2px solid var(--accent-color, #1a1a1a); + outline-offset: 2px; +} + +/* Toggle switch */ +.wf-quotebuilder-toggle { + position: relative; + display: inline-block; + width: 48px; + height: 24px; + cursor: pointer; +} + +.wf-quotebuilder-toggle-input { + opacity: 0; + width: 0; + height: 0; + position: absolute; +} + +.wf-quotebuilder-toggle-slider { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: var(--border-color, #e5e5e5); + border-radius: 24px; + transition: background-color 0.2s; +} + +.wf-quotebuilder-toggle-slider::before { + content: ""; + position: absolute; + height: 18px; + width: 18px; + left: 3px; + bottom: 3px; + background: var(--background-primary, #ffffff); + border-radius: 50%; + transition: transform 0.2s; +} + +.wf-quotebuilder-toggle:hover .wf-quotebuilder-toggle-slider { + background: var(--text-secondary, #737373); +} + +.wf-quotebuilder-toggle-input:checked + .wf-quotebuilder-toggle-slider { + background: var(--accent-color, #1a1a1a); +} + +.wf-quotebuilder-toggle-input:checked + .wf-quotebuilder-toggle-slider::before { + transform: translateX(24px); +} + +.wf-quotebuilder-toggle-input:focus-visible + .wf-quotebuilder-toggle-slider { + outline: 2px solid var(--accent-color, #1a1a1a); + outline-offset: 2px; +} + +.wf-quotebuilder-toggle-input:disabled + .wf-quotebuilder-toggle-slider { + opacity: 0.5; + cursor: not-allowed; +} + +/* Results section */ +.wf-quotebuilder-results { + background: var(--background-primary, #ffffff); + border: 1px solid var(--border-color, #e5e5e5); + border-radius: var(--border-radius, 8px); + padding: 24px; +} + +.wf-quotebuilder-results-list { + display: flex; + flex-direction: column; + gap: 16px; + margin-bottom: 24px; + padding-bottom: 24px; + border-bottom: 1px solid var(--border-color, #e5e5e5); +} + +.wf-quotebuilder-result-item { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 16px; +} + +.wf-quotebuilder-result-label { + font-size: 14px; + color: var(--text-primary, #1a1a1a); + line-height: 1.5; + flex: 1; +} + +.wf-quotebuilder-result-details { + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 4px; +} + +.wf-quotebuilder-result-quantity { + font-size: 13px; + color: var(--text-secondary, #737373); + line-height: 1.4; +} + +.wf-quotebuilder-result-unit-price { + font-size: 13px; + color: var(--text-secondary, #737373); +} + +.wf-quotebuilder-result-subtotal { + font-size: 14px; + font-weight: 600; + color: var(--text-primary, #1a1a1a); + line-height: 1.4; +} + +/* Total section */ +.wf-quotebuilder-total { + display: flex; + justify-content: space-between; + align-items: center; + gap: 16px; +} + +.wf-quotebuilder-total-label { + font-size: 18px; + font-weight: 600; + color: var(--text-primary, #1a1a1a); + line-height: 1.3; +} + +.wf-quotebuilder-total-amount { + font-size: 28px; + font-weight: 700; + color: var(--accent-color, #1a1a1a); + line-height: 1.2; +} + +/* Footer section */ +.wf-quotebuilder-footer { + display: flex; + flex-direction: column; + align-items: center; + gap: 12px; +} + +.wf-quotebuilder-cta { + display: inline-block; + padding: 14px 32px; + background: var(--accent-color, #1a1a1a); + color: var(--accent-text-color, #ffffff); + font-size: 16px; + font-weight: 600; + text-decoration: none; + border-radius: var(--border-radius, 8px); + transition: opacity 0.2s, transform 0.2s; + cursor: pointer; + border: none; + line-height: 1.4; +} + +.wf-quotebuilder-cta:hover { + opacity: 0.9; + transform: translateY(-1px); +} + +.wf-quotebuilder-cta:focus-visible { + outline: 2px solid var(--accent-color, #1a1a1a); + outline-offset: 2px; +} + +.wf-quotebuilder-cta:active { + transform: translateY(0); + opacity: 0.8; +} + +.wf-quotebuilder-cta-subtext { + font-size: 13px; + color: var(--text-secondary, #737373); + text-align: center; + margin: 0; + line-height: 1.5; + max-width: 400px; +} + +/* Responsive adjustments */ +@media (max-width: 768px) { + .wf-quotebuilder-layout-side-by-side .wf-quotebuilder-container { + grid-template-columns: 1fr; + } + + .wf-quotebuilder-heading { + font-size: 24px; + } + + .wf-quotebuilder-total-amount { + font-size: 24px; + } +} \ No newline at end of file diff --git a/quote-builder/src/components/QuoteBuilder/QuoteBuilder.tsx b/quote-builder/src/components/QuoteBuilder/QuoteBuilder.tsx new file mode 100644 index 0000000..f139bb5 --- /dev/null +++ b/quote-builder/src/components/QuoteBuilder/QuoteBuilder.tsx @@ -0,0 +1,346 @@ +import { useState } from "react"; + +export interface QuoteBuilderProps { + id?: string; + layout?: "side-by-side" | "stacked"; + heading?: React.ReactNode; + subheading?: string; + currencySymbol?: string; + inputSectionTitle?: string; + resultsSectionTitle?: string; + item1Label?: string; + item1Type?: "number" | "dropdown" | "toggle"; + item1DefaultValue?: number; + item1UnitPrice?: number; + item1DropdownOptions?: string; + item1Visible?: boolean; + item2Label?: string; + item2Type?: "number" | "dropdown" | "toggle"; + item2DefaultValue?: number; + item2UnitPrice?: number; + item2DropdownOptions?: string; + item2Visible?: boolean; + item3Label?: string; + item3Type?: "number" | "dropdown" | "toggle"; + item3DefaultValue?: number; + item3UnitPrice?: number; + item3DropdownOptions?: string; + item3Visible?: boolean; + item4Label?: string; + item4Type?: "number" | "dropdown" | "toggle"; + item4DefaultValue?: number; + item4UnitPrice?: number; + item4DropdownOptions?: string; + item4Visible?: boolean; + showSubtotals?: boolean; + showUnitPrices?: boolean; + totalLabel?: string; + ctaText?: string; + ctaLink?: string; + ctaSubtext?: string; + showCtaSubtext?: boolean; +} + +interface DropdownOption { + label: string; + value: number; + price: number; +} + +interface LineItem { + label: string; + type: "number" | "dropdown" | "toggle"; + defaultValue: number; + unitPrice: number; + dropdownOptions: string; + visible: boolean; +} + +export default function QuoteBuilder({ + id, + layout = "side-by-side", + heading = "Get Your Custom Quote", + subheading = "Customize your package and see pricing in real-time", + currencySymbol = "$", + inputSectionTitle = "Configure Your Package", + resultsSectionTitle = "Your Quote", + item1Label = "Team Members", + item1Type = "number", + item1DefaultValue = 5, + item1UnitPrice = 25, + item1DropdownOptions = "Basic|1|10\nStandard|2|25\nPremium|3|50", + item1Visible = true, + item2Label = "Storage Space (GB)", + item2Type = "dropdown", + item2DefaultValue = 2, + item2UnitPrice = 15, + item2DropdownOptions = "50GB|1|15\n100GB|2|25\n500GB|3|75", + item2Visible = true, + item3Label = "Priority Support", + item3Type = "toggle", + item3DefaultValue = 0, + item3UnitPrice = 99, + item3DropdownOptions = "None|0|0\nEnabled|1|99", + item3Visible = true, + item4Label = "API Access", + item4Type = "toggle", + item4DefaultValue = 0, + item4UnitPrice = 49, + item4DropdownOptions = "None|0|0\nEnabled|1|49", + item4Visible = true, + showSubtotals = true, + showUnitPrices = true, + totalLabel = "Total Monthly Cost", + ctaText = "Get Your Quote", + ctaLink = "#", + ctaSubtext = "No credit card required. Get a detailed quote in minutes.", + showCtaSubtext = true, +}: QuoteBuilderProps) { + const items: LineItem[] = [ + { + label: item1Label, + type: item1Type, + defaultValue: item1DefaultValue, + unitPrice: item1UnitPrice, + dropdownOptions: item1DropdownOptions, + visible: item1Visible, + }, + { + label: item2Label, + type: item2Type, + defaultValue: item2DefaultValue, + unitPrice: item2UnitPrice, + dropdownOptions: item2DropdownOptions, + visible: item2Visible, + }, + { + label: item3Label, + type: item3Type, + defaultValue: item3DefaultValue, + unitPrice: item3UnitPrice, + dropdownOptions: item3DropdownOptions, + visible: item3Visible, + }, + { + label: item4Label, + type: item4Type, + defaultValue: item4DefaultValue, + unitPrice: item4UnitPrice, + dropdownOptions: item4DropdownOptions, + visible: item4Visible, + }, + ]; + + const [values, setValues] = useState( + items.map((item) => item.defaultValue) + ); + + const parseDropdownOptions = (optionsText: string): DropdownOption[] => { + return optionsText + .split(/\\n|\n/) + .filter((line) => line.trim()) + .map((line) => { + const parts = line.split("|"); + return { + label: parts[0]?.trim() || "", + value: parseFloat(parts[1]?.trim() || "0"), + price: parseFloat(parts[2]?.trim() || "0"), + }; + }); + }; + + const handleValueChange = (index: number, newValue: number) => { + const newValues = [...values]; + newValues[index] = newValue; + setValues(newValues); + }; + + const calculateSubtotal = (index: number): number => { + const item = items[index]; + const value = values[index]; + + if (item.type === "dropdown") { + const options = parseDropdownOptions(item.dropdownOptions); + const selectedOption = options.find((opt) => opt.value === value); + return selectedOption?.price || 0; + } + + return value * item.unitPrice; + }; + + const calculateTotal = (): number => { + return items.reduce((total, item, index) => { + if (!item.visible) return total; + return total + calculateSubtotal(index); + }, 0); + }; + + const formatPrice = (price: number): string => { + return `${currencySymbol}${price.toFixed(2)}`; + }; + + const renderInput = (item: LineItem, index: number) => { + const value = values[index]; + + if (item.type === "number") { + return ( +
+ + + handleValueChange(index, Math.max(0, parseFloat(e.target.value) || 0)) + } + min="0" + /> + +
+ ); + } + + if (item.type === "dropdown") { + const options = parseDropdownOptions(item.dropdownOptions); + return ( + + ); + } + + if (item.type === "toggle") { + return ( + + ); + } + + return null; + }; + + const visibleItems = items.filter((item) => item.visible); + const total = calculateTotal(); + + return ( +
+
+

{heading}

+ {subheading && ( +

{subheading}

+ )} +
+ +
+
+

{inputSectionTitle}

+
+ {items.map( + (item, index) => + item.visible && ( +
+ + {renderInput(item, index)} +
+ ) + )} +
+
+ +
+

+ {resultsSectionTitle} +

+
+ {visibleItems.map((item, visibleIndex) => { + const actualIndex = items.findIndex((i) => i === item); + const value = values[actualIndex]; + const subtotal = calculateSubtotal(actualIndex); + + if (item.type === "toggle" && value === 0) { + return null; + } + + return ( +
+
+ {item.label} +
+
+ {item.type === "number" && ( + + {value} ×{" "} + {showUnitPrices && ( + + {formatPrice(item.unitPrice)} + + )} + + )} + {showSubtotals && ( + + {formatPrice(subtotal)} + + )} +
+
+ ); + })} +
+ +
+ {totalLabel} + + {formatPrice(total)} + +
+
+
+ +
+ + {ctaText} + + {showCtaSubtext && ctaSubtext && ( +

{ctaSubtext}

+ )} +
+
+ ); +} \ No newline at end of file diff --git a/quote-builder/src/components/QuoteBuilder/QuoteBuilder.webflow.tsx b/quote-builder/src/components/QuoteBuilder/QuoteBuilder.webflow.tsx new file mode 100644 index 0000000..7361838 --- /dev/null +++ b/quote-builder/src/components/QuoteBuilder/QuoteBuilder.webflow.tsx @@ -0,0 +1,243 @@ +import QuoteBuilder from "./QuoteBuilder"; +import { props } from "@webflow/data-types"; +import { declareComponent } from "@webflow/react"; +import "./QuoteBuilder.css"; + +export default declareComponent(QuoteBuilder, { + name: "QuoteBuilder", + description: "An interactive pricing calculator with a two-column layout that displays input controls on the left and a live-updating results panel on the right (stacking vertically on mobile). The input section contains labeled number fields with increment/decrement buttons, dropdown selects for option choices, and toggle switches for add-ons. The results panel shows a breakdown of line items with labels, quantities, unit prices, and subtotals, plus a prominent total with configurable currency formatting. All calculations update in real-time as users interact with inputs. The component features a clean card-based design with clear visual separation between input sections and the results panel, and includes a call-to-action button at the bottom for quote requests or contact.", + group: "Interactive", + options: { + ssr: false, + applyTagSelectors: true + }, + props: { + id: props.Id({ + name: "Element ID", + group: "Settings", + tooltip: "HTML ID attribute for targeting with CSS or JavaScript" + }), + layout: props.Variant({ + name: "Layout", + options: ["side-by-side", "stacked"], + defaultValue: "side-by-side", + group: "Style", + tooltip: "Results panel position relative to inputs" + }), + heading: props.TextNode({ + name: "Heading", + defaultValue: "Get Your Custom Quote", + group: "Content", + tooltip: "Main heading for the calculator" + }), + subheading: props.Text({ + name: "Subheading", + defaultValue: "Customize your package and see pricing in real-time", + group: "Content", + tooltip: "Descriptive subheading below main heading" + }), + currencySymbol: props.Text({ + name: "Currency Symbol", + defaultValue: "$", + group: "Settings", + tooltip: "Currency symbol to display before prices" + }), + inputSectionTitle: props.Text({ + name: "Input Section Title", + defaultValue: "Configure Your Package", + group: "Content", + tooltip: "Title for the input controls section" + }), + resultsSectionTitle: props.Text({ + name: "Results Section Title", + defaultValue: "Your Quote", + group: "Content", + tooltip: "Title for the results panel" + }), + item1Label: props.Text({ + name: "Item 1 Label", + defaultValue: "Team Members", + group: "Item 1", + tooltip: "Label for first line item" + }), + item1Type: props.Variant({ + name: "Item 1 Type", + options: ["number", "dropdown", "toggle"], + defaultValue: "number", + group: "Item 1", + tooltip: "Input type for first item" + }), + item1DefaultValue: props.Number({ + name: "Item 1 Default Value", + defaultValue: 5, + group: "Item 1", + tooltip: "Default quantity or value for first item" + }), + item1UnitPrice: props.Number({ + name: "Item 1 Unit Price", + defaultValue: 25, + group: "Item 1", + tooltip: "Price per unit for first item" + }), + item1DropdownOptions: props.Text({ + name: "Item 1 Dropdown Options", + defaultValue: "Basic|1|10\nStandard|2|25\nPremium|3|50", + group: "Item 1", + tooltip: "Dropdown options for first item (one per line, format: label|value|price)" + }), + item1Visible: props.Visibility({ + name: "Item 1 Visible", + group: "Item 1", + tooltip: "Show or hide first line item" + }), + item2Label: props.Text({ + name: "Item 2 Label", + defaultValue: "Storage Space (GB)", + group: "Item 2", + tooltip: "Label for second line item" + }), + item2Type: props.Variant({ + name: "Item 2 Type", + options: ["number", "dropdown", "toggle"], + defaultValue: "dropdown", + group: "Item 2", + tooltip: "Input type for second item" + }), + item2DefaultValue: props.Number({ + name: "Item 2 Default Value", + defaultValue: 2, + group: "Item 2", + tooltip: "Default quantity or value for second item" + }), + item2UnitPrice: props.Number({ + name: "Item 2 Unit Price", + defaultValue: 15, + group: "Item 2", + tooltip: "Price per unit for second item" + }), + item2DropdownOptions: props.Text({ + name: "Item 2 Dropdown Options", + defaultValue: "50GB|1|15\n100GB|2|25\n500GB|3|75", + group: "Item 2", + tooltip: "Dropdown options for second item (one per line, format: label|value|price)" + }), + item2Visible: props.Visibility({ + name: "Item 2 Visible", + group: "Item 2", + tooltip: "Show or hide second line item" + }), + item3Label: props.Text({ + name: "Item 3 Label", + defaultValue: "Priority Support", + group: "Item 3", + tooltip: "Label for third line item" + }), + item3Type: props.Variant({ + name: "Item 3 Type", + options: ["number", "dropdown", "toggle"], + defaultValue: "toggle", + group: "Item 3", + tooltip: "Input type for third item" + }), + item3DefaultValue: props.Number({ + name: "Item 3 Default Value", + defaultValue: 0, + group: "Item 3", + tooltip: "Default quantity or value for third item (0 or 1 for toggle)" + }), + item3UnitPrice: props.Number({ + name: "Item 3 Unit Price", + defaultValue: 99, + group: "Item 3", + tooltip: "Price per unit for third item" + }), + item3DropdownOptions: props.Text({ + name: "Item 3 Dropdown Options", + defaultValue: "None|0|0\nEnabled|1|99", + group: "Item 3", + tooltip: "Dropdown options for third item (one per line, format: label|value|price)" + }), + item3Visible: props.Visibility({ + name: "Item 3 Visible", + group: "Item 3", + tooltip: "Show or hide third line item" + }), + item4Label: props.Text({ + name: "Item 4 Label", + defaultValue: "API Access", + group: "Item 4", + tooltip: "Label for fourth line item" + }), + item4Type: props.Variant({ + name: "Item 4 Type", + options: ["number", "dropdown", "toggle"], + defaultValue: "toggle", + group: "Item 4", + tooltip: "Input type for fourth item" + }), + item4DefaultValue: props.Number({ + name: "Item 4 Default Value", + defaultValue: 0, + group: "Item 4", + tooltip: "Default quantity or value for fourth item (0 or 1 for toggle)" + }), + item4UnitPrice: props.Number({ + name: "Item 4 Unit Price", + defaultValue: 49, + group: "Item 4", + tooltip: "Price per unit for fourth item" + }), + item4DropdownOptions: props.Text({ + name: "Item 4 Dropdown Options", + defaultValue: "None|0|0\nEnabled|1|49", + group: "Item 4", + tooltip: "Dropdown options for fourth item (one per line, format: label|value|price)" + }), + item4Visible: props.Visibility({ + name: "Item 4 Visible", + group: "Item 4", + tooltip: "Show or hide fourth line item" + }), + showSubtotals: props.Boolean({ + name: "Show Subtotals", + defaultValue: true, + group: "Display", + tooltip: "Display individual subtotals for each line item in results" + }), + showUnitPrices: props.Boolean({ + name: "Show Unit Prices", + defaultValue: true, + group: "Display", + tooltip: "Display unit prices for each line item in results" + }), + totalLabel: props.Text({ + name: "Total Label", + defaultValue: "Total Monthly Cost", + group: "Content", + tooltip: "Label for the total amount" + }), + ctaText: props.Text({ + name: "CTA Text", + defaultValue: "Get Your Quote", + group: "Content", + tooltip: "Call-to-action button text" + }), + ctaLink: props.Link({ + name: "CTA Link", + group: "Content", + tooltip: "Call-to-action button link destination" + }), + ctaSubtext: props.Text({ + name: "CTA Subtext", + defaultValue: "No credit card required. Get a detailed quote in minutes.", + group: "Content", + tooltip: "Helper text below the CTA button" + }), + showCtaSubtext: props.Boolean({ + name: "Show CTA Subtext", + defaultValue: true, + group: "Display", + tooltip: "Display helper text below CTA button" + }), + }, +}); \ No newline at end of file diff --git a/quote-builder/src/components/QuoteBuilder/QuoteBuilderSimple.webflow.tsx b/quote-builder/src/components/QuoteBuilder/QuoteBuilderSimple.webflow.tsx new file mode 100644 index 0000000..538c75d --- /dev/null +++ b/quote-builder/src/components/QuoteBuilder/QuoteBuilderSimple.webflow.tsx @@ -0,0 +1,130 @@ +import QuoteBuilder from "./QuoteBuilder"; +import { props } from "@webflow/data-types"; +import { declareComponent } from "@webflow/react"; +import "./QuoteBuilder.css"; + +export default declareComponent(QuoteBuilder, { + name: "QuoteBuilder (Simple)", + description: "An interactive pricing calculator with a two-column layout that displays input controls on the left and a live-updating results panel on the right (stacking vertically on mobile). The input section contains labeled number fields with increment/decrement buttons, dropdown selects for option choices, and toggle switches for add-ons. The results panel shows a breakdown of line items with labels, quantities, unit prices, and subtotals, plus a prominent total with configurable currency formatting. All calculations update in real-time as users interact with inputs. The component features a clean card-based design with clear visual separation between input sections and the results panel, and includes a call-to-action button at the bottom for quote requests or contact.", + group: "Interactive", + options: { + ssr: false, + applyTagSelectors: true + }, + props: { + id: props.Id({ + name: "Element ID", + group: "Settings", + tooltip: "HTML ID attribute for targeting with CSS or JavaScript" + }), + heading: props.TextNode({ + name: "Heading", + defaultValue: "Get Your Custom Quote", + group: "Content", + tooltip: "Main heading for the calculator" + }), + item1Label: props.Text({ + name: "Item 1 Label", + defaultValue: "Team Members", + group: "Item 1", + tooltip: "Label for first line item" + }), + item1DefaultValue: props.Number({ + name: "Item 1 Default Value", + defaultValue: 5, + group: "Item 1", + tooltip: "Default quantity or value for first item" + }), + item1UnitPrice: props.Number({ + name: "Item 1 Unit Price", + defaultValue: 25, + group: "Item 1", + tooltip: "Price per unit for first item" + }), + item1Visible: props.Visibility({ + name: "Item 1 Visible", + group: "Item 1", + tooltip: "Show or hide first line item" + }), + item2Label: props.Text({ + name: "Item 2 Label", + defaultValue: "Storage Space (GB)", + group: "Item 2", + tooltip: "Label for second line item" + }), + item2DefaultValue: props.Number({ + name: "Item 2 Default Value", + defaultValue: 2, + group: "Item 2", + tooltip: "Default quantity or value for second item" + }), + item2UnitPrice: props.Number({ + name: "Item 2 Unit Price", + defaultValue: 15, + group: "Item 2", + tooltip: "Price per unit for second item" + }), + item2Visible: props.Visibility({ + name: "Item 2 Visible", + group: "Item 2", + tooltip: "Show or hide second line item" + }), + item3Label: props.Text({ + name: "Item 3 Label", + defaultValue: "Priority Support", + group: "Item 3", + tooltip: "Label for third line item" + }), + item3DefaultValue: props.Number({ + name: "Item 3 Default Value", + defaultValue: 0, + group: "Item 3", + tooltip: "Default quantity or value for third item (0 or 1 for toggle)" + }), + item3UnitPrice: props.Number({ + name: "Item 3 Unit Price", + defaultValue: 99, + group: "Item 3", + tooltip: "Price per unit for third item" + }), + item3Visible: props.Visibility({ + name: "Item 3 Visible", + group: "Item 3", + tooltip: "Show or hide third line item" + }), + item4Label: props.Text({ + name: "Item 4 Label", + defaultValue: "API Access", + group: "Item 4", + tooltip: "Label for fourth line item" + }), + item4DefaultValue: props.Number({ + name: "Item 4 Default Value", + defaultValue: 0, + group: "Item 4", + tooltip: "Default quantity or value for fourth item (0 or 1 for toggle)" + }), + item4UnitPrice: props.Number({ + name: "Item 4 Unit Price", + defaultValue: 49, + group: "Item 4", + tooltip: "Price per unit for fourth item" + }), + item4Visible: props.Visibility({ + name: "Item 4 Visible", + group: "Item 4", + tooltip: "Show or hide fourth line item" + }), + ctaText: props.Text({ + name: "CTA Text", + defaultValue: "Get Your Quote", + group: "Content", + tooltip: "Call-to-action button text" + }), + ctaLink: props.Link({ + name: "CTA Link", + group: "Content", + tooltip: "Call-to-action button link destination" + }), + }, +}); \ No newline at end of file diff --git a/quote-builder/src/main.tsx b/quote-builder/src/main.tsx new file mode 100644 index 0000000..a82512f --- /dev/null +++ b/quote-builder/src/main.tsx @@ -0,0 +1,371 @@ +import { StrictMode, useState } from "react" +import { createRoot } from "react-dom/client" +import QuoteBuilder from "./components/QuoteBuilder/QuoteBuilder" +import "./components/QuoteBuilder/QuoteBuilder.css" + +type ThemeVars = { + '--background-primary': string + '--background-secondary': string + '--text-primary': string + '--text-secondary': string + '--border-color': string + '--accent-color': string + '--accent-text-color': string + '--border-radius': string +} + +const themes: Record = { + light: { + '--background-primary': '#ffffff', + '--background-secondary': '#f5f5f5', + '--text-primary': '#1a1a1a', + '--text-secondary': '#737373', + '--border-color': '#e5e5e5', + '--accent-color': '#2563eb', + '--accent-text-color': '#ffffff', + '--border-radius': '8px' + }, + dark: { + '--background-primary': '#0a0a0a', + '--background-secondary': '#1a1a1a', + '--text-primary': '#fafafa', + '--text-secondary': '#a3a3a3', + '--border-color': '#2a2a2a', + '--accent-color': '#3b82f6', + '--accent-text-color': '#ffffff', + '--border-radius': '8px' + }, + brand: { + '--background-primary': '#fef7f0', + '--background-secondary': '#fde8d0', + '--text-primary': '#1c1917', + '--text-secondary': '#78716c', + '--border-color': '#e7e5e4', + '--accent-color': '#ea580c', + '--accent-text-color': '#ffffff', + '--border-radius': '12px' + } +} + +function App() { + const [activeTheme, setActiveTheme] = useState<'light' | 'dark' | 'brand' | 'custom'>('light') + const [customVars, setCustomVars] = useState(themes.light) + + const currentVars = activeTheme === 'custom' ? customVars : themes[activeTheme] + + const handleThemeChange = (theme: 'light' | 'dark' | 'brand' | 'custom') => { + setActiveTheme(theme) + if (theme !== 'custom') { + setCustomVars(themes[theme]) + } + } + + const handleCustomVarChange = (key: keyof ThemeVars, value: string) => { + setCustomVars(prev => ({ ...prev, [key]: value })) + } + + const pageBackground = activeTheme === 'dark' ? '#000000' : activeTheme === 'brand' ? '#fef3e8' : '#fafafa' + + return ( +
+
+
+

QuoteBuilder Preview

+

Local development environment - test different themes and configurations

+ +
+ + + + +
+ + {activeTheme === 'custom' && ( +
+

Custom Theme Editor

+
+ {Object.entries(customVars).map(([key, value]) => ( +
+ +
+ handleCustomVarChange(key as keyof ThemeVars, e.target.value)} + style={{ + width: '50px', + height: '38px', + border: `1px solid ${currentVars['--border-color']}`, + borderRadius: '6px', + cursor: 'pointer' + }} + /> + handleCustomVarChange(key as keyof ThemeVars, e.target.value)} + style={{ + flex: 1, + padding: '8px 12px', + border: `1px solid ${currentVars['--border-color']}`, + borderRadius: '6px', + background: currentVars['--background-primary'], + color: currentVars['--text-primary'], + fontSize: '14px', + fontFamily: 'monospace' + }} + /> +
+
+ ))} +
+
+ )} +
+ +
+
+

Default Configuration

+ +
+ +
+

Stacked Layout - Simple Pricing

+ +
+ +
+

Minimal Configuration - Two Items Only

+ +
+
+
+
+ ) +} + +createRoot(document.getElementById("root")!).render( + + + +) \ No newline at end of file diff --git a/quote-builder/src/vite-env.d.ts b/quote-builder/src/vite-env.d.ts new file mode 100644 index 0000000..151aa68 --- /dev/null +++ b/quote-builder/src/vite-env.d.ts @@ -0,0 +1 @@ +/// \ No newline at end of file diff --git a/quote-builder/tsconfig.app.json b/quote-builder/tsconfig.app.json new file mode 100644 index 0000000..d775f2a --- /dev/null +++ b/quote-builder/tsconfig.app.json @@ -0,0 +1,27 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsBuildInfo", + "target": "ES2022", + "useDefineForClassFields": true, + "lib": [ + "ES2022", + "DOM", + "DOM.Iterable" + ], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "noEmit": true, + "jsx": "react-jsx", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "include": [ + "src" + ] +} \ No newline at end of file diff --git a/quote-builder/tsconfig.json b/quote-builder/tsconfig.json new file mode 100644 index 0000000..65f670c --- /dev/null +++ b/quote-builder/tsconfig.json @@ -0,0 +1,11 @@ +{ + "files": [], + "references": [ + { + "path": "./tsconfig.app.json" + }, + { + "path": "./tsconfig.node.json" + } + ] +} \ No newline at end of file diff --git a/quote-builder/tsconfig.node.json b/quote-builder/tsconfig.node.json new file mode 100644 index 0000000..c4a9a48 --- /dev/null +++ b/quote-builder/tsconfig.node.json @@ -0,0 +1,23 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsBuildInfo", + "target": "ES2023", + "lib": [ + "ES2023" + ], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "noEmit": true, + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "include": [ + "vite.config.ts" + ] +} \ No newline at end of file diff --git a/quote-builder/vite.config.ts b/quote-builder/vite.config.ts new file mode 100644 index 0000000..c7a4f78 --- /dev/null +++ b/quote-builder/vite.config.ts @@ -0,0 +1,6 @@ +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; + +export default defineConfig({ + plugins: [react()], +}); \ No newline at end of file diff --git a/quote-builder/webflow.json b/quote-builder/webflow.json new file mode 100644 index 0000000..a2b55db --- /dev/null +++ b/quote-builder/webflow.json @@ -0,0 +1,10 @@ +{ + "library": { + "name": "QuoteBuilder", + "components": [ + "./src/**/*.webflow.@(js|jsx|mjs|ts|tsx)" + ], + "description": "An interactive pricing calculator with a two-column layout that displays input controls on the left and a live-updating results panel on the right (stacking vertically on mobile). The input section contains labeled number fields with increment/decrement buttons, dropdown selects for option choices, and toggle switches for add-ons. The results panel shows a breakdown of line items with labels, quantities, unit prices, and subtotals, plus a prominent total with configurable currency formatting. All calculations update in real-time as users interact with inputs. The component features a clean card-based design with clear visual separation between input sections and the results panel, and includes a call-to-action button at the bottom for quote requests or contact.", + "id": "quote-builder" + } +} \ No newline at end of file