feat(zoho): WebToLead forwarding mode, veteran-owned certified badge, Docker/env/CI updates (batch 0.9.1)

This commit is contained in:
null 2026-06-14 16:08:29 -05:00
parent 05b27d216a
commit d074e597b2
12 changed files with 187 additions and 31 deletions

View File

@ -5,8 +5,17 @@ NODE_ENV=production
SERVER_PORT=3001
# Zoho CRM Integration
# Set ZOHO_ENABLED=true to forward leads/support to Zoho CRM
# Get credentials from https://api-console.zoho.com → Self Client
# Preferred current setup: webtolead for contact leads using the legacy Zoho form tokens.
ZOHO_FORWARDING_MODE=webtolead
ZOHO_WEBTOLEAD_ENABLED=false
ZOHO_WEBTOLEAD_URL=https://crm.zoho.com/crm/WebToLeadForm
ZOHO_WEBTOLEAD_XNQSJSDP=
ZOHO_WEBTOLEAD_XMIWTLD=
ZOHO_WEBTOLEAD_ACTION_TYPE=TGVhZHM=
ZOHO_WEBTOLEAD_RETURN_URL=null
ZOHO_WEBTOLEAD_ZC_GAD=
# Standby REST API/OAuth setup. Set ZOHO_FORWARDING_MODE=api and ZOHO_ENABLED=true to use it.
ZOHO_ENABLED=false
ZOHO_API_DOMAIN=https://www.zohoapis.com
ZOHO_ACCOUNTS_DOMAIN=https://accounts.zoho.com

4
.gitignore vendored
View File

@ -6,6 +6,9 @@ FUTURE.md
HISTORY.md
BUILD_SUMMARY.md
SCRIPTS.md
.drop/
zoho.md
# Dependencies
node_modules/
@ -36,3 +39,4 @@ pnpm-debug.log*
.learnings/
Levi.md
Queue-North-Website.code-workspace
Working Site.zip

View File

@ -47,6 +47,14 @@ ENV SERVER_PORT=3001
ENV RATE_LIMIT_PER_MINUTE=5
ENV CORS_ORIGIN=*
ENV LOG_LEVEL=info
ENV ZOHO_FORWARDING_MODE=webtolead
ENV ZOHO_WEBTOLEAD_ENABLED=false
ENV ZOHO_WEBTOLEAD_URL=https://crm.zoho.com/crm/WebToLeadForm
ENV ZOHO_WEBTOLEAD_XNQSJSDP=
ENV ZOHO_WEBTOLEAD_XMIWTLD=
ENV ZOHO_WEBTOLEAD_ACTION_TYPE=TGVhZHM=
ENV ZOHO_WEBTOLEAD_RETURN_URL=null
ENV ZOHO_WEBTOLEAD_ZC_GAD=
ENV ZOHO_ENABLED=false
ENV ZOHO_API_DOMAIN=https://www.zohoapis.com
ENV ZOHO_ACCOUNTS_DOMAIN=https://accounts.zoho.com

Binary file not shown.

After

Width:  |  Height:  |  Size: 151 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 129 KiB

View File

