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

4
.gitignore vendored
View File

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

View File

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

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

View File

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

View File

@ -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\"",

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 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`)

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 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" />
recaptchaScriptPromise = new Promise((resolve, reject) => {
const existingScript = document.querySelector('script[src^="https://www.google.com/recaptcha/api.js"]')
if (existingScript) {
existingScript.addEventListener('load', () => resolve(window.grecaptcha), { once: true })
existingScript.addEventListener('error', reject, { once: true })
return
}
const script = document.createElement('script')
script.src = 'https://www.google.com/recaptcha/api.js?render=explicit'
script.async = true
script.defer = true
script.onload = () => resolve(window.grecaptcha)
script.onerror = reject
document.head.appendChild(script)
})
return recaptchaScriptPromise
}
const RecaptchaPlaceholder = ({ error = '', onVerify, onExpired, resetKey = 0 }) => {
const containerRef = useRef(null)
const widgetIdRef = useRef(null)
const [isReady, setIsReady] = useState(false)
const [loadError, setLoadError] = useState('')
useEffect(() => {
if (!siteKey || !containerRef.current) return undefined
let isMounted = true
loadRecaptchaScript()
.then((grecaptcha) => {
grecaptcha.ready(() => {
if (!isMounted || !containerRef.current || widgetIdRef.current !== null) return
widgetIdRef.current = grecaptcha.render(containerRef.current, {
sitekey: siteKey,
callback: (token) => {
onVerify?.(token)
},
'expired-callback': () => {
onExpired?.()
},
'error-callback': () => {
onExpired?.()
setLoadError('Security verification could not be completed. Please try again.')
},
})
setIsReady(true)
})
})
.catch(() => {
if (isMounted) {
setLoadError('Security verification could not load. Please refresh and try again.')
}
})
return () => {
isMounted = false
}
}, [onExpired, onVerify])
useEffect(() => {
if (widgetIdRef.current === null || !window.grecaptcha?.reset) return
window.grecaptcha.reset(widgetIdRef.current)
}, [resetKey])
if (!siteKey) {
return (
<div className="rounded-md border border-amber-400 bg-amber-50 px-4 py-3">
<p className="text-sm font-semibold text-primary-navy">Security verification is not configured.</p>
</div> </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> </div>
) {error && <p className="mt-2 text-xs text-red-500">{error}</p>}
} </div>
)
export default RecaptchaPlaceholder export default RecaptchaPlaceholder

View File

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

View File

@ -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 &amp; Operated Veteran Owned &amp; Operated
</p> </p>

View File

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

View File

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

View File

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

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) { export async function submitLead(data) {
const response = await fetch(`${API_BASE_URL}/leads`, { 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 { 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>
) )

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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>
<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> </div>
<p className="text-sm font-semibold uppercase tracking-[0.18em] text-primary-cyan">404</p>
<div className="relative min-w-0 w-full max-w-[calc(100vw-2rem)] lg:max-w-none"> <h1 className="mt-3 text-4xl font-bold md:text-5xl">This page lost direction.</h1>
<div className="absolute inset-0 rounded-md bg-primary-cyan/10 blur-3xl sm:-inset-4 sm:rounded-[2rem]" aria-hidden="true" /> <p className="mx-auto mt-5 max-w-xl text-lg text-white/75">
<div className="relative overflow-hidden rounded-md border border-white/[0.12] bg-white/[0.07] shadow-2xl shadow-black/30 backdrop-blur-xl"> The page you're looking for does not exist, but we can get you back to the right place.
<div className="border-b border-white/10 px-5 py-4"> </p>
<div className="flex items-center justify-between gap-4"> <div className="mt-8 flex flex-col sm:flex-row gap-3 justify-center">
<div> <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">
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-primary-cyan">Recovery paths</p> Back to Home
<p className="mt-1 text-sm text-white/60">Choose the cleanest next hop.</p> <ArrowRight className="h-4 w-4" aria-hidden="true" />
</div> </Link>
<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)]"> <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">
<Compass className="h-5 w-5" aria-hidden="true" /> Contact Us
</div> </Link>
</div>
</div>
<div className="relative p-5 sm:p-6">
<div className="pointer-events-none absolute inset-x-6 top-1/2 h-px bg-gradient-to-r from-transparent via-primary-cyan/50 to-transparent" aria-hidden="true" />
<div className="pointer-events-none absolute left-1/2 top-6 bottom-6 w-px bg-gradient-to-b from-transparent via-white/20 to-transparent" aria-hidden="true" />
<div className="mb-5 rounded-md border border-primary-cyan/25 bg-primary-navy/80 p-5 shadow-[inset_0_1px_0_rgba(255,255,255,0.08)]">
<div className="flex items-center gap-4">
<div className="relative flex h-16 w-16 shrink-0 items-center justify-center overflow-hidden rounded-md border border-primary-cyan/35 bg-primary-cyan/10">
<span className="absolute h-9 w-9 animate-ping rounded-full bg-primary-cyan/20" aria-hidden="true" />
<Compass className="relative h-7 w-7 text-primary-cyan" aria-hidden="true" />
</div>
<div className="min-w-0">
<p className="text-sm font-semibold text-white">Queue North core</p>
<p className="mt-1 text-sm leading-relaxed text-white/60 break-words">Systems and support are still fully reachable.</p>
</div>
</div>
</div>
<div className="grid gap-3">
{recoveryLinks.map((item) => {
const Icon = item.icon
return (
<Link
key={item.title}
to={item.href}
className="group rounded-md border border-white/10 bg-white/[0.06] p-4 transition-all hover:-translate-y-0.5 hover:border-primary-cyan/45 hover:bg-white/[0.1] hover:no-underline"
>
<div className="flex items-start gap-4">
<span className="flex h-11 w-11 shrink-0 items-center justify-center rounded-md bg-white/10">
<Icon className={`h-5 w-5 ${item.accent}`} aria-hidden="true" />
</span>
<div className="min-w-0 flex-1">
<div className="flex items-center justify-between gap-3">
<h2 className="text-base font-semibold text-white">{item.title}</h2>
<ArrowRight className="h-4 w-4 shrink-0 text-white/35 transition-transform group-hover:translate-x-1 group-hover:text-primary-cyan" aria-hidden="true" />
</div>
<p className="mt-1 text-sm leading-relaxed text-white/60 break-words">{item.description}</p>
</div>
</div>
</Link>
)
})}
</div>
</div>
</div>
</div> </div>
</div> </div>
</section> </section>

View File

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

View File

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

View File

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