Skip to main content

Quick Quote Tool

This ~50-line tool demonstrates the full bridge loop for a standalone tool: read customers and products (now including sku), let the user build a quote, and submit via quotes.create.
<!DOCTYPE html>
<html>
<head><script src="/bridge/sdk.js"></script></head>
<body>
  <h2>Quick Quote</h2>
  <label>Customer:</label>
  <select id="customer"></select>
  <label>Product:</label>
  <select id="product"></select>
  <label>Qty:</label>
  <input id="qty" type="number" value="1" min="1" />
  <button onclick="submit()">Create Quote</button>
  <div id="result"></div>
  <script>
    window.pipeline.customers.list().then(function(r) {
      r.items.forEach(function(c) {
        var o = document.createElement('option');
        o.value = JSON.stringify(c);
        o.text = c.name;
        document.getElementById('customer').add(o);
      });
    });
    window.pipeline.products.list().then(function(r) {
      r.items.forEach(function(p) {
        var o = document.createElement('option');
        o.value = JSON.stringify(p);
        // p.sku is now included in the response shape
        o.text = p.name + ' [' + p.sku + '] ($' + p.price + ')';
        document.getElementById('product').add(o);
      });
    });
    function submit() {
      var c = JSON.parse(document.getElementById('customer').value);
      var p = JSON.parse(document.getElementById('product').value);
      var qty = parseFloat(document.getElementById('qty').value);
      window.pipeline.quotes.create({
        customerName: c.name,
        customerEmail: c.email,
        upsertByEmail: true,
        title: 'Quick Quote',
        lines: [{
          description: p.name,
          productCode: p.sku,
          qty: qty,
          unitPrice: p.price,
          taxRate: 0.15
        }]
      }).then(function(r) {
        document.getElementById('result').textContent = 'Quote created: ' + r.ref + ' (total: $' + r.total + ')';
      }).catch(function(e) {
        document.getElementById('result').textContent = 'Error: ' + e.message;
      });
    }
  </script>
</body>
</html>

Reference: Ziptrak Retail Estimator

This is the full reference implementation for a quoting_extension tool. It shows initBridge with skuIdMap, and collectQuoteLines with groupKey, productId, costPrice, unit, location, and specification.
This example uses the legacy productId field, which is now deprecated in favour of linkedItemId + lineType. The deprecated field still works (Ziptrak v2.1.2 is unchanged). For new tools, prefer the modern API:
  • Use lineType: "product" + linkedItemId: <sku-guid> for product lines (replaces productId).
  • Use lineType: "task" + linkedItemId: <task-template-guid> for explicit installation, removal, or service tasks. Resolve GUIDs from codes via taskTemplates.list.
  • Use lineType: "cost" + linkedItemId: <cob-item-guid> for shipping, travel, disposal, and other cost-of-business lines. Resolve GUIDs via costItems.list.
  • Tag every line with a section (e.g. "Blinds", "Installation", "Extras") — the host quote UI groups lines visually by section.
See quotes.addLines for the full field reference and a modern multi-line example.
manifest.json:
{
  "name": "Ziptrak Retail Estimator",
  "shortName": "Ziptrak",
  "type": "quoting_extension",
  "version": "2.0.0",
  "description": "Calculate supply and install costs for Ziptrak outdoor blinds. Adds itemised lines directly to the open quote.",
  "entry": "index.html"
}
index.html (key JS excerpt):
<!DOCTYPE html>
<html>
<head>
  <script src="/bridge/sdk.js"></script>