@ -22,12 +22,21 @@ services:
- RATE_LIMIT_PER_MINUTE=5
- CORS_ORIGIN=https://queuenorth.com
- LOG_LEVEL=info
- ZOHO_ENABLED=false
- ZOHO_API_DOMAIN=https://www.zohoapis.com
- ZOHO_CLIENT_ID=
- ZOHO_CLIENT_SECRET=
- ZOHO_REFRESH_TOKEN=
- ZOHO_REDIRECT_URI=
- ZOHO_FORWARDING_MODE=${ZOHO_FORWARDING_MODE:-webtolead}
- ZOHO_WEBTOLEAD_ENABLED=${ZOHO_WEBTOLEAD_ENABLED:-false}
- ZOHO_WEBTOLEAD_URL=${ZOHO_WEBTOLEAD_URL:-https://crm.zoho.com/crm/WebToLeadForm}
- ZOHO_WEBTOLEAD_XNQSJSDP=${ZOHO_WEBTOLEAD_XNQSJSDP:-}
- ZOHO_WEBTOLEAD_XMIWTLD=${ZOHO_WEBTOLEAD_XMIWTLD:-}
- ZOHO_WEBTOLEAD_ACTION_TYPE=${ZOHO_WEBTOLEAD_ACTION_TYPE:-TGVhZHM=}
- ZOHO_WEBTOLEAD_RETURN_URL=${ZOHO_WEBTOLEAD_RETURN_URL:-null}
- ZOHO_WEBTOLEAD_ZC_GAD=${ZOHO_WEBTOLEAD_ZC_GAD:-}
- ZOHO_ENABLED=${ZOHO_ENABLED:-false}
- ZOHO_API_DOMAIN=${ZOHO_API_DOMAIN:-https://www.zohoapis.com}
- ZOHO_ACCOUNTS_DOMAIN=${ZOHO_ACCOUNTS_DOMAIN:-https://accounts.zoho.com}
- ZOHO_CLIENT_ID=${ZOHO_CLIENT_ID:-}
- ZOHO_CLIENT_SECRET=${ZOHO_CLIENT_SECRET:-}
- ZOHO_REFRESH_TOKEN=${ZOHO_REFRESH_TOKEN:-}
- ZOHO_CASES_ENABLED=${ZOHO_CASES_ENABLED:-false}
- RECAPTCHA_ENABLED=${RECAPTCHA_ENABLED:-false}
- RECAPTCHA_SECRET_KEY=${RECAPTCHA_SECRET_KEY:-}
- RECAPTCHA_MIN_SCORE=${RECAPTCHA_MIN_SCORE:-0.5}

View File

@ -1,18 +1,36 @@
# Zoho CRM Setup Guide for Queue North Admins
This guide walks you through setting up Zoho CRM integration to automatically capture leads and support cases from Queue North's website.
This guide walks you through the current Zoho CRM integration. Contact leads use the legacy Zoho WebToLead form tokens, while the OAuth/API integration remains available as a standby option for future lead upserts or support cases.
---
## 🔒 Prerequisites
## Prerequisites
Before you begin, ensure you have:
- A Zoho CRM account (admin access required)
- Access to Zoho API Console: https://api-console.zoho.com
- The WebToLead hidden field values from the old Zoho form
---
## Step 1: Create a Zoho Self-Client App
## Step 1: Gather WebToLead Values
The current integration needs the old Zoho form's hidden fields:
```text
xnQsjsdp
xmIwtLD
actionType
returnURL
zc_gad
```
These values are stored locally in `zoho.md`, which is ignored by git.
---
## Optional Standby: Create a Zoho Self-Client App
Only use these OAuth steps if switching `ZOHO_FORWARDING_MODE=api`.
1. Go to **https://api-console.zoho.com**
2. Click **"Create Self Client"**
@ -29,7 +47,7 @@ Before you begin, ensure you have:
---
## Step 2: Generate an Authorization Code
## Optional Standby: Generate an Authorization Code
1. In the Self Client tab, click **"Generate Code"**
2. Set the **Scope** to:
@ -42,7 +60,7 @@ Before you begin, ensure you have:
---
## Step 3: Exchange Auth Code for Tokens
## Optional Standby: Exchange Auth Code for Tokens
Run this `curl` command (replace placeholders):
@ -69,12 +87,27 @@ curl -X POST https://accounts.zoho.com/oauth/v2/token \
---
## Step 4: Configure Environment Variables
## Step 2: Configure Environment Variables
Add these to your `.env` file:
The current production-friendly setup uses the legacy Zoho WebToLead form tokens for contact leads while keeping the OAuth API integration available as a standby option.
Add these to your `.env` file for WebToLead lead forwarding:
```env
# Zoho integration is OFF by default — set to true to enable
ZOHO_FORWARDING_MODE=webtolead
ZOHO_WEBTOLEAD_ENABLED=true
ZOHO_WEBTOLEAD_URL=https://crm.zoho.com/crm/WebToLeadForm
ZOHO_WEBTOLEAD_XNQSJSDP=<from Zoho WebToLead hidden field>
ZOHO_WEBTOLEAD_XMIWTLD=<from Zoho WebToLead hidden field>
ZOHO_WEBTOLEAD_ACTION_TYPE=TGVhZHM=
ZOHO_WEBTOLEAD_RETURN_URL=null
ZOHO_WEBTOLEAD_ZC_GAD=
```
Use these only if switching back to the Zoho CRM REST API/OAuth integration:
```env
ZOHO_FORWARDING_MODE=api
ZOHO_ENABLED=false
ZOHO_API_DOMAIN=https://www.zohoapis.com
ZOHO_ACCOUNTS_DOMAIN=https://accounts.zoho.com
@ -85,7 +118,7 @@ ZOHO_REFRESH_TOKEN=<from Step 3>
ZOHO_CASES_ENABLED=false
```
> **Note:** Both `ZOHO_ENABLED` and `ZOHO_CASES_ENABLED` default to `false`. Set them to `true` only after completing Steps 13 and verifying your credentials.
> **Note:** `ZOHO_CASES_ENABLED` only applies to the OAuth/API path. The WebToLead values found in the old site are for lead capture only.
### Datacenter Variants
@ -100,7 +133,7 @@ If your Zoho datacenter is **outside the US**, adjust the domains:
---
## Step 5: Test the Integration
## Step 3: Test the Integration
### Test Lead Capture
1. Submit a lead on the contact form (name, email, phone, message)
@ -156,7 +189,7 @@ Website Contact Form → SQLite (always saved)
3. `refresh_token` never expires — store it securely
### Upsert Logic
- **Leads**: Email-based upsert (update if exists, create if new)
- **Leads**: WebToLead creates leads through the legacy Zoho form endpoint. API mode uses email-based upsert.
- **Cases**: Always insert (new case per submission)
### Fire-and-Forget Design
@ -171,7 +204,7 @@ Website Contact Form → SQLite (always saved)
After configuration:
1. Deploy the environment variables to production
2. Set `ZOHO_ENABLED=true` and `ZOHO_CASES_ENABLED=true` in production `.env`
2. Set `ZOHO_WEBTOLEAD_ENABLED=true` in production `.env`
3. Restart the application
4. Submit a test lead and support case to verify data flows to Zoho CRM
5. Check Zoho CRM Leads and Cases tabs to confirm both appear

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

View File

@ -374,6 +374,14 @@ const ZOHO_ACCOUNTS_DOMAIN = process.env.ZOHO_ACCOUNTS_DOMAIN || 'https://accoun
const ZOHO_CLIENT_ID = process.env.ZOHO_CLIENT_ID || null
const ZOHO_CLIENT_SECRET = process.env.ZOHO_CLIENT_SECRET || null
const ZOHO_REFRESH_TOKEN = process.env.ZOHO_REFRESH_TOKEN || null
const ZOHO_FORWARDING_MODE = (process.env.ZOHO_FORWARDING_MODE || 'api').toLowerCase()
const ZOHO_WEBTOLEAD_ENABLED = process.env.ZOHO_WEBTOLEAD_ENABLED === 'true'
const ZOHO_WEBTOLEAD_URL = process.env.ZOHO_WEBTOLEAD_URL || 'https://crm.zoho.com/crm/WebToLeadForm'
const ZOHO_WEBTOLEAD_XNQSJSDP = process.env.ZOHO_WEBTOLEAD_XNQSJSDP || null
const ZOHO_WEBTOLEAD_XMIWTLD = process.env.ZOHO_WEBTOLEAD_XMIWTLD || null
const ZOHO_WEBTOLEAD_ACTION_TYPE = process.env.ZOHO_WEBTOLEAD_ACTION_TYPE || 'TGVhZHM='
const ZOHO_WEBTOLEAD_RETURN_URL = process.env.ZOHO_WEBTOLEAD_RETURN_URL || 'null'
const ZOHO_WEBTOLEAD_ZC_GAD = process.env.ZOHO_WEBTOLEAD_ZC_GAD || ''
// In-memory access token cache
let zohoAccessToken = null
@ -536,6 +544,70 @@ async function forwardToZoho(leadData) {
}
}
async function forwardToZohoWebToLead(leadData) {
if (!ZOHO_WEBTOLEAD_ENABLED) return
if (!ZOHO_WEBTOLEAD_XNQSJSDP || !ZOHO_WEBTOLEAD_XMIWTLD) {
log.warn('[Zoho WebToLead] Skipping forwarding - hidden form tokens are not configured')
return
}
const descriptionParts = []
if (leadData.message) descriptionParts.push(leadData.message)
if (leadData.service_interest) descriptionParts.push(`Service Interest: ${leadData.service_interest}`)
const payload = new URLSearchParams({
xnQsjsdp: ZOHO_WEBTOLEAD_XNQSJSDP,
zc_gad: ZOHO_WEBTOLEAD_ZC_GAD,
xmIwtLD: ZOHO_WEBTOLEAD_XMIWTLD,
actionType: ZOHO_WEBTOLEAD_ACTION_TYPE,
returnURL: ZOHO_WEBTOLEAD_RETURN_URL,
aG9uZXlwb3Q: '',
Company: leadData.company || '',
'Last Name': leadData.name || 'Unknown',
Email: leadData.email || '',
Phone: leadData.phone || '',
'Zip Code': leadData.zip || '',
Description: descriptionParts.join('\n\n'),
})
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), ZOHO_TIMEOUT_MS)
try {
const response = await fetch(ZOHO_WEBTOLEAD_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: payload.toString(),
signal: controller.signal,
})
if (!response.ok) {
const text = await response.text()
log.error(`[Zoho WebToLead] Forwarding failed (${response.status}):`, text.slice(0, 500))
return
}
log.info('[Zoho WebToLead] Lead forwarded successfully')
} catch (fetchErr) {
if (fetchErr.name === 'AbortError') {
log.warn('[Zoho WebToLead] Lead forwarding timed out after', ZOHO_TIMEOUT_MS, 'ms')
} else {
log.error('[Zoho WebToLead] Forwarding error:', fetchErr.message)
}
} finally {
clearTimeout(timeoutId)
}
}
function forwardLeadToZoho(leadData) {
if (ZOHO_FORWARDING_MODE === 'webtolead') {
return forwardToZohoWebToLead(leadData)
}
return forwardToZoho(leadData)
}
// --- Zoho Cases Forwarding (best-effort, fire-and-forget) ---
async function forwardSupportToZoho(supportData) {
if (!ZOHO_CASES_ENABLED) return
@ -703,7 +775,7 @@ app.post('/api/leads', express.json({ limit: '1mb' }), async (req, res) => {
log.info(`Lead submitted: ${sanitized.email} from ${sanitized.company} (id: ${result.lastInsertRowid})`)
// Fire-and-forget Zoho forwarding (best-effort, non-blocking)
forwardToZoho(sanitized).catch(err => log.error('[Zoho] Forwarding error:', err.message))
forwardLeadToZoho(sanitized).catch(err => log.error('[Zoho] Forwarding error:', err.message))
res.json({ success: true, message: "Thanks! We'll be in touch shortly." })
} catch (err) {
@ -713,7 +785,7 @@ app.post('/api/leads', express.json({ limit: '1mb' }), async (req, res) => {
log.warn(`Duplicate lead email: ${sanitized.email}`)
// Still forward to Zoho (non-blocking) for existing leads
forwardToZoho(sanitized).catch(err => log.error('[Zoho] Forwarding error:', err.message))
forwardLeadToZoho(sanitized).catch(err => log.error('[Zoho] Forwarding error:', err.message))
return res.status(409).json({
error: 'Duplicate lead',
@ -857,13 +929,16 @@ app.get('*', (req, res, next) => {
app.listen(PORT, () => {
log.info(`Server running on http://localhost:${PORT}`)
log.info(`Health check: http://localhost:${PORT}/api/health`)
if (ZOHO_ENABLED) {
log.info(`Zoho CRM forwarding: ENABLED`)
log.info(`Zoho lead forwarding mode: ${ZOHO_FORWARDING_MODE}`)
if (ZOHO_FORWARDING_MODE === 'webtolead') {
log.info(`Zoho WebToLead forwarding: ${ZOHO_WEBTOLEAD_ENABLED ? 'ENABLED' : 'DISABLED'}`)
} else if (ZOHO_ENABLED) {
log.info(`Zoho CRM API forwarding: ENABLED`)
log.info(`Zoho API domain: ${ZOHO_API_DOMAIN}`)
log.info(`Zoho Accounts domain: ${ZOHO_ACCOUNTS_DOMAIN}`)
log.info(`Zoho Cases forwarding: ${process.env.ZOHO_CASES_ENABLED === 'true' ? 'ENABLED' : 'DISABLED'}`)
} else {
log.info('Zoho CRM forwarding: DISABLED (set ZOHO_ENABLED=true to enable)')
log.info('Zoho CRM API forwarding: DISABLED (set ZOHO_ENABLED=true to enable)')
}
log.info(`Rate limiting: ${rateLimitMax} requests per ${rateLimitWindowMs / 1000} seconds`)
log.info(`Security headers: Helmet enabled with CSP configured`)

View File

@ -141,6 +141,13 @@ const Footer = () => {
{/* Veteran Owned & Operated */}
<div className="border-t border-white/10 mt-10 py-6">
<div className="flex flex-col items-center gap-4 text-center">
<span className="flex h-24 w-20 items-center justify-center rounded-md border border-white/10 bg-white p-1 shadow-sm">
<img
src="/assets/brand/veteran-owned-certified.webp"
alt="SBA Veteran-Owned Certified badge"
className="h-full w-full object-contain"
/>
</span>
<p className="text-primary-cyan text-xs font-semibold uppercase tracking-[0.18em]">
Veteran Owned &amp; Operated
</p>

View File

@ -1,6 +1,6 @@
import SEO from '@/components/SEO'
import { Link } from 'react-router-dom'
import { ArrowRight, Award, CheckCircle2, Compass, Cpu, Handshake, Headphones, Route, Users, Wrench } from 'lucide-react'
import { ArrowRight, Award, CheckCircle2, Compass, Cpu, Handshake, Headphones, Route, Wrench } from 'lucide-react'
const proofPoints = [
{ label: '25+ years', detail: 'Communications and infrastructure experience', icon: Award, containerClass: '' },
@ -20,7 +20,14 @@ const proofPoints = [
logoClassName: 'h-full w-full scale-[2]',
containerClass: 'p-1 overflow-hidden',
},
{ label: 'Veteran owned', detail: 'Disciplined delivery and direct accountability', icon: Users, containerClass: '' },
{
label: 'Veteran-Owned Certified',
detail: 'Disciplined delivery and direct accountability',
logo: '/assets/brand/veteran-owned-certified.webp',
logoAlt: 'SBA Veteran-Owned Certified badge',
logoClassName: 'h-full w-full',
containerClass: 'p-1',
},
]
const operatingPrinciples = [

View File

@ -190,10 +190,14 @@ const Home = () => {
<span className="text-sm font-semibold leading-tight text-primary-navy text-center lg:text-left">Cisco Certified Partner</span>
</div>
<div className="flex flex-col items-center gap-2 lg:flex-row lg:gap-3 lg:justify-start">
<span className="flex h-16 w-20 shrink-0 items-center justify-center rounded-md border border-border bg-white">
<ShieldCheck className="h-8 w-8 text-primary-blue" aria-hidden="true" />
<span className="flex h-16 w-20 shrink-0 items-center justify-center rounded-md border border-border bg-white p-1">
<img
src="/assets/brand/veteran-owned-certified.webp"
alt="SBA Veteran-Owned Certified badge"
className="h-full w-full object-contain"
/>
</span>
<span className="text-sm font-semibold leading-tight text-primary-navy text-center lg:text-left">Veteran Owned</span>
<span className="text-sm font-semibold leading-tight text-primary-navy text-center lg:text-left">Veteran-Owned Certified</span>
</div>
<div className="flex flex-col items-center gap-2 lg:flex-row lg:gap-3 lg:justify-start">
<span className="font-numeric flex h-16 w-20 shrink-0 items-center justify-center rounded-md border border-border bg-white text-2xl font-semibold text-primary-navy">