Compare commits

..

No commits in common. "dev" and "v0.8.0" have entirely different histories.
dev ... v0.8.0

32 changed files with 262 additions and 783 deletions

View File

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

4
.gitignore vendored
View File

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

View File

@ -15,10 +15,6 @@ RUN npm ci
# Copy source files
COPY . .
# Public Vite values are compiled into the frontend bundle at build time.
ARG VITE_RECAPTCHA_SITE_KEY=
ENV VITE_RECAPTCHA_SITE_KEY=$VITE_RECAPTCHA_SITE_KEY
# Build the frontend
RUN npm run build
@ -47,14 +43,6 @@ ENV SERVER_PORT=3001
ENV RATE_LIMIT_PER_MINUTE=5
ENV CORS_ORIGIN=*
ENV LOG_LEVEL=info
ENV ZOHO_FORWARDING_MODE=webtolead
ENV ZOHO_WEBTOLEAD_ENABLED=false
ENV ZOHO_WEBTOLEAD_URL=https://crm.zoho.com/crm/WebToLeadForm
ENV ZOHO_WEBTOLEAD_XNQSJSDP=
ENV ZOHO_WEBTOLEAD_XMIWTLD=
ENV ZOHO_WEBTOLEAD_ACTION_TYPE=TGVhZHM=
ENV ZOHO_WEBTOLEAD_RETURN_URL=null
ENV ZOHO_WEBTOLEAD_ZC_GAD=
ENV ZOHO_ENABLED=false
ENV ZOHO_API_DOMAIN=https://www.zohoapis.com
ENV ZOHO_ACCOUNTS_DOMAIN=https://accounts.zoho.com
@ -62,9 +50,6 @@ ENV ZOHO_CLIENT_ID=
ENV ZOHO_CLIENT_SECRET=
ENV ZOHO_REFRESH_TOKEN=
ENV ZOHO_CASES_ENABLED=false
ENV RECAPTCHA_ENABLED=false
ENV RECAPTCHA_SECRET_KEY=
ENV RECAPTCHA_MIN_SCORE=0.5
# Create app directory structure
RUN mkdir -p /app/db /app/logs

Binary file not shown.

Before

Width:  |  Height:  |  Size: 151 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 129 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 MiB

After

Width:  |  Height:  |  Size: 2.3 MiB

View File

