Compare commits
No commits in common. "dev" and "v0.8.0" have entirely different histories.
13
.env.example
|
|
@ -5,17 +5,8 @@ NODE_ENV=production
|
||||||
SERVER_PORT=3001
|
SERVER_PORT=3001
|
||||||
|
|
||||||
# Zoho CRM Integration
|
# Zoho CRM Integration
|
||||||
# Preferred current setup: webtolead for contact leads using the legacy Zoho form tokens.
|
# Set ZOHO_ENABLED=true to forward leads/support to Zoho CRM
|
||||||
ZOHO_FORWARDING_MODE=webtolead
|
# Get credentials from https://api-console.zoho.com → Self Client
|
||||||
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_ENABLED=false
|
||||||
ZOHO_API_DOMAIN=https://www.zohoapis.com
|
ZOHO_API_DOMAIN=https://www.zohoapis.com
|
||||||
ZOHO_ACCOUNTS_DOMAIN=https://accounts.zoho.com
|
ZOHO_ACCOUNTS_DOMAIN=https://accounts.zoho.com
|
||||||
|
|
|
||||||
|
|
@ -6,9 +6,6 @@ FUTURE.md
|
||||||
HISTORY.md
|
HISTORY.md
|
||||||
BUILD_SUMMARY.md
|
BUILD_SUMMARY.md
|
||||||
SCRIPTS.md
|
SCRIPTS.md
|
||||||
.drop/
|
|
||||||
zoho.md
|
|
||||||
|
|
||||||
|
|
||||||
# Dependencies
|
# Dependencies
|
||||||
node_modules/
|
node_modules/
|
||||||
|
|
@ -39,4 +36,3 @@ pnpm-debug.log*
|
||||||
.learnings/
|
.learnings/
|
||||||
Levi.md
|
Levi.md
|
||||||
Queue-North-Website.code-workspace
|
Queue-North-Website.code-workspace
|
||||||
Working Site.zip
|
|
||||||
|
|
|
||||||
15
Dockerfile
|
|
@ -15,10 +15,6 @@ RUN npm ci
|
||||||
# Copy source files
|
# Copy source files
|
||||||
COPY . .
|
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
|
# Build the frontend
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
|
|
@ -47,14 +43,6 @@ ENV SERVER_PORT=3001
|
||||||
ENV RATE_LIMIT_PER_MINUTE=5
|
ENV RATE_LIMIT_PER_MINUTE=5
|
||||||
ENV CORS_ORIGIN=*
|
ENV CORS_ORIGIN=*
|
||||||
ENV LOG_LEVEL=info
|
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_ENABLED=false
|
||||||
ENV ZOHO_API_DOMAIN=https://www.zohoapis.com
|
ENV ZOHO_API_DOMAIN=https://www.zohoapis.com
|
||||||
ENV ZOHO_ACCOUNTS_DOMAIN=https://accounts.zoho.com
|
ENV ZOHO_ACCOUNTS_DOMAIN=https://accounts.zoho.com
|
||||||
|
|
@ -62,9 +50,6 @@ ENV ZOHO_CLIENT_ID=
|
||||||
ENV ZOHO_CLIENT_SECRET=
|
ENV ZOHO_CLIENT_SECRET=
|
||||||
ENV ZOHO_REFRESH_TOKEN=
|
ENV ZOHO_REFRESH_TOKEN=
|
||||||
ENV ZOHO_CASES_ENABLED=false
|
ENV ZOHO_CASES_ENABLED=false
|
||||||
ENV RECAPTCHA_ENABLED=false
|
|
||||||
ENV RECAPTCHA_SECRET_KEY=
|
|
||||||
ENV RECAPTCHA_MIN_SCORE=0.5
|
|
||||||
|
|
||||||
# Create app directory structure
|
# Create app directory structure
|
||||||
RUN mkdir -p /app/db /app/logs
|
RUN mkdir -p /app/db /app/logs
|
||||||
|
|
|
||||||
|
Before Width: | Height: | Size: 151 KiB |
|
Before Width: | Height: | Size: 129 KiB |
|
Before Width: | Height: | Size: 2.3 MiB After Width: | Height: | Size: 2.3 MiB |
|
|
@ -5,8 +5,6 @@ services:
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
args:
|
|
||||||
- VITE_RECAPTCHA_SITE_KEY=${VITE_RECAPTCHA_SITE_KEY:-}
|
|
||||||
container_name: queuenorth-website
|
container_name: queuenorth-website
|
||||||
ports:
|
ports:
|
||||||
- "3001:3001"
|
- "3001:3001"
|
||||||
|
|
@ -22,24 +20,12 @@ services:
|
||||||
- RATE_LIMIT_PER_MINUTE=5
|
- RATE_LIMIT_PER_MINUTE=5
|
||||||
- CORS_ORIGIN=https://queuenorth.com
|
- CORS_ORIGIN=https://queuenorth.com
|
||||||
- LOG_LEVEL=info
|
- LOG_LEVEL=info
|
||||||
- ZOHO_FORWARDING_MODE=${ZOHO_FORWARDING_MODE:-webtolead}
|
- ZOHO_ENABLED=false
|
||||||
- ZOHO_WEBTOLEAD_ENABLED=${ZOHO_WEBTOLEAD_ENABLED:-false}
|
- ZOHO_API_DOMAIN=https://www.zohoapis.com
|
||||||
- ZOHO_WEBTOLEAD_URL=${ZOHO_WEBTOLEAD_URL:-https://crm.zoho.com/crm/WebToLeadForm}
|
- ZOHO_CLIENT_ID=
|
||||||
- ZOHO_WEBTOLEAD_XNQSJSDP=${ZOHO_WEBTOLEAD_XNQSJSDP:-}
|
- ZOHO_CLIENT_SECRET=
|
||||||
- ZOHO_WEBTOLEAD_XMIWTLD=${ZOHO_WEBTOLEAD_XMIWTLD:-}
|
- ZOHO_REFRESH_TOKEN=
|
||||||
- ZOHO_WEBTOLEAD_ACTION_TYPE=${ZOHO_WEBTOLEAD_ACTION_TYPE:-TGVhZHM=}
|
- ZOHO_REDIRECT_URI=
|
||||||
- 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
|
restart: unless-stopped
|
||||||
# Container runs as non-root user (UID 1001) for security
|
# Container runs as non-root user (UID 1001) for security
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,36 +1,18 @@
|
||||||
# Zoho CRM Setup Guide for Queue North Admins
|
# Zoho CRM Setup Guide for Queue North Admins
|
||||||
|
|
||||||
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.
|
This guide walks you through setting up Zoho CRM integration to automatically capture leads and support cases from Queue North's website.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Prerequisites
|
## 🔒 Prerequisites
|
||||||
|
|
||||||
Before you begin, ensure you have:
|
Before you begin, ensure you have:
|
||||||
- A Zoho CRM account (admin access required)
|
- A Zoho CRM account (admin access required)
|
||||||
- The WebToLead hidden field values from the old Zoho form
|
- Access to Zoho API Console: https://api-console.zoho.com
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Step 1: Gather WebToLead Values
|
## Step 1: Create a Zoho Self-Client App
|
||||||
|
|
||||||
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**
|
1. Go to **https://api-console.zoho.com**
|
||||||
2. Click **"Create Self Client"**
|
2. Click **"Create Self Client"**
|
||||||
|
|
@ -47,7 +29,7 @@ Only use these OAuth steps if switching `ZOHO_FORWARDING_MODE=api`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Optional Standby: Generate an Authorization Code
|
## Step 2: Generate an Authorization Code
|
||||||
|
|
||||||
1. In the Self Client tab, click **"Generate Code"**
|
1. In the Self Client tab, click **"Generate Code"**
|
||||||
2. Set the **Scope** to:
|
2. Set the **Scope** to:
|
||||||
|
|
@ -60,7 +42,7 @@ Only use these OAuth steps if switching `ZOHO_FORWARDING_MODE=api`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Optional Standby: Exchange Auth Code for Tokens
|
## Step 3: Exchange Auth Code for Tokens
|
||||||
|
|
||||||
Run this `curl` command (replace placeholders):
|
Run this `curl` command (replace placeholders):
|
||||||
|
|
||||||
|
|
@ -87,27 +69,12 @@ curl -X POST https://accounts.zoho.com/oauth/v2/token \
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Step 2: Configure Environment Variables
|
## Step 4: Configure Environment Variables
|
||||||
|
|
||||||
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:
|
||||||
|
|
||||||
Add these to your `.env` file for WebToLead lead forwarding:
|
|
||||||
|
|
||||||
```env
|
```env
|
||||||
ZOHO_FORWARDING_MODE=webtolead
|
# Zoho integration is OFF by default — set to true to enable
|
||||||
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_ENABLED=false
|
||||||
ZOHO_API_DOMAIN=https://www.zohoapis.com
|
ZOHO_API_DOMAIN=https://www.zohoapis.com
|
||||||
ZOHO_ACCOUNTS_DOMAIN=https://accounts.zoho.com
|
ZOHO_ACCOUNTS_DOMAIN=https://accounts.zoho.com
|
||||||
|
|
@ -118,7 +85,7 @@ ZOHO_REFRESH_TOKEN=<from Step 3>
|
||||||
ZOHO_CASES_ENABLED=false
|
ZOHO_CASES_ENABLED=false
|
||||||
```
|
```
|
||||||
|
|
||||||
> **Note:** `ZOHO_CASES_ENABLED` only applies to the OAuth/API path. The WebToLead values found in the old site are for lead capture only.
|
> **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.
|
||||||
|
|
||||||
### Datacenter Variants
|
### Datacenter Variants
|
||||||
|
|
||||||
|
|
@ -133,7 +100,7 @@ If your Zoho datacenter is **outside the US**, adjust the domains:
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Step 3: Test the Integration
|
## Step 5: Test the Integration
|
||||||
|
|
||||||
### Test Lead Capture
|
### Test Lead Capture
|
||||||
1. Submit a lead on the contact form (name, email, phone, message)
|
1. Submit a lead on the contact form (name, email, phone, message)
|
||||||
|
|
@ -189,7 +156,7 @@ Website Contact Form → SQLite (always saved)
|
||||||
3. `refresh_token` never expires — store it securely
|
3. `refresh_token` never expires — store it securely
|
||||||
|
|
||||||
### Upsert Logic
|
### Upsert Logic
|
||||||
- **Leads**: WebToLead creates leads through the legacy Zoho form endpoint. API mode uses email-based upsert.
|
- **Leads**: Email-based upsert (update if exists, create if new)
|
||||||
- **Cases**: Always insert (new case per submission)
|
- **Cases**: Always insert (new case per submission)
|
||||||
|
|
||||||
### Fire-and-Forget Design
|
### Fire-and-Forget Design
|
||||||
|
|
@ -204,7 +171,7 @@ Website Contact Form → SQLite (always saved)
|
||||||
|
|
||||||
After configuration:
|
After configuration:
|
||||||
1. Deploy the environment variables to production
|
1. Deploy the environment variables to production
|
||||||
2. Set `ZOHO_WEBTOLEAD_ENABLED=true` in production `.env`
|
2. Set `ZOHO_ENABLED=true` and `ZOHO_CASES_ENABLED=true` in production `.env`
|
||||||
3. Restart the application
|
3. Restart the application
|
||||||
4. Submit a test lead and support case to verify data flows to Zoho CRM
|
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
|
5. Check Zoho CRM Leads and Cases tabs to confirm both appear
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
{
|
{
|
||||||
"name": "queuenorth-website",
|
"name": "queuenorth-website",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.8.3",
|
"version": "0.8.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "concurrently \"vite\" \"node server/index.js\"",
|
"dev": "concurrently \"vite\" \"node server/index.js\"",
|
||||||
|
|
|
||||||
|
Before Width: | Height: | Size: 125 KiB After Width: | Height: | Size: 118 KiB |
|
Before Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 8.0 KiB |
|
Before Width: | Height: | Size: 10 KiB |
120
server/index.js
|
|
@ -1,7 +1,7 @@
|
||||||
import express from 'express'
|
import express from 'express'
|
||||||
import path from 'path'
|
import path from 'path'
|
||||||
import { fileURLToPath } from 'url'
|
import { fileURLToPath } from 'url'
|
||||||
import { existsSync, mkdirSync, readFileSync } from 'fs'
|
import { existsSync, mkdirSync } from 'fs'
|
||||||
import sqlite3 from 'better-sqlite3'
|
import sqlite3 from 'better-sqlite3'
|
||||||
import z from 'zod'
|
import z from 'zod'
|
||||||
import rateLimit from 'express-rate-limit'
|
import rateLimit from 'express-rate-limit'
|
||||||
|
|
@ -13,27 +13,6 @@ const __filename = fileURLToPath(import.meta.url)
|
||||||
const __dirname = path.dirname(__filename)
|
const __dirname = path.dirname(__filename)
|
||||||
const app = express()
|
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
|
// Trust first proxy (Docker/reverse proxy) for correct client IP in rate limiting
|
||||||
app.set('trust proxy', 1)
|
app.set('trust proxy', 1)
|
||||||
const dbPath = path.join(__dirname, '../db/queuenorth.db')
|
const dbPath = path.join(__dirname, '../db/queuenorth.db')
|
||||||
|
|
@ -85,17 +64,15 @@ const apiLimiter = rateLimit({
|
||||||
const isDev = process.env.NODE_ENV === 'development'
|
const isDev = process.env.NODE_ENV === 'development'
|
||||||
const cspDirectives = {
|
const cspDirectives = {
|
||||||
defaultSrc: ["'self'"],
|
defaultSrc: ["'self'"],
|
||||||
scriptSrc: ["'self'", 'https://crm.zohopublic.com', 'https://www.google.com/recaptcha/', 'https://www.gstatic.com/recaptcha/'],
|
scriptSrc: ["'self'", 'https://www.google.com/recaptcha/', 'https://www.gstatic.com/recaptcha/'],
|
||||||
styleSrc: ["'self'", "'unsafe-inline'", 'https://fonts.googleapis.com'],
|
styleSrc: ["'self'", 'https://fonts.googleapis.com'],
|
||||||
fontSrc: ["'self'", 'https://fonts.gstatic.com'],
|
fontSrc: ["'self'", 'https://fonts.gstatic.com'],
|
||||||
imgSrc: ["'self'", 'data:'],
|
imgSrc: ["'self'", 'data:'],
|
||||||
connectSrc: isDev
|
connectSrc: isDev ? ["'self'", 'ws://localhost:*'] : ["'self'"],
|
||||||
? ["'self'", 'ws://localhost:*', 'https://www.google.com/recaptcha/', 'https://www.gstatic.com/recaptcha/', 'https://recaptcha.google.com/recaptcha/']
|
frameSrc: ["'self'", 'https://www.google.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'"],
|
objectSrc: ["'none'"],
|
||||||
baseUri: ["'self'"],
|
baseUri: ["'self'"],
|
||||||
formAction: ["'self'", 'https://crm.zoho.com'],
|
formAction: ["'self'"],
|
||||||
}
|
}
|
||||||
|
|
||||||
// Note: connectSrc currently allows 'self' only. Zoho API calls are server-to-server
|
// Note: connectSrc currently allows 'self' only. Zoho API calls are server-to-server
|
||||||
|
|
@ -374,14 +351,6 @@ const ZOHO_ACCOUNTS_DOMAIN = process.env.ZOHO_ACCOUNTS_DOMAIN || 'https://accoun
|
||||||
const ZOHO_CLIENT_ID = process.env.ZOHO_CLIENT_ID || null
|
const ZOHO_CLIENT_ID = process.env.ZOHO_CLIENT_ID || null
|
||||||
const ZOHO_CLIENT_SECRET = process.env.ZOHO_CLIENT_SECRET || null
|
const ZOHO_CLIENT_SECRET = process.env.ZOHO_CLIENT_SECRET || null
|
||||||
const ZOHO_REFRESH_TOKEN = process.env.ZOHO_REFRESH_TOKEN || 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
|
// In-memory access token cache
|
||||||
let zohoAccessToken = null
|
let zohoAccessToken = null
|
||||||
|
|
@ -544,70 +513,6 @@ 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) ---
|
// --- Zoho Cases Forwarding (best-effort, fire-and-forget) ---
|
||||||
async function forwardSupportToZoho(supportData) {
|
async function forwardSupportToZoho(supportData) {
|
||||||
if (!ZOHO_CASES_ENABLED) return
|
if (!ZOHO_CASES_ENABLED) return
|
||||||
|
|
@ -775,7 +680,7 @@ app.post('/api/leads', express.json({ limit: '1mb' }), async (req, res) => {
|
||||||
log.info(`Lead submitted: ${sanitized.email} from ${sanitized.company} (id: ${result.lastInsertRowid})`)
|
log.info(`Lead submitted: ${sanitized.email} from ${sanitized.company} (id: ${result.lastInsertRowid})`)
|
||||||
|
|
||||||
// Fire-and-forget Zoho forwarding (best-effort, non-blocking)
|
// Fire-and-forget Zoho forwarding (best-effort, non-blocking)
|
||||||
forwardLeadToZoho(sanitized).catch(err => log.error('[Zoho] Forwarding error:', err.message))
|
forwardToZoho(sanitized).catch(err => log.error('[Zoho] Forwarding error:', err.message))
|
||||||
|
|
||||||
res.json({ success: true, message: "Thanks! We'll be in touch shortly." })
|
res.json({ success: true, message: "Thanks! We'll be in touch shortly." })
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
@ -785,7 +690,7 @@ app.post('/api/leads', express.json({ limit: '1mb' }), async (req, res) => {
|
||||||
log.warn(`Duplicate lead email: ${sanitized.email}`)
|
log.warn(`Duplicate lead email: ${sanitized.email}`)
|
||||||
|
|
||||||
// Still forward to Zoho (non-blocking) for existing leads
|
// Still forward to Zoho (non-blocking) for existing leads
|
||||||
forwardLeadToZoho(sanitized).catch(err => log.error('[Zoho] Forwarding error:', err.message))
|
forwardToZoho(sanitized).catch(err => log.error('[Zoho] Forwarding error:', err.message))
|
||||||
|
|
||||||
return res.status(409).json({
|
return res.status(409).json({
|
||||||
error: 'Duplicate lead',
|
error: 'Duplicate lead',
|
||||||
|
|
@ -929,16 +834,13 @@ app.get('*', (req, res, next) => {
|
||||||
app.listen(PORT, () => {
|
app.listen(PORT, () => {
|
||||||
log.info(`Server running on http://localhost:${PORT}`)
|
log.info(`Server running on http://localhost:${PORT}`)
|
||||||
log.info(`Health check: http://localhost:${PORT}/api/health`)
|
log.info(`Health check: http://localhost:${PORT}/api/health`)
|
||||||
log.info(`Zoho lead forwarding mode: ${ZOHO_FORWARDING_MODE}`)
|
if (ZOHO_ENABLED) {
|
||||||
if (ZOHO_FORWARDING_MODE === 'webtolead') {
|
log.info(`Zoho CRM forwarding: ENABLED`)
|
||||||
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 API domain: ${ZOHO_API_DOMAIN}`)
|
||||||
log.info(`Zoho Accounts domain: ${ZOHO_ACCOUNTS_DOMAIN}`)
|
log.info(`Zoho Accounts domain: ${ZOHO_ACCOUNTS_DOMAIN}`)
|
||||||
log.info(`Zoho Cases forwarding: ${process.env.ZOHO_CASES_ENABLED === 'true' ? 'ENABLED' : 'DISABLED'}`)
|
log.info(`Zoho Cases forwarding: ${process.env.ZOHO_CASES_ENABLED === 'true' ? 'ENABLED' : 'DISABLED'}`)
|
||||||
} else {
|
} else {
|
||||||
log.info('Zoho CRM API forwarding: DISABLED (set ZOHO_ENABLED=true to enable)')
|
log.info('Zoho CRM forwarding: DISABLED (set ZOHO_ENABLED=true to enable)')
|
||||||
}
|
}
|
||||||
log.info(`Rate limiting: ${rateLimitMax} requests per ${rateLimitWindowMs / 1000} seconds`)
|
log.info(`Rate limiting: ${rateLimitMax} requests per ${rateLimitWindowMs / 1000} seconds`)
|
||||||
log.info(`Security headers: Helmet enabled with CSP configured`)
|
log.info(`Security headers: Helmet enabled with CSP configured`)
|
||||||
|
|
|
||||||
|
|
@ -1,50 +0,0 @@
|
||||||
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,96 +1,16 @@
|
||||||
import { useEffect, useRef, useState } from 'react'
|
const RecaptchaPlaceholder = ({ error = '' }) => (
|
||||||
|
<div className={`rounded-md border bg-background px-4 py-3 ${error ? 'border-red-500' : 'border-border'}`}>
|
||||||
const siteKey = import.meta.env.VITE_RECAPTCHA_SITE_KEY
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
<div>
|
||||||
let recaptchaScriptPromise
|
<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>
|
||||||
const loadRecaptchaScript = () => {
|
</div>
|
||||||
if (window.grecaptcha?.render) return Promise.resolve(window.grecaptcha)
|
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-md border border-border bg-white">
|
||||||
if (recaptchaScriptPromise) return recaptchaScriptPromise
|
<span className="h-4 w-4 rounded-sm border-2 border-primary-blue" aria-hidden="true" />
|
||||||
|
</div>
|
||||||
recaptchaScriptPromise = new Promise((resolve, reject) => {
|
</div>
|
||||||
const existingScript = document.querySelector('script[src^="https://www.google.com/recaptcha/api.js"]')
|
{error && <p className="mt-2 text-xs text-red-500">{error}</p>}
|
||||||
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>
|
</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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default RecaptchaPlaceholder
|
export default RecaptchaPlaceholder
|
||||||
|
|
|
||||||
|
|
@ -2,37 +2,9 @@ import { useEffect } from 'react'
|
||||||
import { useLocation } from 'react-router-dom'
|
import { useLocation } from 'react-router-dom'
|
||||||
|
|
||||||
export default function ScrollToTop() {
|
export default function ScrollToTop() {
|
||||||
const { pathname, hash } = useLocation()
|
const { pathname } = useLocation()
|
||||||
|
|
||||||
// Cross-page navigation: scroll to hash or top on route change
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (hash) {
|
|
||||||
const el = document.querySelector(hash)
|
|
||||||
if (el) {
|
|
||||||
el.scrollIntoView({ behavior: 'smooth' })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
window.scrollTo(0, 0)
|
window.scrollTo(0, 0)
|
||||||
}, [pathname, hash])
|
}, [pathname])
|
||||||
|
|
||||||
// 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
|
return null
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -44,13 +44,13 @@ const Footer = () => {
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-8">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-8">
|
||||||
{/* Company Info */}
|
{/* Company Info */}
|
||||||
<div>
|
<div>
|
||||||
<Link to="/" className="flex items-center gap-3 mb-3 group" aria-label="Queue North Technologies Home">
|
<Link to="/" className="inline-flex items-start gap-4 mb-3 group" aria-label="Queue North Technologies Home">
|
||||||
<img
|
<img
|
||||||
src="/logo.png"
|
src="/logo.png"
|
||||||
alt="Queue North Technologies"
|
alt="Queue North Technologies"
|
||||||
className="brand-logo-on-dark h-12 w-auto shrink-0 transition-opacity group-hover:opacity-90"
|
className="brand-logo-on-dark h-12 w-auto shrink-0 transition-opacity group-hover:opacity-90"
|
||||||
/>
|
/>
|
||||||
<span className="font-bold text-sm leading-tight tracking-tight text-white sm:text-xl sm:whitespace-nowrap">Queue North Technologies</span>
|
<span className="pt-1 font-bold text-xl leading-tight tracking-tight text-white">Queue North Technologies</span>
|
||||||
</Link>
|
</Link>
|
||||||
<p className="text-navy-light text-sm leading-relaxed mb-5">{companyInfo.tagline}</p>
|
<p className="text-navy-light text-sm leading-relaxed mb-5">{companyInfo.tagline}</p>
|
||||||
<a
|
<a
|
||||||
|
|
@ -72,7 +72,7 @@ const Footer = () => {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Link
|
<Link
|
||||||
to="/contact#contact-form"
|
to="/contact"
|
||||||
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"
|
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"
|
aria-label="Request a free consultation"
|
||||||
>
|
>
|
||||||
|
|
@ -141,13 +141,6 @@ const Footer = () => {
|
||||||
{/* Veteran Owned & Operated */}
|
{/* Veteran Owned & Operated */}
|
||||||
<div className="border-t border-white/10 mt-10 py-6">
|
<div className="border-t border-white/10 mt-10 py-6">
|
||||||
<div className="flex flex-col items-center gap-4 text-center">
|
<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]">
|
<p className="text-primary-cyan text-xs font-semibold uppercase tracking-[0.18em]">
|
||||||
Veteran Owned & Operated
|
Veteran Owned & Operated
|
||||||
</p>
|
</p>
|
||||||
|
|
|
||||||
|
|
@ -64,7 +64,7 @@ const Header = () => {
|
||||||
alt="Queue North Technologies"
|
alt="Queue North Technologies"
|
||||||
className="brand-logo-on-dark h-12 md:h-16 w-auto flex-shrink-0"
|
className="brand-logo-on-dark h-12 md:h-16 w-auto flex-shrink-0"
|
||||||
/>
|
/>
|
||||||
<span className="font-bold text-sm sm:text-xl lg:text-2xl text-white whitespace-nowrap tracking-tight">Queue North Technologies</span>
|
<span className="font-bold text-xl lg:text-2xl text-white hidden sm:block tracking-tight">Queue North Technologies</span>
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -138,7 +138,7 @@ const Header = () => {
|
||||||
|
|
||||||
{/* CTA Button */}
|
{/* CTA Button */}
|
||||||
<div className="hidden md:block">
|
<div className="hidden md:block">
|
||||||
<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">
|
<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">
|
||||||
Request Consultation
|
Request Consultation
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -154,18 +154,16 @@ const Header = () => {
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</SheetTrigger>
|
</SheetTrigger>
|
||||||
<SheetContent side="right" aria-describedby={undefined} className="w-[85vw] max-w-[300px] sm:w-[350px] bg-primary-navy text-white">
|
<SheetContent side="right" className="w-[300px] sm:w-[350px] bg-primary-navy text-white">
|
||||||
<VisuallyHidden.Root asChild>
|
<VisuallyHidden.Root asChild>
|
||||||
<SheetTitle>Navigation Menu</SheetTitle>
|
<SheetTitle>Navigation Menu</SheetTitle>
|
||||||
</VisuallyHidden.Root>
|
</VisuallyHidden.Root>
|
||||||
|
|
||||||
{/* Logo + phone */}
|
{/* Logo */}
|
||||||
<div className="flex items-center gap-3 pb-4 border-b border-white/10">
|
<div className="flex items-center gap-3 pb-4 border-b border-white/10">
|
||||||
<Link to="/" onClick={closeMobileMenu} aria-label="Queue North Technologies Home" className="flex-shrink-0">
|
<Link to="/" onClick={closeMobileMenu} className="flex items-center gap-3" aria-label="Queue North Technologies Home">
|
||||||
<img src="/logo.png" alt="Queue North Technologies" className="brand-logo-on-dark h-12 w-auto" />
|
<img src="/logo.png" alt="Queue North Technologies" className="brand-logo-on-dark h-12 w-auto" />
|
||||||
</Link>
|
<span className="font-bold text-xl tracking-tight">Queue North Technologies</span>
|
||||||
<Link to="/" onClick={closeMobileMenu} className="font-bold text-sm tracking-tight whitespace-nowrap text-white leading-tight">
|
|
||||||
Queue North Technologies
|
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -242,7 +240,7 @@ const Header = () => {
|
||||||
{/* CTA */}
|
{/* CTA */}
|
||||||
<div className="pt-4 border-t border-white/10">
|
<div className="pt-4 border-t border-white/10">
|
||||||
<Link
|
<Link
|
||||||
to="/contact#contact-form"
|
to="/contact"
|
||||||
onClick={closeMobileMenu}
|
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"
|
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"
|
aria-label="Get a free quote"
|
||||||
|
|
|
||||||
|
|
@ -129,7 +129,7 @@ const MobileNav = () => {
|
||||||
|
|
||||||
<div className="mt-auto pt-6">
|
<div className="mt-auto pt-6">
|
||||||
<Link
|
<Link
|
||||||
to="/contact#contact-form"
|
to="/contact"
|
||||||
onClick={closeMobileMenu}
|
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"
|
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"
|
aria-label="Request a consultation"
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,6 @@
|
||||||
|
|
||||||
html {
|
html {
|
||||||
scroll-behavior: smooth;
|
scroll-behavior: smooth;
|
||||||
overflow-x: hidden;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
|
|
@ -19,7 +18,6 @@ body {
|
||||||
background-color: #F8FAFC;
|
background-color: #F8FAFC;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
overflow-x: hidden;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
img {
|
img {
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
const API_BASE_URL = import.meta.env.VITE_API_URL || '/api'
|
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:3001/api'
|
||||||
|
|
||||||
export async function submitLead(data) {
|
export async function submitLead(data) {
|
||||||
const response = await fetch(`${API_BASE_URL}/leads`, {
|
const response = await fetch(`${API_BASE_URL}/leads`, {
|
||||||
|
|
|
||||||
|
|
@ -5,16 +5,13 @@ import { Toaster } from 'sonner'
|
||||||
import { HelmetProvider } from 'react-helmet-async'
|
import { HelmetProvider } from 'react-helmet-async'
|
||||||
import router from './router.jsx'
|
import router from './router.jsx'
|
||||||
import App from './App.jsx'
|
import App from './App.jsx'
|
||||||
import ErrorBoundary from './components/ErrorBoundary.jsx'
|
|
||||||
|
|
||||||
// Wrap the router with providers
|
// Wrap the router with providers
|
||||||
const Root = () => (
|
const Root = () => (
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<HelmetProvider>
|
<HelmetProvider>
|
||||||
<ErrorBoundary>
|
|
||||||
<RouterProvider router={router} />
|
<RouterProvider router={router} />
|
||||||
<Toaster position="top-right" />
|
<Toaster position="top-right" />
|
||||||
</ErrorBoundary>
|
|
||||||
</HelmetProvider>
|
</HelmetProvider>
|
||||||
</StrictMode>
|
</StrictMode>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,33 +1,24 @@
|
||||||
import SEO from '@/components/SEO'
|
import SEO from '@/components/SEO'
|
||||||
import { Link } from 'react-router-dom'
|
import { Link } from 'react-router-dom'
|
||||||
import { ArrowRight, Award, CheckCircle2, Compass, Cpu, Handshake, Headphones, Route, Wrench } from 'lucide-react'
|
import { ArrowRight, Award, CheckCircle2, Compass, Cpu, Handshake, Headphones, Route, Users, Wrench } from 'lucide-react'
|
||||||
|
|
||||||
const proofPoints = [
|
const proofPoints = [
|
||||||
{ label: '25+ years', detail: 'Communications and infrastructure experience', icon: Award, containerClass: '' },
|
{ label: '25+ years', detail: 'Communications and infrastructure experience', icon: Award, accent: 'text-primary-blue', bg: 'bg-sky-50', border: 'border-t-primary-blue' },
|
||||||
{
|
{
|
||||||
label: '8x8 Certified Partner',
|
label: '8x8 Certified Partner',
|
||||||
detail: 'Sales, engineering, build, deployment, and support',
|
detail: 'Sales, engineering, build, deployment, and support',
|
||||||
logo: '/assets/brand/8x8-logo-white.svg',
|
logo: '/assets/brand/8x8-logo-white.svg',
|
||||||
logoAlt: '8x8 Certified Partner logo',
|
logoAlt: '8x8 Certified Partner logo',
|
||||||
logoClassName: 'h-6 w-14',
|
logoClassName: 'h-6 w-14',
|
||||||
containerClass: 'px-2',
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Cisco Partner',
|
label: 'Cisco Partner',
|
||||||
detail: 'Networking and communications implementation',
|
detail: 'Networking and communications implementation',
|
||||||
logo: '/assets/brand/Cisco-Partner-Logo_trasnp_w.png',
|
logo: '/assets/brand/cisco-partner-logo-white.svg',
|
||||||
logoAlt: 'Cisco Partner certification logo',
|
logoAlt: 'Cisco Partner certification logo',
|
||||||
logoClassName: 'h-full w-full scale-[2]',
|
logoClassName: 'h-10 w-10',
|
||||||
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 = [
|
const operatingPrinciples = [
|
||||||
|
|
@ -107,8 +98,8 @@ const About = () => {
|
||||||
<div className="absolute inset-0 -z-10">
|
<div className="absolute inset-0 -z-10">
|
||||||
<img
|
<img
|
||||||
src="/assets/about-image.webp"
|
src="/assets/about-image.webp"
|
||||||
alt="Compass on a dark navigation map"
|
alt="Queue North team member reviewing communications infrastructure"
|
||||||
className="h-full w-full object-cover object-[66%_top] md:object-[62%_top]"
|
className="h-full w-full object-cover object-center"
|
||||||
/>
|
/>
|
||||||
<div className="absolute inset-0 bg-primary-navy/88" />
|
<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-gradient-to-r from-primary-navy via-primary-navy/95 to-primary-navy/60" />
|
||||||
|
|
@ -118,7 +109,7 @@ const About = () => {
|
||||||
<div>
|
<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">
|
<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" />
|
<Compass className="h-4 w-4" aria-hidden="true" />
|
||||||
About
|
About Queue North
|
||||||
</div>
|
</div>
|
||||||
<h1 className="mt-6 text-4xl md:text-5xl lg:text-6xl font-bold leading-tight">
|
<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.
|
Accountable communications infrastructure, built around how your business actually works.
|
||||||
|
|
@ -127,11 +118,11 @@ const About = () => {
|
||||||
Queue North helps organizations choose, implement, and support the phone, contact center, network, and IT systems that keep daily operations moving.
|
Queue North helps organizations choose, implement, and support the phone, contact center, network, and IT systems that keep daily operations moving.
|
||||||
</p>
|
</p>
|
||||||
<div className="mt-8 flex flex-col sm:flex-row gap-3">
|
<div className="mt-8 flex flex-col sm:flex-row gap-3">
|
||||||
<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">
|
<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">
|
||||||
Start a Conversation
|
Start a Conversation
|
||||||
<ArrowRight className="h-4 w-4" aria-hidden="true" />
|
<ArrowRight className="h-4 w-4" aria-hidden="true" />
|
||||||
</Link>
|
</Link>
|
||||||
<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">
|
<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">
|
||||||
View Services
|
View Services
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -144,7 +135,7 @@ const About = () => {
|
||||||
const Icon = point.icon
|
const Icon = point.icon
|
||||||
return (
|
return (
|
||||||
<div key={point.label} className="flex gap-4 rounded-md border border-white/10 bg-white/5 p-3">
|
<div key={point.label} className="flex gap-4 rounded-md border border-white/10 bg-white/5 p-3">
|
||||||
<span className={`flex h-10 w-16 shrink-0 items-center justify-center rounded-md bg-white/10 text-primary-cyan ${point.containerClass}`}>
|
<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'}`}>
|
||||||
{point.logo ? (
|
{point.logo ? (
|
||||||
<img
|
<img
|
||||||
src={point.logo}
|
src={point.logo}
|
||||||
|
|
@ -152,7 +143,7 @@ const About = () => {
|
||||||
className={`${point.logoClassName} object-contain`}
|
className={`${point.logoClassName} object-contain`}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<Icon className="h-7 w-7" aria-hidden="true" />
|
<Icon className="h-5 w-5" aria-hidden="true" />
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
<div>
|
<div>
|
||||||
|
|
@ -271,15 +262,15 @@ const About = () => {
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-7 flex flex-col gap-3 sm:flex-row lg:mt-0 lg:flex-shrink-0">
|
<div className="mt-7 flex flex-col gap-3 sm:flex-row lg:mt-0 lg:flex-shrink-0">
|
||||||
<Link
|
<Link
|
||||||
to="/contact#contact-form"
|
to="/contact"
|
||||||
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"
|
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"
|
||||||
>
|
>
|
||||||
Request Consultation
|
Request Consultation
|
||||||
<ArrowRight className="h-4 w-4" aria-hidden="true" />
|
<ArrowRight className="h-4 w-4" aria-hidden="true" />
|
||||||
</Link>
|
</Link>
|
||||||
<a
|
<a
|
||||||
href="tel:+13217308020"
|
href="tel:+13217308020"
|
||||||
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"
|
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"
|
||||||
>
|
>
|
||||||
Call (321) 730-8020
|
Call (321) 730-8020
|
||||||
</a>
|
</a>
|
||||||
|
|
|
||||||
|
|
@ -1,57 +1,49 @@
|
||||||
import SEO from '@/components/SEO'
|
import SEO from '@/components/SEO'
|
||||||
import { useCallback, useEffect, useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { Button } from '@/components/ui/Button'
|
import { Button } from '@/components/ui/Button'
|
||||||
import { Input } from '@/components/ui/Input'
|
import { Input } from '@/components/ui/Input'
|
||||||
import { Textarea } from '@/components/ui/Textarea'
|
import { Textarea } from '@/components/ui/Textarea'
|
||||||
|
import { Select } from '@/components/ui/Select'
|
||||||
import RecaptchaPlaceholder from '@/components/RecaptchaPlaceholder'
|
import RecaptchaPlaceholder from '@/components/RecaptchaPlaceholder'
|
||||||
import { ArrowRight } from 'lucide-react'
|
|
||||||
import { submitLead } from '@/lib/api'
|
import { submitLead } from '@/lib/api'
|
||||||
|
import { useDebounce } from '@/hooks/useDebounce'
|
||||||
const isRecaptchaConfigured = Boolean(import.meta.env.VITE_RECAPTCHA_SITE_KEY)
|
import { ArrowRight } from 'lucide-react'
|
||||||
|
|
||||||
const Contact = () => {
|
const Contact = () => {
|
||||||
const [formState, setFormState] = useState({
|
const [formState, setFormState] = useState({
|
||||||
'Last Name': '',
|
company: '',
|
||||||
Company: '',
|
name: '',
|
||||||
Email: '',
|
email: '',
|
||||||
Phone: '',
|
phone: '',
|
||||||
'Zip Code': '',
|
zip: '',
|
||||||
Description: '',
|
message: '',
|
||||||
|
service_interest: '',
|
||||||
|
recaptcha_token: '',
|
||||||
company_website: '',
|
company_website: '',
|
||||||
})
|
})
|
||||||
const [errors, setErrors] = useState({
|
const [errors, setErrors] = useState({
|
||||||
'Last Name': '',
|
company: '',
|
||||||
Company: '',
|
name: '',
|
||||||
Email: '',
|
email: '',
|
||||||
'Zip Code': '',
|
zip: '',
|
||||||
Description: '',
|
message: '',
|
||||||
recaptcha_token: '',
|
recaptcha_token: '',
|
||||||
})
|
})
|
||||||
const [debouncedErrors, setDebouncedErrors] = useState(errors)
|
const debouncedErrors = useDebounce(errors, 300)
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
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 validateForm = () => {
|
||||||
const newErrors = { 'Last Name': '', Company: '', Email: '', 'Zip Code': '', Description: '', recaptcha_token: '' }
|
const newErrors = { company: '', name: '', email: '', zip: '', message: '', recaptcha_token: '' }
|
||||||
if (!formState.Company.trim()) newErrors.Company = 'Company name is required'
|
if (!formState.company.trim()) newErrors.company = 'Company name is required'
|
||||||
if (!formState['Last Name'].trim()) newErrors['Last Name'] = 'Name is required'
|
if (!formState.name.trim()) newErrors.name = 'Name is required'
|
||||||
if (!formState['Zip Code'].trim()) newErrors['Zip Code'] = 'ZIP code is required'
|
if (!formState.zip.trim()) newErrors.zip = 'ZIP code is required'
|
||||||
if (!formState.Description.trim()) newErrors.Description = 'Message is required'
|
if (!formState.message.trim()) newErrors.message = 'Message is required'
|
||||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
||||||
if (!formState.Email.trim()) {
|
if (!formState.email.trim()) {
|
||||||
newErrors.Email = 'Email is required'
|
newErrors.email = 'Email is required'
|
||||||
} else if (!emailRegex.test(formState.Email)) {
|
} else if (!emailRegex.test(formState.email)) {
|
||||||
newErrors.Email = 'Please enter a valid email address'
|
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 !== '')
|
const hasErrors = Object.values(newErrors).some(error => error !== '')
|
||||||
setErrors(newErrors)
|
setErrors(newErrors)
|
||||||
|
|
@ -62,58 +54,28 @@ const Contact = () => {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
const resetForm = () => {
|
const handleSubmit = (e) => {
|
||||||
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 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()
|
e.preventDefault()
|
||||||
if (!validateForm()) return
|
if (!validateForm()) return
|
||||||
|
handleSubmitForm()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSubmitForm = async () => {
|
||||||
setIsSubmitting(true)
|
setIsSubmitting(true)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await submitLead({
|
await submitLead(formState)
|
||||||
company: formState.Company,
|
toast.success("Thanks! We'll be in touch shortly.")
|
||||||
name: formState['Last Name'],
|
setFormState({ company: '', name: '', email: '', phone: '', zip: '', message: '', service_interest: '', recaptcha_token: '', company_website: '' })
|
||||||
email: formState.Email,
|
setErrors({ company: '', name: '', email: '', zip: '', message: '', recaptcha_token: '' })
|
||||||
phone: formState.Phone,
|
} catch (error) {
|
||||||
zip: formState['Zip Code'],
|
if (error.response?.status === 409) {
|
||||||
message: formState.Description,
|
toast.success("We already have your submission! We'll be in touch.")
|
||||||
recaptcha_token: recaptchaToken,
|
} else if (error.response?.status === 400 && error.fields) {
|
||||||
company_website: formState.company_website,
|
setErrors(prev => ({ ...prev, ...error.fields }))
|
||||||
})
|
toast.error('Please fix the errors in the form')
|
||||||
|
} else {
|
||||||
toast.success(result.message || "Thanks! We'll be in touch shortly.")
|
toast.error(error.message || 'Failed to submit form. Please try again.')
|
||||||
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 {
|
} finally {
|
||||||
setIsSubmitting(false)
|
setIsSubmitting(false)
|
||||||
}
|
}
|
||||||
|
|
@ -125,16 +87,6 @@ const Contact = () => {
|
||||||
if (errors[name]) setErrors(prev => ({ ...prev, [name]: '' }))
|
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 = [
|
const contactDetails = [
|
||||||
{
|
{
|
||||||
label: 'Phone',
|
label: 'Phone',
|
||||||
|
|
@ -183,7 +135,6 @@ const Contact = () => {
|
||||||
|
|
||||||
const trustPoints = [
|
const trustPoints = [
|
||||||
<div key="8x8"><span className="font-numeric">8x8</span> Certified Partner with proven expertise</div>,
|
<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>,
|
<div key="veteran"><span className="font-numeric">25+</span> years of experience</div>,
|
||||||
'SMB to Enterprise solutions',
|
'SMB to Enterprise solutions',
|
||||||
'No vendor bias — we recommend what fits',
|
'No vendor bias — we recommend what fits',
|
||||||
|
|
@ -210,7 +161,7 @@ const Contact = () => {
|
||||||
</div>
|
</div>
|
||||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
<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">
|
<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
|
Contact Queue North
|
||||||
</div>
|
</div>
|
||||||
<h1 className="text-4xl md:text-5xl font-bold text-white mb-4">Let's Talk</h1>
|
<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">
|
<p className="text-xl text-white/70 max-w-2xl">
|
||||||
|
|
@ -250,7 +201,7 @@ const Contact = () => {
|
||||||
<div className="border-t border-white/10" />
|
<div className="border-t border-white/10" />
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<p className="text-white/50 text-xs uppercase tracking-wider mb-5">Why Queue North Technologies</p>
|
<p className="text-white/50 text-xs uppercase tracking-wider mb-5">Why Queue North</p>
|
||||||
<ul className="space-y-4">
|
<ul className="space-y-4">
|
||||||
{trustPoints.map((point, i) => (
|
{trustPoints.map((point, i) => (
|
||||||
<li key={i} className="flex items-start gap-3 text-white/80 text-sm leading-relaxed">
|
<li key={i} className="flex items-start gap-3 text-white/80 text-sm leading-relaxed">
|
||||||
|
|
@ -269,137 +220,150 @@ const Contact = () => {
|
||||||
<h2 className="text-2xl font-bold text-primary-navy mb-1">Send Us a Message</h2>
|
<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>
|
<p className="text-soft-text text-sm mb-8">We typically respond within one business day.</p>
|
||||||
|
|
||||||
<form
|
<form onSubmit={handleSubmit} noValidate className={`space-y-5 ${isSubmitting ? 'opacity-70 pointer-events-none' : ''}`}>
|
||||||
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 */}
|
{/* Company */}
|
||||||
<div>
|
<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>
|
Company Name <span className="text-red-500">*</span>
|
||||||
</label>
|
</label>
|
||||||
<Input
|
<Input
|
||||||
type="text"
|
type="text"
|
||||||
id="Company"
|
id="company"
|
||||||
name="Company"
|
name="company"
|
||||||
value={formState.Company}
|
value={formState.company}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
required
|
required
|
||||||
placeholder="Your company name"
|
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>
|
</div>
|
||||||
|
|
||||||
{/* Name + Email */}
|
{/* Name + Email */}
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="Last_Name" className="block text-sm font-medium text-text mb-1.5">
|
<label htmlFor="name" className="block text-sm font-medium text-text mb-1.5">
|
||||||
Name <span className="text-red-500">*</span>
|
Name <span className="text-red-500">*</span>
|
||||||
</label>
|
</label>
|
||||||
<Input
|
<Input
|
||||||
type="text"
|
type="text"
|
||||||
id="Last_Name"
|
id="name"
|
||||||
name="Last Name"
|
name="name"
|
||||||
value={formState['Last Name']}
|
value={formState.name}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
required
|
required
|
||||||
placeholder="Your full name"
|
placeholder="Your full name"
|
||||||
className={debouncedErrors['Last Name'] ? 'border-red-500 focus-visible:ring-red-500' : ''}
|
className={debouncedErrors.name ? 'border-red-500 focus-visible:ring-red-500' : ''}
|
||||||
/>
|
/>
|
||||||
{debouncedErrors['Last Name'] && <p className="text-xs text-red-500 mt-1">{debouncedErrors['Last Name']}</p>}
|
{debouncedErrors.name && <p className="text-xs text-red-500 mt-1">{debouncedErrors.name}</p>}
|
||||||
</div>
|
</div>
|
||||||
<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>
|
Email <span className="text-red-500">*</span>
|
||||||
</label>
|
</label>
|
||||||
<Input
|
<Input
|
||||||
type="email"
|
type="email"
|
||||||
id="Email"
|
id="email"
|
||||||
name="Email"
|
name="email"
|
||||||
value={formState.Email}
|
value={formState.email}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
required
|
required
|
||||||
placeholder="you@company.com"
|
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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Phone + ZIP */}
|
{/* Phone + ZIP */}
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
<div>
|
<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>
|
Phone <span className="text-soft-text font-normal">(optional)</span>
|
||||||
</label>
|
</label>
|
||||||
<Input
|
<Input
|
||||||
type="tel"
|
type="tel"
|
||||||
id="Phone"
|
id="phone"
|
||||||
name="Phone"
|
name="phone"
|
||||||
value={formState.Phone}
|
value={formState.phone}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
placeholder="(555) 123-4567"
|
placeholder="(555) 123-4567"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="Zip_Code" className="block text-sm font-medium text-text mb-1.5">
|
<label htmlFor="zip" className="block text-sm font-medium text-text mb-1.5">
|
||||||
ZIP Code <span className="text-red-500">*</span>
|
ZIP Code <span className="text-red-500">*</span>
|
||||||
</label>
|
</label>
|
||||||
<Input
|
<Input
|
||||||
type="text"
|
type="text"
|
||||||
id="Zip_Code"
|
id="zip"
|
||||||
name="Zip Code"
|
name="zip"
|
||||||
value={formState['Zip Code']}
|
value={formState.zip}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
required
|
required
|
||||||
autoComplete="postal-code"
|
autoComplete="postal-code"
|
||||||
inputMode="numeric"
|
inputMode="numeric"
|
||||||
placeholder="33702"
|
placeholder="33702"
|
||||||
className={debouncedErrors['Zip Code'] ? 'border-red-500 focus-visible:ring-red-500' : ''}
|
className={debouncedErrors.zip ? 'border-red-500 focus-visible:ring-red-500' : ''}
|
||||||
/>
|
/>
|
||||||
{debouncedErrors['Zip Code'] && <p className="text-xs text-red-500 mt-1">{debouncedErrors['Zip Code']}</p>}
|
{debouncedErrors.zip && <p className="text-xs text-red-500 mt-1">{debouncedErrors.zip}</p>}
|
||||||
</div>
|
</div>
|
||||||
</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 */}
|
{/* Message */}
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="Description" className="block text-sm font-medium text-text mb-1.5">
|
<label htmlFor="message" className="block text-sm font-medium text-text mb-1.5">
|
||||||
Message <span className="text-red-500">*</span>
|
Message <span className="text-red-500">*</span>
|
||||||
</label>
|
</label>
|
||||||
<Textarea
|
<Textarea
|
||||||
id="Description"
|
id="message"
|
||||||
name="Description"
|
name="message"
|
||||||
value={formState.Description}
|
value={formState.message}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
required
|
required
|
||||||
placeholder="Tell us about your needs..."
|
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.Description ? '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.message ? 'border-red-500 focus-visible:ring-red-500' : ''}`}
|
||||||
rows={5}
|
rows={5}
|
||||||
/>
|
/>
|
||||||
{debouncedErrors.Description && <p className="text-xs text-red-500 mt-1">{debouncedErrors.Description}</p>}
|
{debouncedErrors.message && <p className="text-xs text-red-500 mt-1">{debouncedErrors.message}</p>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<RecaptchaPlaceholder
|
{/* Honeypot */}
|
||||||
error={debouncedErrors.recaptcha_token}
|
<div className="absolute opacity-0 h-0 overflow-hidden" aria-hidden="true">
|
||||||
onVerify={handleRecaptchaVerify}
|
<input
|
||||||
onExpired={handleRecaptchaExpired}
|
type="text"
|
||||||
resetKey={recaptchaResetKey}
|
name="company_website"
|
||||||
|
tabIndex="-1"
|
||||||
|
autoComplete="off"
|
||||||
|
value={formState.company_website}
|
||||||
|
onChange={handleChange}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<RecaptchaPlaceholder error={debouncedErrors.recaptcha_token} />
|
||||||
|
|
||||||
<Button type="submit" className="w-full h-11" disabled={isSubmitting}>
|
<Button type="submit" className="w-full h-11" disabled={isSubmitting}>
|
||||||
{isSubmitting ? (
|
{isSubmitting ? (
|
||||||
|
|
|
||||||
|
|
@ -57,7 +57,7 @@ const Home = () => {
|
||||||
'@type': 'ImageObject',
|
'@type': 'ImageObject',
|
||||||
url: 'https://queuenorth.com/logo.png',
|
url: 'https://queuenorth.com/logo.png',
|
||||||
},
|
},
|
||||||
description: 'Veteran-owned 8x8 Certified Partner and Cisco Certified Partner providing business phone systems, UCaaS, contact center, IT support, and networking solutions.',
|
description: 'Veteran-owned 8x8 Certified Partner providing business phone systems, UCaaS, contact center, IT support, and networking solutions.',
|
||||||
address: {
|
address: {
|
||||||
'@type': 'PostalAddress',
|
'@type': 'PostalAddress',
|
||||||
streetAddress: '7901 4th St N',
|
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.
|
Business phone, contact center, network, and IT support built around one accountable implementation partner.
|
||||||
</p>
|
</p>
|
||||||
<div className="mt-8 flex flex-col sm:flex-row gap-3">
|
<div className="mt-8 flex flex-col sm:flex-row gap-3">
|
||||||
<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">
|
<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">
|
||||||
Schedule Consultation
|
Schedule Consultation
|
||||||
<ArrowRight className="h-4 w-4" aria-hidden="true" />
|
<ArrowRight className="h-4 w-4" aria-hidden="true" />
|
||||||
</Link>
|
</Link>
|
||||||
<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">
|
<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">
|
||||||
View Services
|
View Services
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -168,42 +168,38 @@ const Home = () => {
|
||||||
{/* Partner Proof */}
|
{/* Partner Proof */}
|
||||||
<section className="bg-white border-b border-border py-6">
|
<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="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
<div className="grid grid-cols-2 gap-x-4 gap-y-6 lg:grid-cols-4">
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
<div className="flex flex-col items-center gap-2 lg:flex-row lg:gap-3 lg:justify-start">
|
<div className="flex items-center justify-center gap-3 sm:justify-start">
|
||||||
<span className="flex h-16 w-20 shrink-0 items-center justify-center rounded-md border border-border bg-white p-2">
|
<span className="flex h-14 w-24 shrink-0 items-center justify-center rounded-md border border-border bg-white px-3">
|
||||||
<img
|
<img
|
||||||
src="/assets/brand/8x8-logo-dark-gray.png"
|
src="/assets/brand/8x8-logo-dark-gray.png"
|
||||||
alt="8x8 Certified Partner logo"
|
alt="8x8 Certified Partner logo"
|
||||||
className="h-full w-full object-contain"
|
className="h-9 w-full object-contain"
|
||||||
/>
|
/>
|
||||||
</span>
|
</span>
|
||||||
<span className="text-sm font-semibold leading-tight text-primary-navy text-center lg:text-left">8x8 Certified Partner</span>
|
<span className="text-sm font-semibold leading-tight text-primary-navy sm:text-left">8x8 Certified Partner</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col items-center gap-2 lg:flex-row lg:gap-3 lg:justify-start">
|
<div className="flex items-center justify-center gap-3 sm: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">
|
<span className="flex h-14 w-24 shrink-0 items-center justify-center rounded-md border border-border bg-white px-3">
|
||||||
<img
|
<img
|
||||||
src="/assets/brand/cisco-partner-logo-midnight.svg"
|
src="/assets/brand/cisco-partner-logo-midnight.svg"
|
||||||
alt="Cisco Partner certification logo"
|
alt="Cisco Partner certification logo"
|
||||||
className="h-full w-full object-contain scale-[1.5]"
|
className="h-12 w-full object-contain"
|
||||||
/>
|
/>
|
||||||
</span>
|
</span>
|
||||||
<span className="text-sm font-semibold leading-tight text-primary-navy text-center lg:text-left">Cisco Certified Partner</span>
|
<span className="text-sm font-semibold leading-tight text-primary-navy sm:text-left">Cisco Certified Partner</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col items-center gap-2 lg:flex-row lg:gap-3 lg:justify-start">
|
<div className="flex items-center justify-center gap-3 sm:justify-start">
|
||||||
<span className="flex h-16 w-20 shrink-0 items-center justify-center rounded-md border border-border bg-white p-1">
|
<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">
|
||||||
<img
|
<ShieldCheck className="h-7 w-7 text-primary-blue" aria-hidden="true" />
|
||||||
src="/assets/brand/veteran-owned-certified-mark.webp"
|
|
||||||
alt="SBA logo for Veteran-Owned Certified"
|
|
||||||
className="h-full w-full object-contain"
|
|
||||||
/>
|
|
||||||
</span>
|
</span>
|
||||||
<span className="text-sm font-semibold leading-tight text-primary-navy text-center lg:text-left">Veteran-Owned Certified</span>
|
<span className="text-sm font-semibold leading-tight text-primary-navy sm:text-left">Veteran Owned</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col items-center gap-2 lg:flex-row lg:gap-3 lg:justify-start">
|
<div className="flex items-center justify-center gap-3 sm: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">
|
<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">
|
||||||
25+
|
25+
|
||||||
</span>
|
</span>
|
||||||
<span className="text-sm font-semibold leading-tight text-primary-navy text-center lg:text-left">Years Experience</span>
|
<span className="text-sm font-semibold leading-tight text-primary-navy sm:text-left">Years Experience</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -263,7 +259,7 @@ const Home = () => {
|
||||||
Four concrete differentiators that set us apart
|
Four concrete differentiators that set us apart
|
||||||
</p>
|
</p>
|
||||||
<div>
|
<div>
|
||||||
<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">
|
<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">
|
||||||
Request Consultation
|
Request Consultation
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -358,35 +354,35 @@ const Home = () => {
|
||||||
What we'll help you do
|
What we'll help you do
|
||||||
</h2>
|
</h2>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-8">
|
<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 justify-center sm:justify-start">
|
<div className="flex items-start gap-3">
|
||||||
<div className="bg-primary-navy/10 p-2 rounded-md flex-shrink-0">
|
<div className="bg-primary-navy/10 p-2 rounded-md flex-shrink-0">
|
||||||
<MapPin className="w-5 h-5 text-primary-navy" />
|
<MapPin className="w-5 h-5 text-primary-navy" />
|
||||||
</div>
|
</div>
|
||||||
<p className="text-center sm:text-left text-sm md:text-base text-soft-text">
|
<p className="text-left text-sm md:text-base text-soft-text">
|
||||||
Identify the features you actually need
|
Identify the features you actually need
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-start gap-3 justify-center sm:justify-start">
|
<div className="flex items-start gap-3">
|
||||||
<div className="bg-teal-500/10 p-2 rounded-md flex-shrink-0">
|
<div className="bg-teal-500/10 p-2 rounded-md flex-shrink-0">
|
||||||
<Users className="w-5 h-5 text-teal-600" />
|
<Users className="w-5 h-5 text-teal-600" />
|
||||||
</div>
|
</div>
|
||||||
<p className="text-center sm:text-left text-sm md:text-base text-soft-text">
|
<p className="text-left text-sm md:text-base text-soft-text">
|
||||||
Align solutions with operations and budget
|
Align solutions with operations and budget
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-start gap-3 justify-center sm:justify-start">
|
<div className="flex items-start gap-3">
|
||||||
<div className="bg-teal-500/10 p-2 rounded-md flex-shrink-0">
|
<div className="bg-teal-500/10 p-2 rounded-md flex-shrink-0">
|
||||||
<Network className="w-5 h-5 text-teal-600" />
|
<Network className="w-5 h-5 text-teal-600" />
|
||||||
</div>
|
</div>
|
||||||
<p className="text-center sm:text-left text-sm md:text-base text-soft-text">
|
<p className="text-left text-sm md:text-base text-soft-text">
|
||||||
Plan deployment, migration, and training
|
Plan deployment, migration, and training
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-start gap-3 justify-center sm:justify-start">
|
<div className="flex items-start gap-3">
|
||||||
<div className="bg-teal-600/10 p-2 rounded-md flex-shrink-0">
|
<div className="bg-teal-600/10 p-2 rounded-md flex-shrink-0">
|
||||||
<ShieldCheck className="w-5 h-5 text-teal-600" />
|
<ShieldCheck className="w-5 h-5 text-teal-600" />
|
||||||
</div>
|
</div>
|
||||||
<p className="text-center sm:text-left text-sm md:text-base text-teal-700 font-semibold">
|
<p className="text-left text-sm md:text-base text-teal-700 font-semibold">
|
||||||
Ask how you qualify for our free migration
|
Ask how you qualify for our free migration
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -394,7 +390,7 @@ const Home = () => {
|
||||||
<p className="text-lg text-soft-text mb-8 max-w-2xl mx-auto">
|
<p className="text-lg text-soft-text mb-8 max-w-2xl mx-auto">
|
||||||
Share a few details and we'll provide clear direction.
|
Share a few details and we'll provide clear direction.
|
||||||
</p>
|
</p>
|
||||||
<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">
|
<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">
|
||||||
Request Consultation
|
Request Consultation
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -50,7 +50,7 @@ const Industries = () => {
|
||||||
Communications and infrastructure solutions shaped by the compliance, workflow, and connectivity demands of your specific environment.
|
Communications and infrastructure solutions shaped by the compliance, workflow, and connectivity demands of your specific environment.
|
||||||
</p>
|
</p>
|
||||||
<Link
|
<Link
|
||||||
to="/contact#contact-form"
|
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"
|
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
|
Talk to a Specialist
|
||||||
|
|
@ -120,15 +120,15 @@ const Industries = () => {
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-7 flex flex-col gap-3 sm:flex-row lg:mt-0 lg:flex-shrink-0">
|
<div className="mt-7 flex flex-col gap-3 sm:flex-row lg:mt-0 lg:flex-shrink-0">
|
||||||
<Link
|
<Link
|
||||||
to="/contact#contact-form"
|
to="/contact"
|
||||||
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"
|
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"
|
||||||
>
|
>
|
||||||
Talk to a Specialist
|
Talk to a Specialist
|
||||||
<ArrowRight className="h-4 w-4" aria-hidden="true" />
|
<ArrowRight className="h-4 w-4" aria-hidden="true" />
|
||||||
</Link>
|
</Link>
|
||||||
<a
|
<a
|
||||||
href="tel:+13217308020"
|
href="tel:+13217308020"
|
||||||
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"
|
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"
|
||||||
>
|
>
|
||||||
Call (321) 730-8020
|
Call (321) 730-8020
|
||||||
</a>
|
</a>
|
||||||
|
|
|
||||||
|
|
@ -119,7 +119,7 @@ const IndustryDetail = () => {
|
||||||
<p className="text-soft-text">{industry.name}</p>
|
<p className="text-soft-text">{industry.name}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="pt-4 border-t border-border">
|
<div className="pt-4 border-t border-border">
|
||||||
<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">
|
<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">
|
||||||
Request Consultation
|
Request Consultation
|
||||||
<ArrowRight className="h-4 w-4" aria-hidden="true" />
|
<ArrowRight className="h-4 w-4" aria-hidden="true" />
|
||||||
</Link>
|
</Link>
|
||||||
|
|
|
||||||
|
|
@ -1,168 +1,41 @@
|
||||||
import { Helmet } from 'react-helmet-async'
|
import { Helmet } from 'react-helmet-async'
|
||||||
import { Link } from 'react-router-dom'
|
import { Link } from 'react-router-dom'
|
||||||
import { ArrowRight, Building2, Compass, Headphones, Home, LifeBuoy, Network, ShieldCheck } from 'lucide-react'
|
import { ArrowRight, Compass } 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() {
|
export default function NotFound() {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Helmet>
|
<Helmet>
|
||||||
<title>Page Not Found | Queue North Technologies</title>
|
<title>Page Not Found | Queue North Technologies</title>
|
||||||
<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="description" content="The page you're looking for doesn't exist." />
|
||||||
<meta name="robots" content="noindex, follow" />
|
|
||||||
</Helmet>
|
</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">
|
<div className="absolute inset-0 -z-10">
|
||||||
<img
|
<img
|
||||||
src="/assets/about-image.webp"
|
src="/assets/hero-tech.webp"
|
||||||
alt=""
|
alt=""
|
||||||
className="h-full w-full object-cover object-[66%_top] md:object-[62%_top]"
|
className="h-full w-full object-cover object-center"
|
||||||
/>
|
/>
|
||||||
<div className="absolute inset-0 bg-primary-navy/86" />
|
<div className="absolute inset-0 bg-primary-navy/88" />
|
||||||
<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 bg-gradient-to-r from-primary-navy via-primary-navy/95 to-primary-navy/60" />
|
||||||
<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>
|
||||||
|
<div className="mx-auto max-w-3xl px-4 text-center sm:px-6 lg:px-8">
|
||||||
<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="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">
|
||||||
<div className="min-w-0 w-full max-w-[calc(100vw-2rem)] sm:max-w-3xl">
|
<Compass className="h-6 w-6" aria-hidden="true" />
|
||||||
<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>
|
</div>
|
||||||
|
<p className="text-sm font-semibold uppercase tracking-[0.18em] text-primary-cyan">404</p>
|
||||||
<p className="mt-8 font-numeric text-8xl leading-none text-white sm:text-9xl md:text-[10rem]">
|
<h1 className="mt-3 text-4xl font-bold md:text-5xl">This page lost direction.</h1>
|
||||||
404
|
<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>
|
</p>
|
||||||
<h1 className="mt-4 max-w-2xl text-3xl font-bold leading-tight text-white sm:text-5xl lg:text-6xl">
|
<div className="mt-8 flex flex-col sm:flex-row gap-3 justify-center">
|
||||||
This route dropped <span className="block sm:inline">off the network.</span>
|
<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">
|
||||||
</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
|
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" />
|
<ArrowRight className="h-4 w-4" aria-hidden="true" />
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
<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
|
||||||
<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>
|
|
||||||
|
|
||||||
<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>
|
</Link>
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
|
||||||
|
|
@ -84,7 +84,7 @@ const ServiceDetail = () => {
|
||||||
<h1 className="text-4xl md:text-5xl font-bold mb-6">{service.name}</h1>
|
<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>
|
<p className="text-xl text-white/75 max-w-3xl leading-relaxed">{service.shortDesc}</p>
|
||||||
<div className="mt-8">
|
<div className="mt-8">
|
||||||
<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">
|
<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">
|
||||||
Request This Service
|
Request This Service
|
||||||
<ArrowRight className="h-4 w-4" aria-hidden="true" />
|
<ArrowRight className="h-4 w-4" aria-hidden="true" />
|
||||||
</Link>
|
</Link>
|
||||||
|
|
@ -149,7 +149,7 @@ const ServiceDetail = () => {
|
||||||
<p className="text-soft-text">{service.name}</p>
|
<p className="text-soft-text">{service.name}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="pt-4 border-t border-border">
|
<div className="pt-4 border-t border-border">
|
||||||
<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">
|
<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">
|
||||||
Request This Service
|
Request This Service
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -96,15 +96,15 @@ const Services = () => {
|
||||||
</p>
|
</p>
|
||||||
<div className="flex flex-col sm:flex-row gap-3">
|
<div className="flex flex-col sm:flex-row gap-3">
|
||||||
<Link
|
<Link
|
||||||
to="/contact#contact-form"
|
to="/contact"
|
||||||
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"
|
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"
|
||||||
>
|
>
|
||||||
Get a Free Quote
|
Get a Free Quote
|
||||||
<ArrowRight className="h-4 w-4" aria-hidden="true" />
|
<ArrowRight className="h-4 w-4" aria-hidden="true" />
|
||||||
</Link>
|
</Link>
|
||||||
<Link
|
<Link
|
||||||
to="/support"
|
to="/support"
|
||||||
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"
|
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"
|
||||||
>
|
>
|
||||||
Existing Client? Get Support
|
Existing Client? Get Support
|
||||||
</Link>
|
</Link>
|
||||||
|
|
@ -135,7 +135,7 @@ const Services = () => {
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Link
|
<Link
|
||||||
to="/contact#contact-form"
|
to="/contact"
|
||||||
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"
|
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
|
Talk through options
|
||||||
|
|
@ -188,15 +188,15 @@ const Services = () => {
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-7 flex flex-col gap-3 sm:flex-row lg:mt-0 lg:flex-shrink-0">
|
<div className="mt-7 flex flex-col gap-3 sm:flex-row lg:mt-0 lg:flex-shrink-0">
|
||||||
<Link
|
<Link
|
||||||
to="/contact#contact-form"
|
to="/contact"
|
||||||
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"
|
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"
|
||||||
>
|
>
|
||||||
Schedule a Consultation
|
Schedule a Consultation
|
||||||
<ArrowRight className="h-4 w-4" aria-hidden="true" />
|
<ArrowRight className="h-4 w-4" aria-hidden="true" />
|
||||||
</Link>
|
</Link>
|
||||||
<a
|
<a
|
||||||
href="tel:+13217308020"
|
href="tel:+13217308020"
|
||||||
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"
|
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"
|
||||||
>
|
>
|
||||||
Call (321) 730-8020
|
Call (321) 730-8020
|
||||||
</a>
|
</a>
|
||||||
|
|
|
||||||
|
|
@ -51,7 +51,7 @@ const Support = () => {
|
||||||
<div>
|
<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">
|
<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" />
|
<LifeBuoy className="h-4 w-4" aria-hidden="true" />
|
||||||
Support
|
Queue North Support
|
||||||
</div>
|
</div>
|
||||||
<h1 className="mt-6 text-4xl md:text-5xl lg:text-6xl font-bold leading-tight">
|
<h1 className="mt-6 text-4xl md:text-5xl lg:text-6xl font-bold leading-tight">
|
||||||
Get help without getting handed off.
|
Get help without getting handed off.
|
||||||
|
|
@ -64,7 +64,7 @@ const Support = () => {
|
||||||
href={portalLinks[0].href}
|
href={portalLinks[0].href}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
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"
|
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"
|
||||||
>
|
>
|
||||||
Sign In
|
Sign In
|
||||||
<ArrowRight className="h-4 w-4" aria-hidden="true" />
|
<ArrowRight className="h-4 w-4" aria-hidden="true" />
|
||||||
|
|
@ -73,7 +73,7 @@ const Support = () => {
|
||||||
href={portalLinks[1].href}
|
href={portalLinks[1].href}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
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"
|
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"
|
||||||
>
|
>
|
||||||
Create Account
|
Create Account
|
||||||
<ExternalLink className="h-4 w-4" aria-hidden="true" />
|
<ExternalLink className="h-4 w-4" aria-hidden="true" />
|
||||||
|
|
|
||||||