# E-commerce cut-list calculator

URL: https://smartcut.dev/docs/ecommerce-checkout

> Embedded JavaScript widget for cut-list calculation on e-commerce websites

An embedded JavaScript widget that provides a complete cut-list calculator for your e-commerce, estimation or quotation website functionality.

## Overview

SmartCut Checkout allows you to:

- Embed a cut-list calculator directly on your website
- Calculate prices / estimates in real-time on the client side
- Filter & sort products in your catalogue
- Customize the UI to match your brand
- Support multiple materials, thicknesses, dimensions, extras, and configurations
- No server-side integration required

## Quick Start

### Installation

Include the script and default css styling in your HTML:

```html
<link rel="stylesheet" href="https://cutlistevo.com/checkout/checkout.css" />
<script type="module" src="https://cutlistevo.com/checkout/checkout.js"></script>
```

### Basic Usage

```html
<!-- Container for the calculator -->
<div id="smartcut-app"></div>

<script>
// Configuration
const initData = {
  stock: [
    {
      l: 2440,
      w: 1220,
      t: 18,
      cost: 65,
      material: 'Plywood'
    }
  ],
  saw: {
    stockType: 'sheet',
    bladeWidth: 3.2,
    cutType: 'efficiency',
    cutPreference: 'l'
  },
  options: {
    locale: 'en-US',
    currency: 'USD'
  }
}

// Wait for the ready event and then call init
window.addEventListener('smartcut/ready', () => {
  window.smartcutCheckout.init(initData)
})

// Listen for calculation events
window.addEventListener('smartcut/calculating', () => {
  console.log('Calculation started')
})

// Listen for results
window.addEventListener('smartcut/result', (e) => {
  const result = e.detail
  console.log('Optimization complete:', result)
  console.log('Total stock cost:', result.checkout.formattedTotalStockCost)
})
</script>
```

## Configuration

### Stock Configuration

Define the available stock materials:

```javascript
const initData = {
  stock: [
    {
      l: 2440,
      w: 1220,
      t: 18,
      cost: 65,
      material: 'Plywood'
    }
  ],
  saw: {
    stockType: 'sheet',
    bladeWidth: 3.2,
    cutPreference: 'l'
  },
  options: {
    locale: 'en-US',
    currency: 'USD',
    apiVersion: 3 // 2 or 3 (default: 3)
  }
}
```

**Stock Item Properties:**