@ -5,8 +5,6 @@ services:
build:
context: .
dockerfile: Dockerfile
args:
- VITE_RECAPTCHA_SITE_KEY=${VITE_RECAPTCHA_SITE_KEY:-}
container_name: queuenorth-website
ports:
- "3001:3001"
@ -22,24 +20,12 @@ services:
- RATE_LIMIT_PER_MINUTE=5
- CORS_ORIGIN=https://queuenorth.com
- LOG_LEVEL=info
- ZOHO_FORWARDING_MODE=${ZOHO_FORWARDING_MODE:-webtolead}
- ZOHO_WEBTOLEAD_ENABLED=${ZOHO_WEBTOLEAD_ENABLED:-false}
- ZOHO_WEBTOLEAD_URL=${ZOHO_WEBTOLEAD_URL:-https://crm.zoho.com/crm/WebToLeadForm}
- ZOHO_WEBTOLEAD_XNQSJSDP=${ZOHO_WEBTOLEAD_XNQSJSDP:-}
- ZOHO_WEBTOLEAD_XMIWTLD=${ZOHO_WEBTOLEAD_XMIWTLD:-}
- ZOHO_WEBTOLEAD_ACTION_TYPE=${ZOHO_WEBTOLEAD_ACTION_TYPE:-TGVhZHM=}
- ZOHO_WEBTOLEAD_RETURN_URL=${ZOHO_WEBTOLEAD_RETURN_URL:-null}
- ZOHO_WEBTOLEAD_ZC_GAD=${ZOHO_WEBTOLEAD_ZC_GAD:-}
- ZOHO_ENABLED=${ZOHO_ENABLED:-false}
- ZOHO_API_DOMAIN=${ZOHO_API_DOMAIN:-https://www.zohoapis.com}
- ZOHO_ACCOUNTS_DOMAIN=${ZOHO_ACCOUNTS_DOMAIN:-https://accounts.zoho.com}
- ZOHO_CLIENT_ID=${ZOHO_CLIENT_ID:-}
- ZOHO_CLIENT_SECRET=${ZOHO_CLIENT_SECRET:-}
- ZOHO_REFRESH_TOKEN=${ZOHO_REFRESH_TOKEN:-}
- ZOHO_CASES_ENABLED=${ZOHO_CASES_ENABLED:-false}
- RECAPTCHA_ENABLED=${RECAPTCHA_ENABLED:-false}
- RECAPTCHA_SECRET_KEY=${RECAPTCHA_SECRET_KEY:-}
- RECAPTCHA_MIN_SCORE=${RECAPTCHA_MIN_SCORE:-0.5}
- ZOHO_ENABLED=false
- ZOHO_API_DOMAIN=https://www.zohoapis.com
- ZOHO_CLIENT_ID=
- ZOHO_CLIENT_SECRET=
- ZOHO_REFRESH_TOKEN=
- ZOHO_REDIRECT_URI=
restart: unless-stopped
# Container runs as non-root user (UID 1001) for security

View File

@ -1,36 +1,18 @@
# 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:
- 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
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`.
## Step 1: Create a Zoho Self-Client App
1. Go to **https://api-console.zoho.com**
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"**
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):
@ -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 for WebToLead lead forwarding:
Add these to your `.env` file:
```env
ZOHO_FORWARDING_MODE=webtolead
ZOHO_WEBTOLEAD_ENABLED=true
ZOHO_WEBTOLEAD_URL=https://crm.zoho.com/crm/WebToLeadForm
ZOHO_WEBTOLEAD_XNQSJSDP=<from Zoho WebToLead hidden field>
ZOHO_WEBTOLEAD_XMIWTLD=<from Zoho WebToLead hidden field>
ZOHO_WEBTOLEAD_ACTION_TYPE=TGVhZHM=
ZOHO_WEBTOLEAD_RETURN_URL=null
ZOHO_WEBTOLEAD_ZC_GAD=
```
Use these only if switching back to the Zoho CRM REST API/OAuth integration:
```env
ZOHO_FORWARDING_MODE=api
# Zoho integration is OFF by default — set to true to enable
ZOHO_ENABLED=false
ZOHO_API_DOMAIN=https://www.zohoapis.com
ZOHO_ACCOUNTS_DOMAIN=https://accounts.zoho.com
@ -118,7 +85,7 @@ ZOHO_REFRESH_TOKEN=<from Step 3>
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 13 and verifying your credentials.
### 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
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
### 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)
### Fire-and-Forget Design
@ -204,7 +171,7 @@ Website Contact Form → SQLite (always saved)
After configuration:
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
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

View File

@ -1,7 +1,7 @@
{
"name": "queuenorth-website",
"private": true,
"version": "0.8.3",
"version": "0.8.0",
"type": "module",
"scripts": {
"dev": "concurrently \"vite\" \"node server/index.js\"",

Binary file not shown.

Before

Width:  |  Height:  |  Size: 125 KiB

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

View File

@ -1,7 +1,7 @@
import express from 'express'
import path from 'path'
import { fileURLToPath } from 'url'
import { existsSync, mkdirSync, readFileSync } from 'fs'
import { existsSync, mkdirSync } from 'fs'
import sqlite3 from 'better-sqlite3'
import z from 'zod'
import rateLimit from 'express-rate-limit'
@ -13,27 +13,6 @@ const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
const app = express()
const loadLocalEnv = () => {
const envPath = path.resolve(process.cwd(), '.env')
if (!existsSync(envPath)) return
const envFile = readFileSync(envPath, 'utf8')
for (const line of envFile.split(/\r?\n/)) {
const trimmed = line.trim()
if (!trimmed || trimmed.startsWith('#')) continue
const match = trimmed.match(/^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/)
if (!match) continue
const [, key, rawValue] = match
if (process.env[key] !== undefined) continue
process.env[key] = rawValue.replace(/^(['"])(.*)\1$/, '$2')
}
}
loadLocalEnv()
// Trust first proxy (Docker/reverse proxy) for correct client IP in rate limiting
app.set('trust proxy', 1)
const dbPath = path.join(__dirname, '../db/queuenorth.db')
@ -85,17 +64,15 @@ const apiLimiter = rateLimit({
const isDev = process.env.NODE_ENV === 'development'
const cspDirectives = {
defaultSrc: ["'self'"],
scriptSrc: ["'self'", 'https://crm.zohopublic.com', 'https://www.google.com/recaptcha/', 'https://www.gstatic.com/recaptcha/'],
styleSrc: ["'self'", "'unsafe-inline'", 'https://fonts.googleapis.com'],
scriptSrc: ["'self'", 'https://www.google.com/recaptcha/', 'https://www.gstatic.com/recaptcha/'],
styleSrc: ["'self'", 'https://fonts.googleapis.com'],
fontSrc: ["'self'", 'https://fonts.gstatic.com'],
imgSrc: ["'self'", 'data:'],
connectSrc: isDev
? ["'self'", 'ws://localhost:*', 'https://www.google.com/recaptcha/', 'https://www.gstatic.com/recaptcha/', 'https://recaptcha.google.com/recaptcha/']
: ["'self'", 'https://www.google.com/recaptcha/', 'https://www.gstatic.com/recaptcha/', 'https://recaptcha.google.com/recaptcha/'],
frameSrc: ["'self'", 'https://crm.zoho.com', 'https://www.google.com/recaptcha/', 'https://recaptcha.google.com/recaptcha/'],
connectSrc: isDev ? ["'self'", 'ws://localhost:*'] : ["'self'"],
frameSrc: ["'self'", 'https://www.google.com/recaptcha/', 'https://recaptcha.google.com/recaptcha/'],
objectSrc: ["'none'"],
baseUri: ["'self'"],
formAction: ["'self'", 'https://crm.zoho.com'],
formAction: ["'self'"],
}
// 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_SECRET = process.env.ZOHO_CLIENT_SECRET || null
const ZOHO_REFRESH_TOKEN = process.env.ZOHO_REFRESH_TOKEN || null
const ZOHO_FORWARDING_MODE = (process.env.ZOHO_FORWARDING_MODE || 'api').toLowerCase()
const ZOHO_WEBTOLEAD_ENABLED = process.env.ZOHO_WEBTOLEAD_ENABLED === 'true'
const ZOHO_WEBTOLEAD_URL = process.env.ZOHO_WEBTOLEAD_URL || 'https://crm.zoho.com/crm/WebToLeadForm'
const ZOHO_WEBTOLEAD_XNQSJSDP = process.env.ZOHO_WEBTOLEAD_XNQSJSDP || null
const ZOHO_WEBTOLEAD_XMIWTLD = process.env.ZOHO_WEBTOLEAD_XMIWTLD || null
const ZOHO_WEBTOLEAD_ACTION_TYPE = process.env.ZOHO_WEBTOLEAD_ACTION_TYPE || 'TGVhZHM='
const ZOHO_WEBTOLEAD_RETURN_URL = process.env.ZOHO_WEBTOLEAD_RETURN_URL || 'null'
const ZOHO_WEBTOLEAD_ZC_GAD = process.env.ZOHO_WEBTOLEAD_ZC_GAD || ''
// In-memory access token cache
let zohoAccessToken = null
@ -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) ---
async function forwardSupportToZoho(supportData) {
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})`)
// 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." })
} catch (err) {
@ -785,7 +690,7 @@ app.post('/api/leads', express.json({ limit: '1mb' }), async (req, res) => {
log.warn(`Duplicate lead email: ${sanitized.email}`)
// Still forward to Zoho (non-blocking) for existing leads
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({
error: 'Duplicate lead',
@ -929,16 +834,13 @@ app.get('*', (req, res, next) => {
app.listen(PORT, () => {
log.info(`Server running on http://localhost:${PORT}`)
log.info(`Health check: http://localhost:${PORT}/api/health`)
log.info(`Zoho lead forwarding mode: ${ZOHO_FORWARDING_MODE}`)
if (ZOHO_FORWARDING_MODE === 'webtolead') {
log.info(`Zoho WebToLead forwarding: ${ZOHO_WEBTOLEAD_ENABLED ? 'ENABLED' : 'DISABLED'}`)
} else if (ZOHO_ENABLED) {
log.info(`Zoho CRM API forwarding: ENABLED`)
if (ZOHO_ENABLED) {
log.info(`Zoho CRM forwarding: ENABLED`)
log.info(`Zoho API domain: ${ZOHO_API_DOMAIN}`)
log.info(`Zoho Accounts domain: ${ZOHO_ACCOUNTS_DOMAIN}`)
log.info(`Zoho Cases forwarding: ${process.env.ZOHO_CASES_ENABLED === 'true' ? 'ENABLED' : 'DISABLED'}`)
} else {
log.info('Zoho CRM 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(`Security headers: Helmet enabled with CSP configured`)

View File

@ -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

View File

@ -1,96 +1,16 @@
import { useEffect, useRef, useState } from 'react'
const siteKey = import.meta.env.VITE_RECAPTCHA_SITE_KEY
let recaptchaScriptPromise
const loadRecaptchaScript = () => {
if (window.grecaptcha?.render) return Promise.resolve(window.grecaptcha)
if (recaptchaScriptPromise) return recaptchaScriptPromise
recaptchaScriptPromise = new Promise((resolve, reject) => {
const existingScript = document.querySelector('script[src^="https://www.google.com/recaptcha/api.js"]')
if (existingScript) {
existingScript.addEventListener('load', () => resolve(window.grecaptcha), { once: true })
existingScript.addEventListener('error', reject, { once: true })
return
}
const script = document.createElement('script')
script.src = 'https://www.google.com/recaptcha/api.js?render=explicit'
script.async = true
script.defer = true
script.onload = () => resolve(window.grecaptcha)
script.onerror = reject
document.head.appendChild(script)
})
return recaptchaScriptPromise
}
const RecaptchaPlaceholder = ({ error = '', onVerify, onExpired, resetKey = 0 }) => {
const containerRef = useRef(null)
const widgetIdRef = useRef(null)
const [isReady, setIsReady] = useState(false)
const [loadError, setLoadError] = useState('')
useEffect(() => {
if (!siteKey || !containerRef.current) return undefined
let isMounted = true
loadRecaptchaScript()
.then((grecaptcha) => {
grecaptcha.ready(() => {
if (!isMounted || !containerRef.current || widgetIdRef.current !== null) return
widgetIdRef.current = grecaptcha.render(containerRef.current, {
sitekey: siteKey,
callback: (token) => {
onVerify?.(token)
},
'expired-callback': () => {
onExpired?.()
},
'error-callback': () => {
onExpired?.()
setLoadError('Security verification could not be completed. Please try again.')
},
})
setIsReady(true)
})
})
.catch(() => {
if (isMounted) {
setLoadError('Security verification could not load. Please refresh and try again.')
}
})
return () => {
isMounted = false
}
}, [onExpired, onVerify])
useEffect(() => {
if (widgetIdRef.current === null || !window.grecaptcha?.reset) return
window.grecaptcha.reset(widgetIdRef.current)
}, [resetKey])
if (!siteKey) {
return (
<div className="rounded-md border border-amber-400 bg-amber-50 px-4 py-3">
<p className="text-sm font-semibold text-primary-navy">Security verification is not configured.</p>
const RecaptchaPlaceholder = ({ error = '' }) => (
<div className={`rounded-md border bg-background px-4 py-3 ${error ? 'border-red-500' : 'border-border'}`}>
<div className="flex items-center justify-between gap-4">
<div>
<p className="text-sm font-semibold text-primary-navy">Security verification</p>
<p className="mt-1 text-xs text-soft-text">Google reCAPTCHA placeholder</p>
</div>
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-md border border-border bg-white">
<span className="h-4 w-4 rounded-sm border-2 border-primary-blue" aria-hidden="true" />
</div>
)
}
return (
<div className={`rounded-md border bg-background px-4 py-3 ${error || loadError ? 'border-red-500' : 'border-border'}`}>
<div ref={containerRef} />
{!isReady && !loadError && <p className="text-sm text-soft-text">Loading security verification...</p>}
{(error || loadError) && <p className="mt-2 text-xs text-red-500">{error || loadError}</p>}
</div>
)
}
{error && <p className="mt-2 text-xs text-red-500">{error}</p>}
</div>
)
export default RecaptchaPlaceholder

View File

@ -2,37 +2,9 @@ import { useEffect } from 'react'
import { useLocation } from 'react-router-dom'
export default function ScrollToTop() {
const { pathname, hash } = useLocation()
// Cross-page navigation: scroll to hash or top on route change
const { pathname } = useLocation()
useEffect(() => {
if (hash) {
const el = document.querySelector(hash)
if (el) {
el.scrollIntoView({ behavior: 'smooth' })
return
}
}
window.scrollTo(0, 0)
}, [pathname, hash])
// Same-page: React Router won't re-navigate if URL is already identical,
// so intercept clicks on any link pointing to #contact-form directly.
useEffect(() => {
const handleClick = (e) => {
const anchor = e.target.closest('a')
if (!anchor) return
const href = anchor.getAttribute('href') || ''
if (!href.includes('#contact-form')) return
const el = document.querySelector('#contact-form')
if (!el) return
e.preventDefault()
el.scrollIntoView({ behavior: 'smooth' })
window.history.pushState(null, '', '#contact-form')
}
document.addEventListener('click', handleClick)
return () => document.removeEventListener('click', handleClick)
}, [])
}, [pathname])
return null
}

View File

@ -44,13 +44,13 @@ const Footer = () => {
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-8">
{/* Company Info */}
<div>
<Link to="/" className="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
src="/logo.png"
alt="Queue North Technologies"
className="brand-logo-on-dark h-12 w-auto shrink-0 transition-opacity group-hover:opacity-90"
/>
<span className="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>
<p className="text-navy-light text-sm leading-relaxed mb-5">{companyInfo.tagline}</p>
<a
@ -72,7 +72,7 @@ const Footer = () => {
</div>
</div>
<Link
to="/contact#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"
aria-label="Request a free consultation"
>
@ -141,13 +141,6 @@ const Footer = () => {
{/* Veteran Owned & Operated */}
<div className="border-t border-white/10 mt-10 py-6">
<div className="flex flex-col items-center gap-4 text-center">
<span className="flex h-24 w-20 items-center justify-center rounded-md border border-white/10 bg-white p-1 shadow-sm">
<img
src="/assets/brand/veteran-owned-certified.webp"
alt="SBA Veteran-Owned Certified badge"
className="h-full w-full object-contain"
/>
</span>
<p className="text-primary-cyan text-xs font-semibold uppercase tracking-[0.18em]">
Veteran Owned &amp; Operated
</p>

View File

@ -64,7 +64,7 @@ const Header = () => {
alt="Queue North Technologies"
className="brand-logo-on-dark h-12 md:h-16 w-auto flex-shrink-0"
/>
<span className="font-bold text-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>
</div>
@ -138,7 +138,7 @@ const Header = () => {
{/* CTA Button */}
<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
</Link>
</div>
@ -154,18 +154,16 @@ const Header = () => {
</svg>
</button>
</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>
<SheetTitle>Navigation Menu</SheetTitle>
</VisuallyHidden.Root>
{/* Logo + phone */}
{/* Logo */}
<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" />
</Link>
<Link to="/" onClick={closeMobileMenu} className="font-bold text-sm tracking-tight whitespace-nowrap text-white leading-tight">
Queue North Technologies
<span className="font-bold text-xl tracking-tight">Queue North Technologies</span>
</Link>
</div>
@ -242,7 +240,7 @@ const Header = () => {
{/* CTA */}
<div className="pt-4 border-t border-white/10">
<Link
to="/contact#contact-form"
to="/contact"
onClick={closeMobileMenu}
className="inline-flex items-center justify-center gap-2 w-full rounded-md text-sm font-semibold h-11 px-4 bg-primary-cyan text-primary-navy hover:bg-white transition-colors duration-200"
aria-label="Get a free quote"

View File

@ -129,7 +129,7 @@ const MobileNav = () => {
<div className="mt-auto pt-6">
<Link
to="/contact#contact-form"
to="/contact"
onClick={closeMobileMenu}
className="inline-flex items-center justify-center rounded-md text-sm font-medium h-10 px-4 py-2 w-full bg-primary-navy text-white hover:bg-primary-navy-dark transition-colors"
aria-label="Request a consultation"

View File

@ -10,7 +10,6 @@
html {
scroll-behavior: smooth;
overflow-x: hidden;
}
body {
@ -19,7 +18,6 @@ body {
background-color: #F8FAFC;
line-height: 1.5;
-webkit-font-smoothing: antialiased;
overflow-x: hidden;
}
img {

View File

@ -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) {
const response = await fetch(`${API_BASE_URL}/leads`, {

View File

@ -5,16 +5,13 @@ import { Toaster } from 'sonner'
import { HelmetProvider } from 'react-helmet-async'
import router from './router.jsx'
import App from './App.jsx'
import ErrorBoundary from './components/ErrorBoundary.jsx'
// Wrap the router with providers
const Root = () => (
<StrictMode>
<HelmetProvider>
<ErrorBoundary>
<RouterProvider router={router} />
<Toaster position="top-right" />
</ErrorBoundary>
<RouterProvider router={router} />
<Toaster position="top-right" />
</HelmetProvider>
</StrictMode>
)

View File

@ -1,33 +1,24 @@
import SEO from '@/components/SEO'
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 = [
{ 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',
detail: 'Sales, engineering, build, deployment, and support',
logo: '/assets/brand/8x8-logo-white.svg',
logoAlt: '8x8 Certified Partner logo',
logoClassName: 'h-6 w-14',
containerClass: 'px-2',
},
{
label: 'Cisco Partner',
detail: 'Networking and communications implementation',
logo: '/assets/brand/Cisco-Partner-Logo_trasnp_w.png',
logo: '/assets/brand/cisco-partner-logo-white.svg',
logoAlt: 'Cisco Partner certification logo',
logoClassName: 'h-full w-full scale-[2]',
containerClass: 'p-1 overflow-hidden',
},
{
label: 'Veteran-Owned Certified',
detail: 'Disciplined delivery and direct accountability',
logo: '/assets/brand/veteran-owned-certified-mark.webp',
logoAlt: 'SBA logo for Veteran-Owned Certified',
logoClassName: 'h-full w-full',
containerClass: 'p-1',
logoClassName: 'h-10 w-10',
},
{ 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 = [
@ -107,8 +98,8 @@ const About = () => {
<div className="absolute inset-0 -z-10">
<img
src="/assets/about-image.webp"
alt="Compass on a dark navigation map"
className="h-full w-full object-cover object-[66%_top] md:object-[62%_top]"
alt="Queue North team member reviewing communications infrastructure"
className="h-full w-full object-cover object-center"
/>
<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" />
@ -118,7 +109,7 @@ const About = () => {
<div>
<div className="inline-flex items-center gap-2 rounded-md border border-white/20 bg-white/10 px-3 py-2 text-xs font-semibold uppercase tracking-wide text-primary-cyan">
<Compass className="h-4 w-4" aria-hidden="true" />
About
About Queue North
</div>
<h1 className="mt-6 text-4xl md:text-5xl lg:text-6xl font-bold leading-tight">
Accountable communications infrastructure, built around how your business actually works.
@ -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.
</p>
<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
<ArrowRight className="h-4 w-4" aria-hidden="true" />
</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
</Link>
</div>
@ -144,7 +135,7 @@ const About = () => {
const Icon = point.icon
return (
<div key={point.label} className="flex gap-4 rounded-md border border-white/10 bg-white/5 p-3">
<span className={`flex h-10 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 ? (
<img
src={point.logo}
@ -152,7 +143,7 @@ const About = () => {
className={`${point.logoClassName} object-contain`}
/>
) : (
<Icon className="h-7 w-7" aria-hidden="true" />
<Icon className="h-5 w-5" aria-hidden="true" />
)}
</span>
<div>
@ -271,15 +262,15 @@ const About = () => {
</div>
<div className="mt-7 flex flex-col gap-3 sm:flex-row lg:mt-0 lg:flex-shrink-0">
<Link
to="/contact#contact-form"
className="inline-flex w-full sm:w-auto h-11 items-center justify-center gap-2 rounded-md bg-white px-6 text-sm font-semibold text-primary-navy hover:bg-section-alt transition-colors"
to="/contact"
className="inline-flex h-11 items-center justify-center gap-2 rounded-md bg-white px-6 text-sm font-semibold text-primary-navy hover:bg-section-alt transition-colors"
>
Request Consultation
<ArrowRight className="h-4 w-4" aria-hidden="true" />
</Link>
<a
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
</a>

View File

@ -1,57 +1,49 @@
import SEO from '@/components/SEO'
import { useCallback, useEffect, useState } from 'react'
import { useState } from 'react'
import { toast } from 'sonner'
import { Button } from '@/components/ui/Button'
import { Input } from '@/components/ui/Input'
import { Textarea } from '@/components/ui/Textarea'
import { Select } from '@/components/ui/Select'
import RecaptchaPlaceholder from '@/components/RecaptchaPlaceholder'
import { ArrowRight } from 'lucide-react'
import { submitLead } from '@/lib/api'
const isRecaptchaConfigured = Boolean(import.meta.env.VITE_RECAPTCHA_SITE_KEY)
import { useDebounce } from '@/hooks/useDebounce'
import { ArrowRight } from 'lucide-react'
const Contact = () => {
const [formState, setFormState] = useState({
'Last Name': '',
Company: '',
Email: '',
Phone: '',
'Zip Code': '',
Description: '',
company: '',
name: '',
email: '',
phone: '',
zip: '',
message: '',
service_interest: '',
recaptcha_token: '',
company_website: '',
})
const [errors, setErrors] = useState({
'Last Name': '',
Company: '',
Email: '',
'Zip Code': '',
Description: '',
company: '',
name: '',
email: '',
zip: '',
message: '',
recaptcha_token: '',
})
const [debouncedErrors, setDebouncedErrors] = useState(errors)
const debouncedErrors = useDebounce(errors, 300)
const [isSubmitting, setIsSubmitting] = useState(false)
const [recaptchaToken, setRecaptchaToken] = useState('')
const [recaptchaResetKey, setRecaptchaResetKey] = useState(0)
useEffect(() => {
const t = setTimeout(() => setDebouncedErrors(errors), 300)
return () => clearTimeout(t)
}, [errors])
const validateForm = () => {
const newErrors = { 'Last Name': '', Company: '', Email: '', 'Zip Code': '', Description: '', recaptcha_token: '' }
if (!formState.Company.trim()) newErrors.Company = 'Company name is required'
if (!formState['Last Name'].trim()) newErrors['Last Name'] = 'Name is required'
if (!formState['Zip Code'].trim()) newErrors['Zip Code'] = 'ZIP code is required'
if (!formState.Description.trim()) newErrors.Description = 'Message is required'
const newErrors = { company: '', name: '', email: '', zip: '', message: '', recaptcha_token: '' }
if (!formState.company.trim()) newErrors.company = 'Company name is required'
if (!formState.name.trim()) newErrors.name = 'Name is required'
if (!formState.zip.trim()) newErrors.zip = 'ZIP code is required'
if (!formState.message.trim()) newErrors.message = 'Message is required'
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
if (!formState.Email.trim()) {
newErrors.Email = 'Email is required'
} else if (!emailRegex.test(formState.Email)) {
newErrors.Email = 'Please enter a valid email address'
}
if (isRecaptchaConfigured && !recaptchaToken) {
newErrors.recaptcha_token = 'Security verification is required'
if (!formState.email.trim()) {
newErrors.email = 'Email is required'
} else if (!emailRegex.test(formState.email)) {
newErrors.email = 'Please enter a valid email address'
}
const hasErrors = Object.values(newErrors).some(error => error !== '')
setErrors(newErrors)
@ -62,58 +54,28 @@ const Contact = () => {
return true
}
const resetForm = () => {
setFormState({
'Last Name': '',
Company: '',
Email: '',
Phone: '',
'Zip Code': '',
Description: '',
company_website: '',
})
setErrors({ 'Last Name': '', Company: '', Email: '', 'Zip Code': '', Description: '', recaptcha_token: '' })
setRecaptchaToken('')
setRecaptchaResetKey(prev => prev + 1)
}
const 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) => {
const handleSubmit = (e) => {
e.preventDefault()
if (!validateForm()) return
handleSubmitForm()
}
const handleSubmitForm = async () => {
setIsSubmitting(true)
try {
const result = await submitLead({
company: formState.Company,
name: formState['Last Name'],
email: formState.Email,
phone: formState.Phone,
zip: formState['Zip Code'],
message: formState.Description,
recaptcha_token: recaptchaToken,
company_website: formState.company_website,
})
toast.success(result.message || "Thanks! We'll be in touch shortly.")
resetForm()
} catch (err) {
if (err.fields) {
setErrors(mapApiErrors(err.fields))
if (err.fields.recaptcha_token) {
setRecaptchaToken('')
setRecaptchaResetKey(prev => prev + 1)
}
await submitLead(formState)
toast.success("Thanks! We'll be in touch shortly.")
setFormState({ company: '', name: '', email: '', phone: '', zip: '', message: '', service_interest: '', recaptcha_token: '', company_website: '' })
setErrors({ company: '', name: '', email: '', zip: '', message: '', recaptcha_token: '' })
} catch (error) {
if (error.response?.status === 409) {
toast.success("We already have your submission! We'll be in touch.")
} else if (error.response?.status === 400 && error.fields) {
setErrors(prev => ({ ...prev, ...error.fields }))
toast.error('Please fix the errors in the form')
} else {
toast.error(error.message || 'Failed to submit form. Please try again.')
}
toast.error(err.message || 'Failed to submit lead')
} finally {
setIsSubmitting(false)
}
@ -125,16 +87,6 @@ const Contact = () => {
if (errors[name]) setErrors(prev => ({ ...prev, [name]: '' }))
}
const handleRecaptchaVerify = useCallback((token) => {
setRecaptchaToken(token)
setErrors(prev => ({ ...prev, recaptcha_token: '' }))
}, [])
const handleRecaptchaExpired = useCallback(() => {
setRecaptchaToken('')
setErrors(prev => ({ ...prev, recaptcha_token: 'Security verification expired. Please try again.' }))
}, [])
const contactDetails = [
{
label: 'Phone',
@ -183,7 +135,6 @@ const Contact = () => {
const trustPoints = [
<div key="8x8"><span className="font-numeric">8x8</span> Certified Partner with proven expertise</div>,
'Cisco Certified Partner',
<div key="veteran"><span className="font-numeric">25+</span> years of experience</div>,
'SMB to Enterprise solutions',
'No vendor bias — we recommend what fits',
@ -210,7 +161,7 @@ const Contact = () => {
</div>
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="inline-flex items-center gap-2 rounded-md border border-white/20 bg-white/10 px-3 py-2 text-xs font-semibold uppercase tracking-wide text-primary-cyan mb-6">
Contact
Contact Queue North
</div>
<h1 className="text-4xl md:text-5xl font-bold text-white mb-4">Let's Talk</h1>
<p className="text-xl text-white/70 max-w-2xl">
@ -250,7 +201,7 @@ const Contact = () => {
<div className="border-t border-white/10" />
<div>
<p className="text-white/50 text-xs uppercase tracking-wider mb-5">Why Queue North Technologies</p>
<p className="text-white/50 text-xs uppercase tracking-wider mb-5">Why Queue North</p>
<ul className="space-y-4">
{trustPoints.map((point, i) => (
<li key={i} className="flex items-start gap-3 text-white/80 text-sm leading-relaxed">
@ -269,137 +220,150 @@ const Contact = () => {
<h2 className="text-2xl font-bold text-primary-navy mb-1">Send Us a Message</h2>
<p className="text-soft-text text-sm mb-8">We typically respond within one business day.</p>
<form
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' }}
/>
<form onSubmit={handleSubmit} noValidate className={`space-y-5 ${isSubmitting ? 'opacity-70 pointer-events-none' : ''}`}>
{/* Company */}
<div>
<label htmlFor="Company" className="block text-sm font-medium text-text mb-1.5">
<label htmlFor="company" className="block text-sm font-medium text-text mb-1.5">
Company Name <span className="text-red-500">*</span>
</label>
<Input
type="text"
id="Company"
name="Company"
value={formState.Company}
id="company"
name="company"
value={formState.company}
onChange={handleChange}
required
placeholder="Your company name"
className={debouncedErrors.Company ? 'border-red-500 focus-visible:ring-red-500' : ''}
className={debouncedErrors.company ? 'border-red-500 focus-visible:ring-red-500' : ''}
/>
{debouncedErrors.Company && <p className="text-xs text-red-500 mt-1">{debouncedErrors.Company}</p>}
{debouncedErrors.company && <p className="text-xs text-red-500 mt-1">{debouncedErrors.company}</p>}
</div>
{/* Name + Email */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label htmlFor="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>
</label>
<Input
type="text"
id="Last_Name"
name="Last Name"
value={formState['Last Name']}
id="name"
name="name"
value={formState.name}
onChange={handleChange}
required
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>
<label htmlFor="Email" className="block text-sm font-medium text-text mb-1.5">
<label htmlFor="email" className="block text-sm font-medium text-text mb-1.5">
Email <span className="text-red-500">*</span>
</label>
<Input
type="email"
id="Email"
name="Email"
value={formState.Email}
id="email"
name="email"
value={formState.email}
onChange={handleChange}
required
placeholder="you@company.com"
className={debouncedErrors.Email ? 'border-red-500 focus-visible:ring-red-500' : ''}
className={debouncedErrors.email ? 'border-red-500 focus-visible:ring-red-500' : ''}
/>
{debouncedErrors.Email && <p className="text-xs text-red-500 mt-1">{debouncedErrors.Email}</p>}
{debouncedErrors.email && <p className="text-xs text-red-500 mt-1">{debouncedErrors.email}</p>}
</div>
</div>
{/* Phone + ZIP */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label htmlFor="Phone" className="block text-sm font-medium text-text mb-1.5">
<label htmlFor="phone" className="block text-sm font-medium text-text mb-1.5">
Phone <span className="text-soft-text font-normal">(optional)</span>
</label>
<Input
type="tel"
id="Phone"
name="Phone"
value={formState.Phone}
id="phone"
name="phone"
value={formState.phone}
onChange={handleChange}
placeholder="(555) 123-4567"
/>
</div>
<div>
<label htmlFor="Zip_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>
</label>
<Input
type="text"
id="Zip_Code"
name="Zip Code"
value={formState['Zip Code']}
id="zip"
name="zip"
value={formState.zip}
onChange={handleChange}
required
autoComplete="postal-code"
inputMode="numeric"
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>
{/* Service Interest */}
<div>
<label htmlFor="service_interest" className="block text-sm font-medium text-text mb-1.5">
Service Interest <span className="text-soft-text font-normal">(optional)</span>
</label>
<Select
id="service_interest"
name="service_interest"
value={formState.service_interest}
onChange={handleChange}
>
<option value="">Select a service...</option>
<option value="unified-communications">Unified Communications</option>
<option value="contact-center">Contact Center</option>
<option value="managed-support">Managed Support</option>
<option value="consulting-training">Consulting & Training</option>
<option value="infrastructure-cabling">Infrastructure Cabling</option>
<option value="wireless-access">Wireless Access</option>
<option value="local-networking">Local Networking</option>
<option value="other">Other</option>
</Select>
</div>
{/* Message */}
<div>
<label htmlFor="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>
</label>
<Textarea
id="Description"
name="Description"
value={formState.Description}
id="message"
name="message"
value={formState.message}
onChange={handleChange}
required
placeholder="Tell us about your needs..."
className={`w-full rounded-md border bg-background px-3 py-2 text-sm ring-offset-[#F8FAFC] placeholder:text-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-navy focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 ${debouncedErrors.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}
/>
{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>
<RecaptchaPlaceholder
error={debouncedErrors.recaptcha_token}
onVerify={handleRecaptchaVerify}
onExpired={handleRecaptchaExpired}
resetKey={recaptchaResetKey}
/>
{/* Honeypot */}
<div className="absolute opacity-0 h-0 overflow-hidden" aria-hidden="true">
<input
type="text"
name="company_website"
tabIndex="-1"
autoComplete="off"
value={formState.company_website}
onChange={handleChange}
/>
</div>
<RecaptchaPlaceholder error={debouncedErrors.recaptcha_token} />
<Button type="submit" className="w-full h-11" disabled={isSubmitting}>
{isSubmitting ? (

View File

@ -57,7 +57,7 @@ const Home = () => {
'@type': 'ImageObject',
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: {
'@type': 'PostalAddress',
streetAddress: '7901 4th St N',
@ -153,11 +153,11 @@ const Home = () => {
Business phone, contact center, network, and IT support built around one accountable implementation partner.
</p>
<div className="mt-8 flex flex-col sm:flex-row gap-3">
<Link to="/contact#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
<ArrowRight className="h-4 w-4" aria-hidden="true" />
</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
</Link>
</div>
@ -168,42 +168,38 @@ const Home = () => {
{/* Partner Proof */}
<section className="bg-white border-b border-border py-6">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="grid grid-cols-2 gap-x-4 gap-y-6 lg:grid-cols-4">
<div className="flex flex-col items-center gap-2 lg:flex-row lg:gap-3 lg:justify-start">
<span className="flex h-16 w-20 shrink-0 items-center justify-center rounded-md border border-border bg-white p-2">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
<div className="flex items-center justify-center gap-3 sm:justify-start">
<span className="flex h-14 w-24 shrink-0 items-center justify-center rounded-md border border-border bg-white px-3">
<img
src="/assets/brand/8x8-logo-dark-gray.png"
alt="8x8 Certified Partner logo"
className="h-full w-full object-contain"
className="h-9 w-full object-contain"
/>
</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 className="flex flex-col items-center gap-2 lg:flex-row lg:gap-3 lg:justify-start">
<span className="flex h-16 w-20 shrink-0 items-center justify-center rounded-md border border-border bg-white p-1 overflow-hidden">
<div className="flex items-center justify-center gap-3 sm:justify-start">
<span className="flex h-14 w-24 shrink-0 items-center justify-center rounded-md border border-border bg-white px-3">
<img
src="/assets/brand/cisco-partner-logo-midnight.svg"
alt="Cisco Partner certification logo"
className="h-full w-full object-contain scale-[1.5]"
className="h-12 w-full object-contain"
/>
</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 className="flex flex-col items-center gap-2 lg:flex-row lg:gap-3 lg:justify-start">
<span className="flex h-16 w-20 shrink-0 items-center justify-center rounded-md border border-border bg-white p-1">
<img
src="/assets/brand/veteran-owned-certified-mark.webp"
alt="SBA logo for Veteran-Owned Certified"
className="h-full w-full object-contain"
/>
<div className="flex items-center justify-center gap-3 sm:justify-start">
<span className="flex h-14 w-24 shrink-0 items-center justify-center rounded-md border border-border bg-white px-3 text-primary-navy">
<ShieldCheck className="h-7 w-7 text-primary-blue" aria-hidden="true" />
</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 className="flex flex-col items-center gap-2 lg:flex-row lg:gap-3 lg:justify-start">
<span className="font-numeric flex h-16 w-20 shrink-0 items-center justify-center rounded-md border border-border bg-white text-2xl font-semibold text-primary-navy">
<div className="flex items-center justify-center gap-3 sm:justify-start">
<span className="font-numeric flex h-14 w-24 shrink-0 items-center justify-center rounded-md border border-border bg-white px-3 text-2xl font-semibold text-primary-navy">
25+
</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>
@ -263,7 +259,7 @@ const Home = () => {
Four concrete differentiators that set us apart
</p>
<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
</Link>
</div>
@ -358,35 +354,35 @@ const Home = () => {
What we'll help you do
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-8">
<div className="flex items-start gap-3 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">
<MapPin className="w-5 h-5 text-primary-navy" />
</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
</p>
</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">
<Users className="w-5 h-5 text-teal-600" />
</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
</p>
</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">
<Network className="w-5 h-5 text-teal-600" />
</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
</p>
</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">
<ShieldCheck className="w-5 h-5 text-teal-600" />
</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
</p>
</div>
@ -394,7 +390,7 @@ const Home = () => {
<p className="text-lg text-soft-text mb-8 max-w-2xl mx-auto">
Share a few details and we'll provide clear direction.
</p>
<Link to="/contact#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
</Link>
</div>

View File

@ -50,7 +50,7 @@ const Industries = () => {
Communications and infrastructure solutions shaped by the compliance, workflow, and connectivity demands of your specific environment.
</p>
<Link
to="/contact#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"
>
Talk to a Specialist
@ -120,15 +120,15 @@ const Industries = () => {
</div>
<div className="mt-7 flex flex-col gap-3 sm:flex-row lg:mt-0 lg:flex-shrink-0">
<Link
to="/contact#contact-form"
className="inline-flex w-full sm:w-auto h-11 items-center justify-center gap-2 rounded-md bg-white px-6 text-sm font-semibold text-primary-navy hover:bg-section-alt transition-colors"
to="/contact"
className="inline-flex h-11 items-center justify-center gap-2 rounded-md bg-white px-6 text-sm font-semibold text-primary-navy hover:bg-section-alt transition-colors"
>
Talk to a Specialist
<ArrowRight className="h-4 w-4" aria-hidden="true" />
</Link>
<a
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
</a>

View File

@ -119,7 +119,7 @@ const IndustryDetail = () => {
<p className="text-soft-text">{industry.name}</p>
</div>
<div className="pt-4 border-t border-border">
<Link to="/contact#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
<ArrowRight className="h-4 w-4" aria-hidden="true" />
</Link>

View File

@ -1,168 +1,41 @@
import { Helmet } from 'react-helmet-async'
import { Link } from 'react-router-dom'
import { ArrowRight, Building2, Compass, Headphones, Home, LifeBuoy, Network, ShieldCheck } from 'lucide-react'
const recoveryLinks = [
{
title: 'Services',
description: 'Communications, support, cabling, wireless, and networking.',
href: '/services',
icon: Network,
accent: 'text-primary-cyan',
},
{
title: 'Industries',
description: 'Solutions by business environment.',
href: '/industries',
icon: Building2,
accent: 'text-teal-300',
},
{
title: 'Support',
description: 'Help for live systems, users, and endpoints.',
href: '/support',
icon: LifeBuoy,
accent: 'text-amber-300',
},
]
const signalPoints = [
{ label: 'Route check', value: 'Active', icon: Compass },
{ label: 'Partner desk', value: 'Online', icon: Headphones },
{ label: 'Uptime focus', value: 'Locked', icon: ShieldCheck },
]
import { ArrowRight, Compass } from 'lucide-react'
export default function NotFound() {
return (
<>
<Helmet>
<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="robots" content="noindex, follow" />
<meta name="description" content="The page you're looking for doesn't exist." />
</Helmet>
<section className="relative isolate overflow-hidden bg-primary-navy text-white">
<section className="relative isolate flex min-h-[70vh] items-center overflow-hidden bg-primary-navy py-16 text-white">
<div className="absolute inset-0 -z-10">
<img
src="/assets/about-image.webp"
src="/assets/hero-tech.webp"
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-[radial-gradient(circle_at_72%_22%,rgba(34,211,238,0.28),transparent_28%),radial-gradient(circle_at_18%_74%,rgba(245,158,11,0.16),transparent_24%),linear-gradient(115deg,#071A2A_0%,rgba(11,42,60,0.96)_46%,rgba(7,26,42,0.74)_100%)]" />
<div className="absolute inset-0 opacity-[0.13] [background-image:linear-gradient(rgba(255,255,255,0.12)_1px,transparent_1px),linear-gradient(90deg,rgba(255,255,255,0.12)_1px,transparent_1px)] [background-size:48px_48px]" />
<div className="absolute left-0 right-0 top-0 h-px bg-gradient-to-r from-transparent via-primary-cyan/70 to-transparent" />
<div 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>
<div className="mx-auto grid min-h-[calc(100vh-4rem)] max-w-7xl grid-cols-[minmax(0,1fr)] items-center gap-12 px-4 py-16 sm:px-6 md:py-20 lg:grid-cols-[minmax(0,1.03fr)_minmax(0,0.97fr)] lg:px-8 lg:py-24">
<div className="min-w-0 w-full max-w-[calc(100vw-2rem)] sm:max-w-3xl">
<div className="inline-flex items-center gap-2 rounded-md border border-white/[0.15] bg-white/10 px-3 py-2 text-xs font-semibold uppercase tracking-wide text-primary-cyan shadow-[0_0_40px_rgba(34,211,238,0.18)] backdrop-blur">
<Compass className="h-4 w-4" aria-hidden="true" />
Route recalibration
</div>
<p className="mt-8 font-numeric text-8xl leading-none text-white sm:text-9xl md:text-[10rem]">
404
</p>
<h1 className="mt-4 max-w-2xl text-3xl font-bold leading-tight text-white sm:text-5xl lg:text-6xl">
This route dropped <span className="block sm:inline">off the network.</span>
</h1>
<p className="mt-6 max-w-2xl text-base leading-relaxed text-white/75 sm:text-lg md:text-xl">
That page is gone or renamed. We'll get you back to a live connection.
</p>
<div className="mt-8 flex flex-col gap-3 sm:flex-row">
<Link to="/" className="inline-flex h-11 w-full items-center justify-center gap-2 rounded-md bg-white px-5 text-sm font-semibold text-primary-navy transition-colors hover:bg-section-alt sm:w-auto" aria-label="Return to the Queue North home page">
Back to Home
<Home className="h-4 w-4" aria-hidden="true" />
</Link>
<Link to="/contact#contact-form" className="inline-flex h-11 w-full items-center justify-center gap-2 rounded-md border border-white/35 px-5 text-sm font-semibold text-white transition-colors hover:bg-white/10 sm:w-auto" aria-label="Contact Queue North Technologies">
Talk to Us
<ArrowRight className="h-4 w-4" aria-hidden="true" />
</Link>
</div>
<div className="mt-10 grid gap-3 sm:grid-cols-3">
{signalPoints.map((point) => {
const Icon = point.icon
return (
<div key={point.label} className="rounded-md border border-white/10 bg-white/[0.06] p-4 backdrop-blur">
<div className="flex items-center gap-3">
<span className="flex h-9 w-9 items-center justify-center rounded-md bg-white/10 text-primary-cyan">
<Icon className="h-4 w-4" aria-hidden="true" />
</span>
<div>
<p className="text-xs uppercase tracking-wide text-white/50">{point.label}</p>
<p className="text-sm font-semibold text-white">{point.value}</p>
</div>
</div>
</div>
)
})}
</div>
<div className="mx-auto max-w-3xl px-4 text-center sm:px-6 lg:px-8">
<div className="mx-auto mb-6 flex h-12 w-12 items-center justify-center rounded-md border border-white/15 bg-white/10 text-primary-cyan">
<Compass className="h-6 w-6" aria-hidden="true" />
</div>
<div className="relative min-w-0 w-full max-w-[calc(100vw-2rem)] lg:max-w-none">
<div className="absolute inset-0 rounded-md bg-primary-cyan/10 blur-3xl sm:-inset-4 sm:rounded-[2rem]" aria-hidden="true" />
<div className="relative overflow-hidden rounded-md border border-white/[0.12] bg-white/[0.07] shadow-2xl shadow-black/30 backdrop-blur-xl">
<div className="border-b border-white/10 px-5 py-4">
<div className="flex items-center justify-between gap-4">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-primary-cyan">Recovery paths</p>
<p className="mt-1 text-sm text-white/60">Choose the cleanest next hop.</p>
</div>
<div className="flex h-10 w-10 items-center justify-center rounded-md bg-primary-cyan text-primary-navy shadow-[0_0_28px_rgba(34,211,238,0.45)]">
<Compass className="h-5 w-5" aria-hidden="true" />
</div>
</div>
</div>
<div className="relative p-5 sm:p-6">
<div className="pointer-events-none absolute inset-x-6 top-1/2 h-px bg-gradient-to-r from-transparent via-primary-cyan/50 to-transparent" aria-hidden="true" />
<div className="pointer-events-none absolute left-1/2 top-6 bottom-6 w-px bg-gradient-to-b from-transparent via-white/20 to-transparent" aria-hidden="true" />
<div className="mb-5 rounded-md border border-primary-cyan/25 bg-primary-navy/80 p-5 shadow-[inset_0_1px_0_rgba(255,255,255,0.08)]">
<div className="flex items-center gap-4">
<div className="relative flex h-16 w-16 shrink-0 items-center justify-center overflow-hidden rounded-md border border-primary-cyan/35 bg-primary-cyan/10">
<span className="absolute h-9 w-9 animate-ping rounded-full bg-primary-cyan/20" aria-hidden="true" />
<Compass className="relative h-7 w-7 text-primary-cyan" aria-hidden="true" />
</div>
<div className="min-w-0">
<p className="text-sm font-semibold text-white">Queue North core</p>
<p className="mt-1 text-sm leading-relaxed text-white/60 break-words">Systems and support are still fully reachable.</p>
</div>
</div>
</div>
<div className="grid gap-3">
{recoveryLinks.map((item) => {
const Icon = item.icon
return (
<Link
key={item.title}
to={item.href}
className="group rounded-md border border-white/10 bg-white/[0.06] p-4 transition-all hover:-translate-y-0.5 hover:border-primary-cyan/45 hover:bg-white/[0.1] hover:no-underline"
>
<div className="flex items-start gap-4">
<span className="flex h-11 w-11 shrink-0 items-center justify-center rounded-md bg-white/10">
<Icon className={`h-5 w-5 ${item.accent}`} aria-hidden="true" />
</span>
<div className="min-w-0 flex-1">
<div className="flex items-center justify-between gap-3">
<h2 className="text-base font-semibold text-white">{item.title}</h2>
<ArrowRight className="h-4 w-4 shrink-0 text-white/35 transition-transform group-hover:translate-x-1 group-hover:text-primary-cyan" aria-hidden="true" />
</div>
<p className="mt-1 text-sm leading-relaxed text-white/60 break-words">{item.description}</p>
</div>
</div>
</Link>
)
})}
</div>
</div>
</div>
<p className="text-sm font-semibold uppercase tracking-[0.18em] text-primary-cyan">404</p>
<h1 className="mt-3 text-4xl font-bold md:text-5xl">This page lost direction.</h1>
<p className="mx-auto mt-5 max-w-xl text-lg text-white/75">
The page you're looking for does not exist, but we can get you back to the right place.
</p>
<div className="mt-8 flex flex-col sm:flex-row gap-3 justify-center">
<Link to="/" className="inline-flex h-11 items-center justify-center gap-2 rounded-md bg-white px-6 text-sm font-semibold text-primary-navy hover:bg-section-alt transition-colors">
Back to Home
<ArrowRight className="h-4 w-4" aria-hidden="true" />
</Link>
<Link to="/contact" className="inline-flex h-11 items-center justify-center rounded-md border border-white/35 px-6 text-sm font-semibold text-white hover:bg-white/10 transition-colors">
Contact Us
</Link>
</div>
</div>
</section>

View File

@ -84,7 +84,7 @@ const ServiceDetail = () => {
<h1 className="text-4xl md:text-5xl font-bold mb-6">{service.name}</h1>
<p className="text-xl text-white/75 max-w-3xl leading-relaxed">{service.shortDesc}</p>
<div className="mt-8">
<Link to="/contact#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
<ArrowRight className="h-4 w-4" aria-hidden="true" />
</Link>
@ -149,7 +149,7 @@ const ServiceDetail = () => {
<p className="text-soft-text">{service.name}</p>
</div>
<div className="pt-4 border-t border-border">
<Link to="/contact#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
</Link>
</div>

View File

@ -96,15 +96,15 @@ const Services = () => {
</p>
<div className="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"
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"
>
Get a Free Quote
<ArrowRight className="h-4 w-4" aria-hidden="true" />
</Link>
<Link
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
</Link>
@ -135,7 +135,7 @@ const Services = () => {
</p>
</div>
<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"
>
Talk through options
@ -188,15 +188,15 @@ const Services = () => {
</div>
<div className="mt-7 flex flex-col gap-3 sm:flex-row lg:mt-0 lg:flex-shrink-0">
<Link
to="/contact#contact-form"
className="inline-flex w-full sm:w-auto h-11 items-center justify-center gap-2 rounded-md bg-white px-6 text-sm font-semibold text-primary-navy hover:bg-section-alt transition-colors"
to="/contact"
className="inline-flex h-11 items-center justify-center gap-2 rounded-md bg-white px-6 text-sm font-semibold text-primary-navy hover:bg-section-alt transition-colors"
>
Schedule a Consultation
<ArrowRight className="h-4 w-4" aria-hidden="true" />
</Link>
<a
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
</a>

View File

@ -51,7 +51,7 @@ const Support = () => {
<div>
<div className="inline-flex items-center gap-2 rounded-md border border-white/20 bg-white/10 px-3 py-2 text-xs font-semibold uppercase tracking-wide text-primary-cyan">
<LifeBuoy className="h-4 w-4" aria-hidden="true" />
Support
Queue North Support
</div>
<h1 className="mt-6 text-4xl md:text-5xl lg:text-6xl font-bold leading-tight">
Get help without getting handed off.
@ -64,7 +64,7 @@ const Support = () => {
href={portalLinks[0].href}
target="_blank"
rel="noopener noreferrer"
className="inline-flex 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
<ArrowRight className="h-4 w-4" aria-hidden="true" />
@ -73,7 +73,7 @@ const Support = () => {
href={portalLinks[1].href}
target="_blank"
rel="noopener noreferrer"
className="inline-flex 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
<ExternalLink className="h-4 w-4" aria-hidden="true" />