</head>
<body>
  <!-- ... estimator UI ... -->
  <button id="submitQuoteBtn">Add to Quote</button>

  <script>
    // Maps uppercase SKU code → product GUID, populated during initBridge().
    // Sent as productId on each line so the host can inject task template sequences.
    let skuIdMap = {};

    // Tenant-specific SKU codes — edit to match the tenant's Stock_SKU.SKU values.
    const SKU_CODES = {
      mesh:   'ZPME',   // Ziptrak outdoor blinds - Mesh
      pvc:    'ZPPC',   // Ziptrak outdoor blinds - Clear PVC
      pelmet: 'PLMT'    // Pelmet for Ziptrak
    };

    const SKU_NAMES = {
      mesh:   'Ziptrak Outdoor Blind \u2013 Mesh',
      pvc:    'Ziptrak Outdoor Blind \u2013 Clear PVC',
      pelmet: 'Pelmet for Ziptrak'
    };

    async function initBridge() {
      if (typeof window.pipeline === 'undefined' || !window.pipeline.quotes) {
        // Running outside Pipeline — disable submit button
        document.getElementById('submitQuoteBtn').disabled = true;
        return;
      }

      try {
        // Load all products and build sku→id map for task template injection
        const productResult = await window.pipeline.products.list({ page: 1, pageSize: 500 });
        const products = (productResult && productResult.items) ? productResult.items : [];
        products.forEach(function(p) {
          if (p.sku && p.id) skuIdMap[p.sku.toUpperCase()] = p.id;
        });
      } catch (_) {
        // Product lookup is informational — tool still submits with SKU_CODES as free-form
      }
    }

    // Build BridgeAddLineDto[] for all configured blind rows.
    // Each blind row generates up to 3 lines (blind, pelmet, add-extra) sharing a groupKey.
    function collectQuoteLines() {
      const lines = [];

      rows.forEach(function(row, idx) {
        const blindCalc = getBlindWholesale(row.type, row.widthMm, row.heightMm, row.reverseHandle);
        const pelmetCalc = getPelmetWholesale(row.pelmetType, row.widthMm);
        if (!blindCalc.valid) return;

        const blindMarkup = num(document.getElementById('blindMarkup').value) / 100;
        const pelmetMarkup = num(document.getElementById('pelmetMarkup').value) / 100;
        const blindRetail = blindCalc.price * (1 + blindMarkup);
        const pelmetRetail = pelmetCalc.valid ? pelmetCalc.price * (1 + pelmetMarkup) : 0;
        const installCost = /* per-row install rate */ 150;
        const rowLabel = row.label || ('Blind ' + (idx + 1));
        const groupKey = 'blind-' + (idx + 1);   // all lines for this blind share one group

        // Main blind line — fabric + installation bundled as one retail price
        const blindSkuCode = row.type === 'Mesh' ? SKU_CODES.mesh : SKU_CODES.pvc;
        const blindSkuName = row.type === 'Mesh' ? SKU_NAMES.mesh : SKU_NAMES.pvc;
        lines.push({
          description:   blindSkuName,
          productCode:   blindSkuCode,
          productId:     skuIdMap[blindSkuCode.toUpperCase()] || null,  // triggers task injection
          qty:           1,
          unitPrice:     Math.round((blindRetail + installCost) * 100) / 100,  // retail (marked-up)
          costPrice:     Math.round((blindCalc.price + installCost) * 100) / 100,  // buy price
          taxRate:       15,
          location:      rowLabel,                 // e.g. "Patio", "Dining"
          specification: row.type + ', ' + blindCalc.band,  // e.g. "Mesh, W ≤ 3.0m, H ≤ 2.5m"
          unit:          'T_ITEM',                 // each blind is one item
          groupKey:      groupKey
        });

        // Pelmet line — grouped with the blind above
        if (pelmetCalc.valid && pelmetRetail > 0) {
          lines.push({
            description:   SKU_NAMES.pelmet,
            productCode:   SKU_CODES.pelmet,
            productId:     skuIdMap[SKU_CODES.pelmet.toUpperCase()] || null,
            qty:           1,
            unitPrice:     Math.round(pelmetRetail * 100) / 100,
            costPrice:     Math.round(pelmetCalc.price * 100) / 100,
            taxRate:       15,
            location:      rowLabel,
            specification: row.pelmetType,
            unit:          'T_ITEM',
            groupKey:      groupKey               // same group as the blind above
          });
        }

        // Optional: add a per-m² fabric charge as a separate line
        // const widthM = row.widthMm / 1000;
        // const heightM = row.heightMm / 1000;
        // lines.push({
        //   description: 'Mesh fabric',
        //   qty: widthM * heightM,
        //   unitPrice: 45.00,
        //   taxRate: 15,
        //   unit: 'T_SQUARE_METRE',
        //   groupKey: groupKey
        // });
      });

      return lines;
    }

    document.getElementById('submitQuoteBtn').addEventListener('click', async function() {
      const quoteLines = collectQuoteLines();
      if (quoteLines.length === 0) return;

      try {
        await window.pipeline.quotes.addLines(quoteLines);
        alert(quoteLines.length + ' lines added to quote.');
      } catch (err) {
        alert('Failed: ' + err.message);
      }
    });

    initBridge();
  </script>
</body>
</html>
What the Ziptrak example demonstrates:
  • manifest.json with type: "quoting_extension" so addLines is unlocked
  • initBridge() calls products.list({ pageSize: 500 }) and populates skuIdMap
  • collectQuoteLines() builds lines with:
    • groupKey ("blind-1", "blind-2") so blind + pelmet lines are grouped in the quote
    • productId resolved from skuIdMap for task template injection
    • productCode as the human-readable SKU
    • costPrice (wholesale) alongside unitPrice (retail) for real margin in the COB panel
    • unit: "T_ITEM" for per-blind lines (use "T_SQUARE_METRE" for fabric area charges)
    • location for the area/zone label and specification for dimensions and config detail