| Property | Type | Description |
|----------|------|-------------|
| `l` | number | Sheet length in mm (required) |
| `w` | number | Sheet width in mm (required) |
| `t` | number | Sheet thickness in mm (required) |
| `material` | string | Friendly display name shown in the material picker (required) |
| `code` | string | Unique SKU / identifier for this specific stock item (recommended — see below) |
| `cost` | number | Cost per sheet |
| `weight` | number | Weight in kg |
| `description` | string | Description shown in the picker |
| `imageUrl` | string | Product image URL |
| `brand` | string | Brand name (shown as picker hierarchy) |
| `variant` | string | Variant / decor name |
| `db_id` | string | Your database ID — passed through to results for lookup |
| `fullSizeOnly` | boolean | When `true`, the customer cannot enter custom part sizes on this material |
| `grain` | `'l'` \| `'w'` \| `''` | Grain direction of this stock (length-aligned, width-aligned, or none) |
| `preventGrainRotation` | boolean | When `true`, parts placed on this stock cannot be rotated against grain |
| `category` | string | Category for filtering (used by Product Filtering UI) |
| `tags` | string[] | Searchable tags (used by Product Filtering UI) |
| `available` | boolean | Available-for-purchase flag (used by Product Filtering UI) |
| `imageUrl` | string | Product image URL — surfaced in results too |
| `db_sawId` | string | Mongo ObjectId of the saw to use when this stock is picked. Lets you pin different materials to different saws per pick — see [Per-Pick Saw Mapping](#per-pick-saw-mapping) |

#### How the material picker is built

Stock items are **grouped by their `material` field** into a single picker entry. Multiple items with the same `material` but different dimensions (e.g. different thicknesses or sheet sizes) all appear under one dropdown row — the customer selects the material once, then the calculator filters available thicknesses automatically.

The **`code` field** is the per-item identifier: it distinguishes between individual stock rows that share the same `material` name, and it is carried through to the calculation result so you can map each used sheet back to a specific product in your catalogue.

```javascript
// Two sheet sizes of the same material — one picker entry, two thickness options
stock: [
  { l: 2440, w: 1220, t: 18, cost: 65, material: 'Birch Plywood', code: 'BPW-2440-18' },
  { l: 2440, w: 1220, t: 12, cost: 48, material: 'Birch Plywood', code: 'BPW-2440-12' },
]

// Two different materials — two picker entries
stock: [
  { l: 2440, w: 1220, t: 18, cost: 65, material: 'Birch Plywood',  code: 'BPW-18' },
  { l: 2440, w: 1220, t: 18, cost: 45, material: 'White MDF',      code: 'MDF-18' },
]
```

When `code` is omitted, the calculator falls back to the composed label (`brand` + `variant` + `material`) as the grouping key — this works for simple setups but can cause items to appear as separate entries if any field differs between rows of the same material. **Always supply `code` when passing multiple stock rows.**

### Parts Configuration

Pre-populate the calculator with parts at init time. Parts arrive as an array on the `parts` key of the init payload:

```javascript
const initData = {
  stock: [...],
  parts: [
    {
      l: 600,
      w: 400,
      t: 18,
      q: 4,
      name: 'Shelf',
      material: 'Plywood',
      orientationLock: 'l', // 'l', 'w', or '' (free)
      banding: {
        sides: { l1: 'oak|1mm', l2: 'oak|1mm', w1: '', w2: '' }
      },
      finish: {
        faces: { a: 'spray|matt', b: '' }
      },
      customData: { projectId: '12345', orderRef: 'ORD-001' }
    }
  ],
  options: {...}
}
```

Pre-populated parts pass through validation, defaults are applied, then each part is added to the calculator's parts list (existing parts are cleared first). Per-part validation issues are surfaced as inline errors in the UI rather than blocking the whole init.

**Part Properties:**
| Property | Type | Description |
|----------|------|-------------|
| `l` | number | Part length in mm (defaults to 1 if missing) |
| `w` | number | Part width in mm (defaults to 1 if missing) |
| `t` | number \| string | Thickness (optional, inherits from stock if not set) |
| `q` | number | Quantity (default: 1) |
| `name` | string | Part name/label. **Normalised to UPPERCASE** on intake. |
| `material` | string | Material identifier for stock matching. **Normalised to UPPERCASE** on intake — match this against the uppercase form of your stock's `material` field. |
| `orientationLock` | `'l'` \| `'w'` \| `''` | Orientation constraint: length-locked, width-locked, or free |
| `banding` | object | Edge banding — V3 nested form: `{ sides: { l1, l2, w1, w2 } }`. Legacy V2 flat form (`{ x1, x2, y1, y2 }` and `0`/`1` boolean shorthands) is accepted and migrated automatically. |
| `finish` | object | Surface finish — V3 nested form: `{ faces: { a, b } }`. Legacy flat `{ a, b }` (with `0`/`1`) is accepted and migrated. |
| `planing` | object | Planing configuration |
| `customData` | object | Arbitrary key-value data attached to the part. **This is the only field that round-trips** — it appears verbatim on the matching part in the `smartcut/result` event, so it's the supported way to attach your own identifiers (order refs, line-item IDs, etc.) for reconciliation. |
| `stockId` | string | Lock part to a specific stock item (by stock `code`) |
| `stock` | object | Stock reference: `{ db_id?, code?, material?, thickness? }` — locks the part to the stock it originated from. Useful when pre-populating from an existing cut list. |

**Pre-populating after init.** As an alternative to passing parts on `init`, host code can fire a `smartcut/load` window event whose `detail` matches the calculate-data structure (see [`smartcut/load` event](#smartcutload-input-event)). The calculator will clear existing parts and import the supplied ones the same way as init-time pre-population.

### Saw Options

Saw configuration should be provided in the `saw` property (recommended) or in `options` for backward compatibility.

#### Complete Saw Configuration
```javascript
saw: {
  stockType: 'sheet', // 'sheet', 'linear', or 'roll'
  bladeWidth: 3.2, // Blade width in mm
  cutType: 'guillotine', // 'efficiency', 'guillotine', 'beam'
  cutPreference: 'l', // 'l' (length), 'w' (width)
  stackHeight: 60, // Maximum stack height in mm (beam saws only)
  efficiencyOptions: { // Optional efficiency settings
    primaryCompression: 'l' // 'l' (length) or 'w' (width) - primary compression direction
  },
  guillotineOptions: { // Optional guillotine settings
    strategy: 'efficiency', // 'efficiency' or 'time'
    maxPhase: 0, // Maximum cutting phase depth (0 = unlimited, 1-10)
    headCuts: false // Enable head cuts for optimization
  },
  options: { // Additional saw options
    stockSelection: 'efficiency', // 'efficiency' or 'smallest' - stock selection method
    stackingMode: 'identical', // 'dimensions', 'identical', or 'none' - part stacking mode
    minSpacing: 0 // Minimum spacing between parts in mm
  }
}
```

**Cut Type Values:**
- `'efficiency'` - Standard efficiency optimization
- `'guillotine'` - Guillotine cutting (only straight cuts across entire stock)
- `'beam'` - Beam saw optimization

**Cut Preference Values:**
- `'l'` - Prefer length-first cuts
- `'w'` - Prefer width-first cuts

**Stock Selection Values:**
- `'efficiency'` - Select stock that maximizes material efficiency (default)
- `'smallest'` - Prefer smaller stock sizes when possible

**Stacking Mode Values:**
- `'identical'` - Stack only parts with identical dimensions and properties (default)
- `'dimensions'` - Stack parts with same dimensions regardless of other properties
- `'none'` - Disable part stacking

### Configuration Options

#### API Version
```javascript
options: {
  apiVersion: 3 // 2 or 3 (default: 3)
}
```

**API Version Differences:**
- **Version 2**: Uses flat extras structure (`{ banding: { x1: string, x2: string, y1: string, y2: string } }`)
- **Version 3**: Uses nested extras structure (`{ extras: { banding: { sides: { l1: string, l2: string } } } }`)

See [Result Format](#result-format) for details on the differences between versions.

#### Emit API Result
```javascript
options: {
  enable: {
    emitAPIResult: true // Include full API v3 response in result (default: false)
  }
}
```

When enabled, the result object will include an `apiResultV3` property containing the complete API v3 response format. This is useful when you need the standardized API response structure regardless of which `apiVersion` is configured for the checkout result.

See [API Result V3](#api-result-v3) in the Result Format section for the structure.

#### Debug Mode
```javascript
options: {
  enable: {
    debug: true // Enable debug output in console (default: false)
  }
}
```

When enabled, additional debug information will be logged to the browser console during calculations.

#### Unit System
```javascript
options: {
  unitSystem: 'metric' // 'metric' or 'imperial' (default: 'metric')
}
```

Controls the unit system used for display and input. `'imperial'` switches dimensions to inches in the UI. Internally all values remain in millimetres.

#### Stock Grain
```javascript
options: {
  stockGrain: 'l' // 'l', 'w', or '' — default grain direction applied to stock items without an explicit grain field
}
```

#### Number Format
```javascript
options: {
  numberFormat: 'decimal', // 'decimal' or 'fraction'
  decimalPlaces: 2,
  fractionRoundTo: 0 // For fraction formatting
}
```

#### Orientation Model
```javascript
options: {
  orientationModel: 0, // 0, 1, or 2 (controls how INPUT parts handle orientation)
  resultOrientationModel: 0 // 0, 1, or 2 (controls how RESULT parts are reported)
}
```

**Orientation Model Values:**
- `0` - No orientation control
- `1` - Grain lock (parts maintain grain direction)
- `2` - Dimension lock (length always maps to longer dimension)

`resultOrientationModel` lets you display results in a different orientation convention than the inputs (e.g. accept any orientation on input but always report results with length on the longer side).

#### Part Limits
```javascript
options: {
  maxParts: 100,    // Maximum parts per order (0 = unlimited, default: 0)
  minDimension: 10, // Minimum allowed part dimension in mm (default: 0)
  partTrim: 0,      // Amount to trim from each part dimension before optimisation, in mm
  minSpacing: 0     // Minimum spacing between adjacent parts in mm
}
```

`partTrim` is applied symmetrically to `l` and `w` at calculation time — useful when you want to optimise on rough sizes but cut to a finished size. `minSpacing` adds a gap between parts independent of blade kerf.

#### Pagination
```javascript
options: {
  partsPerPage: 10, // Parts shown per page when pagination enabled
  enable: {
    pagination: true // Enable pagination for parts list
  }
}
```

#### Field Order
```javascript
options: {
  fieldOrder: 'l, w, t, q, name, orientation' // Custom field display order
}
```

Control the order of input fields in the parts form. Provide a comma-separated list of field IDs.

#### Custom Fields
```javascript
options: {
  customFields: [
    {
      type: 'string',
      label: 'Project Name',
      id: 'projectName',
      placeholder: 'Enter project name'
    },
    {
      type: 'integer',
      label: 'Quantity',
      id: 'quantity',
      default: 1
    },
    {
      type: 'select',
      label: 'Finish',
      id: 'finish',
      outputType: 'string',
      options: [
        { label: 'Matt', value: 'matt' },
        { label: 'Gloss', value: 'gloss' }
      ]
    },
    {
      type: 'checkbox',
      label: 'Rush Order',
      id: 'rush',
      trueValue: 'yes',
      falseValue: 'no',
      default: 'no'
    }
  ]
}
```

#### UI Customization

Control colors, enabled features, and field order:

```javascript
options: {
  enable: {
    // Extras + machining
    banding: false,       // Edge banding column on parts (default: false)
    finish: false,        // Finishing column on parts (default: false)
    planing: false,       // Planing column on parts (default: false)
    machining: false,     // Machining operations (holes, corners) (default: false)

    // Parts list UI
    orientation: true,    // Part orientation lock control (default: true)
    partName: true,       // Allow naming parts (default: true)
    pagination: false,    // Paginate parts list (default: false)
    progressNumber: true, // Show progress/step numbers in UI (default: true)
    focus: true,          // Highlight selected part in diagram on row focus (default: true)
    click: true,          // Allow clicking parts in the diagram to select them (default: true)
    groups: false,        // User-defined part groups (default: false)
    fullStock: false,     // "Full sheet" toggle per part — purchase entire sheet without cutting (default: false)
    imageUpload: false,   // Image upload column on parts (default: false)

    // Diagram
    diagram: true,        // Show cutting diagram (default: true)
    diagramNav: false,    // Diagram navigation controls when multiple sheets (default: false)

    // Import
    csvImport: false,     // CSV import for parts (default: false)
    csvTemplate: false,   // Show CSV template download link in import dialog (default: false)

    // Diagnostics
    debug: false          // Verbose debug logging — also flippable via `?debug` URL param (default: false)
  },
  colors: {
    partA: '#118ab2',
    partB: '#06d6a0',            // Alternating part color
    stock: '#ffd166',
    button: '#118ab2',           // Required
    buttonText: '#ffffff',       // Required
    headerBackground: '#ffffff', // Header background color
    headerText: '#212529'        // Header text color
  }
}
```

When `enable.fullStock` is on, parts can be toggled to "full sheet" mode. If multiple sheet sizes exist for the chosen material, a dimension picker dialog opens; if `fullSizeOnly: true` is set on the stock, the part is locked into full-sheet mode automatically.

### Extras Configuration

Extras are additional services like edge banding, finishing, planing, and machining that can be applied to parts with custom pricing.

#### Basic Extras Setup

```javascript
const initData = {
  stock: [...],

  // Edge banding configuration
  banding: {
    labels: ['type', 'thickness'],
    pricing: {
      'oak|1mm': 1.0,
      'oak|2mm': 1.1,
      'pine|1mm': 2.0,
      'maple|1mm': 3.0
    }
  },

  // Finish configuration
  finish: {
    labels: ['type', 'style'],
    pricing: {
      'spray|matt': 1.0,
      'spray|satin': 1.1,
      'lacquer|matt': 2.0,
      'lacquer|satin': 2.2
    }
  },

  // Planing configuration
  planing: {
    labels: ['type'],
    pricing: {
      'standard': 0.5,
      'premium': 0.8
    }
  },

  options: {...}
}
```

> **Labels must match the pricing levels.** Each `pricing` key is a `|`-joined tuple with one choice per entry in `labels`. The number of `labels` must equal the number of `|`-separated levels in **every** key — e.g. `labels: ['type', 'thickness']` (2) pairs with `'oak|1mm'` (2 levels). A key with the wrong level count (or a `labels` length that doesn't match) means the extra can't be built, and the widget shows **"&lt;Type&gt; options couldn't be loaded"**.
>
> An extra only renders when its type is enabled in `options.enable` (e.g. `options.enable.banding = true`). A disabled type ships no UI even if `labels`/`pricing` are present — and produces no error.

#### Catalogue-linked extras (admin-configured)

Banding and finish can instead be **linked to a catalogue** in the SmartCut admin (the **Extras** tab) rather than defined inline here. When a shop uses a catalogue-linked extra, SmartCut supplies its options to the widget automatically and the customer picks from an **on-demand cascade** (for example decor → finish → colour) instead of the inline `labels`/`pricing` grid above.

For these you don't author `labels`/`pricing` — the catalogue owns both the option schema and the pricing, and large lists (such as an edge-banding decor range) are loaded on demand rather than embedded in the config. CSV-imported banding lists are handled this way. This is transparent to the embedding page: the widget renders the appropriate picker per extra. The inline `labels`/`pricing` form documented above is for shops that define their extras directly in `init()`.

#### Extras Location Filtering

Control which sides and faces are available for each extra type by adding a `locations` property directly to each extra configuration:

```javascript
// Available sides: 'side.l1', 'side.l2', 'side.w1', 'side.w2' (main edges)
//                  'side.a', 'side.b', 'side.c', 'side.d' (corners)
// Available faces: 'face.a', 'face.b'

banding: {
  labels: ['material', 'thickness'],
  pricing: {...},
  locations: ['side.l1', 'side.l2', 'side.w1', 'side.w2'] // Only main edges
},

finish: {
  labels: ['type'],
  pricing: {...},
  locations: ['face.a', 'face.b'] // Both faces
},

planing: {
  labels: ['type'],
  pricing: {...},
  locations: ['side.w1', 'side.w2', 'face.a', 'face.b'] // Edges and faces
}
```

#### Extras Location Groups

Create custom groupings of locations for bulk operations with optional pricing by adding a `groups` array directly to each extra configuration:

```javascript
planing: {
  labels: ['type'],
  pricing: {
    'standard': 0.5,
    'premium': 0.8
  },
  locations: ['side.w1', 'side.w2', 'face.a', 'face.b'],
  groups: [
    {
      id: 'two-sided',
      label: '2 sided',
      locations: ['face.a', 'face.b'],
      price: 100,
      hideIndividualLocations: true // Optional: hide individual location controls when group is available
    },
    {
      id: 'four-sided',
      label: '4 sided',
      locations: ['face.a', 'face.b', 'side.w1', 'side.w2'],
      price: 200,
      hideIndividualLocations: true,
      // Optional: Group-level validation rules
      rules: {
        longSide: { min: 200 },
        shortSide: { min: 150 },
        message: '4-sided planing requires minimum 200x150mm parts'
      }
    }
  ]
},

banding: {
  labels: ['material'],
  pricing: {...},
  groups: [
    {
      id: 'all-corners',
      label: 'All Corners',
      locations: ['side.a', 'side.b', 'side.c', 'side.d']
    },
    {
      id: 'long-sides',
      label: 'Long Sides',
      locations: ['side.l1', 'side.l2']
    }
  ]
}
```

**Group Properties:**
- `id` (required) - Unique identifier for the group
- `label` (required) - Display name shown in the UI
- `locations` (required) - Array of location strings included in this group
- `price` (optional) - Fixed price for the entire group (overrides individual location pricing)
- `hideIndividualLocations` (optional) - When `true`, hides the individual location controls for locations in this group
- `rules` (optional) - Validation rules that apply when using this group (see [Group-Level Validation Rules](#group-level-validation-rules))

#### Extras Validation Rules

Apply dimension constraints to control which parts can have extras applied by adding a `rules` property directly to each extra configuration:

```javascript
planing: {
  labels: ['type'],
  pricing: {...},
  locations: [...],
  rules: {
    t: {
      min: 8,
      max: 230
    },
    shortSide: {
      min: 10,
      max: 620
    },
    longSide: {
      max: 1000
    },
    message: 'Planing requires thickness 8-230mm, short side 10-620mm, and long side max 1000mm'
  }
},

banding: {
  labels: ['material'],
  pricing: {...},
  rules: {
    t: {
      min: 12,
      max: 30
    },
    message: 'Banding is only available for 12-30mm thick materials'
  }
},

finish: {
  labels: ['type'],
  pricing: {...},
  rules: {
    longSide: {
      max: 2400
    },
    shortSide: {
      max: 1200
    },
    message: 'Finish is only available for parts smaller than 2400x1200mm'
  }
}
```

**Validation Rule Fields:**
- `longSide.min` / `longSide.max` - Constraints on the longer dimension
- `shortSide.min` / `shortSide.max` - Constraints on the shorter dimension
- `t.min` / `t.max` - Thickness constraints
- `formula` (optional) - JavaScript expression for complex validation (can reference `longSide`, `shortSide`, `t`)
- `message` - Custom error message shown when validation fails
- `locations` (optional) - Array of location-specific rules (see [Location-Specific Validation Rules](#location-specific-validation-rules))

When a part doesn't meet the validation rules, the extras options will be disabled and an error message displayed.

#### Location-Specific Validation Rules

Apply different validation constraints to individual locations within an extra type. This allows fine-grained control over which parts can use specific edges or faces:

```javascript
banding: {
  labels: ['material'],
  pricing: {...},
  rules: {
    // Type-level rules (apply to all locations by default)
    longSide: { min: 100 },
    shortSide: { min: 50 },
    message: 'Banding requires minimum 100x50mm parts',

    // Location-specific rules (override type-level for specific locations)
    locations: [
      {
        location: 'side.l1',
        longSide: { min: 500 },
        message: 'L1 banding only available for parts >= 500mm long side'
      },
      {
        location: 'side.l2',
        longSide: { min: 500 },
        message: 'L2 banding only available for parts >= 500mm long side'
      }
    ]
  }
}
```

**Location Rule Properties:**
- `location` (required) - The location this rule applies to (e.g., `'side.l1'`, `'face.a'`)
- `longSide` / `shortSide` / `t` - Dimension constraints for this location
- `formula` (optional) - JavaScript expression for complex validation
- `message` (optional) - Custom error message for this location

#### Group-Level Validation Rules

Groups can have their own validation rules that apply when any location in the group is selected:

```javascript
planing: {
  labels: ['type'],
  pricing: {...},
  groups: [
    {
      id: 'four-sided',
      label: '4 Sided',
      locations: ['face.a', 'face.b', 'side.w1', 'side.w2'],
      price: 200,
      rules: {
        longSide: { min: 200 },
        shortSide: { min: 150 },
        t: { min: 18 },
        message: '4-sided planing requires minimum 200x150x18mm parts'
      }
    }
  ]
}
```

#### Rule Precedence

When multiple rules could apply to a location, the most specific rule wins:

1. **Location-specific rule** - Rules defined in `rules.locations[]` for a specific location
2. **Group rule** - Rules defined on a group that contains the location
3. **Type-level rule** - The base rules for the extra type

Only one rule level applies per location - the most specific one completely overrides less specific rules.

**Example:**
```javascript
banding: {
  rules: {
    longSide: { min: 100 },  // Type-level: default for all locations
    locations: [
      { location: 'side.l1', longSide: { min: 500 } }  // Location-specific: overrides for l1
    ]
  },
  groups: [
    {
      id: 'all-sides',
      locations: ['side.l1', 'side.l2', 'side.w1', 'side.w2'],
      rules: { longSide: { min: 200 } }  // Group rule: applies to group members without location rules
    }
  ]
}
```

In this example:
- `side.l1`: Uses location rule (min 500mm) - most specific
- `side.l2`, `side.w1`, `side.w2`: Use group rule (min 200mm) - group is more specific than type
- Any other location: Uses type-level rule (min 100mm) - fallback

### Part Validation Rules

Apply dimension constraints to parts themselves to control which parts can be processed by adding a `partRules` property to the init data:

```javascript
const initData = {
  stock: [...],

  // Part validation rules
  partRules: {
    // Basic dimension constraints
    l: {
      min: 50,
      max: 2400
    },
    w: {
      min: 20,
      max: 1200
    },
    t: {
      min: 12,
      max: 30
    },
    longSide: {
      min: 100,
      max: 2400
    },
    shortSide: {
      min: 50,
      max: 1200
    },

    // Cross-dimensional rule: at least one side must meet primaryMin,
    // and the other side must meet secondaryMin
    crossDimensionalRule: {
      primaryMin: 50,
      secondaryMin: 20
    },

    // Optional custom message
    message: 'At least one side must be ≥ 50 mm and the other side must be ≥ 20 mm'
  },

  options: {...}
}
```

**Part Validation Rule Fields:**
- `l.min` / `l.max` - Length constraints
- `w.min` / `w.max` - Width constraints
- `t.min` / `t.max` - Thickness constraints
- `longSide.min` / `longSide.max` - Constraints on the longer dimension
- `shortSide.min` / `shortSide.max` - Constraints on the shorter dimension
- `crossDimensionalRule` (optional) - Cross-dimensional validation
  - `primaryMin` - At least one side must be ≥ this value
  - `secondaryMin` - The other side must be ≥ this value
- `formula` (optional) - Formula expression for complex validation (can reference `l`, `w`, `t`, `longSide`, `shortSide`)
  - Supports arithmetic operators: `+`, `-`, `*`, `/`
  - Supports comparison operators: `>`, `<`, `>=`, `<=`, `==`
  - Supports logical operators: `&&` (AND), `||` (OR)
  - Supports ternary operator: `condition ? trueValue : falseValue`
- `message` - Custom error message shown when validation fails

**Example with formula:**
```javascript
partRules: {
  formula: '(l >= 50 && w >= 20) || (w >= 50 && l >= 20)',
  message: 'At least one side must be ≥ 50 mm and the other side must be ≥ 20 mm'
}
```

**Complex formula example:**
```javascript
partRules: {
  formula: '(l * w) > 100000 && t >= 12',
  message: 'Part must have area > 100,000 mm² and thickness ≥ 12 mm'
}
```

When a part doesn't meet the validation rules, an error message will be displayed below the part and calculation will be blocked until the issue is resolved.

### Custom Validation Rules

For more complex validation scenarios, you can use custom validation rules that support full access to part properties including extras and machining. This is configured via the EcommerceSettings admin page or by adding a `customValidation` property to the init data:

```javascript
const initData = {
  stock: [...],

  // Custom validation rules
  customValidation: {
    enabled: true,
    rules: [
      {
        id: 'min-size',
        enabled: true,
        name: 'Minimum Part Size',
        formula: 'longSide >= 100 && shortSide >= 50',
        message: 'Parts must be at least 100mm x 50mm'
      },
      {
        id: 'banding-thickness',
        enabled: true,
        name: 'Banding Thickness Check',
        formula: '!hasBanding || t >= 12',
        message: 'Banding requires minimum 12mm thickness'
      },
      {
        id: 'machining-thickness',
        enabled: true,
        name: 'Machining Thickness Check',
        formula: '!hasMachining || t >= 18',
        message: 'Machining operations require minimum 18mm thickness'
      }
    ]
  },

  options: {...}
}
```

**Custom Validation Rule Fields:**
- `id` - Unique identifier for the rule
- `enabled` - Whether the rule is active (default: true)
- `name` - User-friendly name for the rule (optional)
- `formula` - Condition that must evaluate to true for the part to be valid
- `message` - Error message shown when validation fails

**Available Variables:**

| Variable | Type | Description |
|----------|------|-------------|
| `l` | number | Part length |
| `w` | number | Part width |
| `t` | number | Part thickness |
| `q` | number | Part quantity |
| `longSide` | number | max(l, w) |
| `shortSide` | number | min(l, w) |
| `material` | string | Material name |
| `name` | string | Part name |
| `grain` | string | Grain direction |
| `fullStock` | boolean | Full stock purchase flag |
| `hasBanding` | boolean | True if any banding applied |
| `hasFinish` | boolean | True if any finish applied |
| `hasPlaning` | boolean | True if any planing applied |
| `hasMachining` | boolean | True if any machining operations |
| `extras.banding.sides.l1` | string | Banding on long side 1 |
| `extras.banding.sides.l2` | string | Banding on long side 2 |
| `extras.banding.sides.w1` | string | Banding on short side 1 |
| `extras.banding.sides.w2` | string | Banding on short side 2 |
| `extras.finish.faces.a` | string | Finish on face A |
| `extras.finish.faces.b` | string | Finish on face B |
| `machining.holes` | number | Number of holes |
| `machining.corners` | number | Number of corner operations |

**Supported Operators:**
- Arithmetic: `+`, `-`, `*`, `/`
- Comparison: `>`, `<`, `>=`, `<=`, `==`
- Logical: `&&` (AND), `||` (OR), `!` (NOT)
- Ternary: `condition ? trueValue : falseValue`
- Parentheses: `(expression)`

**Example Rules:**

```javascript
// Minimum area requirement
{
  formula: '(l * w) >= 10000',
  message: 'Parts must have minimum 10,000mm² area'
}

// Combined extras check
{
  formula: '!(hasBanding && hasFinish) || longSide >= 200',
  message: 'Parts with both banding and finish must be at least 200mm'
}

// Specific banding requirement
{
  formula: "extras.banding.sides.l1 == '' || t >= 15",
  message: 'Banding on long side 1 requires minimum 15mm thickness'
}

// Machining hole limit
{
  formula: 'machining.holes <= 10',
  message: 'Maximum 10 holes per part'
}
```

When a part fails custom validation, an error message will be displayed and calculation will be blocked until the issue is resolved.

#### Complete Extras Example

```javascript
const initData = {
  stock: [
    {
      l: 2440,
      w: 1220,
      t: 18,
      cost: 65,
      material: 'Plywood'
    }
  ],

  // Edge banding
  banding: {
    labels: ['material', 'thickness'],
    pricing: {
      'oak|1mm': 1.0,
      'oak|2mm': 1.2,
      'pine|1mm': 0.8,
      'maple|1mm': 1.5
    },
    locations: ['side.l1', 'side.l2', 'side.w1', 'side.w2'],
    rules: {
      t: { min: 12, max: 30 },
      message: 'Banding only available for 12-30mm thick materials'
    }
  },

  // Finishing
  finish: {
    labels: ['type', 'style'],
    pricing: {
      'spray|matt': 5.0,
      'spray|satin': 5.5,
      'lacquer|matt': 8.0,
      'lacquer|satin': 8.5
    },
    locations: ['face.a', 'face.b'],
    rules: {
      longSide: { max: 2400 },
      shortSide: { max: 1200 },
      message: 'Finish is only available for parts smaller than 2400x1200mm'
    }
  },

  // Planing
  planing: {
    labels: ['type'],
    pricing: {
      'standard': 2.0,
      'premium': 3.5
    },
    locations: ['face.a', 'face.b', 'side.w1', 'side.w2'],
    groups: [
      {
        id: 'two-sided',
        label: 'Two Sides (Top & Bottom)',
        locations: ['face.a', 'face.b'],
        price: 100,
        hideIndividualLocations: true
      },
      {
        id: 'four-sided',
        label: 'Four Sides (All Faces & Edges)',
        locations: ['face.a', 'face.b', 'side.w1', 'side.w2'],
        price: 200,
        hideIndividualLocations: true
      }
    ],
    rules: {
      t: { min: 8, max: 230 },
      shortSide: { min: 10, max: 620 },
      longSide: { max: 1000 },
      message: 'Planing requires: thickness 8-230mm, width 10-620mm, length max 1000mm'
    }
  },

  saw: {
    stockType: 'sheet',
    bladeWidth: 3.2,
    cutType: 'guillotine',
    cutPreference: 'l'
  },

  options: {
    locale: 'en-US',
    currency: 'USD',
    apiVersion: 3, // 2 or 3 (default: 3)

    // Enable extras features
    enable: {
      banding: true,
      finish: true,
      planing: true,
      machining: false
    }
  }
}
```

### Product Catalog

Surface a product browser on the checkout so customers can pick from your catalogue before configuring parts. Independent of [Product Filtering](#product-filtering) (which filters the stock list).

```javascript
const initData = {
  stock: [...],
  products: {
    enabled: true,        // Show the product browser tab
    showCategories: true  // Group products by category in the browser
  },
  options: {...}
}
```

A user picking a "simple" product fires [`smartcut/product-selected`](#smartcutproduct-selected); picking a "formula" (configurable) product hands off to the [Configurator](#configurator-formula-products). Stock filter "Create Cut List" confirmations fire [`smartcut/selection-confirmed`](#smartcutselection-confirmed).

You can deep-link directly to a product by appending `?product=<productId>` to the page URL; the calculator picks up the parameter on mount.

### API / Order Lookup

Wire up the storefront's order-lookup view and the live-inventory watcher by supplying the host's API config:

```javascript
const initData = {
  api: {
    baseUrl: 'https://your-storefront.example.com',
    orgSlug: 'your-org-slug',
    isCustomDomain: false,  // true if storefront is on a custom domain
    wsServer: 'wss://your-storefront.example.com', // optional
    orgId: '66abc123...'    // optional Mongo org id (required with wsServer)
  },
  options: {...}
}
```

Appending `?view=track-order` (or `?view=order-lookup`) to the page URL opens the order-lookup view on mount instead of the calculator.

When both `wsServer` and `orgId` are set, the calculator connects via Socket.IO and fires [`smartcut/inventoryUpdated`](#smartcutinventoryupdated) whenever stock changes for the org.

### Configurator (Formula Products)

Enable formula-driven (parametric) products — typically used for configurable cabinets, where customer inputs (height/width/etc.) drive a JS formula that emits the parts list:

```javascript
const initData = {
  config: {
    configurator: {
      enabled: true,
      url: 'https://your-host.example.com/configurator.html', // optional iframe URL
      spec: { /* configurator spec, opaque to the widget */ }
    }
  },
  options: {...}
}
```

A product whose `type === 'formula'` (via the Product Catalog) routes to the Configurator; on confirmation the calculator receives the generated parts and falls through to the normal calculate flow.

### Per-Pick Saw Mapping

Different materials can route to different saws. When a customer picks a material whose `db_sawId` differs from the page-load saw, supply a per-saw map so the storefront UI can swap stock-type-driven fields, blade width, and validation without a server round-trip:

```javascript
const initData = {
  saw: {
    /* page-load saw config */
    stockType: 'sheet',
    bladeWidth: 3.2,
    cutType: 'efficiency'
  },
  sawsById: {
    '66abc1...': { stockType: 'linear', bladeWidth: 1.6, cutType: 'efficiency' },
    '66def2...': { stockType: 'sheet',  bladeWidth: 4.0, cutType: 'guillotine', cutPreference: 'l' }
  },
  options: {...}
}
```

Source of truth for saw resolution is server-side (`resolveSawFromInputStock`); `sawsById` exists purely to keep the storefront UI consistent with what the server will actually apply at calc time. Each map value mirrors the same shape as the top-level `saw` field.

### Machining Configuration

Advanced machining features for holes, corners, and custom operations:

```javascript
const initData = {
  machining: {
    faces: {
      enabled: true
    },
    holes: {
      enabled: true,
      defaultDiameter: 5,
      minDiameter: 5,
      maxDiameter: 10,
      enableDepth: true,
      minDepth: 5
    },
    hingeHoles: {
      enabled: true,
      minimumHoleDistance: 10,
      defaultDistanceFromEdge: 22,
      defaultOuterSpacing: 10,
      defaultHingeLength: 50
    },
    shelfHoles: {
      enabled: true,
      diameters: [5, 10]
    },
    corners: {
      enabled: true,
      types: ['radius', 'bevel'],
      enableBanding: true
    }
  },

  options: {
    enable: {
      machining: true
    }
  }
}
```

## Events

The Checkout API dispatches custom window events for integration. Events fall into two groups:

- **Output events** (`smartcut/...`) — the widget dispatches these on `window`; host code listens.
- **Input events** ([`smartcut/load`](#smartcutload-input-event)) — host code dispatches these; the widget listens.

### `smartcut/ready`
Fired when the calculator is loaded and ready to initialize.

```javascript
window.addEventListener('smartcut/ready', () => {
  window.smartcutCheckout.init(initData)
})
```

### `smartcut/initComplete`
Fired when initialization is complete.

```javascript
window.addEventListener('smartcut/initComplete', () => {
  console.log('Calculator initialized')
})
```

### `smartcut/calculating`
Fired when a calculation starts.

```javascript
window.addEventListener('smartcut/calculating', () => {
  console.log('Calculation in progress...')
})
```

### `smartcut/beforeCalculate`
Fired after internal validation passes but before the calculation is sent to the server. This allows you to add custom validation logic using JavaScript and optionally prevent the calculation from proceeding.

**Event properties:**
- `cancelable: true` - Call `event.preventDefault()` to cancel the calculation
- `event.detail.data` - The calculation data that would be sent (parts, stock, saw config, etc.)
- `event.detail.error` - Set this to a string to display a custom error message

```javascript
window.addEventListener('smartcut/beforeCalculate', (event) => {
  const calculationData = event.detail.data

  // Example: Custom validation - check total quantity
  const totalQuantity = calculationData.inputShapes.reduce(
    (sum, part) => sum + (part.q || 1),
    0
  )

  if (totalQuantity > 500) {
    // Prevent the calculation
    event.preventDefault()
    // Set a custom error message to display
    event.detail.error = 'Maximum 500 parts per order. Please split your order.'
    return
  }

  // Example: Check part dimensions
  for (const part of calculationData.inputShapes) {
    if (part.l > 3000 || part.w > 2000) {
      event.preventDefault()
      event.detail.error = `Part "${part.name || 'Unnamed'}" exceeds maximum dimensions (3000x2000mm)`
      return
    }
  }

  // Validation passed - calculation will proceed
  console.log('Custom validation passed, calculating...')
})
```

**When the calculation is prevented:**
- The calculate button is re-enabled (thinking state is reset)
- If `event.detail.error` is set, the error message is displayed to the user
- The `smartcut/result` event will not fire

**Calculation data structure (`event.detail.data`):**
```typescript
{
  inputSaw: {
    stockType: string,
    bladeWidth: number,
    cutType: string,
    cutPreference: string,
    // ... other saw properties
  },
  inputShapes: Array<{
    l: number,
    w: number,
    t: number | null,
    q: number,
    material: string | null,
    name: string | null,
    orientationLock: string | null,
    extras?: {...},
    customData?: Record<string, any>
  }>,
  inputStock: Array<{
    l: number,
    w: number,
    t: number | null,
    q: number,
    material: string | null,
    cost: number | null,
    // ... other stock properties
  }>,
  extrasOptions: {...} | null
}
```

### `smartcut/result`
Fired when optimization results are available.

```javascript
window.addEventListener('smartcut/result', (e) => {
  const result = e.detail

  // Optimization metadata
  console.log(result.metadata)

  // Parts used in optimization
  console.log(result.parts)

  // Stock sheets used
  console.log(result.stock)

  // Formatted pricing with currency
  console.log(result.checkout.formattedTotalStockCost)
  console.log(result.checkout.formattedBandingCost)
  console.log(result.checkout.formattedFinishCost)
  console.log(result.checkout.formattedPlaningCost)
})
```

### `smartcut/validationError`
Fired when input validation fails.

```javascript
window.addEventListener('smartcut/validationError', () => {
  console.log('Please check your inputs')
})
```

### `smartcut/product-selected`
Fired when a customer picks a **simple** (non-configurable) product from the Product Catalog browser.

```javascript
window.addEventListener('smartcut/product-selected', (event) => {
  console.log('Picked product:', event.detail.product)
  // event.detail = { product: Product, type: 'simple' }
})
```

Formula products do not emit this event — they route to the Configurator instead.

### `smartcut/selection-confirmed`
Fired when the customer presses **"Create Cut List"** in the Product Filtering UI (the stock filter's confirmation step).

```javascript
window.addEventListener('smartcut/selection-confirmed', () => {
  console.log('Customer confirmed stock selection')
})
```

No detail payload — the widget scrolls to the calculator after dispatching.

### `smartcut/inventoryUpdated`
Fired when the live-inventory watcher detects a stock change for the configured org. Requires [`api.wsServer` and `api.orgId`](#api--order-lookup) to be set.

```javascript
window.addEventListener('smartcut/inventoryUpdated', () => {
  // Refresh your stock display or re-run a calculation
})
```

No detail payload.

### `smartcut/load` (input event)

Host-dispatched event the widget **listens** for. Use this to load a saved cut list after init — equivalent to pre-populating `parts` on init, but available at any time:

```javascript
window.dispatchEvent(new CustomEvent('smartcut/load', {
  detail: {
    inputs: {
      parts: [
        { l: 600, w: 400, t: 18, q: 4, material: 'Plywood', name: 'Shelf' },
        { l: 800, w: 300, t: 18, q: 2, material: 'Plywood', name: 'Side' }
      ]
    }
  }
}))
```

The widget clears existing parts and loads each part via the same path as init-time pre-population. Useful when the customer signs in and you want to restore their last basket, or when integrating with a "saved designs" feature in your storefront.

## Result Format

The result object returned via the `smartcut/result` event contains different structures based on the `apiVersion` setting.

### API Version 2 Result Structure

When `apiVersion: 2` is set, results use a **flat extras structure**:

```typescript
{
  jobId: number,

  metadata: {
    totalStockCost: number,
    bandingLengthByType: Record<string, number>,
    finishAreaByType: Record<string, number>,
    planingAreaByType: Record<string, number>,
    addedPartTally: Record<string, number>,
    usedStockTally: Record<string, number>,
    unplacedParts: Array<Part>
  },

  parts: Array<{
    l: number,
    w: number,
    t: number | null,
    q: number,
    material: string | null,
    name: string | null,
    orientationLock: '' | 'l' | 'w' | null,
    // Flat extras structure (API v2)
    banding?: Record<string, string | boolean>, // e.g., { x1: 'oak|1mm', x2: 'oak|1mm', y1: '', y2: '' }
    finish?: Record<string, string | boolean>, // e.g., { a: true, b: false }
    planing?: Record<string, string | boolean>,
    customData?: Record<string, any>
  }>,

  stock: Array<{
    id: string,
    name: string | null,
    l: number,
    w: number,
    t: number | null,
    material: string | null,
    q: number,
    trim?: { x1: number, x2: number, y1: number, y2: number },
    cost?: number,
    discount?: number,        // Configured percentage discount (0–100), surfaced to pricing formulas
    pricingFormula?: string,  // Per-stock pricing override evaluated at checkout
    // Analysis is aggregated across all stock with same parentId when stacking
    // Most values are summed; areaEfficiency is averaged
    analysis?: {
      areaEfficiency: number,   // Average efficiency across all stacked stock
      finishArea: number,       // Total finish area
      bandingLength: number,    // Total banding length
      partArea: number,         // Total part area
      totalParts: number,       // Total number of parts
      stackedNumberOfCuts: number,
      numberOfCuts: number,
      stackedCutLength: number,
      cutLength: number,
      rollLength: number
    },
    sheets?: Array<{...}>,      // Per-sheet analysis (same shape as `analysis` per row) — see V3 below
    customData?: Record<string, any>
  }>,

  offcuts: Array<{
    l: number,
    w: number,
    t: number | null,
    q: number,
    stockId: string
  }>,

  inputs: {
    parts: Array<Part> // Same structure as parts above
  }
}
```

### API Version 3 Result Structure

When `apiVersion: 3` is set (default), results use a **nested extras structure**:

```typescript
{
  jobId: number,

  metadata: {
    totalStockCost: number,
    bandingLengthByType: Record<string, number>,
    finishAreaByType: Record<string, number>,
    planingAreaByType: Record<string, number>,
    addedPartTally: Record<string, number>,
    usedStockTally: Record<string, number>,
    unplacedParts: Array<Part>
  },

  parts: Array<{
    l: number,
    w: number,
    t: number | null,
    q: number,
    material: string | null,
    name: string | null,
    orientationLock: '' | 'l' | 'w' | null,
    // Nested extras structure (API v3)
    extras?: {
      banding?: {
        sides: Record<string, string | boolean> // e.g., { l1: 'oak|1mm', l2: 'oak|1mm', w1: '', w2: '' }
      },
      finish?: {
        faces: Record<string, string | boolean> // e.g., { a: true, b: false }
      },
      planing?: {
        sides?: Record<string, string | boolean>,
        faces?: Record<string, string | boolean>
      }
    },
    customData?: Record<string, any>
  }>,

  stock: Array<{
    id: string,
    name: string | null,
    l: number,
    w: number,
    t: number | null,
    material: string | null,
    q: number,
    trim?: { l1: number, l2: number, w1: number, w2: number }, // Note: uses l1/l2/w1/w2 in v3
    cost?: number,
    // Analysis is aggregated across all stock with same parentId when stacking
    // Most values are summed; areaEfficiency is averaged
    analysis?: {
      areaEfficiency: number,   // Average efficiency across all stacked stock
      finishArea: number,       // Total finish area
      bandingLength: number,    // Total banding length
      partArea: number,         // Total part area
      totalParts: number,       // Total number of parts
      stackedNumberOfCuts: number,
      numberOfCuts: number,
      stackedCutLength: number,
      cutLength: number,
      rollLength: number
    },
    // Additional v3 fields
    color?: { hex: string, name: string } | string,
    weight?: number,
    imageUrl?: string,
    tags?: string[],
    available?: boolean,
    discount?: number,         // Configured percentage discount (0–100), surfaced to pricing formulas
    pricingFormula?: string,   // Per-stock pricing override evaluated at checkout
    sheets?: Array<{           // Per-sheet analysis — present when the cut server emits per-sheet data.
      areaEfficiency: number,  // Pricing formulas iterate these when set so usedFraction reflects each
      finishArea: number,      // physical sheet, not the order-wide average.
      bandingLength: number,
      partArea: number,
      totalParts: number,
      stackedNumberOfCuts: number,
      numberOfCuts: number,
      stackedCutLength: number,
      cutLength: number,
      rollLength: number
    }>,
    customData?: Record<string, any>
  }>,

  offcuts: Array<{
    l: number,
    w: number,
    t: number | null,
    q: number,
    stockId: string
  }>,

  inputs: {
    parts: Array<Part> // Same structure as parts above
  }
}
```

### Using Result Data

```javascript
window.addEventListener('smartcut/result', (e) => {
  const result = e.detail

  // Get total cost from metadata
  const totalCost = result.metadata.totalStockCost

  // Get parts with optimization data
  const optimizedParts = result.parts

  // Get stock usage
  const stockUsed = result.stock

  // Get offcuts for reuse
  const offcuts = result.offcuts

  // Access extras based on API version
  const apiVersion = 3 // Your configured version

  result.parts.forEach(part => {
    if (apiVersion === 2) {
      // API v2: Flat structure
      const bandingX1 = part.banding?.x1
      const finishFaceA = part.finish?.a
    } else {
      // API v3: Nested structure
      const bandingL1 = part.extras?.banding?.sides?.l1
      const finishFaceA = part.extras?.finish?.faces?.a
    }
  })

  // Get extras costs breakdown from metadata
  const bandingCosts = result.metadata.bandingLengthByType
  const finishCosts = result.metadata.finishAreaByType
  const planingCosts = result.metadata.planingAreaByType
})
```

### API Result V3

When the `emitAPIResult` option is enabled, the result object includes an `apiResultV3` property containing the complete API v3 response. This provides a standardized format matching the SmartCut API v3 specification:

```typescript
{
  // Standard checkout result properties...
  jobId: number,
  metadata: {...},
  parts: [...],
  stock: [...],
  offcuts: [...],
  inputs: {...},

  // Additional API v3 response (when emitAPIResult: true)
  apiResultV3?: {
    jobId: number,
    saw: {
      stockType: string,
      bladeWidth: number,
      cutType: string,
      cutPreference: string
    },
    stockList: Array<{...}>,
    shapeList: Array<{...}>,
    cutList: Array<{...}>,
    offcuts: Array<{...}>,
    unusableShapes: Array<{...}>,
    metadata: {...}
  }
}
```

This is useful when integrating with systems that expect the full API v3 response format, or when you need access to additional data like `cutList` that isn't included in the standard checkout result.

```javascript
window.addEventListener('smartcut/result', (e) => {
  const result = e.detail

  // Access the API v3 response if available
  if (result.apiResultV3) {
    console.log('Cut list:', result.apiResultV3.cutList)
    console.log('Shape list:', result.apiResultV3.shapeList)
  }
})
```


## Product Filtering

Enable dynamic stock selection with filtering and search:

```javascript
const initData = {
  stock: [
    {
      l: 2800,
      w: 2070,
      t: 18,
      cost: 95,
      material: 'White MDF',
      code: 'MDF-2800-18',
      category: 'Sheet Materials',
      tags: ['mdf', 'white', 'standard'],
      available: true
    }
  ],

  stockFilter: {
    enabled: true,
    config: {
      displayMode: 'grid', // 'grid' or 'list'
      enableSearch: true,
      itemsPerPage: 20,
      allowMultipleSelection: true,
      availableFilters: [
        {
          field: 'material',
          type: 'multiselect',
          label: 'Material'
        },
        {
          field: 't',
          type: 'range',
          label: 'Thickness (mm)',
          min: 0,
          max: 30
        },
        {
          field: 'category',
          type: 'multiselect',
          label: 'Category'
        }
      ]
    }
  },

  options: {...}
}
```

## Support

- **Email**: hello@cutrevolution.com
- **Chat**: https://smartcut.dev/
