Compare commits
30 Commits
| Author | SHA1 | Date |
|---|---|---|
|
|
196389ddf3 | |
|
|
d074e597b2 | |
|
|
05b27d216a | |
|
|
76cb558e8b | |
|
|
a8d9492a80 | |
|
|
0f272fcf19 | |
|
|
3a61000c12 | |
|
|
e625a24b6e | |
|
|
f35de43952 | |
|
|
c43d3bc955 | |
|
|
1dcfbfc7a7 | |
|
|
ec14701795 | |
|
|
78967ff56f | |
|
|
cc1970fd1d | |
|
|
8c1e0f4c3d | |
|
|
fb12d8cf3c | |
|
|
a3ba03b7e1 | |
|
|
f59d053afd | |
|
|
033bdf6625 | |
|
|
548e20e6f0 | |
|
|
225c4e5485 | |
|
|
8f20670292 | |
|
|
0cfa048d0d | |
|
|
4410f01d48 | |
|
|
6bab4b5c89 | |
|
|
f9b36c198b | |
|
|
4b17e9f109 | |
|
|
6de9490764 | |
|
|
a81e97fb31 | |
|
|
362a7e0059 |
13
.env.example
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
15
Dockerfile
|
|
@ -15,6 +15,10 @@ RUN npm ci
|
|||
# Copy source files
|
||||
COPY . .
|
||||
|
||||
# Public Vite values are compiled into the frontend bundle at build time.
|
||||
ARG VITE_RECAPTCHA_SITE_KEY=
|
||||
ENV VITE_RECAPTCHA_SITE_KEY=$VITE_RECAPTCHA_SITE_KEY
|
||||
|
||||
# Build the frontend
|
||||
RUN npm run build
|
||||
|
||||
|
|
@ -43,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
|
||||
|
|
@ -50,6 +62,9 @@ ENV ZOHO_CLIENT_ID=
|
|||
ENV ZOHO_CLIENT_SECRET=
|
||||
ENV ZOHO_REFRESH_TOKEN=
|
||||
ENV ZOHO_CASES_ENABLED=false
|
||||
ENV RECAPTCHA_ENABLED=false
|
||||
ENV RECAPTCHA_SECRET_KEY=
|
||||
ENV RECAPTCHA_MIN_SCORE=0.5
|
||||
|
||||
# Create app directory structure
|
||||
RUN mkdir -p /app/db /app/logs
|
||||
|
|
|
|||
|
After Width: | Height: | Size: 151 KiB |
|
After Width: | Height: | Size: 129 KiB |
|
Before Width: | Height: | Size: 2.3 MiB After Width: | Height: | Size: 2.3 MiB |
|
|
@ -5,6 +5,8 @@ services:
|
|||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
args:
|
||||
- VITE_RECAPTCHA_SITE_KEY=${VITE_RECAPTCHA_SITE_KEY:-}
|
||||
container_name: queuenorth-website
|
||||
ports:
|
||||
- "3001:3001"
|
||||
|
|
@ -20,12 +22,24 @@ 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}
|
||||
restart: unless-stopped
|
||||
# Container runs as non-root user (UID 1001) for security
|
||||
|
||||
|
|
|
|||
|
|
@ -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 1–3 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
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"name": "queuenorth-website",
|
||||
"private": true,
|
||||
"version": "0.8.0",
|
||||
"version": "0.8.3",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "concurrently \"vite\" \"node server/index.js\"",
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 118 KiB After Width: | Height: | Size: 125 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 8.0 KiB |
|
After Width: | Height: | Size: 10 KiB |
120
server/index.js
|
|
@ -1,7 +1,7 @@
|
|||
import express from 'express'
|
||||
import path from 'path'
|
||||
import { fileURLToPath } from 'url'
|
||||
import { existsSync, mkdirSync } from 'fs'
|
||||
import { existsSync, mkdirSync, readFileSync } from 'fs'
|
||||
import sqlite3 from 'better-sqlite3'
|
||||
import z from 'zod'
|
||||
import rateLimit from 'express-rate-limit'
|
||||
|
|
@ -13,6 +13,27 @@ const __filename = fileURLToPath(import.meta.url)
|
|||
const __dirname = path.dirname(__filename)
|
||||
const app = express()
|
||||
|
||||
const loadLocalEnv = () => {
|
||||
const envPath = path.resolve(process.cwd(), '.env')
|
||||
if (!existsSync(envPath)) return
|
||||
|
||||
const envFile = readFileSync(envPath, 'utf8')
|
||||
for (const line of envFile.split(/\r?\n/)) {
|
||||
const trimmed = line.trim()
|
||||
if (!trimmed || trimmed.startsWith('#')) continue
|
||||
|
||||
const match = trimmed.match(/^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/)
|
||||
if (!match) continue
|
||||
|
||||
const [, key, rawValue] = match
|
||||
if (process.env[key] !== undefined) continue
|
||||
|
||||
process.env[key] = rawValue.replace(/^(['"])(.*)\1$/, '$2')
|
||||
}
|
||||
}
|
||||
|
||||
loadLocalEnv()
|
||||
|
||||
// Trust first proxy (Docker/reverse proxy) for correct client IP in rate limiting
|
||||
app.set('trust proxy', 1)
|
||||
const dbPath = path.join(__dirname, '../db/queuenorth.db')
|
||||
|
|
@ -64,15 +85,17 @@ const apiLimiter = rateLimit({
|
|||
const isDev = process.env.NODE_ENV === 'development'
|
||||
const cspDirectives = {
|
||||
defaultSrc: ["'self'"],
|
||||
scriptSrc: ["'self'", 'https://www.google.com/recaptcha/', 'https://www.gstatic.com/recaptcha/'],
|
||||
styleSrc: ["'self'", 'https://fonts.googleapis.com'],
|
||||
scriptSrc: ["'self'", 'https://crm.zohopublic.com', 'https://www.google.com/recaptcha/', 'https://www.gstatic.com/recaptcha/'],
|
||||
styleSrc: ["'self'", "'unsafe-inline'", 'https://fonts.googleapis.com'],
|
||||
fontSrc: ["'self'", 'https://fonts.gstatic.com'],
|
||||
imgSrc: ["'self'", 'data:'],
|
||||
connectSrc: isDev ? ["'self'", 'ws://localhost:*'] : ["'self'"],
|
||||
frameSrc: ["'self'", 'https://www.google.com/recaptcha/', 'https://recaptcha.google.com/recaptcha/'],
|
||||
connectSrc: isDev
|
||||
? ["'self'", 'ws://localhost:*', 'https://www.google.com/recaptcha/', 'https://www.gstatic.com/recaptcha/', 'https://recaptcha.google.com/recaptcha/']
|
||||
: ["'self'", 'https://www.google.com/recaptcha/', 'https://www.gstatic.com/recaptcha/', 'https://recaptcha.google.com/recaptcha/'],
|
||||
frameSrc: ["'self'", 'https://crm.zoho.com', 'https://www.google.com/recaptcha/', 'https://recaptcha.google.com/recaptcha/'],
|
||||
objectSrc: ["'none'"],
|
||||
baseUri: ["'self'"],
|
||||
formAction: ["'self'"],
|
||||
formAction: ["'self'", 'https://crm.zoho.com'],
|
||||
}
|
||||
|
||||
// Note: connectSrc currently allows 'self' only. Zoho API calls are server-to-server
|
||||
|
|
@ -351,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
|
||||
|
|
@ -513,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
|
||||
|
|
@ -680,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) {
|
||||
|
|
@ -690,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',
|
||||
|
|
@ -834,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`)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,50 @@
|
|||
import { Component } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
|
||||
class ErrorBoundary extends Component {
|
||||
constructor(props) {
|
||||
super(props)
|
||||
this.state = { hasError: false, error: null }
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error) {
|
||||
return { hasError: true, error }
|
||||
}
|
||||
|
||||
componentDidCatch(error, info) {
|
||||
console.error('[ErrorBoundary] Uncaught error:', error, info.componentStack)
|
||||
}
|
||||
|
||||
render() {
|
||||
if (!this.state.hasError) return this.props.children
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-primary-navy flex items-center justify-center px-4">
|
||||
<div className="text-center text-white max-w-md">
|
||||
<p className="text-primary-cyan text-sm font-semibold uppercase tracking-widest mb-4">Something went wrong</p>
|
||||
<h1 className="text-4xl font-bold mb-4">Unexpected Error</h1>
|
||||
<p className="text-white/70 mb-8">
|
||||
A problem occurred while loading this page. Please try refreshing, or contact us if the issue continues.
|
||||
</p>
|
||||
<div className="flex flex-col sm:flex-row gap-3 justify-center">
|
||||
<button
|
||||
onClick={() => window.location.reload()}
|
||||
className="inline-flex h-11 items-center justify-center rounded-md bg-primary-cyan px-6 text-sm font-semibold text-primary-navy hover:bg-white transition-colors"
|
||||
>
|
||||
Refresh Page
|
||||
</button>
|
||||
<Link
|
||||
to="/"
|
||||
onClick={() => this.setState({ hasError: false, error: null })}
|
||||
className="inline-flex h-11 items-center justify-center rounded-md border border-white/35 px-6 text-sm font-semibold text-white hover:bg-white/10 transition-colors"
|
||||
>
|
||||
Back to Home
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export default ErrorBoundary
|
||||
|
|
@ -1,16 +1,96 @@
|
|||
const RecaptchaPlaceholder = ({ error = '' }) => (
|
||||
<div className={`rounded-md border bg-background px-4 py-3 ${error ? 'border-red-500' : 'border-border'}`}>
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-primary-navy">Security verification</p>
|
||||
<p className="mt-1 text-xs text-soft-text">Google reCAPTCHA placeholder</p>
|
||||
</div>
|
||||
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-md border border-border bg-white">
|
||||
<span className="h-4 w-4 rounded-sm border-2 border-primary-blue" aria-hidden="true" />
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
|
||||
const siteKey = import.meta.env.VITE_RECAPTCHA_SITE_KEY
|
||||
|
||||
let recaptchaScriptPromise
|
||||
|
||||
const loadRecaptchaScript = () => {
|
||||
if (window.grecaptcha?.render) return Promise.resolve(window.grecaptcha)
|
||||
if (recaptchaScriptPromise) return recaptchaScriptPromise
|
||||
|
||||
recaptchaScriptPromise = new Promise((resolve, reject) => {
|
||||
const existingScript = document.querySelector('script[src^="https://www.google.com/recaptcha/api.js"]')
|
||||
if (existingScript) {
|
||||
existingScript.addEventListener('load', () => resolve(window.grecaptcha), { once: true })
|
||||
existingScript.addEventListener('error', reject, { once: true })
|
||||
return
|
||||
}
|
||||
|
||||
const script = document.createElement('script')
|
||||
script.src = 'https://www.google.com/recaptcha/api.js?render=explicit'
|
||||
script.async = true
|
||||
script.defer = true
|
||||
script.onload = () => resolve(window.grecaptcha)
|
||||
script.onerror = reject
|
||||
document.head.appendChild(script)
|
||||
})
|
||||
|
||||
return recaptchaScriptPromise
|
||||
}
|
||||
|
||||
const RecaptchaPlaceholder = ({ error = '', onVerify, onExpired, resetKey = 0 }) => {
|
||||
const containerRef = useRef(null)
|
||||
const widgetIdRef = useRef(null)
|
||||
const [isReady, setIsReady] = useState(false)
|
||||
const [loadError, setLoadError] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
if (!siteKey || !containerRef.current) return undefined
|
||||
|
||||
let isMounted = true
|
||||
|
||||
loadRecaptchaScript()
|
||||
.then((grecaptcha) => {
|
||||
grecaptcha.ready(() => {
|
||||
if (!isMounted || !containerRef.current || widgetIdRef.current !== null) return
|
||||
|
||||
widgetIdRef.current = grecaptcha.render(containerRef.current, {
|
||||
sitekey: siteKey,
|
||||
callback: (token) => {
|
||||
onVerify?.(token)
|
||||
},
|
||||
'expired-callback': () => {
|
||||
onExpired?.()
|
||||
},
|
||||
'error-callback': () => {
|
||||
onExpired?.()
|
||||
setLoadError('Security verification could not be completed. Please try again.')
|
||||
},
|
||||
})
|
||||
setIsReady(true)
|
||||
})
|
||||
})
|
||||
.catch(() => {
|
||||
if (isMounted) {
|
||||
setLoadError('Security verification could not load. Please refresh and try again.')
|
||||
}
|
||||
})
|
||||
|
||||
return () => {
|
||||
isMounted = false
|
||||
}
|
||||
}, [onExpired, onVerify])
|
||||
|
||||
useEffect(() => {
|
||||
if (widgetIdRef.current === null || !window.grecaptcha?.reset) return
|
||||
window.grecaptcha.reset(widgetIdRef.current)
|
||||
}, [resetKey])
|
||||
|
||||
if (!siteKey) {
|
||||
return (
|
||||
<div className="rounded-md border border-amber-400 bg-amber-50 px-4 py-3">
|
||||
<p className="text-sm font-semibold text-primary-navy">Security verification is not configured.</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`rounded-md border bg-background px-4 py-3 ${error || loadError ? 'border-red-500' : 'border-border'}`}>
|
||||
<div ref={containerRef} />
|
||||
{!isReady && !loadError && <p className="text-sm text-soft-text">Loading security verification...</p>}
|
||||
{(error || loadError) && <p className="mt-2 text-xs text-red-500">{error || loadError}</p>}
|
||||
</div>
|
||||
{error && <p className="mt-2 text-xs text-red-500">{error}</p>}
|
||||
</div>
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
export default RecaptchaPlaceholder
|
||||
|
|
|
|||
|
|
@ -2,9 +2,37 @@ import { useEffect } from 'react'
|
|||
import { useLocation } from 'react-router-dom'
|
||||
|
||||
export default function ScrollToTop() {
|
||||
const { pathname } = useLocation()
|
||||
const { pathname, hash } = useLocation()
|
||||
|
||||
// Cross-page navigation: scroll to hash or top on route change
|
||||
useEffect(() => {
|
||||
if (hash) {
|
||||
const el = document.querySelector(hash)
|
||||
if (el) {
|
||||
el.scrollIntoView({ behavior: 'smooth' })
|
||||
return
|
||||
}
|
||||
}
|
||||
window.scrollTo(0, 0)
|
||||
}, [pathname])
|
||||
}, [pathname, hash])
|
||||
|
||||
// Same-page: React Router won't re-navigate if URL is already identical,
|
||||
// so intercept clicks on any link pointing to #contact-form directly.
|
||||
useEffect(() => {
|
||||
const handleClick = (e) => {
|
||||
const anchor = e.target.closest('a')
|
||||
if (!anchor) return
|
||||
const href = anchor.getAttribute('href') || ''
|
||||
if (!href.includes('#contact-form')) return
|
||||
const el = document.querySelector('#contact-form')
|
||||
if (!el) return
|
||||
e.preventDefault()
|
||||
el.scrollIntoView({ behavior: 'smooth' })
|
||||
window.history.pushState(null, '', '#contact-form')
|
||||
}
|
||||
document.addEventListener('click', handleClick)
|
||||
return () => document.removeEventListener('click', handleClick)
|
||||
}, [])
|
||||
|
||||
return null
|
||||
}
|
||||
|
|
|
|||
|
|
@ -44,13 +44,13 @@ const Footer = () => {
|
|||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-8">
|
||||
{/* Company Info */}
|
||||
<div>
|
||||
<Link to="/" className="inline-flex items-start gap-4 mb-3 group" aria-label="Queue North Technologies Home">
|
||||
<Link to="/" className="flex items-center gap-3 mb-3 group" aria-label="Queue North Technologies Home">
|
||||
<img
|
||||
src="/logo.png"
|
||||
alt="Queue North Technologies"
|
||||
className="brand-logo-on-dark h-12 w-auto shrink-0 transition-opacity group-hover:opacity-90"
|
||||
/>
|
||||
<span className="pt-1 font-bold text-xl leading-tight tracking-tight text-white">Queue North Technologies</span>
|
||||
<span className="font-bold text-sm leading-tight tracking-tight text-white sm:text-xl sm:whitespace-nowrap">Queue North Technologies</span>
|
||||
</Link>
|
||||
<p className="text-navy-light text-sm leading-relaxed mb-5">{companyInfo.tagline}</p>
|
||||
<a
|
||||
|
|
@ -72,7 +72,7 @@ const Footer = () => {
|
|||
</div>
|
||||
</div>
|
||||
<Link
|
||||
to="/contact"
|
||||
to="/contact#contact-form"
|
||||
className="inline-flex items-center gap-2 rounded-md text-sm font-semibold px-5 py-2.5 bg-primary-cyan text-primary-navy hover:bg-white transition-colors duration-200"
|
||||
aria-label="Request a free consultation"
|
||||
>
|
||||
|
|
@ -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 & Operated
|
||||
</p>
|
||||
|
|
|
|||
|
|
@ -64,7 +64,7 @@ const Header = () => {
|
|||
alt="Queue North Technologies"
|
||||
className="brand-logo-on-dark h-12 md:h-16 w-auto flex-shrink-0"
|
||||
/>
|
||||
<span className="font-bold text-xl lg:text-2xl text-white hidden sm:block tracking-tight">Queue North Technologies</span>
|
||||
<span className="font-bold text-sm sm:text-xl lg:text-2xl text-white whitespace-nowrap tracking-tight">Queue North Technologies</span>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
|
|
@ -138,7 +138,7 @@ const Header = () => {
|
|||
|
||||
{/* CTA Button */}
|
||||
<div className="hidden md:block">
|
||||
<Link to="/contact" className="inline-flex items-center justify-center rounded-md text-sm font-medium h-9 px-3 bg-primary-cyan text-primary-navy hover:bg-cyan-600 transition-colors" aria-label="Request a consultation">
|
||||
<Link to="/contact#contact-form" className="inline-flex items-center justify-center rounded-md text-sm font-medium h-9 px-3 bg-primary-cyan text-primary-navy hover:bg-cyan-600 transition-colors" aria-label="Request a consultation">
|
||||
Request Consultation
|
||||
</Link>
|
||||
</div>
|
||||
|
|
@ -154,16 +154,18 @@ const Header = () => {
|
|||
</svg>
|
||||
</button>
|
||||
</SheetTrigger>
|
||||
<SheetContent side="right" className="w-[300px] sm:w-[350px] bg-primary-navy text-white">
|
||||
<SheetContent side="right" aria-describedby={undefined} className="w-[85vw] max-w-[300px] sm:w-[350px] bg-primary-navy text-white">
|
||||
<VisuallyHidden.Root asChild>
|
||||
<SheetTitle>Navigation Menu</SheetTitle>
|
||||
</VisuallyHidden.Root>
|
||||
|
||||
{/* Logo */}
|
||||
{/* Logo + phone */}
|
||||
<div className="flex items-center gap-3 pb-4 border-b border-white/10">
|
||||
<Link to="/" onClick={closeMobileMenu} className="flex items-center gap-3" aria-label="Queue North Technologies Home">
|
||||
<Link to="/" onClick={closeMobileMenu} aria-label="Queue North Technologies Home" className="flex-shrink-0">
|
||||
<img src="/logo.png" alt="Queue North Technologies" className="brand-logo-on-dark h-12 w-auto" />
|
||||
<span className="font-bold text-xl tracking-tight">Queue North Technologies</span>
|
||||
</Link>
|
||||
<Link to="/" onClick={closeMobileMenu} className="font-bold text-sm tracking-tight whitespace-nowrap text-white leading-tight">
|
||||
Queue North Technologies
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
|
|
@ -240,7 +242,7 @@ const Header = () => {
|
|||
{/* CTA */}
|
||||
<div className="pt-4 border-t border-white/10">
|
||||
<Link
|
||||
to="/contact"
|
||||
to="/contact#contact-form"
|
||||
onClick={closeMobileMenu}
|
||||
className="inline-flex items-center justify-center gap-2 w-full rounded-md text-sm font-semibold h-11 px-4 bg-primary-cyan text-primary-navy hover:bg-white transition-colors duration-200"
|
||||
aria-label="Get a free quote"
|
||||
|
|
|
|||
|
|
@ -129,7 +129,7 @@ const MobileNav = () => {
|
|||
|
||||
<div className="mt-auto pt-6">
|
||||
<Link
|
||||
to="/contact"
|
||||
to="/contact#contact-form"
|
||||
onClick={closeMobileMenu}
|
||||
className="inline-flex items-center justify-center rounded-md text-sm font-medium h-10 px-4 py-2 w-full bg-primary-navy text-white hover:bg-primary-navy-dark transition-colors"
|
||||
aria-label="Request a consultation"
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@
|
|||
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
body {
|
||||
|
|
@ -18,6 +19,7 @@ body {
|
|||
background-color: #F8FAFC;
|
||||
line-height: 1.5;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
img {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:3001/api'
|
||||
const API_BASE_URL = import.meta.env.VITE_API_URL || '/api'
|
||||
|
||||
export async function submitLead(data) {
|
||||
const response = await fetch(`${API_BASE_URL}/leads`, {
|
||||
|
|
|
|||
|
|
@ -5,13 +5,16 @@ import { Toaster } from 'sonner'
|
|||
import { HelmetProvider } from 'react-helmet-async'
|
||||
import router from './router.jsx'
|
||||
import App from './App.jsx'
|
||||
import ErrorBoundary from './components/ErrorBoundary.jsx'
|
||||
|
||||
// Wrap the router with providers
|
||||
const Root = () => (
|
||||
<StrictMode>
|
||||
<HelmetProvider>
|
||||
<RouterProvider router={router} />
|
||||
<Toaster position="top-right" />
|
||||
<ErrorBoundary>
|
||||
<RouterProvider router={router} />
|
||||
<Toaster position="top-right" />
|
||||
</ErrorBoundary>
|
||||
</HelmetProvider>
|
||||
</StrictMode>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,24 +1,33 @@
|
|||
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, accent: 'text-primary-blue', bg: 'bg-sky-50', border: 'border-t-primary-blue' },
|
||||
{ label: '25+ years', detail: 'Communications and infrastructure experience', icon: Award, containerClass: '' },
|
||||
{
|
||||
label: '8x8 Certified Partner',
|
||||
detail: 'Sales, engineering, build, deployment, and support',
|
||||
logo: '/assets/brand/8x8-logo-white.svg',
|
||||
logoAlt: '8x8 Certified Partner logo',
|
||||
logoClassName: 'h-6 w-14',
|
||||
containerClass: 'px-2',
|
||||
},
|
||||
{
|
||||
label: 'Cisco Partner',
|
||||
detail: 'Networking and communications implementation',
|
||||
logo: '/assets/brand/cisco-partner-logo-white.svg',
|
||||
logo: '/assets/brand/Cisco-Partner-Logo_trasnp_w.png',
|
||||
logoAlt: 'Cisco Partner certification logo',
|
||||
logoClassName: 'h-10 w-10',
|
||||
logoClassName: 'h-full w-full scale-[2]',
|
||||
containerClass: 'p-1 overflow-hidden',
|
||||
},
|
||||
{
|
||||
label: 'Veteran-Owned Certified',
|
||||
detail: 'Disciplined delivery and direct accountability',
|
||||
logo: '/assets/brand/veteran-owned-certified-mark.webp',
|
||||
logoAlt: 'SBA logo for Veteran-Owned Certified',
|
||||
logoClassName: 'h-full w-full',
|
||||
containerClass: 'p-1',
|
||||
},
|
||||
{ label: 'Veteran owned', detail: 'Disciplined delivery and direct accountability', icon: Users, accent: 'text-amber-600', bg: 'bg-amber-50', border: 'border-t-accent-gold' },
|
||||
]
|
||||
|
||||
const operatingPrinciples = [
|
||||
|
|
@ -98,8 +107,8 @@ const About = () => {
|
|||
<div className="absolute inset-0 -z-10">
|
||||
<img
|
||||
src="/assets/about-image.webp"
|
||||
alt="Queue North team member reviewing communications infrastructure"
|
||||
className="h-full w-full object-cover object-center"
|
||||
alt="Compass on a dark navigation map"
|
||||
className="h-full w-full object-cover object-[66%_top] md:object-[62%_top]"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-primary-navy/88" />
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-primary-navy via-primary-navy/95 to-primary-navy/60" />
|
||||
|
|
@ -109,7 +118,7 @@ const About = () => {
|
|||
<div>
|
||||
<div className="inline-flex items-center gap-2 rounded-md border border-white/20 bg-white/10 px-3 py-2 text-xs font-semibold uppercase tracking-wide text-primary-cyan">
|
||||
<Compass className="h-4 w-4" aria-hidden="true" />
|
||||
About Queue North
|
||||
About
|
||||
</div>
|
||||
<h1 className="mt-6 text-4xl md:text-5xl lg:text-6xl font-bold leading-tight">
|
||||
Accountable communications infrastructure, built around how your business actually works.
|
||||
|
|
@ -118,11 +127,11 @@ const About = () => {
|
|||
Queue North helps organizations choose, implement, and support the phone, contact center, network, and IT systems that keep daily operations moving.
|
||||
</p>
|
||||
<div className="mt-8 flex flex-col sm:flex-row gap-3">
|
||||
<Link to="/contact" className="inline-flex h-11 items-center justify-center gap-2 rounded-md bg-white px-5 text-sm font-semibold text-primary-navy hover:bg-section-alt transition-colors">
|
||||
<Link to="/contact#contact-form" className="inline-flex w-full sm:w-auto h-11 items-center justify-center gap-2 rounded-md bg-white px-5 text-sm font-semibold text-primary-navy hover:bg-section-alt transition-colors">
|
||||
Start a Conversation
|
||||
<ArrowRight className="h-4 w-4" aria-hidden="true" />
|
||||
</Link>
|
||||
<Link to="/services" className="inline-flex h-11 items-center justify-center rounded-md border border-white/40 px-5 text-sm font-semibold text-white hover:bg-white/10 transition-colors">
|
||||
<Link to="/services" className="inline-flex w-full sm:w-auto h-11 items-center justify-center rounded-md border border-white/40 px-5 text-sm font-semibold text-white hover:bg-white/10 transition-colors">
|
||||
View Services
|
||||
</Link>
|
||||
</div>
|
||||
|
|
@ -135,7 +144,7 @@ const About = () => {
|
|||
const Icon = point.icon
|
||||
return (
|
||||
<div key={point.label} className="flex gap-4 rounded-md border border-white/10 bg-white/5 p-3">
|
||||
<span className={`flex h-10 shrink-0 items-center justify-center rounded-md bg-white/10 text-primary-cyan ${point.logo ? 'w-16 px-2' : 'w-10'}`}>
|
||||
<span className={`flex h-10 w-16 shrink-0 items-center justify-center rounded-md bg-white/10 text-primary-cyan ${point.containerClass}`}>
|
||||
{point.logo ? (
|
||||
<img
|
||||
src={point.logo}
|
||||
|
|
@ -143,7 +152,7 @@ const About = () => {
|
|||
className={`${point.logoClassName} object-contain`}
|
||||
/>
|
||||
) : (
|
||||
<Icon className="h-5 w-5" aria-hidden="true" />
|
||||
<Icon className="h-7 w-7" aria-hidden="true" />
|
||||
)}
|
||||
</span>
|
||||
<div>
|
||||
|
|
@ -262,15 +271,15 @@ const About = () => {
|
|||
</div>
|
||||
<div className="mt-7 flex flex-col gap-3 sm:flex-row lg:mt-0 lg:flex-shrink-0">
|
||||
<Link
|
||||
to="/contact"
|
||||
className="inline-flex h-11 items-center justify-center gap-2 rounded-md bg-white px-6 text-sm font-semibold text-primary-navy hover:bg-section-alt transition-colors"
|
||||
to="/contact#contact-form"
|
||||
className="inline-flex w-full sm:w-auto h-11 items-center justify-center gap-2 rounded-md bg-white px-6 text-sm font-semibold text-primary-navy hover:bg-section-alt transition-colors"
|
||||
>
|
||||
Request Consultation
|
||||
<ArrowRight className="h-4 w-4" aria-hidden="true" />
|
||||
</Link>
|
||||
<a
|
||||
href="tel:+13217308020"
|
||||
className="inline-flex h-11 items-center justify-center rounded-md border border-white/35 px-6 text-sm font-semibold text-white hover:bg-white/10 transition-colors"
|
||||
className="inline-flex w-full sm:w-auto h-11 items-center justify-center rounded-md border border-white/35 px-6 text-sm font-semibold text-white hover:bg-white/10 transition-colors"
|
||||
>
|
||||
Call (321) 730-8020
|
||||
</a>
|
||||
|
|
|
|||
|
|
@ -1,49 +1,57 @@
|
|||
import SEO from '@/components/SEO'
|
||||
import { useState } from 'react'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
import { Input } from '@/components/ui/Input'
|
||||
import { Textarea } from '@/components/ui/Textarea'
|
||||
import { Select } from '@/components/ui/Select'
|
||||
import RecaptchaPlaceholder from '@/components/RecaptchaPlaceholder'
|
||||
import { submitLead } from '@/lib/api'
|
||||
import { useDebounce } from '@/hooks/useDebounce'
|
||||
import { ArrowRight } from 'lucide-react'
|
||||
import { submitLead } from '@/lib/api'
|
||||
|
||||
const isRecaptchaConfigured = Boolean(import.meta.env.VITE_RECAPTCHA_SITE_KEY)
|
||||
|
||||
const Contact = () => {
|
||||
const [formState, setFormState] = useState({
|
||||
company: '',
|
||||
name: '',
|
||||
email: '',
|
||||
phone: '',
|
||||
zip: '',
|
||||
message: '',
|
||||
service_interest: '',
|
||||
recaptcha_token: '',
|
||||
'Last Name': '',
|
||||
Company: '',
|
||||
Email: '',
|
||||
Phone: '',
|
||||
'Zip Code': '',
|
||||
Description: '',
|
||||
company_website: '',
|
||||
})
|
||||
const [errors, setErrors] = useState({
|
||||
company: '',
|
||||
name: '',
|
||||
email: '',
|
||||
zip: '',
|
||||
message: '',
|
||||
'Last Name': '',
|
||||
Company: '',
|
||||
Email: '',
|
||||
'Zip Code': '',
|
||||
Description: '',
|
||||
recaptcha_token: '',
|
||||
})
|
||||
const debouncedErrors = useDebounce(errors, 300)
|
||||
const [debouncedErrors, setDebouncedErrors] = useState(errors)
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const [recaptchaToken, setRecaptchaToken] = useState('')
|
||||
const [recaptchaResetKey, setRecaptchaResetKey] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
const t = setTimeout(() => setDebouncedErrors(errors), 300)
|
||||
return () => clearTimeout(t)
|
||||
}, [errors])
|
||||
|
||||
const validateForm = () => {
|
||||
const newErrors = { company: '', name: '', email: '', zip: '', message: '', recaptcha_token: '' }
|
||||
if (!formState.company.trim()) newErrors.company = 'Company name is required'
|
||||
if (!formState.name.trim()) newErrors.name = 'Name is required'
|
||||
if (!formState.zip.trim()) newErrors.zip = 'ZIP code is required'
|
||||
if (!formState.message.trim()) newErrors.message = 'Message is required'
|
||||
const newErrors = { 'Last Name': '', Company: '', Email: '', 'Zip Code': '', Description: '', recaptcha_token: '' }
|
||||
if (!formState.Company.trim()) newErrors.Company = 'Company name is required'
|
||||
if (!formState['Last Name'].trim()) newErrors['Last Name'] = 'Name is required'
|
||||
if (!formState['Zip Code'].trim()) newErrors['Zip Code'] = 'ZIP code is required'
|
||||
if (!formState.Description.trim()) newErrors.Description = 'Message is required'
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
||||
if (!formState.email.trim()) {
|
||||
newErrors.email = 'Email is required'
|
||||
} else if (!emailRegex.test(formState.email)) {
|
||||
newErrors.email = 'Please enter a valid email address'
|
||||
if (!formState.Email.trim()) {
|
||||
newErrors.Email = 'Email is required'
|
||||
} else if (!emailRegex.test(formState.Email)) {
|
||||
newErrors.Email = 'Please enter a valid email address'
|
||||
}
|
||||
if (isRecaptchaConfigured && !recaptchaToken) {
|
||||
newErrors.recaptcha_token = 'Security verification is required'
|
||||
}
|
||||
const hasErrors = Object.values(newErrors).some(error => error !== '')
|
||||
setErrors(newErrors)
|
||||
|
|
@ -54,28 +62,58 @@ const Contact = () => {
|
|||
return true
|
||||
}
|
||||
|
||||
const handleSubmit = (e) => {
|
||||
e.preventDefault()
|
||||
if (!validateForm()) return
|
||||
handleSubmitForm()
|
||||
const resetForm = () => {
|
||||
setFormState({
|
||||
'Last Name': '',
|
||||
Company: '',
|
||||
Email: '',
|
||||
Phone: '',
|
||||
'Zip Code': '',
|
||||
Description: '',
|
||||
company_website: '',
|
||||
})
|
||||
setErrors({ 'Last Name': '', Company: '', Email: '', 'Zip Code': '', Description: '', recaptcha_token: '' })
|
||||
setRecaptchaToken('')
|
||||
setRecaptchaResetKey(prev => prev + 1)
|
||||
}
|
||||
|
||||
const handleSubmitForm = async () => {
|
||||
const mapApiErrors = (fields = {}) => ({
|
||||
'Last Name': fields.name || '',
|
||||
Company: fields.company || '',
|
||||
Email: fields.email || '',
|
||||
'Zip Code': fields.zip || '',
|
||||
Description: fields.message || '',
|
||||
recaptcha_token: fields.recaptcha_token || '',
|
||||
})
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault()
|
||||
if (!validateForm()) return
|
||||
setIsSubmitting(true)
|
||||
|
||||
try {
|
||||
await submitLead(formState)
|
||||
toast.success("Thanks! We'll be in touch shortly.")
|
||||
setFormState({ company: '', name: '', email: '', phone: '', zip: '', message: '', service_interest: '', recaptcha_token: '', company_website: '' })
|
||||
setErrors({ company: '', name: '', email: '', zip: '', message: '', recaptcha_token: '' })
|
||||
} catch (error) {
|
||||
if (error.response?.status === 409) {
|
||||
toast.success("We already have your submission! We'll be in touch.")
|
||||
} else if (error.response?.status === 400 && error.fields) {
|
||||
setErrors(prev => ({ ...prev, ...error.fields }))
|
||||
toast.error('Please fix the errors in the form')
|
||||
} else {
|
||||
toast.error(error.message || 'Failed to submit form. Please try again.')
|
||||
const result = await submitLead({
|
||||
company: formState.Company,
|
||||
name: formState['Last Name'],
|
||||
email: formState.Email,
|
||||
phone: formState.Phone,
|
||||
zip: formState['Zip Code'],
|
||||
message: formState.Description,
|
||||
recaptcha_token: recaptchaToken,
|
||||
company_website: formState.company_website,
|
||||
})
|
||||
|
||||
toast.success(result.message || "Thanks! We'll be in touch shortly.")
|
||||
resetForm()
|
||||
} catch (err) {
|
||||
if (err.fields) {
|
||||
setErrors(mapApiErrors(err.fields))
|
||||
if (err.fields.recaptcha_token) {
|
||||
setRecaptchaToken('')
|
||||
setRecaptchaResetKey(prev => prev + 1)
|
||||
}
|
||||
}
|
||||
toast.error(err.message || 'Failed to submit lead')
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
|
|
@ -87,6 +125,16 @@ const Contact = () => {
|
|||
if (errors[name]) setErrors(prev => ({ ...prev, [name]: '' }))
|
||||
}
|
||||
|
||||
const handleRecaptchaVerify = useCallback((token) => {
|
||||
setRecaptchaToken(token)
|
||||
setErrors(prev => ({ ...prev, recaptcha_token: '' }))
|
||||
}, [])
|
||||
|
||||
const handleRecaptchaExpired = useCallback(() => {
|
||||
setRecaptchaToken('')
|
||||
setErrors(prev => ({ ...prev, recaptcha_token: 'Security verification expired. Please try again.' }))
|
||||
}, [])
|
||||
|
||||
const contactDetails = [
|
||||
{
|
||||
label: 'Phone',
|
||||
|
|
@ -135,6 +183,7 @@ const Contact = () => {
|
|||
|
||||
const trustPoints = [
|
||||
<div key="8x8"><span className="font-numeric">8x8</span> Certified Partner with proven expertise</div>,
|
||||
'Cisco Certified Partner',
|
||||
<div key="veteran"><span className="font-numeric">25+</span> years of experience</div>,
|
||||
'SMB to Enterprise solutions',
|
||||
'No vendor bias — we recommend what fits',
|
||||
|
|
@ -161,7 +210,7 @@ const Contact = () => {
|
|||
</div>
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="inline-flex items-center gap-2 rounded-md border border-white/20 bg-white/10 px-3 py-2 text-xs font-semibold uppercase tracking-wide text-primary-cyan mb-6">
|
||||
Contact Queue North
|
||||
Contact
|
||||
</div>
|
||||
<h1 className="text-4xl md:text-5xl font-bold text-white mb-4">Let's Talk</h1>
|
||||
<p className="text-xl text-white/70 max-w-2xl">
|
||||
|
|
@ -201,7 +250,7 @@ const Contact = () => {
|
|||
<div className="border-t border-white/10" />
|
||||
|
||||
<div>
|
||||
<p className="text-white/50 text-xs uppercase tracking-wider mb-5">Why Queue North</p>
|
||||
<p className="text-white/50 text-xs uppercase tracking-wider mb-5">Why Queue North Technologies</p>
|
||||
<ul className="space-y-4">
|
||||
{trustPoints.map((point, i) => (
|
||||
<li key={i} className="flex items-start gap-3 text-white/80 text-sm leading-relaxed">
|
||||
|
|
@ -220,150 +269,137 @@ const Contact = () => {
|
|||
<h2 className="text-2xl font-bold text-primary-navy mb-1">Send Us a Message</h2>
|
||||
<p className="text-soft-text text-sm mb-8">We typically respond within one business day.</p>
|
||||
|
||||
<form onSubmit={handleSubmit} noValidate className={`space-y-5 ${isSubmitting ? 'opacity-70 pointer-events-none' : ''}`}>
|
||||
<form
|
||||
id="contact-form"
|
||||
onSubmit={handleSubmit}
|
||||
noValidate
|
||||
className={`space-y-5 ${isSubmitting ? 'opacity-70 pointer-events-none' : ''}`}
|
||||
>
|
||||
{/* Honeypot */}
|
||||
<input
|
||||
type="text"
|
||||
name="company_website"
|
||||
value={formState.company_website}
|
||||
onChange={handleChange}
|
||||
tabIndex={-1}
|
||||
autoComplete="off"
|
||||
aria-hidden="true"
|
||||
style={{ display: 'none' }}
|
||||
/>
|
||||
|
||||
{/* Company */}
|
||||
<div>
|
||||
<label htmlFor="company" className="block text-sm font-medium text-text mb-1.5">
|
||||
<label htmlFor="Company" className="block text-sm font-medium text-text mb-1.5">
|
||||
Company Name <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<Input
|
||||
type="text"
|
||||
id="company"
|
||||
name="company"
|
||||
value={formState.company}
|
||||
id="Company"
|
||||
name="Company"
|
||||
value={formState.Company}
|
||||
onChange={handleChange}
|
||||
required
|
||||
placeholder="Your company name"
|
||||
className={debouncedErrors.company ? 'border-red-500 focus-visible:ring-red-500' : ''}
|
||||
className={debouncedErrors.Company ? 'border-red-500 focus-visible:ring-red-500' : ''}
|
||||
/>
|
||||
{debouncedErrors.company && <p className="text-xs text-red-500 mt-1">{debouncedErrors.company}</p>}
|
||||
{debouncedErrors.Company && <p className="text-xs text-red-500 mt-1">{debouncedErrors.Company}</p>}
|
||||
</div>
|
||||
|
||||
{/* Name + Email */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label htmlFor="name" className="block text-sm font-medium text-text mb-1.5">
|
||||
<label htmlFor="Last_Name" className="block text-sm font-medium text-text mb-1.5">
|
||||
Name <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<Input
|
||||
type="text"
|
||||
id="name"
|
||||
name="name"
|
||||
value={formState.name}
|
||||
id="Last_Name"
|
||||
name="Last Name"
|
||||
value={formState['Last Name']}
|
||||
onChange={handleChange}
|
||||
required
|
||||
placeholder="Your full name"
|
||||
className={debouncedErrors.name ? 'border-red-500 focus-visible:ring-red-500' : ''}
|
||||
className={debouncedErrors['Last Name'] ? 'border-red-500 focus-visible:ring-red-500' : ''}
|
||||
/>
|
||||
{debouncedErrors.name && <p className="text-xs text-red-500 mt-1">{debouncedErrors.name}</p>}
|
||||
{debouncedErrors['Last Name'] && <p className="text-xs text-red-500 mt-1">{debouncedErrors['Last Name']}</p>}
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="email" className="block text-sm font-medium text-text mb-1.5">
|
||||
<label htmlFor="Email" className="block text-sm font-medium text-text mb-1.5">
|
||||
Email <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<Input
|
||||
type="email"
|
||||
id="email"
|
||||
name="email"
|
||||
value={formState.email}
|
||||
id="Email"
|
||||
name="Email"
|
||||
value={formState.Email}
|
||||
onChange={handleChange}
|
||||
required
|
||||
placeholder="you@company.com"
|
||||
className={debouncedErrors.email ? 'border-red-500 focus-visible:ring-red-500' : ''}
|
||||
className={debouncedErrors.Email ? 'border-red-500 focus-visible:ring-red-500' : ''}
|
||||
/>
|
||||
{debouncedErrors.email && <p className="text-xs text-red-500 mt-1">{debouncedErrors.email}</p>}
|
||||
{debouncedErrors.Email && <p className="text-xs text-red-500 mt-1">{debouncedErrors.Email}</p>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Phone + ZIP */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label htmlFor="phone" className="block text-sm font-medium text-text mb-1.5">
|
||||
<label htmlFor="Phone" className="block text-sm font-medium text-text mb-1.5">
|
||||
Phone <span className="text-soft-text font-normal">(optional)</span>
|
||||
</label>
|
||||
<Input
|
||||
type="tel"
|
||||
id="phone"
|
||||
name="phone"
|
||||
value={formState.phone}
|
||||
id="Phone"
|
||||
name="Phone"
|
||||
value={formState.Phone}
|
||||
onChange={handleChange}
|
||||
placeholder="(555) 123-4567"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="zip" className="block text-sm font-medium text-text mb-1.5">
|
||||
<label htmlFor="Zip_Code" className="block text-sm font-medium text-text mb-1.5">
|
||||
ZIP Code <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<Input
|
||||
type="text"
|
||||
id="zip"
|
||||
name="zip"
|
||||
value={formState.zip}
|
||||
id="Zip_Code"
|
||||
name="Zip Code"
|
||||
value={formState['Zip Code']}
|
||||
onChange={handleChange}
|
||||
required
|
||||
autoComplete="postal-code"
|
||||
inputMode="numeric"
|
||||
placeholder="33702"
|
||||
className={debouncedErrors.zip ? 'border-red-500 focus-visible:ring-red-500' : ''}
|
||||
className={debouncedErrors['Zip Code'] ? 'border-red-500 focus-visible:ring-red-500' : ''}
|
||||
/>
|
||||
{debouncedErrors.zip && <p className="text-xs text-red-500 mt-1">{debouncedErrors.zip}</p>}
|
||||
{debouncedErrors['Zip Code'] && <p className="text-xs text-red-500 mt-1">{debouncedErrors['Zip Code']}</p>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Service Interest */}
|
||||
<div>
|
||||
<label htmlFor="service_interest" className="block text-sm font-medium text-text mb-1.5">
|
||||
Service Interest <span className="text-soft-text font-normal">(optional)</span>
|
||||
</label>
|
||||
<Select
|
||||
id="service_interest"
|
||||
name="service_interest"
|
||||
value={formState.service_interest}
|
||||
onChange={handleChange}
|
||||
>
|
||||
<option value="">Select a service...</option>
|
||||
<option value="unified-communications">Unified Communications</option>
|
||||
<option value="contact-center">Contact Center</option>
|
||||
<option value="managed-support">Managed Support</option>
|
||||
<option value="consulting-training">Consulting & Training</option>
|
||||
<option value="infrastructure-cabling">Infrastructure Cabling</option>
|
||||
<option value="wireless-access">Wireless Access</option>
|
||||
<option value="local-networking">Local Networking</option>
|
||||
<option value="other">Other</option>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Message */}
|
||||
<div>
|
||||
<label htmlFor="message" className="block text-sm font-medium text-text mb-1.5">
|
||||
<label htmlFor="Description" className="block text-sm font-medium text-text mb-1.5">
|
||||
Message <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<Textarea
|
||||
id="message"
|
||||
name="message"
|
||||
value={formState.message}
|
||||
id="Description"
|
||||
name="Description"
|
||||
value={formState.Description}
|
||||
onChange={handleChange}
|
||||
required
|
||||
placeholder="Tell us about your needs..."
|
||||
className={`w-full rounded-md border bg-background px-3 py-2 text-sm ring-offset-[#F8FAFC] placeholder:text-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-navy focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 ${debouncedErrors.message ? 'border-red-500 focus-visible:ring-red-500' : ''}`}
|
||||
className={`w-full rounded-md border bg-background px-3 py-2 text-sm ring-offset-[#F8FAFC] placeholder:text-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-navy focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 ${debouncedErrors.Description ? 'border-red-500 focus-visible:ring-red-500' : ''}`}
|
||||
rows={5}
|
||||
/>
|
||||
{debouncedErrors.message && <p className="text-xs text-red-500 mt-1">{debouncedErrors.message}</p>}
|
||||
{debouncedErrors.Description && <p className="text-xs text-red-500 mt-1">{debouncedErrors.Description}</p>}
|
||||
</div>
|
||||
|
||||
{/* Honeypot */}
|
||||
<div className="absolute opacity-0 h-0 overflow-hidden" aria-hidden="true">
|
||||
<input
|
||||
type="text"
|
||||
name="company_website"
|
||||
tabIndex="-1"
|
||||
autoComplete="off"
|
||||
value={formState.company_website}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<RecaptchaPlaceholder error={debouncedErrors.recaptcha_token} />
|
||||
<RecaptchaPlaceholder
|
||||
error={debouncedErrors.recaptcha_token}
|
||||
onVerify={handleRecaptchaVerify}
|
||||
onExpired={handleRecaptchaExpired}
|
||||
resetKey={recaptchaResetKey}
|
||||
/>
|
||||
|
||||
<Button type="submit" className="w-full h-11" disabled={isSubmitting}>
|
||||
{isSubmitting ? (
|
||||
|
|
|
|||
|
|
@ -57,7 +57,7 @@ const Home = () => {
|
|||
'@type': 'ImageObject',
|
||||
url: 'https://queuenorth.com/logo.png',
|
||||
},
|
||||
description: 'Veteran-owned 8x8 Certified Partner providing business phone systems, UCaaS, contact center, IT support, and networking solutions.',
|
||||
description: 'Veteran-owned 8x8 Certified Partner and Cisco Certified Partner providing business phone systems, UCaaS, contact center, IT support, and networking solutions.',
|
||||
address: {
|
||||
'@type': 'PostalAddress',
|
||||
streetAddress: '7901 4th St N',
|
||||
|
|
@ -153,11 +153,11 @@ const Home = () => {
|
|||
Business phone, contact center, network, and IT support built around one accountable implementation partner.
|
||||
</p>
|
||||
<div className="mt-8 flex flex-col sm:flex-row gap-3">
|
||||
<Link to="/contact" className="inline-flex h-11 items-center justify-center gap-2 rounded-md bg-white px-5 text-sm font-semibold text-primary-navy hover:bg-section-alt transition-colors" aria-label="Schedule a consultation">
|
||||
<Link to="/contact#contact-form" className="inline-flex w-full sm:w-auto h-11 items-center justify-center gap-2 rounded-md bg-white px-5 text-sm font-semibold text-primary-navy hover:bg-section-alt transition-colors" aria-label="Schedule a consultation">
|
||||
Schedule Consultation
|
||||
<ArrowRight className="h-4 w-4" aria-hidden="true" />
|
||||
</Link>
|
||||
<Link to="/services" className="inline-flex h-11 items-center justify-center rounded-md border border-white/45 px-5 text-sm font-semibold text-white hover:bg-white/10 transition-colors" aria-label="View our services">
|
||||
<Link to="/services" className="inline-flex w-full sm:w-auto h-11 items-center justify-center rounded-md border border-white/45 px-5 text-sm font-semibold text-white hover:bg-white/10 transition-colors" aria-label="View our services">
|
||||
View Services
|
||||
</Link>
|
||||
</div>
|
||||
|
|
@ -168,38 +168,42 @@ const Home = () => {
|
|||
{/* Partner Proof */}
|
||||
<section className="bg-white border-b border-border py-6">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<div className="flex items-center justify-center gap-3 sm:justify-start">
|
||||
<span className="flex h-14 w-24 shrink-0 items-center justify-center rounded-md border border-border bg-white px-3">
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-6 lg:grid-cols-4">
|
||||
<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 p-2">
|
||||
<img
|
||||
src="/assets/brand/8x8-logo-dark-gray.png"
|
||||
alt="8x8 Certified Partner logo"
|
||||
className="h-9 w-full object-contain"
|
||||
className="h-full w-full object-contain"
|
||||
/>
|
||||
</span>
|
||||
<span className="text-sm font-semibold leading-tight text-primary-navy sm:text-left">8x8 Certified Partner</span>
|
||||
<span className="text-sm font-semibold leading-tight text-primary-navy text-center lg:text-left">8x8 Certified Partner</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-center gap-3 sm:justify-start">
|
||||
<span className="flex h-14 w-24 shrink-0 items-center justify-center rounded-md border border-border bg-white px-3">
|
||||
<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 p-1 overflow-hidden">
|
||||
<img
|
||||
src="/assets/brand/cisco-partner-logo-midnight.svg"
|
||||
alt="Cisco Partner certification logo"
|
||||
className="h-12 w-full object-contain"
|
||||
className="h-full w-full object-contain scale-[1.5]"
|
||||
/>
|
||||
</span>
|
||||
<span className="text-sm font-semibold leading-tight text-primary-navy sm:text-left">Cisco Certified Partner</span>
|
||||
<span className="text-sm font-semibold leading-tight text-primary-navy text-center lg:text-left">Cisco Certified Partner</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-center gap-3 sm:justify-start">
|
||||
<span className="flex h-14 w-24 shrink-0 items-center justify-center rounded-md border border-border bg-white px-3 text-primary-navy">
|
||||
<ShieldCheck className="h-7 w-7 text-primary-blue" aria-hidden="true" />
|
||||
<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 p-1">
|
||||
<img
|
||||
src="/assets/brand/veteran-owned-certified-mark.webp"
|
||||
alt="SBA logo for Veteran-Owned Certified"
|
||||
className="h-full w-full object-contain"
|
||||
/>
|
||||
</span>
|
||||
<span className="text-sm font-semibold leading-tight text-primary-navy sm: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 items-center justify-center gap-3 sm:justify-start">
|
||||
<span className="font-numeric flex h-14 w-24 shrink-0 items-center justify-center rounded-md border border-border bg-white px-3 text-2xl font-semibold text-primary-navy">
|
||||
<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">
|
||||
25+
|
||||
</span>
|
||||
<span className="text-sm font-semibold leading-tight text-primary-navy sm:text-left">Years Experience</span>
|
||||
<span className="text-sm font-semibold leading-tight text-primary-navy text-center lg:text-left">Years Experience</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -259,7 +263,7 @@ const Home = () => {
|
|||
Four concrete differentiators that set us apart
|
||||
</p>
|
||||
<div>
|
||||
<Link to="/contact" className="inline-flex items-center justify-center rounded-md text-sm font-medium h-10 px-4 py-2 bg-primary-navy text-white hover:bg-primary-navy-dark transition-colors" aria-label="Request a consultation">
|
||||
<Link to="/contact#contact-form" className="inline-flex items-center justify-center rounded-md text-sm font-medium h-10 px-4 py-2 bg-primary-navy text-white hover:bg-primary-navy-dark transition-colors" aria-label="Request a consultation">
|
||||
Request Consultation
|
||||
</Link>
|
||||
</div>
|
||||
|
|
@ -354,35 +358,35 @@ const Home = () => {
|
|||
What we'll help you do
|
||||
</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-8">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex items-start gap-3 justify-center sm:justify-start">
|
||||
<div className="bg-primary-navy/10 p-2 rounded-md flex-shrink-0">
|
||||
<MapPin className="w-5 h-5 text-primary-navy" />
|
||||
</div>
|
||||
<p className="text-left text-sm md:text-base text-soft-text">
|
||||
<p className="text-center sm:text-left text-sm md:text-base text-soft-text">
|
||||
Identify the features you actually need
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex items-start gap-3 justify-center sm:justify-start">
|
||||
<div className="bg-teal-500/10 p-2 rounded-md flex-shrink-0">
|
||||
<Users className="w-5 h-5 text-teal-600" />
|
||||
</div>
|
||||
<p className="text-left text-sm md:text-base text-soft-text">
|
||||
<p className="text-center sm:text-left text-sm md:text-base text-soft-text">
|
||||
Align solutions with operations and budget
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex items-start gap-3 justify-center sm:justify-start">
|
||||
<div className="bg-teal-500/10 p-2 rounded-md flex-shrink-0">
|
||||
<Network className="w-5 h-5 text-teal-600" />
|
||||
</div>
|
||||
<p className="text-left text-sm md:text-base text-soft-text">
|
||||
<p className="text-center sm:text-left text-sm md:text-base text-soft-text">
|
||||
Plan deployment, migration, and training
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex items-start gap-3 justify-center sm:justify-start">
|
||||
<div className="bg-teal-600/10 p-2 rounded-md flex-shrink-0">
|
||||
<ShieldCheck className="w-5 h-5 text-teal-600" />
|
||||
</div>
|
||||
<p className="text-left text-sm md:text-base text-teal-700 font-semibold">
|
||||
<p className="text-center sm:text-left text-sm md:text-base text-teal-700 font-semibold">
|
||||
Ask how you qualify for our free migration
|
||||
</p>
|
||||
</div>
|
||||
|
|
@ -390,7 +394,7 @@ const Home = () => {
|
|||
<p className="text-lg text-soft-text mb-8 max-w-2xl mx-auto">
|
||||
Share a few details and we'll provide clear direction.
|
||||
</p>
|
||||
<Link to="/contact" className="inline-flex items-center justify-center rounded-md text-sm font-medium h-10 px-6 bg-primary-navy text-white hover:bg-primary-navy-dark transition-colors" aria-label="Request a consultation">
|
||||
<Link to="/contact#contact-form" className="inline-flex items-center justify-center rounded-md text-sm font-medium h-10 px-6 bg-primary-navy text-white hover:bg-primary-navy-dark transition-colors" aria-label="Request a consultation">
|
||||
Request Consultation
|
||||
</Link>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -50,7 +50,7 @@ const Industries = () => {
|
|||
Communications and infrastructure solutions shaped by the compliance, workflow, and connectivity demands of your specific environment.
|
||||
</p>
|
||||
<Link
|
||||
to="/contact"
|
||||
to="/contact#contact-form"
|
||||
className="inline-flex h-11 items-center justify-center gap-2 rounded-md bg-white px-5 text-sm font-semibold text-primary-navy hover:bg-section-alt transition-colors"
|
||||
>
|
||||
Talk to a Specialist
|
||||
|
|
@ -120,15 +120,15 @@ const Industries = () => {
|
|||
</div>
|
||||
<div className="mt-7 flex flex-col gap-3 sm:flex-row lg:mt-0 lg:flex-shrink-0">
|
||||
<Link
|
||||
to="/contact"
|
||||
className="inline-flex h-11 items-center justify-center gap-2 rounded-md bg-white px-6 text-sm font-semibold text-primary-navy hover:bg-section-alt transition-colors"
|
||||
to="/contact#contact-form"
|
||||
className="inline-flex w-full sm:w-auto h-11 items-center justify-center gap-2 rounded-md bg-white px-6 text-sm font-semibold text-primary-navy hover:bg-section-alt transition-colors"
|
||||
>
|
||||
Talk to a Specialist
|
||||
<ArrowRight className="h-4 w-4" aria-hidden="true" />
|
||||
</Link>
|
||||
<a
|
||||
href="tel:+13217308020"
|
||||
className="inline-flex h-11 items-center justify-center rounded-md border border-white/35 px-6 text-sm font-semibold text-white hover:bg-white/10 transition-colors"
|
||||
className="inline-flex w-full sm:w-auto h-11 items-center justify-center rounded-md border border-white/35 px-6 text-sm font-semibold text-white hover:bg-white/10 transition-colors"
|
||||
>
|
||||
Call (321) 730-8020
|
||||
</a>
|
||||
|
|
|
|||
|
|
@ -119,7 +119,7 @@ const IndustryDetail = () => {
|
|||
<p className="text-soft-text">{industry.name}</p>
|
||||
</div>
|
||||
<div className="pt-4 border-t border-border">
|
||||
<Link to="/contact" className="flex w-full items-center justify-center gap-2 bg-primary-navy text-white px-4 py-3 rounded-md text-center font-medium hover:bg-primary-navy-dark transition-colors">
|
||||
<Link to="/contact#contact-form" className="flex w-full items-center justify-center gap-2 bg-primary-navy text-white px-4 py-3 rounded-md text-center font-medium hover:bg-primary-navy-dark transition-colors">
|
||||
Request Consultation
|
||||
<ArrowRight className="h-4 w-4" aria-hidden="true" />
|
||||
</Link>
|
||||
|
|
|
|||
|
|
@ -1,41 +1,168 @@
|
|||
import { Helmet } from 'react-helmet-async'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { ArrowRight, Compass } from 'lucide-react'
|
||||
import { ArrowRight, Building2, Compass, Headphones, Home, LifeBuoy, Network, ShieldCheck } from 'lucide-react'
|
||||
|
||||
const recoveryLinks = [
|
||||
{
|
||||
title: 'Services',
|
||||
description: 'Communications, support, cabling, wireless, and networking.',
|
||||
href: '/services',
|
||||
icon: Network,
|
||||
accent: 'text-primary-cyan',
|
||||
},
|
||||
{
|
||||
title: 'Industries',
|
||||
description: 'Solutions by business environment.',
|
||||
href: '/industries',
|
||||
icon: Building2,
|
||||
accent: 'text-teal-300',
|
||||
},
|
||||
{
|
||||
title: 'Support',
|
||||
description: 'Help for live systems, users, and endpoints.',
|
||||
href: '/support',
|
||||
icon: LifeBuoy,
|
||||
accent: 'text-amber-300',
|
||||
},
|
||||
]
|
||||
|
||||
const signalPoints = [
|
||||
{ label: 'Route check', value: 'Active', icon: Compass },
|
||||
{ label: 'Partner desk', value: 'Online', icon: Headphones },
|
||||
{ label: 'Uptime focus', value: 'Locked', icon: ShieldCheck },
|
||||
]
|
||||
|
||||
export default function NotFound() {
|
||||
return (
|
||||
<>
|
||||
<Helmet>
|
||||
<title>Page Not Found | Queue North Technologies</title>
|
||||
<meta name="description" content="The page you're looking for doesn't exist." />
|
||||
<meta name="description" content="The Queue North page you requested could not be found. Return home, explore services, or contact our team for help." />
|
||||
<meta name="robots" content="noindex, follow" />
|
||||
</Helmet>
|
||||
<section className="relative isolate flex min-h-[70vh] items-center overflow-hidden bg-primary-navy py-16 text-white">
|
||||
|
||||
<section className="relative isolate overflow-hidden bg-primary-navy text-white">
|
||||
<div className="absolute inset-0 -z-10">
|
||||
<img
|
||||
src="/assets/hero-tech.webp"
|
||||
src="/assets/about-image.webp"
|
||||
alt=""
|
||||
className="h-full w-full object-cover object-center"
|
||||
className="h-full w-full object-cover object-[66%_top] md:object-[62%_top]"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-primary-navy/88" />
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-primary-navy via-primary-navy/95 to-primary-navy/60" />
|
||||
<div className="absolute inset-0 bg-primary-navy/86" />
|
||||
<div className="absolute inset-0 bg-[radial-gradient(circle_at_72%_22%,rgba(34,211,238,0.28),transparent_28%),radial-gradient(circle_at_18%_74%,rgba(245,158,11,0.16),transparent_24%),linear-gradient(115deg,#071A2A_0%,rgba(11,42,60,0.96)_46%,rgba(7,26,42,0.74)_100%)]" />
|
||||
<div className="absolute inset-0 opacity-[0.13] [background-image:linear-gradient(rgba(255,255,255,0.12)_1px,transparent_1px),linear-gradient(90deg,rgba(255,255,255,0.12)_1px,transparent_1px)] [background-size:48px_48px]" />
|
||||
<div className="absolute left-0 right-0 top-0 h-px bg-gradient-to-r from-transparent via-primary-cyan/70 to-transparent" />
|
||||
</div>
|
||||
<div className="mx-auto max-w-3xl px-4 text-center sm:px-6 lg:px-8">
|
||||
<div className="mx-auto mb-6 flex h-12 w-12 items-center justify-center rounded-md border border-white/15 bg-white/10 text-primary-cyan">
|
||||
<Compass className="h-6 w-6" aria-hidden="true" />
|
||||
|
||||
<div className="mx-auto grid min-h-[calc(100vh-4rem)] max-w-7xl grid-cols-[minmax(0,1fr)] items-center gap-12 px-4 py-16 sm:px-6 md:py-20 lg:grid-cols-[minmax(0,1.03fr)_minmax(0,0.97fr)] lg:px-8 lg:py-24">
|
||||
<div className="min-w-0 w-full max-w-[calc(100vw-2rem)] sm:max-w-3xl">
|
||||
<div className="inline-flex items-center gap-2 rounded-md border border-white/[0.15] bg-white/10 px-3 py-2 text-xs font-semibold uppercase tracking-wide text-primary-cyan shadow-[0_0_40px_rgba(34,211,238,0.18)] backdrop-blur">
|
||||
<Compass className="h-4 w-4" aria-hidden="true" />
|
||||
Route recalibration
|
||||
</div>
|
||||
|
||||
<p className="mt-8 font-numeric text-8xl leading-none text-white sm:text-9xl md:text-[10rem]">
|
||||
404
|
||||
</p>
|
||||
<h1 className="mt-4 max-w-2xl text-3xl font-bold leading-tight text-white sm:text-5xl lg:text-6xl">
|
||||
This route dropped <span className="block sm:inline">off the network.</span>
|
||||
</h1>
|
||||
<p className="mt-6 max-w-2xl text-base leading-relaxed text-white/75 sm:text-lg md:text-xl">
|
||||
That page is gone or renamed. We'll get you back to a live connection.
|
||||
</p>
|
||||
|
||||
<div className="mt-8 flex flex-col gap-3 sm:flex-row">
|
||||
<Link to="/" className="inline-flex h-11 w-full items-center justify-center gap-2 rounded-md bg-white px-5 text-sm font-semibold text-primary-navy transition-colors hover:bg-section-alt sm:w-auto" aria-label="Return to the Queue North home page">
|
||||
Back to Home
|
||||
<Home className="h-4 w-4" aria-hidden="true" />
|
||||
</Link>
|
||||
<Link to="/contact#contact-form" className="inline-flex h-11 w-full items-center justify-center gap-2 rounded-md border border-white/35 px-5 text-sm font-semibold text-white transition-colors hover:bg-white/10 sm:w-auto" aria-label="Contact Queue North Technologies">
|
||||
Talk to Us
|
||||
<ArrowRight className="h-4 w-4" aria-hidden="true" />
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="mt-10 grid gap-3 sm:grid-cols-3">
|
||||
{signalPoints.map((point) => {
|
||||
const Icon = point.icon
|
||||
|
||||
return (
|
||||
<div key={point.label} className="rounded-md border border-white/10 bg-white/[0.06] p-4 backdrop-blur">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="flex h-9 w-9 items-center justify-center rounded-md bg-white/10 text-primary-cyan">
|
||||
<Icon className="h-4 w-4" aria-hidden="true" />
|
||||
</span>
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-wide text-white/50">{point.label}</p>
|
||||
<p className="text-sm font-semibold text-white">{point.value}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm font-semibold uppercase tracking-[0.18em] text-primary-cyan">404</p>
|
||||
<h1 className="mt-3 text-4xl font-bold md:text-5xl">This page lost direction.</h1>
|
||||
<p className="mx-auto mt-5 max-w-xl text-lg text-white/75">
|
||||
The page you're looking for does not exist, but we can get you back to the right place.
|
||||
</p>
|
||||
<div className="mt-8 flex flex-col sm:flex-row gap-3 justify-center">
|
||||
<Link to="/" className="inline-flex h-11 items-center justify-center gap-2 rounded-md bg-white px-6 text-sm font-semibold text-primary-navy hover:bg-section-alt transition-colors">
|
||||
Back to Home
|
||||
<ArrowRight className="h-4 w-4" aria-hidden="true" />
|
||||
</Link>
|
||||
<Link to="/contact" className="inline-flex h-11 items-center justify-center rounded-md border border-white/35 px-6 text-sm font-semibold text-white hover:bg-white/10 transition-colors">
|
||||
Contact Us
|
||||
</Link>
|
||||
|
||||
<div className="relative min-w-0 w-full max-w-[calc(100vw-2rem)] lg:max-w-none">
|
||||
<div className="absolute inset-0 rounded-md bg-primary-cyan/10 blur-3xl sm:-inset-4 sm:rounded-[2rem]" aria-hidden="true" />
|
||||
<div className="relative overflow-hidden rounded-md border border-white/[0.12] bg-white/[0.07] shadow-2xl shadow-black/30 backdrop-blur-xl">
|
||||
<div className="border-b border-white/10 px-5 py-4">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-primary-cyan">Recovery paths</p>
|
||||
<p className="mt-1 text-sm text-white/60">Choose the cleanest next hop.</p>
|
||||
</div>
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-md bg-primary-cyan text-primary-navy shadow-[0_0_28px_rgba(34,211,238,0.45)]">
|
||||
<Compass className="h-5 w-5" aria-hidden="true" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative p-5 sm:p-6">
|
||||
<div className="pointer-events-none absolute inset-x-6 top-1/2 h-px bg-gradient-to-r from-transparent via-primary-cyan/50 to-transparent" aria-hidden="true" />
|
||||
<div className="pointer-events-none absolute left-1/2 top-6 bottom-6 w-px bg-gradient-to-b from-transparent via-white/20 to-transparent" aria-hidden="true" />
|
||||
|
||||
<div className="mb-5 rounded-md border border-primary-cyan/25 bg-primary-navy/80 p-5 shadow-[inset_0_1px_0_rgba(255,255,255,0.08)]">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="relative flex h-16 w-16 shrink-0 items-center justify-center overflow-hidden rounded-md border border-primary-cyan/35 bg-primary-cyan/10">
|
||||
<span className="absolute h-9 w-9 animate-ping rounded-full bg-primary-cyan/20" aria-hidden="true" />
|
||||
<Compass className="relative h-7 w-7 text-primary-cyan" aria-hidden="true" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-semibold text-white">Queue North core</p>
|
||||
<p className="mt-1 text-sm leading-relaxed text-white/60 break-words">Systems and support are still fully reachable.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3">
|
||||
{recoveryLinks.map((item) => {
|
||||
const Icon = item.icon
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={item.title}
|
||||
to={item.href}
|
||||
className="group rounded-md border border-white/10 bg-white/[0.06] p-4 transition-all hover:-translate-y-0.5 hover:border-primary-cyan/45 hover:bg-white/[0.1] hover:no-underline"
|
||||
>
|
||||
<div className="flex items-start gap-4">
|
||||
<span className="flex h-11 w-11 shrink-0 items-center justify-center rounded-md bg-white/10">
|
||||
<Icon className={`h-5 w-5 ${item.accent}`} aria-hidden="true" />
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<h2 className="text-base font-semibold text-white">{item.title}</h2>
|
||||
<ArrowRight className="h-4 w-4 shrink-0 text-white/35 transition-transform group-hover:translate-x-1 group-hover:text-primary-cyan" aria-hidden="true" />
|
||||
</div>
|
||||
<p className="mt-1 text-sm leading-relaxed text-white/60 break-words">{item.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
|
|
|||
|
|
@ -84,7 +84,7 @@ const ServiceDetail = () => {
|
|||
<h1 className="text-4xl md:text-5xl font-bold mb-6">{service.name}</h1>
|
||||
<p className="text-xl text-white/75 max-w-3xl leading-relaxed">{service.shortDesc}</p>
|
||||
<div className="mt-8">
|
||||
<Link to="/contact" className="inline-flex h-11 items-center justify-center gap-2 rounded-md bg-white px-5 text-sm font-semibold text-primary-navy hover:bg-section-alt transition-colors">
|
||||
<Link to="/contact#contact-form" className="inline-flex h-11 items-center justify-center gap-2 rounded-md bg-white px-5 text-sm font-semibold text-primary-navy hover:bg-section-alt transition-colors">
|
||||
Request This Service
|
||||
<ArrowRight className="h-4 w-4" aria-hidden="true" />
|
||||
</Link>
|
||||
|
|
@ -149,7 +149,7 @@ const ServiceDetail = () => {
|
|||
<p className="text-soft-text">{service.name}</p>
|
||||
</div>
|
||||
<div className="pt-4 border-t border-border">
|
||||
<Link to="/contact" className="block w-full bg-primary-navy text-white px-4 py-3 rounded-md text-center font-medium hover:bg-primary-navy-dark transition-colors">
|
||||
<Link to="/contact#contact-form" className="block w-full bg-primary-navy text-white px-4 py-3 rounded-md text-center font-medium hover:bg-primary-navy-dark transition-colors">
|
||||
Request This Service
|
||||
</Link>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -96,15 +96,15 @@ const Services = () => {
|
|||
</p>
|
||||
<div className="flex flex-col sm:flex-row gap-3">
|
||||
<Link
|
||||
to="/contact"
|
||||
className="inline-flex h-11 items-center justify-center gap-2 rounded-md bg-white px-5 text-sm font-semibold text-primary-navy hover:bg-section-alt transition-colors"
|
||||
to="/contact#contact-form"
|
||||
className="inline-flex w-full sm:w-auto h-11 items-center justify-center gap-2 rounded-md bg-white px-5 text-sm font-semibold text-primary-navy hover:bg-section-alt transition-colors"
|
||||
>
|
||||
Get a Free Quote
|
||||
<ArrowRight className="h-4 w-4" aria-hidden="true" />
|
||||
</Link>
|
||||
<Link
|
||||
to="/support"
|
||||
className="inline-flex h-11 items-center justify-center rounded-md border border-white/40 px-5 text-sm font-semibold text-white hover:bg-white/10 transition-colors"
|
||||
className="inline-flex w-full sm:w-auto h-11 items-center justify-center rounded-md border border-white/40 px-5 text-sm font-semibold text-white hover:bg-white/10 transition-colors"
|
||||
>
|
||||
Existing Client? Get Support
|
||||
</Link>
|
||||
|
|
@ -135,7 +135,7 @@ const Services = () => {
|
|||
</p>
|
||||
</div>
|
||||
<Link
|
||||
to="/contact"
|
||||
to="/contact#contact-form"
|
||||
className="inline-flex h-10 items-center justify-center gap-2 rounded-md border border-primary-navy/20 px-4 text-sm font-semibold text-primary-navy hover:border-primary-blue hover:text-primary-blue transition-colors"
|
||||
>
|
||||
Talk through options
|
||||
|
|
@ -188,15 +188,15 @@ const Services = () => {
|
|||
</div>
|
||||
<div className="mt-7 flex flex-col gap-3 sm:flex-row lg:mt-0 lg:flex-shrink-0">
|
||||
<Link
|
||||
to="/contact"
|
||||
className="inline-flex h-11 items-center justify-center gap-2 rounded-md bg-white px-6 text-sm font-semibold text-primary-navy hover:bg-section-alt transition-colors"
|
||||
to="/contact#contact-form"
|
||||
className="inline-flex w-full sm:w-auto h-11 items-center justify-center gap-2 rounded-md bg-white px-6 text-sm font-semibold text-primary-navy hover:bg-section-alt transition-colors"
|
||||
>
|
||||
Schedule a Consultation
|
||||
<ArrowRight className="h-4 w-4" aria-hidden="true" />
|
||||
</Link>
|
||||
<a
|
||||
href="tel:+13217308020"
|
||||
className="inline-flex h-11 items-center justify-center rounded-md border border-white/35 px-6 text-sm font-semibold text-white hover:bg-white/10 transition-colors"
|
||||
className="inline-flex w-full sm:w-auto h-11 items-center justify-center rounded-md border border-white/35 px-6 text-sm font-semibold text-white hover:bg-white/10 transition-colors"
|
||||
>
|
||||
Call (321) 730-8020
|
||||
</a>
|
||||
|
|
|
|||
|
|
@ -51,7 +51,7 @@ const Support = () => {
|
|||
<div>
|
||||
<div className="inline-flex items-center gap-2 rounded-md border border-white/20 bg-white/10 px-3 py-2 text-xs font-semibold uppercase tracking-wide text-primary-cyan">
|
||||
<LifeBuoy className="h-4 w-4" aria-hidden="true" />
|
||||
Queue North Support
|
||||
Support
|
||||
</div>
|
||||
<h1 className="mt-6 text-4xl md:text-5xl lg:text-6xl font-bold leading-tight">
|
||||
Get help without getting handed off.
|
||||
|
|
@ -64,7 +64,7 @@ const Support = () => {
|
|||
href={portalLinks[0].href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex h-11 items-center justify-center gap-2 rounded-md bg-white px-5 text-sm font-semibold text-primary-navy hover:bg-section-alt transition-colors"
|
||||
className="inline-flex w-full sm:w-auto h-11 items-center justify-center gap-2 rounded-md bg-white px-5 text-sm font-semibold text-primary-navy hover:bg-section-alt transition-colors"
|
||||
>
|
||||
Sign In
|
||||
<ArrowRight className="h-4 w-4" aria-hidden="true" />
|
||||
|
|
@ -73,7 +73,7 @@ const Support = () => {
|
|||
href={portalLinks[1].href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex h-11 items-center justify-center gap-2 rounded-md border border-white/40 px-5 text-sm font-semibold text-white hover:bg-white/10 transition-colors"
|
||||
className="inline-flex w-full sm:w-auto h-11 items-center justify-center gap-2 rounded-md border border-white/40 px-5 text-sm font-semibold text-white hover:bg-white/10 transition-colors"
|
||||
>
|
||||
Create Account
|
||||
<ExternalLink className="h-4 w-4" aria-hidden="true" />
|
||||
|
|
|
|||