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>
This commit is contained in:
parent
196389ddf3
commit
ac6f5d1f78
|
|
@ -15,6 +15,7 @@ node_modules/
|
||||||
|
|
||||||
# Build output
|
# Build output
|
||||||
dist/
|
dist/
|
||||||
|
dist-ssr/
|
||||||
|
|
||||||
# Runtime/database artifacts
|
# Runtime/database artifacts
|
||||||
db/*.db
|
db/*.db
|
||||||
|
|
|
||||||
|
|
@ -29,8 +29,13 @@
|
||||||
<meta name="twitter:description" content="Veteran-owned 8x8 Certified Partner. Business phone, UCaaS, contact center, IT support, and networking solutions." />
|
<meta name="twitter:description" content="Veteran-owned 8x8 Certified Partner. Business phone, UCaaS, contact center, IT support, and networking solutions." />
|
||||||
<meta name="twitter:image" content="https://queuenorth.com/assets/og-image.png" />
|
<meta name="twitter:image" content="https://queuenorth.com/assets/og-image.png" />
|
||||||
<meta name="twitter:image:alt" content="Queue North Technologies — Business Communications & IT Partner" />
|
<meta name="twitter:image:alt" content="Queue North Technologies — Business Communications & IT Partner" />
|
||||||
|
<!-- The prerenderer injects a per-route <link rel="preload"> for that page's
|
||||||
|
hero image here (scripts/prerender.js). -->
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<!-- Kept as a plain stylesheet link: the usual media="print"/onload swap needs
|
||||||
|
an inline event handler, which this site's CSP (script-src 'self') blocks.
|
||||||
|
display=swap already prevents invisible text while the font loads. -->
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,8 @@
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "concurrently \"vite\" \"node server/index.js\"",
|
"dev": "concurrently \"vite\" \"node server/index.js\"",
|
||||||
"build": "vite build",
|
"build": "vite build && vite build --ssr src/entry-server.jsx --outDir dist-ssr && node scripts/prerender.js",
|
||||||
|
"build:client": "vite build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"start": "node server/index.js",
|
"start": "node server/index.js",
|
||||||
"server": "node server/index.js",
|
"server": "node server/index.js",
|
||||||
|
|
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 94 KiB |
|
|
@ -1,88 +0,0 @@
|
||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
|
||||||
<url>
|
|
||||||
<loc>https://queuenorth.com</loc>
|
|
||||||
<changefreq>weekly</changefreq>
|
|
||||||
<priority>1.0</priority>
|
|
||||||
</url>
|
|
||||||
<url>
|
|
||||||
<loc>https://queuenorth.com/about</loc>
|
|
||||||
<changefreq>monthly</changefreq>
|
|
||||||
<priority>0.8</priority>
|
|
||||||
</url>
|
|
||||||
<url>
|
|
||||||
<loc>https://queuenorth.com/services</loc>
|
|
||||||
<changefreq>monthly</changefreq>
|
|
||||||
<priority>0.9</priority>
|
|
||||||
</url>
|
|
||||||
<url>
|
|
||||||
<loc>https://queuenorth.com/services/unified-communications</loc>
|
|
||||||
<changefreq>monthly</changefreq>
|
|
||||||
<priority>0.7</priority>
|
|
||||||
</url>
|
|
||||||
<url>
|
|
||||||
<loc>https://queuenorth.com/services/contact-center</loc>
|
|
||||||
<changefreq>monthly</changefreq>
|
|
||||||
<priority>0.7</priority>
|
|
||||||
</url>
|
|
||||||
<url>
|
|
||||||
<loc>https://queuenorth.com/services/managed-support</loc>
|
|
||||||
<changefreq>monthly</changefreq>
|
|
||||||
<priority>0.7</priority>
|
|
||||||
</url>
|
|
||||||
<url>
|
|
||||||
<loc>https://queuenorth.com/services/consulting-training</loc>
|
|
||||||
<changefreq>monthly</changefreq>
|
|
||||||
<priority>0.7</priority>
|
|
||||||
</url>
|
|
||||||
<url>
|
|
||||||
<loc>https://queuenorth.com/services/infrastructure-cabling</loc>
|
|
||||||
<changefreq>monthly</changefreq>
|
|
||||||
<priority>0.7</priority>
|
|
||||||
</url>
|
|
||||||
<url>
|
|
||||||
<loc>https://queuenorth.com/services/wireless-access</loc>
|
|
||||||
<changefreq>monthly</changefreq>
|
|
||||||
<priority>0.7</priority>
|
|
||||||
</url>
|
|
||||||
<url>
|
|
||||||
<loc>https://queuenorth.com/services/local-networking</loc>
|
|
||||||
<changefreq>monthly</changefreq>
|
|
||||||
<priority>0.7</priority>
|
|
||||||
</url>
|
|
||||||
<url>
|
|
||||||
<loc>https://queuenorth.com/industries</loc>
|
|
||||||
<changefreq>monthly</changefreq>
|
|
||||||
<priority>0.8</priority>
|
|
||||||
</url>
|
|
||||||
<url>
|
|
||||||
<loc>https://queuenorth.com/industries/healthcare</loc>
|
|
||||||
<changefreq>monthly</changefreq>
|
|
||||||
<priority>0.7</priority>
|
|
||||||
</url>
|
|
||||||
<url>
|
|
||||||
<loc>https://queuenorth.com/industries/retail</loc>
|
|
||||||
<changefreq>monthly</changefreq>
|
|
||||||
<priority>0.7</priority>
|
|
||||||
</url>
|
|
||||||
<url>
|
|
||||||
<loc>https://queuenorth.com/industries/manufacturing</loc>
|
|
||||||
<changefreq>monthly</changefreq>
|
|
||||||
<priority>0.7</priority>
|
|
||||||
</url>
|
|
||||||
<url>
|
|
||||||
<loc>https://queuenorth.com/industries/education-finance</loc>
|
|
||||||
<changefreq>monthly</changefreq>
|
|
||||||
<priority>0.7</priority>
|
|
||||||
</url>
|
|
||||||
<url>
|
|
||||||
<loc>https://queuenorth.com/contact</loc>
|
|
||||||
<changefreq>monthly</changefreq>
|
|
||||||
<priority>0.9</priority>
|
|
||||||
</url>
|
|
||||||
<url>
|
|
||||||
<loc>https://queuenorth.com/support</loc>
|
|
||||||
<changefreq>monthly</changefreq>
|
|
||||||
<priority>0.8</priority>
|
|
||||||
</url>
|
|
||||||
</urlset>
|
|
||||||
|
|
@ -0,0 +1,201 @@
|
||||||
|
// Build-time prerenderer.
|
||||||
|
//
|
||||||
|
// Renders every route to static HTML so crawlers that do not execute JavaScript
|
||||||
|
// (Meta, LinkedIn, Slack, Bing) receive real content, correct per-route <title>,
|
||||||
|
// meta description, canonical, and Open Graph tags. Google also benefits: pages
|
||||||
|
// no longer sit in its deferred JS-rendering queue.
|
||||||
|
//
|
||||||
|
// Output layout (consumed by server/index.js):
|
||||||
|
// dist/index.html -> /
|
||||||
|
// dist/about/index.html -> /about
|
||||||
|
// dist/services/<slug>/index.html
|
||||||
|
// dist/404.html -> served with a real 404 status
|
||||||
|
//
|
||||||
|
// Run automatically as part of `npm run build`.
|
||||||
|
|
||||||
|
import { execFileSync } from 'child_process'
|
||||||
|
import { mkdirSync, readFileSync, writeFileSync } from 'fs'
|
||||||
|
import path from 'path'
|
||||||
|
import { fileURLToPath } from 'url'
|
||||||
|
|
||||||
|
import { render } from '../dist-ssr/entry-server.js'
|
||||||
|
import { services } from '../src/data/services.js'
|
||||||
|
import { industries } from '../src/data/industries.js'
|
||||||
|
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||||
|
const distDir = path.join(__dirname, '../dist')
|
||||||
|
|
||||||
|
const STATIC_ROUTES = [
|
||||||
|
'/',
|
||||||
|
'/about',
|
||||||
|
'/services',
|
||||||
|
'/industries',
|
||||||
|
'/contact',
|
||||||
|
'/support',
|
||||||
|
'/privacy-policy',
|
||||||
|
]
|
||||||
|
|
||||||
|
const routes = [
|
||||||
|
...STATIC_ROUTES,
|
||||||
|
...services.map((s) => `/services/${s.id}`),
|
||||||
|
...industries.map((i) => `/industries/${i.id}`),
|
||||||
|
]
|
||||||
|
|
||||||
|
// Tags the SEO component owns per-route. They are stripped from the template so
|
||||||
|
// Helmet's values replace them instead of duplicating them.
|
||||||
|
const TEMPLATE_TAGS_TO_STRIP = [
|
||||||
|
/<title>[\s\S]*?<\/title>\s*/,
|
||||||
|
/<meta name="description"[^>]*>\s*/,
|
||||||
|
/<meta property="og:[^"]*"[^>]*>\s*/g,
|
||||||
|
/<meta name="twitter:[^"]*"[^>]*>\s*/g,
|
||||||
|
/<!-- Open Graph fallback for crawlers that don't execute JavaScript -->\s*/,
|
||||||
|
/<!-- Twitter \/ X Card fallback -->\s*/,
|
||||||
|
]
|
||||||
|
|
||||||
|
// Metadata React renders inside the component tree. React 19 hoists these into
|
||||||
|
// <head> in the browser and in its streaming renderer, but renderToString leaves
|
||||||
|
// them inline, so the prerenderer performs the same hoist. Leaving them in <body>
|
||||||
|
// would put every title, canonical, and og: tag somewhere crawlers ignore.
|
||||||
|
const HOISTABLE_TAGS =
|
||||||
|
/<title[^>]*>[\s\S]*?<\/title>|<meta\b[^>]*?\/?>|<link\b[^>]*?\/?>|<script[^>]*type="application\/ld\+json"[^>]*>[\s\S]*?<\/script>/g
|
||||||
|
|
||||||
|
const buildPage = (template, url) => {
|
||||||
|
const { html } = render(url)
|
||||||
|
|
||||||
|
const hoisted = html.match(HOISTABLE_TAGS) || []
|
||||||
|
const body = html.replace(HOISTABLE_TAGS, '')
|
||||||
|
|
||||||
|
// Preload this route's own LCP image. The hero is the one marked eager during
|
||||||
|
// render, so the hint always matches what the page actually paints first.
|
||||||
|
const heroSrc = html
|
||||||
|
.match(/<img[^>]*loading="eager"[^>]*>/)?.[0]
|
||||||
|
.match(/src="([^"]+)"/)?.[1]
|
||||||
|
|
||||||
|
const head = []
|
||||||
|
if (heroSrc) {
|
||||||
|
head.push(`<link rel="preload" as="image" href="${heroSrc}" fetchpriority="high" />`)
|
||||||
|
}
|
||||||
|
// React emits its own image preload for the eager hero; drop it so the hint
|
||||||
|
// above isn't duplicated.
|
||||||
|
for (const tag of hoisted) {
|
||||||
|
if (/rel="preload"[^>]*as="image"/.test(tag)) continue
|
||||||
|
head.push(tag)
|
||||||
|
}
|
||||||
|
|
||||||
|
let page = template
|
||||||
|
for (const pattern of TEMPLATE_TAGS_TO_STRIP) {
|
||||||
|
page = page.replace(pattern, '')
|
||||||
|
}
|
||||||
|
|
||||||
|
page = page.replace('</head>', ` ${head.join('\n ')}\n </head>`)
|
||||||
|
page = page.replace('<div id="root"></div>', `<div id="root">${body}</div>`)
|
||||||
|
|
||||||
|
return page
|
||||||
|
}
|
||||||
|
|
||||||
|
const outputPathFor = (url) =>
|
||||||
|
url === '/'
|
||||||
|
? path.join(distDir, 'index.html')
|
||||||
|
: path.join(distDir, url, 'index.html')
|
||||||
|
|
||||||
|
const template = readFileSync(path.join(distDir, 'index.html'), 'utf8')
|
||||||
|
|
||||||
|
const written = []
|
||||||
|
for (const url of routes) {
|
||||||
|
const page = buildPage(template, url)
|
||||||
|
const outPath = outputPathFor(url)
|
||||||
|
mkdirSync(path.dirname(outPath), { recursive: true })
|
||||||
|
writeFileSync(outPath, page)
|
||||||
|
written.push([url, page.length])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dedicated 404 document. Rendering an unmatched path hits the catch-all route,
|
||||||
|
// which carries `noindex, follow` — now visible to crawlers in static HTML.
|
||||||
|
const notFoundPage = buildPage(template, '/__not_found__')
|
||||||
|
writeFileSync(path.join(distDir, '404.html'), notFoundPage)
|
||||||
|
written.push(['404.html', notFoundPage.length])
|
||||||
|
|
||||||
|
console.log(`\nPrerendered ${written.length} pages:`)
|
||||||
|
for (const [url, size] of written) {
|
||||||
|
console.log(` ${url.padEnd(42)} ${(size / 1024).toFixed(1)} KB`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- sitemap.xml -------------------------------------------------------------
|
||||||
|
// Generated from the same route list that drives prerendering, so the sitemap can
|
||||||
|
// never drift out of sync with what the site actually serves.
|
||||||
|
|
||||||
|
const SITE_URL = 'https://queuenorth.com'
|
||||||
|
|
||||||
|
// lastmod comes from git history for the files that produce each route. A build
|
||||||
|
// timestamp would mark every page as changed on every deploy, which search engines
|
||||||
|
// learn to distrust.
|
||||||
|
const lastModifiedFor = (files) => {
|
||||||
|
let newest = null
|
||||||
|
for (const file of files) {
|
||||||
|
try {
|
||||||
|
const iso = execFileSync('git', ['log', '-1', '--format=%cI', '--', file], {
|
||||||
|
cwd: path.join(__dirname, '..'),
|
||||||
|
encoding: 'utf8',
|
||||||
|
stdio: ['ignore', 'pipe', 'ignore'],
|
||||||
|
}).trim()
|
||||||
|
if (iso && (!newest || iso > newest)) newest = iso
|
||||||
|
} catch {
|
||||||
|
// git unavailable or file untracked — fall through
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return newest ? newest.slice(0, 10) : null
|
||||||
|
}
|
||||||
|
|
||||||
|
const ROUTE_SOURCES = {
|
||||||
|
'/': ['src/pages/Home.jsx'],
|
||||||
|
'/about': ['src/pages/About.jsx'],
|
||||||
|
'/services': ['src/pages/Services.jsx', 'src/data/services.js'],
|
||||||
|
'/industries': ['src/pages/Industries.jsx', 'src/data/industries.js'],
|
||||||
|
'/contact': ['src/pages/Contact.jsx'],
|
||||||
|
'/support': ['src/pages/Support.jsx'],
|
||||||
|
'/privacy-policy': ['src/pages/PrivacyPolicy.jsx', 'src/data/privacyPolicy.js'],
|
||||||
|
}
|
||||||
|
|
||||||
|
const PRIORITY = {
|
||||||
|
'/': '1.0',
|
||||||
|
'/services': '0.9',
|
||||||
|
'/contact': '0.9',
|
||||||
|
'/about': '0.8',
|
||||||
|
'/industries': '0.8',
|
||||||
|
'/support': '0.8',
|
||||||
|
'/privacy-policy': '0.3',
|
||||||
|
}
|
||||||
|
|
||||||
|
const CHANGEFREQ = { '/': 'weekly', '/privacy-policy': 'yearly' }
|
||||||
|
|
||||||
|
const sourcesFor = (url) => {
|
||||||
|
if (ROUTE_SOURCES[url]) return ROUTE_SOURCES[url]
|
||||||
|
if (url.startsWith('/services/')) return ['src/pages/ServiceDetail.jsx', 'src/data/services.js']
|
||||||
|
return ['src/pages/IndustryDetail.jsx', 'src/data/industries.js']
|
||||||
|
}
|
||||||
|
|
||||||
|
const entries = routes.map((url) => {
|
||||||
|
const loc = url === '/' ? SITE_URL : `${SITE_URL}${url}`
|
||||||
|
const lastmod = lastModifiedFor(sourcesFor(url))
|
||||||
|
return [
|
||||||
|
' <url>',
|
||||||
|
` <loc>${loc}</loc>`,
|
||||||
|
lastmod ? ` <lastmod>${lastmod}</lastmod>` : null,
|
||||||
|
` <changefreq>${CHANGEFREQ[url] || 'monthly'}</changefreq>`,
|
||||||
|
` <priority>${PRIORITY[url] || '0.7'}</priority>`,
|
||||||
|
' </url>',
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join('\n')
|
||||||
|
})
|
||||||
|
|
||||||
|
const sitemap = [
|
||||||
|
'<?xml version="1.0" encoding="UTF-8"?>',
|
||||||
|
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">',
|
||||||
|
...entries,
|
||||||
|
'</urlset>',
|
||||||
|
'',
|
||||||
|
].join('\n')
|
||||||
|
|
||||||
|
writeFileSync(path.join(distDir, 'sitemap.xml'), sitemap)
|
||||||
|
console.log(`\nGenerated sitemap.xml with ${routes.length} URLs`)
|
||||||
|
|
@ -914,16 +914,92 @@ app.use((req, res, next) => {
|
||||||
})
|
})
|
||||||
|
|
||||||
// Static file serving for SPA
|
// Static file serving for SPA
|
||||||
app.use(express.static(path.join(__dirname, '../dist')))
|
// Collapse the URL variants that would otherwise serve identical content at
|
||||||
|
// several addresses (/about/, //about, /index.html), which search engines treat
|
||||||
|
// as duplicates and which split ranking signals across URLs.
|
||||||
|
app.get(/.*/, (req, res, next) => {
|
||||||
|
if (req.path.startsWith('/api')) return next()
|
||||||
|
|
||||||
|
let normalised = req.path.replace(/\/{2,}/g, '/')
|
||||||
|
if (normalised.endsWith('/index.html')) {
|
||||||
|
normalised = normalised.slice(0, -'index.html'.length)
|
||||||
|
}
|
||||||
|
if (normalised.length > 1) {
|
||||||
|
normalised = normalised.replace(/\/+$/, '') || '/'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalised !== req.path) {
|
||||||
|
const query = req.originalUrl.slice(req.path.length)
|
||||||
|
return res.redirect(301, `${normalised}${query}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return next()
|
||||||
|
})
|
||||||
|
|
||||||
|
// Public images (Open Graph / Twitter card art, logos, favicons) must be embeddable
|
||||||
|
// from other origins. Helmet's global same-origin CORP would otherwise cause social
|
||||||
|
// platforms and link-preview clients that hotlink the URL to drop the image.
|
||||||
|
const PUBLICLY_EMBEDDABLE = /\.(png|jpe?g|webp|gif|svg|ico|avif|webmanifest)$/i
|
||||||
|
|
||||||
|
// Vite fingerprints JS/CSS with a content hash, so a given URL's bytes never
|
||||||
|
// change — safe to cache for a year. A new deploy emits new filenames.
|
||||||
|
const CONTENT_HASHED = /-[A-Za-z0-9_-]{8,}\.(js|css)$/
|
||||||
|
|
||||||
|
app.use(express.static(path.join(__dirname, '../dist'), {
|
||||||
|
// Prerendered routes live at dist/<route>/index.html. Serving them is handled
|
||||||
|
// explicitly below so that /about resolves directly instead of 301-redirecting
|
||||||
|
// to /about/, which would fight the canonical (no-trailing-slash) URLs.
|
||||||
|
redirect: false,
|
||||||
|
index: false,
|
||||||
|
setHeaders: (res, filePath) => {
|
||||||
|
// CORP is what governs <img> embedding across origins; CORS headers are
|
||||||
|
// deliberately left untouched so they stay consistent with the API's
|
||||||
|
// credentialed same-origin policy.
|
||||||
|
if (CONTENT_HASHED.test(filePath)) {
|
||||||
|
res.setHeader('Cache-Control', 'public, max-age=31536000, immutable')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (PUBLICLY_EMBEDDABLE.test(filePath)) {
|
||||||
|
res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin')
|
||||||
|
res.setHeader('Cache-Control', 'public, max-age=86400')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Serve build-time prerendered pages (scripts/prerender.js). Every route has real
|
||||||
|
// HTML with its own title, description, canonical, and Open Graph tags, so crawlers
|
||||||
|
// that do not execute JavaScript still index the site correctly. React hydrates the
|
||||||
|
// markup on the client.
|
||||||
|
//
|
||||||
|
// Unknown paths get the prerendered 404 document with a genuine 404 status, which
|
||||||
|
// stops search engines from indexing arbitrary URLs as duplicates of the homepage.
|
||||||
|
const distDir = path.join(__dirname, '../dist')
|
||||||
|
|
||||||
// SPA catch-all — serve index.html for any non-API, non-asset route
|
|
||||||
// This lets React Router handle client-side routing
|
|
||||||
app.get('*', (req, res, next) => {
|
app.get('*', (req, res, next) => {
|
||||||
// Skip API routes (already handled above) and requests for static assets
|
// Skip API routes (already handled above) and requests for static assets
|
||||||
if (req.path.startsWith('/api/') || req.path.includes('.')) {
|
if (req.path.startsWith('/api/') || req.path.includes('.')) {
|
||||||
return next()
|
return next()
|
||||||
}
|
}
|
||||||
res.sendFile(path.join(__dirname, '../dist/index.html'))
|
|
||||||
|
const normalised = req.path.replace(/\/+$/, '') || '/'
|
||||||
|
const candidate =
|
||||||
|
normalised === '/'
|
||||||
|
? path.join(distDir, 'index.html')
|
||||||
|
: path.join(distDir, normalised, 'index.html')
|
||||||
|
|
||||||
|
// Guard against path traversal before touching the filesystem
|
||||||
|
if (!candidate.startsWith(distDir)) {
|
||||||
|
return res.status(404).sendFile(path.join(distDir, '404.html'))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prerendered HTML is replaced on every deploy, so it must always revalidate.
|
||||||
|
res.setHeader('Cache-Control', 'no-cache')
|
||||||
|
|
||||||
|
if (existsSync(candidate)) {
|
||||||
|
return res.sendFile(candidate)
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.status(404).sendFile(path.join(distDir, '404.html'))
|
||||||
})
|
})
|
||||||
|
|
||||||
app.listen(PORT, () => {
|
app.listen(PORT, () => {
|
||||||
|
|
|
||||||
|
|
@ -4,13 +4,14 @@ const DEFAULT_IMAGE = 'https://queuenorth.com/assets/og-image.png'
|
||||||
const DEFAULT_IMAGE_ALT = 'Queue North Technologies — Business Communications & IT Partner'
|
const DEFAULT_IMAGE_ALT = 'Queue North Technologies — Business Communications & IT Partner'
|
||||||
const SITE_NAME = 'Queue North Technologies'
|
const SITE_NAME = 'Queue North Technologies'
|
||||||
|
|
||||||
const SEO = ({ title, description, url, type = 'website', image = DEFAULT_IMAGE, jsonLd }) => {
|
const SEO = ({ title, description, url, type = 'website', image = DEFAULT_IMAGE, jsonLd, noindex = false }) => {
|
||||||
const schemas = jsonLd ? (Array.isArray(jsonLd) ? jsonLd : [jsonLd]) : []
|
const schemas = jsonLd ? (Array.isArray(jsonLd) ? jsonLd : [jsonLd]) : []
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Helmet>
|
<Helmet>
|
||||||
<title>{title}</title>
|
<title>{title}</title>
|
||||||
<meta name="description" content={description} />
|
<meta name="description" content={description} />
|
||||||
|
{noindex && <meta name="robots" content="noindex, follow" />}
|
||||||
|
|
||||||
{/* Canonical URL — prevents duplicate content */}
|
{/* Canonical URL — prevents duplicate content */}
|
||||||
<link rel="canonical" href={url} />
|
<link rel="canonical" href={url} />
|
||||||
|
|
|
||||||
|
|
@ -6,8 +6,6 @@ const Footer = () => {
|
||||||
const companyInfo = {
|
const companyInfo = {
|
||||||
name: 'Queue North Technologies',
|
name: 'Queue North Technologies',
|
||||||
tagline: 'Modern communications infrastructure without the vendor noise.',
|
tagline: 'Modern communications infrastructure without the vendor noise.',
|
||||||
addressLine1: '7901 4th St N',
|
|
||||||
addressLine2: 'St. Petersburg, FL 33702',
|
|
||||||
phone: '(321) 730-8020',
|
phone: '(321) 730-8020',
|
||||||
tollFree: '(888) 656-2850',
|
tollFree: '(888) 656-2850',
|
||||||
}
|
}
|
||||||
|
|
@ -19,6 +17,7 @@ const Footer = () => {
|
||||||
{ name: 'About', href: '/about' },
|
{ name: 'About', href: '/about' },
|
||||||
{ name: 'Contact', href: '/contact' },
|
{ name: 'Contact', href: '/contact' },
|
||||||
{ name: 'Support', href: '/support' },
|
{ name: 'Support', href: '/support' },
|
||||||
|
{ name: 'Privacy Policy', href: '/privacy-policy' },
|
||||||
]
|
]
|
||||||
|
|
||||||
const services = [
|
const services = [
|
||||||
|
|
@ -49,20 +48,14 @@ const Footer = () => {
|
||||||
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"
|
||||||
|
loading="lazy"
|
||||||
|
decoding="async"
|
||||||
|
width="200"
|
||||||
|
height="200"
|
||||||
/>
|
/>
|
||||||
<span className="font-bold text-sm leading-tight tracking-tight text-white sm:text-xl sm:whitespace-nowrap">Queue North Technologies</span>
|
<span className="font-bold text-sm leading-tight tracking-tight text-white sm:text-xl sm:whitespace-nowrap">Queue North Technologies</span>
|
||||||
</Link>
|
</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
|
|
||||||
href="https://maps.google.com/?q=7901+4th+St+N+St+Petersburg+FL+33702"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="block text-navy-light text-sm mb-3 hover:text-primary-cyan transition-colors leading-relaxed"
|
|
||||||
aria-label="View Queue North office on Google Maps"
|
|
||||||
>
|
|
||||||
<span className="block">{companyInfo.addressLine1}</span>
|
|
||||||
<span className="block">{companyInfo.addressLine2}</span>
|
|
||||||
</a>
|
|
||||||
<div className="space-y-2 text-navy-light text-sm mb-6">
|
<div className="space-y-2 text-navy-light text-sm mb-6">
|
||||||
<div>
|
<div>
|
||||||
<a href={`tel:+1${companyInfo.phone.replace(/\D/g, '')}`} className="hover:text-primary-cyan transition-colors" aria-label={`Call ${companyInfo.phone}`}>{companyInfo.phone}</a>
|
<a href={`tel:+1${companyInfo.phone.replace(/\D/g, '')}`} className="hover:text-primary-cyan transition-colors" aria-label={`Call ${companyInfo.phone}`}>{companyInfo.phone}</a>
|
||||||
|
|
@ -146,6 +139,8 @@ const Footer = () => {
|
||||||
src="/assets/brand/veteran-owned-certified.webp"
|
src="/assets/brand/veteran-owned-certified.webp"
|
||||||
alt="SBA Veteran-Owned Certified badge"
|
alt="SBA Veteran-Owned Certified badge"
|
||||||
className="h-full w-full object-contain"
|
className="h-full w-full object-contain"
|
||||||
|
loading="lazy"
|
||||||
|
decoding="async"
|
||||||
/>
|
/>
|
||||||
</span>
|
</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]">
|
||||||
|
|
@ -169,6 +164,13 @@ const Footer = () => {
|
||||||
<p className="text-navy-light text-xs">
|
<p className="text-navy-light text-xs">
|
||||||
© {currentYear} Queue North Technologies. All rights reserved.
|
© {currentYear} Queue North Technologies. All rights reserved.
|
||||||
</p>
|
</p>
|
||||||
|
<Link
|
||||||
|
to="/privacy-policy"
|
||||||
|
className="text-navy-light text-xs hover:text-primary-cyan transition-colors w-fit"
|
||||||
|
aria-label="Read the Queue North Technologies Privacy Policy"
|
||||||
|
>
|
||||||
|
Privacy Policy
|
||||||
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<a
|
<a
|
||||||
|
|
|
||||||
|
|
@ -63,6 +63,10 @@ const Header = () => {
|
||||||
src="/logo.png"
|
src="/logo.png"
|
||||||
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"
|
||||||
|
loading="eager"
|
||||||
|
decoding="async"
|
||||||
|
width="200"
|
||||||
|
height="200"
|
||||||
/>
|
/>
|
||||||
<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-sm sm:text-xl lg:text-2xl text-white whitespace-nowrap tracking-tight">Queue North Technologies</span>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
@ -162,7 +166,12 @@ const Header = () => {
|
||||||
{/* Logo + phone */}
|
{/* Logo + phone */}
|
||||||
<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} aria-label="Queue North Technologies Home" className="flex-shrink-0">
|
||||||
<img src="/logo.png" alt="Queue North Technologies" className="brand-logo-on-dark h-12 w-auto" />
|
<img src="/logo.png" alt="Queue North Technologies" className="brand-logo-on-dark h-12 w-auto"
|
||||||
|
loading="lazy"
|
||||||
|
decoding="async"
|
||||||
|
width="200"
|
||||||
|
height="200"
|
||||||
|
/>
|
||||||
</Link>
|
</Link>
|
||||||
<Link to="/" onClick={closeMobileMenu} className="font-bold text-sm tracking-tight whitespace-nowrap text-white leading-tight">
|
<Link to="/" onClick={closeMobileMenu} className="font-bold text-sm tracking-tight whitespace-nowrap text-white leading-tight">
|
||||||
Queue North Technologies
|
Queue North Technologies
|
||||||
|
|
|
||||||
|
|
@ -63,7 +63,11 @@ const MobileNav = () => {
|
||||||
<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"
|
className="brand-logo-on-dark h-12 w-auto"
|
||||||
|
loading="lazy"
|
||||||
|
decoding="async"
|
||||||
|
width="200"
|
||||||
|
height="200"
|
||||||
/>
|
/>
|
||||||
<span className="font-bold text-xl leading-tight">Queue North Technologies</span>
|
<span className="font-bold text-xl leading-tight">Queue North Technologies</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,527 @@
|
||||||
|
// Single source of truth for the approved privacy policy content.
|
||||||
|
// Consumed by the React page (src/pages/PrivacyPolicy.jsx) and by the server-side
|
||||||
|
// static HTML fallback (server/privacyPolicyHtml.js) so crawlers that do not
|
||||||
|
// execute JavaScript still receive the full policy text.
|
||||||
|
//
|
||||||
|
// Block types: 'p' (paragraph), 'ul' (bullet list), 'h3' (sub-heading),
|
||||||
|
// 'callout' (emphasised notice), 'email' (mailto line), 'contactBlock' (contact card)
|
||||||
|
|
||||||
|
export const EFFECTIVE_DATE = 'July 31, 2026'
|
||||||
|
export const LAST_UPDATED = 'July 31, 2026'
|
||||||
|
export const PRIVACY_EMAIL = 'privacy@queuenorth.com'
|
||||||
|
|
||||||
|
// Section content mirrors the approved policy document verbatim.
|
||||||
|
// Block types: 'p' (paragraph), 'ul' (bullet list), 'h3' (sub-heading), 'email' (mailto callout)
|
||||||
|
export const sections = [
|
||||||
|
{
|
||||||
|
id: 'scope',
|
||||||
|
title: 'Scope of This Policy',
|
||||||
|
blocks: [
|
||||||
|
{ type: 'p', text: 'Queue North Technologies (“Queue North,” “we,” “us,” or “our”) respects your privacy. This Privacy Policy explains how we collect, use, disclose, retain, and protect information when you interact with Queue North.' },
|
||||||
|
{ type: 'p', text: 'This Privacy Policy applies when you:' },
|
||||||
|
{
|
||||||
|
type: 'ul',
|
||||||
|
items: [
|
||||||
|
'Visit our website.',
|
||||||
|
'Submit a website or hosted lead form.',
|
||||||
|
'Respond to an advertisement.',
|
||||||
|
'Contact us by email, telephone, social media, or another communication method.',
|
||||||
|
'Request information, a consultation, a communications review, a proposal, or services.',
|
||||||
|
'Become or seek to become a customer, referral partner, vendor, contractor, or other business partner.',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ type: 'p', text: 'This policy applies to information collected through our website, advertisements, hosted forms, email, telephone communications, social media accounts, sales activities, customer relationships, and other business operations.' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'information-we-collect',
|
||||||
|
number: 1,
|
||||||
|
title: 'Information We Collect',
|
||||||
|
blocks: [
|
||||||
|
{ type: 'p', text: 'The information we collect depends on how you interact with Queue North.' },
|
||||||
|
|
||||||
|
{ type: 'h3', text: 'Contact and professional information' },
|
||||||
|
{ type: 'p', text: 'We may collect:' },
|
||||||
|
{
|
||||||
|
type: 'ul',
|
||||||
|
items: [
|
||||||
|
'First and last name.',
|
||||||
|
'Company or organization name.',
|
||||||
|
'Job title or professional role.',
|
||||||
|
'Business email address.',
|
||||||
|
'Business telephone or mobile number.',
|
||||||
|
'Mailing or business address when voluntarily provided.',
|
||||||
|
'Preferred method of contact.',
|
||||||
|
'Professional profile or social-media information.',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
{ type: 'h3', text: 'Company and service information' },
|
||||||
|
{ type: 'p', text: 'We may collect information about your organization and its needs, including:' },
|
||||||
|
{
|
||||||
|
type: 'ul',
|
||||||
|
items: [
|
||||||
|
'Company website.',
|
||||||
|
'Industry.',
|
||||||
|
'Number of employees, communications users, locations, devices, telephone numbers, or contact-center agents.',
|
||||||
|
'Current communications, technology, network, carrier, or service providers.',
|
||||||
|
'Current systems, products, licenses, and services.',
|
||||||
|
'Contract, renewal, migration, or purchasing timeframe.',
|
||||||
|
'Communications, contact-center, networking, Wi-Fi, support, security, or managed-service requirements.',
|
||||||
|
'Business challenges, service concerns, technical requirements, project details, and purchasing criteria.',
|
||||||
|
'Information contained in bills, contracts, inventories, diagrams, reports, or other documents you voluntarily provide.',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ type: 'callout', text: 'Please remove unnecessary sensitive personal information before sending documents to Queue North. Do not use a general inquiry or advertising form to submit Social Security numbers, payment-card numbers, medical information, account passwords, or other highly sensitive information.' },
|
||||||
|
|
||||||
|
{ type: 'h3', text: 'Communications and relationship information' },
|
||||||
|
{ type: 'p', text: 'We may retain information contained in or generated through:' },
|
||||||
|
{
|
||||||
|
type: 'ul',
|
||||||
|
items: [
|
||||||
|
'Emails.',
|
||||||
|
'Form submissions.',
|
||||||
|
'Telephone inquiries and voicemails.',
|
||||||
|
'Social-media messages.',
|
||||||
|
'Meetings and consultations.',
|
||||||
|
'Sales and discovery notes.',
|
||||||
|
'Proposals and quotes.',
|
||||||
|
'Project communications.',
|
||||||
|
'Support and service communications.',
|
||||||
|
'Feedback and other correspondence.',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
{ type: 'h3', text: 'Website, device, and advertising information' },
|
||||||
|
{ type: 'p', text: 'When you visit our website or interact with digital advertisements, we or our service providers may automatically collect information such as:' },
|
||||||
|
{
|
||||||
|
type: 'ul',
|
||||||
|
items: [
|
||||||
|
'Internet Protocol address.',
|
||||||
|
'Browser and device type.',
|
||||||
|
'Operating system.',
|
||||||
|
'Approximate location derived from an IP address.',
|
||||||
|
'Referring webpage or source.',
|
||||||
|
'Pages viewed.',
|
||||||
|
'Links selected.',
|
||||||
|
'Date and time of access.',
|
||||||
|
'Advertising and campaign identifiers.',
|
||||||
|
'Cookie, pixel, or similar technology identifiers.',
|
||||||
|
'Actions taken on our website, forms, or advertisements.',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
{ type: 'h3', text: 'Customer and transaction information' },
|
||||||
|
{ type: 'p', text: 'When applicable, we may collect or receive information concerning:' },
|
||||||
|
{
|
||||||
|
type: 'ul',
|
||||||
|
items: [
|
||||||
|
'Quotes and proposals.',
|
||||||
|
'Contracts and service agreements.',
|
||||||
|
'Products and services requested or purchased.',
|
||||||
|
'Project history.',
|
||||||
|
'Support history.',
|
||||||
|
'Invoices.',
|
||||||
|
'Payment status.',
|
||||||
|
'Customer-account activity.',
|
||||||
|
'Renewal and service dates.',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ type: 'p', text: 'Payment-card and bank-account details may be collected and processed directly by an authorized payment processor or financial-service provider. Queue North may receive transaction confirmations, payment status, and limited payment-related information without receiving complete card or bank credentials.' },
|
||||||
|
|
||||||
|
{ type: 'h3', text: 'Information from other sources' },
|
||||||
|
{ type: 'p', text: 'We may receive business contact information from:' },
|
||||||
|
{
|
||||||
|
type: 'ul',
|
||||||
|
items: [
|
||||||
|
'Referral partners.',
|
||||||
|
'Vendors and manufacturers.',
|
||||||
|
'Distributors and carriers.',
|
||||||
|
'Professional contacts.',
|
||||||
|
'Publicly available company websites.',
|
||||||
|
'Business directories.',
|
||||||
|
'Professional networking services.',
|
||||||
|
'Public records.',
|
||||||
|
'Other lawful business sources.',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'how-we-collect',
|
||||||
|
number: 2,
|
||||||
|
title: 'How We Collect Information',
|
||||||
|
blocks: [
|
||||||
|
{ type: 'p', text: 'We may collect information:' },
|
||||||
|
{
|
||||||
|
type: 'ul',
|
||||||
|
items: [
|
||||||
|
'Directly from you.',
|
||||||
|
'Through our website.',
|
||||||
|
'Through Google-hosted lead forms.',
|
||||||
|
'Through Facebook or Instagram Instant Forms.',
|
||||||
|
'Through Zoho Forms or other hosted forms.',
|
||||||
|
'Through email, telephone calls, meetings, and social media.',
|
||||||
|
'Through customer, vendor, referral, and professional relationships.',
|
||||||
|
'From publicly available business sources.',
|
||||||
|
'Through cookies, analytics, pixels, advertising identifiers, and similar technologies.',
|
||||||
|
'From service providers that support our operations.',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ type: 'p', text: 'When you interact with Google, Meta, LinkedIn, Zoho, or another third-party platform, that platform may separately collect and process information according to its own privacy policy and terms.' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'how-we-use',
|
||||||
|
number: 3,
|
||||||
|
title: 'How We Use Information',
|
||||||
|
blocks: [
|
||||||
|
{ type: 'p', text: 'Queue North may use information to:' },
|
||||||
|
{
|
||||||
|
type: 'ul',
|
||||||
|
items: [
|
||||||
|
'Respond to inquiries, requests, and form submissions.',
|
||||||
|
'Contact you about your inquiry.',
|
||||||
|
'Confirm that you represent a legitimate business or organization.',
|
||||||
|
'Determine whether our products or services are appropriate for your organization.',
|
||||||
|
'Conduct consultations, discovery meetings, reviews, assessments, and demonstrations.',
|
||||||
|
'Prepare recommendations, designs, proposals, quotes, contracts, and project documentation.',
|
||||||
|
'Provide, administer, maintain, support, and improve our products and services.',
|
||||||
|
'Manage customer, vendor, referral, and business relationships.',
|
||||||
|
'Maintain records in our customer relationship management and business systems.',
|
||||||
|
'Assign and track sales, project, support, and follow-up activities.',
|
||||||
|
'Communicate about relevant services, products, events, educational information, and business opportunities.',
|
||||||
|
'Personalize, deliver, and measure advertising where permitted.',
|
||||||
|
'Determine which advertisements, campaigns, forms, or referral sources generated inquiries.',
|
||||||
|
'Improve our website, forms, advertisements, services, and business processes.',
|
||||||
|
'Prevent fraud, abuse, security incidents, and unlawful activity.',
|
||||||
|
'Enforce contracts and protect our rights and property.',
|
||||||
|
'Meet accounting, tax, regulatory, contractual, and legal obligations.',
|
||||||
|
'Carry out another purpose disclosed when the information is collected.',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ type: 'p', text: 'Submitting an inquiry or lead form does not obligate you to purchase anything from Queue North.' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'advertising-lead-forms',
|
||||||
|
number: 4,
|
||||||
|
title: 'Advertising and Hosted Lead Forms',
|
||||||
|
blocks: [
|
||||||
|
{ type: 'p', text: 'Queue North may use lead-generation and advertising services provided by Google, Meta, LinkedIn, and other platforms.' },
|
||||||
|
{ type: 'p', text: 'When you submit a hosted lead form:' },
|
||||||
|
{
|
||||||
|
type: 'ul',
|
||||||
|
items: [
|
||||||
|
'The platform may prefill information associated with your account.',
|
||||||
|
'You decide whether to submit the form.',
|
||||||
|
'The information you submit is provided to Queue North.',
|
||||||
|
'Queue North may store the information in Zoho CRM or another authorized business system.',
|
||||||
|
'Queue North may contact you regarding your inquiry and related business services.',
|
||||||
|
'The advertising platform may separately process information under its own privacy policy and terms.',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ type: 'p', text: 'We may retain advertising information such as:' },
|
||||||
|
{
|
||||||
|
type: 'ul',
|
||||||
|
items: [
|
||||||
|
'Advertising platform.',
|
||||||
|
'Campaign.',
|
||||||
|
'Advertisement.',
|
||||||
|
'Keyword or search term.',
|
||||||
|
'Form type.',
|
||||||
|
'Referral source.',
|
||||||
|
'Date of submission.',
|
||||||
|
'Qualification responses.',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ type: 'p', text: 'We use this information to respond to inquiries, measure advertising performance, improve targeting, and determine which marketing efforts produce legitimate business opportunities.' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'cookies',
|
||||||
|
number: 5,
|
||||||
|
title: 'Cookies, Pixels, and Similar Technologies',
|
||||||
|
blocks: [
|
||||||
|
{ type: 'p', text: 'Our website and service providers may use cookies, pixels, tags, local storage, advertising identifiers, and similar technologies to:' },
|
||||||
|
{
|
||||||
|
type: 'ul',
|
||||||
|
items: [
|
||||||
|
'Operate and secure the website.',
|
||||||
|
'Remember preferences.',
|
||||||
|
'Understand website traffic and activity.',
|
||||||
|
'Diagnose errors and technical problems.',
|
||||||
|
'Measure advertising performance.',
|
||||||
|
'Attribute inquiries to advertisements or campaigns.',
|
||||||
|
'Improve content, forms, and services.',
|
||||||
|
'Support relevant advertising and retargeting where permitted.',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ type: 'p', text: 'Your browser may allow you to block, remove, or limit cookies. Blocking certain technologies may affect website functionality.' },
|
||||||
|
{ type: 'p', text: 'Google, Meta, LinkedIn, and other platforms provide separate privacy and advertising controls through their own services.' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'marketing',
|
||||||
|
number: 6,
|
||||||
|
title: 'Marketing Communications',
|
||||||
|
blocks: [
|
||||||
|
{ type: 'p', text: 'Queue North may contact you regarding:' },
|
||||||
|
{
|
||||||
|
type: 'ul',
|
||||||
|
items: [
|
||||||
|
'An inquiry or form submission.',
|
||||||
|
'A requested consultation, review, quote, or service.',
|
||||||
|
'Products or services related to your business needs.',
|
||||||
|
'Educational information.',
|
||||||
|
'Events.',
|
||||||
|
'Business updates.',
|
||||||
|
'Relevant professional opportunities.',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ type: 'p', text: 'You may unsubscribe from marketing emails through the unsubscribe link in the message or by contacting:' },
|
||||||
|
{ type: 'email' },
|
||||||
|
{ type: 'p', text: 'Where consent is legally required for marketing text messages, Queue North will seek the required consent before sending them.' },
|
||||||
|
{ type: 'p', text: 'Opting out of marketing does not prevent Queue North from sending necessary transactional, contractual, project, billing, security, service, or support communications.' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'disclosure',
|
||||||
|
number: 7,
|
||||||
|
title: 'How We Disclose Information',
|
||||||
|
blocks: [
|
||||||
|
{ type: 'p', text: 'Queue North may disclose information to the following categories of recipients.' },
|
||||||
|
|
||||||
|
{ type: 'h3', text: 'Service providers' },
|
||||||
|
{ type: 'p', text: 'We may use service providers for:' },
|
||||||
|
{
|
||||||
|
type: 'ul',
|
||||||
|
items: [
|
||||||
|
'Website hosting and administration.',
|
||||||
|
'Cloud storage.',
|
||||||
|
'Email and business communications.',
|
||||||
|
'Customer relationship management.',
|
||||||
|
'Hosted forms.',
|
||||||
|
'Advertising.',
|
||||||
|
'Analytics.',
|
||||||
|
'Scheduling.',
|
||||||
|
'Online meetings.',
|
||||||
|
'Accounting.',
|
||||||
|
'Invoicing.',
|
||||||
|
'Payment processing.',
|
||||||
|
'Project management.',
|
||||||
|
'Customer support.',
|
||||||
|
'Cybersecurity.',
|
||||||
|
'Information technology.',
|
||||||
|
'Document management.',
|
||||||
|
'Professional consulting.',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ type: 'p', text: 'These providers may process information on our behalf to perform services for Queue North.' },
|
||||||
|
{ type: 'p', text: 'Examples may include Google, Meta, Zoho, financial institutions, payment processors, hosting providers, communications providers, and other business-technology vendors.' },
|
||||||
|
|
||||||
|
{ type: 'h3', text: 'Technology vendors and delivery partners' },
|
||||||
|
{ type: 'p', text: 'When reasonably necessary to evaluate, quote, design, implement, deliver, or support a requested solution, we may disclose appropriate information to:' },
|
||||||
|
{
|
||||||
|
type: 'ul',
|
||||||
|
items: [
|
||||||
|
'Technology manufacturers.',
|
||||||
|
'Software and cloud-service providers.',
|
||||||
|
'Communications carriers.',
|
||||||
|
'Distributors.',
|
||||||
|
'Implementation partners.',
|
||||||
|
'Consultants.',
|
||||||
|
'Contractors.',
|
||||||
|
'Subcontractors.',
|
||||||
|
'Referral partners.',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ type: 'p', text: 'We limit these disclosures to information reasonably necessary for the relevant business purpose.' },
|
||||||
|
|
||||||
|
{ type: 'h3', text: 'Legal, regulatory, and safety disclosures' },
|
||||||
|
{ type: 'p', text: 'We may disclose information when reasonably necessary to:' },
|
||||||
|
{
|
||||||
|
type: 'ul',
|
||||||
|
items: [
|
||||||
|
'Comply with applicable law or regulation.',
|
||||||
|
'Respond to a subpoena, court order, or lawful governmental request.',
|
||||||
|
'Investigate suspected fraud, security incidents, or unlawful activity.',
|
||||||
|
'Protect Queue North, our customers, our business partners, or other people.',
|
||||||
|
'Establish, exercise, or defend legal claims.',
|
||||||
|
'Enforce our contracts, policies, or terms.',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
{ type: 'h3', text: 'Business transactions' },
|
||||||
|
{ type: 'p', text: 'Information may be disclosed or transferred in connection with:' },
|
||||||
|
{
|
||||||
|
type: 'ul',
|
||||||
|
items: [
|
||||||
|
'A merger.',
|
||||||
|
'Acquisition.',
|
||||||
|
'Financing.',
|
||||||
|
'Reorganization.',
|
||||||
|
'Sale of assets.',
|
||||||
|
'Bankruptcy.',
|
||||||
|
'Due diligence.',
|
||||||
|
'Another business transaction or proposed transaction.',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'sale-sharing',
|
||||||
|
number: 8,
|
||||||
|
title: 'Sale, Sharing, and Targeted Advertising',
|
||||||
|
blocks: [
|
||||||
|
{ type: 'p', text: 'Queue North does not sell personal information in exchange for money.' },
|
||||||
|
{ type: 'p', text: 'We may disclose online identifiers, campaign information, website activity, or similar information to advertising and analytics providers to:' },
|
||||||
|
{
|
||||||
|
type: 'ul',
|
||||||
|
items: [
|
||||||
|
'Deliver advertisements.',
|
||||||
|
'Measure advertising effectiveness.',
|
||||||
|
'Attribute inquiries to advertising campaigns.',
|
||||||
|
'Create or use advertising audiences.',
|
||||||
|
'Retarget people who have previously interacted with Queue North, where permitted.',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ type: 'p', text: 'Under some state privacy laws, certain advertising-related disclosures may be classified as “sharing,” “targeted advertising,” or a “sale,” even when no money is exchanged.' },
|
||||||
|
{ type: 'p', text: 'Where applicable law provides a right to opt out of these activities, you may submit a request to:' },
|
||||||
|
{ type: 'email' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'retention',
|
||||||
|
number: 9,
|
||||||
|
title: 'Data Retention',
|
||||||
|
blocks: [
|
||||||
|
{ type: 'p', text: 'Queue North retains information for as long as reasonably necessary to:' },
|
||||||
|
{
|
||||||
|
type: 'ul',
|
||||||
|
items: [
|
||||||
|
'Respond to inquiries.',
|
||||||
|
'Maintain sales and customer records.',
|
||||||
|
'Evaluate potential business opportunities.',
|
||||||
|
'Provide and support services.',
|
||||||
|
'Administer contracts and projects.',
|
||||||
|
'Maintain accounting and tax records.',
|
||||||
|
'Resolve disputes.',
|
||||||
|
'Prevent fraud and security incidents.',
|
||||||
|
'Meet legal, regulatory, and contractual obligations.',
|
||||||
|
'Enforce agreements.',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ type: 'p', text: 'Retention periods vary based on:' },
|
||||||
|
{
|
||||||
|
type: 'ul',
|
||||||
|
items: [
|
||||||
|
'The type of information.',
|
||||||
|
'The business relationship.',
|
||||||
|
'The reason the information was collected.',
|
||||||
|
'Contractual requirements.',
|
||||||
|
'Security needs.',
|
||||||
|
'Legal and regulatory obligations.',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ type: 'p', text: 'When information is no longer reasonably required, we may delete it, anonymize it, or securely archive it where continued retention is necessary.' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'security',
|
||||||
|
number: 10,
|
||||||
|
title: 'Information Security',
|
||||||
|
blocks: [
|
||||||
|
{ type: 'p', text: 'Queue North uses reasonable administrative, technical, and organizational safeguards designed to protect information against unauthorized access, use, alteration, disclosure, or destruction.' },
|
||||||
|
{ type: 'p', text: 'No internet transmission, electronic system, or storage method is completely secure. Queue North cannot guarantee absolute security.' },
|
||||||
|
{ type: 'p', text: 'The Federal Trade Commission requires businesses to honor the privacy and security representations they make. For that reason, this policy does not promise specific security controls that Queue North may not use in every circumstance.' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'rights',
|
||||||
|
number: 11,
|
||||||
|
title: 'Privacy Rights and Choices',
|
||||||
|
blocks: [
|
||||||
|
{ type: 'p', text: 'Depending on where you live and which laws apply, you may have the right to request that Queue North:' },
|
||||||
|
{
|
||||||
|
type: 'ul',
|
||||||
|
items: [
|
||||||
|
'Confirm whether we process information about you.',
|
||||||
|
'Provide access to certain information.',
|
||||||
|
'Correct inaccurate information.',
|
||||||
|
'Delete certain information.',
|
||||||
|
'Provide a portable copy of certain information.',
|
||||||
|
'Explain how information is used or disclosed.',
|
||||||
|
'Opt you out of marketing communications.',
|
||||||
|
'Opt you out of certain targeted advertising or data-sharing practices.',
|
||||||
|
'Limit certain uses or disclosures.',
|
||||||
|
'Review or appeal a decision concerning a privacy request.',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ type: 'p', text: 'Submit privacy requests to:' },
|
||||||
|
{ type: 'email' },
|
||||||
|
{ type: 'p', text: 'Please provide enough information for us to identify the relevant record and understand your request.' },
|
||||||
|
{ type: 'p', text: 'Queue North may:' },
|
||||||
|
{
|
||||||
|
type: 'ul',
|
||||||
|
items: [
|
||||||
|
'Verify your identity before completing a request.',
|
||||||
|
'Request additional information when reasonably necessary.',
|
||||||
|
'Deny or limit a request when permitted by law.',
|
||||||
|
'Retain information needed for security, accounting, legal, contractual, fraud-prevention, or operational purposes.',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ type: 'p', text: 'Authorized agents may submit requests where permitted by applicable law. Queue North may request evidence of the agent’s authority and may separately verify the request with the individual.' },
|
||||||
|
{ type: 'p', text: 'Queue North will not unlawfully discriminate against anyone for exercising an applicable privacy right.' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'childrens-privacy',
|
||||||
|
number: 12,
|
||||||
|
title: 'Children’s Privacy',
|
||||||
|
blocks: [
|
||||||
|
{ type: 'p', text: 'Queue North provides business-to-business technology and communications services.' },
|
||||||
|
{ type: 'p', text: 'Our website, advertisements, and forms are not directed to children under 13, and we do not knowingly seek to collect personal information from children under 13.' },
|
||||||
|
{ type: 'p', text: 'Contact privacy@queuenorth.com if you believe a child has submitted information to Queue North.', linkEmail: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'third-party',
|
||||||
|
number: 13,
|
||||||
|
title: 'Third-Party Websites and Services',
|
||||||
|
blocks: [
|
||||||
|
{ type: 'p', text: 'Our website, advertisements, emails, forms, and social-media accounts may contain links to third-party websites, services, and platforms.' },
|
||||||
|
{ type: 'p', text: 'Queue North does not control the privacy, security, content, or business practices of third parties. Review their privacy policies before providing information.' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'changes',
|
||||||
|
number: 14,
|
||||||
|
title: 'Changes to This Privacy Policy',
|
||||||
|
blocks: [
|
||||||
|
{ type: 'p', text: 'Queue North may update this Privacy Policy to reflect:' },
|
||||||
|
{
|
||||||
|
type: 'ul',
|
||||||
|
items: [
|
||||||
|
'Changes to our business.',
|
||||||
|
'New products or services.',
|
||||||
|
'Changes in technology.',
|
||||||
|
'Changes in advertising or data practices.',
|
||||||
|
'Changes in service providers.',
|
||||||
|
'Legal or regulatory developments.',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ type: 'p', text: 'The revised policy will be posted publicly with an updated “Last Updated” date.' },
|
||||||
|
{ type: 'p', text: 'Material changes may be communicated through additional methods when appropriate or legally required.' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'contact',
|
||||||
|
number: 15,
|
||||||
|
title: 'Contact Queue North',
|
||||||
|
blocks: [
|
||||||
|
{ type: 'p', text: 'For privacy questions, requests, complaints, corrections, deletion requests, advertising opt-outs, or marketing opt-outs, contact:' },
|
||||||
|
{ type: 'contactBlock' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
@ -0,0 +1,30 @@
|
||||||
|
import { renderToString } from 'react-dom/server'
|
||||||
|
// React Router v7 exports the server-side StaticRouter from the package root
|
||||||
|
// (v6's 'react-router-dom/server' subpath no longer exists).
|
||||||
|
import { StaticRouter, useRoutes } from 'react-router-dom'
|
||||||
|
import { HelmetProvider } from 'react-helmet-async'
|
||||||
|
import { routes } from './routes.jsx'
|
||||||
|
import './index.css'
|
||||||
|
|
||||||
|
const AppRoutes = () => useRoutes(routes)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders a single route to static HTML at build time.
|
||||||
|
* @param {string} url route path, e.g. '/about'
|
||||||
|
* @returns {{ html: string, helmet: object }} markup plus collected head tags
|
||||||
|
*/
|
||||||
|
export const render = (url) => {
|
||||||
|
const helmetContext = {}
|
||||||
|
|
||||||
|
const html = renderToString(
|
||||||
|
<HelmetProvider context={helmetContext}>
|
||||||
|
<StaticRouter location={url}>
|
||||||
|
<AppRoutes />
|
||||||
|
</StaticRouter>
|
||||||
|
</HelmetProvider>,
|
||||||
|
)
|
||||||
|
|
||||||
|
return { html, helmet: helmetContext.helmet }
|
||||||
|
}
|
||||||
|
|
||||||
|
export default render
|
||||||
|
|
@ -0,0 +1,45 @@
|
||||||
|
export const SITE_URL = 'https://queuenorth.com'
|
||||||
|
|
||||||
|
// Sitewide search/entity anchor. Referenced by the Organization entity on Home.
|
||||||
|
export const websiteLd = {
|
||||||
|
'@context': 'https://schema.org',
|
||||||
|
'@type': 'WebSite',
|
||||||
|
'@id': `${SITE_URL}/#website`,
|
||||||
|
url: SITE_URL,
|
||||||
|
name: 'Queue North Technologies',
|
||||||
|
publisher: { '@id': `${SITE_URL}/#organization` },
|
||||||
|
inLanguage: 'en-US',
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAX_DESCRIPTION = 158
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Joins description fragments and trims to what search engines actually display,
|
||||||
|
* cutting on a word boundary rather than mid-word.
|
||||||
|
* @param {...string} parts
|
||||||
|
*/
|
||||||
|
export const clampDescription = (...parts) => {
|
||||||
|
const text = parts.filter(Boolean).join(' ').replace(/\s+/g, ' ').trim()
|
||||||
|
if (text.length <= MAX_DESCRIPTION) return text
|
||||||
|
|
||||||
|
const cut = text.slice(0, MAX_DESCRIPTION)
|
||||||
|
return `${cut.slice(0, cut.lastIndexOf(' ')).replace(/[,;:.]$/, '')}…`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds a BreadcrumbList for a page's position in the site hierarchy.
|
||||||
|
* @param {Array<{name: string, path: string}>} trail ordered from the site root
|
||||||
|
*/
|
||||||
|
export const buildBreadcrumbLd = (trail) => ({
|
||||||
|
'@context': 'https://schema.org',
|
||||||
|
'@type': 'BreadcrumbList',
|
||||||
|
itemListElement: [
|
||||||
|
{ name: 'Home', path: '/' },
|
||||||
|
...trail,
|
||||||
|
].map((crumb, index) => ({
|
||||||
|
'@type': 'ListItem',
|
||||||
|
position: index + 1,
|
||||||
|
name: crumb.name,
|
||||||
|
item: `${SITE_URL}${crumb.path === '/' ? '' : crumb.path}`,
|
||||||
|
})),
|
||||||
|
})
|
||||||
13
src/main.jsx
13
src/main.jsx
|
|
@ -1,5 +1,5 @@
|
||||||
import { StrictMode } from 'react'
|
import { StrictMode } from 'react'
|
||||||
import { createRoot } from 'react-dom/client'
|
import { createRoot, hydrateRoot } from 'react-dom/client'
|
||||||
import { RouterProvider } from 'react-router-dom'
|
import { RouterProvider } from 'react-router-dom'
|
||||||
import { Toaster } from 'sonner'
|
import { Toaster } from 'sonner'
|
||||||
import { HelmetProvider } from 'react-helmet-async'
|
import { HelmetProvider } from 'react-helmet-async'
|
||||||
|
|
@ -19,4 +19,13 @@ const Root = () => (
|
||||||
</StrictMode>
|
</StrictMode>
|
||||||
)
|
)
|
||||||
|
|
||||||
createRoot(document.getElementById('root')).render(<Root />)
|
// Production pages are prerendered (scripts/prerender.js), so attach to the
|
||||||
|
// existing markup instead of discarding it. The dev server ships an empty shell,
|
||||||
|
// which falls back to a normal client render.
|
||||||
|
const container = document.getElementById('root')
|
||||||
|
|
||||||
|
if (container.hasChildNodes()) {
|
||||||
|
hydrateRoot(container, <Root />)
|
||||||
|
} else {
|
||||||
|
createRoot(container).render(<Root />)
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import SEO from '@/components/SEO'
|
import SEO from '@/components/SEO'
|
||||||
|
import { buildBreadcrumbLd } from '@/lib/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, Wrench } from 'lucide-react'
|
||||||
|
|
||||||
|
|
@ -97,9 +98,10 @@ const About = () => {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<SEO
|
<SEO
|
||||||
title="About Queue North | Veteran-Owned 8x8 Partner — 25+ Years of Service"
|
title="About Us | Veteran-Owned 8x8 Partner | Queue North"
|
||||||
description="Queue North Technologies is a veteran-owned 8x8 and Cisco Certified Partner with 25+ years of experience in communications, contact center, support, and network infrastructure."
|
description="Veteran-owned 8x8 and Cisco Certified Partner with 25+ years in communications, contact center, managed support, and network infrastructure."
|
||||||
url="https://queuenorth.com/about"
|
url="https://queuenorth.com/about"
|
||||||
|
jsonLd={buildBreadcrumbLd([{ name: 'About', path: '/about' }])}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Page Hero */}
|
{/* Page Hero */}
|
||||||
|
|
@ -109,6 +111,9 @@ const About = () => {
|
||||||
src="/assets/about-image.webp"
|
src="/assets/about-image.webp"
|
||||||
alt="Compass on a dark navigation map"
|
alt="Compass on a dark navigation map"
|
||||||
className="h-full w-full object-cover object-[66%_top] md:object-[62%_top]"
|
className="h-full w-full object-cover object-[66%_top] md:object-[62%_top]"
|
||||||
|
loading="eager"
|
||||||
|
fetchPriority="high"
|
||||||
|
decoding="async"
|
||||||
/>
|
/>
|
||||||
<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" />
|
||||||
|
|
@ -150,6 +155,8 @@ const About = () => {
|
||||||
src={point.logo}
|
src={point.logo}
|
||||||
alt={point.logoAlt}
|
alt={point.logoAlt}
|
||||||
className={`${point.logoClassName} object-contain`}
|
className={`${point.logoClassName} object-contain`}
|
||||||
|
loading="lazy"
|
||||||
|
decoding="async"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<Icon className="h-7 w-7" aria-hidden="true" />
|
<Icon className="h-7 w-7" aria-hidden="true" />
|
||||||
|
|
@ -245,6 +252,9 @@ const About = () => {
|
||||||
<p className="mt-5 text-lg leading-relaxed text-soft-text">
|
<p className="mt-5 text-lg leading-relaxed text-soft-text">
|
||||||
We work across the pieces that make communications reliable: platforms, networks, users, support process, and vendor coordination.
|
We work across the pieces that make communications reliable: platforms, networks, users, support process, and vendor coordination.
|
||||||
</p>
|
</p>
|
||||||
|
<p className="mt-4 text-base leading-relaxed text-soft-text">
|
||||||
|
Queue North Technologies provides remote business communications and technology services to organizations throughout the United States. On-site services are available based on project scope and location.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import SEO from '@/components/SEO'
|
import SEO from '@/components/SEO'
|
||||||
|
import { buildBreadcrumbLd } from '@/lib/seo'
|
||||||
import { useCallback, useEffect, useState } from 'react'
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { Button } from '@/components/ui/Button'
|
import { Button } from '@/components/ui/Button'
|
||||||
|
|
@ -151,23 +152,16 @@ const Contact = () => {
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Office',
|
label: 'Service Area',
|
||||||
icon: (
|
icon: (
|
||||||
<svg className="h-5 w-5 text-primary-cyan" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
<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="M15 10.5a3 3 0 11-6 0 3 3 0 016 0z" />
|
<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" />
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 10.5c0 7.142-7.5 11.25-7.5 11.25S4.5 17.642 4.5 10.5a7.5 7.5 0 1115 0z" />
|
|
||||||
</svg>
|
</svg>
|
||||||
),
|
),
|
||||||
content: (
|
content: (
|
||||||
<a
|
<p className="text-white/80 text-sm leading-relaxed">
|
||||||
href="https://maps.google.com/?q=7901+4th+St+N+St+Petersburg+FL+33702"
|
Remote services nationwide. On-site services available based on project scope and location.
|
||||||
target="_blank"
|
</p>
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="text-white hover:text-primary-cyan transition-colors leading-relaxed"
|
|
||||||
>
|
|
||||||
<span className="block">7901 4th St N</span>
|
|
||||||
<span className="block">St. Petersburg, FL 33702</span>
|
|
||||||
</a>
|
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -193,8 +187,9 @@ const Contact = () => {
|
||||||
<>
|
<>
|
||||||
<SEO
|
<SEO
|
||||||
title="Contact Queue North | Schedule a Free Consultation"
|
title="Contact Queue North | Schedule a Free Consultation"
|
||||||
description="Contact Queue North Technologies to schedule a free consultation. Call (321) 730-8020 or toll-free (888) 656-2850 for business phone, UCaaS, IT support, and networking solutions."
|
description="Schedule a free consultation with Queue North. Call (321) 730-8020 or (888) 656-2850 for business phone, UCaaS, IT support, and networking."
|
||||||
url="https://queuenorth.com/contact"
|
url="https://queuenorth.com/contact"
|
||||||
|
jsonLd={buildBreadcrumbLd([{ name: 'Contact', path: '/contact' }])}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Hero */}
|
{/* Hero */}
|
||||||
|
|
@ -204,6 +199,9 @@ const Contact = () => {
|
||||||
src="/assets/hero-tech.webp"
|
src="/assets/hero-tech.webp"
|
||||||
alt="Queue North communications infrastructure consultation"
|
alt="Queue North communications infrastructure consultation"
|
||||||
className="h-full w-full object-cover object-center"
|
className="h-full w-full object-cover object-center"
|
||||||
|
loading="eager"
|
||||||
|
fetchPriority="high"
|
||||||
|
decoding="async"
|
||||||
/>
|
/>
|
||||||
<div className="absolute inset-0 bg-primary-navy/82" />
|
<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 className="absolute inset-0 bg-gradient-to-r from-primary-navy via-primary-navy/92 to-primary-navy/45" />
|
||||||
|
|
@ -369,7 +367,7 @@ const Contact = () => {
|
||||||
required
|
required
|
||||||
autoComplete="postal-code"
|
autoComplete="postal-code"
|
||||||
inputMode="numeric"
|
inputMode="numeric"
|
||||||
placeholder="33702"
|
placeholder="12345"
|
||||||
className={debouncedErrors['Zip Code'] ? 'border-red-500 focus-visible:ring-red-500' : ''}
|
className={debouncedErrors['Zip Code'] ? '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 Code'] && <p className="text-xs text-red-500 mt-1">{debouncedErrors['Zip Code']}</p>}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import SEO from '@/components/SEO'
|
import SEO from '@/components/SEO'
|
||||||
|
import { websiteLd } from '@/lib/seo'
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/Card'
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/Card'
|
||||||
import { services } from '@/data/services'
|
import { services } from '@/data/services'
|
||||||
import { industries } from '@/data/industries'
|
import { industries } from '@/data/industries'
|
||||||
|
|
@ -48,23 +49,24 @@ const industryAccentStyles = [
|
||||||
]
|
]
|
||||||
|
|
||||||
const Home = () => {
|
const Home = () => {
|
||||||
|
// Single Organization entity — no LocalBusiness/address markup, since Queue North
|
||||||
|
// does not publish a physical location.
|
||||||
const organizationLd = {
|
const organizationLd = {
|
||||||
'@context': 'https://schema.org',
|
'@context': 'https://schema.org',
|
||||||
'@type': 'Organization',
|
'@type': 'Organization',
|
||||||
|
'@id': 'https://queuenorth.com/#organization',
|
||||||
name: 'Queue North Technologies',
|
name: 'Queue North Technologies',
|
||||||
url: 'https://queuenorth.com',
|
url: 'https://queuenorth.com',
|
||||||
|
image: 'https://queuenorth.com/assets/og-image.png',
|
||||||
|
telephone: '+1-321-730-8020',
|
||||||
logo: {
|
logo: {
|
||||||
'@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 and Cisco Certified Partner providing business phone systems, UCaaS, contact center, IT support, and networking solutions.',
|
||||||
address: {
|
areaServed: {
|
||||||
'@type': 'PostalAddress',
|
'@type': 'Country',
|
||||||
streetAddress: '7901 4th St N',
|
name: 'United States',
|
||||||
addressLocality: 'St. Petersburg',
|
|
||||||
addressRegion: 'FL',
|
|
||||||
postalCode: '33702',
|
|
||||||
addressCountry: 'US',
|
|
||||||
},
|
},
|
||||||
contactPoint: [
|
contactPoint: [
|
||||||
{
|
{
|
||||||
|
|
@ -84,50 +86,17 @@ const Home = () => {
|
||||||
sameAs: [
|
sameAs: [
|
||||||
'https://www.linkedin.com/company/queue-north-technologies-llc',
|
'https://www.linkedin.com/company/queue-north-technologies-llc',
|
||||||
'https://www.facebook.com/QueueNorth',
|
'https://www.facebook.com/QueueNorth',
|
||||||
],
|
'https://www.instagram.com/queue_north/',
|
||||||
}
|
|
||||||
|
|
||||||
const localBusinessLd = {
|
|
||||||
'@context': 'https://schema.org',
|
|
||||||
'@type': 'ProfessionalService',
|
|
||||||
'@id': 'https://queuenorth.com/#business',
|
|
||||||
name: 'Queue North Technologies',
|
|
||||||
image: 'https://queuenorth.com/assets/og-image.png',
|
|
||||||
url: 'https://queuenorth.com',
|
|
||||||
telephone: '+1-321-730-8020',
|
|
||||||
address: {
|
|
||||||
'@type': 'PostalAddress',
|
|
||||||
streetAddress: '7901 4th St N',
|
|
||||||
addressLocality: 'St. Petersburg',
|
|
||||||
addressRegion: 'FL',
|
|
||||||
postalCode: '33702',
|
|
||||||
addressCountry: 'US',
|
|
||||||
},
|
|
||||||
geo: {
|
|
||||||
'@type': 'GeoCoordinates',
|
|
||||||
latitude: 27.8306,
|
|
||||||
longitude: -82.6765,
|
|
||||||
},
|
|
||||||
priceRange: '$$',
|
|
||||||
openingHoursSpecification: {
|
|
||||||
'@type': 'OpeningHoursSpecification',
|
|
||||||
dayOfWeek: ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday'],
|
|
||||||
opens: '08:00',
|
|
||||||
closes: '18:00',
|
|
||||||
},
|
|
||||||
sameAs: [
|
|
||||||
'https://www.linkedin.com/company/queue-north-technologies-llc',
|
|
||||||
'https://www.facebook.com/QueueNorth',
|
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<SEO
|
<SEO
|
||||||
title="Queue North Technologies | Business Communications & IT Partner"
|
title="Business Phone, UCaaS & IT Services | Queue North"
|
||||||
description="Queue North Technologies is a veteran-owned 8x8 Certified Partner providing business phone systems, UCaaS, contact center, IT support, and networking solutions. 25+ years of proven reliability."
|
description="Veteran-owned 8x8 and Cisco Certified Partner delivering business phone systems, UCaaS, contact center, IT support, and networking. 25+ years."
|
||||||
url="https://queuenorth.com"
|
url="https://queuenorth.com"
|
||||||
jsonLd={[organizationLd, localBusinessLd]}
|
jsonLd={[organizationLd, websiteLd]}
|
||||||
/>
|
/>
|
||||||
{/* Hero Section */}
|
{/* Hero Section */}
|
||||||
<section className="relative isolate overflow-hidden bg-primary-navy text-white">
|
<section className="relative isolate overflow-hidden bg-primary-navy text-white">
|
||||||
|
|
@ -136,6 +105,9 @@ const Home = () => {
|
||||||
src="/assets/hero-tech.webp"
|
src="/assets/hero-tech.webp"
|
||||||
alt="Queue North technician working inside a communications rack"
|
alt="Queue North technician working inside a communications rack"
|
||||||
className="h-full w-full object-cover object-center"
|
className="h-full w-full object-cover object-center"
|
||||||
|
loading="eager"
|
||||||
|
fetchPriority="high"
|
||||||
|
decoding="async"
|
||||||
/>
|
/>
|
||||||
<div className="absolute inset-0 bg-primary-navy/65" />
|
<div className="absolute inset-0 bg-primary-navy/65" />
|
||||||
<div className="absolute inset-0 bg-gradient-to-r from-primary-navy via-primary-navy/90 to-primary-navy/25" />
|
<div className="absolute inset-0 bg-gradient-to-r from-primary-navy via-primary-navy/90 to-primary-navy/25" />
|
||||||
|
|
@ -175,6 +147,8 @@ const Home = () => {
|
||||||
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-full w-full object-contain"
|
||||||
|
loading="lazy"
|
||||||
|
decoding="async"
|
||||||
/>
|
/>
|
||||||
</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 text-center lg:text-left">8x8 Certified Partner</span>
|
||||||
|
|
@ -185,6 +159,8 @@ const Home = () => {
|
||||||
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-full w-full object-contain scale-[1.5]"
|
||||||
|
loading="lazy"
|
||||||
|
decoding="async"
|
||||||
/>
|
/>
|
||||||
</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 text-center lg:text-left">Cisco Certified Partner</span>
|
||||||
|
|
@ -195,6 +171,8 @@ const Home = () => {
|
||||||
src="/assets/brand/veteran-owned-certified-mark.webp"
|
src="/assets/brand/veteran-owned-certified-mark.webp"
|
||||||
alt="SBA logo for Veteran-Owned Certified"
|
alt="SBA logo for Veteran-Owned Certified"
|
||||||
className="h-full w-full object-contain"
|
className="h-full w-full object-contain"
|
||||||
|
loading="lazy"
|
||||||
|
decoding="async"
|
||||||
/>
|
/>
|
||||||
</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 text-center lg:text-left">Veteran-Owned Certified</span>
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import SEO from '@/components/SEO'
|
import SEO from '@/components/SEO'
|
||||||
|
import { buildBreadcrumbLd } from '@/lib/seo'
|
||||||
import { industries } from '@/data/industries'
|
import { industries } from '@/data/industries'
|
||||||
import { ArrowRight, Building2, CheckCircle2, HeartPulse, ShoppingCart, Factory, Landmark } from 'lucide-react'
|
import { ArrowRight, Building2, CheckCircle2, HeartPulse, ShoppingCart, Factory, Landmark } from 'lucide-react'
|
||||||
import { Link } from 'react-router-dom'
|
import { Link } from 'react-router-dom'
|
||||||
|
|
@ -21,9 +22,10 @@ const Industries = () => {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<SEO
|
<SEO
|
||||||
title="Industries We Serve | Queue North Technologies"
|
title="Industries We Serve | Queue North"
|
||||||
description="Queue North Technologies serves healthcare, retail, manufacturing, education, and finance industries with tailored communications and IT solutions."
|
description="Queue North Technologies serves healthcare, retail, manufacturing, education, and finance industries with tailored communications and IT solutions."
|
||||||
url="https://queuenorth.com/industries"
|
url="https://queuenorth.com/industries"
|
||||||
|
jsonLd={buildBreadcrumbLd([{ name: 'Industries', path: '/industries' }])}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Hero */}
|
{/* Hero */}
|
||||||
|
|
@ -33,6 +35,9 @@ const Industries = () => {
|
||||||
src="/assets/local-networking.webp"
|
src="/assets/local-networking.webp"
|
||||||
alt="Network infrastructure used by businesses across industries"
|
alt="Network infrastructure used by businesses across industries"
|
||||||
className="h-full w-full object-cover object-center"
|
className="h-full w-full object-cover object-center"
|
||||||
|
loading="eager"
|
||||||
|
fetchPriority="high"
|
||||||
|
decoding="async"
|
||||||
/>
|
/>
|
||||||
<div className="absolute inset-0 bg-primary-navy/82" />
|
<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 className="absolute inset-0 bg-gradient-to-r from-primary-navy via-primary-navy/92 to-primary-navy/45" />
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import SEO from '@/components/SEO'
|
import SEO from '@/components/SEO'
|
||||||
|
import { buildBreadcrumbLd, clampDescription } from '@/lib/seo'
|
||||||
import { useParams } from 'react-router-dom'
|
import { useParams } from 'react-router-dom'
|
||||||
import { industries } from '@/data/industries'
|
import { industries } from '@/data/industries'
|
||||||
import { Link } from 'react-router-dom'
|
import { Link } from 'react-router-dom'
|
||||||
|
|
@ -13,9 +14,10 @@ const IndustryDetail = () => {
|
||||||
return (
|
return (
|
||||||
<section className="bg-background py-12 lg:py-20">
|
<section className="bg-background py-12 lg:py-20">
|
||||||
<SEO
|
<SEO
|
||||||
title="Industry Not Found | Queue North Technologies"
|
title="Industry Not Found | Queue North"
|
||||||
description="The industry page you are looking for does not exist."
|
description="The industry page you are looking for does not exist. Browse the industries Queue North serves."
|
||||||
url="https://queuenorth.com/industries"
|
url="https://queuenorth.com/industries"
|
||||||
|
noindex
|
||||||
/>
|
/>
|
||||||
<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="text-center">
|
<div className="text-center">
|
||||||
|
|
@ -30,9 +32,16 @@ const IndustryDetail = () => {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const industryTitle = `${industry.name} | Queue North Technologies`
|
const industryTitle = `${industry.name} Communications & IT | Queue North`
|
||||||
const industryDesc = industry.shortDesc || `Learn about Queue North Technologies solutions for the ${industry.name} industry.`
|
const industryDesc = clampDescription(
|
||||||
|
industry.shortDesc,
|
||||||
|
`Queue North delivers phone, contact center, network, and IT support for ${industry.name.toLowerCase()} organizations.`,
|
||||||
|
)
|
||||||
const industryUrl = `https://queuenorth.com/industries/${industry.id}`
|
const industryUrl = `https://queuenorth.com/industries/${industry.id}`
|
||||||
|
const industryBreadcrumbLd = buildBreadcrumbLd([
|
||||||
|
{ name: 'Industries', path: '/industries' },
|
||||||
|
{ name: industry.name, path: `/industries/${industry.id}` },
|
||||||
|
])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|
@ -40,6 +49,7 @@ const IndustryDetail = () => {
|
||||||
title={industryTitle}
|
title={industryTitle}
|
||||||
description={industryDesc}
|
description={industryDesc}
|
||||||
url={industryUrl}
|
url={industryUrl}
|
||||||
|
jsonLd={industryBreadcrumbLd}
|
||||||
/>
|
/>
|
||||||
{/* Page Hero */}
|
{/* Page Hero */}
|
||||||
<section className="relative isolate overflow-hidden bg-primary-navy py-16 lg:py-24 text-white">
|
<section className="relative isolate overflow-hidden bg-primary-navy py-16 lg:py-24 text-white">
|
||||||
|
|
@ -48,6 +58,9 @@ const IndustryDetail = () => {
|
||||||
src="/assets/modern-call-center.webp"
|
src="/assets/modern-call-center.webp"
|
||||||
alt="Business communications team supporting industry operations"
|
alt="Business communications team supporting industry operations"
|
||||||
className="h-full w-full object-cover object-center"
|
className="h-full w-full object-cover object-center"
|
||||||
|
loading="eager"
|
||||||
|
fetchPriority="high"
|
||||||
|
decoding="async"
|
||||||
/>
|
/>
|
||||||
<div className="absolute inset-0 bg-primary-navy/84" />
|
<div className="absolute inset-0 bg-primary-navy/84" />
|
||||||
<div className="absolute inset-0 bg-gradient-to-r from-primary-navy via-primary-navy/92 to-primary-navy/50" />
|
<div className="absolute inset-0 bg-gradient-to-r from-primary-navy via-primary-navy/92 to-primary-navy/50" />
|
||||||
|
|
|
||||||
|
|
@ -47,6 +47,9 @@ export default function NotFound() {
|
||||||
src="/assets/about-image.webp"
|
src="/assets/about-image.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-[66%_top] md:object-[62%_top]"
|
||||||
|
loading="eager"
|
||||||
|
fetchPriority="high"
|
||||||
|
decoding="async"
|
||||||
/>
|
/>
|
||||||
<div className="absolute inset-0 bg-primary-navy/86" />
|
<div className="absolute inset-0 bg-primary-navy/86" />
|
||||||
<div className="absolute inset-0 bg-[radial-gradient(circle_at_72%_22%,rgba(34,211,238,0.28),transparent_28%),radial-gradient(circle_at_18%_74%,rgba(245,158,11,0.16),transparent_24%),linear-gradient(115deg,#071A2A_0%,rgba(11,42,60,0.96)_46%,rgba(7,26,42,0.74)_100%)]" />
|
<div className="absolute inset-0 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%)]" />
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,164 @@
|
||||||
|
import SEO from '@/components/SEO'
|
||||||
|
import { buildBreadcrumbLd } from '@/lib/seo'
|
||||||
|
import { ShieldCheck } from 'lucide-react'
|
||||||
|
import { EFFECTIVE_DATE, LAST_UPDATED, PRIVACY_EMAIL, sections } from '@/data/privacyPolicy'
|
||||||
|
|
||||||
|
const EmailLink = ({ className = '' }) => (
|
||||||
|
<a
|
||||||
|
href={`mailto:${PRIVACY_EMAIL}`}
|
||||||
|
className={`font-semibold text-primary-blue underline underline-offset-4 hover:text-primary-navy transition-colors ${className}`}
|
||||||
|
>
|
||||||
|
{PRIVACY_EMAIL}
|
||||||
|
</a>
|
||||||
|
)
|
||||||
|
|
||||||
|
// Renders a paragraph, splitting the privacy address out as a mailto link when present.
|
||||||
|
const Paragraph = ({ text, linkEmail }) => {
|
||||||
|
if (!linkEmail || !text.includes(PRIVACY_EMAIL)) {
|
||||||
|
return <p className="mt-4 text-base leading-relaxed text-soft-text">{text}</p>
|
||||||
|
}
|
||||||
|
|
||||||
|
const [before, after] = text.split(PRIVACY_EMAIL)
|
||||||
|
return (
|
||||||
|
<p className="mt-4 text-base leading-relaxed text-soft-text">
|
||||||
|
{before}
|
||||||
|
<EmailLink />
|
||||||
|
{after}
|
||||||
|
</p>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const Block = ({ block }) => {
|
||||||
|
switch (block.type) {
|
||||||
|
case 'h3':
|
||||||
|
return <h3 className="mt-8 text-lg font-semibold text-primary-navy">{block.text}</h3>
|
||||||
|
|
||||||
|
case 'ul':
|
||||||
|
return (
|
||||||
|
<ul className="mt-4 space-y-2">
|
||||||
|
{block.items.map((item) => (
|
||||||
|
<li key={item} className="flex gap-3 text-base leading-relaxed text-soft-text">
|
||||||
|
<span className="mt-2 h-1.5 w-1.5 shrink-0 rounded-full bg-primary-cyan" aria-hidden="true" />
|
||||||
|
<span>{item}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)
|
||||||
|
|
||||||
|
case 'callout':
|
||||||
|
return (
|
||||||
|
<p className="mt-5 rounded-md border border-border border-l-[3px] border-l-accent-gold bg-section-alt p-5 text-base leading-relaxed text-text">
|
||||||
|
{block.text}
|
||||||
|
</p>
|
||||||
|
)
|
||||||
|
|
||||||
|
case 'email':
|
||||||
|
return (
|
||||||
|
<p className="mt-4 text-base leading-relaxed">
|
||||||
|
<EmailLink />
|
||||||
|
</p>
|
||||||
|
)
|
||||||
|
|
||||||
|
case 'contactBlock':
|
||||||
|
return (
|
||||||
|
<div className="mt-5 rounded-md border border-border bg-section-alt p-6">
|
||||||
|
<p className="text-base font-semibold text-primary-navy">Queue North Technologies</p>
|
||||||
|
<p className="mt-2 text-base leading-relaxed text-soft-text">
|
||||||
|
Email: <EmailLink />
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-base leading-relaxed text-soft-text">
|
||||||
|
Website:{' '}
|
||||||
|
<a
|
||||||
|
href="https://queuenorth.com"
|
||||||
|
className="font-semibold text-primary-blue underline underline-offset-4 hover:text-primary-navy transition-colors"
|
||||||
|
>
|
||||||
|
https://queuenorth.com
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
default:
|
||||||
|
return <Paragraph text={block.text} linkEmail={block.linkEmail} />
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const PrivacyPolicy = () => {
|
||||||
|
const numberedSections = sections.filter((section) => section.number)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<SEO
|
||||||
|
title="Privacy Policy | Queue North Technologies"
|
||||||
|
description="How Queue North collects, uses, discloses, retains, and protects information across our website, advertisements, and hosted lead forms."
|
||||||
|
url="https://queuenorth.com/privacy-policy"
|
||||||
|
jsonLd={buildBreadcrumbLd([{ name: 'Privacy Policy', path: '/privacy-policy' }])}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Page Hero */}
|
||||||
|
<section className="bg-primary-navy py-16 lg:py-20 text-white">
|
||||||
|
<div className="max-w-4xl 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">
|
||||||
|
<ShieldCheck className="h-4 w-4" aria-hidden="true" />
|
||||||
|
Legal
|
||||||
|
</div>
|
||||||
|
<h1 className="mt-6 text-4xl md:text-5xl font-bold leading-tight">
|
||||||
|
Queue North Technologies Privacy Policy
|
||||||
|
</h1>
|
||||||
|
<dl className="mt-6 flex flex-col gap-2 text-sm text-white/75 sm:flex-row sm:gap-8">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<dt className="font-semibold text-white">Effective Date:</dt>
|
||||||
|
<dd>{EFFECTIVE_DATE}</dd>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<dt className="font-semibold text-white">Last Updated:</dt>
|
||||||
|
<dd>{LAST_UPDATED}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Policy Body */}
|
||||||
|
<section className="bg-background py-16 lg:py-20">
|
||||||
|
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
|
{/* Contents */}
|
||||||
|
<nav aria-label="Privacy policy contents" className="rounded-md border border-border bg-white p-6 shadow-sm">
|
||||||
|
<h2 className="text-sm font-semibold uppercase tracking-wide text-primary-blue">Contents</h2>
|
||||||
|
<ol className="mt-4 grid grid-cols-1 gap-x-8 gap-y-2 sm:grid-cols-2">
|
||||||
|
{numberedSections.map((section) => (
|
||||||
|
<li key={section.id} className="text-sm">
|
||||||
|
<a
|
||||||
|
href={`#${section.id}`}
|
||||||
|
className="text-soft-text hover:text-primary-blue transition-colors"
|
||||||
|
>
|
||||||
|
<span className="font-numeric font-semibold text-primary-navy">{section.number}.</span>{' '}
|
||||||
|
{section.title}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{sections.map((section) => (
|
||||||
|
<article key={section.id} id={section.id} className="mt-12 scroll-mt-28">
|
||||||
|
<h2 className="text-2xl md:text-3xl font-bold text-primary-navy">
|
||||||
|
{section.number ? (
|
||||||
|
<>
|
||||||
|
<span className="font-numeric">{section.number}.</span> {section.title}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
section.title
|
||||||
|
)}
|
||||||
|
</h2>
|
||||||
|
{section.blocks.map((block, index) => (
|
||||||
|
<Block key={`${section.id}-${index}`} block={block} />
|
||||||
|
))}
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default PrivacyPolicy
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import SEO from '@/components/SEO'
|
import SEO from '@/components/SEO'
|
||||||
|
import { buildBreadcrumbLd, clampDescription } from '@/lib/seo'
|
||||||
import { useParams } from 'react-router-dom'
|
import { useParams } from 'react-router-dom'
|
||||||
import { services } from '@/data/services'
|
import { services } from '@/data/services'
|
||||||
import { Link } from 'react-router-dom'
|
import { Link } from 'react-router-dom'
|
||||||
|
|
@ -19,9 +20,10 @@ const ServiceDetail = () => {
|
||||||
return (
|
return (
|
||||||
<section className="bg-background py-16 md:py-24">
|
<section className="bg-background py-16 md:py-24">
|
||||||
<SEO
|
<SEO
|
||||||
title="Service Not Found | Queue North Technologies"
|
title="Service Not Found | Queue North"
|
||||||
description="The service page you are looking for does not exist."
|
description="The service page you are looking for does not exist. Browse the services Queue North provides."
|
||||||
url="https://queuenorth.com/services"
|
url="https://queuenorth.com/services"
|
||||||
|
noindex
|
||||||
/>
|
/>
|
||||||
<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="text-center">
|
<div className="text-center">
|
||||||
|
|
@ -36,9 +38,16 @@ const ServiceDetail = () => {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const serviceTitle = `${service.name} | Queue North Technologies`
|
const serviceTitle = `${service.name} | Queue North`
|
||||||
const serviceDesc = service.shortDesc || `Learn about ${service.name} from Queue North Technologies.`
|
const serviceDesc = clampDescription(
|
||||||
|
service.shortDesc,
|
||||||
|
'Delivered by Queue North, a veteran-owned 8x8 and Cisco Certified Partner.',
|
||||||
|
)
|
||||||
const serviceUrl = `https://queuenorth.com/services/${service.id}`
|
const serviceUrl = `https://queuenorth.com/services/${service.id}`
|
||||||
|
const serviceBreadcrumbLd = buildBreadcrumbLd([
|
||||||
|
{ name: 'Services', path: '/services' },
|
||||||
|
{ name: service.name, path: `/services/${service.id}` },
|
||||||
|
])
|
||||||
const serviceDetailLd = {
|
const serviceDetailLd = {
|
||||||
'@context': 'https://schema.org',
|
'@context': 'https://schema.org',
|
||||||
'@type': 'Service',
|
'@type': 'Service',
|
||||||
|
|
@ -61,7 +70,7 @@ const ServiceDetail = () => {
|
||||||
title={serviceTitle}
|
title={serviceTitle}
|
||||||
description={serviceDesc}
|
description={serviceDesc}
|
||||||
url={serviceUrl}
|
url={serviceUrl}
|
||||||
jsonLd={serviceDetailLd}
|
jsonLd={[serviceDetailLd, serviceBreadcrumbLd]}
|
||||||
/>
|
/>
|
||||||
{/* Page Hero */}
|
{/* Page Hero */}
|
||||||
<section className="relative isolate overflow-hidden bg-primary-navy py-16 md:py-24 text-white">
|
<section className="relative isolate overflow-hidden bg-primary-navy py-16 md:py-24 text-white">
|
||||||
|
|
@ -70,6 +79,9 @@ const ServiceDetail = () => {
|
||||||
src={service.image || '/assets/hero-tech.webp'}
|
src={service.image || '/assets/hero-tech.webp'}
|
||||||
alt={serviceImageAlt[service.id] || `${service.name} service visual`}
|
alt={serviceImageAlt[service.id] || `${service.name} service visual`}
|
||||||
className="h-full w-full object-cover object-center"
|
className="h-full w-full object-cover object-center"
|
||||||
|
loading="eager"
|
||||||
|
fetchPriority="high"
|
||||||
|
decoding="async"
|
||||||
/>
|
/>
|
||||||
<div className="absolute inset-0 bg-primary-navy/84" />
|
<div className="absolute inset-0 bg-primary-navy/84" />
|
||||||
<div className="absolute inset-0 bg-gradient-to-r from-primary-navy via-primary-navy/92 to-primary-navy/50" />
|
<div className="absolute inset-0 bg-gradient-to-r from-primary-navy via-primary-navy/92 to-primary-navy/50" />
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import SEO from '@/components/SEO'
|
import SEO from '@/components/SEO'
|
||||||
|
import { buildBreadcrumbLd } from '@/lib/seo'
|
||||||
import { services } from '@/data/services'
|
import { services } from '@/data/services'
|
||||||
import { ArrowRight, MessageCircle, Users, LifeBuoy, GraduationCap, Link as LinkIcon, Wifi, Network, Layers, CheckCircle2, ShieldCheck, PhoneCall } from 'lucide-react'
|
import { ArrowRight, MessageCircle, Users, LifeBuoy, GraduationCap, Link as LinkIcon, Wifi, Network, Layers, CheckCircle2, ShieldCheck, PhoneCall } from 'lucide-react'
|
||||||
import { Link } from 'react-router-dom'
|
import { Link } from 'react-router-dom'
|
||||||
|
|
@ -65,10 +66,10 @@ const Services = () => {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<SEO
|
<SEO
|
||||||
title="Business Phone, UCaaS & IT Services | Queue North Technologies"
|
title="Communications & IT Services | Queue North"
|
||||||
description="Explore Queue North Technologies services: unified communications, contact center, managed IT support, consulting & training, infrastructure cabling, wireless access, and local networking."
|
description="Unified communications, contact center, managed IT support, consulting, cabling, wireless access, and local networking for growing businesses."
|
||||||
url="https://queuenorth.com/services"
|
url="https://queuenorth.com/services"
|
||||||
jsonLd={serviceLd}
|
jsonLd={[serviceLd, buildBreadcrumbLd([{ name: 'Services', path: '/services' }])]}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Hero */}
|
{/* Hero */}
|
||||||
|
|
@ -78,6 +79,9 @@ const Services = () => {
|
||||||
src="/assets/hero-tech.webp"
|
src="/assets/hero-tech.webp"
|
||||||
alt="Queue North technician working inside a communications rack"
|
alt="Queue North technician working inside a communications rack"
|
||||||
className="h-full w-full object-cover object-center"
|
className="h-full w-full object-cover object-center"
|
||||||
|
loading="eager"
|
||||||
|
fetchPriority="high"
|
||||||
|
decoding="async"
|
||||||
/>
|
/>
|
||||||
<div className="absolute inset-0 bg-primary-navy/75" />
|
<div className="absolute inset-0 bg-primary-navy/75" />
|
||||||
<div className="absolute inset-0 bg-gradient-to-r from-primary-navy via-primary-navy/90 to-primary-navy/35" />
|
<div className="absolute inset-0 bg-gradient-to-r from-primary-navy via-primary-navy/90 to-primary-navy/35" />
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import SEO from '@/components/SEO'
|
import SEO from '@/components/SEO'
|
||||||
|
import { buildBreadcrumbLd } from '@/lib/seo'
|
||||||
import { AlertCircle, ArrowRight, CheckCircle2, Clock3, ExternalLink, LifeBuoy, ShieldCheck, TicketCheck, Wrench } from 'lucide-react'
|
import { AlertCircle, ArrowRight, CheckCircle2, Clock3, ExternalLink, LifeBuoy, ShieldCheck, TicketCheck, Wrench } from 'lucide-react'
|
||||||
|
|
||||||
const portalLinks = [
|
const portalLinks = [
|
||||||
|
|
@ -34,6 +35,7 @@ const Support = () => {
|
||||||
title="IT Support & Help Desk | Queue North Technologies"
|
title="IT Support & Help Desk | Queue North Technologies"
|
||||||
description="Get IT support and help desk services from Queue North Technologies. 24/7 monitoring, rapid response SLAs, and dedicated support engineers for your business."
|
description="Get IT support and help desk services from Queue North Technologies. 24/7 monitoring, rapid response SLAs, and dedicated support engineers for your business."
|
||||||
url="https://queuenorth.com/support"
|
url="https://queuenorth.com/support"
|
||||||
|
jsonLd={buildBreadcrumbLd([{ name: 'Support', path: '/support' }])}
|
||||||
/>
|
/>
|
||||||
{/* Page Hero */}
|
{/* Page Hero */}
|
||||||
<section className="relative isolate overflow-hidden bg-primary-navy py-16 lg:py-24 text-white">
|
<section className="relative isolate overflow-hidden bg-primary-navy py-16 lg:py-24 text-white">
|
||||||
|
|
@ -42,6 +44,9 @@ const Support = () => {
|
||||||
src="/assets/modern-call-center.webp"
|
src="/assets/modern-call-center.webp"
|
||||||
alt="Support team managing communications requests"
|
alt="Support team managing communications requests"
|
||||||
className="h-full w-full object-cover object-center"
|
className="h-full w-full object-cover object-center"
|
||||||
|
loading="eager"
|
||||||
|
fetchPriority="high"
|
||||||
|
decoding="async"
|
||||||
/>
|
/>
|
||||||
<div className="absolute inset-0 bg-primary-navy/84" />
|
<div className="absolute inset-0 bg-primary-navy/84" />
|
||||||
<div className="absolute inset-0 bg-gradient-to-r from-primary-navy via-primary-navy/92 to-primary-navy/50" />
|
<div className="absolute inset-0 bg-gradient-to-r from-primary-navy via-primary-navy/92 to-primary-navy/50" />
|
||||||
|
|
|
||||||
|
|
@ -1,33 +1,6 @@
|
||||||
import { createBrowserRouter } from 'react-router-dom'
|
import { createBrowserRouter } from 'react-router-dom'
|
||||||
import App from './App.jsx'
|
import { routes } from './routes.jsx'
|
||||||
import Home from './pages/Home.jsx'
|
|
||||||
import About from './pages/About.jsx'
|
|
||||||
import Services from './pages/Services.jsx'
|
|
||||||
import ServiceDetail from './pages/ServiceDetail.jsx'
|
|
||||||
import Industries from './pages/Industries.jsx'
|
|
||||||
import IndustryDetail from './pages/IndustryDetail.jsx'
|
|
||||||
import Contact from './pages/Contact.jsx'
|
|
||||||
import Support from './pages/Support.jsx'
|
|
||||||
import NotFound from './pages/NotFound.jsx'
|
|
||||||
|
|
||||||
const router = createBrowserRouter([
|
const router = createBrowserRouter(routes)
|
||||||
{
|
|
||||||
path: '/',
|
|
||||||
element: (
|
|
||||||
<App />
|
|
||||||
),
|
|
||||||
children: [
|
|
||||||
{ index: true, element: <Home /> },
|
|
||||||
{ path: 'about', element: <About /> },
|
|
||||||
{ path: 'services', element: <Services /> },
|
|
||||||
{ path: 'services/:slug', element: <ServiceDetail /> },
|
|
||||||
{ path: 'industries', element: <Industries /> },
|
|
||||||
{ path: 'industries/:slug', element: <IndustryDetail /> },
|
|
||||||
{ path: 'contact', element: <Contact /> },
|
|
||||||
{ path: 'support', element: <Support /> },
|
|
||||||
{ path: '*', element: <NotFound /> },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
])
|
|
||||||
|
|
||||||
export default router
|
export default router
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,34 @@
|
||||||
|
import App from './App.jsx'
|
||||||
|
import Home from './pages/Home.jsx'
|
||||||
|
import About from './pages/About.jsx'
|
||||||
|
import Services from './pages/Services.jsx'
|
||||||
|
import ServiceDetail from './pages/ServiceDetail.jsx'
|
||||||
|
import Industries from './pages/Industries.jsx'
|
||||||
|
import IndustryDetail from './pages/IndustryDetail.jsx'
|
||||||
|
import Contact from './pages/Contact.jsx'
|
||||||
|
import Support from './pages/Support.jsx'
|
||||||
|
import PrivacyPolicy from './pages/PrivacyPolicy.jsx'
|
||||||
|
import NotFound from './pages/NotFound.jsx'
|
||||||
|
|
||||||
|
// Shared between the browser router (src/router.jsx) and the build-time
|
||||||
|
// prerenderer (src/entry-server.jsx) so both render an identical tree.
|
||||||
|
export const routes = [
|
||||||
|
{
|
||||||
|
path: '/',
|
||||||
|
element: <App />,
|
||||||
|
children: [
|
||||||
|
{ index: true, element: <Home /> },
|
||||||
|
{ path: 'about', element: <About /> },
|
||||||
|
{ path: 'services', element: <Services /> },
|
||||||
|
{ path: 'services/:slug', element: <ServiceDetail /> },
|
||||||
|
{ path: 'industries', element: <Industries /> },
|
||||||
|
{ path: 'industries/:slug', element: <IndustryDetail /> },
|
||||||
|
{ path: 'contact', element: <Contact /> },
|
||||||
|
{ path: 'support', element: <Support /> },
|
||||||
|
{ path: 'privacy-policy', element: <PrivacyPolicy /> },
|
||||||
|
{ path: '*', element: <NotFound /> },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
export default routes
|
||||||
|
|
@ -2,7 +2,17 @@ import { defineConfig } from 'vite'
|
||||||
import react from '@vitejs/plugin-react'
|
import react from '@vitejs/plugin-react'
|
||||||
import path from 'path'
|
import path from 'path'
|
||||||
|
|
||||||
export default defineConfig({
|
// Split rarely-changing vendor code out of the app chunk so repeat visits and
|
||||||
|
// post-deploy cache hits don't re-download React, the router, and the icon set.
|
||||||
|
const manualChunks = (id) => {
|
||||||
|
if (!id.includes('node_modules')) return undefined
|
||||||
|
if (id.includes('lucide-react')) return 'icons'
|
||||||
|
if (id.includes('react-router')) return 'router'
|
||||||
|
if (/node_modules\/(react|react-dom|scheduler)\//.test(id)) return 'react-vendor'
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
export default defineConfig(({ isSsrBuild }) => ({
|
||||||
plugins: [react()],
|
plugins: [react()],
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
|
|
@ -21,5 +31,7 @@ export default defineConfig({
|
||||||
build: {
|
build: {
|
||||||
outDir: 'dist',
|
outDir: 'dist',
|
||||||
sourcemap: process.env.NODE_ENV !== 'production',
|
sourcemap: process.env.NODE_ENV !== 'production',
|
||||||
|
// The SSR bundle externalises its dependencies, so chunking does not apply.
|
||||||
|
rollupOptions: isSsrBuild ? {} : { output: { manualChunks } },
|
||||||
},
|
},
|
||||||
})
|
}))
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue