Skip to content

In n8n, copy this file and paste it onto the canvas, or use Import from File.

{
  "name": "24 · Proposal Generator",
  "nodes": [
    {
      "parameters": {
        "httpMethod": "POST",
        "path": "prop/create",
        "responseMode": "responseNode",
        "options": {}
      },
      "id": "m24-create-api",
      "name": "Create API",
      "type": "n8n-nodes-base.webhook",
      "typeVersion": 2,
      "position": [
        0,
        180
      ],
      "webhookId": "course-24-create"
    },
    {
      "parameters": {
        "httpMethod": "GET",
        "path": "prop/view",
        "responseMode": "responseNode",
        "options": {}
      },
      "id": "m24-view-api",
      "name": "View API",
      "type": "n8n-nodes-base.webhook",
      "typeVersion": 2,
      "position": [
        0,
        420
      ],
      "webhookId": "course-24-view"
    },
    {
      "parameters": {
        "httpMethod": "POST",
        "path": "prop/accept",
        "responseMode": "responseNode",
        "options": {}
      },
      "id": "m24-accept-api",
      "name": "Accept API",
      "type": "n8n-nodes-base.webhook",
      "typeVersion": 2,
      "position": [
        0,
        600
      ],
      "webhookId": "course-24-accept"
    },
    {
      "parameters": {
        "httpMethod": "GET",
        "path": "prop/status",
        "responseMode": "responseNode",
        "options": {}
      },
      "id": "m24-status-api",
      "name": "Status API",
      "type": "n8n-nodes-base.webhook",
      "typeVersion": 2,
      "position": [
        0,
        780
      ],
      "webhookId": "course-24-status"
    },
    {
      "parameters": {
        "assignments": {
          "assignments": [
            {
              "id": "s1",
              "name": "business_name",
              "value": "Brightline Studio",
              "type": "string"
            },
            {
              "id": "s2",
              "name": "sender_name",
              "value": "Sam Ortiz",
              "type": "string"
            },
            {
              "id": "s3",
              "name": "owner_email",
              "value": "sam@brightline.example",
              "type": "string"
            },
            {
              "id": "s4",
              "name": "public_base_url",
              "value": "http://localhost:5678",
              "type": "string"
            },
            {
              "id": "s5",
              "name": "currency",
              "value": "usd",
              "type": "string"
            },
            {
              "id": "s6",
              "name": "services",
              "value": "={{ [{\"key\": \"website\",\"name\": \"Website design & build\",\"description\": \"A fast, mobile-first website built to turn visitors into enquiries.\",\"tiers\": [{\"name\": \"Starter\",\"price\": 4500,\"weeks\": 4,\"deliverables\": [\"up to 5 pages\",\"mobile-first design\",\"contact form\",\"basic SEO setup\"]},{\"name\": \"Growth\",\"price\": 8500,\"weeks\": 6,\"deliverables\": [\"up to 12 pages\",\"CMS for your team\",\"on-page SEO\",\"analytics and conversion tracking\"]},{\"name\": \"Scale\",\"price\": 15000,\"weeks\": 10,\"deliverables\": [\"custom design system\",\"up to 30 pages\",\"CRM and booking integrations\",\"performance budget\"]}]},{\"key\": \"seo\",\"name\": \"SEO foundation\",\"description\": \"Technical and on-page SEO so the right people can find you.\",\"tiers\": [{\"name\": \"Essentials\",\"price\": 1500,\"weeks\": 3,\"deliverables\": [\"technical audit\",\"fixes for top issues\",\"keyword map for 10 pages\"]},{\"name\": \"Plus\",\"price\": 3000,\"weeks\": 5,\"deliverables\": [\"everything in Essentials\",\"content briefs for 6 articles\",\"local SEO setup\"]}]},{\"key\": \"automation\",\"name\": \"Workflow automation\",\"description\": \"n8n workflows that take repetitive admin off your team's plate.\",\"tiers\": [{\"name\": \"Single workflow\",\"price\": 1800,\"weeks\": 2,\"deliverables\": [\"one workflow end to end\",\"error alerts\",\"handover video\"]},{\"name\": \"Ops package\",\"price\": 4800,\"weeks\": 5,\"deliverables\": [\"up to 4 workflows\",\"shared error handling\",\"team training session\"]}]},{\"key\": \"branding\",\"name\": \"Brand identity\",\"description\": \"A clear, consistent look that makes you recognisable.\",\"tiers\": [{\"name\": \"Refresh\",\"price\": 2500,\"weeks\": 3,\"deliverables\": [\"logo refinement\",\"colour and type system\",\"one-page guidelines\"]},{\"name\": \"Full identity\",\"price\": 6000,\"weeks\": 6,\"deliverables\": [\"new logo suite\",\"brand guidelines\",\"templates for social and documents\"]}]}] }}",
              "type": "array"
            },
            {
              "id": "s7",
              "name": "bundle_min_services",
              "value": 3,
              "type": "number"
            },
            {
              "id": "s8",
              "name": "bundle_discount_percent",
              "value": 10,
              "type": "number"
            },
            {
              "id": "s9",
              "name": "tax_rate",
              "value": 0,
              "type": "number"
            },
            {
              "id": "s10",
              "name": "deposit_percent",
              "value": 50,
              "type": "number"
            },
            {
              "id": "s11",
              "name": "valid_days",
              "value": 14,
              "type": "number"
            },
            {
              "id": "s12",
              "name": "tone",
              "value": "Plain, confident and specific. Short paragraphs. No jargon, no hype.",
              "type": "string"
            },
            {
              "id": "s13",
              "name": "banned_promises",
              "value": "={{ ['guarantee', 'guaranteed', '#1', 'first page of google', 'double your', '100%'] }}",
              "type": "array"
            },
            {
              "id": "s14",
              "name": "use_ai",
              "value": true,
              "type": "boolean"
            }
          ]
        },
        "includeOtherFields": true,
        "options": {}
      },
      "id": "m24-settings-edit-me",
      "name": "Settings (edit me)",
      "type": "n8n-nodes-base.set",
      "typeVersion": 3.4,
      "position": [
        240,
        400
      ]
    },
    {
      "parameters": {
        "rules": {
          "values": [
            {
              "conditions": {
                "options": {
                  "caseSensitive": true,
                  "leftValue": "",
                  "typeValidation": "strict",
                  "version": 2
                },
                "conditions": [
                  {
                    "id": "r1",
                    "leftValue": "={{ $('Create API').isExecuted }}",
                    "rightValue": "",
                    "operator": {
                      "type": "boolean",
                      "operation": "true",
                      "singleValue": true
                    }
                  }
                ],
                "combinator": "and"
              },
              "renameOutput": true,
              "outputKey": "create"
            },
            {
              "conditions": {
                "options": {
                  "caseSensitive": true,
                  "leftValue": "",
                  "typeValidation": "strict",
                  "version": 2
                },
                "conditions": [
                  {
                    "id": "r2",
                    "leftValue": "={{ $('View API').isExecuted }}",
                    "rightValue": "",
                    "operator": {
                      "type": "boolean",
                      "operation": "true",
                      "singleValue": true
                    }
                  }
                ],
                "combinator": "and"
              },
              "renameOutput": true,
              "outputKey": "view"
            },
            {
              "conditions": {
                "options": {
                  "caseSensitive": true,
                  "leftValue": "",
                  "typeValidation": "strict",
                  "version": 2
                },
                "conditions": [
                  {
                    "id": "r3",
                    "leftValue": "={{ $('Accept API').isExecuted }}",
                    "rightValue": "",
                    "operator": {
                      "type": "boolean",
                      "operation": "true",
                      "singleValue": true
                    }
                  }
                ],
                "combinator": "and"
              },
              "renameOutput": true,
              "outputKey": "accept"
            },
            {
              "conditions": {
                "options": {
                  "caseSensitive": true,
                  "leftValue": "",
                  "typeValidation": "strict",
                  "version": 2
                },
                "conditions": [
                  {
                    "id": "r4",
                    "leftValue": "={{ $('Status API').isExecuted }}",
                    "rightValue": "",
                    "operator": {
                      "type": "boolean",
                      "operation": "true",
                      "singleValue": true
                    }
                  }
                ],
                "combinator": "and"
              },
              "renameOutput": true,
              "outputKey": "status"
            }
          ]
        },
        "options": {}
      },
      "id": "m24-route",
      "name": "Route",
      "type": "n8n-nodes-base.switch",
      "typeVersion": 3.2,
      "position": [
        460,
        400
      ]
    },
    {
      "parameters": {
        "jsCode": "// Normalize the intake (form or API) and build the quote from the service catalog:\n// cheapest tier of each requested service first, then upgrade while the total still fits the budget.\nconst s = $('Settings (edit me)').first().json;\n// (The n8n Form lives in its own workflow, learner-form.json, and posts here: Form Trigger refuses workflows with Respond nodes.)\nconst viaApi = true;\nconst f = $('Create API').first().json.body ?? {};\nconst get = (...ks) => { for (const k of ks) if (f[k] !== undefined && f[k] !== '') return f[k]; return undefined; };\n\n// \"$5k-$10k\", \"10000\", \"under 8k\", \"8,500\" → the upper limit in dollars\nfunction budgetCap(v) {\n  if (typeof v === 'number') return v;\n  const nums = [...String(v ?? '').toLowerCase().replace(/,/g, '').matchAll(/(\\d+(?:\\.\\d+)?)\\s*(k)?/g)].map((m) => Number(m[1]) * (m[2] ? 1000 : 1));\n  return nums.length ? Math.max(...nums) : null;\n}\nconst wanted = (Array.isArray(get('services')) ? get('services') : String(get('services') ?? '').split(','))\n  .map((x) => String(x).trim().toLowerCase()).filter(Boolean)\n  .map((x) => s.services.find((sv) => sv.key === x || sv.name.toLowerCase() === x)).filter(Boolean);\nconst lead = {\n  client: String(get('client') ?? '').trim(),\n  company: String(get('company') ?? '').trim(),\n  email: String(get('email') ?? '').trim().toLowerCase(),\n  goals: String(get('goals') ?? '').trim(),\n  notes: String(get('notes') ?? '').trim(),\n  budget_text: String(get('budget') ?? ''),\n  timeline_weeks: Number(String(get('timeline_weeks') ?? '').replace(/\\D/g, '')) || null,\n};\nconst errors = [];\nif (!lead.client) errors.push('client name is required');\nif (!/^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$/.test(lead.email)) errors.push('a valid email is required');\nif (!wanted.length) errors.push(`pick at least one service: ${s.services.map((x) => x.key).join(', ')}`);\n\nconst cap = budgetCap(lead.budget_text);\nconst discountPct = wanted.length >= s.bundle_min_services ? s.bundle_discount_percent : 0;\nconst priceOf = (sel) => { const sub = sel.reduce((t, x) => t + x.tier.price, 0); return Math.round(sub * (1 - discountPct / 100) * (1 + s.tax_rate / 100)); };\nconst sel = wanted.map((sv) => ({ service: sv, i: 0, tier: sv.tiers[0] }));\nif (cap) {\n  for (let upgraded = true; upgraded;) {\n    upgraded = false;\n    for (const x of sel) {\n      if (x.i + 1 >= x.service.tiers.length) continue;\n      const trial = sel.map((y) => (y === x ? { ...y, tier: y.service.tiers[y.i + 1] } : y));\n      if (priceOf(trial) <= cap) { x.i++; x.tier = x.service.tiers[x.i]; upgraded = true; }\n    }\n  }\n}\nconst items = sel.map((x) => ({ service: x.service.key, name: `${x.service.name}: ${x.tier.name}`, description: x.service.description,\n  deliverables: x.tier.deliverables, weeks: x.tier.weeks, price: x.tier.price,\n  upgrade: x.service.tiers[x.i + 1] ? { name: x.service.tiers[x.i + 1].name, price: x.service.tiers[x.i + 1].price } : null }));\nconst subtotal = items.reduce((t, x) => t + x.price, 0);\nconst discount = Math.round(subtotal * discountPct / 100);\nconst tax = Math.round((subtotal - discount) * s.tax_rate / 100);\nconst total = subtotal - discount + tax;\nconst weeks = items.reduce((t, x) => t + x.weeks, 0); // phases run one after another\nconst flags = [];\nif (cap && total > cap) flags.push(`over budget: the smallest option is ${total} vs a budget of ${cap}`);\nif (!cap) flags.push('no budget given: quoted the entry tier of each service');\nif (lead.timeline_weeks && weeks > lead.timeline_weeks) flags.push(`timeline: ${weeks} weeks of work vs ${lead.timeline_weeks} requested`);\n\nconst now = f.now || new Date().toISOString(); // tests inject the clock\nreturn [{\n  json: {\n    ok: errors.length === 0, errors, lead, budget_cap: cap,\n    quote: { items, subtotal, discount_percent: discountPct, discount, tax_rate: s.tax_rate, tax, total, deposit: Math.round(total * s.deposit_percent / 100), weeks, currency: s.currency },\n    flags, created_at: now, valid_until: new Date(new Date(now).getTime() + s.valid_days * 864e5).toISOString().slice(0, 10),\n    dry_run: viaApi && f.dry_run === true, send_email: !(viaApi && f.send_email === false), via_api: viaApi,\n    use_ai: s.use_ai && !(viaApi && (f.ai === false || f.sections)), supplied_sections: viaApi ? f.sections ?? null : null,\n  },\n}];\n"
      },
      "id": "m24-build-quote",
      "name": "Build Quote",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        700,
        60
      ]
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "strict",
            "version": 2
          },
          "conditions": [
            {
              "id": "c1",
              "leftValue": "={{ $json.ok }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "m24-valid-intake",
      "name": "Valid Intake?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        920,
        60
      ]
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "strict",
            "version": 2
          },
          "conditions": [
            {
              "id": "c1",
              "leftValue": "={{ $json.via_api }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "m24-invalid-via-api",
      "name": "Invalid via API?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        1140,
        240
      ]
    },
    {
      "parameters": {
        "respondWith": "json",
        "responseBody": "={{ { ok: false, errors: $json.errors } }}",
        "options": {
          "responseCode": 400
        }
      },
      "id": "m24-respond-invalid",
      "name": "Respond Invalid",
      "type": "n8n-nodes-base.respondToWebhook",
      "typeVersion": 1.1,
      "position": [
        1360,
        240
      ]
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "strict",
            "version": 2
          },
          "conditions": [
            {
              "id": "c1",
              "leftValue": "={{ $json.use_ai }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "m24-use-ai",
      "name": "Use AI?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        1140,
        40
      ]
    },
    {
      "parameters": {
        "promptType": "define",
        "text": "=Write the narrative sections of a sales proposal from {{ $('Settings (edit me)').first().json.business_name }}.\n\nClient: {{ $json.lead.client }}{{ $json.lead.company ? ' (' + $json.lead.company + ')' : '' }}\nTheir goals, in their words: {{ $json.lead.goals || '(not given)' }}\nOther notes: {{ $json.lead.notes || '(none)' }}\nWhat we're proposing (fixed, don't change): {{ $json.quote.items.map(i => i.name + ' - ' + i.deliverables.join(', ') + ' (' + i.weeks + ' weeks)').join('; ') }}\n\nTone: {{ $('Settings (edit me)').first().json.tone }}\n\nRules:\n- summary: 2 sentences on the outcome for the client.\n- understanding: restate their goals and constraints in 2-3 sentences. Don't invent facts about their business.\n- approach: one short paragraph per item above, in the same order, about how we'll deliver it.\n- next_steps: 1-2 sentences inviting them to accept and book a kickoff.\n- Do NOT mention any prices, discounts or dates; the pricing table is added separately.\n- Never promise results you can't control (rankings, revenue, \"guaranteed\").",
        "hasOutputParser": true
      },
      "id": "m24-write-sections",
      "name": "Write Sections",
      "type": "@n8n/n8n-nodes-langchain.chainLlm",
      "typeVersion": 1.9,
      "position": [
        1360,
        -100
      ],
      "onError": "continueRegularOutput"
    },
    {
      "parameters": {
        "model": {
          "__rl": true,
          "mode": "list",
          "value": "gpt-5-mini",
          "cachedResultName": "gpt-5-mini"
        },
        "options": {}
      },
      "id": "m24-openai-chat-model",
      "name": "OpenAI Chat Model",
      "type": "@n8n/n8n-nodes-langchain.lmChatOpenAi",
      "typeVersion": 1.3,
      "position": [
        1300,
        100
      ]
    },
    {
      "parameters": {
        "schemaType": "fromJson",
        "jsonSchemaExample": "{\n  \"summary\": \"A faster, clearer website that turns more visitors into booked calls.\",\n  \"understanding\": \"You want more qualified enquiries and a site your team can update without a developer.\",\n  \"approach\": [\n    \"We start with a short discovery workshop, then design and build in weekly iterations.\"\n  ],\n  \"next_steps\": \"Accept below and we'll book a kickoff call this week.\"\n}"
      },
      "id": "m24-sections-format",
      "name": "Sections Format",
      "type": "@n8n/n8n-nodes-langchain.outputParserStructured",
      "typeVersion": 1.3,
      "position": [
        1460,
        100
      ]
    },
    {
      "parameters": {},
      "id": "m24-join-branches",
      "name": "Join Branches",
      "type": "n8n-nodes-base.merge",
      "typeVersion": 3,
      "position": [
        1600,
        40
      ]
    },
    {
      "parameters": {
        "jsCode": "// Final text (AI, supplied or template) + guardrails, then the hosted proposal page. Saves it unless dry run.\nconst s = $('Settings (edit me)').first().json;\nconst item = $input.first();\nconst fromAi = item.json.ok === undefined; // the AI chain only outputs { output } or { error }\nconst q = fromAi ? $('Build Quote').first().json : item.json;\nconst ai = fromAi ? item.json.output : null;\nconst money = (n) => new Intl.NumberFormat('en-US', { style: 'currency', currency: q.quote.currency.toUpperCase(), maximumFractionDigits: 0 }).format(n);\nconst esc = (t) => String(t ?? '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/\"/g, '&quot;');\nconst first = q.lead.client.split(' ')[0];\nconst who = q.lead.company || q.lead.client;\n\nconst template = {\n  summary: `${who} wants to ${q.lead.goals ? q.lead.goals.replace(/\\.$/, '').replace(/^./, (c) => c.toLowerCase()) : 'move a key project forward'}. This proposal covers ${q.quote.items.map((i) => i.name).join(' and ')}, delivered in about ${q.quote.weeks} weeks.`,\n  understanding: q.lead.goals ? `From our conversation: ${q.lead.goals}${q.lead.notes ? ' ' + q.lead.notes : ''}` : `We'll confirm goals and success measures in the kickoff call.`,\n  approach: q.quote.items.map((i) => `${i.name}: ${i.description} Includes ${i.deliverables.join(', ')}.`),\n  next_steps: `Accept this proposal below, and we'll send the deposit invoice and book a kickoff call within two business days.`,\n};\nlet text, source, ai_error;\nif (q.supplied_sections) { text = { ...template, ...q.supplied_sections }; source = 'supplied'; }\nelse if (ai?.summary) { text = { ...template, ...ai }; source = 'ai'; }\nelse { text = template; source = 'template'; if (fromAi) ai_error = String(item.json.error?.message ?? item.json.error ?? 'no AI output').slice(0, 200); }\n\n// Guardrails: prices only come from the quote; no promises we can't keep.\nconst issues = [];\nconst allowed = new Set([q.quote.subtotal, q.quote.discount, q.quote.tax, q.quote.total, q.quote.deposit, ...q.quote.items.map((i) => i.price)].map(String));\nfor (const key of ['summary', 'understanding', 'next_steps']) {\n  const bad = (String(text[key]).match(/\\$\\s?[\\d,]+(?:\\.\\d+)?k?/gi) ?? []).filter((m) => !allowed.has(m.replace(/[$,\\s]/g, '').replace(/k$/i, '000')));\n  if (bad.length) { issues.push(`${key}: price not in the quote (${bad.join(', ')}), replaced with the template text`); text[key] = template[key]; }\n}\ntext.approach = (Array.isArray(text.approach) ? text.approach : [String(text.approach)]).map((p, i) => {\n  const bad = (String(p).match(/\\$\\s?[\\d,]+/g) ?? []).filter((m) => !allowed.has(m.replace(/[$,\\s]/g, '')));\n  if (bad.length) { issues.push(`approach ${i + 1}: price not in the quote (${bad.join(', ')}), replaced`); return template.approach[i] ?? ''; }\n  return p;\n});\nconst blob = [text.summary, text.understanding, ...text.approach, text.next_steps].join(' ');\nfor (const p of s.banned_promises) if (new RegExp(`\\\\b${p}\\\\b`, 'i').test(blob)) issues.push(`promise to check: \"${p}\"`);\n\nconst id = `P-${q.created_at.slice(0, 10).replace(/-/g, '')}-${Math.random().toString(36).slice(2, 6).toUpperCase()}`;\nconst token = Array.from({ length: 3 }, () => Math.random().toString(36).slice(2)).join('').slice(0, 24);\nconst link = `${s.public_base_url}/webhook/prop/view?id=${id}&t=${token}`;\nconst rows = q.quote.items.map((i) => `<tr><td><strong>${esc(i.name)}</strong><br><small>${i.deliverables.map(esc).join(' · ')}</small></td><td>${i.weeks} wk</td><td class=\"r\">${money(i.price)}</td></tr>`).join('');\nconst html = `<!doctype html><html lang=\"en\"><head><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width,initial-scale=1\"><title>Proposal for ${esc(who)}</title>\n<style>body{font-family:system-ui,-apple-system,sans-serif;max-width:760px;margin:2rem auto;padding:0 1rem;color:#1d2433;line-height:1.55}h1{margin-bottom:.2rem}.muted{color:#667}table{width:100%;border-collapse:collapse;margin:1rem 0}td,th{padding:.6rem .4rem;border-bottom:1px solid #e3e6ee;vertical-align:top;text-align:left}.r{text-align:right;white-space:nowrap}.total td{font-weight:700;border-top:2px solid #1d2433}.box{background:#f5f7fb;border-radius:10px;padding:1rem 1.2rem;margin:1.5rem 0}button{background:#1d4ed8;color:#fff;border:0;border-radius:8px;padding:.7rem 1.4rem;font-size:1rem;cursor:pointer}input[type=text]{padding:.55rem;border:1px solid #c9cedb;border-radius:6px;width:100%;max-width:320px}@media(prefers-color-scheme:dark){body{background:#12151c;color:#e6e9ef}.box{background:#1c212c}td,th{border-color:#2a3040}.total td{border-color:#e6e9ef}.muted{color:#9aa3b2}}</style></head><body>\n<!--BANNER-->\n<p class=\"muted\">${esc(s.business_name)} · Proposal ${id} · valid until ${q.valid_until}</p>\n<h1>Proposal for ${esc(who)}</h1><p class=\"muted\">Prepared for ${esc(q.lead.client)}</p>\n<h2>Summary</h2><p>${esc(text.summary)}</p>\n<h2>What we heard</h2><p>${esc(text.understanding)}</p>\n<h2>Our approach</h2>${text.approach.map((p) => `<p>${esc(p)}</p>`).join('')}\n<h2>Investment</h2><table><tr><th>Item</th><th>Time</th><th class=\"r\">Price</th></tr>${rows}\n${q.quote.discount ? `<tr><td>Bundle discount (${q.quote.discount_percent}%)</td><td></td><td class=\"r\">−${money(q.quote.discount)}</td></tr>` : ''}\n${q.quote.tax ? `<tr><td>Tax (${q.quote.tax_rate}%)</td><td></td><td class=\"r\">${money(q.quote.tax)}</td></tr>` : ''}\n<tr class=\"total\"><td>Total</td><td>${q.quote.weeks} wk</td><td class=\"r\">${money(q.quote.total)}</td></tr></table>\n<p>${s.deposit_percent}% deposit (${money(q.quote.deposit)}) to start, the rest on completion.</p>\n<h2>Next steps</h2><p>${esc(text.next_steps)}</p>\n<div class=\"box\"><!--ACCEPT--></div>\n</body></html>`;\n\nconst record = { id, token, status: 'draft', lead: q.lead, quote: q.quote, flags: q.flags, issues, source, text, html, link,\n  created_at: q.created_at, valid_until: q.valid_until, views: 0, first_viewed_at: null, sent_at: null };\nif (q.ok && !q.dry_run) ($getWorkflowStaticData('global').proposals ??= {})[id] = record;\n\nreturn [{ json: { ok: q.ok, errors: q.errors, dry_run: q.dry_run, send_email: q.send_email, via_api: q.via_api, ai_error,\n  ...(q.ok ? { id, link, status: 'draft', source, flags: q.flags, issues, needs_review: issues.length > 0 || q.flags.length > 0, quote: q.quote, text, valid_until: q.valid_until,\n    email: { to: q.lead.email, subject: `Your proposal from ${s.business_name}`, body: `Hi ${first},\\n\\nThanks for the conversation. Here's your proposal for ${q.quote.items.map((i) => i.name).join(' and ')}:\\n${link}\\n\\nIt's valid until ${q.valid_until}. You can accept it right on the page, or reply with any questions.\\n\\n${s.sender_name}\\n${s.business_name}` },\n    html: q.dry_run ? html : undefined } : {}) } }];\n"
      },
      "id": "m24-assemble-proposal",
      "name": "Assemble Proposal",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1820,
        40
      ]
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "strict",
            "version": 2
          },
          "conditions": [
            {
              "id": "c1",
              "leftValue": "={{ $json.dry_run }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "m24-dry-run",
      "name": "Dry Run?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        2040,
        40
      ]
    },
    {
      "parameters": {
        "respondWith": "json",
        "responseBody": "={{ $json }}",
        "options": {}
      },
      "id": "m24-respond-preview",
      "name": "Respond Preview",
      "type": "n8n-nodes-base.respondToWebhook",
      "typeVersion": 1.1,
      "position": [
        2260,
        -100
      ]
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "strict",
            "version": 2
          },
          "conditions": [
            {
              "id": "c1",
              "leftValue": "={{ $json.send_email }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "m24-email-it",
      "name": "Email It?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        2260,
        60
      ]
    },
    {
      "parameters": {
        "sendTo": "={{ $json.email.to }}",
        "subject": "={{ $json.email.subject }}",
        "emailType": "text",
        "message": "={{ $json.email.body }}",
        "options": {
          "appendAttribution": false,
          "senderName": "={{ $('Settings (edit me)').first().json.sender_name }}"
        }
      },
      "id": "m24-email-proposal",
      "name": "Email Proposal",
      "type": "n8n-nodes-base.gmail",
      "typeVersion": 2.1,
      "position": [
        2480,
        -20
      ],
      "onError": "continueRegularOutput",
      "webhookId": "course-24-gmail"
    },
    {
      "parameters": {
        "jsCode": "// Mark the proposal sent only if Gmail returned a message id.\nconst x = $('Assemble Proposal').first().json;\nconst res = $input.first().json;\nconst sent = Boolean(res.id) && !res.ok; // Gmail's response has id/threadId; without a credential the input item passes through\nconst p = $getWorkflowStaticData('global').proposals[x.id];\nif (sent) { p.status = 'sent'; p.sent_at = new Date().toISOString(); }\nelse p.send_error = String(res.error?.message ?? res.error ?? 'no message id returned (check the Gmail credential)').slice(0, 300);\nconst { html, ...rest } = x;\nreturn [{ json: { ...rest, status: p.status, sent, send_error: sent ? null : p.send_error } }];\n"
      },
      "id": "m24-record-sent",
      "name": "Record Sent",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        2700,
        -20
      ]
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "strict",
            "version": 2
          },
          "conditions": [
            {
              "id": "c1",
              "leftValue": "={{ $json.via_api }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "m24-created-via-api",
      "name": "Created via API?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        2920,
        60
      ]
    },
    {
      "parameters": {
        "respondWith": "json",
        "responseBody": "={{ (({ html, ...rest }) => rest)($json) }}",
        "options": {}
      },
      "id": "m24-respond-created",
      "name": "Respond Created",
      "type": "n8n-nodes-base.respondToWebhook",
      "typeVersion": 1.1,
      "position": [
        3140,
        60
      ]
    },
    {
      "parameters": {
        "jsCode": "// Serve the proposal page (token required), count views, show the accept form or the current state.\nconst q = $('View API').first().json.query ?? {};\nconst p = ($getWorkflowStaticData('global').proposals ?? {})[String(q.id ?? '')];\nconst page = (title, msg) => `<!doctype html><html><head><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width,initial-scale=1\"><title>${title}</title></head><body style=\"font-family:system-ui;max-width:560px;margin:4rem auto;padding:0 1rem\"><h1>${title}</h1><p>${msg}</p></body></html>`;\nif (!p) return [{ json: { code: 404, html: page('Proposal not found', 'Check the link in your email.') } }];\nif (q.t !== p.token) return [{ json: { code: 403, html: page('Link not valid', 'Please use the link from your email.') } }];\nconst today = String(q.now || new Date().toISOString()).slice(0, 10);\nconst expired = p.status !== 'accepted' && today > p.valid_until;\nif (q.preview !== '1') { p.views += 1; p.first_viewed_at ??= new Date().toISOString(); p.last_viewed_at = new Date().toISOString(); }\nconst s = $('Settings (edit me)').first().json;\nconst banner = expired ? `<p style=\"background:#fde68a;color:#422;padding:.8rem 1rem;border-radius:8px\">This proposal expired on ${p.valid_until}. Reply to the email and we'll refresh it.</p>`\n  : p.status === 'accepted' ? `<p style=\"background:#bbf7d0;color:#123;padding:.8rem 1rem;border-radius:8px\">Accepted by ${p.accepted_by} on ${p.accepted_at.slice(0, 10)}. Thank you!</p>` : '';\nconst accept = expired || p.status === 'accepted' ? `<p>Questions? Reply to the email or write to ${s.owner_email}.</p>`\n  : `<form method=\"post\" action=\"${s.public_base_url}/webhook/prop/accept\"><input type=\"hidden\" name=\"id\" value=\"${p.id}\"><input type=\"hidden\" name=\"t\" value=\"${p.token}\">\n<p><strong>Ready to go?</strong> Type your full name to accept this proposal and its terms.</p><p><input type=\"text\" name=\"name\" required placeholder=\"Full name\" autocomplete=\"name\"></p>\n<p><label><input type=\"checkbox\" name=\"agree\" value=\"yes\" required> I accept the scope and the ${s.deposit_percent}% deposit</label></p><button type=\"submit\">Accept proposal</button></form>`;\nreturn [{ json: { code: 200, html: p.html.replace('<!--BANNER-->', banner).replace('<!--ACCEPT-->', accept) } }];\n"
      },
      "id": "m24-render-proposal",
      "name": "Render Proposal",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        700,
        420
      ]
    },
    {
      "parameters": {
        "respondWith": "text",
        "responseBody": "={{ $json.html }}",
        "options": {
          "responseCode": "={{ $json.code }}",
          "responseHeaders": {
            "entries": [
              {
                "name": "Content-Type",
                "value": "text/html; charset=utf-8"
              }
            ]
          }
        }
      },
      "id": "m24-respond-page",
      "name": "Respond Page",
      "type": "n8n-nodes-base.respondToWebhook",
      "typeVersion": 1.1,
      "position": [
        920,
        420
      ]
    },
    {
      "parameters": {
        "jsCode": "// Accept from the page's form (POST, so link scanners in email can't accept by visiting a URL).\nconst b = $('Accept API').first().json.body ?? {};\nconst q = $('Accept API').first().json.query ?? {};\nconst p = ($getWorkflowStaticData('global').proposals ?? {})[String(b.id ?? '')];\nconst today = String(q.now || new Date().toISOString()).slice(0, 10);\nconst fail = (code, title, msg) => [{ json: { accepted: false, code, title, msg } }];\nif (!p) return fail(404, 'Proposal not found', 'Check the link in your email.');\nif (b.t !== p.token) return fail(403, 'Link not valid', 'Please use the link from your email.');\nif (p.status === 'accepted') return fail(409, 'Already accepted', `Accepted by ${p.accepted_by} on ${p.accepted_at.slice(0, 10)}.`);\nif (today > p.valid_until) return fail(410, 'Proposal expired', `It expired on ${p.valid_until}. Reply to the email and we'll refresh it.`);\nconst name = String(b.name ?? '').trim();\nif (name.length < 2 || b.agree !== 'yes') return fail(400, 'Almost there', 'Type your full name and tick the box to accept.');\np.status = 'accepted'; p.accepted_by = name; p.accepted_at = new Date().toISOString();\np.accepted_from = $('Accept API').first().json.headers?.['x-forwarded-for'] ?? null;\nconst s = $('Settings (edit me)').first().json;\nconst money = (n) => new Intl.NumberFormat('en-US', { style: 'currency', currency: p.quote.currency.toUpperCase(), maximumFractionDigits: 0 }).format(n);\nreturn [{ json: { accepted: true, code: 200, id: p.id, title: 'Thank you!', msg: `Proposal accepted. We'll send the deposit invoice (${money(p.quote.deposit)}) and a kickoff invite shortly.`,\n  notify: { to: s.owner_email, subject: `✅ ${p.lead.company || p.lead.client} accepted ${p.id} (${money(p.quote.total)})`,\n    body: `${name} accepted proposal ${p.id} for ${p.quote.items.map((i) => i.name).join(', ')}.\\nTotal ${money(p.quote.total)}, deposit ${money(p.quote.deposit)}.\\nClient: ${p.lead.client} <${p.lead.email}>\\nViewed ${p.views} time(s).` } } }];\n"
      },
      "id": "m24-accept-proposal",
      "name": "Accept Proposal",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        700,
        600
      ]
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "strict",
            "version": 2
          },
          "conditions": [
            {
              "id": "c1",
              "leftValue": "={{ $json.accepted }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "m24-accepted",
      "name": "Accepted?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        920,
        600
      ]
    },
    {
      "parameters": {
        "sendTo": "={{ $json.notify.to }}",
        "subject": "={{ $json.notify.subject }}",
        "emailType": "text",
        "message": "={{ $json.notify.body }}",
        "options": {
          "appendAttribution": false
        }
      },
      "id": "m24-notify-owner",
      "name": "Notify Owner",
      "type": "n8n-nodes-base.gmail",
      "typeVersion": 2.1,
      "position": [
        1140,
        560
      ],
      "onError": "continueRegularOutput",
      "webhookId": "course-24-gmail-owner"
    },
    {
      "parameters": {
        "respondWith": "text",
        "responseBody": "={{ '<!doctype html><html><head><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width,initial-scale=1\"><title>' + $('Accept Proposal').first().json.title + '</title></head><body style=\"font-family:system-ui;max-width:560px;margin:4rem auto;padding:0 1rem\"><h1>' + $('Accept Proposal').first().json.title + '</h1><p>' + $('Accept Proposal').first().json.msg + '</p></body></html>' }}",
        "options": {
          "responseCode": "={{ $('Accept Proposal').first().json.code }}",
          "responseHeaders": {
            "entries": [
              {
                "name": "Content-Type",
                "value": "text/html; charset=utf-8"
              }
            ]
          }
        }
      },
      "id": "m24-respond-thanks",
      "name": "Respond Thanks",
      "type": "n8n-nodes-base.respondToWebhook",
      "typeVersion": 1.1,
      "position": [
        1360,
        560
      ]
    },
    {
      "parameters": {
        "respondWith": "text",
        "responseBody": "={{ '<!doctype html><html><head><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width,initial-scale=1\"><title>' + $('Accept Proposal').first().json.title + '</title></head><body style=\"font-family:system-ui;max-width:560px;margin:4rem auto;padding:0 1rem\"><h1>' + $('Accept Proposal').first().json.title + '</h1><p>' + $('Accept Proposal').first().json.msg + '</p></body></html>' }}",
        "options": {
          "responseCode": "={{ $('Accept Proposal').first().json.code }}",
          "responseHeaders": {
            "entries": [
              {
                "name": "Content-Type",
                "value": "text/html; charset=utf-8"
              }
            ]
          }
        }
      },
      "id": "m24-respond-not-accepted",
      "name": "Respond Not Accepted",
      "type": "n8n-nodes-base.respondToWebhook",
      "typeVersion": 1.1,
      "position": [
        1140,
        720
      ]
    },
    {
      "parameters": {
        "jsCode": "// Proposal status for the owner (no HTML, no token): by ?id=, or the latest one for ?email=.\nconst q = $('Status API').first().json.query ?? {};\nconst all = Object.values($getWorkflowStaticData('global').proposals ?? {});\nconst p = q.id ? all.find((x) => x.id === String(q.id))\n  : all.filter((x) => x.lead.email === String(q.email ?? '').toLowerCase()).sort((a, b) => (a.created_at < b.created_at ? 1 : -1))[0];\nif (!p) return [{ json: { found: false, id: q.id ?? null, email: q.email ?? null } }];\nconst { html, token, text, ...rest } = p;\nreturn [{ json: { found: true, ...rest } }];\n"
      },
      "id": "m24-proposal-status",
      "name": "Proposal Status",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        700,
        800
      ]
    },
    {
      "parameters": {
        "respondWith": "json",
        "responseBody": "={{ $json }}",
        "options": {
          "responseCode": "={{ $json.found ? 200 : 404 }}"
        }
      },
      "id": "m24-respond-status",
      "name": "Respond Status",
      "type": "n8n-nodes-base.respondToWebhook",
      "typeVersion": 1.1,
      "position": [
        920,
        800
      ]
    }
  ],
  "connections": {
    "Create API": {
      "main": [
        [
          {
            "node": "Settings (edit me)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "View API": {
      "main": [
        [
          {
            "node": "Settings (edit me)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Accept API": {
      "main": [
        [
          {
            "node": "Settings (edit me)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Status API": {
      "main": [
        [
          {
            "node": "Settings (edit me)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Settings (edit me)": {
      "main": [
        [
          {
            "node": "Route",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Route": {
      "main": [
        [
          {
            "node": "Build Quote",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Render Proposal",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Accept Proposal",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Proposal Status",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Build Quote": {
      "main": [
        [
          {
            "node": "Valid Intake?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Valid Intake?": {
      "main": [
        [
          {
            "node": "Use AI?",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Invalid via API?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Invalid via API?": {
      "main": [
        [
          {
            "node": "Respond Invalid",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Use AI?": {
      "main": [
        [
          {
            "node": "Write Sections",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Join Branches",
            "type": "main",
            "index": 1
          }
        ]
      ]
    },
    "OpenAI Chat Model": {
      "ai_languageModel": [
        [
          {
            "node": "Write Sections",
            "type": "ai_languageModel",
            "index": 0
          }
        ]
      ]
    },
    "Sections Format": {
      "ai_outputParser": [
        [
          {
            "node": "Write Sections",
            "type": "ai_outputParser",
            "index": 0
          }
        ]
      ]
    },
    "Write Sections": {
      "main": [
        [
          {
            "node": "Join Branches",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Join Branches": {
      "main": [
        [
          {
            "node": "Assemble Proposal",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Assemble Proposal": {
      "main": [
        [
          {
            "node": "Dry Run?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Dry Run?": {
      "main": [
        [
          {
            "node": "Respond Preview",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Email It?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Email It?": {
      "main": [
        [
          {
            "node": "Email Proposal",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Created via API?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Email Proposal": {
      "main": [
        [
          {
            "node": "Record Sent",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Record Sent": {
      "main": [
        [
          {
            "node": "Created via API?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Created via API?": {
      "main": [
        [
          {
            "node": "Respond Created",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Render Proposal": {
      "main": [
        [
          {
            "node": "Respond Page",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Accept Proposal": {
      "main": [
        [
          {
            "node": "Accepted?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Accepted?": {
      "main": [
        [
          {
            "node": "Notify Owner",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Respond Not Accepted",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Notify Owner": {
      "main": [
        [
          {
            "node": "Respond Thanks",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Proposal Status": {
      "main": [
        [
          {
            "node": "Respond Status",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  },
  "settings": {
    "executionOrder": "v1"
  },
  "pinData": {}
}