diff --git a/.gitignore b/.gitignore index 24fa201..174fd64 100644 --- a/.gitignore +++ b/.gitignore @@ -15,6 +15,7 @@ node_modules/ # Build output dist/ +dist-ssr/ # Runtime/database artifacts db/*.db diff --git a/index.html b/index.html index f452469..208ed65 100644 --- a/index.html +++ b/index.html @@ -29,8 +29,13 @@ + + diff --git a/package.json b/package.json index 8b77e43..2c9c1a9 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,8 @@ "type": "module", "scripts": { "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", "start": "node server/index.js", "server": "node server/index.js", diff --git a/public/assets/cabling.webp b/public/assets/cabling.webp deleted file mode 100644 index 5243ddf..0000000 Binary files a/public/assets/cabling.webp and /dev/null differ diff --git a/public/sitemap.xml b/public/sitemap.xml deleted file mode 100644 index 38e480d..0000000 --- a/public/sitemap.xml +++ /dev/null @@ -1,88 +0,0 @@ - - - - https://queuenorth.com - weekly - 1.0 - - - https://queuenorth.com/about - monthly - 0.8 - - - https://queuenorth.com/services - monthly - 0.9 - - - https://queuenorth.com/services/unified-communications - monthly - 0.7 - - - https://queuenorth.com/services/contact-center - monthly - 0.7 - - - https://queuenorth.com/services/managed-support - monthly - 0.7 - - - https://queuenorth.com/services/consulting-training - monthly - 0.7 - - - https://queuenorth.com/services/infrastructure-cabling - monthly - 0.7 - - - https://queuenorth.com/services/wireless-access - monthly - 0.7 - - - https://queuenorth.com/services/local-networking - monthly - 0.7 - - - https://queuenorth.com/industries - monthly - 0.8 - - - https://queuenorth.com/industries/healthcare - monthly - 0.7 - - - https://queuenorth.com/industries/retail - monthly - 0.7 - - - https://queuenorth.com/industries/manufacturing - monthly - 0.7 - - - https://queuenorth.com/industries/education-finance - monthly - 0.7 - - - https://queuenorth.com/contact - monthly - 0.9 - - - https://queuenorth.com/support - monthly - 0.8 - - \ No newline at end of file diff --git a/scripts/prerender.js b/scripts/prerender.js new file mode 100644 index 0000000..3179070 --- /dev/null +++ b/scripts/prerender.js @@ -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 , +// 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`) diff --git a/server/index.js b/server/index.js index 4539cd7..6d8248c 100644 --- a/server/index.js +++ b/server/index.js @@ -914,16 +914,92 @@ app.use((req, res, next) => { }) // 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) => { // Skip API routes (already handled above) and requests for static assets if (req.path.startsWith('/api/') || req.path.includes('.')) { 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, () => { diff --git a/src/components/SEO.jsx b/src/components/SEO.jsx index d544a29..2501696 100644 --- a/src/components/SEO.jsx +++ b/src/components/SEO.jsx @@ -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 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]) : [] return ( <Helmet> <title>{title} + {noindex && } {/* Canonical URL — prevents duplicate content */} diff --git a/src/components/layout/Footer.jsx b/src/components/layout/Footer.jsx index b4703dd..4f95876 100644 --- a/src/components/layout/Footer.jsx +++ b/src/components/layout/Footer.jsx @@ -6,8 +6,6 @@ const Footer = () => { const companyInfo = { name: 'Queue North Technologies', tagline: 'Modern communications infrastructure without the vendor noise.', - addressLine1: '7901 4th St N', - addressLine2: 'St. Petersburg, FL 33702', phone: '(321) 730-8020', tollFree: '(888) 656-2850', } @@ -19,6 +17,7 @@ const Footer = () => { { name: 'About', href: '/about' }, { name: 'Contact', href: '/contact' }, { name: 'Support', href: '/support' }, + { name: 'Privacy Policy', href: '/privacy-policy' }, ] const services = [ @@ -49,20 +48,14 @@ const Footer = () => { src="/logo.png" alt="Queue North Technologies" 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" /> Queue North Technologies

{companyInfo.tagline}

