2026-05-18 13:55:06 -05:00
import SEO from '@/components/SEO'
feat(seo): publish privacy policy, remove street address, prerender all routes (batch 0.9.3)
Client directive (Levi Halford, 2026-08-01) ahead of Google/Meta lead forms.
Privacy policy:
- Publish approved policy verbatim at /privacy-policy (src/data/privacyPolicy.js
is the single source of truth; 292/292 source lines verified present)
- Privacy Policy link in the footer of every page
- Effective/Last Updated 2026-07-31, privacy@queuenorth.com as mailto
Remove St. Petersburg street address from every surface named in the brief:
footer, contact page, schema markup, SEO metadata, Google Maps links. Collapse
ProfessionalService + Organization schema into a single Organization with
areaServed: United States; drop geo coordinates, priceRange, openingHours.
Add the approved US-coverage sentence to About. No replacement address.
Crawler visibility (the site previously served 0 bytes of body HTML without JS):
- Prerender all 19 routes at build time via src/entry-server.jsx + scripts/prerender.js
- Hoist title/meta/canonical/JSON-LD into <head>; renderToString does not do this
and react-helmet-async's context is empty under React 19
- Serve prerendered HTML; return a real 404 for unknown paths instead of 200
- Hydrate instead of discarding the prerendered markup
SEO/perf:
- Titles <=60 and descriptions <=160 chars across all pages
- Add BreadcrumbList to interior pages, WebSite to home
- Generate sitemap.xml from the route list with git-derived lastmod
- 301 duplicate URL forms (trailing slash, //, /index.html), preserving query
- Immutable caching for content-hashed assets; no-cache for HTML
- Split the 522 KB bundle into app/react-vendor/router/icons
- loading/decoding/fetchpriority + per-route hero preload; drop unused asset
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 01:45:52 -05:00
import { buildBreadcrumbLd } from '@/lib/seo'
2026-06-14 15:37:26 -05:00
import { useCallback , useEffect , useState } from 'react'
2026-05-13 22:07:35 -05:00
import { toast } from 'sonner'
2026-05-12 01:04:17 -05:00
import { Button } from '@/components/ui/Button'
import { Input } from '@/components/ui/Input'
import { Textarea } from '@/components/ui/Textarea'
2026-05-25 20:20:15 -05:00
import RecaptchaPlaceholder from '@/components/RecaptchaPlaceholder'
2026-05-26 12:22:11 -05:00
import { ArrowRight } from 'lucide-react'
2026-06-14 15:37:26 -05:00
import { submitLead } from '@/lib/api'
const isRecaptchaConfigured = Boolean ( import . meta . env . VITE _RECAPTCHA _SITE _KEY )
2026-05-12 01:04:17 -05:00
const Contact = ( ) => {
const [ formState , setFormState ] = useState ( {
2026-05-27 20:57:55 -05:00
'Last Name' : '' ,
Company : '' ,
Email : '' ,
Phone : '' ,
'Zip Code' : '' ,
Description : '' ,
2026-06-14 15:37:26 -05:00
company _website : '' ,
2026-05-12 01:04:17 -05:00
} )
2026-05-13 18:10:04 -05:00
const [ errors , setErrors ] = useState ( {
2026-05-27 20:57:55 -05:00
'Last Name' : '' ,
Company : '' ,
Email : '' ,
'Zip Code' : '' ,
Description : '' ,
2026-05-25 20:20:15 -05:00
recaptcha _token : '' ,
2026-05-13 18:10:04 -05:00
} )
2026-05-27 20:57:55 -05:00
const [ debouncedErrors , setDebouncedErrors ] = useState ( errors )
2026-05-17 22:33:11 -05:00
const [ isSubmitting , setIsSubmitting ] = useState ( false )
2026-06-14 15:37:26 -05:00
const [ recaptchaToken , setRecaptchaToken ] = useState ( '' )
const [ recaptchaResetKey , setRecaptchaResetKey ] = useState ( 0 )
2026-05-12 01:04:17 -05:00
2026-05-27 20:57:55 -05:00
useEffect ( ( ) => {
const t = setTimeout ( ( ) => setDebouncedErrors ( errors ) , 300 )
return ( ) => clearTimeout ( t )
} , [ errors ] )
2026-05-13 18:10:04 -05:00
const validateForm = ( ) => {
2026-05-27 20:57:55 -05:00
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'
2026-05-13 18:10:04 -05:00
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
2026-05-27 20:57:55 -05:00
if ( ! formState . Email . trim ( ) ) {
newErrors . Email = 'Email is required'
} else if ( ! emailRegex . test ( formState . Email ) ) {
newErrors . Email = 'Please enter a valid email address'
2026-05-13 18:10:04 -05:00
}
2026-06-14 15:37:26 -05:00
if ( isRecaptchaConfigured && ! recaptchaToken ) {
newErrors . recaptcha _token = 'Security verification is required'
}
2026-05-13 18:10:04 -05:00
const hasErrors = Object . values ( newErrors ) . some ( error => error !== '' )
setErrors ( newErrors )
if ( hasErrors ) {
toast . error ( 'Please fix the errors in the form' )
return false
}
return true
}
2026-06-14 15:37:26 -05:00
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 ) => {
2026-05-12 01:04:17 -05:00
e . preventDefault ( )
2026-05-13 18:10:04 -05:00
if ( ! validateForm ( ) ) return
2026-05-17 22:33:11 -05:00
setIsSubmitting ( true )
2026-06-14 15:37:26 -05:00
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 )
}
}
toast . error ( err . message || 'Failed to submit lead' )
} finally {
setIsSubmitting ( false )
2026-05-28 00:18:08 -05:00
}
2026-05-12 01:04:17 -05:00
}
const handleChange = ( e ) => {
const { name , value } = e . target
setFormState ( prev => ( { ... prev , [ name ] : value } ) )
2026-05-18 12:11:56 -05:00
if ( errors [ name ] ) setErrors ( prev => ( { ... prev , [ name ] : '' } ) )
2026-05-12 01:04:17 -05:00
}
2026-06-14 15:37:26 -05:00
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.' } ) )
} , [ ] )
2026-05-18 12:11:56 -05:00
const contactDetails = [
{
label : 'Phone' ,
icon : (
< svg className = "h-5 w-5 text-primary-cyan" fill = "none" viewBox = "0 0 24 24" stroke = "currentColor" strokeWidth = { 1.5 } >
< path strokeLinecap = "round" strokeLinejoin = "round" d = "M2.25 6.75c0 8.284 6.716 15 15 15h2.25a2.25 2.25 0 002.25-2.25v-1.372c0-.516-.351-.966-.852-1.091l-4.423-1.106c-.44-.11-.902.055-1.173.417l-.97 1.293c-.282.376-.769.542-1.21.38a12.035 12.035 0 01-7.143-7.143c-.162-.441.004-.928.38-1.21l1.293-.97c.363-.271.527-.734.417-1.173L6.963 3.102a1.125 1.125 0 00-1.091-.852H4.5A2.25 2.25 0 002.25 4.5v2.25z" / >
< / svg >
) ,
content : (
< div >
< a href = "tel:+13217308020" className = "block text-white hover:text-primary-cyan transition-colors" > ( 321 ) 730 - 8020 < / a >
< a href = "tel:+18886562850" className = "block text-white/70 text-sm hover:text-primary-cyan transition-colors mt-0.5" > ( 888 ) 656 - 2850 Toll - Free < / a >
< / div >
) ,
} ,
{
feat(seo): publish privacy policy, remove street address, prerender all routes (batch 0.9.3)
Client directive (Levi Halford, 2026-08-01) ahead of Google/Meta lead forms.
Privacy policy:
- Publish approved policy verbatim at /privacy-policy (src/data/privacyPolicy.js
is the single source of truth; 292/292 source lines verified present)
- Privacy Policy link in the footer of every page
- Effective/Last Updated 2026-07-31, privacy@queuenorth.com as mailto
Remove St. Petersburg street address from every surface named in the brief:
footer, contact page, schema markup, SEO metadata, Google Maps links. Collapse
ProfessionalService + Organization schema into a single Organization with
areaServed: United States; drop geo coordinates, priceRange, openingHours.
Add the approved US-coverage sentence to About. No replacement address.
Crawler visibility (the site previously served 0 bytes of body HTML without JS):
- Prerender all 19 routes at build time via src/entry-server.jsx + scripts/prerender.js
- Hoist title/meta/canonical/JSON-LD into <head>; renderToString does not do this
and react-helmet-async's context is empty under React 19
- Serve prerendered HTML; return a real 404 for unknown paths instead of 200
- Hydrate instead of discarding the prerendered markup
SEO/perf:
- Titles <=60 and descriptions <=160 chars across all pages
- Add BreadcrumbList to interior pages, WebSite to home
- Generate sitemap.xml from the route list with git-derived lastmod
- 301 duplicate URL forms (trailing slash, //, /index.html), preserving query
- Immutable caching for content-hashed assets; no-cache for HTML
- Split the 522 KB bundle into app/react-vendor/router/icons
- loading/decoding/fetchpriority + per-route hero preload; drop unused asset
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 01:45:52 -05:00
label : 'Service Area' ,
2026-05-18 12:11:56 -05:00
icon : (
< svg className = "h-5 w-5 text-primary-cyan" fill = "none" viewBox = "0 0 24 24" stroke = "currentColor" strokeWidth = { 1.5 } >
feat(seo): publish privacy policy, remove street address, prerender all routes (batch 0.9.3)
Client directive (Levi Halford, 2026-08-01) ahead of Google/Meta lead forms.
Privacy policy:
- Publish approved policy verbatim at /privacy-policy (src/data/privacyPolicy.js
is the single source of truth; 292/292 source lines verified present)
- Privacy Policy link in the footer of every page
- Effective/Last Updated 2026-07-31, privacy@queuenorth.com as mailto
Remove St. Petersburg street address from every surface named in the brief:
footer, contact page, schema markup, SEO metadata, Google Maps links. Collapse
ProfessionalService + Organization schema into a single Organization with
areaServed: United States; drop geo coordinates, priceRange, openingHours.
Add the approved US-coverage sentence to About. No replacement address.
Crawler visibility (the site previously served 0 bytes of body HTML without JS):
- Prerender all 19 routes at build time via src/entry-server.jsx + scripts/prerender.js
- Hoist title/meta/canonical/JSON-LD into <head>; renderToString does not do this
and react-helmet-async's context is empty under React 19
- Serve prerendered HTML; return a real 404 for unknown paths instead of 200
- Hydrate instead of discarding the prerendered markup
SEO/perf:
- Titles <=60 and descriptions <=160 chars across all pages
- Add BreadcrumbList to interior pages, WebSite to home
- Generate sitemap.xml from the route list with git-derived lastmod
- 301 duplicate URL forms (trailing slash, //, /index.html), preserving query
- Immutable caching for content-hashed assets; no-cache for HTML
- Split the 522 KB bundle into app/react-vendor/router/icons
- loading/decoding/fetchpriority + per-route hero preload; drop unused asset
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 01:45:52 -05:00
< path strokeLinecap = "round" strokeLinejoin = "round" d = "M12 21a9 9 0 100-18 9 9 0 000 18zm0 0c2.485 0 4.5-4.03 4.5-9S14.485 3 12 3 7.5 7.03 7.5 12s2.015 9 4.5 9zM3.6 9h16.8M3.6 15h16.8" / >
2026-05-18 12:11:56 -05:00
< / svg >
) ,
content : (
feat(seo): publish privacy policy, remove street address, prerender all routes (batch 0.9.3)
Client directive (Levi Halford, 2026-08-01) ahead of Google/Meta lead forms.
Privacy policy:
- Publish approved policy verbatim at /privacy-policy (src/data/privacyPolicy.js
is the single source of truth; 292/292 source lines verified present)
- Privacy Policy link in the footer of every page
- Effective/Last Updated 2026-07-31, privacy@queuenorth.com as mailto
Remove St. Petersburg street address from every surface named in the brief:
footer, contact page, schema markup, SEO metadata, Google Maps links. Collapse
ProfessionalService + Organization schema into a single Organization with
areaServed: United States; drop geo coordinates, priceRange, openingHours.
Add the approved US-coverage sentence to About. No replacement address.
Crawler visibility (the site previously served 0 bytes of body HTML without JS):
- Prerender all 19 routes at build time via src/entry-server.jsx + scripts/prerender.js
- Hoist title/meta/canonical/JSON-LD into <head>; renderToString does not do this
and react-helmet-async's context is empty under React 19
- Serve prerendered HTML; return a real 404 for unknown paths instead of 200
- Hydrate instead of discarding the prerendered markup
SEO/perf:
- Titles <=60 and descriptions <=160 chars across all pages
- Add BreadcrumbList to interior pages, WebSite to home
- Generate sitemap.xml from the route list with git-derived lastmod
- 301 duplicate URL forms (trailing slash, //, /index.html), preserving query
- Immutable caching for content-hashed assets; no-cache for HTML
- Split the 522 KB bundle into app/react-vendor/router/icons
- loading/decoding/fetchpriority + per-route hero preload; drop unused asset
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 01:45:52 -05:00
< p className = "text-white/80 text-sm leading-relaxed" >
Remote services nationwide . On - site services available based on project scope and location .
< / p >
2026-05-18 12:11:56 -05:00
) ,
} ,
{
label : 'Hours' ,
icon : (
< svg className = "h-5 w-5 text-primary-cyan" fill = "none" viewBox = "0 0 24 24" stroke = "currentColor" strokeWidth = { 1.5 } >
< path strokeLinecap = "round" strokeLinejoin = "round" d = "M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z" / >
< / svg >
) ,
content : < p className = "text-white/80 text-sm" > Mon – Fri : 8 : 00 AM – 6 : 00 PM CT < / p > ,
} ,
]
const trustPoints = [
2026-05-25 17:56:32 -05:00
< div key = "8x8" > < span className = "font-numeric" > 8 x8 < / span > Certified Partner with proven expertise < / div > ,
2026-05-27 14:10:28 -05:00
'Cisco Certified Partner' ,
2026-05-25 17:56:32 -05:00
< div key = "veteran" > < span className = "font-numeric" > 25 + < / span > years of experience < / div > ,
2026-05-18 12:11:56 -05:00
'SMB to Enterprise solutions' ,
ui: no em dashes in anything a visitor or a crawler reads
Null's direction of 2026-09-10. Punctuation only: not one word changed.
Nine of them, and the share-image alt text was on every page twice:
src/components/SEO.jsx:4 the default og:image and twitter:image alt
index.html:25,:31 the same string, as template fallbacks
src/pages/Home.jsx:257,:290
src/pages/Services.jsx:99
src/pages/Contact.jsx:183
src/data/services.js:120 the wireless card on Home and the index
public/site.webmanifest:4 linked from every page
Thirteen more sit in code comments and reach no page, so they stay. The hours
range on /contact uses en dashes, which are a different character and are not
what was asked about.
This lands before the guards that reject an em dash in content or in built
HTML, because until now every built page carried one and those guards would
have gone red on arrival.
Verified against the build, not the source: no U+2014 in any file under dist/,
including the HTML, the webmanifest, the sitemap and the JS bundle.
Closes #222.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-10 04:26:21 -05:00
'No vendor bias: we recommend what fits' ,
2026-05-18 12:11:56 -05:00
]
2026-05-12 01:04:17 -05:00
return (
2026-05-17 17:11:29 -05:00
< >
2026-05-18 13:55:06 -05:00
< SEO
title = "Contact Queue North | Schedule a Free Consultation"
feat(seo): publish privacy policy, remove street address, prerender all routes (batch 0.9.3)
Client directive (Levi Halford, 2026-08-01) ahead of Google/Meta lead forms.
Privacy policy:
- Publish approved policy verbatim at /privacy-policy (src/data/privacyPolicy.js
is the single source of truth; 292/292 source lines verified present)
- Privacy Policy link in the footer of every page
- Effective/Last Updated 2026-07-31, privacy@queuenorth.com as mailto
Remove St. Petersburg street address from every surface named in the brief:
footer, contact page, schema markup, SEO metadata, Google Maps links. Collapse
ProfessionalService + Organization schema into a single Organization with
areaServed: United States; drop geo coordinates, priceRange, openingHours.
Add the approved US-coverage sentence to About. No replacement address.
Crawler visibility (the site previously served 0 bytes of body HTML without JS):
- Prerender all 19 routes at build time via src/entry-server.jsx + scripts/prerender.js
- Hoist title/meta/canonical/JSON-LD into <head>; renderToString does not do this
and react-helmet-async's context is empty under React 19
- Serve prerendered HTML; return a real 404 for unknown paths instead of 200
- Hydrate instead of discarding the prerendered markup
SEO/perf:
- Titles <=60 and descriptions <=160 chars across all pages
- Add BreadcrumbList to interior pages, WebSite to home
- Generate sitemap.xml from the route list with git-derived lastmod
- 301 duplicate URL forms (trailing slash, //, /index.html), preserving query
- Immutable caching for content-hashed assets; no-cache for HTML
- Split the 522 KB bundle into app/react-vendor/router/icons
- loading/decoding/fetchpriority + per-route hero preload; drop unused asset
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 01:45:52 -05:00
description = "Schedule a free consultation with Queue North. Call (321) 730-8020 or (888) 656-2850 for business phone, UCaaS, IT support, and networking."
2026-05-18 13:55:06 -05:00
url = "https://queuenorth.com/contact"
feat(seo): publish privacy policy, remove street address, prerender all routes (batch 0.9.3)
Client directive (Levi Halford, 2026-08-01) ahead of Google/Meta lead forms.
Privacy policy:
- Publish approved policy verbatim at /privacy-policy (src/data/privacyPolicy.js
is the single source of truth; 292/292 source lines verified present)
- Privacy Policy link in the footer of every page
- Effective/Last Updated 2026-07-31, privacy@queuenorth.com as mailto
Remove St. Petersburg street address from every surface named in the brief:
footer, contact page, schema markup, SEO metadata, Google Maps links. Collapse
ProfessionalService + Organization schema into a single Organization with
areaServed: United States; drop geo coordinates, priceRange, openingHours.
Add the approved US-coverage sentence to About. No replacement address.
Crawler visibility (the site previously served 0 bytes of body HTML without JS):
- Prerender all 19 routes at build time via src/entry-server.jsx + scripts/prerender.js
- Hoist title/meta/canonical/JSON-LD into <head>; renderToString does not do this
and react-helmet-async's context is empty under React 19
- Serve prerendered HTML; return a real 404 for unknown paths instead of 200
- Hydrate instead of discarding the prerendered markup
SEO/perf:
- Titles <=60 and descriptions <=160 chars across all pages
- Add BreadcrumbList to interior pages, WebSite to home
- Generate sitemap.xml from the route list with git-derived lastmod
- 301 duplicate URL forms (trailing slash, //, /index.html), preserving query
- Immutable caching for content-hashed assets; no-cache for HTML
- Split the 522 KB bundle into app/react-vendor/router/icons
- loading/decoding/fetchpriority + per-route hero preload; drop unused asset
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 01:45:52 -05:00
jsonLd = { buildBreadcrumbLd ( [ { name : 'Contact' , path : '/contact' } ] ) }
2026-05-18 13:55:06 -05:00
/ >
2026-05-18 09:35:29 -05:00
2026-05-18 12:11:56 -05:00
{ /* Hero */ }
2026-05-26 12:22:11 -05:00
< section className = "relative isolate overflow-hidden bg-primary-navy py-16 lg:py-24" >
< div className = "absolute inset-0 -z-10" >
< img
src = "/assets/hero-tech.webp"
alt = "Queue North communications infrastructure consultation"
className = "h-full w-full object-cover object-center"
feat(seo): publish privacy policy, remove street address, prerender all routes (batch 0.9.3)
Client directive (Levi Halford, 2026-08-01) ahead of Google/Meta lead forms.
Privacy policy:
- Publish approved policy verbatim at /privacy-policy (src/data/privacyPolicy.js
is the single source of truth; 292/292 source lines verified present)
- Privacy Policy link in the footer of every page
- Effective/Last Updated 2026-07-31, privacy@queuenorth.com as mailto
Remove St. Petersburg street address from every surface named in the brief:
footer, contact page, schema markup, SEO metadata, Google Maps links. Collapse
ProfessionalService + Organization schema into a single Organization with
areaServed: United States; drop geo coordinates, priceRange, openingHours.
Add the approved US-coverage sentence to About. No replacement address.
Crawler visibility (the site previously served 0 bytes of body HTML without JS):
- Prerender all 19 routes at build time via src/entry-server.jsx + scripts/prerender.js
- Hoist title/meta/canonical/JSON-LD into <head>; renderToString does not do this
and react-helmet-async's context is empty under React 19
- Serve prerendered HTML; return a real 404 for unknown paths instead of 200
- Hydrate instead of discarding the prerendered markup
SEO/perf:
- Titles <=60 and descriptions <=160 chars across all pages
- Add BreadcrumbList to interior pages, WebSite to home
- Generate sitemap.xml from the route list with git-derived lastmod
- 301 duplicate URL forms (trailing slash, //, /index.html), preserving query
- Immutable caching for content-hashed assets; no-cache for HTML
- Split the 522 KB bundle into app/react-vendor/router/icons
- loading/decoding/fetchpriority + per-route hero preload; drop unused asset
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 01:45:52 -05:00
loading = "eager"
fetchPriority = "high"
decoding = "async"
2026-05-26 12:22:11 -05:00
/ >
< div className = "absolute inset-0 bg-primary-navy/82" / >
< div className = "absolute inset-0 bg-gradient-to-r from-primary-navy via-primary-navy/92 to-primary-navy/45" / >
< / div >
2026-05-18 12:11:56 -05:00
< div className = "max-w-7xl mx-auto px-4 sm:px-6 lg:px-8" >
2026-05-26 12:22:11 -05:00
< 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" >
2026-05-27 14:27:30 -05:00
Contact
2026-05-26 12:22:11 -05:00
< / div >
2026-05-18 12:11:56 -05:00
< 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" >
Tell us about your business and we ' ll cut through the noise to find what actually works for you .
< / p >
2026-05-26 12:22:11 -05:00
< a
href = "#contact-form"
className = "mt-8 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"
>
Send a Message
< ArrowRight className = "h-4 w-4" aria - hidden = "true" / >
< / a >
2026-05-12 01:04:17 -05:00
< / div >
2026-05-17 17:11:29 -05:00
< / section >
2026-05-12 01:04:17 -05:00
2026-05-18 12:11:56 -05:00
{ /* Contact Body */ }
2026-05-26 12:22:11 -05:00
< section id = "contact-form" className = "bg-background py-16 lg:py-24" >
2026-05-18 13:12:18 -05:00
< div className = "max-w-7xl mx-auto px-0 sm:px-6 lg:px-8" >
< div className = "grid grid-cols-1 lg:grid-cols-5 rounded-none sm:rounded-md overflow-hidden shadow-none sm:shadow-xl border-y sm:border border-border" >
2026-05-18 12:11:56 -05:00
2026-05-18 13:12:18 -05:00
{ /* Left: Info panel — order 2 on mobile so form appears first */ }
< div className = "lg:col-span-2 order-2 lg:order-1 bg-primary-navy text-white p-8 lg:p-10 flex flex-col gap-10" >
2026-05-18 12:11:56 -05:00
< div className = "space-y-7" >
{ contactDetails . map ( ( item ) => (
< div key = { item . label } className = "flex items-start gap-4" >
2026-05-18 13:12:18 -05:00
< div className = "w-10 h-10 bg-white/10 rounded-md flex items-center justify-center flex-shrink-0" >
2026-05-18 12:11:56 -05:00
{ item . icon }
< / div >
< div >
< p className = "text-white/50 text-xs uppercase tracking-wider mb-1" > { item . label } < / p >
{ item . content }
< / div >
2026-05-17 17:11:29 -05:00
< / div >
2026-05-18 12:11:56 -05:00
) ) }
2026-05-17 17:11:29 -05:00
< / div >
2026-05-12 01:04:17 -05:00
2026-05-18 12:11:56 -05:00
< div className = "border-t border-white/10" / >
< div >
2026-05-27 14:10:28 -05:00
< p className = "text-white/50 text-xs uppercase tracking-wider mb-5" > Why Queue North Technologies < / p >
2026-05-18 12:11:56 -05:00
< 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" >
< svg className = "h-4 w-4 text-primary-cyan flex-shrink-0 mt-0.5" fill = "none" viewBox = "0 0 24 24" stroke = "currentColor" strokeWidth = { 2.5 } >
< path strokeLinecap = "round" strokeLinejoin = "round" d = "M4.5 12.75l6 6 9-13.5" / >
2026-05-17 17:11:29 -05:00
< / svg >
2026-05-18 12:11:56 -05:00
{ point }
2026-05-17 17:11:29 -05:00
< / li >
) ) }
< / ul >
< / div >
2026-05-12 01:04:17 -05:00
< / div >
2026-05-18 13:12:18 -05:00
{ /* Right: Form panel — order 1 on mobile so it appears first */ }
< div className = "lg:col-span-3 order-1 lg:order-2 bg-white p-6 sm:p-8 lg:p-10" >
2026-05-18 12:11:56 -05:00
< 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 >
2026-05-27 20:57:55 -05:00
< form
id = "contact-form"
onSubmit = { handleSubmit }
noValidate
className = { ` space-y-5 ${ isSubmitting ? 'opacity-70 pointer-events-none' : '' } ` }
>
{ /* Honeypot */ }
2026-06-14 15:37:26 -05:00
< input
type = "text"
name = "company_website"
value = { formState . company _website }
onChange = { handleChange }
tabIndex = { - 1 }
autoComplete = "off"
aria - hidden = "true"
style = { { display : 'none' } }
/ >
2026-05-27 20:57:55 -05:00
2026-05-18 12:11:56 -05:00
{ /* Company */ }
2026-05-17 17:11:29 -05:00
< div >
2026-05-27 20:57:55 -05:00
< label htmlFor = "Company" className = "block text-sm font-medium text-text mb-1.5" >
2026-05-18 12:11:56 -05:00
Company Name < span className = "text-red-500" > * < / span >
2026-05-17 17:11:29 -05:00
< / label >
< Input
type = "text"
2026-05-27 20:57:55 -05:00
id = "Company"
name = "Company"
value = { formState . Company }
2026-05-17 17:11:29 -05:00
onChange = { handleChange }
required
placeholder = "Your company name"
2026-05-27 20:57:55 -05:00
className = { debouncedErrors . Company ? 'border-red-500 focus-visible:ring-red-500' : '' }
2026-05-17 17:11:29 -05:00
/ >
2026-05-27 20:57:55 -05:00
{ debouncedErrors . Company && < p className = "text-xs text-red-500 mt-1" > { debouncedErrors . Company } < / p > }
2026-05-17 17:11:29 -05:00
< / div >
2026-05-12 01:04:17 -05:00
2026-05-18 12:11:56 -05:00
{ /* Name + Email */ }
< div className = "grid grid-cols-1 sm:grid-cols-2 gap-4" >
< div >
2026-05-27 20:57:55 -05:00
< label htmlFor = "Last_Name" className = "block text-sm font-medium text-text mb-1.5" >
2026-05-18 12:11:56 -05:00
Name < span className = "text-red-500" > * < / span >
< / label >
< Input
type = "text"
2026-05-27 20:57:55 -05:00
id = "Last_Name"
name = "Last Name"
value = { formState [ 'Last Name' ] }
2026-05-18 12:11:56 -05:00
onChange = { handleChange }
required
placeholder = "Your full name"
2026-05-27 20:57:55 -05:00
className = { debouncedErrors [ 'Last Name' ] ? 'border-red-500 focus-visible:ring-red-500' : '' }
2026-05-18 12:11:56 -05:00
/ >
2026-05-27 20:57:55 -05:00
{ debouncedErrors [ 'Last Name' ] && < p className = "text-xs text-red-500 mt-1" > { debouncedErrors [ 'Last Name' ] } < / p > }
2026-05-18 12:11:56 -05:00
< / div >
< div >
2026-05-27 20:57:55 -05:00
< label htmlFor = "Email" className = "block text-sm font-medium text-text mb-1.5" >
2026-05-18 12:11:56 -05:00
Email < span className = "text-red-500" > * < / span >
< / label >
< Input
type = "email"
2026-05-27 20:57:55 -05:00
id = "Email"
name = "Email"
value = { formState . Email }
2026-05-18 12:11:56 -05:00
onChange = { handleChange }
required
placeholder = "you@company.com"
2026-05-27 20:57:55 -05:00
className = { debouncedErrors . Email ? 'border-red-500 focus-visible:ring-red-500' : '' }
2026-05-18 12:11:56 -05:00
/ >
2026-05-27 20:57:55 -05:00
{ debouncedErrors . Email && < p className = "text-xs text-red-500 mt-1" > { debouncedErrors . Email } < / p > }
2026-05-18 12:11:56 -05:00
< / div >
2026-05-17 17:11:29 -05:00
< / div >
2026-05-25 18:17:16 -05:00
{ /* Phone + ZIP */ }
2026-05-18 12:11:56 -05:00
< div className = "grid grid-cols-1 sm:grid-cols-2 gap-4" >
< div >
2026-05-27 20:57:55 -05:00
< label htmlFor = "Phone" className = "block text-sm font-medium text-text mb-1.5" >
2026-05-18 12:11:56 -05:00
Phone < span className = "text-soft-text font-normal" > ( optional ) < / span >
< / label >
< Input
type = "tel"
2026-05-27 20:57:55 -05:00
id = "Phone"
name = "Phone"
value = { formState . Phone }
2026-05-18 12:11:56 -05:00
onChange = { handleChange }
placeholder = "(555) 123-4567"
/ >
< / div >
< div >
2026-05-27 20:57:55 -05:00
< label htmlFor = "Zip_Code" className = "block text-sm font-medium text-text mb-1.5" >
2026-05-25 18:17:16 -05:00
ZIP Code < span className = "text-red-500" > * < / span >
2026-05-18 12:11:56 -05:00
< / label >
2026-05-25 18:17:16 -05:00
< Input
type = "text"
2026-05-27 20:57:55 -05:00
id = "Zip_Code"
name = "Zip Code"
value = { formState [ 'Zip Code' ] }
2026-05-18 12:11:56 -05:00
onChange = { handleChange }
2026-05-25 18:17:16 -05:00
required
autoComplete = "postal-code"
inputMode = "numeric"
feat(seo): publish privacy policy, remove street address, prerender all routes (batch 0.9.3)
Client directive (Levi Halford, 2026-08-01) ahead of Google/Meta lead forms.
Privacy policy:
- Publish approved policy verbatim at /privacy-policy (src/data/privacyPolicy.js
is the single source of truth; 292/292 source lines verified present)
- Privacy Policy link in the footer of every page
- Effective/Last Updated 2026-07-31, privacy@queuenorth.com as mailto
Remove St. Petersburg street address from every surface named in the brief:
footer, contact page, schema markup, SEO metadata, Google Maps links. Collapse
ProfessionalService + Organization schema into a single Organization with
areaServed: United States; drop geo coordinates, priceRange, openingHours.
Add the approved US-coverage sentence to About. No replacement address.
Crawler visibility (the site previously served 0 bytes of body HTML without JS):
- Prerender all 19 routes at build time via src/entry-server.jsx + scripts/prerender.js
- Hoist title/meta/canonical/JSON-LD into <head>; renderToString does not do this
and react-helmet-async's context is empty under React 19
- Serve prerendered HTML; return a real 404 for unknown paths instead of 200
- Hydrate instead of discarding the prerendered markup
SEO/perf:
- Titles <=60 and descriptions <=160 chars across all pages
- Add BreadcrumbList to interior pages, WebSite to home
- Generate sitemap.xml from the route list with git-derived lastmod
- 301 duplicate URL forms (trailing slash, //, /index.html), preserving query
- Immutable caching for content-hashed assets; no-cache for HTML
- Split the 522 KB bundle into app/react-vendor/router/icons
- loading/decoding/fetchpriority + per-route hero preload; drop unused asset
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 01:45:52 -05:00
placeholder = "12345"
2026-05-27 20:57:55 -05:00
className = { debouncedErrors [ 'Zip Code' ] ? 'border-red-500 focus-visible:ring-red-500' : '' }
2026-05-25 18:17:16 -05:00
/ >
2026-05-27 20:57:55 -05:00
{ debouncedErrors [ 'Zip Code' ] && < p className = "text-xs text-red-500 mt-1" > { debouncedErrors [ 'Zip Code' ] } < / p > }
2026-05-18 12:11:56 -05:00
< / div >
2026-05-17 17:11:29 -05:00
< / div >
2026-05-18 12:11:56 -05:00
{ /* Message */ }
2026-05-17 17:11:29 -05:00
< div >
2026-05-27 20:57:55 -05:00
< label htmlFor = "Description" className = "block text-sm font-medium text-text mb-1.5" >
2026-05-18 12:11:56 -05:00
Message < span className = "text-red-500" > * < / span >
2026-05-17 17:11:29 -05:00
< / label >
< Textarea
2026-05-27 20:57:55 -05:00
id = "Description"
name = "Description"
value = { formState . Description }
2026-05-17 17:11:29 -05:00
onChange = { handleChange }
required
placeholder = "Tell us about your needs..."
2026-05-27 20:57:55 -05:00
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' : '' } ` }
2026-05-17 17:11:29 -05:00
rows = { 5 }
/ >
2026-05-27 20:57:55 -05:00
{ debouncedErrors . Description && < p className = "text-xs text-red-500 mt-1" > { debouncedErrors . Description } < / p > }
2026-05-17 21:51:53 -05:00
< / div >
2026-06-14 15:37:26 -05:00
< RecaptchaPlaceholder
error = { debouncedErrors . recaptcha _token }
onVerify = { handleRecaptchaVerify }
onExpired = { handleRecaptchaExpired }
resetKey = { recaptchaResetKey }
/ >
2026-05-25 20:20:15 -05:00
2026-05-18 12:11:56 -05:00
< Button type = "submit" className = "w-full h-11" disabled = { isSubmitting } >
2026-05-17 22:35:55 -05:00
{ isSubmitting ? (
< >
< svg className = "animate-spin -ml-1 mr-2 h-4 w-4" xmlns = "http://www.w3.org/2000/svg" fill = "none" viewBox = "0 0 24 24" >
< circle className = "opacity-25" cx = "12" cy = "12" r = "10" stroke = "currentColor" strokeWidth = "4" / >
< path className = "opacity-75" fill = "currentColor" d = "M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" / >
< / svg >
2026-05-18 12:11:56 -05:00
Sending ...
2026-05-17 22:35:55 -05:00
< / >
) : (
2026-05-18 12:11:56 -05:00
'Send Message'
2026-05-17 22:35:55 -05:00
) }
2026-05-17 17:11:29 -05:00
< / Button >
< / form >
< / div >
< / div >
2026-05-12 01:04:17 -05:00
< / div >
2026-05-17 17:11:29 -05:00
< / section >
< / >
2026-05-12 01:04:17 -05:00
)
}
export default Contact