- - {companyInfo.addressLine1} - {companyInfo.addressLine2} -
{companyInfo.phone} @@ -146,6 +139,8 @@ const Footer = () => { src="/assets/brand/veteran-owned-certified.webp" alt="SBA Veteran-Owned Certified badge" className="h-full w-full object-contain" + loading="lazy" + decoding="async" />

@@ -169,6 +164,13 @@ const Footer = () => {

© {currentYear} Queue North Technologies. All rights reserved.

+ + Privacy Policy +
{ src="/logo.png" alt="Queue North Technologies" className="brand-logo-on-dark h-12 md:h-16 w-auto flex-shrink-0" + loading="eager" + decoding="async" + width="200" + height="200" /> Queue North Technologies @@ -162,7 +166,12 @@ const Header = () => { {/* Logo + phone */}
- Queue North Technologies + Queue North Technologies Queue North Technologies diff --git a/src/components/layout/MobileNav.jsx b/src/components/layout/MobileNav.jsx index fc9142a..88fd7c8 100644 --- a/src/components/layout/MobileNav.jsx +++ b/src/components/layout/MobileNav.jsx @@ -63,7 +63,11 @@ const MobileNav = () => { Queue North Technologies Queue North Technologies
diff --git a/src/data/privacyPolicy.js b/src/data/privacyPolicy.js new file mode 100644 index 0000000..5052fdc --- /dev/null +++ b/src/data/privacyPolicy.js @@ -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' }, + ], + }, +] diff --git a/src/entry-server.jsx b/src/entry-server.jsx new file mode 100644 index 0000000..913a316 --- /dev/null +++ b/src/entry-server.jsx @@ -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( + + + + + , + ) + + return { html, helmet: helmetContext.helmet } +} + +export default render diff --git a/src/lib/seo.js b/src/lib/seo.js new file mode 100644 index 0000000..d673ae5 --- /dev/null +++ b/src/lib/seo.js @@ -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}`, + })), +}) diff --git a/src/main.jsx b/src/main.jsx index b987019..5641361 100644 --- a/src/main.jsx +++ b/src/main.jsx @@ -1,5 +1,5 @@ import { StrictMode } from 'react' -import { createRoot } from 'react-dom/client' +import { createRoot, hydrateRoot } from 'react-dom/client' import { RouterProvider } from 'react-router-dom' import { Toaster } from 'sonner' import { HelmetProvider } from 'react-helmet-async' @@ -19,4 +19,13 @@ const Root = () => ( ) -createRoot(document.getElementById('root')).render() \ No newline at end of file +// 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, ) +} else { + createRoot(container).render() +} \ No newline at end of file diff --git a/src/pages/About.jsx b/src/pages/About.jsx index 821b290..9c145af 100644 --- a/src/pages/About.jsx +++ b/src/pages/About.jsx @@ -1,4 +1,5 @@ import SEO from '@/components/SEO' +import { buildBreadcrumbLd } from '@/lib/seo' import { Link } from 'react-router-dom' import { ArrowRight, Award, CheckCircle2, Compass, Cpu, Handshake, Headphones, Route, Wrench } from 'lucide-react' @@ -97,9 +98,10 @@ const About = () => { return ( <> {/* Page Hero */} @@ -109,6 +111,9 @@ const About = () => { src="/assets/about-image.webp" alt="Compass on a dark navigation map" className="h-full w-full object-cover object-[66%_top] md:object-[62%_top]" + loading="eager" + fetchPriority="high" + decoding="async" />
@@ -150,6 +155,8 @@ const About = () => { src={point.logo} alt={point.logoAlt} className={`${point.logoClassName} object-contain`} + loading="lazy" + decoding="async" /> ) : (
diff --git a/src/pages/Contact.jsx b/src/pages/Contact.jsx index 80f9171..461a615 100644 --- a/src/pages/Contact.jsx +++ b/src/pages/Contact.jsx @@ -1,4 +1,5 @@ import SEO from '@/components/SEO' +import { buildBreadcrumbLd } from '@/lib/seo' import { useCallback, useEffect, useState } from 'react' import { toast } from 'sonner' import { Button } from '@/components/ui/Button' @@ -151,23 +152,16 @@ const Contact = () => { ), }, { - label: 'Office', + label: 'Service Area', icon: ( - - + ), content: ( - - 7901 4th St N - St. Petersburg, FL 33702 - +

+ Remote services nationwide. On-site services available based on project scope and location. +

), }, { @@ -193,8 +187,9 @@ const Contact = () => { <> {/* Hero */} @@ -204,6 +199,9 @@ const Contact = () => { src="/assets/hero-tech.webp" alt="Queue North communications infrastructure consultation" className="h-full w-full object-cover object-center" + loading="eager" + fetchPriority="high" + decoding="async" />
@@ -369,7 +367,7 @@ const Contact = () => { required autoComplete="postal-code" inputMode="numeric" - placeholder="33702" + placeholder="12345" className={debouncedErrors['Zip Code'] ? 'border-red-500 focus-visible:ring-red-500' : ''} /> {debouncedErrors['Zip Code'] &&

{debouncedErrors['Zip Code']}

} diff --git a/src/pages/Home.jsx b/src/pages/Home.jsx index fef6ffa..1e07fb4 100644 --- a/src/pages/Home.jsx +++ b/src/pages/Home.jsx @@ -1,4 +1,5 @@ import SEO from '@/components/SEO' +import { websiteLd } from '@/lib/seo' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/Card' import { services } from '@/data/services' import { industries } from '@/data/industries' @@ -48,23 +49,24 @@ const industryAccentStyles = [ ] const Home = () => { + // Single Organization entity — no LocalBusiness/address markup, since Queue North + // does not publish a physical location. const organizationLd = { '@context': 'https://schema.org', '@type': 'Organization', + '@id': 'https://queuenorth.com/#organization', name: 'Queue North Technologies', url: 'https://queuenorth.com', + image: 'https://queuenorth.com/assets/og-image.png', + telephone: '+1-321-730-8020', logo: { '@type': 'ImageObject', url: 'https://queuenorth.com/logo.png', }, description: 'Veteran-owned 8x8 Certified Partner and Cisco Certified Partner providing business phone systems, UCaaS, contact center, IT support, and networking solutions.', - address: { - '@type': 'PostalAddress', - streetAddress: '7901 4th St N', - addressLocality: 'St. Petersburg', - addressRegion: 'FL', - postalCode: '33702', - addressCountry: 'US', + areaServed: { + '@type': 'Country', + name: 'United States', }, contactPoint: [ { @@ -84,50 +86,17 @@ const Home = () => { sameAs: [ 'https://www.linkedin.com/company/queue-north-technologies-llc', 'https://www.facebook.com/QueueNorth', - ], - } - - 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', + 'https://www.instagram.com/queue_north/', ], } return ( <> {/* Hero Section */}
@@ -136,6 +105,9 @@ const Home = () => { src="/assets/hero-tech.webp" alt="Queue North technician working inside a communications rack" className="h-full w-full object-cover object-center" + loading="eager" + fetchPriority="high" + decoding="async" />
@@ -175,6 +147,8 @@ const Home = () => { src="/assets/brand/8x8-logo-dark-gray.png" alt="8x8 Certified Partner logo" className="h-full w-full object-contain" + loading="lazy" + decoding="async" /> 8x8 Certified Partner @@ -185,6 +159,8 @@ const Home = () => { src="/assets/brand/cisco-partner-logo-midnight.svg" alt="Cisco Partner certification logo" className="h-full w-full object-contain scale-[1.5]" + loading="lazy" + decoding="async" /> Cisco Certified Partner @@ -195,6 +171,8 @@ const Home = () => { src="/assets/brand/veteran-owned-certified-mark.webp" alt="SBA logo for Veteran-Owned Certified" className="h-full w-full object-contain" + loading="lazy" + decoding="async" /> Veteran-Owned Certified diff --git a/src/pages/Industries.jsx b/src/pages/Industries.jsx index d635afb..165224e 100644 --- a/src/pages/Industries.jsx +++ b/src/pages/Industries.jsx @@ -1,4 +1,5 @@ import SEO from '@/components/SEO' +import { buildBreadcrumbLd } from '@/lib/seo' import { industries } from '@/data/industries' import { ArrowRight, Building2, CheckCircle2, HeartPulse, ShoppingCart, Factory, Landmark } from 'lucide-react' import { Link } from 'react-router-dom' @@ -21,9 +22,10 @@ const Industries = () => { return ( <> {/* Hero */} @@ -33,6 +35,9 @@ const Industries = () => { src="/assets/local-networking.webp" alt="Network infrastructure used by businesses across industries" className="h-full w-full object-cover object-center" + loading="eager" + fetchPriority="high" + decoding="async" />
diff --git a/src/pages/IndustryDetail.jsx b/src/pages/IndustryDetail.jsx index 3edd2fa..74e922b 100644 --- a/src/pages/IndustryDetail.jsx +++ b/src/pages/IndustryDetail.jsx @@ -1,4 +1,5 @@ import SEO from '@/components/SEO' +import { buildBreadcrumbLd, clampDescription } from '@/lib/seo' import { useParams } from 'react-router-dom' import { industries } from '@/data/industries' import { Link } from 'react-router-dom' @@ -13,9 +14,10 @@ const IndustryDetail = () => { return (
@@ -30,9 +32,16 @@ const IndustryDetail = () => { ) } - const industryTitle = `${industry.name} | Queue North Technologies` - const industryDesc = industry.shortDesc || `Learn about Queue North Technologies solutions for the ${industry.name} industry.` + const industryTitle = `${industry.name} Communications & IT | Queue North` + 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 industryBreadcrumbLd = buildBreadcrumbLd([ + { name: 'Industries', path: '/industries' }, + { name: industry.name, path: `/industries/${industry.id}` }, + ]) return ( <> @@ -40,6 +49,7 @@ const IndustryDetail = () => { title={industryTitle} description={industryDesc} url={industryUrl} + jsonLd={industryBreadcrumbLd} /> {/* Page Hero */}
@@ -48,6 +58,9 @@ const IndustryDetail = () => { src="/assets/modern-call-center.webp" alt="Business communications team supporting industry operations" className="h-full w-full object-cover object-center" + loading="eager" + fetchPriority="high" + decoding="async" />
diff --git a/src/pages/NotFound.jsx b/src/pages/NotFound.jsx index b53063c..4bbd03f 100644 --- a/src/pages/NotFound.jsx +++ b/src/pages/NotFound.jsx @@ -47,6 +47,9 @@ export default function NotFound() { src="/assets/about-image.webp" alt="" className="h-full w-full object-cover object-[66%_top] md:object-[62%_top]" + loading="eager" + fetchPriority="high" + decoding="async" />
diff --git a/src/pages/PrivacyPolicy.jsx b/src/pages/PrivacyPolicy.jsx new file mode 100644 index 0000000..547da67 --- /dev/null +++ b/src/pages/PrivacyPolicy.jsx @@ -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 = '' }) => ( + + {PRIVACY_EMAIL} + +) + +// 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

{text}

+ } + + const [before, after] = text.split(PRIVACY_EMAIL) + return ( +

+ {before} + + {after} +

+ ) +} + +const Block = ({ block }) => { + switch (block.type) { + case 'h3': + return

{block.text}

+ + case 'ul': + return ( +
    + {block.items.map((item) => ( +
  • +
  • + ))} +
+ ) + + case 'callout': + return ( +

+ {block.text} +

+ ) + + case 'email': + return ( +

+ +

+ ) + + case 'contactBlock': + return ( +
+

Queue North Technologies

+

+ Email: +

+

+ Website:{' '} + + https://queuenorth.com + +

+
+ ) + + default: + return + } +} + +const PrivacyPolicy = () => { + const numberedSections = sections.filter((section) => section.number) + + return ( + <> + + + {/* Page Hero */} +
+
+
+
+

+ Queue North Technologies Privacy Policy +

+
+
+
Effective Date:
+
{EFFECTIVE_DATE}
+
+
+
Last Updated:
+
{LAST_UPDATED}
+
+
+
+
+ + {/* Policy Body */} +
+
+ {/* Contents */} + + + {sections.map((section) => ( +
+

+ {section.number ? ( + <> + {section.number}. {section.title} + + ) : ( + section.title + )} +

+ {section.blocks.map((block, index) => ( + + ))} +
+ ))} +
+
+ + ) +} + +export default PrivacyPolicy diff --git a/src/pages/ServiceDetail.jsx b/src/pages/ServiceDetail.jsx index 0e6b2ed..20a595c 100644 --- a/src/pages/ServiceDetail.jsx +++ b/src/pages/ServiceDetail.jsx @@ -1,4 +1,5 @@ import SEO from '@/components/SEO' +import { buildBreadcrumbLd, clampDescription } from '@/lib/seo' import { useParams } from 'react-router-dom' import { services } from '@/data/services' import { Link } from 'react-router-dom' @@ -19,9 +20,10 @@ const ServiceDetail = () => { return (
@@ -36,9 +38,16 @@ const ServiceDetail = () => { ) } - const serviceTitle = `${service.name} | Queue North Technologies` - const serviceDesc = service.shortDesc || `Learn about ${service.name} from Queue North Technologies.` + const serviceTitle = `${service.name} | Queue North` + 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 serviceBreadcrumbLd = buildBreadcrumbLd([ + { name: 'Services', path: '/services' }, + { name: service.name, path: `/services/${service.id}` }, + ]) const serviceDetailLd = { '@context': 'https://schema.org', '@type': 'Service', @@ -61,7 +70,7 @@ const ServiceDetail = () => { title={serviceTitle} description={serviceDesc} url={serviceUrl} - jsonLd={serviceDetailLd} + jsonLd={[serviceDetailLd, serviceBreadcrumbLd]} /> {/* Page Hero */}
@@ -70,6 +79,9 @@ const ServiceDetail = () => { src={service.image || '/assets/hero-tech.webp'} alt={serviceImageAlt[service.id] || `${service.name} service visual`} className="h-full w-full object-cover object-center" + loading="eager" + fetchPriority="high" + decoding="async" />
diff --git a/src/pages/Services.jsx b/src/pages/Services.jsx index c1a12f3..5458086 100644 --- a/src/pages/Services.jsx +++ b/src/pages/Services.jsx @@ -1,4 +1,5 @@ import SEO from '@/components/SEO' +import { buildBreadcrumbLd } from '@/lib/seo' import { services } from '@/data/services' import { ArrowRight, MessageCircle, Users, LifeBuoy, GraduationCap, Link as LinkIcon, Wifi, Network, Layers, CheckCircle2, ShieldCheck, PhoneCall } from 'lucide-react' import { Link } from 'react-router-dom' @@ -65,10 +66,10 @@ const Services = () => { return ( <> {/* Hero */} @@ -78,6 +79,9 @@ const Services = () => { src="/assets/hero-tech.webp" alt="Queue North technician working inside a communications rack" className="h-full w-full object-cover object-center" + loading="eager" + fetchPriority="high" + decoding="async" />
diff --git a/src/pages/Support.jsx b/src/pages/Support.jsx index 66ed13d..509aac0 100644 --- a/src/pages/Support.jsx +++ b/src/pages/Support.jsx @@ -1,4 +1,5 @@ import SEO from '@/components/SEO' +import { buildBreadcrumbLd } from '@/lib/seo' import { AlertCircle, ArrowRight, CheckCircle2, Clock3, ExternalLink, LifeBuoy, ShieldCheck, TicketCheck, Wrench } from 'lucide-react' const portalLinks = [ @@ -34,6 +35,7 @@ const Support = () => { 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." url="https://queuenorth.com/support" + jsonLd={buildBreadcrumbLd([{ name: 'Support', path: '/support' }])} /> {/* Page Hero */}
@@ -42,6 +44,9 @@ const Support = () => { src="/assets/modern-call-center.webp" alt="Support team managing communications requests" className="h-full w-full object-cover object-center" + loading="eager" + fetchPriority="high" + decoding="async" />
diff --git a/src/router.jsx b/src/router.jsx index fac6072..9753d23 100644 --- a/src/router.jsx +++ b/src/router.jsx @@ -1,33 +1,6 @@ import { createBrowserRouter } from 'react-router-dom' -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 NotFound from './pages/NotFound.jsx' +import { routes } from './routes.jsx' -const router = createBrowserRouter([ - { - path: '/', - element: ( - - ), - children: [ - { index: true, element: }, - { path: 'about', element: }, - { path: 'services', element: }, - { path: 'services/:slug', element: }, - { path: 'industries', element: }, - { path: 'industries/:slug', element: }, - { path: 'contact', element: }, - { path: 'support', element: }, - { path: '*', element: }, - ], - }, -]) +const router = createBrowserRouter(routes) export default router diff --git a/src/routes.jsx b/src/routes.jsx new file mode 100644 index 0000000..b8d5163 --- /dev/null +++ b/src/routes.jsx @@ -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: , + children: [ + { index: true, element: }, + { path: 'about', element: }, + { path: 'services', element: }, + { path: 'services/:slug', element: }, + { path: 'industries', element: }, + { path: 'industries/:slug', element: }, + { path: 'contact', element: }, + { path: 'support', element: }, + { path: 'privacy-policy', element: }, + { path: '*', element: }, + ], + }, +] + +export default routes diff --git a/vite.config.js b/vite.config.js index 73fc649..4e66a1a 100644 --- a/vite.config.js +++ b/vite.config.js @@ -2,7 +2,17 @@ import { defineConfig } from 'vite' import react from '@vitejs/plugin-react' 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()], resolve: { alias: { @@ -21,5 +31,7 @@ export default defineConfig({ build: { outDir: 'dist', sourcemap: process.env.NODE_ENV !== 'production', + // The SSR bundle externalises its dependencies, so chunking does not apply. + rollupOptions: isSsrBuild ? {} : { output: { manualChunks } }, }, -}) +}))